This commit is contained in:
Louis Mazin 2026-09-24 19:35:37 +02:00
parent 5298d69e1f
commit d424689e12
53 changed files with 7918 additions and 2142 deletions

84
backend/API.md Normal file
View File

@ -0,0 +1,84 @@
# Contrat API
Toutes les routes métier exigent un JWT. Les listes de pointages et les routes
de détail sont limitées au propriétaire ; les administrateurs peuvent consulter
et gérer les pointages de tous les utilisateurs. Les événements restent privés
à leur créateur, comme auparavant.
## Pointages
- `GET /api/time/entries/` renvoie une liste de lignes journalières, avec
`start_date`, `end_date`, `start_time`, `end_time`, `group_id` et `duration` en heures.
- `POST /api/time/entries/create/` accepte ces dates/heures et, pour un
administrateur, `user_id`. Une entrée sans sortie est permise sur une journée.
- `PATCH /api/time/entries/{id}/` modifie un pointage simple. Une période sur
plusieurs jours est découpée côté serveur. L'ancienne route `.../{id}/update/`
reste un alias avec les mêmes validations ; `PUT` accepte aussi les champs partiels.
- `PATCH /api/time/groups/{uuid}/` modifie une période entière en transaction.
Les dates/heures omises conservent les bornes existantes. La réponse est la
première ligne, au même format que la création.
- `DELETE /api/time/groups/{uuid}/` supprime toutes les lignes du groupe en
transaction. Une ligne d'un groupe ne peut pas être modifiée/supprimée isolément.
- `group_id` est attribué exclusivement par le serveur. Les chevauchements et
les entrées ouvertes concurrentes sont refusés.
Le front recharge la liste après une mutation. Il utilise `duration` fourni par
le serveur et additionne les lignes d'un groupe pour afficher sa durée.
Le scan conserve les conflits HTTP 409. Pour les résoudre, le client renvoie
`resolution`, `time`, `scan_date` et, selon le conflit, `entry_id` ou `scan_time`.
Une résolution devenue obsolète ou chevauchant un autre pointage est refusée.
## Événements et comptes
La création et la modification d'événements renvoient le même objet complet
que la lecture. Dates, heures et couleurs sont validées côté serveur.
Un administrateur standard peut gérer les comptes ordinaires et les promouvoir
administrateurs. Seul un superutilisateur peut gérer les comptes superutilisateurs
ou accorder ce rôle. Un compte ne peut pas se supprimer, se désactiver ou retirer
son propre accès administrateur.
## Tâches, informations et mémos
L'espace Entreprise → Tâches et mémos est partagé entre tous les utilisateurs
authentifiés. Le destinataire désigne la personne concernée ; ce n'est pas une
publication privée.
- Table `notes_note` : titre, contenu, type (`task`, `info`, `memo`), auteur,
destinataire facultatif, date de clôture et dates de création/modification.
- `GET /api/notes/` : liste paginée (`count`, `next`, `previous`, `results`,
24 éléments par page). Filtres : `kind`, `scope=all|mine|to_me`,
`state=all|pending|done`, `search`, `page`.
- `POST /api/notes/` : `title`, `content` facultatif, `kind`, `recipient_id`
facultatif ou `null` pour toute l'équipe. L'auteur est fixé par le serveur.
- `GET|PATCH|DELETE /api/notes/{id}/` : consulter, modifier ou supprimer.
Seuls l'auteur et les administrateurs peuvent modifier ou supprimer.
- `PATCH /api/notes/{id}/completion/` : `{ "is_done": true|false }`.
L'auteur, le destinataire et les administrateurs peuvent terminer ou rouvrir
une tâche. Une tâche destinée à toute l'équipe peut être terminée par chacun.
- `GET /api/notes/recipients/` : annuaire des comptes actifs limité à
`id`, `username`, `first_name`, `last_name` et `color`.
Les réponses comprennent `can_edit` et `can_complete` pour l'interface ; les
mêmes permissions sont contrôlées à chaque écriture côté serveur. La suppression
d'un compte conserve ses publications en retirant la référence au compte.
## Session et migrations
Le client renouvelle un accès expiré avec `/api/auth/refresh/`, mutualise les
renouvellements concurrents dans le même onglet, puis rejoue la requête une fois.
Le nouveau refresh token remplace l'ancien, qui est placé sur liste noire.
Les erreurs de réseau ne suppriment pas la session ; un refresh invalide la ferme.
Exécuter `python manage.py migrate` à chaque déploiement. La migration 0005
réconcilie les contraintes physiques des pointages avec l'état Django. Elle
refuse les anciens pointages sans heure d'entrée et les doublons : ces données
doivent être corrigées explicitement, elles ne sont ni inventées ni supprimées.
## Vérification
- Backend : `python manage.py test time_tracking events users notes --noinput` avec
une instance MySQL de test et un utilisateur autorisé à créer la base de test.
Ne pas pointer `DATABASE_URL` vers une base de production pour ces tests.
- Frontend : `npm test`, `npm run lint`, `npm run build` dans `frontend`.

View File

@ -41,11 +41,13 @@ INSTALLED_APPS = [
# Third party apps # Third party apps
'rest_framework', 'rest_framework',
'rest_framework_simplejwt', 'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist',
'corsheaders', 'corsheaders',
# Local apps # Local apps
'users', 'users',
'time_tracking', 'time_tracking',
'events', 'events',
'notes',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -149,11 +151,9 @@ SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(days=1), 'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True, 'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_ROTTEN_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True,
'AUTH_HEADER_TYPES': ('Bearer',), 'AUTH_HEADER_TYPES': ('Bearer',),
} }
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@ -28,4 +28,5 @@ urlpatterns = [
path('api/users/', include('users.urls')), path('api/users/', include('users.urls')),
path('api/time/', include('time_tracking.urls')), path('api/time/', include('time_tracking.urls')),
path('api/event/', include('events.urls')), path('api/event/', include('events.urls')),
path('api/notes/', include('notes.urls')),
] ]

View File

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-09-22 11:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='event',
name='color',
field=models.CharField(blank=True, max_length=7, null=True),
),
migrations.AlterField(
model_name='event',
name='end_time',
field=models.TimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='event',
name='start_time',
field=models.TimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-09-24 12:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0002_event_color_alter_event_end_time_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='visibility',
field=models.CharField(choices=[('public', 'Public'), ('private', 'Private')], default='public', max_length=10),
),
]

View File

@ -18,10 +18,15 @@ class Event(models.Model):
) )
start_date = models.DateField() start_date = models.DateField()
end_date = models.DateField() end_date = models.DateField()
start_time = models.TimeField() start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField() end_time = models.TimeField(null=True, blank=True)
color = models.CharField(max_length=7, null=True, blank=True) # Hex color code
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
description = models.TextField(blank=True) description = models.TextField(blank=True)
visibility = models.CharField(max_length=10, default='public', choices=[
('public', 'Public'),
('private', 'Private'),
])
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View File

@ -17,30 +17,24 @@ class EventSerializer(serializers.ModelSerializer):
'end_date', 'end_date',
'start_time', 'start_time',
'end_time', 'end_time',
'color',
'name', 'name',
'description', 'description',
'visibility',
'created_at', 'created_at',
'updated_at' 'updated_at',
]
read_only_fields = [
'id',
'user',
'created_at',
'updated_at',
] ]
read_only_fields = ['id', 'user', 'created_at', 'updated_at']
class EventCreateSerializer(serializers.ModelSerializer): class EventCreateSerializer(serializers.ModelSerializer):
class Meta: color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False, allow_null=True, allow_blank=True)
model = Event
fields = [
'user',
'linked_user',
'start_date',
'end_date',
'start_time',
'end_time',
'name',
'description'
]
class EventUpdateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Event model = Event
fields = [ fields = [
@ -49,6 +43,49 @@ class EventUpdateSerializer(serializers.ModelSerializer):
'end_date', 'end_date',
'start_time', 'start_time',
'end_time', 'end_time',
'color',
'name', 'name',
'description' 'description',
'visibility',
]
def validate(self, attrs):
values = {
field: attrs.get(field, getattr(self.instance, field, None))
for field in ('start_date', 'end_date', 'start_time', 'end_time')
}
if values['end_date'] < values['start_date']:
raise serializers.ValidationError({'end_date': 'La fin doit suivre le début.'})
if (values['start_date'] == values['end_date']
and values['start_time'] is not None and values['end_time'] is not None
and values['end_time'] <= values['start_time']):
raise serializers.ValidationError({'end_time': 'La fin doit suivre le début.'})
return attrs
def create(self, validated_data):
request = self.context.get('request')
if request is None or not request.user.is_authenticated:
raise serializers.ValidationError({
'user': 'Un utilisateur authentifié est requis.'
})
validated_data['user'] = request.user
return Event.objects.create(**validated_data)
class EventUpdateSerializer(EventCreateSerializer):
class Meta:
model = Event
fields = [
'linked_user',
'start_date',
'end_date',
'start_time',
'end_time',
'color',
'name',
'description',
'visibility',
] ]

3
backend/events/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -16,10 +16,17 @@ class EventListView(generics.ListCreateAPIView):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user=self.request.user) serializer.save(user=self.request.user)
def create(self, request, *args, **kwargs):
serializer = EventCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
event = serializer.save(user=request.user)
return Response(EventSerializer(event).data, status=status.HTTP_201_CREATED)
class EventDetailView(generics.RetrieveUpdateDestroyAPIView): class EventDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = EventSerializer serializer_class = EventSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
lookup_url_kwarg = 'id'
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
@ -33,12 +40,13 @@ class EventDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer = EventUpdateSerializer( serializer = EventUpdateSerializer(
instance, instance,
data=request.data, data=request.data,
partial=partial partial=partial,
context=self.get_serializer_context(),
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_update(serializer) self.perform_update(serializer)
return Response(serializer.data) return Response(EventSerializer(serializer.instance).data)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
@ -49,7 +57,8 @@ class EventDetailView(generics.RetrieveUpdateDestroyAPIView):
) )
class EventCreateView(generics.CreateAPIView): class EventCreateView(EventListView):
http_method_names = ['post', 'options']
serializer_class = EventCreateSerializer serializer_class = EventCreateSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]

View File

7
backend/notes/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class NotesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notes'
verbose_name = 'Tâches et mémos'

View File

@ -0,0 +1,35 @@
# Generated by Django 5.2.17 on 2026-09-23 15:04
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Note',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=160)),
('content', models.TextField(blank=True, max_length=5000)),
('kind', models.CharField(choices=[('task', 'Tâche'), ('info', 'Information'), ('memo', 'Mémo')], default='memo', max_length=4)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authored_notes', to=settings.AUTH_USER_MODEL)),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='addressed_notes', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
'constraints': [models.CheckConstraint(condition=models.Q(('kind', 'task'), ('completed_at__isnull', True), _connector='OR'), name='only_tasks_can_be_completed')],
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-09-24 16:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notes', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='note',
name='is_public',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='note',
name='kind',
field=models.CharField(choices=[('task', 'Tâche'), ('info', 'Information'), ('memo', 'Mémo'), ('sugg', 'Suggestion')], default='memo', max_length=4),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.2.17 on 2026-09-24 16:53
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notes', '0002_note_is_public_alter_note_kind'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='note',
name='recipient',
),
migrations.AddField(
model_name='note',
name='recipients',
field=models.ManyToManyField(blank=True, related_name='addressed_notes', to=settings.AUTH_USER_MODEL),
),
]

View File

80
backend/notes/models.py Normal file
View File

@ -0,0 +1,80 @@
from django.conf import settings
from django.db import models
class Note(models.Model):
class Kind(models.TextChoices):
TASK = 'task', 'Tâche'
INFO = 'info', 'Information'
MEMO = 'memo', 'Mémo'
SUGGESTION = 'sugg', 'Suggestion'
title = models.CharField(
max_length=160,
)
content = models.TextField(
blank=True,
max_length=5000,
)
kind = models.CharField(
max_length=4,
choices=Kind.choices,
default=Kind.MEMO,
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='authored_notes',
)
recipients = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name='addressed_notes',
)
is_public = models.BooleanField(
default=True,
)
completed_at = models.DateTimeField(
null=True,
blank=True,
)
created_at = models.DateTimeField(
auto_now_add=True,
)
updated_at = models.DateTimeField(
auto_now=True,
)
class Meta:
ordering = [
'-created_at',
'-pk',
]
constraints = [
models.CheckConstraint(
condition=(
models.Q(kind='task')
| models.Q(
completed_at__isnull=True,
)
),
name='only_tasks_can_be_completed',
),
]
@property
def is_done(self):
return self.completed_at is not None
def __str__(self):
return self.title

View File

@ -0,0 +1,34 @@
def is_admin(user):
return (
user.is_authenticated
and (
user.is_staff
or user.is_superuser
)
)
def can_edit_note(user, note):
if not user.is_authenticated:
return False
return (
is_admin(user)
or note.author_id == user.id
)
def can_complete_note(user, note):
if not user.is_authenticated:
return False
if note.kind != 'task':
return False
return (
is_admin(user)
or note.author_id == user.id
or note.recipients.filter(
pk=user.pk,
).exists()
)

View File

