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 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 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}" message = ( f"Payslip Generated\n\n" f"Record ID: #{payroll_record.id}\n" f"Worker: {worker.name}\n" f"Date: {payroll_record.date}\n" f"Total Paid: R {payroll_record.amount}\n\n" f"Breakdown:\n" f"Base Pay ({log_count} days): R {logs_amount}\n" f"Adjustments: R {adj_amount}\n\n" f"This is an automated notification." ) recipient_list = ['foxfitt-ed9wc+expense@to.sparkreceipt.com'] try: send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list) 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 HTML content html_message = render_to_string('core/email/receipt_email.html', { 'receipt': receipt, 'items': line_items, }) plain_message = strip_tags(html_message) try: send_mail( subject, plain_message, settings.DEFAULT_FROM_EMAIL, recipient_list, html_message=html_message ) 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 })