Louis Mazin b890cdd24f nice
2026-10-05 18:54:10 +02:00

481 lines
19 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 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é déjà dû sur la période.
Les statistiques ne comptent que les journées strictement antérieures à
aujourd'hui : la journée en cours et les journées futures ne doivent pas
apparaître comme des heures manquantes.
Les objectifs hebdomadaires n'imposent aucun jour précis : leur total reste
réparti uniformément sur les sept jours, mais uniquement pour les journées
déjà passées de la période demandée.
"""
yesterday = timezone.localdate() - timedelta(days=1)
counted_end = min(end, yesterday)
if start > counted_end:
return 0
plan = load_plan(user_id)
total = 0.0
day = start
while day <= counted_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 = []
total_hours = 0
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.duration is not None]
day_is_pointed = bool(day_entries) and len(completed) == len(day_entries)
day_duration = sum(entry.duration or 0 for entry in day_entries) if day_is_pointed else 0
total_hours += day_duration
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 day_is_pointed else None,
'duration': day_duration,
'entries_count': len(day_entries),
})
return dict(user=user.username, username=user.username,
total_hours=round(total_hours, 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', 'week', 'month', 'year'):
raise ValidationError({'period': 'Vue inconnue. Choisissez jour, semaine, 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 == 'week':
start = anchor - timedelta(days=anchor.weekday())
end = start + timedelta(days=6)
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 = {}
entries_by_day = {}
for entry in entries:
entries_by_day.setdefault(entry.start_date, []).append(entry)
pointed_days = {
day
for day, day_entries in entries_by_day.items()
if day_entries and all(entry.duration is not None for entry in day_entries)
}
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
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)
# Une journée ne compte dans les heures faites que lorsque tous ses
# pointages sont complets. Une sortie manquante exclut donc toute la
# journée du total jusqu'à sa régularisation.
if day not in pointed_days:
continue
total_minutes += minutes
row['minutes'] += minutes
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,
})