@ -0,0 +1,148 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from .models import Note
from .permissions import (
can_complete_note,
can_edit_note,
)
class NoteUserSerializer(serializers.ModelSerializer):
"""
Annuaire minimal accessible à l'équipe
sans exposer la gestion des comptes.
"""
class Meta:
model = get_user_model()
fields = [
'id',
'username',
'first_name',
'last_name',
'color',
]
read_only_fields = fields
class NoteSerializer(serializers.ModelSerializer):
author = NoteUserSerializer(
read_only=True,
)
recipients = NoteUserSerializer(
many=True,
read_only=True,
)
recipient_ids = serializers.PrimaryKeyRelatedField(
source='recipients',
queryset=get_user_model().objects.filter(
is_active=True,
),
many=True,
required=False,
write_only=True,
)
is_done = serializers.ReadOnlyField()
can_edit = serializers.SerializerMethodField()
can_complete = serializers.SerializerMethodField()
class Meta:
model = Note
fields = [
'id',
'title',
'content',
'kind',
'author',
'recipients',
'recipient_ids',
'is_public',
'is_done',
'completed_at',
'created_at',
'updated_at',
'can_edit',
'can_complete',
]
read_only_fields = [
'id',
'completed_at',
'created_at',
'updated_at',
]
def get_can_edit(self, obj):
return can_edit_note(
self.context['request'].user,
obj,
)
def get_can_complete(self, obj):
return can_complete_note(
self.context['request'].user,
obj,
)
def validate(self, attrs):
is_public = attrs.get(
'is_public',
self.instance.is_public
if self.instance
else True,
)
if 'recipients' in attrs:
recipients = attrs['recipients']
has_recipients = bool(recipients)
elif self.instance:
has_recipients = (
self.instance.recipients.exists()
)
else:
has_recipients = False
if not is_public and not has_recipients:
raise serializers.ValidationError({
'recipient_ids':
'Une publication privée doit avoir au moins un destinataire.',
})
return attrs
def update(
self,
instance,
validated_data,
):
if (
validated_data.get(
'kind',
instance.kind,
)
!= Note.Kind.TASK
):
validated_data[
'completed_at'
] = None
return super().update(
instance,
validated_data,
)
class NoteCompletionSerializer(
serializers.Serializer,
):
is_done = serializers.BooleanField()

3
backend/notes/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
backend/notes/urls.py Normal file
View File

@ -0,0 +1,10 @@
from django.urls import path
from .views import NoteCompletionView, NoteDetailView, NoteListView, NoteRecipientListView
urlpatterns = [
path('', NoteListView.as_view(), name='note-list'),
path('recipients/', NoteRecipientListView.as_view(), name='note-recipients'),
path('<int:pk>/', NoteDetailView.as_view(), name='note-detail'),
path('<int:pk>/completion/', NoteCompletionView.as_view(), name='note-completion'),
]

320
backend/notes/views.py Normal file
View File

@ -0,0 +1,320 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import generics
from rest_framework.exceptions import (
PermissionDenied,
ValidationError,
)
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Note
from .permissions import (
can_complete_note,
can_edit_note,
)
from .serializers import (
NoteCompletionSerializer,
NoteSerializer,
NoteUserSerializer,
)
def visible_notes(user):
"""
Retourne uniquement les publications
que l'utilisateur a le droit de voir.
Publique :
visible par toute l'équipe.
Privée :
visible par l'auteur et les destinataires.
"""
return (
Note.objects
.select_related('author')
.prefetch_related('recipients')
.filter(
Q(is_public=True)
| Q(author=user)
| Q(recipients=user)
)
.distinct()
)
class NotePagination(PageNumberPagination):
page_size = 24
class NoteListView(
generics.ListCreateAPIView,
):
permission_classes = [
IsAuthenticated,
]
serializer_class = NoteSerializer
pagination_class = NotePagination
def get_queryset(self):
notes = visible_notes(
self.request.user,
)
params = self.request.query_params
kind = params.get(
'kind',
'',
)
scope = params.get(
'scope',
'all',
)
state = params.get(
'state',
'all',
)
# -----------------------------
# Type
# -----------------------------
if kind:
if kind not in Note.Kind.values:
raise ValidationError({
'kind':
'Type de publication inconnu.',
})
notes = notes.filter(
kind=kind,
)
# -----------------------------
# Portée
# -----------------------------
if scope == 'mine':
notes = notes.filter(
author=self.request.user,
)
elif scope == 'to_me':
notes = notes.filter(
recipients=self.request.user,
)
elif scope != 'all':
raise ValidationError({
'scope':
'Filtre inconnu.',
})
# -----------------------------
# État des tâches
# -----------------------------
if state in (
'pending',
'done',
):
notes = notes.filter(
kind=Note.Kind.TASK,
completed_at__isnull=(
state == 'pending'
),
)
elif state != 'all':
raise ValidationError({
'state':
'État inconnu.',
})
# -----------------------------
# Recherche
# -----------------------------
search = params.get(
'search',
'',
).strip()
if search:
notes = notes.filter(
Q(
title__icontains=search,
)
| Q(
content__icontains=search,
)
)
return notes.distinct()
def perform_create(
self,
serializer,
):
serializer.save(
author=self.request.user,
)
class NoteDetailView(
generics.RetrieveUpdateDestroyAPIView,
):
permission_classes = [
IsAuthenticated,
]
serializer_class = NoteSerializer
def get_queryset(self):
return visible_notes(
self.request.user,
)
def get_object(self):
note = super().get_object()
if (
self.request.method
not in (
'GET',
'HEAD',
'OPTIONS',
)
and not can_edit_note(
self.request.user,
note,
)
):
raise PermissionDenied(
'Seuls l’auteur et les administrateurs '
'peuvent modifier ou supprimer '
'cette publication.'
)
return note
@transaction.atomic
def update(
self,
request,
*args,
**kwargs,
):
return super().update(
request,
*args,
**kwargs,
)
class NoteCompletionView(APIView):
permission_classes = [
IsAuthenticated,
]
@transaction.atomic
def patch(
self,
request,
pk,
):
data = NoteCompletionSerializer(
data=request.data,
)
data.is_valid(
raise_exception=True,
)
note = get_object_or_404(
visible_notes(request.user)
.select_for_update(),
pk=pk,
)
if note.kind != Note.Kind.TASK:
raise ValidationError({
'kind':
'Seules les tâches peuvent être terminées.',
})
if not can_complete_note(
request.user,
note,
):
raise PermissionDenied(
'Seuls l’auteur, les destinataires '
'et les administrateurs peuvent '
'terminer cette tâche.'
)
is_done = (
data.validated_data[
'is_done'
]
)
if note.is_done != is_done:
note.completed_at = (
timezone.now()
if is_done
else None
)
note.save(
update_fields=[
'completed_at',
'updated_at',
],
)
return Response(
NoteSerializer(
note,
context={
'request': request,
},
).data
)
class NoteRecipientListView(
generics.ListAPIView,
):
permission_classes = [
IsAuthenticated,
]
serializer_class = (
NoteUserSerializer
)
queryset = (
get_user_model()
.objects
.filter(
is_active=True,
)
.order_by(
'first_name',
'last_name',
'username',
)
)

View File

@ -0,0 +1,125 @@
from django.db import migrations, models
def migrate_timeentry_schema(apps, schema_editor):
"""
Migration tolérante aux deux états possibles de la BDD :
Ancien état :
date
start_time
end_time
...
Nouvel état déjà présent :
start_date
start_time
end_date
...
On adapte physiquement la BDD uniquement si nécessaire.
"""
connection = schema_editor.connection
table = "time_tracking_timeentry"
with connection.cursor() as cursor:
columns = {
column.name
for column in connection.introspection.get_table_description(
cursor,
table,
)
}
# ---------------------------------------------------------
# ANCIENNE BDD
# date existe encore
# ---------------------------------------------------------
if "date" in columns and "start_date" not in columns:
schema_editor.execute(
f"""
ALTER TABLE `{table}`
CHANGE COLUMN `date` `start_date` DATE NOT NULL
"""
)
columns.remove("date")
columns.add("start_date")
# ---------------------------------------------------------
# end_date n'existe pas encore
# On l'ajoute temporairement nullable.
# ---------------------------------------------------------
if "end_date" not in columns:
schema_editor.execute(
f"""
ALTER TABLE `{table}`
ADD COLUMN `end_date` DATE NULL
AFTER `start_date`
"""
)
# Les anciennes entrées sont des entrées mono-journée.
schema_editor.execute(
f"""
UPDATE `{table}`
SET `end_date` = `start_date`
WHERE `end_date` IS NULL
"""
)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("time_tracking", "0003_timeentry_group_id"),
]
operations = [
migrations.RunPython(
migrate_timeentry_schema,
migrations.RunPython.noop,
atomic=False,
),
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.RenameField(
model_name="timeentry",
old_name="date",
new_name="start_date",
),
migrations.AddField(
model_name="timeentry",
name="end_date",
field=models.DateField(
null=True,
blank=True,
),
),
migrations.AlterModelOptions(
name="timeentry",
options={
"ordering": ["-start_date", "-start_time"],
},
),
migrations.AlterField(
model_name="timeentry",
name="start_time",
field=models.TimeField(),
),
migrations.AlterUniqueTogether(
name="timeentry",
unique_together=set(),
),
],
),
]

View File

@ -0,0 +1,64 @@
from django.db import migrations, models
from django.db.models import Count, F
def reconcile_schema(apps, schema_editor):
"""Réconcilie la base réelle après les changements d'état seuls de 0004."""
entry = apps.get_model('time_tracking', 'TimeEntry')
rows = entry.objects.using(schema_editor.connection.alias)
# Ne jamais inventer une heure ni supprimer un doublon historique.
if rows.filter(start_time__isnull=True).exists():
raise RuntimeError('Corrigez les pointages sans heure d’entrée avant la migration 0005.')
if rows.values('user_id', 'start_date', 'start_time').annotate(n=Count('pk')).filter(n__gt=1).exists():
raise RuntimeError('Corrigez les doublons de pointages avant la migration 0005.')
rows.filter(end_date__isnull=True).update(end_date=F('start_date'))
connection = schema_editor.connection
with connection.cursor() as cursor:
columns = {
column.name: column
for column in connection.introspection.get_table_description(cursor, entry._meta.db_table)
}
constraints = connection.introspection.get_constraints(cursor, entry._meta.db_table)
for field_name in ('start_time', 'end_date'):
if columns[field_name].null_ok:
old_field = entry._meta.get_field(field_name).clone()
old_field.set_attributes_from_name(field_name)
old_field.model = entry
old_field.null = True
new_field = old_field.clone()
new_field.set_attributes_from_name(field_name)
new_field.model = entry
new_field.null = False
schema_editor.alter_field(entry, old_field, new_field, strict=True)
fields = ['user', 'start_date', 'start_time']
target = 'unique_time_entry_start'
if target not in constraints:
schema_editor.add_constraint(entry, models.UniqueConstraint(fields=fields, name=target))
# L'ancien unique_together a été retiré de l'état, mais peut encore exister en SQL.
for name, constraint in constraints.items():
if (name != target and constraint['unique']
and constraint['columns'] == ['user_id', 'start_date', 'start_time']):
schema_editor.remove_constraint(entry, models.UniqueConstraint(fields=fields, name=name))
class Migration(migrations.Migration):
atomic = False
dependencies = [('time_tracking', '0004_alter_timeentry_options_and_more')]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[migrations.RunPython(reconcile_schema, atomic=False)],
state_operations=[
migrations.AlterField(model_name='timeentry', name='end_date', field=models.DateField()),
migrations.AddConstraint(
model_name='timeentry',
constraint=models.UniqueConstraint(
fields=('user', 'start_date', 'start_time'), name='unique_time_entry_start',
),
),
],
),
]

View File

@ -1,4 +1,3 @@
import uuid
from datetime import datetime from datetime import datetime
from django.conf import settings from django.conf import settings
@ -20,17 +19,17 @@ class TimeEntry(models.Model):
db_index=True, db_index=True,
) )
date = models.DateField() start_date = models.DateField()
start_time = models.TimeField( start_time = models.TimeField()
null=True,
blank=True,
)
end_time = models.TimeField( # Chaque ligne représente une seule journée.
null=True, # Pour une entrée classique :
blank=True, # start_date = end_date
) #
# Pour un pointage multi-jours :
# une ligne par jour, toutes liées par group_id.
end_date = models.DateField()
end_time = models.TimeField( end_time = models.TimeField(
null=True, null=True,
@ -46,30 +45,49 @@ class TimeEntry(models.Model):
) )
class Meta: class Meta:
ordering = ['-date', '-start_time'] ordering = ['-start_date', '-start_time']
unique_together = [ constraints = [
'user', models.UniqueConstraint(
'date', fields=[
'start_time', 'user',
'start_date',
'start_time',
],
name='unique_time_entry_start',
),
] ]
def __str__(self): def __str__(self):
if self.end_time:
return (
f"{self.user.username} - "
f"{self.start_date} "
f"({self.start_time} → {self.end_time})"
)
return ( return (
f"{self.user.username} - " f"{self.user.username} - "
f"{self.date} " f"{self.start_date} "
f"({self.start_time} to {self.end_time})" f"({self.start_time} → en cours)"
) )
@property @property
def duration(self): def duration(self):
"""
Durée de CETTE ligne.
Chaque TimeEntry représente une seule journée.
Les différentes journées d'un même pointage sont reliées
par group_id.
"""
if not self.start_time or not self.end_time: if not self.start_time or not self.end_time:
return None return None
end_date = self.end_date or self.date end_date = self.end_date or self.start_date
start = datetime.combine( start = datetime.combine(
self.date, self.start_date,
self.start_time, self.start_time,
) )
@ -89,7 +107,7 @@ class TimeEntry(models.Model):
@property @property
def day_of_week(self): def day_of_week(self):
return self.date.strftime('%A') return self.start_date.strftime('%A')
@property @property
def is_complete(self): def is_complete(self):

View File

