Louis Mazin 923f371e8b temp
2026-09-20 04:02:02 +02:00

815 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
)