from datetime import timedelta from datetime import time as datetime_time from decouple import config from django.conf import settings from django.contrib.auth import get_user_model from django.db import transaction from django.utils import timezone from rest_framework import generics, status 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, ) User = get_user_model() class TimeEntryListView(generics.ListAPIView): serializer_class = TimeEntrySerializer permission_classes = [IsAuthenticated] def get_queryset(self): user = self.request.user if user.is_staff or user.is_superuser: return TimeEntry.objects.all() return TimeEntry.objects.filter( user=user, ) class TimeEntryCreateView(generics.CreateAPIView): serializer_class = TimeEntryCreateSerializer permission_classes = [IsAuthenticated] def perform_create(self, serializer): requested_user = serializer.validated_data.get('user') if requested_user is not None: if not ( self.request.user.is_staff or self.request.user.is_superuser ): from rest_framework.exceptions import PermissionDenied raise PermissionDenied( "Vous ne pouvez pas créer un pointage pour un autre utilisateur." ) self.created_entry = serializer.save( user=requested_user, ) else: self.created_entry = serializer.save( user=self.request.user, ) def create(self, request, *args, **kwargs): serializer = self.get_serializer( data=request.data, ) serializer.is_valid( raise_exception=True, ) self.perform_create(serializer) output_serializer = TimeEntrySerializer( self.created_entry, context={ 'request': request, }, ) headers = self.get_success_headers( serializer.data, ) return Response( output_serializer.data, status=status.HTTP_201_CREATED, headers=headers, ) class TimeEntryScanView(APIView): permission_classes = [IsAuthenticated] def _parse_code(self, raw_code): code = str( raw_code or '' ).strip().strip('"\'"') if '=' in code: code = ( code .split('=', 1)[1] .strip() .strip('"\'"') ) return code def _parse_time(self, value): if not value: now = timezone.localtime() return now.time().replace( second=0, microsecond=0, ) try: return datetime_time.fromisoformat( str(value)[:5], ) except ValueError: return None def _serialize_entry(self, entry, request): return TimeEntrySerializer( entry, context={ 'request': request, }, ).data def post(self, request): code = self._parse_code( request.data.get('code'), ) enter_code = config( 'SECRET_POINTAGE_ENTER', default='', ).strip().strip('"\'"') exit_code = config( 'SECRET_POINTAGE_EXIT', default='', ).strip().strip('"\'"') if code == enter_code: action = 'enter' elif code == exit_code: action = 'exit' else: return Response( { 'error': 'QR code de pointage invalide.' }, status=status.HTTP_400_BAD_REQUEST, ) selected_time = self._parse_time( request.data.get('time'), ) if selected_time is None: return Response( { 'error': 'Heure invalide.' }, status=status.HTTP_400_BAD_REQUEST, ) resolution = request.data.get( 'resolution', ) # Heure réelle du scan. # # Lors d'une résolution d'un conflit "sortie sans entrée", # on utilise scan_time comme heure de sortie et "time" # comme heure d'entrée. scan_time_value = request.data.get( 'scan_time', ) scan_time = self._parse_time( scan_time_value, ) if scan_time is None: scan_time = selected_time now = timezone.localtime() today = now.date() with transaction.atomic(): # ===================================================== # RÉSOLUTION : entrée déjà ouverte # ===================================================== if resolution == 'complete_exit': open_entry = ( TimeEntry.objects .select_for_update() .filter( user=request.user, start_time__isnull=False, end_time__isnull=True, ) .order_by( '-date', '-start_time', ) .first() ) if not open_entry: return Response( { 'error': 'Aucune entrée ouverte à compléter.' }, status=status.HTTP_400_BAD_REQUEST, ) if selected_time <= open_entry.start_time: return Response( { 'error': 'L’heure de sortie doit être après l’entrée.' }, status=status.HTTP_400_BAD_REQUEST, ) open_entry.end_time = selected_time open_entry.save( update_fields=[ 'end_time', 'updated_at', ], ) return Response( { 'action': 'exit', 'message': 'Sortie enregistrée.', 'entry': self._serialize_entry( open_entry, request, ), } ) # ===================================================== # RÉSOLUTION : sortie scannée sans entrée # ===================================================== if resolution == 'complete_missing_entry': missing_entry_time = selected_time exit_time = scan_time if missing_entry_time >= exit_time: return Response( { 'error': "L'heure d'entrée doit être avant l'heure de sortie." }, status=status.HTTP_400_BAD_REQUEST, ) entry = TimeEntry.objects.create( user=request.user, date=today, start_time=missing_entry_time, end_time=exit_time, ) return Response( { 'action': 'exit', 'message': 'Entrée et sortie enregistrées.', 'entry': self._serialize_entry( entry, request, ), } ) # ===================================================== # RÉSOLUTION : sortie existante sans entrée # ===================================================== if resolution == 'complete_open_exit': open_exit = ( TimeEntry.objects .select_for_update() .filter( user=request.user, start_time__isnull=True, end_time__isnull=False, ) .order_by( '-date', '-end_time', ) .first() ) if not open_exit: return Response( { 'error': 'Aucune sortie incomplète à compléter.' }, status=status.HTTP_400_BAD_REQUEST, ) if selected_time >= open_exit.end_time: return Response( { 'error': "L'heure d'entrée doit être avant la sortie." }, status=status.HTTP_400_BAD_REQUEST, ) open_exit.start_time = selected_time open_exit.save( update_fields=[ 'start_time', 'updated_at', ], ) return Response( { 'action': 'enter', 'message': 'Entrée enregistrée.', 'entry': self._serialize_entry( open_exit, request, ), } ) # ===================================================== # POINTAGE EXISTANT DU JOUR # ===================================================== open_entry = ( TimeEntry.objects .select_for_update() .filter( user=request.user, date=today, start_time__isnull=False, end_time__isnull=True, ) .order_by( '-start_time', ) .first() ) open_exit = ( TimeEntry.objects .select_for_update() .filter( user=request.user, date=today, start_time__isnull=True, end_time__isnull=False, ) .order_by( '-end_time', ) .first() ) # ===================================================== # QR ENTRÉE # ===================================================== if action == 'enter': # Une entrée est déjà ouverte. # # IMPORTANT : # on ne supprime plus l'entrée si l'utilisateur # refuse. Le frontend demande d'abord confirmation. if open_entry: return Response( { 'error': ( 'Une entrée est déjà ouverte. ' 'Voulez-vous enregistrer une sortie ?' ), 'conflict': 'open_entry', 'resolution': 'complete_exit', 'missing_time': 'end_time', 'message': ( 'Une entrée est déjà ouverte. ' 'Voulez-vous enregistrer une sortie ?' ), 'scan_time': selected_time.strftime( '%H:%M' ), }, status=status.HTTP_409_CONFLICT, ) # Une sortie existe sans entrée. if open_exit: return Response( { 'error': ( 'Une sortie existe déjà sans entrée.' ), 'conflict': 'open_exit', 'resolution': 'complete_open_exit', 'missing_time': 'start_time', 'message': ( 'Une sortie existe déjà sans entrée. ' 'Voulez-vous renseigner l’heure d’entrée ?' ), }, status=status.HTTP_409_CONFLICT, ) entry = TimeEntry.objects.create( user=request.user, date=today, start_time=selected_time, ) return Response( { 'action': 'enter', 'message': 'Entrée enregistrée.', 'entry': self._serialize_entry( entry, request, ), } ) # ===================================================== # QR SORTIE # ===================================================== # Entrée ouverte aujourd'hui : # sortie normale. if open_entry: if selected_time <= open_entry.start_time: return Response( { 'error': ( "L'heure de sortie doit être " "après l'entrée." ) }, status=status.HTTP_400_BAD_REQUEST, ) open_entry.end_time = selected_time open_entry.save( update_fields=[ 'end_time', 'updated_at', ], ) return Response( { 'action': 'exit', 'message': 'Sortie enregistrée.', 'entry': self._serialize_entry( open_entry, request, ), } ) # ===================================================== # SORTIE SANS ENTRÉE # ===================================================== if open_exit: return Response( { 'error': ( 'Une sortie existe déjà sans entrée.' ), 'conflict': 'open_exit', 'resolution': 'complete_open_exit', 'missing_time': 'start_time', 'message': ( 'Une sortie existe déjà sans entrée. ' 'Voulez-vous renseigner l’heure d’entrée ?' ), }, status=status.HTTP_409_CONFLICT, ) # ===================================================== # POINTAGE OUVERT SUR UN JOUR PRÉCÉDENT # ===================================================== previous_open_entry = ( TimeEntry.objects .select_for_update() .filter( user=request.user, start_time__isnull=False, end_time__isnull=True, ) .exclude( date=today, ) .order_by( '-date', '-start_time', ) .first() ) if previous_open_entry: last_date = previous_open_entry.date group_id = ( previous_open_entry.group_id or previous_open_entry.id ) # Si l'ancien pointage n'avait pas encore de groupe, # on lui attribue un UUID basé sur un nouvel objet. if not previous_open_entry.group_id: import uuid group_id = uuid.uuid4() previous_open_entry.group_id = group_id previous_open_entry.end_time = datetime_time( 23, 59, 59, ) previous_open_entry.save( update_fields=[ 'group_id', 'end_time', 'updated_at', ], ) current_date = ( last_date + timedelta(days=1) ) while current_date < today: TimeEntry.objects.create( user=request.user, group_id=group_id, date=current_date, start_time=datetime_time.min, end_time=datetime_time( 23, 59, 59, ), ) current_date += timedelta( days=1, ) entry = TimeEntry.objects.create( user=request.user, group_id=group_id, date=today, end_time=selected_time, ) return Response( { 'action': 'exit', 'message': 'Sortie enregistrée sur plusieurs jours.', 'entry': self._serialize_entry( entry, request, ), } ) # ===================================================== # AUCUNE ENTRÉE # # On NE crée rien immédiatement. # Le frontend demande d'abord confirmation. # ===================================================== return Response( { 'error': ( 'Aucune entrée ouverte. ' 'Voulez-vous enregistrer une sortie ?' ), 'conflict': 'missing_entry', 'resolution': 'complete_missing_entry', 'missing_time': 'start_time', 'message': ( 'Aucune entrée ouverte. ' 'Voulez-vous enregistrer une sortie ?' ), 'scan_time': selected_time.strftime( '%H:%M' ), }, status=status.HTTP_409_CONFLICT, ) class TimeEntryDetailView( generics.RetrieveUpdateDestroyAPIView ): serializer_class = TimeEntrySerializer permission_classes = [IsAuthenticated] lookup_url_kwarg = 'entry_id' def get_queryset(self): user = self.request.user if user.is_staff or user.is_superuser: return TimeEntry.objects.all() return TimeEntry.objects.filter( user=user, ) class TimeEntryUpdateView(APIView): permission_classes = [IsAuthenticated] def put(self, request, entry_id): try: entry = TimeEntry.objects.get( id=entry_id, ) except TimeEntry.DoesNotExist: return Response( { 'error': 'Pointage non trouvé' }, status=status.HTTP_404_NOT_FOUND, ) if not ( request.user.is_staff or request.user.is_superuser or entry.user == request.user ): return Response( { 'error': 'Accès refusé' }, status=status.HTTP_403_FORBIDDEN, ) serializer = TimeEntryUpdateSerializer( entry, data=request.data, ) if serializer.is_valid(): serializer.save() return Response( serializer.data, ) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST, ) def build_weekly_summary(user): today = timezone.localdate() start_of_week = ( today - timedelta( days=today.weekday(), ) ) end_of_week = ( start_of_week + timedelta(days=6) ) entries = list( TimeEntry.objects.filter( user=user, date__range=( start_of_week, end_of_week, ), ).order_by( 'date', 'start_time', ) ) entries_by_date = {} for entry in entries: entries_by_date.setdefault( entry.date, entry, ) days_data = [] for day_offset in range(7): current_day = ( start_of_week + timedelta( days=day_offset, ) ) entry = entries_by_date.get( current_day, ) days_data.append( { 'date': current_day.isoformat(), 'day': current_day.strftime('%A'), 'start_time': ( str(entry.start_time) if entry and entry.start_time else None ), 'end_time': ( str(entry.end_time) if entry and entry.end_time else None ), 'duration': ( entry.duration if entry else 0 ), } ) return { '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 and ( request.user.is_staff or request.user.is_superuser ) ): try: user = User.objects.get( id=user_id, ) except User.DoesNotExist: return Response( { 'error': 'Utilisateur non trouvé' }, status=status.HTTP_404_NOT_FOUND, ) serializer = WeeklySummarySerializer( build_weekly_summary(user), ) return Response( serializer.data, )