@ -1,242 +1,69 @@
from datetime import time as datetime_time
from uuid import uuid4
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction
from rest_framework import serializers from rest_framework import serializers
from users.serializers import UserSerializer from users.serializers import UserSerializer
from .models import TimeEntry from .models import TimeEntry
from .services import save_period, validate_period
User = get_user_model()
class TimeEntrySerializer(serializers.ModelSerializer): class TimeEntrySerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True) user = UserSerializer(read_only=True)
is_complete = serializers.ReadOnlyField()
is_incomplete = serializers.ReadOnlyField()
class Meta: class Meta:
model = TimeEntry model = TimeEntry
fields = [ fields = [
'id', 'id', 'user', 'group_id', 'start_date', 'start_time', 'end_date',
'user', 'end_time', 'created_at', 'updated_at', 'duration', 'day_of_week',
'group_id', 'is_complete', 'is_incomplete',
'date',
'start_time',
'end_time',
'created_at',
'updated_at',
'duration',
'day_of_week',
'is_complete',
'is_incomplete',
] ]
read_only_fields = fields
read_only_fields = [
'id', class TimeEntryCreateSerializer(serializers.Serializer):
'user', user_id = serializers.PrimaryKeyRelatedField(
'created_at', source='user', queryset=get_user_model().objects.all(), required=False,
'updated_at', write_only=True,
'duration', )
'day_of_week', start_date = serializers.DateField()
'is_complete', start_time = serializers.TimeField()
'is_incomplete', end_date = serializers.DateField(required=False)
] end_time = serializers.TimeField(required=False, allow_null=True)
def validate(self, attrs):
validate_period(attrs)
return attrs
def create(self, validated_data): def create(self, validated_data):
validated_data['user'] = self.context['request'].user user = validated_data.pop('user', self.context['request'].user)
return super().create(validated_data) return save_period(user, validated_data)
class TimeEntryUpdateSerializer(serializers.Serializer):
start_date = serializers.DateField()
start_time = serializers.TimeField()
end_date = serializers.DateField()
end_time = serializers.TimeField(allow_null=True)
def validate(self, attrs):
entries = self.context.get('entries', [self.instance])
first, last = entries[0], entries[-1]
values = dict(
start_date=first.start_date, start_time=first.start_time,
end_date=last.end_date, end_time=last.end_time,
)
values.update(attrs)
validate_period(values)
return values
def update(self, instance, validated_data): def update(self, instance, validated_data):
validated_data.pop('user', None) return save_period(
return super().update(instance, validated_data) instance.user, validated_data,
self.context.get('entries', [instance]),
class TimeEntryCreateSerializer(serializers.ModelSerializer):
user_id = serializers.PrimaryKeyRelatedField(
source='user',
queryset=User.objects.all(),
write_only=True,
required=False,
)
end_date = serializers.DateField(
write_only=True,
required=False,
)
class Meta:
model = TimeEntry
fields = [
'user_id',
'date',
'end_date',
'start_time',
'end_time',
]
def validate(self, attrs):
start_time = attrs.get('start_time')
end_time = attrs.get('end_time')
date = attrs.get('date')
end_date = attrs.get('end_date') or date
if not start_time and not end_time:
raise serializers.ValidationError(
'Une heure d’entrée ou de sortie est requise.'
)
if end_date < date:
raise serializers.ValidationError(
'La date de sortie doit être postérieure ou égale à la date d’entrée.'
)
# Une entrée et une sortie le même jour doivent être cohérentes.
if (
date == end_date
and start_time
and end_time
and end_time <= start_time
):
raise serializers.ValidationError(
'L’heure de sortie doit être après l’entrée.'
)
# Pour un pointage sur plusieurs jours, les deux extrémités
# doivent être connues.
if (
date != end_date
and (
not start_time
or not end_time
)
):
raise serializers.ValidationError(
'Un pointage sur plusieurs jours nécessite une heure d’entrée et une heure de sortie.'
)
attrs['end_date'] = end_date
return attrs
def create(self, validated_data):
user = validated_data.pop('user', None)
end_date = validated_data.pop('end_date')
if user is None:
user = self.context['request'].user
start_date = validated_data['date']
start_time = validated_data.get('start_time')
end_time = validated_data.get('end_time')
# Pointage classique sur une seule journée.
if start_date == end_date:
return TimeEntry.objects.create(
user=user,
date=start_date,
start_time=start_time,
end_time=end_time,
)
# Pointage multi-jours :
#
# Exemple :
# 20/09 14:00 -> 22/09 10:00
#
# 20/09 14:00 -> 23:59:59
# 21/09 00:00 -> 23:59:59
# 22/09 00:00 -> 10:00
group_id = uuid4()
created_entries = []
current_date = start_date
with transaction.atomic():
while current_date <= end_date:
if current_date == start_date:
entry_start = start_time
entry_end = datetime_time(
23,
59,
59,
)
elif current_date == end_date:
entry_start = datetime_time.min
entry_end = end_time
else:
entry_start = datetime_time.min
entry_end = datetime_time(
23,
59,
59,
)
created_entries.append(
TimeEntry.objects.create(
user=user,
group_id=group_id,
date=current_date,
start_time=entry_start,
end_time=entry_end,
)
)
from datetime import timedelta
current_date += timedelta(days=1)
return created_entries[0]
class TimeEntryUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = TimeEntry
fields = [
'start_time',
'end_time',
]
def validate(self, attrs):
start_time = attrs.get(
'start_time',
self.instance.start_time,
) )
end_time = attrs.get(
'end_time',
self.instance.end_time,
)
if not start_time and not end_time:
raise serializers.ValidationError(
'Une heure d’entrée ou de sortie est requise.'
)
if (
start_time
and end_time
and end_time <= start_time
):
raise serializers.ValidationError(
'L’heure de sortie doit être après l’entrée.'
)
return attrs
class WeeklySummarySerializer(serializers.Serializer): class WeeklySummarySerializer(serializers.Serializer):
user = serializers.CharField() user = serializers.CharField()
username = serializers.CharField() username = serializers.CharField()
total_hours = serializers.FloatField() total_hours = serializers.FloatField()
entries = serializers.ListField( entries = serializers.ListField(child=serializers.DictField())
child=serializers.DictField(),
)

View File

@ -0,0 +1,85 @@
"""Règles communes aux saisies manuelles, aux groupes et aux scans."""
from datetime import datetime, time, timedelta
from uuid import uuid4
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from rest_framework.exceptions import ValidationError
from .models import TimeEntry
def validate_period(data):
if data.get('start_time') is None:
raise ValidationError({'start_time': "L'heure d'entrée est requise."})
data['end_date'] = data.get('end_date') or data['start_date']
start = datetime.combine(data['start_date'], data['start_time'])
end = None
if data['end_date'] < data['start_date']:
raise ValidationError({'end_date': 'La sortie doit suivre l’entrée.'})
if data.get('end_time') is not None:
end = datetime.combine(data['end_date'], data['end_time'])
if end <= start:
raise ValidationError({'end_time': 'La sortie doit suivre l’entrée.'})
elif data['end_date'] != data['start_date']:
raise ValidationError({'end_time': 'Une sortie est requise pour un pointage multi-jours.'})
return start, end
def lock_user(user):
# Verrou stable, même lorsqu'il n'existe encore aucun pointage.
get_user_model().objects.select_for_update().get(pk=user.pk)
def validate_overlap(user, data, excluded_ids=()):
start, end = validate_period(data)
entries = TimeEntry.objects.filter(user=user).exclude(pk__in=excluded_ids)
entries = entries.filter(Q(end_date__gte=start.date()) | Q(end_time__isnull=True))
if end is not None:
entries = entries.filter(start_date__lte=end.date())
for entry in entries:
if entry.start_time is None:
raise ValidationError({'detail': 'Un ancien pointage sans entrée doit être corrigé.'})
existing_start = datetime.combine(entry.start_date, entry.start_time)
existing_end = (
datetime.combine(entry.end_date or entry.start_date, entry.end_time)
if entry.end_time is not None else None
)
if (end is None or existing_start < end) and (existing_end is None or start < existing_end):
raise ValidationError({'detail': 'Ce pointage chevauche un pointage existant ou une entrée ouverte.'})
@transaction.atomic
def save_period(user, data, entries=()):
"""Enregistre un pointage logique entier, sans laisser de groupe partiel."""
lock_user(user)
data = dict(data)
validate_overlap(user, data, [entry.pk for entry in entries])
group_id = None
if data['start_date'] != data['end_date']:
group_id = next((entry.group_id for entry in entries if entry.group_id), None) or uuid4()
first = entries[0] if entries else None
if entries:
TimeEntry.objects.filter(pk__in=[entry.pk for entry in entries[1:]]).delete()
current_date = data['start_date']
created = []
while current_date <= data['end_date']:
values = dict(
user=user,
group_id=group_id,
start_date=current_date,
end_date=current_date,
start_time=data['start_time'] if current_date == data['start_date'] else time.min,
end_time=data.get('end_time') if current_date == data['end_date'] else time(23, 59, 59),
)
if first is not None and not created:
for key, value in values.items():
setattr(first, key, value)
first.save()
created.append(first)
else:
created.append(TimeEntry.objects.create(**values))
current_date += timedelta(days=1)
return created[0]

View File

@ -0,0 +1,169 @@
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, time
from threading import Barrier
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.db import connections
from django.test import TransactionTestCase
from django.utils import timezone
from rest_framework.test import APIClient, APITestCase
from .models import TimeEntry
class TimeEntryAPITests(APITestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='employee')
self.other = get_user_model().objects.create_user(username='other')
self.client.force_authenticate(self.user)
self.payload = dict(start_date='2026-09-23', end_date='2026-09-23', start_time='08:00', end_time='12:00')
def create_entry(self, **changes):
response = self.client.post('/api/time/entries/create/', {**self.payload, **changes}, format='json')
self.assertEqual(response.status_code, 201, response.data)
return response.data
def test_partial_updates_on_both_routes_and_date_changes(self):
entry = self.create_entry()
for suffix in ('', 'update/'):
response = self.client.put(f'/api/time/entries/{entry["id"]}/{suffix}', {'start_time': '09:00'}, format='json')
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data['start_time'], '09:00:00')
response = self.client.patch(f'/api/time/entries/{entry["id"]}/{suffix}', {'end_time': '07:00'}, format='json')
self.assertEqual(response.status_code, 400)
response = self.client.patch(f'/api/time/entries/{entry["id"]}/',
{'start_date': '2026-09-24', 'end_date': '2026-09-24'}, format='json')
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data['start_date'], '2026-09-24')
def test_owner_permissions(self):
entry = self.create_entry()
self.client.force_authenticate(self.other)
self.assertEqual(self.client.get('/api/time/entries/').data, [])
self.assertEqual(self.client.patch(f'/api/time/entries/{entry["id"]}/', {'end_time': '13:00'}, format='json').status_code, 404)
self.assertEqual(self.client.delete(f'/api/time/entries/{entry["id"]}/').status_code, 404)
self.assertEqual(self.client.get(f'/api/time/weekly-summary/{self.user.pk}/').status_code, 403)
response = self.client.post('/api/time/entries/create/', {**self.payload, 'user_id': self.user.pk}, format='json')
self.assertEqual(response.status_code, 403)
def test_staff_can_create_for_employee(self):
self.other.is_staff = True
self.other.save()
self.client.force_authenticate(self.other)
entry = self.create_entry(user_id=self.user.pk)
self.assertEqual(entry['user']['id'], self.user.pk)
def test_overlap_rejected_but_adjacent_period_allowed(self):
self.create_entry()
for changes in ({}, {'start_time': '10:00', 'end_time': '14:00'}):
response = self.client.post('/api/time/entries/create/', {**self.payload, **changes}, format='json')
self.assertEqual(response.status_code, 400, response.data)
self.create_entry(start_time='12:00', end_time='14:00')
self.assertEqual(TimeEntry.objects.count(), 2)
def test_group_update_delete_and_individual_route_protection(self):
entry = self.create_entry(start_time='22:00', end_date='2026-09-25', end_time='06:00')
url = f'/api/time/groups/{entry["group_id"]}/'
self.assertEqual(TimeEntry.objects.count(), 3)
self.assertEqual(self.client.patch(f'/api/time/entries/{entry["id"]}/', {'end_time': '23:00'}, format='json').status_code, 400)
self.assertEqual(self.client.delete(f'/api/time/entries/{entry["id"]}/').status_code, 400)
response = self.client.patch(url, {'start_time': '21:00', 'end_time': '07:00'}, format='json')
self.assertEqual(response.status_code, 200, response.data)
entries = list(TimeEntry.objects.order_by('start_date'))
self.assertEqual(entries[0].start_time, time(21))
self.assertEqual(entries[-1].end_time, time(7))
self.client.force_authenticate(self.other)
self.assertEqual(self.client.delete(url).status_code, 404)
self.client.force_authenticate(self.user)
self.assertEqual(self.client.delete(url).status_code, 204)
self.assertFalse(TimeEntry.objects.exists())
def test_group_validation_does_not_partially_modify_rows(self):
entry = self.create_entry(start_time='22:00', end_date='2026-09-24', end_time='06:00')
self.create_entry(start_date='2026-09-24', end_date='2026-09-24', start_time='07:00', end_time='12:00')
before = list(TimeEntry.objects.values())
response = self.client.patch(f'/api/time/groups/{entry["group_id"]}/',
{'start_time': '21:00', 'end_time': '08:00'}, format='json')
self.assertEqual(response.status_code, 400, response.data)
self.assertEqual(list(TimeEntry.objects.values()), before)
def test_group_write_failure_rolls_back(self):
entry = self.create_entry(start_time='22:00', end_date='2026-09-24', end_time='06:00')
before = list(TimeEntry.objects.values())
with patch('time_tracking.services.TimeEntry.objects.create', side_effect=RuntimeError('simulated failure')):
with self.assertRaises(RuntimeError):
self.client.patch(f'/api/time/groups/{entry["group_id"]}/', {'start_time': '21:00'}, format='json')
self.assertEqual(list(TimeEntry.objects.values()), before)
def test_single_entry_can_become_multiday(self):
entry = self.create_entry()
response = self.client.patch(f'/api/time/entries/{entry["id"]}/',
{'end_date': '2026-09-24', 'end_time': '06:00'}, format='json')
self.assertEqual(response.status_code, 200, response.data)
self.assertTrue(response.data['group_id'])
self.assertEqual(TimeEntry.objects.count(), 2)
def test_only_one_open_entry_allowed(self):
self.create_entry(end_time=None)
response = self.client.post('/api/time/entries/create/',
{**self.payload, 'start_date': '2026-09-24', 'end_date': '2026-09-24', 'end_time': None}, format='json')
self.assertEqual(response.status_code, 400)
def test_scan_handles_previous_day_and_stale_resolution(self):
now = timezone.make_aware(datetime(2026, 9, 24, 6))
entry = self.create_entry(start_time='22:00', end_time=None)
codes = {'SECRET_POINTAGE_ENTER': 'enter-test', 'SECRET_POINTAGE_EXIT': 'exit-test'}
with patch('time_tracking.views.config', side_effect=lambda key, default='': codes.get(key, default)), patch('time_tracking.views.timezone.localtime', return_value=now):
response = self.client.post('/api/time/entries/scan/', {'code': 'enter-test'}, format='json')
self.assertEqual(response.status_code, 409, response.data)
self.assertEqual(TimeEntry.objects.count(), 1)
conflict = response.data
response = self.client.post('/api/time/entries/scan/', {
'code': 'enter-test', 'resolution': conflict['resolution'], 'time': '06:00',
'scan_date': conflict['scan_date'], 'entry_id': conflict['entry_id'],
}, format='json')
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(TimeEntry.objects.count(), 2)
self.assertFalse(TimeEntry.objects.filter(end_time__isnull=True).exists())
def test_scan_missing_entry_requires_original_scan_and_rejects_replay(self):
now = timezone.make_aware(datetime(2026, 9, 23, 12))
codes = {'SECRET_POINTAGE_ENTER': 'enter-test', 'SECRET_POINTAGE_EXIT': 'exit-test'}
with patch('time_tracking.views.config', side_effect=lambda key, default='': codes.get(key, default)), patch('time_tracking.views.timezone.localtime', return_value=now):
conflict = self.client.post('/api/time/entries/scan/', {'code': 'exit-test'}, format='json')
self.assertEqual(conflict.status_code, 409)
data = dict(code='exit-test', resolution='complete_missing_entry', time='08:00',
scan_time=conflict.data['scan_time'], scan_date=conflict.data['scan_date'])
self.assertEqual(self.client.post('/api/time/entries/scan/', data, format='json').status_code, 200)
self.assertEqual(self.client.post('/api/time/entries/scan/', data, format='json').status_code, 400)
self.assertEqual(TimeEntry.objects.count(), 1)
data['scan_date'] = '2026-09-22'
self.assertEqual(self.client.post('/api/time/entries/scan/', data, format='json').status_code, 400)
def test_unconfigured_scan_fails_closed(self):
with patch('time_tracking.views.config', return_value=''):
self.assertEqual(self.client.post('/api/time/entries/scan/', {}, format='json').status_code, 503)
self.assertFalse(TimeEntry.objects.exists())
class ConcurrentTimeEntryTests(TransactionTestCase):
def test_concurrent_first_entries_are_serialized_per_user(self):
user = get_user_model().objects.create_user(username='concurrent')
ready = Barrier(2)
def create(start_time):
try:
client = APIClient()
client.force_authenticate(user)
ready.wait(timeout=10)
return client.post('/api/time/entries/create/', {
'start_date': '2026-09-23', 'start_time': start_time,
}, format='json').status_code
finally:
connections.close_all()
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(create, ['08:00', '08:01']))
self.assertEqual(sorted(results), [201, 400])
self.assertEqual(TimeEntry.objects.filter(end_time__isnull=True).count(), 1)

