815 lines
24 KiB
Python
815 lines
24 KiB
Python
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,
|
||
) |