Louis Mazin a393d9409f nice
2026-10-05 18:22:05 +02:00

451 lines
18 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é 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', '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 = {}
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,
})