View File

@ -5,6 +5,7 @@ from .views import (
TimeEntryScanView, TimeEntryScanView,
TimeEntryDetailView, TimeEntryDetailView,
TimeEntryUpdateView, TimeEntryUpdateView,
TimeEntryGroupView,
WeeklySummaryView, WeeklySummaryView,
) )
@ -12,6 +13,7 @@ urlpatterns = [
path('entries/', TimeEntryListView.as_view(), name='time-entry-list'), path('entries/', TimeEntryListView.as_view(), name='time-entry-list'),
path('entries/create/', TimeEntryCreateView.as_view(), name='time-entry-create'), path('entries/create/', TimeEntryCreateView.as_view(), name='time-entry-create'),
path('entries/scan/', TimeEntryScanView.as_view(), name='time-entry-scan'), path('entries/scan/', TimeEntryScanView.as_view(), name='time-entry-scan'),
path('groups/<uuid:group_id>/', TimeEntryGroupView.as_view(), name='time-entry-group'),
path('entries/<int:entry_id>/', TimeEntryDetailView.as_view(), name='time-entry-detail'), path('entries/<int:entry_id>/', TimeEntryDetailView.as_view(), name='time-entry-detail'),
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'), path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
path('weekly-summary/', WeeklySummaryView.as_view(), name='user-weekly-summary'), path('weekly-summary/', WeeklySummaryView.as_view(), name='user-weekly-summary'),

View File

@ -1,783 +1,234 @@
from datetime import timedelta from datetime import timedelta
from datetime import time as datetime_time
from decouple import config from decouple import config
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import TimeEntry from .models import TimeEntry
from .serializers import ( from .serializers import (
TimeEntryCreateSerializer, TimeEntryCreateSerializer, TimeEntrySerializer,
TimeEntrySerializer, TimeEntryUpdateSerializer, WeeklySummarySerializer,
TimeEntryUpdateSerializer,
WeeklySummarySerializer,
) )
from .services import lock_user, save_period
User = get_user_model() User = get_user_model()
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): class TimeEntryListView(generics.ListAPIView):
serializer_class = TimeEntrySerializer serializer_class = TimeEntrySerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def get_queryset(self): def get_queryset(self):
user = self.request.user return visible_entries(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): class TimeEntryCreateView(generics.CreateAPIView):
serializer_class = TimeEntryCreateSerializer serializer_class = TimeEntryCreateSerializer
permission_classes = [IsAuthenticated] 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): def create(self, request, *args, **kwargs):
serializer = self.get_serializer( serializer = self.get_serializer(data=request.data)
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)
serializer.is_valid(
raise_exception=True,
)
self.perform_create(serializer) class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView):
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 serializer_class = TimeEntrySerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
lookup_url_kwarg = 'entry_id' lookup_url_kwarg = 'entry_id'
def get_queryset(self): def get_queryset(self):
user = self.request.user return visible_entries(self.request.user)
if user.is_staff or user.is_superuser: @transaction.atomic
return TimeEntry.objects.all() def update(self, request, *args, **kwargs):
entry = self.get_object()
return TimeEntry.objects.filter( lock_user(entry.user)
user=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(APIView): 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] permission_classes = [IsAuthenticated]
def put(self, request, entry_id): def get_entries(self, request, group_id):
try: queryset = visible_entries(request.user).filter(group_id=group_id)
entry = TimeEntry.objects.get( first = queryset.order_by('start_date', 'start_time').first()
id=entry_id, if first is None:
) from rest_framework.exceptions import NotFound
raise NotFound()
except TimeEntry.DoesNotExist: lock_user(first.user)
return Response( entries = list(queryset.filter(user=first.user).select_for_update().order_by('start_date', 'start_time'))
{ if not entries:
'error': 'Pointage non trouvé' from rest_framework.exceptions import NotFound
}, raise NotFound()
status=status.HTTP_404_NOT_FOUND, return entries
)
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,
)
@transaction.atomic
def patch(self, request, group_id):
entries = self.get_entries(request, group_id)
serializer = TimeEntryUpdateSerializer( serializer = TimeEntryUpdateSerializer(
entry, entries[0], data=request.data, partial=True,
data=request.data, context={'entries': entries},
) )
serializer.is_valid(raise_exception=True)
result = serializer.save()
return Response(TimeEntrySerializer(result).data)
if serializer.is_valid(): put = patch
serializer.save()
return Response( @transaction.atomic
serializer.data, 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)
return Response(
serializer.errors, class TimeEntryScanView(APIView):
status=status.HTTP_400_BAD_REQUEST, 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_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.'})
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.'})
entry = save_period(request.user, dict(
start_date=open_entry.start_date, start_time=open_entry.start_time,
end_date=today, 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): def build_weekly_summary(user):
today = timezone.localdate() today = timezone.localdate()
start_of_week = today - timedelta(days=today.weekday())
start_of_week = ( entries = list(TimeEntry.objects.filter(
today user=user, start_date__range=(start_of_week, start_of_week + timedelta(days=6)),
- timedelta( ).order_by('start_date', 'start_time'))
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 = [] days_data = []
for offset in range(7):
for day_offset in range(7): day = start_of_week + timedelta(days=offset)
current_day = ( day_entries = [entry for entry in entries if entry.start_date == day]
start_of_week completed = [entry for entry in day_entries if entry.end_time is not None]
+ timedelta( days_data.append({
days=day_offset, '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),
entry = entries_by_date.get( 'entries_count': len(day_entries),
current_day, })
) return dict(user=user.username, username=user.username,
total_hours=round(sum(entry.duration or 0 for entry in entries), 2), entries=days_data)
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): class WeeklySummaryView(APIView):
@ -785,31 +236,8 @@ class WeeklySummaryView(APIView):
def get(self, request, user_id=None): def get(self, request, user_id=None):
user = request.user user = request.user
if user_id is not None and user_id != user.pk:
if ( if not (user.is_staff or user.is_superuser):
user_id raise PermissionDenied('Vous ne pouvez pas consulter ce résumé.')
and ( user = get_object_or_404(User, pk=user_id)
request.user.is_staff return Response(WeeklySummarySerializer(build_weekly_summary(user)).data)
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,
)

View File

@ -1,4 +1,6 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
@ -11,6 +13,7 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
is_admin = serializers.SerializerMethodField() is_admin = serializers.SerializerMethodField()
color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False)
class Meta: class Meta:
model = User model = User
@ -32,6 +35,20 @@ class UserSerializer(serializers.ModelSerializer):
def get_is_admin(self, obj): def get_is_admin(self, obj):
return obj.is_superuser or obj.is_staff return obj.is_superuser or obj.is_staff
def validate(self, attrs):
actor = self.context['request'].user
if not actor.is_superuser:
if self.instance.is_superuser:
raise serializers.ValidationError('Seul un superutilisateur peut modifier ce compte.')
if attrs.get('is_superuser', False):
raise serializers.ValidationError({'is_superuser': 'Seul un superutilisateur peut accorder ce rôle.'})
if self.instance.pk == actor.pk:
if not attrs.get('is_active', self.instance.is_active):
raise serializers.ValidationError({'is_active': 'Vous ne pouvez pas désactiver votre propre compte.'})
if not (attrs.get('is_staff', self.instance.is_staff) or attrs.get('is_superuser', self.instance.is_superuser)):
raise serializers.ValidationError('Vous ne pouvez pas retirer votre propre accès administrateur.')
return attrs
class RegisterSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True)
@ -57,6 +74,11 @@ class RegisterSerializer(serializers.ModelSerializer):
) )
}) })
candidate = User(**{field: attrs.get(field, '') for field in ('username', 'first_name', 'last_name')})
try:
validate_password(attrs['password'], candidate)
except DjangoValidationError as error:
raise serializers.ValidationError({'password': error.messages}) from error
return attrs return attrs
def validate_invite(self, value): def validate_invite(self, value):

View File

@ -2,7 +2,7 @@ from rest_framework import generics
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied, ValidationError
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from .models import Invitation, User from .models import Invitation, User
@ -48,6 +48,13 @@ class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
return super().update(request, *args, **kwargs) return super().update(request, *args, **kwargs)
def perform_destroy(self, instance):
if instance.pk == self.request.user.pk:
raise ValidationError({'error': 'Vous ne pouvez pas supprimer votre propre compte.'})
if instance.is_superuser and not self.request.user.is_superuser:
raise PermissionDenied('Seul un superutilisateur peut supprimer ce compte.')
instance.delete()
class UserPromoteView(APIView): class UserPromoteView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
@ -58,6 +65,8 @@ class UserPromoteView(APIView):
try: try:
user_obj = User.objects.get(id=user) user_obj = User.objects.get(id=user)
if user_obj.is_superuser and not request.user.is_superuser:
raise PermissionDenied('Seul un superutilisateur peut modifier ce compte.')
user_obj.is_staff = True user_obj.is_staff = True
user_obj.save() user_obj.save()
return Response({'message': 'Utilisateur promu avec succès'}, status=200) return Response({'message': 'Utilisateur promu avec succès'}, status=200)
@ -65,23 +74,8 @@ class UserPromoteView(APIView):
return Response({'error': 'Utilisateur non trouvé'}, status=404) return Response({'error': 'Utilisateur non trouvé'}, status=404)
class UserDeleteView(APIView): class UserDeleteView(UserDetailView):
permission_classes = (IsAuthenticated,) http_method_names = ['delete', 'options']
def delete(self, request, user):
if not (request.user.is_staff or request.user.is_superuser):
raise PermissionDenied('Seuls les administrateurs peuvent supprimer des utilisateurs.')
try:
user_obj = User.objects.get(id=user)
# Ne pas permettre de supprimer l'utilisateur courant
if user_obj == request.user:
return Response({'error': 'Vous ne pouvez pas supprimer votre propre compte'}, status=400)
user_obj.delete()
return Response({'message': 'Utilisateur supprimé avec succès'}, status=200)
except User.DoesNotExist:
return Response({'error': 'Utilisateur non trouvé'}, status=404)
class InvitationCreateView(APIView): class InvitationCreateView(APIView):
@ -98,4 +92,3 @@ class InvitationCreateView(APIView):
'token': str(invitation.token), 'token': str(invitation.token),
'expires_at': invitation.expires_at, 'expires_at': invitation.expires_at,
}, status=201) }, status=201)

View File

@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "oxlint", "lint": "oxlint src vite.config.js",
"test": "node --test tests/*.test.js",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

View File

