# === VIEWS === # All the page logic for the LabourPay app. # Each function here handles a URL and decides what to show the user. import csv import json import datetime import calendar as cal_module from decimal import Decimal from django.shortcuts import render, redirect, get_object_or_404 from django.utils import timezone from django.db import transaction from django.db.models import Sum, Count, Q, F, Prefetch, Max, Min from django.db.models.functions import TruncMonth from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import JsonResponse, HttpResponseForbidden, HttpResponse from django.middleware.csrf import get_token from django.urls import reverse from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings from .models import ( Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment, WorkerCertificate, WorkerWarning, ) from .forms import ( AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, ExpenseLineItemFormSet, WorkerForm, WorkerCertificateFormSet, WorkerWarningFormSet, TeamForm, ProjectForm, ) # NOTE: render_to_pdf is NOT imported here at the top level. # It's imported lazily inside process_payment() and create_receipt() # to avoid crashing the entire app if xhtml2pdf is not installed on the server. # === PAYROLL CONSTANTS === # These define which adjustment types ADD to a worker's pay vs SUBTRACT from it. # "New Loan" and "Advance Payment" are additive — the worker receives money upfront. # "Loan Repayment" and "Advance Repayment" are deductive — they reduce net pay. ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan', 'Advance Payment'] DEDUCTIVE_TYPES = ['Deduction', 'Loan Repayment', 'Advance Repayment'] # === PERMISSION HELPERS === # These small functions check what kind of user is logged in. # "Admin" = the boss (is_staff or is_superuser in Django). # "Supervisor" = someone who manages teams or projects, or is in the Work Logger group. def is_admin(user): """Returns True if the user is staff or superuser (the boss).""" return user.is_staff or user.is_superuser def is_supervisor(user): """Returns True if the user manages teams, has assigned projects, or is a Work Logger.""" return ( user.supervised_teams.exists() or user.assigned_projects.exists() or user.groups.filter(name='Work Logger').exists() ) def is_staff_or_supervisor(user): """Returns True if the user is either an admin or a supervisor.""" return is_admin(user) or is_supervisor(user) # === PAY SCHEDULE HELPERS === # These help figure out a worker's pay period based on their team's schedule. def get_worker_active_team(worker): """Return the worker's active team (first one found), or None.""" return worker.teams.filter(active=True).first() def get_pay_period(team, reference_date=None): """ Calculate the current pay period's start and end dates for a team. Returns (period_start, period_end) or (None, None) if the team has no pay schedule configured. How it works: - pay_start_date is the "anchor" — the first day of the very first pay period. - pay_frequency determines the length of each period (7, 14, or ~30 days). - We step forward from the anchor in period-length increments until we find the period that contains reference_date (today by default). """ if not team or not team.pay_frequency or not team.pay_start_date: return (None, None) if reference_date is None: reference_date = timezone.now().date() anchor = team.pay_start_date # === WEEKLY / FORTNIGHTLY === # Simple fixed-length periods (7 or 14 days). if team.pay_frequency in ('weekly', 'fortnightly'): period_days = 7 if team.pay_frequency == 'weekly' else 14 # How many full periods have passed since the anchor? days_since_anchor = (reference_date - anchor).days if days_since_anchor < 0: # reference_date is before the anchor — use the first period return (anchor, anchor + datetime.timedelta(days=period_days - 1)) periods_passed = days_since_anchor // period_days period_start = anchor + datetime.timedelta(days=periods_passed * period_days) period_end = period_start + datetime.timedelta(days=period_days - 1) return (period_start, period_end) # === MONTHLY === # Step through calendar months from the anchor's day-of-month. # E.g., anchor = Jan 15 means periods are: Jan 15–Feb 14, Feb 15–Mar 14, etc. elif team.pay_frequency == 'monthly': anchor_day = anchor.day current_start = anchor # Walk forward month by month until we find the period containing today for _ in range(120): # Safety limit — 10 years of months if current_start.month == 12: next_month, next_year = 1, current_start.year + 1 else: next_month, next_year = current_start.month + 1, current_start.year # Clamp anchor day to the max days in that month (e.g., 31 → 28 for Feb) max_day = cal_module.monthrange(next_year, next_month)[1] next_start = datetime.date(next_year, next_month, min(anchor_day, max_day)) current_end = next_start - datetime.timedelta(days=1) if reference_date <= current_end: return (current_start, current_end) current_start = next_start return (None, None) # ============================================================================= # === OUTSTANDING PAYMENTS — SHARED HELPER === # Used by the home dashboard AND the payroll report. Computes: # - outstanding_payments: Decimal total (unpaid wages + net unpaid adjustments) # - unpaid_wages: Decimal (pure daily rates for unpaid workers) # - pending_adj_add: Decimal (unpaid additive adjustments, e.g. bonuses) # - pending_adj_sub: Decimal (unpaid deductive adjustments, e.g. loan repayments) # - outstanding_by_project: dict[str project_name -> Decimal amount] # # Accepts optional project_ids / team_ids filters. Empty list or None = no filter. # ============================================================================= def _compute_outstanding(project_ids=None, team_ids=None): """Return current-moment outstanding payment breakdown. Plain-English: for each work log that hasn't been fully paid, adds up each unpaid worker's daily rate. Then adds unpaid additive adjustments (bonuses, overtime, new loans, advances) and subtracts unpaid deductive adjustments (deductions, loan/advance repayments). Results are the "as of right now" snapshot shown on the home dashboard's Outstanding Payments card. Optional filters scope the answer to specific projects and/or teams. """ # --- Work logs in scope --- work_logs = WorkLog.objects.select_related('project').prefetch_related('workers', 'payroll_records') if project_ids: work_logs = work_logs.filter(project_id__in=project_ids) if team_ids: work_logs = work_logs.filter(team_id__in=team_ids) unpaid_wages = Decimal('0.00') outstanding_by_project = {} for wl in work_logs: paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()} project_name = wl.project.name if wl.project else 'No Project' for worker in wl.workers.all(): if worker.id not in paid_worker_ids: cost = worker.daily_rate unpaid_wages += cost outstanding_by_project.setdefault(project_name, Decimal('0.00')) outstanding_by_project[project_name] += cost # --- Unpaid adjustments in scope --- adj_qs = PayrollAdjustment.objects.filter(payroll_record__isnull=True).select_related('project') if project_ids: adj_qs = adj_qs.filter(project_id__in=project_ids) if team_ids: # worker__teams is M2M — use subquery pattern (see CLAUDE.md Django ORM gotcha) adj_qs = adj_qs.filter( worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id') ) pending_adj_add = Decimal('0.00') pending_adj_sub = Decimal('0.00') for adj in adj_qs: project_name = adj.project.name if adj.project else 'No Project' outstanding_by_project.setdefault(project_name, Decimal('0.00')) if adj.type in ADDITIVE_TYPES: pending_adj_add += adj.amount outstanding_by_project[project_name] += adj.amount elif adj.type in DEDUCTIVE_TYPES: pending_adj_sub += adj.amount outstanding_by_project[project_name] -= adj.amount outstanding_payments = unpaid_wages + pending_adj_add - pending_adj_sub return { 'outstanding_payments': outstanding_payments, 'unpaid_wages': unpaid_wages, 'pending_adj_add': pending_adj_add, 'pending_adj_sub': pending_adj_sub, 'outstanding_by_project': outstanding_by_project, } # ============================================================================= # === COMPANY COST VELOCITY === # Lifetime "what does a typical FoxFitt working day cost us?" metric. # Denominator = COUNT(DISTINCT work_log.date) — true working days, not # calendar days (rain days, weekends, permit delays don't dilute the rate). # Used by the hero KPI band on the payroll report. # ============================================================================= def _company_cost_velocity(): """Return company-wide avg daily and monthly labour cost (lifetime).""" # Total lifetime labour cost: sum of (worker.daily_rate) over every # (log, worker) pair that has ever been logged. total_cost = Decimal('0.00') for wl in WorkLog.objects.prefetch_related('workers').all(): for worker in wl.workers.all(): total_cost += worker.daily_rate # Distinct work-log dates = working days working_days = WorkLog.objects.values('date').distinct().count() if working_days == 0: avg_daily = Decimal('0.00') else: avg_daily = (total_cost / working_days).quantize(Decimal('0.01')) # 30.44 = 365.25 / 12 — standard month-length approximation. # Keeps annualised totals correct on average. avg_monthly = (avg_daily * Decimal('30.44')).quantize(Decimal('0.01')) return { 'avg_daily': avg_daily, 'avg_monthly': avg_monthly, 'working_days': working_days, } # ============================================================================= # === CURRENT OUTSTANDING — SCOPED FOR THE REPORT === # Thin wrapper around _compute_outstanding that shapes the output for # the executive report's hero card 2. Includes a 'by_project' list # sorted by amount desc, ready for direct template rendering. # ============================================================================= def _current_outstanding_in_scope(project_ids=None, team_ids=None): """Return current outstanding payments, optionally scoped by project/team. Calls _compute_outstanding and reshapes the by_project dict into a list sorted by amount descending (for display). The 'total' field is the net outstanding (unpaid wages + additive adjustments minus deductive adjustments), matching the home dashboard card. """ raw = _compute_outstanding(project_ids=project_ids, team_ids=team_ids) by_project_list = sorted( [{'name': name, 'amount': amt} for name, amt in raw['outstanding_by_project'].items()], key=lambda r: r['amount'], reverse=True, ) return { 'total': raw['outstanding_payments'], 'by_project': by_project_list, } # ============================================================================= # === TEAM × PROJECT ACTIVITY PIVOT === # Chapter IV of the executive report: "how many days did each team work # on each project in this period?" Cell value = COUNT(DISTINCT work_log.date). # Logs with no team (team IS NULL) are excluded — the pivot is meaningless # without a team axis. # ============================================================================= def _team_project_activity(work_logs_qs): """Return pivot data for team × project activity within a work-logs queryset. Plain-English: for each team-project pair represented in the given queryset, counts the number of distinct calendar dates the team worked on that project. Rows and columns include only teams/projects that actually appeared (zero-activity teams/projects aren't shown). """ # Narrow to logs that have both a team and a project (we can't pivot # on NULL axes; also filters out the "No Project" ghost rows). qs = work_logs_qs.filter(team__isnull=False, project__isnull=False) # Aggregate: (team_id, project_id) -> distinct dates rows_data = qs.values( 'team_id', 'team__name', 'project_id', 'project__name' ).annotate(days=Count('date', distinct=True)).order_by('team__name') # Build column list (unique projects, ordered by name) columns_seen = {} for r in rows_data: columns_seen.setdefault(r['project_id'], r['project__name']) columns = [ {'id': pid, 'name': pname} for pid, pname in sorted(columns_seen.items(), key=lambda kv: kv[1]) ] # Build rows: team_id -> cells_by_project_id dict rows_by_team = {} # team_id -> {'team_id', 'team_name', 'cells_by_project_id', 'row_total'} col_totals = {col['id']: 0 for col in columns} grand_total = 0 for r in rows_data: tid = r['team_id'] pid = r['project_id'] days = r['days'] row = rows_by_team.setdefault(tid, { 'team_id': tid, 'team_name': r['team__name'], 'cells_by_project_id': {}, 'row_total': 0, }) row['cells_by_project_id'][pid] = days row['row_total'] += days col_totals[pid] += days grand_total += days # Ordered rows list (by team name) rows = sorted(rows_by_team.values(), key=lambda r: r['team_name']) return { 'columns': columns, 'rows': rows, 'col_totals': col_totals, 'grand_total': grand_total, } # === HOME DASHBOARD === # The main page users see after logging in. Shows different content # depending on whether the user is an admin or supervisor. @login_required def index(request): user = request.user if is_admin(user): # --- ADMIN DASHBOARD --- # === OUTSTANDING BREAKDOWN === # Uses the shared _compute_outstanding helper so the dashboard and the # payroll report can't drift. Unscoped (no filters) = whole company. _o = _compute_outstanding() outstanding_payments = _o['outstanding_payments'] unpaid_wages = _o['unpaid_wages'] pending_adjustments_add = _o['pending_adj_add'] pending_adjustments_sub = _o['pending_adj_sub'] outstanding_by_project = _o['outstanding_by_project'] # Sum total paid out in the last 60 days sixty_days_ago = timezone.now().date() - timezone.timedelta(days=60) paid_this_month = PayrollRecord.objects.filter( date__gte=sixty_days_ago ).aggregate(total=Sum('amount_paid'))['total'] or Decimal('0.00') # Count and total balance of active loans active_loans_qs = Loan.objects.filter(active=True) active_loans_count = active_loans_qs.count() active_loans_balance = active_loans_qs.aggregate( total=Sum('remaining_balance') )['total'] or Decimal('0.00') # This week summary start_of_week = timezone.now().date() - timezone.timedelta( days=timezone.now().date().weekday() ) this_week_logs = WorkLog.objects.filter(date__gte=start_of_week).count() # Recent activity — last 5 work logs recent_activity = WorkLog.objects.select_related( 'project', 'supervisor' ).prefetch_related('workers').order_by('-date', '-id')[:5] # All workers, projects, and teams for the Manage Resources tab. # The template uses a JS filter bar (Active / Inactive / All) to show/hide # rows based on data-active attribute — defaults to showing only active items. workers = Worker.objects.all().order_by('name') projects = Project.objects.all().order_by('name') teams = Team.objects.all().order_by('name') # === CERT EXPIRY SUMMARY === # Count certificates that are expired or expire within the next 30 days. # Only shown on the dashboard when the count is non-zero (so the stat # card disappears when everything is in good standing). today = datetime.date.today() thirty_days_out = today + datetime.timedelta(days=30) certs_expired_count = WorkerCertificate.objects.filter( valid_until__lt=today, worker__active=True, ).count() certs_expiring_count = WorkerCertificate.objects.filter( valid_until__gte=today, valid_until__lte=thirty_days_out, worker__active=True, ).count() certs_alert_total = certs_expired_count + certs_expiring_count context = { 'is_admin': True, 'outstanding_payments': outstanding_payments, 'unpaid_wages': unpaid_wages, 'pending_adjustments_add': pending_adjustments_add, 'pending_adjustments_sub': pending_adjustments_sub, 'paid_this_month': paid_this_month, 'active_loans_count': active_loans_count, 'active_loans_balance': active_loans_balance, 'outstanding_by_project': outstanding_by_project, 'this_week_logs': this_week_logs, 'recent_activity': recent_activity, 'workers': workers, 'projects': projects, 'teams': teams, # Cert-expiry card (rendered only when > 0) 'certs_expired_count': certs_expired_count, 'certs_expiring_count': certs_expiring_count, 'certs_alert_total': certs_alert_total, # Empty on the home dashboard — modal opens clean (no pre-selected filters) 'selected_project_ids': [], 'selected_team_ids': [], } return render(request, 'core/index.html', context) else: # --- SUPERVISOR DASHBOARD --- # Count projects this supervisor is assigned to my_projects_count = user.assigned_projects.filter(active=True).count() # Count teams this supervisor manages my_teams_count = user.supervised_teams.filter(active=True).count() # Count unique workers across all their teams my_workers_count = Worker.objects.filter( active=True, teams__supervisor=user, teams__active=True ).distinct().count() # This week summary — only their own logs start_of_week = timezone.now().date() - timezone.timedelta( days=timezone.now().date().weekday() ) this_week_logs = WorkLog.objects.filter( date__gte=start_of_week, supervisor=user ).count() # Their last 5 work logs recent_activity = WorkLog.objects.filter( supervisor=user ).select_related('project').prefetch_related('workers').order_by('-date', '-id')[:5] context = { 'is_admin': False, 'my_projects_count': my_projects_count, 'my_teams_count': my_teams_count, 'my_workers_count': my_workers_count, 'this_week_logs': this_week_logs, 'recent_activity': recent_activity, } return render(request, 'core/index.html', context) # === ATTENDANCE LOGGING === # This is where supervisors log which workers showed up to work each day. # Supports logging a single day or a date range (e.g. a whole week). # Includes conflict detection to prevent double-logging workers. @login_required def attendance_log(request): user = request.user if request.method == 'POST': form = AttendanceLogForm(request.POST, user=user) if form.is_valid(): start_date = form.cleaned_data['date'] end_date = form.cleaned_data.get('end_date') or start_date include_saturday = form.cleaned_data.get('include_saturday', False) include_sunday = form.cleaned_data.get('include_sunday', False) project = form.cleaned_data['project'] team = form.cleaned_data.get('team') workers = form.cleaned_data['workers'] overtime_amount = form.cleaned_data['overtime_amount'] notes = form.cleaned_data.get('notes', '') # --- Build list of dates to log --- # Go through each day from start to end, skipping weekends # unless the user checked the "Include Saturday/Sunday" boxes dates_to_log = [] current_date = start_date while current_date <= end_date: day_of_week = current_date.weekday() # 0=Mon, 5=Sat, 6=Sun if day_of_week == 5 and not include_saturday: current_date += datetime.timedelta(days=1) continue if day_of_week == 6 and not include_sunday: current_date += datetime.timedelta(days=1) continue dates_to_log.append(current_date) current_date += datetime.timedelta(days=1) if not dates_to_log: messages.warning(request, 'No valid dates in the selected range.') # Still need team_workers_json for the JS even on error re-render tw_map = {} for t in Team.objects.filter(active=True).prefetch_related('workers'): tw_map[t.id] = list(t.workers.filter(active=True).values_list('id', flat=True)) return render(request, 'core/attendance_log.html', { 'form': form, 'is_admin': is_admin(user), 'team_workers_json': json.dumps(tw_map), }) # --- Conflict detection --- # Check if any selected workers already have a WorkLog on any of these dates worker_ids = list(workers.values_list('id', flat=True)) existing_logs = WorkLog.objects.filter( date__in=dates_to_log, workers__id__in=worker_ids ).prefetch_related('workers').select_related('project') conflicts = [] for log in existing_logs: for w in log.workers.all(): if w.id in worker_ids: conflicts.append({ 'worker_name': w.name, 'date': log.date, 'project_name': log.project.name, }) # If there are conflicts and the user hasn't chosen what to do yet conflict_action = request.POST.get('conflict_action', '') if conflicts and not conflict_action: # Show the conflict warning — let user choose Skip or Overwrite # Still need team_workers_json for the JS even on conflict re-render tw_map = {} for t in Team.objects.filter(active=True).prefetch_related('workers'): tw_map[t.id] = list(t.workers.filter(active=True).values_list('id', flat=True)) # Pass the selected worker IDs explicitly for the conflict # re-submission forms. We can't use form.data.workers in the # template because QueryDict.__getitem__ returns only the last # value, losing all other selections for multi-value fields. selected_worker_ids = request.POST.getlist('workers') return render(request, 'core/attendance_log.html', { 'form': form, 'conflicts': conflicts, 'is_admin': is_admin(user), 'team_workers_json': json.dumps(tw_map), 'selected_worker_ids': selected_worker_ids, }) # --- Create work logs --- created_count = 0 skipped_count = 0 for log_date in dates_to_log: # Check which workers already have a log on this date workers_with_existing = set( WorkLog.objects.filter( date=log_date, workers__id__in=worker_ids ).values_list('workers__id', flat=True) ) if conflict_action == 'overwrite': # Remove conflicting workers from their existing logs conflicting_logs = WorkLog.objects.filter( date=log_date, workers__id__in=worker_ids ) for existing_log in conflicting_logs: for w_id in worker_ids: existing_log.workers.remove(w_id) workers_to_add = workers elif conflict_action == 'skip': # Skip workers who already have logs on this date workers_to_add = workers.exclude(id__in=workers_with_existing) skipped_count += len(workers_with_existing & set(worker_ids)) else: # No conflicts, or first submission — add all workers workers_to_add = workers if workers_to_add.exists(): # Create the WorkLog record work_log = WorkLog.objects.create( date=log_date, project=project, team=team, supervisor=user, # Auto-set to logged-in user overtime_amount=overtime_amount, notes=notes, ) work_log.workers.set(workers_to_add) created_count += 1 # Show success message if created_count > 0: msg = f'Successfully created {created_count} work log(s).' if skipped_count > 0: msg += f' Skipped {skipped_count} conflicts.' messages.success(request, msg) else: messages.warning(request, 'No work logs created — all entries were conflicts.') return redirect('home') else: # Don't pre-fill the start date — force the user to pick one # so they don't accidentally log work on the wrong day form = AttendanceLogForm(user=user) # Build a list of worker data for the estimated cost JavaScript # (admins only — supervisors don't see the cost card) worker_rates = {} if is_admin(user): for w in Worker.objects.filter(active=True): worker_rates[str(w.id)] = str(w.daily_rate) # Build team→workers mapping so the JS can auto-check workers when a # team is selected from the dropdown. Key = team ID, Value = list of worker IDs. team_workers_map = {} teams_qs = Team.objects.filter(active=True).prefetch_related('workers') if not is_admin(user): # Supervisors only see their own teams teams_qs = teams_qs.filter(supervisor=user) for team in teams_qs: active_worker_ids = list( team.workers.filter(active=True).values_list('id', flat=True) ) team_workers_map[team.id] = active_worker_ids return render(request, 'core/attendance_log.html', { 'form': form, 'is_admin': is_admin(user), 'worker_rates_json': worker_rates, 'team_workers_json': json.dumps(team_workers_map), }) # === WORK LOG HISTORY === # Shows work logs in two modes: a table list or a monthly calendar grid. # Supervisors only see their own projects. Admins see everything. # The calendar view groups logs by day and lets you click a day to see details. @login_required def work_history(request): user = request.user # Start with base queryset if is_admin(user): logs = WorkLog.objects.all() else: # Supervisors only see logs for their projects logs = WorkLog.objects.filter( Q(supervisor=user) | Q(project__supervisors=user) ).distinct() # --- Filters --- # Read filter values from the URL query string. # Validate numeric params to prevent 500 errors from bad/malformed URLs. worker_filter = request.GET.get('worker', '') project_filter = request.GET.get('project', '') status_filter = request.GET.get('status', '') # Validate: worker and project must be numeric IDs (or empty) try: worker_filter = str(int(worker_filter)) if worker_filter else '' except (ValueError, TypeError): worker_filter = '' try: project_filter = str(int(project_filter)) if project_filter else '' except (ValueError, TypeError): project_filter = '' # Count total logs BEFORE filtering (so we can show "X of Y" to the user) total_log_count = logs.count() if worker_filter: logs = logs.filter(workers__id=worker_filter).distinct() if project_filter: logs = logs.filter(project__id=project_filter) if status_filter == 'paid': # "Paid" = has at least one PayrollRecord linked logs = logs.filter(payroll_records__isnull=False).distinct() elif status_filter == 'unpaid': # "Unpaid" = has no PayrollRecord linked logs = logs.filter(payroll_records__isnull=True) # Track whether any filter is active (for showing feedback in the template) has_active_filters = bool(worker_filter or project_filter or status_filter) # Count filtered results BEFORE adding joins (more efficient SQL) filtered_log_count = logs.count() if has_active_filters else 0 # If filtering by worker, look up the Worker object so the template can # show just that worker's name instead of all workers on the log. filtered_worker_obj = None if worker_filter: filtered_worker_obj = Worker.objects.filter(id=worker_filter).first() # Add related data and order by date (newest first) logs = logs.select_related( 'project', 'supervisor' ).prefetch_related('workers', 'payroll_records').order_by('-date', '-id') # Get filter options for the dropdowns if is_admin(user): filter_workers = Worker.objects.filter(active=True).order_by('name') filter_projects = Project.objects.filter(active=True).order_by('name') else: supervised_teams = Team.objects.filter(supervisor=user, active=True) filter_workers = Worker.objects.filter( active=True, teams__in=supervised_teams ).distinct().order_by('name') filter_projects = Project.objects.filter( active=True, supervisors=user ).order_by('name') # --- View mode: list or calendar --- view_mode = request.GET.get('view', 'list') today = timezone.now().date() # Build a query string that preserves all current filters # (used by the List/Calendar toggle links to keep filters when switching) filter_params = '' if worker_filter: filter_params += '&worker=' + worker_filter if project_filter: filter_params += '&project=' + project_filter if status_filter: filter_params += '&status=' + status_filter context = { 'logs': logs, 'filter_workers': filter_workers, 'filter_projects': filter_projects, 'selected_worker': worker_filter, 'selected_project': project_filter, 'selected_status': status_filter, 'is_admin': is_admin(user), 'view_mode': view_mode, 'filter_params': filter_params, 'has_active_filters': has_active_filters, 'total_log_count': total_log_count, 'filtered_log_count': filtered_log_count, 'filtered_worker_obj': filtered_worker_obj, } # === CALENDAR MODE === # Build a monthly grid of days, each containing the work logs for that day. # Also build a JSON object keyed by date string for the JavaScript # click-to-see-details panel. if view_mode == 'calendar': # Get target month from URL (default: current month) try: target_year = int(request.GET.get('year', today.year)) target_month = int(request.GET.get('month', today.month)) if not (1 <= target_month <= 12): target_year, target_month = today.year, today.month except (ValueError, TypeError): target_year, target_month = today.year, today.month # Build the calendar grid using Python's calendar module. # monthdatescalendar() returns a list of weeks, where each week is # a list of 7 datetime.date objects (including overflow from prev/next month). cal = cal_module.Calendar(firstweekday=0) # Week starts on Monday month_dates = cal.monthdatescalendar(target_year, target_month) # Get the full date range for the calendar grid (includes overflow days) first_display_date = month_dates[0][0] last_display_date = month_dates[-1][-1] # Filter logs to only this date range (improves performance) month_logs = logs.filter(date__range=[first_display_date, last_display_date]) # Group logs by date string for quick lookup logs_by_date = {} for log in month_logs: date_key = log.date.isoformat() if date_key not in logs_by_date: logs_by_date[date_key] = [] logs_by_date[date_key].append(log) # Build the calendar_weeks structure that the template iterates over. # Each day cell has: date, day number, whether it's the current month, # a list of log objects, and a count badge number. calendar_weeks = [] for week in month_dates: week_data = [] for day in week: date_key = day.isoformat() day_logs = logs_by_date.get(date_key, []) week_data.append({ 'date': day, 'day': day.day, 'is_current_month': day.month == target_month, 'is_today': day == today, 'records': day_logs, 'count': len(day_logs), }) calendar_weeks.append(week_data) # Build detail data for JavaScript — when you click a day cell, # the JS reads this JSON to populate the detail panel below the calendar. # NOTE: Pass raw Python dict, not json.dumps() — the template's # |json_script filter handles serialization. # # IMPORTANT: When a worker filter is active, log.workers.all() would # still return ALL workers on that WorkLog (not just the filtered one). # We need to narrow the displayed workers to match the filter. calendar_detail = {} for date_key, day_logs in logs_by_date.items(): calendar_detail[date_key] = [] for log in day_logs: # Get the workers to show — if filtering by worker, # only show that worker (not everyone else on the log) if worker_filter: display_workers = [ w for w in log.workers.all() if str(w.id) == worker_filter ] else: display_workers = list(log.workers.all()) entry = { 'project': log.project.name, 'workers': [w.name for w in display_workers], 'supervisor': ( log.supervisor.get_full_name() or log.supervisor.username ) if log.supervisor else '-', 'notes': log.notes or '', 'is_paid': log.payroll_records.exists(), 'overtime': log.get_overtime_amount_display() if log.overtime_amount > 0 else '', } # Only show cost data to admins — use filtered workers for amount if is_admin(user): entry['amount'] = float( sum(w.daily_rate for w in display_workers) ) calendar_detail[date_key].append(entry) # Calculate previous/next month for navigation arrows if target_month == 1: prev_year, prev_month = target_year - 1, 12 else: prev_year, prev_month = target_year, target_month - 1 if target_month == 12: next_year, next_month = target_year + 1, 1 else: next_year, next_month = target_year, target_month + 1 month_name = datetime.date(target_year, target_month, 1).strftime('%B %Y') context.update({ 'calendar_weeks': calendar_weeks, 'calendar_detail': calendar_detail, 'curr_year': target_year, 'curr_month': target_month, 'month_name': month_name, 'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, }) return render(request, 'core/work_history.html', context) # ============================================================================= # === WORK LOG PAYROLL CROSS-LINK === # From any historic work log, see which workers got paid, which didn't, and # (for paid ones) which payslip it was. Admin-only; supervisors never see # payroll data. Two endpoints share one helper so the modal and the full # page can never drift apart. # ============================================================================= def _build_work_log_payroll_context(log): """Return a context dict describing the payroll status of a work log. Plain-English summary for future-you: For the given work log, loop over each worker on it and decide which of three buckets they fall into: - "Paid" -> a PayrollRecord links this worker + this log - "Priced, not paid" -> worker is in log.priced_workers but no record yet - "Unpaid" -> neither Also collects any PayrollAdjustments tied to this log (e.g. overtime). Used by the AJAX endpoint AND the full detail page — keep them sharing this helper so they can never show different data. """ # Prefetch payroll records once, rather than re-querying per worker. payroll_records = list( PayrollRecord.objects.filter(work_logs=log).select_related('worker') ) # Lookup: worker_id -> first PayrollRecord found. record_by_worker = {r.worker_id: r for r in payroll_records} # IDs of workers who've been priced on this log but aren't necessarily paid yet. priced_worker_ids = set(log.priced_workers.values_list('id', flat=True)) worker_rows = [] total_earned = Decimal('0.00') total_paid = Decimal('0.00') total_outstanding = Decimal('0.00') # Loop each worker on the log and classify them into one of three buckets. for worker in log.workers.all(): record = record_by_worker.get(worker.id) if record: status = 'Paid' earned = worker.daily_rate total_paid += earned elif worker.id in priced_worker_ids: status = 'Priced, not paid' earned = worker.daily_rate total_outstanding += earned else: status = 'Unpaid' earned = worker.daily_rate total_outstanding += earned total_earned += earned worker_rows.append({ 'worker': worker, 'status': status, 'earned': earned, 'payroll_record': record, 'paid_date': record.date if record else None, }) # Adjustments tied directly to this log (mostly overtime pricing). # Reverse accessor is adjustments_by_work_log (see PayrollAdjustment.work_log related_name). adjustments = list( log.adjustments_by_work_log .select_related('worker', 'payroll_record') .order_by('type', 'id') ) # Pay-period info (only if the team has a schedule configured). # Use the log's own date as the reference so we report the period the # log falls into — not whichever period happens to contain "today". pay_period = get_pay_period(log.team, reference_date=log.date) if log.team else (None, None) # Overtime "needs pricing" flag: log has OT hours but no priced_workers yet. # log.overtime_amount is a Decimal with default=0.00 — always present on saved # instances, so no defensive getattr needed. Compare via Decimal arithmetic. log_overtime = log.overtime_amount or Decimal('0.00') overtime_needs_pricing = log_overtime > 0 and not priced_worker_ids return { 'log': log, 'worker_rows': worker_rows, 'adjustments': adjustments, 'total_earned': total_earned, 'total_paid': total_paid, 'total_outstanding': total_outstanding, 'pay_period': pay_period, 'overtime_needs_pricing': overtime_needs_pricing, } @login_required def work_log_payroll_ajax(request, log_id): """Return JSON describing the payroll status of a work log. Admin-only. The modal's JS builds its DOM from this JSON using textContent/createElement (matches the worker_lookup_ajax pattern). """ # Only admins can see this data (salaries, adjustments, etc.) if not is_admin(request.user): return JsonResponse({'error': 'Not authorized'}, status=403) # Fetch the log with related objects pre-loaded to avoid extra queries log = get_object_or_404( WorkLog.objects.select_related('project', 'team', 'supervisor'), id=log_id, ) # Shared helper also used by the full-page view (Task 4) — keeps the # JSON payload and the HTML view in perfect sync. ctx = _build_work_log_payroll_context(log) # === SERIALIZE FOR JSON === # JSON can't represent Decimals or dates natively, so we convert: # - Decimal -> float (JS does math in floats anyway) # - date -> ISO 8601 string ("2026-04-10") def _date_iso(d): return d.strftime('%Y-%m-%d') if d else None # One dict per worker row — small, hand-picked fields the modal needs. worker_rows = [{ 'worker_id': row['worker'].id, 'worker_name': row['worker'].name, 'worker_active': row['worker'].active, 'status': row['status'], 'earned': float(row['earned']), 'payroll_record_id': row['payroll_record'].pk if row['payroll_record'] else None, 'paid_date': _date_iso(row['paid_date']), } for row in ctx['worker_rows']] # Adjustments linked directly to this work_log (Overtime, etc.). adjustments = [{ 'type': adj.type, 'amount': float(adj.amount), 'worker_id': adj.worker.id, 'worker_name': adj.worker.name, 'payroll_record_id': adj.payroll_record.pk if adj.payroll_record else None, } for adj in ctx['adjustments']] return JsonResponse({ 'log_id': log.id, 'date': _date_iso(log.date), 'project': {'id': log.project.id, 'name': log.project.name} if log.project else None, 'team': {'id': log.team.id, 'name': log.team.name} if log.team else None, # get_full_name() returns "" if no first/last, so fall back to username. 'supervisor': (log.supervisor.get_full_name() or log.supervisor.username) if log.supervisor else None, 'worker_rows': worker_rows, 'adjustments': adjustments, 'total_earned': float(ctx['total_earned']), 'total_paid': float(ctx['total_paid']), 'total_outstanding': float(ctx['total_outstanding']), 'pay_period_start': _date_iso(ctx['pay_period'][0]), 'pay_period_end': _date_iso(ctx['pay_period'][1]), 'overtime_needs_pricing': ctx['overtime_needs_pricing'], # Link to the full-page view (Task 4) for the "Open full page" button. 'full_page_url': reverse('work_log_payroll_detail', args=[log.id]), }) @login_required def work_log_payroll_detail(request, log_id): """Full-page payroll-status view for a single work log. Admin-only. Shares the exact same context builder as the AJAX endpoint, so the full page and the modal can never drift out of sync. """ # Admin-only: this page shows salary-level data. if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") # Fetch the log with related objects pre-loaded to avoid extra queries. log = get_object_or_404( WorkLog.objects.select_related('project', 'team', 'supervisor'), id=log_id, ) context = _build_work_log_payroll_context(log) return render(request, 'core/work_log_payroll.html', context) # === CSV EXPORT === # Downloads the filtered work log history as a CSV file. # Uses the same filters as the work_history page. @login_required def export_work_log_csv(request): user = request.user # Build the same queryset as work_history, using the same filters if is_admin(user): logs = WorkLog.objects.all() else: logs = WorkLog.objects.filter( Q(supervisor=user) | Q(project__supervisors=user) ).distinct() worker_filter = request.GET.get('worker', '') project_filter = request.GET.get('project', '') status_filter = request.GET.get('status', '') if worker_filter: logs = logs.filter(workers__id=worker_filter).distinct() if project_filter: logs = logs.filter(project__id=project_filter) if status_filter == 'paid': logs = logs.filter(payroll_records__isnull=False).distinct() elif status_filter == 'unpaid': logs = logs.filter(payroll_records__isnull=True) logs = logs.select_related( 'project', 'supervisor' ).prefetch_related('workers', 'payroll_records').order_by('-date', '-id') # Create the CSV response response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="work_log_history.csv"' writer = csv.writer(response) writer.writerow(['Date', 'Project', 'Workers', 'Overtime', 'Payment Status', 'Supervisor']) for log in logs: worker_names = ', '.join(w.name for w in log.workers.all()) payment_status = 'Paid' if log.payroll_records.exists() else 'Unpaid' overtime_display = log.get_overtime_amount_display() if log.overtime_amount > 0 else 'None' supervisor_name = log.supervisor.get_full_name() or log.supervisor.username if log.supervisor else '-' writer.writerow([ log.date.strftime('%Y-%m-%d'), log.project.name, worker_names, overtime_display, payment_status, supervisor_name, ]) return response # === EXPORT WORKERS CSV — FULL WORKER EXPORT === # Downloads every field we have on every worker as a CSV file. # Admin-only (supervisors don't have access to salary / ID / banking data). # # Columns are organised into logical groups so the file reads naturally # left-to-right in a spreadsheet: # 1. Identity & Pay 2. Banking & Tax # 3. Employment & Notes 4. PPE Sizing # 5. Driver's License 6. Certifications (one column per type → valid_until date) # 7. Warnings summary 8. Activity aggregates (days worked, payslips, total paid) # # For certs we show one column per cert type with the valid-until date # as the value (or "Yes (no expiry)" if the worker has the cert with # no expiry set, or empty if they don't hold it). This lets you sort # and filter in Excel: "who has a Medical expiring before June?" @login_required def export_workers_csv(request): """Export ALL worker data to CSV (profile, banking, PPE, certs, warnings, history).""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") # Pull everything we'll need with prefetching so we don't N+1 workers = ( Worker.objects.all() .prefetch_related('certificates', 'warnings', 'payroll_records', 'work_logs__project', 'teams') .annotate( _days_worked=Count('work_logs__date', distinct=True), _first_payslip=Min('payroll_records__date'), _last_payslip=Max('payroll_records__date'), _total_paid=Sum('payroll_records__amount_paid'), _payslip_count=Count('payroll_records', distinct=True), ) .order_by('name') ) # Cert types in the order we want them to appear in the CSV cert_types = [ ('skills', 'Skills Cert'), ('pdp', 'PDP'), ('first_aid', 'First Aid'), ('medical', 'Medical'), ('work_at_height', 'Work at Height'), ] response = HttpResponse(content_type='text/csv; charset=utf-8') response['Content-Disposition'] = 'attachment; filename="workers_full_export.csv"' writer = csv.writer(response) writer.writerow([ # Identity & pay 'Name', 'ID Number', 'Phone Number', 'Monthly Salary', 'Daily Rate', # Banking & tax 'Tax No', 'UIF', 'Bank', 'Acc No.', # Employment & notes 'Employment Date', 'Active', 'Notes', # PPE sizing 'Shoe Size', 'Overall Top Size', 'Pants Size', 'T-Shirt Size', # Driver's License 'Has Drivers License', 'License Code', # Certifications — one column per type, value = valid_until date *(f'{label} Valid Until' for _code, label in cert_types), # Warnings 'Total Warnings', 'Last Warning Date', 'Last Warning Severity', # Activity aggregates (lifetime) 'Days Worked', 'Projects Worked On', 'Teams', 'First Payslip', 'Last Payslip', 'Payslip Count', 'Total Paid Lifetime', ]) for w in workers: # --- Build a cert-type → valid_until lookup for this worker --- cert_by_type = {c.cert_type: c for c in w.certificates.all()} cert_cells = [] for code, _label in cert_types: c = cert_by_type.get(code) if not c: cert_cells.append('') # doesn't hold it elif c.valid_until is None: cert_cells.append('Yes (no expiry)') # has it, no expiry else: cert_cells.append(c.valid_until.strftime('%Y-%m-%d')) # --- Warnings summary --- warnings = list(w.warnings.all()) # already ordered -date last_warning = warnings[0] if warnings else None # --- Projects & teams worked (distinct names) --- project_names = sorted({ log.project.name for log in w.work_logs.all() if log.project }) team_names = sorted({t.name for t in w.teams.all()}) writer.writerow([ # Identity & pay w.name, w.id_number, w.phone_number, f'{w.monthly_salary:.2f}', f'{w.daily_rate:.2f}', # Banking & tax w.tax_number, w.uif_number, w.bank_name, w.bank_account_number, # Employment & notes w.employment_date.strftime('%Y-%m-%d') if w.employment_date else '', 'Yes' if w.active else 'No', w.notes, # PPE sizing w.shoe_size, w.overall_top_size, w.pants_size, w.tshirt_size, # Driver's License 'Yes' if w.has_drivers_license else 'No', w.drivers_license_code, # Certifications (one per cert type) *cert_cells, # Warnings len(warnings), last_warning.date.strftime('%Y-%m-%d') if last_warning else '', last_warning.get_severity_display() if last_warning else '', # Activity aggregates w._days_worked or 0, '; '.join(project_names), '; '.join(team_names), w._first_payslip.strftime('%Y-%m-%d') if w._first_payslip else '', w._last_payslip.strftime('%Y-%m-%d') if w._last_payslip else '', w._payslip_count or 0, f'{(w._total_paid or 0):.2f}', ]) return response # ============================================================= # === WORKER MANAGEMENT (friendly UI — alternative to /admin/) === # ============================================================= @login_required def worker_list(request): """Admin-friendly list of all workers with search + status filter. Query params: ?q=search_term — search name / ID number / phone ?status=active — default, only active workers ?status=inactive — only inactive ?status=all — both """ if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") q = (request.GET.get('q') or '').strip() status = request.GET.get('status') or 'active' workers = Worker.objects.all() if status == 'active': workers = workers.filter(active=True) elif status == 'inactive': workers = workers.filter(active=False) # 'all' → no filter if q: workers = workers.filter( Q(name__icontains=q) | Q(id_number__icontains=q) | Q(phone_number__icontains=q) ) # Annotate days worked (distinct WorkLog dates) — shown in the table workers = workers.annotate( days_worked=Count('work_logs__date', distinct=True), ).order_by('name') context = { 'workers': workers, 'q': q, 'status': status, 'total_count': workers.count(), } return render(request, 'core/workers/list.html', context) @login_required def worker_detail(request, worker_id): """Read-only worker profile with certs, warnings, and history tabs.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") worker = get_object_or_404(Worker, id=worker_id) # --- History aggregates --- projects_worked = ( Project.objects.filter(work_logs__workers=worker).distinct().order_by('name') ) days_worked = worker.work_logs.values('date').distinct().count() payslips = worker.payroll_records.order_by('-date')[:10] first_payslip = worker.payroll_records.order_by('date').first() last_payslip = worker.payroll_records.order_by('-date').first() total_paid = worker.payroll_records.aggregate(t=Sum('amount_paid'))['t'] or Decimal('0.00') # --- Certs grouped by status for the visual badges --- certs = worker.certificates.all().order_by('cert_type') # --- Warnings (already ordered -date in Meta) --- warnings = worker.warnings.all() # --- Active loans / advances --- active_loans = worker.loans.filter(active=True).order_by('-date') context = { 'worker': worker, 'projects_worked': projects_worked, 'days_worked': days_worked, 'payslips': payslips, 'first_payslip': first_payslip, 'last_payslip': last_payslip, 'total_paid': total_paid, 'certs': certs, 'warnings': warnings, 'active_loans': active_loans, } return render(request, 'core/workers/detail.html', context) @login_required def worker_edit(request, worker_id=None): """Create or edit a Worker plus their certs and warnings in one page. - GET /workers/new/ → blank form - GET /workers//edit/ → form pre-filled - POST to either URL → validate + save + redirect to worker_detail """ if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") worker = get_object_or_404(Worker, id=worker_id) if worker_id else None is_new = worker is None if request.method == 'POST': form = WorkerForm(request.POST, request.FILES, instance=worker) # Inline formsets need the parent instance bound early so they # can scope queryset + handle created rows correctly. if form.is_valid(): saved_worker = form.save() cert_fs = WorkerCertificateFormSet( request.POST, request.FILES, instance=saved_worker, ) warn_fs = WorkerWarningFormSet( request.POST, request.FILES, instance=saved_worker, ) if cert_fs.is_valid() and warn_fs.is_valid(): cert_fs.save() # Warnings save — set issued_by to current admin on new rows warnings = warn_fs.save(commit=False) for obj in warnings: if obj.issued_by_id is None: obj.issued_by = request.user obj.save() for obj in warn_fs.deleted_objects: obj.delete() action = 'added' if is_new else 'updated' messages.success(request, f'Worker "{saved_worker.name}" {action} successfully.') return redirect('worker_detail', worker_id=saved_worker.id) # Re-bind forms with errors for re-render form = WorkerForm(request.POST, request.FILES, instance=saved_worker) else: cert_fs = WorkerCertificateFormSet(request.POST, request.FILES, instance=worker) warn_fs = WorkerWarningFormSet(request.POST, request.FILES, instance=worker) else: form = WorkerForm(instance=worker) cert_fs = WorkerCertificateFormSet(instance=worker) warn_fs = WorkerWarningFormSet(instance=worker) context = { 'form': form, 'cert_formset': cert_fs, 'warn_formset': warn_fs, 'worker': worker, 'is_new': is_new, } return render(request, 'core/workers/edit.html', context) # ============================================================= # === WORKER BATCH REPORT === # ============================================================= def _build_worker_report_context(status=None, project_id=None, team_id=None): """Build the per-worker aggregation list used by HTML / CSV / PDF views. Returns a list of dicts — one per worker — with projects, teams, days worked, first/last payslip dates, total paid, cert counts, and warning counts. All aggregates are computed in a single query via annotate/prefetch to avoid N+1 database hits. """ workers = Worker.objects.all() if status == 'active': workers = workers.filter(active=True) elif status == 'inactive': workers = workers.filter(active=False) if project_id: workers = workers.filter(work_logs__project_id=project_id).distinct() if team_id: workers = workers.filter(teams__id=team_id).distinct() workers = workers.annotate( _days_worked=Count('work_logs__date', distinct=True), _first_payslip_date=Min('payroll_records__date'), _last_payslip_date=Max('payroll_records__date'), _total_paid_lifetime=Sum('payroll_records__amount_paid'), _payslip_count=Count('payroll_records', distinct=True), _active_warnings=Count('warnings', distinct=True), ).order_by('name') today = datetime.date.today() thirty_days_out = today + datetime.timedelta(days=30) rows = [] for w in workers: projects = list( Project.objects.filter(work_logs__workers=w).distinct().values_list('name', flat=True) ) teams = list(w.teams.values_list('name', flat=True)) certs = w.certificates.all() certs_total = certs.count() certs_active = 0 certs_expiring = 0 certs_expired = 0 for c in certs: if c.valid_until is None: certs_active += 1 # non-expiring counts as active elif c.valid_until < today: certs_expired += 1 elif c.valid_until <= thirty_days_out: certs_expiring += 1 certs_active += 1 else: certs_active += 1 rows.append({ 'worker': w, 'projects': projects, 'teams': teams, 'days_worked': w._days_worked or 0, 'first_payslip_date': w._first_payslip_date, 'last_payslip_date': w._last_payslip_date, 'total_paid_lifetime': w._total_paid_lifetime or Decimal('0.00'), 'payslip_count': w._payslip_count or 0, 'certs_total': certs_total, 'certs_active': certs_active, 'certs_expiring': certs_expiring, 'certs_expired': certs_expired, 'warnings_count': w._active_warnings or 0, }) return rows @login_required def worker_batch_report(request): """HTML table of every worker with aggregated project/team/day/payslip history.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") status = request.GET.get('status') or 'all' project_id = request.GET.get('project') or None team_id = request.GET.get('team') or None rows = _build_worker_report_context(status=status, project_id=project_id, team_id=team_id) context = { 'rows': rows, 'status': status, 'project_id': project_id, 'team_id': team_id, 'projects': Project.objects.all().order_by('name'), 'teams': Team.objects.all().order_by('name'), 'query_string': request.GET.urlencode(), 'total_workers': len(rows), } return render(request, 'core/workers/batch_report.html', context) @login_required def worker_batch_report_csv(request): """CSV download of the batch worker report.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") status = request.GET.get('status') or 'all' project_id = request.GET.get('project') or None team_id = request.GET.get('team') or None rows = _build_worker_report_context(status=status, project_id=project_id, team_id=team_id) response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="worker_batch_report.csv"' writer = csv.writer(response) writer.writerow([ 'Name', 'ID Number', 'Monthly Salary', 'Active', 'Days Worked', 'Projects', 'Teams', 'First Payslip', 'Last Payslip', 'Payslip Count', 'Total Paid Lifetime', 'Certs (Active/Total)', 'Warnings', ]) for r in rows: w = r['worker'] writer.writerow([ w.name, w.id_number, f'{w.monthly_salary:.2f}', 'Yes' if w.active else 'No', r['days_worked'], '; '.join(r['projects']), '; '.join(r['teams']), r['first_payslip_date'].strftime('%Y-%m-%d') if r['first_payslip_date'] else '', r['last_payslip_date'].strftime('%Y-%m-%d') if r['last_payslip_date'] else '', r['payslip_count'], f'{r["total_paid_lifetime"]:.2f}', f'{r["certs_active"]}/{r["certs_total"]}', r['warnings_count'], ]) return response @login_required def worker_batch_report_pdf(request): """PDF version of the batch worker report.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") from .utils import render_to_pdf status = request.GET.get('status') or 'all' project_id = request.GET.get('project') or None team_id = request.GET.get('team') or None rows = _build_worker_report_context(status=status, project_id=project_id, team_id=team_id) context = { 'rows': rows, 'status': status, 'project_name': ( Project.objects.get(id=project_id).name if project_id else 'All Projects' ), 'team_name': Team.objects.get(id=team_id).name if team_id else 'All Teams', 'now': timezone.now(), 'total_workers': len(rows), } pdf = render_to_pdf('core/pdf/workers_report_pdf.html', context) if pdf: response = HttpResponse(pdf, content_type='application/pdf') response['Content-Disposition'] = 'attachment; filename="worker_batch_report.pdf"' return response messages.error(request, "PDF generation failed.") return redirect('worker_batch_report') # ============================================================= # === TEAM MANAGEMENT (friendly UI — alternative to /admin/) === # ============================================================= @login_required def team_list(request): """Admin-friendly list of all teams with search + status filter.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") q = (request.GET.get('q') or '').strip() status = request.GET.get('status') or 'active' teams = Team.objects.all().select_related('supervisor') if status == 'active': teams = teams.filter(active=True) elif status == 'inactive': teams = teams.filter(active=False) if q: teams = teams.filter(name__icontains=q) # Annotate counts for the list table (Django templates can't access # attributes starting with underscore, so use a plain name). teams = teams.annotate( workers_count=Count('workers', distinct=True), ).order_by('name') context = { 'teams': teams, 'q': q, 'status': status, 'total_count': teams.count(), } return render(request, 'core/teams/list.html', context) @login_required def team_detail(request, team_id): """Read-only team profile with pay schedule, workers, and history tabs.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") team = get_object_or_404(Team.objects.select_related('supervisor'), id=team_id) # --- Workers (all, including inactive — flagged via template) --- workers = team.workers.all().order_by('-active', 'name') # --- Work history aggregates --- work_logs = team.work_logs.select_related('project').prefetch_related('workers').order_by('-date') days_worked = work_logs.values('date').distinct().count() projects_worked = ( Project.objects.filter(work_logs__team=team).distinct().order_by('name') ) recent_logs = work_logs[:10] # --- Labour cost for this team (lifetime) using the existing helper --- cost_breakdown = _get_labour_costs(work_logs, 'project__name', 'project') total_labour_cost = sum((r['total'] for r in cost_breakdown), Decimal('0.00')) # --- Pay schedule preview: current + next 2 periods (3 total) --- pay_periods = [] if team.pay_frequency and team.pay_start_date: today = datetime.date.today() current = get_pay_period(team, today) if current: pay_periods.append(current) next_ref = current[1] + datetime.timedelta(days=1) for _ in range(2): p = get_pay_period(team, next_ref) if not p: break pay_periods.append(p) next_ref = p[1] + datetime.timedelta(days=1) context = { 'team': team, 'workers': workers, 'days_worked': days_worked, 'projects_worked': projects_worked, 'recent_logs': recent_logs, 'cost_breakdown': cost_breakdown, 'total_labour_cost': total_labour_cost, 'pay_periods': pay_periods, } return render(request, 'core/teams/detail.html', context) @login_required def team_edit(request, team_id=None): """Create or edit a Team.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") team = get_object_or_404(Team, id=team_id) if team_id else None is_new = team is None if request.method == 'POST': form = TeamForm(request.POST, instance=team) if form.is_valid(): saved = form.save() action = 'added' if is_new else 'updated' messages.success(request, f'Team "{saved.name}" {action} successfully.') return redirect('team_detail', team_id=saved.id) else: form = TeamForm(instance=team) context = { 'form': form, 'team': team, 'is_new': is_new, } return render(request, 'core/teams/edit.html', context) def _build_team_report_context(status=None): """Build the per-team aggregation list used by HTML + CSV views.""" teams = Team.objects.all().select_related('supervisor') if status == 'active': teams = teams.filter(active=True) elif status == 'inactive': teams = teams.filter(active=False) teams = teams.annotate( _worker_count=Count('workers', distinct=True), _days_worked=Count('work_logs__date', distinct=True), ).order_by('name') rows = [] for t in teams: projects = list( Project.objects.filter(work_logs__team=t).distinct().values_list('name', flat=True) ) cost_breakdown = _get_labour_costs(t.work_logs.all(), 'project__name', 'project') total_cost = sum((r['total'] for r in cost_breakdown), Decimal('0.00')) rows.append({ 'team': t, 'worker_count': t._worker_count or 0, 'days_worked': t._days_worked or 0, 'projects': projects, 'total_labour_cost': total_cost, }) return rows @login_required def team_batch_report(request): """HTML table of every team with aggregated stats.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") status = request.GET.get('status') or 'all' rows = _build_team_report_context(status=status) context = { 'rows': rows, 'status': status, 'query_string': request.GET.urlencode(), 'total_teams': len(rows), } return render(request, 'core/teams/batch_report.html', context) @login_required def team_batch_report_csv(request): """CSV download of the batch team report.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") status = request.GET.get('status') or 'all' rows = _build_team_report_context(status=status) response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="team_batch_report.csv"' writer = csv.writer(response) writer.writerow([ 'Team Name', 'Supervisor', 'Active', 'Pay Frequency', 'Pay Start Date', 'Worker Count', 'Days Worked', 'Projects Worked On', 'Total Labour Cost', ]) for r in rows: t = r['team'] writer.writerow([ t.name, t.supervisor.username if t.supervisor else '', 'Yes' if t.active else 'No', t.get_pay_frequency_display() if t.pay_frequency else '', t.pay_start_date.strftime('%Y-%m-%d') if t.pay_start_date else '', r['worker_count'], r['days_worked'], '; '.join(r['projects']), f'{r["total_labour_cost"]:.2f}', ]) return response # ============================================================= # === PROJECT MANAGEMENT (friendly UI — alternative to /admin/) === # ============================================================= @login_required def project_list(request): """Admin-friendly list of all projects with search + status filter.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") q = (request.GET.get('q') or '').strip() status = request.GET.get('status') or 'active' projects = Project.objects.all().prefetch_related('supervisors') if status == 'active': projects = projects.filter(active=True) elif status == 'inactive': projects = projects.filter(active=False) if q: projects = projects.filter( Q(name__icontains=q) | Q(description__icontains=q) ) projects = projects.annotate( workers_count=Count('work_logs__workers', distinct=True), ).order_by('name') context = { 'projects': projects, 'q': q, 'status': status, 'total_count': projects.count(), } return render(request, 'core/projects/list.html', context) @login_required def project_detail(request, project_id): """Read-only project profile with supervisors, teams, workers, history tabs.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") project = get_object_or_404( Project.objects.prefetch_related('supervisors'), id=project_id, ) # --- Teams that have worked on this project --- teams_worked = ( Team.objects.filter(work_logs__project=project).distinct().order_by('name') ) # --- Workers who have worked on this project --- workers_worked = ( Worker.objects.filter(work_logs__project=project).distinct().order_by('name') ) # --- Work logs for history tab --- work_logs = project.work_logs.prefetch_related('workers', 'team').order_by('-date') days_worked = work_logs.values('date').distinct().count() recent_logs = work_logs[:10] # --- Activity date range --- date_range = work_logs.aggregate(first=Min('date'), last=Max('date')) # --- Labour cost for this project (lifetime) --- cost_breakdown = _get_labour_costs(work_logs, 'team__name', 'team') total_labour_cost = sum((r['total'] for r in cost_breakdown), Decimal('0.00')) context = { 'project': project, 'teams_worked': teams_worked, 'workers_worked': workers_worked, 'days_worked': days_worked, 'recent_logs': recent_logs, 'first_activity': date_range.get('first'), 'last_activity': date_range.get('last'), 'cost_breakdown': cost_breakdown, 'total_labour_cost': total_labour_cost, } return render(request, 'core/projects/detail.html', context) @login_required def project_edit(request, project_id=None): """Create or edit a Project.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") project = get_object_or_404(Project, id=project_id) if project_id else None is_new = project is None if request.method == 'POST': form = ProjectForm(request.POST, instance=project) if form.is_valid(): saved = form.save() action = 'added' if is_new else 'updated' messages.success(request, f'Project "{saved.name}" {action} successfully.') return redirect('project_detail', project_id=saved.id) else: form = ProjectForm(instance=project) context = { 'form': form, 'project': project, 'is_new': is_new, } return render(request, 'core/projects/edit.html', context) def _build_project_report_context(status=None): """Build per-project aggregation list used by HTML + CSV views.""" projects = Project.objects.all().prefetch_related('supervisors') if status == 'active': projects = projects.filter(active=True) elif status == 'inactive': projects = projects.filter(active=False) projects = projects.annotate( _worker_days=Count('work_logs__workers', distinct=False), _distinct_workers=Count('work_logs__workers', distinct=True), _first_date=Min('work_logs__date'), _last_date=Max('work_logs__date'), ).order_by('name') rows = [] for p in projects: teams = list( Team.objects.filter(work_logs__project=p).distinct().values_list('name', flat=True) ) supervisors = list(p.supervisors.values_list('username', flat=True)) cost_breakdown = _get_labour_costs(p.work_logs.all(), 'team__name', 'team') total_cost = sum((r['total'] for r in cost_breakdown), Decimal('0.00')) rows.append({ 'project': p, 'supervisors': supervisors, 'teams': teams, 'worker_days': p._worker_days or 0, 'distinct_workers': p._distinct_workers or 0, 'first_date': p._first_date, 'last_date': p._last_date, 'total_labour_cost': total_cost, }) return rows @login_required def project_batch_report(request): """HTML table of every project with aggregated stats.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") status = request.GET.get('status') or 'all' rows = _build_project_report_context(status=status) context = { 'rows': rows, 'status': status, 'query_string': request.GET.urlencode(), 'total_projects': len(rows), } return render(request, 'core/projects/batch_report.html', context) @login_required def project_batch_report_csv(request): """CSV download of the batch project report.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") status = request.GET.get('status') or 'all' rows = _build_project_report_context(status=status) response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="project_batch_report.csv"' writer = csv.writer(response) writer.writerow([ 'Project Name', 'Active', 'Start Date', 'End Date', 'Supervisors', 'Teams Worked', 'Distinct Workers', 'Worker-Days', 'First Activity', 'Last Activity', 'Total Labour Cost', ]) for r in rows: p = r['project'] writer.writerow([ p.name, 'Yes' if p.active else 'No', p.start_date.strftime('%Y-%m-%d') if p.start_date else '', p.end_date.strftime('%Y-%m-%d') if p.end_date else '', '; '.join(r['supervisors']), '; '.join(r['teams']), r['distinct_workers'], r['worker_days'], r['first_date'].strftime('%Y-%m-%d') if r['first_date'] else '', r['last_date'].strftime('%Y-%m-%d') if r['last_date'] else '', f'{r["total_labour_cost"]:.2f}', ]) return response # === REPORT GENERATION === # Builds a comprehensive payroll report for a given date range. # Used by both the on-screen HTML report and the PDF download. # # TERMINOLOGY (used consistently throughout report): # - "Worker-Days" = total individual worker×day entries (if 5 workers work 22 days = 110 worker-days) # - "Days Worked" (per worker) = distinct dates that specific worker was logged # - "Total Paid" = actual money transferred to a worker (net of all adjustments) # - "Loans Outstanding" = current remaining balance on active loans # - "Advances Outstanding" = current remaining balance on active advances # === REPORT LABEL MAP === # Maps internal PayrollAdjustment type names to human-readable report labels. # These are used in both the Adjustment Summary and Worker Breakdown tables. REPORT_ADJ_LABELS = { 'Bonus': 'Bonuses', 'Overtime': 'Overtime', 'Deduction': 'Deductions', 'Loan Repayment': 'Loan Repaid', 'Advance Repayment': 'Advance Repaid', 'New Loan': 'Loans Issued', 'Advance Payment': 'Advances Issued', } def _get_labour_costs(work_logs_qs, group_by_field, name_key): """ Calculate labour cost (sum of daily rates) grouped by a field. Used for project and team cost breakdowns. Args: work_logs_qs: Filtered WorkLog queryset group_by_field: Field to group by (e.g. 'project__name', 'team__name') name_key: Key name for the result dict (e.g. 'project', 'team') Returns list of dicts: [{name_key: ..., 'worker_days': ..., 'total': ...}] """ data = list( work_logs_qs .values(group_by_field) .annotate( worker_days=Count('workers'), labour_cost=Sum(F('workers__monthly_salary') / Decimal('20')) ) .filter(worker_days__gt=0) .order_by('-labour_cost') ) return [ { name_key: item[group_by_field] or 'Unknown', 'worker_days': item['worker_days'], 'total': item['labour_cost'] or Decimal('0.00'), } for item in data ] def _build_report_context(start_date, end_date, project_ids=None, team_ids=None): """ Compute all report data for the given date range and filters. Returns a dictionary of totals, breakdowns, and worker-level data. project_ids / team_ids are lists of ints (from request.GET.getlist). None or [] are treated as "no filter" — returning data for every project or every team respectively. A single-element list like [3] reproduces the old single-id behaviour (so old URLs like ?project=3 still work). Key design decision: "Worker-Days" counts total worker×log entries (not distinct calendar dates). This correlates correctly with cost — if 5 workers work 22 days, that's 110 worker-days, and cost / worker-days ≈ average daily rate. """ # --- Base filters --- date_filter = Q(date__gte=start_date, date__lte=end_date) # --- PayrollRecords in range --- # # IMPORTANT — avoid M2M double-JOIN inflation: # Chaining `.filter(work_logs__project_id__in=X).distinct().filter(work_logs__team_id__in=Y)` # creates TWO separate JOIN aliases on core_payrollrecord_work_logs. Any # later `.values().annotate(Sum())` then aggregates across the cartesian # product of matching rows, inflating per-worker and per-date totals by # N × M (where N and M are the counts of matching logs per record). # `.aggregate(Sum())` is safe because Django wraps it in a subquery when # distinct() is in play, but `.values().annotate(Sum())` isn't — so we # use id__in subqueries to keep the outer queryset JOIN-free. # See ReportContextFilterInflationTests for regression coverage. records = PayrollRecord.objects.filter(date_filter) if project_ids: records = records.filter( id__in=PayrollRecord.objects.filter( work_logs__project_id__in=project_ids ).values('id') ) if team_ids: records = records.filter( id__in=PayrollRecord.objects.filter( work_logs__team_id__in=team_ids ).values('id') ) # --- Total Paid Out (sum of all payments made) --- total_paid_out = records.aggregate(total=Sum('amount_paid'))['total'] or Decimal('0.00') # --- Payments by Date (total paid per day) --- payments_by_date = ( records.values('date') .annotate(total=Sum('amount_paid')) .order_by('date') ) # --- Adjustments in range --- # project_ids filters via an FK column (no JOIN inflation risk), but # team_ids goes through worker__teams M2M — apply the same subquery # pattern as above to keep adj_by_type's values().annotate(Sum()) safe. adjustments = PayrollAdjustment.objects.filter(date_filter) if project_ids: adjustments = adjustments.filter(project_id__in=project_ids) if team_ids: adjustments = adjustments.filter( worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id') ) # --- Work Logs in range (for calculating actual labour cost) --- work_logs_qs = WorkLog.objects.filter(date__gte=start_date, date__lte=end_date) if project_ids: work_logs_qs = work_logs_qs.filter(project_id__in=project_ids) if team_ids: work_logs_qs = work_logs_qs.filter(team_id__in=team_ids) # Total worker-days across all work logs (counts M2M worker entries) total_worker_days = work_logs_qs.aggregate( total=Count('workers'))['total'] or 0 # --- Labour Cost by Project (selected period) --- # Uses daily rates (monthly_salary / 20) for the TRUE cost per project cost_per_project = _get_labour_costs(work_logs_qs, 'project__name', 'project') # --- Labour Cost by Team (selected period) --- cost_per_team = _get_labour_costs( work_logs_qs.filter(team__isnull=False), 'team__name', 'team' ) # --- ALL TIME: project and team costs since the very first work log --- all_time_logs = WorkLog.objects.all() if project_ids: all_time_logs = all_time_logs.filter(project_id__in=project_ids) if team_ids: all_time_logs = all_time_logs.filter(team_id__in=team_ids) # === CHAPTER I — All Time Projects (enriched) === # Adds working_days and avg_per_working_day (the 2026-04-23 design). # Can't just extend _get_labour_costs because that helper is used by # other sections with different columns. Wrap it here instead. alltime_projects_raw = _get_labour_costs(all_time_logs, 'project__name', 'project') # Build a lookup of working_days per project (distinct work-log dates) project_working_days = dict( all_time_logs.filter(project__isnull=False) .values('project_id', 'project__name') .annotate(days=Count('date', distinct=True)) .values_list('project__name', 'days') ) # Lookup project start_date from the Project model (authoritative source) start_dates = dict( Project.objects.values_list('name', 'start_date') ) alltime_projects = [] for row in alltime_projects_raw: name = row['project'] wdays = project_working_days.get(name, 0) total = row['total'] or Decimal('0.00') avg = (total / wdays).quantize(Decimal('0.01')) if wdays else Decimal('0.00') alltime_projects.append({ 'project': name, 'worker_days': row['worker_days'], 'total': total, 'start_date': start_dates.get(name), # may be None 'working_days': wdays, 'avg_per_working_day': avg, }) alltime_teams = _get_labour_costs( all_time_logs.filter(team__isnull=False), 'team__name', 'team' ) # --- THIS YEAR: project and team costs for the current calendar year --- current_year = timezone.now().year year_start = datetime.date(current_year, 1, 1) year_end = datetime.date(current_year, 12, 31) year_logs = WorkLog.objects.filter(date__gte=year_start, date__lte=year_end) if project_ids: year_logs = year_logs.filter(project_id__in=project_ids) if team_ids: year_logs = year_logs.filter(team_id__in=team_ids) year_projects = _get_labour_costs(year_logs, 'project__name', 'project') year_teams = _get_labour_costs( year_logs.filter(team__isnull=False), 'team__name', 'team' ) # --- Loans & Advances Outstanding (current balances) --- # team filter goes through worker__teams (M2M). Use the subquery pattern # (CLAUDE.md Django ORM gotcha) so we don't pick up JOIN inflation on the # aggregate. active_loans = Loan.objects.filter(active=True, date__lte=end_date) if team_ids: active_loans = active_loans.filter( worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id') ) loans_outstanding = active_loans.filter(loan_type='loan').aggregate( total=Sum('remaining_balance'))['total'] or Decimal('0.00') advances_outstanding = active_loans.filter(loan_type='advance').aggregate( total=Sum('remaining_balance'))['total'] or Decimal('0.00') # --- Loans & Advances Issued This Period --- loans_issued_qs = Loan.objects.filter(date_filter, loan_type='loan') advances_issued_qs = Loan.objects.filter(date_filter, loan_type='advance') if team_ids: team_worker_ids = Worker.objects.filter(teams__id__in=team_ids).values('id') loans_issued_qs = loans_issued_qs.filter(worker__in=team_worker_ids) advances_issued_qs = advances_issued_qs.filter(worker__in=team_worker_ids) loans_issued = loans_issued_qs.aggregate( total=Sum('principal_amount'))['total'] or Decimal('0.00') advances_issued = advances_issued_qs.aggregate( total=Sum('principal_amount'))['total'] or Decimal('0.00') # --- Adjustment Summary --- # Group by type, use readable labels, and sort by logical grouping adj_by_type = ( adjustments.values('type') .annotate(total=Sum('amount')) .order_by('type') ) adjustment_totals = [ { 'type': item['type'], 'label': REPORT_ADJ_LABELS.get(item['type'], item['type']), 'total': item['total'], } for item in adj_by_type ] # --- Determine which adjustment types appear (for worker table columns) --- # Only types with non-zero totals get a column — keeps the table readable active_adj_types = list( adjustments.values_list('type', flat=True).distinct().order_by('type') ) # Create matching readable labels for column headers active_adj_labels = [REPORT_ADJ_LABELS.get(t, t) for t in active_adj_types] # --- Worker Breakdown --- # Per worker: days worked, total paid, and each adjustment type worker_records = ( records.values('worker__id', 'worker__name') .annotate(total_paid=Sum('amount_paid')) .order_by('worker__name') ) # Days worked per worker = distinct dates they appear in work logs days_per_worker = dict( work_logs_qs.values('workers__id') .annotate(days=Count('date', distinct=True)) .values_list('workers__id', 'days') ) worker_breakdown = [] for wr in worker_records: w_adjs = adjustments.filter(worker_id=wr['worker__id']) # Per-type amounts for this worker (only for types that exist in the period) adj_values = [] for adj_type in active_adj_types: amt = w_adjs.filter(type=adj_type).aggregate( t=Sum('amount'))['t'] or Decimal('0.00') adj_values.append(amt) worker_breakdown.append({ 'name': wr['worker__name'], 'total_paid': wr['total_paid'], 'days': days_per_worker.get(wr['worker__id'], 0), 'adj_values': adj_values, }) # === Hero KPI band data (executive report v2) === # Small helpers that power the new hero band at the top of the report. # Kept separate so the big return dict stays easy to scan. _cv = _company_cost_velocity() return { 'start_date': start_date, 'end_date': end_date, 'project_name': ( ', '.join( Project.objects.filter(id__in=project_ids).values_list('name', flat=True) ) if project_ids else 'All Projects' ), 'team_name': ( ', '.join( Team.objects.filter(id__in=team_ids).values_list('name', flat=True) ) if team_ids else 'All Teams' ), # --- Summary --- 'total_paid_out': total_paid_out, 'total_worker_days': total_worker_days, 'loans_outstanding': loans_outstanding, 'advances_outstanding': advances_outstanding, 'loans_issued': loans_issued, 'advances_issued': advances_issued, # --- All Time & Year context --- 'alltime_projects': alltime_projects, 'alltime_teams': alltime_teams, 'current_year': current_year, 'year_projects': year_projects, 'year_teams': year_teams, # --- Selected Period tables --- 'payments_by_date': payments_by_date, 'cost_per_project': cost_per_project, 'cost_per_team': cost_per_team, 'adjustment_totals': adjustment_totals, 'active_adj_types': active_adj_types, 'active_adj_labels': active_adj_labels, 'worker_breakdown': worker_breakdown, # --- Hero KPI band (executive report v2) --- 'current_outstanding': _current_outstanding_in_scope( project_ids=project_ids, team_ids=team_ids ), 'current_as_of': timezone.now(), 'company_avg_daily': _cv['avg_daily'], 'company_avg_monthly': _cv['avg_monthly'], 'company_working_days': _cv['working_days'], 'team_project_activity': _team_project_activity(work_logs_qs), } def _parse_report_dates(request): """ Parse report date range from GET params. Supports two modes: - "from_month" + "to_month" params (e.g. "2026-01" to "2026-03") → Jan 1 to Mar 31 - "start_date" + "end_date" params → custom range Also supports legacy "month" param for backward compatibility. Returns (start_date, end_date) as date objects, or (None, None) if invalid. """ from_month_str = request.GET.get('from_month', '').strip() to_month_str = request.GET.get('to_month', '').strip() start_str = request.GET.get('start_date', '').strip() end_str = request.GET.get('end_date', '').strip() # Legacy single month param month_str = request.GET.get('month', '').strip() if from_month_str: # Multi-month mode: from_month → first day, to_month → last day try: fy, fm = map(int, from_month_str.split('-')) start_date = datetime.date(fy, fm, 1) # If to_month is missing, use same as from_month (single month) if to_month_str: ty, tm = map(int, to_month_str.split('-')) else: ty, tm = fy, fm last_day = cal_module.monthrange(ty, tm)[1] end_date = datetime.date(ty, tm, last_day) return start_date, end_date except (ValueError, TypeError): return None, None elif month_str: # Legacy single month mode try: year, month = map(int, month_str.split('-')) start_date = datetime.date(year, month, 1) last_day = cal_module.monthrange(year, month)[1] end_date = datetime.date(year, month, last_day) return start_date, end_date except (ValueError, TypeError): return None, None elif start_str and end_str: # Custom range mode try: return datetime.date.fromisoformat(start_str), datetime.date.fromisoformat(end_str) except ValueError: return None, None return None, None @login_required def generate_report(request): """Render on-screen payroll report with filters from GET params.""" if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") # Parse dates — supports both "month" and "start_date/end_date" params start_date, end_date = _parse_report_dates(request) # Multi-value: ?project=1&project=2 comes in as getlist ['1','2']. # Cast to ints; drop empties. None if list is empty (= "no filter"). def _ids(name): return [int(v) for v in request.GET.getlist(name) if v.strip().isdigit()] project_ids = _ids('project') or None team_ids = _ids('team') or None if not start_date or not end_date: messages.error(request, "Please select a month or provide start and end dates.") return redirect('home') # Build report data using shared helper context = _build_report_context( start_date, end_date, project_ids=project_ids, team_ids=team_ids, ) # Pass the raw query params so the "Download PDF" button can use them context['query_string'] = request.GET.urlencode() # === FILTER PILL CLEAR LINKS === # For the filter-pill x buttons: rebuild the querystring with one filter removed. # QueryDict.pop() only removes the first occurrence, so for multi-value keys # (e.g. project=1&project=2) we follow up with setlist(key, []) to strip them all. def _qs_without(key): qd = request.GET.copy() qd.pop(key, None) qd.setlist(key, []) return qd.urlencode() context['query_string_without_project'] = _qs_without('project') context['query_string_without_team'] = _qs_without('team') # Pass projects and teams so the "New Report" modal's dropdowns can # populate (same lists the Dashboard modal uses) context['projects'] = Project.objects.all().order_by('name') context['teams'] = Team.objects.all().order_by('name') # For the modal's ' '

' '

' '

' ' Cancel

' '' )