from calendar import monthrange from datetime import date, timedelta from decouple import config from django.contrib.auth import get_user_model from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import generics, status from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from .models import TimeEntry from .serializers import ( TimeEntryCreateSerializer, TimeEntrySerializer, TimeEntryUpdateSerializer, WeeklySummarySerializer, ) from .services import lock_user, save_period from users.permissions import is_admin, require_management_permission from planning.services import effective_day, load_plan User = get_user_model() def planned_minutes_for_period(user_id, start, end): """Total planifié sur la période. Les objectifs hebdomadaires n'imposent aucun jour précis : pour les statistiques, leur total est réparti uniformément sur les sept jours afin qu'un jour, un mois et une année restent comparables et additionnables. """ plan = load_plan(user_id) total = 0.0 day = start while day <= end: planned = effective_day(user_id, day, plan) if planned.get('schedule_mode') == 'weekly_hours': total += float(planned.get('week_target_minutes') or 0) / 7 else: total += float(planned.get('target_minutes') or 0) day += timedelta(days=1) return round(total) def visible_entries(user): entries = TimeEntry.objects.select_related('user') return entries if user.is_staff or user.is_superuser else entries.filter(user=user) class TimeEntryListView(generics.ListAPIView): serializer_class = TimeEntrySerializer permission_classes = [IsAuthenticated] def get_queryset(self): return visible_entries(self.request.user) class TimeEntryCreateView(generics.CreateAPIView): serializer_class = TimeEntryCreateSerializer permission_classes = [IsAuthenticated] def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.validated_data.get('user', request.user) if user != request.user and not (request.user.is_staff or request.user.is_superuser): raise PermissionDenied('Vous ne pouvez pas créer un pointage pour un autre utilisateur.') entry = serializer.save(user=user) return Response(TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED) class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView): serializer_class = TimeEntrySerializer permission_classes = [IsAuthenticated] lookup_url_kwarg = 'entry_id' def get_queryset(self): return visible_entries(self.request.user) @transaction.atomic def update(self, request, *args, **kwargs): entry = self.get_object() lock_user(entry.user) entry = self.get_queryset().select_for_update().get(pk=entry.pk) if entry.group_id: raise ValidationError({'detail': 'Modifiez le pointage entier avec la route groups.'}) serializer = TimeEntryUpdateSerializer( entry, data=request.data, partial=True, ) serializer.is_valid(raise_exception=True) result = serializer.save() return Response(TimeEntrySerializer(result).data) @transaction.atomic def destroy(self, request, *args, **kwargs): entry = self.get_object() lock_user(entry.user) entry = self.get_queryset().select_for_update().get(pk=entry.pk) if entry.group_id: raise ValidationError({'detail': 'Supprimez le pointage entier avec la route groups.'}) entry.delete() return Response(status=status.HTTP_204_NO_CONTENT) class TimeEntryUpdateView(TimeEntryDetailView): # Alias conservé pour les anciens clients, avec les mêmes validations. http_method_names = ['put', 'patch', 'options'] class TimeEntryGroupView(APIView): permission_classes = [IsAuthenticated] def get_entries(self, request, group_id): queryset = visible_entries(request.user).filter(group_id=group_id) first = queryset.order_by('start_date', 'start_time').first() if first is None: from rest_framework.exceptions import NotFound raise NotFound() lock_user(first.user) entries = list(queryset.filter(user=first.user).select_for_update().order_by('start_date', 'start_time')) if not entries: from rest_framework.exceptions import NotFound raise NotFound() return entries @transaction.atomic def patch(self, request, group_id): entries = self.get_entries(request, group_id) serializer = TimeEntryUpdateSerializer( entries[0], data=request.data, partial=True, context={'entries': entries}, ) serializer.is_valid(raise_exception=True) result = serializer.save() return Response(TimeEntrySerializer(result).data) put = patch @transaction.atomic def delete(self, request, group_id): entries = self.get_entries(request, group_id) TimeEntry.objects.filter(pk__in=[entry.pk for entry in entries]).delete() return Response(status=status.HTTP_204_NO_CONTENT) class TimeEntryScanView(APIView): permission_classes = [IsAuthenticated] def parse_time(self, value, default): if value is None: return default from rest_framework import serializers return serializers.TimeField(input_formats=['%H:%M', '%H:%M:%S']).run_validation(value) def conflict(self, kind, resolution, message, selected_time, today, entry=None): return Response({ 'error': message, 'message': message, 'conflict': kind, 'resolution': resolution, 'missing_time': 'end_time' if kind == 'open_entry' else 'start_time', 'scan_time': selected_time.strftime('%H:%M'), 'scan_date': today.isoformat(), 'entry_date': entry.start_date.isoformat() if entry else None, 'entry_id': entry.pk if entry else None, }, status=status.HTTP_409_CONFLICT) @transaction.atomic def post(self, request): code = str(request.data.get('code') or '').strip().strip('\"\'') if '=' in code: code = code.split('=', 1)[1].strip().strip('\"\'') enter_code = config('SECRET_POINTAGE_ENTER', default='').strip().strip('\"\'') exit_code = config('SECRET_POINTAGE_EXIT', default='').strip().strip('\"\'') if not enter_code or not exit_code or enter_code == exit_code: return Response({'error': 'Les codes de pointage ne sont pas configurés.'}, status=503) if code not in (enter_code, exit_code): raise ValidationError({'error': 'QR code de pointage invalide.'}) action = 'enter' if code == enter_code else 'exit' now = timezone.localtime() today = now.date() selected_time = self.parse_time(request.data.get('time'), now.time().replace(second=0, microsecond=0)) resolution = request.data.get('resolution') if resolution not in (None, 'complete_exit', 'complete_missing_entry'): raise ValidationError({'error': 'Résolution de conflit inconnue.'}) if resolution and request.data.get('scan_date') != today.isoformat(): raise ValidationError({'error': 'Le jour a changé. Scannez à nouveau le code.'}) correction_date = today if resolution: from rest_framework import serializers correction_date = serializers.DateField().run_validation( request.data.get('correction_date', today.isoformat()) ) if correction_date > today: raise ValidationError({'correction_date': 'La date corrigée ne peut pas être dans le futur.'}) lock_user(request.user) open_entries = list(TimeEntry.objects.select_for_update().filter( user=request.user, end_time__isnull=True, ).order_by('start_date', 'start_time')) if len(open_entries) > 1: raise ValidationError({'error': 'Plusieurs entrées sont ouvertes. Corrigez les pointages avant de scanner.'}) open_entry = open_entries[0] if open_entries else None if resolution == 'complete_missing_entry': if action != 'exit' or open_entry: raise ValidationError({'error': 'La situation a changé. Scannez à nouveau le code.'}) scan_time = self.parse_time(request.data.get('scan_time'), None) if scan_time is None: raise ValidationError({'scan_time': 'L’heure de sortie scannée est requise.'}) entry = save_period(request.user, dict( start_date=today, end_date=today, start_time=selected_time, end_time=scan_time, )) action = 'exit' elif resolution == 'complete_exit' or action == 'exit': if not open_entry: if resolution: raise ValidationError({'error': 'Aucune entrée ouverte à compléter.'}) return self.conflict('missing_entry', 'complete_missing_entry', 'Aucune entrée ouverte. Renseignez l’heure d’entrée.', selected_time, today) if resolution and str(request.data.get('entry_id')) != str(open_entry.pk): raise ValidationError({'error': 'Le pointage a changé. Scannez à nouveau le code.'}) if correction_date < open_entry.start_date: raise ValidationError({'correction_date': "La sortie ne peut pas être antérieure à l’entrée."}) entry = save_period(request.user, dict( start_date=open_entry.start_date, start_time=open_entry.start_time, end_date=correction_date, end_time=selected_time, ), [open_entry]) action = 'exit' else: if open_entry: return self.conflict('open_entry', 'complete_exit', 'Une entrée est déjà ouverte. Voulez-vous enregistrer une sortie ?', selected_time, today, open_entry) entry = save_period(request.user, dict( start_date=today, end_date=today, start_time=selected_time, end_time=None, )) return Response({ 'action': action, 'message': 'Entrée enregistrée.' if action == 'enter' else 'Sortie enregistrée.', 'entry': TimeEntrySerializer(entry).data, }) def build_weekly_summary(user): today = timezone.localdate() start_of_week = today - timedelta(days=today.weekday()) entries = list(TimeEntry.objects.filter( user=user, start_date__range=(start_of_week, start_of_week + timedelta(days=6)), ).order_by('start_date', 'start_time')) days_data = [] for offset in range(7): day = start_of_week + timedelta(days=offset) day_entries = [entry for entry in entries if entry.start_date == day] completed = [entry for entry in day_entries if entry.end_time is not None] days_data.append({ 'date': day.isoformat(), 'day': day.strftime('%A'), 'start_time': str(day_entries[0].start_time) if day_entries else None, 'end_time': str(max(entry.end_time for entry in completed)) if completed and len(completed) == len(day_entries) else None, 'duration': sum(entry.duration or 0 for entry in day_entries), 'entries_count': len(day_entries), }) return dict(user=user.username, username=user.username, total_hours=round(sum(entry.duration or 0 for entry in entries), 2), entries=days_data) class WeeklySummaryView(APIView): permission_classes = [IsAuthenticated] def get(self, request, user_id=None): user = request.user if user_id is not None and user_id != user.pk: if not (user.is_staff or user.is_superuser): raise PermissionDenied('Vous ne pouvez pas consulter ce résumé.') user = get_object_or_404(User, pk=user_id) return Response(WeeklySummarySerializer(build_weekly_summary(user)).data) class TimeStatsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): period = request.query_params.get('period', 'month') if period not in ('day', 'month', 'year'): raise ValidationError({'period': 'Vue inconnue. Choisissez jour, mois ou année.'}) anchor_raw = request.query_params.get('anchor') or timezone.localdate().isoformat() try: anchor = date.fromisoformat(anchor_raw[:10]) except (TypeError, ValueError): raise ValidationError({'anchor': 'Date invalide.'}) raw_user_id = request.query_params.get('user_id') or request.user.pk try: user_id = int(raw_user_id) except (TypeError, ValueError): raise ValidationError({'user_id': 'Utilisateur invalide.'}) user = get_object_or_404(User, pk=user_id, is_active=True) if user.pk != request.user.pk and not is_admin(request.user): require_management_permission( request.user, 'view_statistics', user.pk, 'Vous n’avez pas l’autorisation de consulter les statistiques de ce salarié.', ) if period == 'day': start = end = anchor elif period == 'month': start = anchor.replace(day=1) end = anchor.replace(day=monthrange(anchor.year, anchor.month)[1]) else: start = date(anchor.year, 1, 1) end = date(anchor.year, 12, 31) entries = list( TimeEntry.objects.filter(user=user, start_date__range=(start, end)) .order_by('start_date', 'start_time', 'pk') ) total_minutes = 0 sunday_minutes = 0 sunday_dates = set() incomplete = 0 logical_trips = set() daily = {} for entry in entries: duration = entry.duration minutes = round(duration * 60) if duration is not None else 0 day = entry.start_date row = daily.setdefault(day, {'minutes': 0, 'trip_keys': set(), 'incomplete': 0}) if duration is None: incomplete += 1 row['incomplete'] += 1 continue total_minutes += minutes row['minutes'] += minutes trip_key = f'g:{entry.group_id}' if entry.group_id else f'e:{entry.pk}' logical_trips.add(trip_key) row['trip_keys'].add(trip_key) if day.weekday() == 6: sunday_minutes += minutes sunday_dates.add(day) planned_minutes = planned_minutes_for_period(user.pk, start, end) breakdown = [ { 'date': day.isoformat(), 'minutes': row['minutes'], 'round_trips': len(row['trip_keys']), 'incomplete_entries': row['incomplete'], 'is_sunday': day.weekday() == 6, } for day, row in sorted(daily.items()) ] return Response({ 'user': { 'id': user.pk, 'username': user.username, 'first_name': user.first_name, 'last_name': user.last_name, 'color': user.color, }, 'period': period, 'anchor': anchor.isoformat(), 'start': start.isoformat(), 'end': end.isoformat(), 'total_minutes': total_minutes, 'planned_minutes': planned_minutes, 'round_trips': len(logical_trips), 'sundays_worked': len(sunday_dates), 'sunday_minutes': sunday_minutes, 'incomplete_entries': incomplete, 'breakdown': breakdown, }) class MonthlyRaceView(APIView): permission_classes = [IsAuthenticated] def get(self, request): current_month_start = timezone.localdate().replace(day=1) end = current_month_start - timedelta(days=1) start = end.replace(day=1) days_count = end.day users = list( User.objects.filter(is_active=True) .order_by('first_name', 'last_name', 'username', 'pk') ) totals = {user.pk: 0 for user in users} daily_totals = {user.pk: [0] * days_count for user in users} entries = TimeEntry.objects.filter( user_id__in=totals.keys(), start_date__range=(start, end), ).only( 'id', 'user_id', 'start_date', 'start_time', 'end_date', 'end_time', 'group_id', ) for entry in entries: duration = entry.duration if duration is None: continue minutes = round(duration * 60) totals[entry.user_id] = totals.get(entry.user_id, 0) + minutes day_index = entry.start_date.day - 1 if 0 <= day_index < days_count: daily_totals[entry.user_id][day_index] += minutes rows = [ { 'id': user.pk, 'username': user.username, 'first_name': user.first_name, 'last_name': user.last_name, 'color': user.color, 'total_minutes': totals.get(user.pk, 0), 'daily_minutes': daily_totals.get(user.pk, [0] * days_count), } for user in users ] rows.sort(key=lambda row: ( -row['total_minutes'], (row['first_name'] or '').lower(), (row['last_name'] or '').lower(), (row['username'] or '').lower(), row['id'], )) for index, row in enumerate(rows, start=1): row['rank'] = index return Response({ 'start': start.isoformat(), 'end': end.isoformat(), 'month': start.strftime('%Y-%m'), 'days_count': days_count, 'participants': rows, })