@ -13,14 +13,17 @@ import {
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import MyspacePage from './pages/MyspacePage';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import AdminRoute from './components/AdminRoute'; import AdminRoute from './components/AdminRoute';
import UnloggedRoute from './components/UnloggedRoute';
import NavBar from './components/NavBar'; import NavBar from './components/NavBar';
import AdminUsersPage from './pages/AdminUsersPage'; import AdminUsersPage from './pages/AdminUsersPage';
import RegisterPage from './pages/RegisterPage'; import RegisterPage from './pages/RegisterPage';
import TimeClockPage from './pages/TimeClockPage'; import TimeClockPage from './pages/TimeClockPage';
import WeeklySummaryPage from './pages/WeeklySummaryPage'; import WeeklySummaryPage from './pages/WeeklySummaryPage';
import CalendarPage from './pages/CalendarPage'; import CalendarPage from './pages/CalendarPage';
import NotesPage from './pages/NotesPage';
import { useAuth } from './context/useAuth'; import { useAuth } from './context/useAuth';
// Composant personnalisé pour le trigger mobile avec icône menu // Composant personnalisé pour le trigger mobile avec icône menu
@ -65,12 +68,20 @@ function App() {
<Route <Route
path="/login" path="/login"
element={<LoginPage />} element={
<UnloggedRoute>
<LoginPage />
</UnloggedRoute>
}
/> />
<Route <Route
path="/register" path="/register"
element={<RegisterPage />} element={
<UnloggedRoute>
<RegisterPage />
</UnloggedRoute>
}
/> />
<Route <Route
@ -82,6 +93,15 @@ function App() {
} }
/> />
<Route
path="/myspace"
element={
<ProtectedRoute>
<MyspacePage />
</ProtectedRoute>
}
/>
<Route <Route
path="/" path="/"
element={ element={
@ -123,6 +143,8 @@ function App() {
element={<Navigate to="/" replace />} element={<Navigate to="/" replace />}
/> />
<Route path="/notes" element={<ProtectedRoute><NotesPage /></ProtectedRoute>} />
</Routes> </Routes>
</div> </div>
@ -142,3 +164,4 @@ function App() {
} }
export default App; export default App;

View File

@ -1,46 +1,76 @@
import axios from 'axios'; import axios from 'axios';
import { getApiErrorMessage } from './errors.js';
const apiHost = window.location.hostname;
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.PROD ? '/api' : `http://${apiHost}:1029/api`, baseURL: import.meta.env?.PROD ? '/api' : `http://${window.location.hostname}:1029/api`,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
});
const refreshClient = axios.create({ baseURL: api.defaults.baseURL });
let refreshPromise = null;
export function clearSession() {
localStorage.removeItem('token');
localStorage.removeItem('refresh');
localStorage.removeItem('user');
window.dispatchEvent(new Event('auth:logout'));
}
function expireSession() {
clearSession();
if (!['/login', '/register'].includes(window.location.pathname)) {
window.location.href = '/login';
}
}
function refreshAccessToken() {
if (!refreshPromise) {
const refresh = localStorage.getItem('refresh');
refreshPromise = refreshClient.post('/auth/refresh/', { refresh }).then(({ data }) => {
// Une déconnexion ou une nouvelle connexion peut avoir eu lieu pendant la requête.
if (localStorage.getItem('refresh') !== refresh) {
throw new Error('La session a changé.');
}
localStorage.setItem('token', data.access);
if (data.refresh) localStorage.setItem('refresh', data.refresh);
return data.access;
}).catch((error) => {
if ([400, 401].includes(error.response?.status) && localStorage.getItem('refresh') === refresh) {
expireSession();
}
throw error;
}).finally(() => { refreshPromise = null; });
}
return refreshPromise;
}
api.interceptors.request.use((config) => {
const publicRequest = ['/auth/login/', '/users/register/'].includes(config.url);
const token = localStorage.getItem('token');
config._publicRequest = publicRequest;
if (token && !publicRequest) config.headers.Authorization = `Bearer ${token}`;
else delete config.headers.Authorization;
return config;
}); });
// Ajouter un intercepteur pour gérer les erreurs de réseau
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
if (error.code === 'ECONNREFUSED') { error.message = getApiErrorMessage(error);
console.error('Connexion refusée : le serveur backend n\'est pas accessible'); const request = error.config;
} else if (error.response && error.response.status === 401) { if (error.response?.status !== 401 || !request || request._publicRequest) throw error;
// Token expired or invalid if (request._retry || !localStorage.getItem('refresh')) {
localStorage.removeItem('token'); expireSession();
localStorage.removeItem('user'); throw error;
// Rediriger vers la page de login
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
} }
return Promise.reject(error); request._retry = true;
} const currentToken = localStorage.getItem('token');
); const sentToken = request.headers.Authorization;
// Une autre requête a peut-être déjà renouvelé la session.
// Ajouter un intercepteur pour les requêtes const access = currentToken && sentToken !== `Bearer ${currentToken}`
api.interceptors.request.use( ? currentToken : await refreshAccessToken();
(config) => { request.headers.Authorization = `Bearer ${access}`;
const token = localStorage.getItem('token'); return api(request);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Ajouter un header pour les requêtes CORS
config.headers['Content-Type'] = 'application/json';
config.headers['Accept'] = 'application/json';
return config;
}, },
(error) => {
return Promise.reject(error);
}
); );
export default api; export default api;

View File

@ -0,0 +1,12 @@
export function getApiErrorMessage(error, fallback = 'La requête a échoué.') {
const flatten = (value, prefix = '') => {
if (Array.isArray(value)) return value.flatMap((item) => flatten(item, prefix));
if (value && typeof value === 'object') {
return Object.entries(value).flatMap(([key, item]) =>
flatten(item, ['detail', 'error', 'non_field_errors'].includes(key) ? prefix : key),
);
}
return typeof value === 'string' ? [`${prefix ? `${prefix} : ` : ''}${value}`] : [];
};
return flatten(error.response?.data).join(' ') || error.message || fallback;
}

View File

@ -1,58 +1,9 @@
import axios from './axios'; import api from './axios';
export const eventApi = { export const eventApi = {
// Récupérer tous les événements getAllEvents: async () => (await api.get('/event/')).data,
getAllEvents: async () => { createEvent: async (data) => (await api.post('/event/create/', data)).data,
try { updateEvent: async (id, data) => (await api.patch(`/event/${id}/`, data)).data,
const response = await axios.get('/event/'); deleteEvent: async (id) => (await api.delete(`/event/${id}/`)).data,
return response.data; getEvent: async (id) => (await api.get(`/event/${id}/`)).data,
} catch (error) {
console.error('Error fetching events:', error);
throw error;
}
},
// Créer un nouvel événement
createEvent: async (eventData) => {
try {
const response = await axios.post('/event/create/', eventData);
return response.data;
} catch (error) {
console.error('Error creating event:', error);
throw error;
}
},
// Mettre à jour un événement
updateEvent: async (eventId, eventData) => {
try {
const response = await axios.put(`/event/${eventId}/`, eventData);
return response.data;
} catch (error) {
console.error('Error updating event:', error);
throw error;
}
},
// Supprimer un événement
deleteEvent: async (eventId) => {
try {
const response = await axios.delete(`/event/${eventId}/`);
return response.data;
} catch (error) {
console.error('Error deleting event:', error);
throw error;
}
},
// Récupérer un événement spécifique
getEvent: async (eventId) => {
try {
const response = await axios.get(`/event/${eventId}/`);
return response.data;
} catch (error) {
console.error('Error fetching event:', error);
throw error;
}
}
}; };

10
frontend/src/api/notes.js Normal file
View File

@ -0,0 +1,10 @@
import api from './axios.js';
export const notesApi = {
list: async (params = {}, options = {}) => (await api.get('/notes/', { ...options, params })).data,
recipients: async (options = {}) => (await api.get('/notes/recipients/', options)).data,
create: async (data) => (await api.post('/notes/', data)).data,
update: async (id, data) => (await api.patch(`/notes/${id}/`, data)).data,
remove: async (id) => (await api.delete(`/notes/${id}/`)).data,
complete: async (id, isDone) => (await api.patch(`/notes/${id}/completion/`, { is_done: isDone })).data,
};

View File

@ -1,176 +1,30 @@
import api from './axios'; import api from './axios.js';
export const timeApi = { export const timeApi = {
getTimeEntries: async () => { getTimeEntries: async () => (await api.get('/time/entries/')).data,
getAttendances: async () => (await api.get('/time/entries/')).data,
getUsers: async () => (await api.get('/users/')).data,
createTimeEntry: async (data) => (await api.post('/time/entries/create/', data)).data,
getTimeEntry: async (id) => (await api.get(`/time/entries/${id}/`)).data,
updateTimeEntry: async (id, data) => (await api.patch(`/time/entries/${id}/`, data)).data,
deleteTimeEntry: async (id) => (await api.delete(`/time/entries/${id}/`)).data,
updateTimeEntryGroup: async (id, data) => (await api.patch(`/time/groups/${id}/`, data)).data,
deleteTimeEntryGroup: async (id) => (await api.delete(`/time/groups/${id}/`)).data,
getWeeklySummary: async () => (await api.get('/time/weekly-summary/')).data,
getUserWeeklySummary: async (id) => (await api.get(`/time/weekly-summary/${id}/`)).data,
scanTimeEntry: async (code, options = {}) => {
try { try {
const response = await api.get('/time/entries/'); return (await api.post('/time/entries/scan/', { code, ...options })).data;
return response.data;
} catch (error) { } catch (error) {
throw new Error( const data = error.response?.data || {};
'Failed to fetch time entries: ' + error.conflict = data.conflict;
error.message, error.resolution = data.resolution;
); error.missingTime = data.missing_time;
} error.scanTime = data.scan_time;
}, error.scanDate = data.scan_date;
error.entryId = data.entry_id;
getUsers: async () => { error.code = error.response?.status;
try { throw error;
const response = await api.get('/users/');
return response.data;
} catch (error) {
throw new Error(
'Failed to fetch users: ' +
error.message,
);
}
},
createTimeEntry: async (entryData) => {
try {
const response = await api.post(
'/time/entries/create/',
entryData,
);
return response.data;
} catch (error) {
const message =
error.response?.data?.detail ||
error.response?.data?.error ||
error.message;
throw new Error(
"Erreur lors de la création du pointage : " +
message,
);
}
},
scanTimeEntry: async (
code,
options = {},
) => {
try {
const response = await api.post(
'/time/entries/scan/',
{
code,
...options,
},
);
return response.data;
} catch (error) {
const data =
error.response?.data || {};
const scanError = new Error(
data.error ||
`Erreur lors du pointage: ${error.message}`,
);
scanError.conflict =
data.conflict;
scanError.resolution =
data.resolution;
scanError.missingTime =
data.missing_time;
scanError.scanTime =
data.scan_time;
scanError.code =
error.response?.status;
throw scanError;
}
},
getTimeEntry: async (id) => {
try {
const response = await api.get(
`/time/entries/${id}/`,
);
return response.data;
} catch (error) {
throw new Error(
'Failed to fetch time entry: ' +
error.message,
);
}
},
updateTimeEntry: async (
id,
entryData,
) => {
try {
const response = await api.put(
`/time/entries/${id}/update/`,
entryData,
);
return response.data;
} catch (error) {
const message =
error.response?.data?.detail ||
error.response?.data?.error ||
error.message;
throw new Error(
'Erreur lors de la mise à jour : ' +
message,
);
}
},
deleteTimeEntry: async (id) => {
try {
const response = await api.delete(
`/time/entries/${id}/`,
);
return response.data;
} catch (error) {
throw new Error(
error.response?.data?.detail ||
`Erreur lors de la suppression: ${error.message}`,
);
}
},
getWeeklySummary: async () => {
try {
const response = await api.get(
'/time/weekly-summary/',
);
return response.data;
} catch (error) {
throw new Error(
'Failed to fetch weekly summary: ' +
error.message,
);
}
},
getUserWeeklySummary: async (
userId,
) => {
try {
const response = await api.get(
`/time/weekly-summary/${userId}/`,
);
return response.data;
} catch (error) {
throw new Error(
'Failed to fetch user weekly summary: ' +
error.message,
);
} }
}, },
}; };

View File

@ -1,52 +1,9 @@
import api from './axios'; import api from './axios';
export const userApi = { export const userApi = {
// Get all users getUsers: async () => (await api.get('/users/')).data,
getUsers: async () => { deleteUser: async (id) => (await api.delete(`/users/${id}/`)).data,
try { promoteUser: async (id) => (await api.post(`/users/${id}/promote/`)).data,
const response = await api.get('/users/'); updateUser: async (id, data) => (await api.patch(`/users/${id}/`, data)).data,
return response.data; createInvitation: async () => (await api.post('/users/invitations/')).data,
} catch (error) {
throw new Error('Failed to fetch users: ' + error.message);
}
},
// Delete a user
deleteUser: async (id) => {
try {
// Assurer que l'URL se termine par un slash
const response = await api.delete(`/users/${id}/`);
return response.data;
} catch (error) {
throw new Error('Failed to delete user: ' + error.message);
}
},
// Promote a user to admin
promoteUser: async (id) => {
try {
const response = await api.post(`/users/${id}/promote/`);
return response.data;
} catch (error) {
throw new Error('Failed to promote user: ' + error.message);
}
},
updateUser: async (id, data) => {
try {
const response = await api.patch(`/users/${id}/`, data);
return response.data;
} catch (error) {
throw new Error('Failed to update user: ' + error.message);
}
},
createInvitation: async () => {
try {
const response = await api.post('/users/invitations/');
return response.data;
} catch (error) {
throw new Error('Failed to create invitation: ' + error.message);
}
},
}; };

View File

