import os import platform import json import csv import calendar import datetime from django.shortcuts import render, redirect, get_object_or_404 from django.utils import timezone from django.contrib.auth.decorators import login_required, user_passes_test from django.db.models import Sum, Q, Prefetch from django.core.mail import send_mail, EmailMultiAlternatives from django.conf import settings from django.contrib import messages from django.http import JsonResponse, HttpResponse from django.template.loader import render_to_string from django.utils.html import strip_tags from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem from .forms import WorkLogForm, ExpenseReceiptForm, ExpenseLineItemFormSet from datetime import timedelta from decimal import Decimal from core.utils import render_to_pdf def is_staff_or_supervisor(user): """Check if user is staff or manages at least one team/project.""" if user.is_staff or user.is_superuser: return True return user.managed_teams.exists() or user.assigned_projects.exists() @login_required def home(request): """Render the landing screen with dashboard stats.""" # If not staff or supervisor, redirect to log attendance if not is_staff_or_supervisor(request.user): return redirect('log_attendance') workers_count = Worker.objects.count() projects_count = Project.objects.count() teams_count = Team.objects.count() recent_logs = WorkLog.objects.order_by('-date')[:5] # Analytics # 1. Outstanding Payments (Approximate, from logs only) outstanding_total = 0 active_workers = Worker.objects.filter(is_active=True) for worker in active_workers: # Find unpaid logs for this worker unpaid_logs_count = worker.work_logs.exclude(paid_in__worker=worker).count() outstanding_total += unpaid_logs_count * worker.day_rate # 2. Project Costs (Active Projects) # Calculate sum of day_rates for all workers in all logs for each project project_costs = [] active_projects = Project.objects.filter(is_active=True) # Simple iteration for calculation (safer than complex annotations given properties) for project in active_projects: cost = 0 logs = project.logs.all() for log in logs: # We need to sum the day_rate of all workers in this log # Optimization: prefetch workers if slow, but for now just iterate for worker in log.workers.all(): cost += worker.day_rate if cost > 0: project_costs.append({'name': project.name, 'cost': cost}) # 3. Previous 2 months payments two_months_ago = timezone.now().date() - timedelta(days=60) recent_payments_total = PayrollRecord.objects.filter(date__gte=two_months_ago).aggregate(total=Sum('amount'))['total'] or 0 context = { "workers_count": workers_count, "projects_count": projects_count, "teams_count": teams_count, "recent_logs": recent_logs, "current_time": timezone.now(), "outstanding_total": outstanding_total, "project_costs": project_costs, "recent_payments_total": recent_payments_total, } return render(request, "core/index.html", context) @login_required def log_attendance(request): # Build team workers map for frontend JS (needed for both GET and POST if re-rendering) teams_qs = Team.objects.filter(is_active=True) if request.user.is_authenticated and not request.user.is_superuser: teams_qs = teams_qs.filter(supervisor=request.user) team_workers_map = {} for team in teams_qs: # Get active workers for the team active_workers = team.workers.filter(is_active=True).values_list('id', flat=True) team_workers_map[team.id] = list(active_workers) if request.method == 'POST': form = WorkLogForm(request.POST, user=request.user) if form.is_valid(): start_date = form.cleaned_data['date'] end_date = form.cleaned_data.get('end_date') include_sat = form.cleaned_data.get('include_saturday') include_sun = form.cleaned_data.get('include_sunday') selected_workers = form.cleaned_data['workers'] project = form.cleaned_data['project'] notes = form.cleaned_data['notes'] conflict_action = request.POST.get('conflict_action') # Generate Target Dates target_dates = [] if end_date and end_date >= start_date: curr = start_date while curr <= end_date: # 5 = Saturday, 6 = Sunday if (curr.weekday() == 5 and not include_sat) or (curr.weekday() == 6 and not include_sun): curr += timedelta(days=1) continue target_dates.append(curr) curr += timedelta(days=1) else: target_dates = [start_date] if not target_dates: messages.warning(request, "No valid dates selected (check weekends).") return render(request, 'core/log_attendance.html', { 'form': form, 'team_workers_json': json.dumps(team_workers_map) }) # Check Conflicts - Scan all target dates if not conflict_action: conflicts = [] for d in target_dates: # Find workers who already have a log on this date existing_logs = WorkLog.objects.filter(date=d, workers__in=selected_workers).distinct() for log in existing_logs: # Which of the selected workers are in this log? for w in log.workers.all(): if w in selected_workers: # Avoid adding duplicates if multiple logs exist for same worker/day (rare but possible) conflict_entry = {'name': f"{w.name} ({d.strftime('%Y-%m-%d')})"} if conflict_entry not in conflicts: conflicts.append(conflict_entry) if conflicts: context = { 'form': form, 'team_workers_json': json.dumps(team_workers_map), 'conflicting_workers': conflicts, 'is_conflict': True, } return render(request, 'core/log_attendance.html', context) # Execution Phase created_count = 0 skipped_count = 0 overwritten_count = 0 for d in target_dates: # Find conflicts for this specific day day_conflicts = Worker.objects.filter( work_logs__date=d, id__in=selected_workers.values_list('id', flat=True) ).distinct() workers_to_save = list(selected_workers) if day_conflicts.exists(): if conflict_action == 'skip': conflicting_ids = day_conflicts.values_list('id', flat=True) workers_to_save = [w for w in selected_workers if w.id not in conflicting_ids] skipped_count += day_conflicts.count() elif conflict_action == 'overwrite': # Remove conflicting workers from their OLD logs for worker in day_conflicts: old_logs = WorkLog.objects.filter(date=d, workers=worker) for log in old_logs: log.workers.remove(worker) if log.workers.count() == 0: log.delete() overwritten_count += day_conflicts.count() # workers_to_save remains full list if workers_to_save: # Create Log log = WorkLog.objects.create( date=d, project=project, notes=notes, supervisor=request.user if request.user.is_authenticated else None ) log.workers.set(workers_to_save) created_count += len(workers_to_save) msg = f"Logged {created_count} entries." if skipped_count: msg += f" Skipped {skipped_count} duplicates." if overwritten_count: msg += f" Overwrote {overwritten_count} previous entries." messages.success(request, msg) # Redirect to home, which will then redirect back to log_attendance if restricted return redirect('home') else: form = WorkLogForm(user=request.user if request.user.is_authenticated else None) context = { 'form': form, 'team_workers_json': json.dumps(team_workers_map) } return render(request, 'core/log_attendance.html', context) @login_required def work_log_list(request): """View work log history with advanced filtering.""" if not is_staff_or_supervisor(request.user): return redirect('log_attendance') worker_id = request.GET.get('worker') team_id = request.GET.get('team') project_id = request.GET.get('project') payment_status = request.GET.get('payment_status') # 'paid', 'unpaid', 'all' view_mode = request.GET.get('view', 'list') logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id') target_worker = None if worker_id: logs = logs.filter(workers__id=worker_id) # Fetch the worker to get the day rate reliably target_worker = Worker.objects.filter(id=worker_id).first() if team_id: # Find workers in this team and filter logs containing them team_workers = Worker.objects.filter(teams__id=team_id) logs = logs.filter(workers__in=team_workers).distinct() if project_id: logs = logs.filter(project_id=project_id) if payment_status == 'paid': # Logs that are linked to at least one PayrollRecord logs = logs.filter(paid_in__isnull=False).distinct() elif payment_status == 'unpaid': # This is tricky because a log can have multiple workers, some paid some not. # But usually a WorkLog is marked paid when its workers are paid. # If we filtered by worker, we can check if THAT worker is paid in that log. if worker_id: worker = get_object_or_404(Worker, pk=worker_id) logs = logs.exclude(paid_in__worker=worker) else: logs = logs.filter(paid_in__isnull=True) # Calculate amounts for display # Convert to list to attach attributes final_logs = [] total_amount = 0 # If Calendar View: Filter logs by Month BEFORE iterating to prevent fetching ALL history if view_mode == 'calendar': today = timezone.now().date() try: curr_year = int(request.GET.get('year', today.year)) curr_month = int(request.GET.get('month', today.month)) except ValueError: curr_year = today.year curr_month = today.month # Bounds safety if curr_month < 1: curr_month = 1; if curr_month > 12: curr_month = 12; # Get range _, num_days = calendar.monthrange(curr_year, curr_month) start_date = datetime.date(curr_year, curr_month, 1) end_date = datetime.date(curr_year, curr_month, num_days) logs = logs.filter(date__range=(start_date, end_date)) for log in logs: if target_worker: log.display_amount = target_worker.day_rate else: # Sum of all workers in this log log.display_amount = sum(w.day_rate for w in log.workers.all()) final_logs.append(log) total_amount += log.display_amount # Context for filters context = { 'total_amount': total_amount, 'workers': Worker.objects.filter(is_active=True).order_by('name'), 'teams': Team.objects.filter(is_active=True).order_by('name'), 'projects': Project.objects.filter(is_active=True).order_by('name'), 'selected_worker': int(worker_id) if worker_id else None, 'selected_team': int(team_id) if team_id else None, 'selected_project': int(project_id) if project_id else None, 'selected_payment_status': payment_status, 'target_worker': target_worker, 'view_mode': view_mode, } if view_mode == 'calendar': # Group by date for easy lookup in template logs_map = {} for log in final_logs: if log.date not in logs_map: logs_map[log.date] = [] logs_map[log.date].append(log) cal = calendar.Calendar(firstweekday=0) # Monday is 0 month_dates = cal.monthdatescalendar(curr_year, curr_month) # Prepare structured data for template calendar_weeks = [] for week in month_dates: week_data = [] for d in week: week_data.append({ 'date': d, 'day': d.day, 'is_current_month': d.month == curr_month, 'logs': logs_map.get(d, []) }) calendar_weeks.append(week_data) # Nav Links prev_month_date = start_date - datetime.timedelta(days=1) next_month_date = end_date + datetime.timedelta(days=1) context.update({ 'calendar_weeks': calendar_weeks, 'curr_month': curr_month, 'curr_year': curr_year, 'month_name': calendar.month_name[curr_month], 'prev_month': prev_month_date.month, 'prev_year': prev_month_date.year, 'next_month': next_month_date.month, 'next_year': next_month_date.year, }) else: context['logs'] = final_logs return render(request, 'core/work_log_list.html', context) @login_required def export_work_log_csv(request): """Export filtered work logs to CSV.""" if not is_staff_or_supervisor(request.user): return HttpResponse("Unauthorized", status=401) worker_id = request.GET.get('worker') team_id = request.GET.get('team') project_id = request.GET.get('project') payment_status = request.GET.get('payment_status') logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id') target_worker = None if worker_id: logs = logs.filter(workers__id=worker_id) target_worker = Worker.objects.filter(id=worker_id).first() if team_id: team_workers = Worker.objects.filter(teams__id=team_id) logs = logs.filter(workers__in=team_workers).distinct() if project_id: logs = logs.filter(project_id=project_id) if payment_status == 'paid': logs = logs.filter(paid_in__isnull=False).distinct() elif payment_status == 'unpaid': if worker_id: worker = get_object_or_404(Worker, pk=worker_id) logs = logs.exclude(paid_in__worker=worker) else: logs = logs.filter(paid_in__isnull=True) response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="work_logs.csv"' writer = csv.writer(response) writer.writerow(['Date', 'Project', 'Workers', 'Amount', 'Payment Status', 'Supervisor']) for log in logs: # Amount Logic if target_worker: display_amount = target_worker.day_rate workers_str = target_worker.name else: display_amount = sum(w.day_rate for w in log.workers.all()) workers_str = ", ".join([w.name for w in log.workers.all()]) # Payment Status Logic is_paid = log.paid_in.exists() status_str = "Paid" if is_paid else "Pending" writer.writerow([ log.date, log.project.name, workers_str, f"{display_amount:.2f}", status_str, log.supervisor.username if log.supervisor else "System" ]) return response @login_required def manage_resources(request): """View to manage active status of resources.""" if not request.user.is_staff and not request.user.is_superuser: return redirect('log_attendance') # Prefetch teams for workers to avoid N+1 in template workers = Worker.objects.all().prefetch_related('teams').order_by('name') projects = Project.objects.all().order_by('name') teams = Team.objects.all().prefetch_related('workers').order_by('name') context = { 'workers': workers, 'projects': projects, 'teams': teams, } return render(request, 'core/manage_resources.html', context) @login_required def toggle_resource_status(request, model_type, pk): """Toggle the is_active status of a resource.""" if not request.user.is_staff and not request.user.is_superuser: return redirect('log_attendance') if request.method == 'POST': model_map = { 'worker': Worker, 'project': Project, 'team': Team, } model_class = model_map.get(model_type) if model_class: obj = get_object_or_404(model_class, pk=pk) obj.is_active = not obj.is_active obj.save() if request.headers.get('x-requested-with') == 'XMLHttpRequest': return JsonResponse({ 'success': True, 'is_active': obj.is_active, 'message': f"{obj.name} is now {'Active' if obj.is_active else 'Inactive'}." }) return redirect('manage_resources') @login_required def payroll_dashboard(request): """Dashboard for payroll management with filtering.""" if not request.user.is_staff and not request.user.is_superuser: return redirect('log_attendance') status_filter = request.GET.get('status', 'pending') # pending, paid, all # Common Analytics outstanding_total = 0 active_workers = Worker.objects.filter(is_active=True).order_by('name').prefetch_related('adjustments') workers_data = [] # For pending payments for worker in active_workers: # Unpaid Work Logs unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker) log_count = unpaid_logs.count() log_amount = log_count * worker.day_rate # Pending Adjustments (unlinked to any payroll record) pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True) adj_total = Decimal('0.00') for adj in pending_adjustments: if adj.type in ['BONUS', 'OVERTIME', 'LOAN']: adj_total += adj.amount elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: adj_total -= adj.amount total_payable = log_amount + adj_total # Only show if there is something to pay or negative (e.g. loan repayment greater than work) # Note: If total_payable is negative, it implies they owe money. if log_count > 0 or pending_adjustments.exists(): outstanding_total += max(total_payable, Decimal('0.00')) # Only count positive payable for grand total if status_filter in ['pending', 'all']: workers_data.append({ 'worker': worker, 'unpaid_count': log_count, 'unpaid_amount': log_amount, 'adj_amount': adj_total, 'total_payable': total_payable, 'adjustments': pending_adjustments, 'logs': unpaid_logs }) # Paid History paid_records = [] if status_filter in ['paid', 'all']: paid_records = PayrollRecord.objects.select_related('worker').order_by('-date', '-id') # Analytics: Project Costs (Active Projects) project_costs = [] active_projects = Project.objects.filter(is_active=True) for project in active_projects: cost = 0 logs = project.logs.all() for log in logs: for worker in log.workers.all(): cost += worker.day_rate if cost > 0: project_costs.append({'name': project.name, 'cost': cost}) # Analytics: Previous 2 months payments two_months_ago = timezone.now().date() - timedelta(days=60) recent_payments_total = PayrollRecord.objects.filter(date__gte=two_months_ago).aggregate(total=Sum('amount'))['total'] or 0 # Active Loans for dropdowns/modals all_workers = Worker.objects.filter(is_active=True).order_by('name') context = { 'workers_data': workers_data, 'paid_records': paid_records, 'outstanding_total': outstanding_total, 'project_costs': project_costs, 'recent_payments_total': recent_payments_total, 'active_tab': status_filter, 'all_workers': all_workers, 'adjustment_types': PayrollAdjustment.ADJUSTMENT_TYPES, } return render(request, 'core/payroll_dashboard.html', context) @login_required def process_payment(request, worker_id): """Process payment for a worker, mark logs as paid, link adjustments, and email receipt.""" if not request.user.is_staff and not request.user.is_superuser: return redirect('log_attendance') worker = get_object_or_404(Worker, pk=worker_id) if request.method == 'POST': # Find unpaid logs unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker) log_count = unpaid_logs.count() logs_amount = log_count * worker.day_rate # Find pending adjustments pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True) adj_amount = Decimal('0.00') for adj in pending_adjustments: if adj.type in ['BONUS', 'OVERTIME', 'LOAN']: adj_amount += adj.amount elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: adj_amount -= adj.amount total_amount = logs_amount + adj_amount if log_count > 0 or pending_adjustments.exists(): # Create Payroll Record payroll_record = PayrollRecord.objects.create( worker=worker, amount=total_amount, date=timezone.now().date() ) # Link logs payroll_record.work_logs.set(unpaid_logs) # Link Adjustments and Handle Loans for adj in pending_adjustments: adj.payroll_record = payroll_record adj.save() # Update Loan Balance if it's a repayment if adj.type == 'LOAN_REPAYMENT' and adj.loan: adj.loan.balance -= adj.amount if adj.loan.balance <= 0: adj.loan.balance = 0 adj.loan.is_active = False adj.loan.save() payroll_record.save() # Email Notification subject = f"Payslip for {worker.name} - {payroll_record.date}" # Prepare Context context = { 'record': payroll_record, 'logs_count': log_count, 'logs_amount': logs_amount, 'adjustments': payroll_record.adjustments.all(), } # 1. Render HTML Body html_message = render_to_string('core/email/payslip_email.html', context) plain_message = strip_tags(html_message) # 2. Render PDF Attachment pdf_content = render_to_pdf('core/pdf/payslip_pdf.html', context) recipient_list = ['foxfitt-ed9wc+expense@to.sparkreceipt.com'] try: # Construct Email with Attachment email = EmailMultiAlternatives( subject, plain_message, settings.DEFAULT_FROM_EMAIL, recipient_list, ) email.attach_alternative(html_message, "text/html") if pdf_content: email.attach(f"Payslip_{worker.id}_{payroll_record.date}.pdf", pdf_content, 'application/pdf') email.send() messages.success(request, f"Payment processed for {worker.name}. Net Pay: R {payroll_record.amount}") except Exception as e: messages.warning(request, f"Payment processed, but email delivery failed: {str(e)}") return redirect('payroll_dashboard') return redirect('payroll_dashboard') @login_required def payslip_detail(request, pk): """Show details of a payslip (Payment Record).""" if not request.user.is_staff and not request.user.is_superuser: return redirect('log_attendance') record = get_object_or_404(PayrollRecord, pk=pk) # Get the logs included in this payment logs = record.work_logs.all().order_by('date') adjustments = record.adjustments.all().order_by('type') # Calculate base pay from logs (re-verify logic) # The record.amount is the final NET. # We can reconstruct the display. base_pay = sum(w.day_rate for l in logs for w in l.workers.all() if w == record.worker) adjustments_net = record.amount - base_pay context = { 'record': record, 'logs': logs, 'adjustments': adjustments, 'base_pay': base_pay, 'adjustments_net': adjustments_net, } return render(request, 'core/payslip.html', context) @login_required def loan_list(request): """List outstanding and historical loans.""" if not request.user.is_staff and not request.user.is_superuser: return redirect('log_attendance') filter_status = request.GET.get('status', 'active') # active, history if filter_status == 'history': loans = Loan.objects.filter(is_active=False).order_by('-date') else: loans = Loan.objects.filter(is_active=True).order_by('-date') context = { 'loans': loans, 'filter_status': filter_status, 'workers': Worker.objects.filter(is_active=True).order_by('name'), # For modal } return render(request, 'core/loan_list.html', context) @login_required def add_loan(request): """Create a new loan.""" if not request.user.is_staff and not request.user.is_superuser: return redirect('log_attendance') if request.method == 'POST': worker_id = request.POST.get('worker') amount = request.POST.get('amount') reason = request.POST.get('reason') date = request.POST.get('date') or timezone.now().date() if worker_id and amount: worker = get_object_or_404(Worker, pk=worker_id) Loan.objects.create( worker=worker, amount=amount, date=date, reason=reason ) messages.success(request, f"Loan of R{amount} recorded for {worker.name}.") return redirect('loan_list') @login_required def add_adjustment(request): """Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment).""" if not request.user.is_staff and not request.user.is_superuser: return redirect('log_attendance') if request.method == 'POST': worker_id = request.POST.get('worker') adj_type = request.POST.get('type') amount = request.POST.get('amount') description = request.POST.get('description') date = request.POST.get('date') or timezone.now().date() loan_id = request.POST.get('loan_id') # Optional, for repayments if worker_id and amount and adj_type: worker = get_object_or_404(Worker, pk=worker_id) # Validation for repayment OR Creation for New Loan loan = None if adj_type == 'LOAN_REPAYMENT': if loan_id: loan = get_object_or_404(Loan, pk=loan_id) else: # Try to find an active loan loan = worker.loans.filter(is_active=True).first() if not loan: messages.warning(request, f"Cannot add repayment: {worker.name} has no active loans.") return redirect('payroll_dashboard') elif adj_type == 'LOAN': # Create the Loan object tracking the debt loan = Loan.objects.create( worker=worker, amount=amount, date=date, reason=description ) PayrollAdjustment.objects.create( worker=worker, type=adj_type, amount=amount, description=description, date=date, loan=loan ) messages.success(request, f"{adj_type} of R{amount} added for {worker.name}.") return redirect('payroll_dashboard') @login_required def create_receipt(request): """Create a new expense receipt and email it.""" if not is_staff_or_supervisor(request.user): return redirect('log_attendance') if request.method == 'POST': form = ExpenseReceiptForm(request.POST) items = ExpenseLineItemFormSet(request.POST) if form.is_valid() and items.is_valid(): receipt = form.save(commit=False) receipt.user = request.user receipt.save() items.instance = receipt line_items = items.save() # Backend Calculation for Consistency sum_amount = sum(item.amount for item in line_items) vat_type = receipt.vat_type if vat_type == 'INCLUDED': receipt.total_amount = sum_amount receipt.subtotal = sum_amount / Decimal('1.15') receipt.vat_amount = receipt.total_amount - receipt.subtotal elif vat_type == 'EXCLUDED': receipt.subtotal = sum_amount receipt.vat_amount = sum_amount * Decimal('0.15') receipt.total_amount = receipt.subtotal + receipt.vat_amount else: # NONE receipt.subtotal = sum_amount receipt.vat_amount = Decimal('0.00') receipt.total_amount = sum_amount receipt.save() # Email Generation subject = f"Receipt from {receipt.vendor} - {receipt.date}" recipient_list = ['foxfitt-ed9wc+expense@to.sparkreceipt.com'] # Prepare Context context = { 'receipt': receipt, 'items': line_items, } # 1. Render HTML Body html_message = render_to_string('core/email/receipt_email.html', context) plain_message = strip_tags(html_message) # 2. Render PDF Attachment pdf_content = render_to_pdf('core/pdf/receipt_pdf.html', context) try: # Construct Email with Attachment email = EmailMultiAlternatives( subject, plain_message, settings.DEFAULT_FROM_EMAIL, recipient_list, ) email.attach_alternative(html_message, "text/html") if pdf_content: email.attach(f"Receipt_{receipt.id}.pdf", pdf_content, 'application/pdf') email.send() messages.success(request, "Receipt created and sent to SparkReceipt.") return redirect('create_receipt') except Exception as e: messages.warning(request, f"Receipt saved, but email failed: {e}") else: form = ExpenseReceiptForm(initial={'date': timezone.now().date()}) items = ExpenseLineItemFormSet() return render(request, 'core/create_receipt.html', { 'form': form, 'items': items })