@ -18,7 +18,7 @@ const AdminRoute = ({ children }) => {
// If user is not admin, redirect to profile page // If user is not admin, redirect to profile page
if (!isAdmin) { if (!isAdmin) {
return <Navigate to="/profile" state={{ from: location }} replace />; return <Navigate to="/" replace />;
} }
return children; return children;

View File

@ -8,22 +8,23 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
const ConfirmDialog = ({ const ConfirmDialog = ({
title = "Confirmation", title = 'Confirmation',
description, description,
onConfirm, onConfirm,
onCancel, onCancel,
children, children,
confirmText = "Confirmer", confirmText = 'Confirmer',
cancelText = "Annuler" cancelText = 'Annuler',
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const handleConfirm = () => { const handleConfirm = () => {
setIsOpen(false); setIsOpen(false);
if (onConfirm) { if (onConfirm) {
onConfirm(); onConfirm();
} }
@ -31,24 +32,40 @@ const ConfirmDialog = ({
const handleCancel = () => { const handleCancel = () => {
setIsOpen(false); setIsOpen(false);
if (onCancel) { if (onCancel) {
onCancel(); onCancel();
} }
}; };
return ( return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}> <AlertDialog
<AlertDialogTrigger asChild onClick={(e) => e.stopPropagation()}> open={isOpen}
{children} onOpenChange={setIsOpen}
</AlertDialogTrigger> >
<AlertDialogTrigger
render={children}
/>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription> {title}
</AlertDialogTitle>
<AlertDialogDescription>
{description}
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>{cancelText}</AlertDialogCancel> <AlertDialogCancel onClick={handleCancel}>
<AlertDialogAction onClick={handleConfirm}>{confirmText}</AlertDialogAction> {cancelText}
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@ -0,0 +1,102 @@
import React from 'react';
const formatTime = (time) => {
if (!time) return null;
return String(time).slice(0, 5);
};
const HoverInfo = ({
title,
description,
startTime,
endTime,
color,
position,
}) => {
if (!position || !title) {
return null;
}
const formattedStartTime = formatTime(startTime);
const formattedEndTime = formatTime(endTime);
const hasTime =
formattedStartTime ||
formattedEndTime;
return (
<div
className="
pointer-events-none
fixed
z-[9999]
max-w-[320px]
rounded-lg
border
border-border
bg-popover
px-3
py-2.5
text-popover-foreground
shadow-lg
"
style={{
left: `${position.x + 12}px`,
top: `${position.y + 12}px`,
}}
>
<div className="flex items-start gap-2">
{color && (
<div
className="
mt-0.5
h-3
w-3
shrink-0
rounded-full
"
style={{
backgroundColor: color,
}}
/>
)}
<div className="text-sm font-semibold">
{title}
</div>
</div>
{description && (
<div className="mt-1 text-xs text-muted-foreground">
{description}
</div>
)}
{hasTime && (
<div className="mt-2 text-xs text-muted-foreground">
{formattedStartTime && (
<span>
Début : {formattedStartTime}
</span>
)}
{formattedStartTime &&
formattedEndTime && (
<span className="mx-1">
•
</span>
)}
{formattedEndTime && (
<span>
Fin : {formattedEndTime}
</span>
)}
</div>
)}
</div>
);
};
export default HoverInfo;

View File

@ -23,6 +23,7 @@ import {
LogOut, LogOut,
Wheat, Wheat,
Calendar, Calendar,
ClipboardList,
} from 'lucide-react'; } from 'lucide-react';
const NavBar = () => { const NavBar = () => {
@ -68,10 +69,13 @@ const NavBar = () => {
} }
}; };
/*
* Navigation principale
*/
const navigation = [ const navigation = [
{ {
title: 'Profil', title: user.first_name,
href: '/profile', href: '/myspace',
icon: User, icon: User,
}, },
{ {
@ -86,7 +90,35 @@ const NavBar = () => {
}, },
]; ];
const menuItemClass = 'h-12 min-h-12 flex items-center'; /*
* Entreprise
*/
const entreprise = [
{
title: 'Calendrier',
href: '/calendar',
icon: Calendar,
},
{
title: 'Espace d\'échanges',
href: '/notes',
icon: ClipboardList,
},
];
/*
* Administration
*/
const administration = [
{
title: 'Utilisateurs',
href: '/users',
icon: Users,
},
];
const menuItemClass =
'h-12 min-h-12 flex items-center';
const buttonClass = ` const buttonClass = `
w-full w-full
@ -116,7 +148,6 @@ const NavBar = () => {
px-2 px-2
" "
> >
{/* Nom du groupe */}
<span <span
className=" className="
truncate truncate
@ -128,7 +159,6 @@ const NavBar = () => {
{children} {children}
</span> </span>
{/* Séparation en mode plié */}
<div <div
className=" className="
hidden hidden
@ -141,6 +171,40 @@ const NavBar = () => {
</div> </div>
); );
/*
* Permet d'éviter de répéter le rendu
* de chaque élément de navigation.
*/
const renderMenuItems = (items) =>
items.map((item) => {
const Icon = item.icon;
return (
<SidebarMenuItem
key={item.href}
className={menuItemClass}
>
<SidebarMenuButton
tooltip={item.title}
onClick={handleCloseMenu}
className={buttonClass}
render={
<NavLink
to={item.href}
className={linkClass}
>
<Icon className="size-6 shrink-0" />
<span className="truncate text-base">
{item.title}
</span>
</NavLink>
}
/>
</SidebarMenuItem>
);
});
return ( return (
<Sidebar <Sidebar
className="border-0" className="border-0"
@ -195,34 +259,7 @@ const NavBar = () => {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{navigation.map((item) => { {renderMenuItems(navigation)}
const Icon = item.icon;
return (
<SidebarMenuItem
key={item.href}
className={menuItemClass}
>
<SidebarMenuButton
tooltip={item.title}
onClick={handleCloseMenu}
className={buttonClass}
render={
<NavLink
to={item.href}
className={linkClass}
>
<Icon className="size-6 shrink-0" />
<span className="truncate text-base">
{item.title}
</span>
</NavLink>
}
/>
</SidebarMenuItem>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
@ -235,25 +272,7 @@ const NavBar = () => {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem className={menuItemClass}> {renderMenuItems(entreprise)}
<SidebarMenuButton
tooltip="Calendrier"
onClick={handleCloseMenu}
className={buttonClass}
render={
<NavLink
to="/calendar"
className={linkClass}
>
<Calendar className="size-6 shrink-0" />
<span className="truncate text-base">
Calendrier
</span>
</NavLink>
}
/>
</SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
@ -267,25 +286,7 @@ const NavBar = () => {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem className={menuItemClass}> {renderMenuItems(administration)}
<SidebarMenuButton
tooltip="Comptes existants"
onClick={handleCloseMenu}
className={buttonClass}
render={
<NavLink
to="/users"
className={linkClass}
>
<Users className="size-6 shrink-0" />
<span className="truncate text-base">
Utilisateurs
</span>
</NavLink>
}
/>
</SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>

View File

@ -0,0 +1,22 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/useAuth';
import { Skeleton } from "@/components/ui/skeleton";
const UnloggedRoute = ({ children }) => {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return <Skeleton className="w-full h-16" />;
}
if (user) {
// Redirect them to the /login page, but save the current location they were trying to go to.
return <Navigate to="/myspace" state={{ from: location }} replace />;
}
return children;
};
export default UnloggedRoute;

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import api from '../api/axios'; import api, { clearSession } from '../api/axios';
import { AuthContext } from './authContextValue'; import { AuthContext } from './authContextValue';
export const AuthProvider = ({ children }) => { export const AuthProvider = ({ children }) => {
@ -8,31 +8,40 @@ export const AuthProvider = ({ children }) => {
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false;
const onLogout = () => {
setUser(null);
setIsAdmin(false);
};
window.addEventListener('auth:logout', onLogout);
const checkAuth = async () => { const checkAuth = async () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const savedUser = localStorage.getItem('user');
if (token && savedUser) { if (token) {
try { try {
// Verify token by fetching user profile // Verify token by fetching user profile
const response = await api.get('/users/me/'); const response = await api.get('/users/me/');
const userData = response.data; const userData = response.data;
setUser(userData); if (!cancelled && localStorage.getItem('token')) {
setIsAdmin(userData.is_admin || false); localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
setIsAdmin(userData.is_admin || false);
}
} catch (error) { } catch (error) {
console.error('Failed to verify authentication', error); console.error('Failed to verify authentication', error);
localStorage.removeItem('token');
localStorage.removeItem('refresh');
localStorage.removeItem('user');
} }
} }
setLoading(false); if (!cancelled) setLoading(false);
}; };
checkAuth(); checkAuth();
return () => {
cancelled = true;
window.removeEventListener('auth:logout', onLogout);
};
}, []); }, []);
const login = async (username, password) => { const login = async (username, password) => {
@ -65,9 +74,7 @@ export const AuthProvider = ({ children }) => {
}; };
const logout = () => { const logout = () => {
localStorage.removeItem('token'); clearSession();
localStorage.removeItem('refresh');
localStorage.removeItem('user');
setUser(null); setUser(null);
setIsAdmin(false); setIsAdmin(false);

View File

@ -17,12 +17,12 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
import { Users} from 'lucide-react'; import { Users} from 'lucide-react';
const AdminUsersPage = () => { const AdminUsersPage = () => {
const { user, logout } = useAuth(); const { user: currentUser } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -51,24 +51,6 @@ const AdminUsersPage = () => {
console.error(error); console.error(error);
} }
}; };
const getUserId = (user) => {
if (!user) return '';
if (typeof user === 'number') return String(user);
if (typeof user === 'string') {
return /^\d+$/.test(user) ? user : '';
}
return String(
user.id ??
user.pk ??
user.user_id ??
user.userId ??
user.user?.id ??
user.user?.pk ??
''
);
};
const copyToClipboard = () => { const copyToClipboard = () => {
if (inviteLink) { if (inviteLink) {
navigator.clipboard.writeText(inviteLink).then(() => { navigator.clipboard.writeText(inviteLink).then(() => {
@ -102,32 +84,19 @@ const AdminUsersPage = () => {
try { try {
await userApi.deleteUser(userId); await userApi.deleteUser(userId);
if (String(userId) === String(getUserId(user))) {
logout();
return;
}
setUsers(users.filter(user => String(user.id) !== String(userId))); setUsers(users.filter(user => String(user.id) !== String(userId)));
} catch (err) { } catch (err) {
setError('Erreur lors de la suppression de l\'utilisateur'); setError(err.message);
console.error(err); console.error(err);
} }
}; };
const handleMakeAdmin = async (userId) => { const handleMakeAdmin = async (userId) => {
const confirmed = await new Promise((resolve) => {
resolve(true);
});
if (!confirmed) {
return;
}
try { try {
await userApi.promoteUser(userId); await userApi.promoteUser(userId);
window.location.reload(); window.location.reload();
} catch (err) { } catch (err) {
setError('Erreur lors de la promotion de l\'utilisateur'); setError(err.message);
console.error(err); console.error(err);
} }
}; };
@ -139,10 +108,12 @@ const AdminUsersPage = () => {
} }
try { try {
await userApi.updateUser(userId, editForm); const payload = { ...editForm };
if (!currentUser.is_superuser) delete payload.is_superuser;
await userApi.updateUser(userId, payload);
window.location.reload(); window.location.reload();
} catch (err) { } catch (err) {
setError('Erreur lors de la mise à jour de l\'utilisateur'); setError(err.message);
console.error(err); console.error(err);
} }
}; };
@ -260,20 +231,31 @@ const AdminUsersPage = () => {
} }
}} }}
> >
<DialogTrigger asChild> <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" disabled={user.is_superuser && !currentUser.is_superuser}
onClick={() => setEditingUser(user.id)} onClick={() => {
> setEditingUser(user.id);
Éditer setEditForm({
</Button> first_name: user.first_name || '',
</DialogTrigger> last_name: user.last_name || '',
email: user.email || '',
color: user.color || '#0000ff',
is_active: user.is_active,
is_staff: user.is_staff || user.is_admin,
is_superuser: user.is_superuser,
});
}}
>
Éditer
</Button>
<DialogContent> <DialogContent>
<DialogHeader className="border-b"> <DialogHeader className="border-b">
<DialogTitle>Modifier l'utilisateur</DialogTitle> <DialogTitle>Modifier l'utilisateur</DialogTitle>
</DialogHeader> </DialogHeader>
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -418,7 +400,7 @@ const AdminUsersPage = () => {
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?" description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
onConfirm={() => handleDeleteUser(user.id)} onConfirm={() => handleDeleteUser(user.id)}
> >
<Button variant="destructive" size="sm"> <Button variant="destructive" size="sm" disabled={String(user.id) === String(currentUser.id) || (user.is_superuser && !currentUser.is_superuser)}>
Supprimer Supprimer
</Button> </Button>
</ConfirmDialog> </ConfirmDialog>

File diff suppressed because it is too large Load Diff

View File

@ -1,72 +1,77 @@
import React from 'react'; import React from 'react';
import LoadingCard from '@/components/LoadingCard'; import { Link } from 'react-router-dom';
import MainCard from '@/components/MainCard'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { useAuth } from '../context/useAuth'; import { Button } from '@/components/ui/button';
import { import MainCard from "@/components/MainCard"
CardContent, import { House } from 'lucide-react';
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { User } from 'lucide-react';
const HomePage = () => { const HomePage = () => {
const { user } = useAuth(); const tabs = [
{
if (!user) { name: 'Accueil',
return ( description: 'Page d\'accueil principale avec la liste des onglets et leurs utilités',
<LoadingCard path: '/'
icon={User} },
title="Profil" {
message="Chargement du profil..." name: 'Profil',
/> description: 'Ton profil',
); path: '/profile'
} },
{
name: 'Utilisateurs',
description: 'Gestion des utilisateurs et permissions',
path: '/users'
},
{
name: 'Produits',
description: 'Gestion des produits et catégories',
path: '/notes'
},
{
name: 'Commandes',
description: 'Suivi et gestion des commandes',
path: '/calendar'
},
{
name: 'Statistiques',
description: 'Vue d\'ensemble des statistiques et analyses',
path: '/weekly-summary'
},
{
name: 'Paramètres',
description: 'Configuration générale de l\'application',
path: '/pointage'
}
];
return ( return (
<MainCard title="Profil" icon={User}> <MainCard title="Accueil" icon={House}>
<CardContent className="overflow-y-auto"> <main className="home-content">
<div className="space-y-4"> <div className="tabs-list">
<div className="flex items-center justify-between"> <h2>Liste des onglets</h2>
<span className="font-medium">Prénom:</span> <div className="tabs-grid">
<span>{user.first_name}</span> {tabs.map((tab, index) => (
</div> <Card key={index} className="tab-card">
<CardHeader>
<Separator /> <CardTitle>{tab.name}</CardTitle>
<CardDescription>{tab.description}</CardDescription>
<div className="flex items-center justify-between"> </CardHeader>
<span className="font-medium">Nom:</span> <CardContent>
<span>{user.last_name}</span> <Button asChild variant="default">
</div> <Link to={tab.path}>
Accéder à {tab.name}
<Separator /> </Link>
</Button>
<div className="flex items-center justify-between"> </CardContent>
<span className="font-medium">Admin:</span> </Card>
<Badge variant={user.is_admin ? 'default' : 'secondary'}> ))}
{user.is_admin ? 'Oui' : 'Non'}
</Badge>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-medium">Nom d'utilisateur:</span>
<span>{user.username}</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-medium">Date d'inscription:</span>
<span>
{user.date_joined
? new Date(user.date_joined).toLocaleDateString('fr-FR')
: 'Non spécifié'}
</span>
</div> </div>
</div> </div>
</CardContent> </main>
<footer className="home-footer">
<p>© 2023 - Application</p>
</footer>
</MainCard> </MainCard>
); );
}; };

View File

@ -0,0 +1,74 @@
import React from 'react';
import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard';
import { useAuth } from '../context/useAuth';
import {
CardContent,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { User } from 'lucide-react';
const MyspacePage = () => {
const { user } = useAuth();
if (!user) {
return (
<LoadingCard
icon={User}
title="Profil"
message="Chargement du profil..."
/>
);
}
return (
<MainCard title={user.first_name} icon={User}>
<CardContent className="overflow-y-auto">
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="font-medium">Prénom:</span>
<span>{user.first_name}</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-medium">Nom:</span>
<span>{user.last_name}</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-medium">Admin:</span>
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
{user.is_admin ? 'Oui' : 'Non'}
</Badge>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-medium">Nom d'utilisateur:</span>
<span>{user.username}</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-medium">Date d'inscription:</span>
<span>
{user.date_joined
? new Date(user.date_joined).toLocaleDateString('fr-FR')
: 'Non spécifié'}
</span>
</div>
</div>
</CardContent>
</MainCard>
);
};
export default MyspacePage;

File diff suppressed because it is too large Load Diff

View File

@ -179,95 +179,6 @@ const formatDate = (
DATE/TIME DURATION HELPERS DATE/TIME DURATION HELPERS
========================================================= */ ========================================================= */
const parseDateTime = (
dateValue,
timeValue,
) => {
if (!dateValue || !timeValue) {
return null;
}
const [
year,
month,
day,
] = dateValue.split('-').map(Number);
const [
hours,
minutes,
seconds = 0,
] = timeValue
.split(':')
.map(Number);
if (
!year ||
!month ||
!day ||
Number.isNaN(hours) ||
Number.isNaN(minutes)
) {
return null;
}
return new Date(
year,
month - 1,
day,
hours,
minutes,
Number(seconds) || 0,
);
};
const calculateDuration = ({
startDate,
startTime,
endDate,
endTime,
}) => {
if (
!startDate ||
!startTime ||
!endDate ||
!endTime
) {
return null;
}
const start =
parseDateTime(
startDate,
startTime,
);
const end =
parseDateTime(
endDate,
endTime,
);
if (!start || !end) {
return null;
}
const difference =
end.getTime() -
start.getTime();
if (difference < 0) {
return null;
}
return (
difference /
(1000 * 60 * 60)
);
};
/* ========================================================= /* =========================================================
DATE PICKER DATE PICKER
========================================================= */ ========================================================= */
@ -446,11 +357,7 @@ const TimePicker = ({
); );
}; };
const handleWheel = ( const handleWheel = (event, type) => {
event,
type,
) => {
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const direction = const direction =
@ -473,10 +380,7 @@ const TimePicker = ({
}; };
}; };
const handleTouchMove = ( const handleTouchMove = (event, type) => {
event,
type,
) => {
if (!touchState.current.active) { if (!touchState.current.active) {
return; return;
} }
@ -489,8 +393,7 @@ const TimePicker = ({
touchState.current.lastY = currentY; touchState.current.lastY = currentY;
touchState.current.accumulated += touchState.current.accumulated += deltaY;
deltaY;
const pixelsPerUnit = 18; const pixelsPerUnit = 18;
@ -512,7 +415,6 @@ const TimePicker = ({
changeMinute(steps); changeMinute(steps);
} }
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}; };
@ -792,7 +694,7 @@ const TimeClockPage = () => {
newEntry, newEntry,
setNewEntry, setNewEntry,
] = useState({ ] = useState({
date: today, start_date: today,
end_date: today, end_date: today,
start_time: '', start_time: '',
end_time: '', end_time: '',
@ -807,7 +709,7 @@ const TimeClockPage = () => {
editForm, editForm,
setEditForm, setEditForm,
] = useState({ ] = useState({
date: '', start_date: '',
end_date: '', end_date: '',
start_time: '', start_time: '',
end_time: '', end_time: '',
@ -1005,24 +907,14 @@ const TimeClockPage = () => {
isGrouped: false, isGrouped: false,
sourceEntries: [entry], sourceEntries: [entry],
displayStartDate: displayStartDate:
entry.date, entry.start_date,
displayEndDate: displayEndDate:
entry.date, entry.start_date,
displayStartTime: displayStartTime:
entry.start_time, entry.start_time,
displayEndTime: displayEndTime:
entry.end_time, entry.end_time,
displayDuration: displayDuration: entry.duration,
calculateDuration({
startDate:
entry.date,
startTime:
entry.start_time,
endDate:
entry.date,
endTime:
entry.end_time,
}),
}); });
return; return;
@ -1059,10 +951,10 @@ const TimeClockPage = () => {
(a, b) => { (a, b) => {
const dateCompare = const dateCompare =
String( String(
a.date || '', a.start_date || '',
).localeCompare( ).localeCompare(
String( String(
b.date || '', b.start_date || '',
), ),
); );
@ -1091,11 +983,11 @@ const TimeClockPage = () => {
]; ];
const startDate = const startDate =
firstEntry?.date || firstEntry?.start_date ||
''; '';
const endDate = const endDate =
lastEntry?.date || lastEntry?.start_date ||
startDate; startDate;
const startTime = const startTime =
@ -1121,7 +1013,7 @@ const TimeClockPage = () => {
sourceEntries: sourceEntries:
sortedEntries, sortedEntries,
date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
@ -1143,13 +1035,9 @@ const TimeClockPage = () => {
displayEndTime: displayEndTime:
endTime, endTime,
displayDuration: displayDuration: sortedEntries.every((item) => item.duration != null)
calculateDuration({ ? sortedEntries.reduce((total, item) => total + Number(item.duration), 0)
startDate, : null,
startTime,
endDate,
endTime,
}),
}; };
}, },
); );
@ -1166,12 +1054,12 @@ const TimeClockPage = () => {
const dateCompare = const dateCompare =
String( String(
b.displayStartDate || b.displayStartDate ||
b.date || b.start_date ||
'', '',
).localeCompare( ).localeCompare(
String( String(
a.displayStartDate || a.displayStartDate ||
a.date || a.start_date ||
'', '',
), ),
); );
@ -1393,12 +1281,12 @@ const TimeClockPage = () => {
========================================================= */ ========================================================= */
const validateEntry = ({ const validateEntry = ({
date, start_date,
end_date, end_date,
start_time, start_time,
end_time, end_time,
}) => { }) => {
if (!date || !end_date) { if (!start_date || !end_date) {
return 'Veuillez renseigner les dates.'; return 'Veuillez renseigner les dates.';
} }
@ -1409,12 +1297,12 @@ const TimeClockPage = () => {
return 'Veuillez renseigner une heure d’entrée et une heure de sortie.'; return 'Veuillez renseigner une heure d’entrée et une heure de sortie.';
} }
if (end_date < date) { if (end_date < start_date) {
return 'La date de sortie doit être postérieure ou égale à la date d’entrée.'; return 'La date de sortie doit être postérieure ou égale à la date d’entrée.';
} }
if ( if (
date === end_date && start_date === end_date &&
end_time <= start_time end_time <= start_time
) { ) {
return "L'heure de sortie doit être après l'heure d'entrée lorsque le pointage est sur la même journée."; return "L'heure de sortie doit être après l'heure d'entrée lorsque le pointage est sur la même journée.";
@ -1458,7 +1346,7 @@ const TimeClockPage = () => {
try { try {
await timeApi.createTimeEntry( await timeApi.createTimeEntry(
{ {
date: newEntry.date, start_date: newEntry.start_date,
end_date: end_date:
newEntry.end_date, newEntry.end_date,
@ -1485,16 +1373,15 @@ const TimeClockPage = () => {
setNewEntry({ setNewEntry({
date: today, start_date: today,
end_date: today, end_date: today,
start_time: '', start_time: '',
end_time: '', end_time: '',
}); });
await loadTimeEntries();
setError(''); setError('');
await loadTimeEntries();
} catch (err) { } catch (err) {
setError( setError(
err.message || err.message ||
@ -1524,10 +1411,10 @@ const TimeClockPage = () => {
(a, b) => { (a, b) => {
const dateCompare = const dateCompare =
String( String(
a.date || '', a.start_date || '',
).localeCompare( ).localeCompare(
String( String(
b.date || '', b.start_date || '',
), ),
); );
@ -1556,15 +1443,15 @@ const TimeClockPage = () => {
]; ];
setEditForm({ setEditForm({
date: start_date:
firstEntry?.date || firstEntry?.start_date ||
entry.date || entry.start_date ||
'', '',
end_date: end_date:
lastEntry?.date || lastEntry?.start_date ||
entry.end_date || entry.end_date ||
entry.date || entry.start_date ||
'', '',
start_time: start_time:
@ -1577,12 +1464,12 @@ const TimeClockPage = () => {
}); });
} else { } else {
setEditForm({ setEditForm({
date: start_date:
entry.date || '', entry.start_date || '',
end_date: end_date:
entry.end_date || entry.end_date ||
entry.date || entry.start_date ||
'', '',
start_time: start_time:
@ -1618,122 +1505,23 @@ const TimeClockPage = () => {
try { try {
if ( if (editingEntry.isGrouped) {
editingEntry.isGrouped await timeApi.updateTimeEntryGroup(editingEntry.group_id, editForm);
) {
const sortedEntries =
[...(
editingEntry.sourceEntries ||
[]
)].sort(
(a, b) => {
const dateCompare =
String(
a.date || '',
).localeCompare(
String(
b.date || '',
),
);
if (
dateCompare !== 0
) {
return dateCompare;
}
return String(
a.start_time || '',
).localeCompare(
String(
b.start_time || '',
),
);
},
);
const firstEntry =
sortedEntries[0];
const lastEntry =
sortedEntries[
sortedEntries.length - 1
];
if (
!firstEntry ||
!lastEntry
) {
throw new Error(
'Impossible de déterminer les lignes du pointage multi-jours.',
);
}
if (
firstEntry.id ===
lastEntry.id
) {
await timeApi.updateTimeEntry(
firstEntry.id,
{
start_time:
editForm.start_time,
end_time:
editForm.end_time,
},
);
} else {
await timeApi.updateTimeEntry(
firstEntry.id,
{
start_time:
editForm.start_time,
end_time:
firstEntry.end_time,
},
);
await timeApi.updateTimeEntry(
lastEntry.id,
{
start_time:
lastEntry.start_time,
end_time:
editForm.end_time,
},
);
}
} else { } else {
await timeApi.updateTimeEntry( await timeApi.updateTimeEntry(editingEntry.id, editForm);
editingEntry.id,
{
start_time:
editForm.start_time,
end_time:
editForm.end_time,
},
);
} }
setEditingEntry(null); setEditingEntry(null);
setEditForm({ setEditForm({
date: '', start_date: '',
end_date: '', end_date: '',
start_time: '', start_time: '',
end_time: '', end_time: '',
}); });
await loadTimeEntries();
setError(''); setError('');
await loadTimeEntries();
} catch (err) { } catch (err) {
setError( setError(
err.message || err.message ||
@ -1752,30 +1540,13 @@ const TimeClockPage = () => {
const handleDeleteEntry = const handleDeleteEntry =
async (entry) => { async (entry) => {
try { try {
if ( if (entry.isGrouped) {
entry?.isGrouped && await timeApi.deleteTimeEntryGroup(entry.group_id);
Array.isArray(
entry.sourceEntries,
)
) {
for (
const sourceEntry of
entry.sourceEntries
) {
await timeApi.deleteTimeEntry(
sourceEntry.id,
);
}
} else { } else {
await timeApi.deleteTimeEntry( await timeApi.deleteTimeEntry(entry.id);
entry.id,
);
} }
await loadTimeEntries();
setError(''); setError('');
await loadTimeEntries();
} catch (err) { } catch (err) {
setError( setError(
'Erreur lors de la suppression du pointage', 'Erreur lors de la suppression du pointage',
@ -1897,6 +1668,8 @@ const TimeClockPage = () => {
scanError.missingTime, scanError.missingTime,
scanTime: scanTime:
scanError.scanTime, scanError.scanTime,
scanDate: scanError.scanDate,
entryId: scanError.entryId,
step: 'confirm', step: 'confirm',
}); });
@ -2017,6 +1790,8 @@ const TimeClockPage = () => {
resolution: resolution:
config.resolution, config.resolution,
time: conflictTime, time: conflictTime,
scan_date: scanConflict.scanDate,
entry_id: scanConflict.entryId,
}; };
if ( if (
@ -2411,16 +2186,16 @@ const TimeClockPage = () => {
const formatEntryDate = ( const formatEntryDate = (
entry, entry,
) => { ) => {
if (!entry?.date) { if (!entry?.start_date) {
return '────'; return '────';
} }
const startDate = const startDate =
formatDate(entry.date); formatDate(entry.start_date);
const endDate = const endDate =
entry.end_date && entry.end_date &&
entry.end_date !== entry.date entry.end_date !== entry.start_date
? formatDate( ? formatDate(
entry.end_date, entry.end_date,
) )
@ -2901,13 +2676,13 @@ const TimeClockPage = () => {
{editingEntry.isGrouped ? ( {editingEntry.isGrouped ? (
<div className="flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm"> <div className="flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
{formatDateLabel( {formatDateLabel(
editForm.date, editForm.start_date,
)} )}
</div> </div>
) : ( ) : (
<DatePicker <DatePicker
value={ value={
editForm.date editForm.start_date
} }
onChange={( onChange={(
value, value,
@ -2915,7 +2690,7 @@ const TimeClockPage = () => {
setEditForm( setEditForm(
{ {
...editForm, ...editForm,
date: start_date:
value, value,
end_date: end_date:
@ -2980,7 +2755,7 @@ const TimeClockPage = () => {
editForm.end_date editForm.end_date
} }
min={ min={
editForm.date editForm.start_date
} }
onChange={( onChange={(
value, value,
@ -3080,7 +2855,7 @@ const TimeClockPage = () => {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<DatePicker <DatePicker
value={ value={
newEntry.date newEntry.start_date
} }
onChange={( onChange={(
value, value,
@ -3088,7 +2863,7 @@ const TimeClockPage = () => {
setNewEntry( setNewEntry(
{ {
...newEntry, ...newEntry,
date: start_date:
value, value,
end_date: end_date:
@ -3145,7 +2920,7 @@ const TimeClockPage = () => {
newEntry.end_date newEntry.end_date
} }
min={ min={
newEntry.date newEntry.start_date
} }
onChange={( onChange={(
value, value,
@ -3396,7 +3171,7 @@ const TimeClockPage = () => {
description={ description={
entry.isGrouped entry.isGrouped
? `Êtes-vous sûr de vouloir supprimer ce pointage multi-jours du ${formatDate( ? `Êtes-vous sûr de vouloir supprimer ce pointage multi-jours du ${formatDate(
entry.date, entry.start_date,
)} au ${formatDate( )} au ${formatDate(
entry.end_date, entry.end_date,
)} ?` )} ?`

View File

@ -8,13 +8,9 @@ import { timeApi } from '../api/time';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
import LoadingCard from '@/components/LoadingCard'; import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard'; import MainCard from '@/components/MainCard';
import HoverInfo from '@/components/HoverInfo';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { CardContent } from '@/components/ui/card';
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
ChevronLeft, ChevronLeft,
@ -29,15 +25,6 @@ const DEFAULT_END_HOUR = 18;
const SCHEDULE_HEADER_HEIGHT = 58; const SCHEDULE_HEADER_HEIGHT = 58;
const MIN_SCHEDULE_HEIGHT = 180; const MIN_SCHEDULE_HEIGHT = 180;
/*
* Hauteur minimale d'une heure sur mobile.
*
* Si l'espace disponible permet de faire plus grand :
* les heures prennent toute la place disponible.
*
* Si l'espace disponible est trop petit :
* le planning devient scrollable.
*/
const MOBILE_MIN_HOUR_HEIGHT = 52; const MOBILE_MIN_HOUR_HEIGHT = 52;
const MOBILE_TIME_COLUMN_WIDTH = 64; const MOBILE_TIME_COLUMN_WIDTH = 64;
@ -94,6 +81,55 @@ const WeeklySummaryPage = ({ userId }) => {
const [mobileContainerWidth, setMobileContainerWidth] = const [mobileContainerWidth, setMobileContainerWidth] =
useState(0); useState(0);
/*
* ------------------------------------------------------------
* HoverInfo
* ------------------------------------------------------------
*/
const [hoverInfo, setHoverInfo] = useState(null);
const showHoverInfo = ({
event,
title,
description,
startTime,
endTime,
color,
}) => {
setHoverInfo({
title,
description,
startTime,
endTime,
color,
position: {
x: event.clientX,
y: event.clientY,
},
});
};
const moveHoverInfo = (event) => {
setHoverInfo((current) => {
if (!current) {
return null;
}
return {
...current,
position: {
x: event.clientX,
y: event.clientY,
},
};
});
};
const hideHoverInfo = () => {
setHoverInfo(null);
};
const scheduleTableRef = useRef(null); const scheduleTableRef = useRef(null);
/* /*
@ -223,6 +259,16 @@ const WeeklySummaryPage = ({ userId }) => {
return null; return null;
} }
const existingUser = users.find(
(user) =>
getUserId(user) ===
String(entryUserId),
);
if (existingUser) {
return existingUser;
}
return { return {
id: entryUserId, id: entryUserId,
first_name: entry.first_name, first_name: entry.first_name,
@ -400,15 +446,6 @@ const WeeklySummaryPage = ({ userId }) => {
scheduleTable.clientWidth, scheduleTable.clientWidth,
); );
/*
* MOBILE
*
* Le conteneur est maintenant un flex-item qui prend
* toute la hauteur disponible.
*
* On mesure donc sa vraie hauteur au lieu d'utiliser
* une valeur arbitraire comme 60vh.
*/
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
setMobileContainerHeight( setMobileContainerHeight(
scheduleTable.clientHeight, scheduleTable.clientHeight,
@ -417,10 +454,6 @@ const WeeklySummaryPage = ({ userId }) => {
return; return;
} }
/*
* DESKTOP
*/
const cardContent = const cardContent =
scheduleTable.closest( scheduleTable.closest(
'[data-slot="card-content"]', '[data-slot="card-content"]',
@ -634,7 +667,7 @@ const WeeklySummaryPage = ({ userId }) => {
attendances, attendances,
).filter( ).filter(
(attendance) => (attendance) =>
String(attendance.date).slice( String(attendance.start_date).slice(
0, 0,
10, 10,
) === dateKey, ) === dateKey,
@ -694,6 +727,7 @@ const WeeklySummaryPage = ({ userId }) => {
); );
setCurrentWeek(newWeek); setCurrentWeek(newWeek);
hideHoverInfo();
}; };
const navigateDay = ( const navigateDay = (
@ -703,6 +737,8 @@ const WeeklySummaryPage = ({ userId }) => {
selectedDayIndex + selectedDayIndex +
direction; direction;
hideHoverInfo();
if ( if (
nextDayIndex < 0 || nextDayIndex < 0 ||
nextDayIndex > 6 nextDayIndex > 6
@ -746,7 +782,7 @@ const WeeklySummaryPage = ({ userId }) => {
(attendance) => (attendance) =>
weekDateKeys.has( weekDateKeys.has(
String( String(
attendance.date, attendance.start_date,
).slice(0, 10), ).slice(0, 10),
), ),
); );
@ -926,7 +962,7 @@ const WeeklySummaryPage = ({ userId }) => {
(attendance) => (attendance) =>
weekDateKeys.has( weekDateKeys.has(
String( String(
attendance.date, attendance.start_date,
).slice(0, 10), ).slice(0, 10),
), ),
); );
@ -1021,23 +1057,6 @@ const WeeklySummaryPage = ({ userId }) => {
* ------------------------------------------------------------ * ------------------------------------------------------------
* Hauteur du planning * Hauteur du planning
* ------------------------------------------------------------ * ------------------------------------------------------------
*
* MOBILE
*
* Le conteneur visible prend maintenant toute la place
* disponible grâce au flex.
*
* Si suffisamment de place est disponible :
* -> les heures sont réparties sur toute la hauteur.
*
* Si la hauteur disponible est insuffisante :
* -> chaque heure garde au minimum
* MOBILE_MIN_HOUR_HEIGHT.
* -> le contenu devient alors scrollable.
*
* DESKTOP
*
* On conserve le calcul dynamique existant.
*/ */
const mobileAvailableBodyHeight = const mobileAvailableBodyHeight =
@ -1178,27 +1197,27 @@ const WeeklySummaryPage = ({ userId }) => {
return ( return (
<MainCard <MainCard
icon={CalendarDays} icon={CalendarDays}
className= className={
{isMobile isMobile
? 'overflow-visible' ? 'overflow-visible'
: 'overflow-hidden' : 'overflow-hidden'
} }
title={ title={
isAdmin ? ( isAdmin ? (
<> <>
{showAllUsers {showAllUsers
? 'Planning de tous les utilisateurs' ? 'Planning de tous les utilisateurs'
: `Planning de ${ : `Planning de ${
selectedUser selectedUser
? getUserLabel(selectedUser) ? getUserLabel(selectedUser)
: 'l’utilisateur' : 'l’utilisateur'
}`} }`}
</> </>
) : ( ) : (
<> <>
Planning Planning
</> </>
) )
} }
headerContent={ headerContent={
<> <>
@ -1299,29 +1318,23 @@ const WeeklySummaryPage = ({ userId }) => {
event.target.value, event.target.value,
); );
hideHoverInfo();
setError(''); setError('');
}} }}
className="h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" className="h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
> >
{users.length === 0 ? ( {users.length === 0 ? (
<option value=""> <option value="">
Aucun utilisateur Aucun utilisateur disponible
disponible
</option> </option>
) : ( ) : (
users.map( users.map(
(user) => ( (user) => (
<option <option
key={getUserId( key={getUserId(user)}
user, value={getUserId(user)}
)}
value={getUserId(
user,
)}
> >
{getUserLabel( {getUserLabel(user)}
user,
)}
</option> </option>
), ),
) )
@ -1338,6 +1351,7 @@ const WeeklySummaryPage = ({ userId }) => {
event.target.checked, event.target.checked,
); );
hideHoverInfo();
setError(''); setError('');
}} }}
className="size-4 accent-primary" className="size-4 accent-primary"
@ -1535,9 +1549,6 @@ const WeeklySummaryPage = ({ userId }) => {
user, user,
)} )}
className="relative border-r border-border/30 last:border-r-0" className="relative border-r border-border/30 last:border-r-0"
title={getUserLabel(
user,
)}
> >
{schedules.map( {schedules.map(
({ ({
@ -1565,13 +1576,32 @@ const WeeklySummaryPage = ({ userId }) => {
backgroundColor: backgroundColor:
color, color,
}} }}
title={`${getUserLabel( onMouseEnter={(event) =>
user, showHoverInfo({
)} : ${formatSegmentTime( event,
start, title:
)} - ${formatSegmentTime( getUserLabel(
end, user,
)}`} ),
startTime:
getTimeValue(
attendance,
'start_time',
),
endTime:
getTimeValue(
attendance,
'end_time',
),
color,
})
}
onMouseMove={
moveHoverInfo
}
onMouseLeave={
hideHoverInfo
}
/> />
), ),
)} )}
@ -1631,6 +1661,12 @@ const WeeklySummaryPage = ({ userId }) => {
hourHeight, hourHeight,
); );
const attendanceUser =
getEntryUser(
attendance,
) ||
selectedUser;
return ( return (
<div <div
key={ key={
@ -1645,7 +1681,33 @@ const WeeklySummaryPage = ({ userId }) => {
style={ style={
style style
} }
title={`${formatSegmentTime(start)} - ${formatSegmentTime(end)}`} onMouseEnter={(event) =>
showHoverInfo({
event,
title:
getUserLabel(
attendanceUser,
),
startTime:
getTimeValue(
attendance,
'start_time',
),
endTime:
getTimeValue(
attendance,
'end_time',
),
color:
attendanceUser?.color,
})
}
onMouseMove={
moveHoverInfo
}
onMouseLeave={
hideHoverInfo
}
> >
<div className="p-1 text-xs text-white"> <div className="p-1 text-xs text-white">
{formatSegmentTime( {formatSegmentTime(
@ -1799,7 +1861,8 @@ const WeeklySummaryPage = ({ userId }) => {
user, user,
); );
const color = user.color; const color =
user.color;
return ( return (
<div <div
@ -1807,9 +1870,6 @@ const WeeklySummaryPage = ({ userId }) => {
user, user,
)} )}
className="relative min-w-0 border-r border-border/30 last:border-r-0" className="relative min-w-0 border-r border-border/30 last:border-r-0"
title={getUserLabel(
user,
)}
> >
{schedules.map( {schedules.map(
({ ({
@ -1839,13 +1899,32 @@ const WeeklySummaryPage = ({ userId }) => {
backgroundColor: backgroundColor:
color, color,
}} }}
title={`${getUserLabel( onMouseEnter={(event) =>
user, showHoverInfo({
)} : ${formatSegmentTime( event,
start, title:
)} - ${formatSegmentTime( getUserLabel(
end, user,
)}`} ),
startTime:
getTimeValue(
attendance,
'start_time',
),
endTime:
getTimeValue(
attendance,
'end_time',
),
color,
})
}
onMouseMove={
moveHoverInfo
}
onMouseLeave={
hideHoverInfo
}
/> />
), ),
)} )}
@ -1904,6 +1983,12 @@ const WeeklySummaryPage = ({ userId }) => {
hourHeight, hourHeight,
); );
const attendanceUser =
getEntryUser(
attendance,
) ||
selectedUser;
return ( return (
<div <div
key={ key={
@ -1916,7 +2001,33 @@ const WeeklySummaryPage = ({ userId }) => {
style={ style={
style style
} }
title={`${formatSegmentTime(start)} - ${formatSegmentTime(end)}`} onMouseEnter={(event) =>
showHoverInfo({
event,
title:
getUserLabel(
attendanceUser,
),
startTime:
getTimeValue(
attendance,
'start_time',
),
endTime:
getTimeValue(
attendance,
'end_time',
),
color:
attendanceUser?.color,
})
}
onMouseMove={
moveHoverInfo
}
onMouseLeave={
hideHoverInfo
}
> >
<div className="p-2 text-xs text-white"> <div className="p-2 text-xs text-white">
<div className="font-medium"> <div className="font-medium">
@ -1941,6 +2052,15 @@ const WeeklySummaryPage = ({ userId }) => {
</div> </div>
)} )}
</div> </div>
<HoverInfo
title={hoverInfo?.title}
description={hoverInfo?.description}
startTime={hoverInfo?.startTime}
endTime={hoverInfo?.endTime}
color={hoverInfo?.color}
position={hoverInfo?.position}
/>
</CardContent> </CardContent>
</MainCard> </MainCard>
); );