From d424689e125866e1183a436e06cd77dbb7e74676 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Thu, 24 Sep 2026 19:35:37 +0200 Subject: [PATCH] big maj --- backend/API.md | 84 + backend/core/settings.py | 6 +- backend/core/urls.py | 1 + backend/events/__init__.py | 0 ...ent_color_alter_event_end_time_and_more.py | 28 + .../migrations/0003_event_visibility.py | 18 + backend/events/models.py | 9 +- backend/events/serializers.py | 71 +- backend/events/tests.py | 3 + backend/events/views.py | 17 +- backend/notes/__init__.py | 0 backend/notes/apps.py | 7 + backend/notes/migrations/0001_initial.py | 35 + .../0002_note_is_public_alter_note_kind.py | 23 + ...3_remove_note_recipient_note_recipients.py | 24 + backend/notes/migrations/__init__.py | 0 backend/notes/models.py | 80 + backend/notes/permissions.py | 34 + backend/notes/serializers.py | 148 + backend/notes/tests.py | 3 + backend/notes/urls.py | 10 + backend/notes/views.py | 320 ++ .../0004_alter_timeentry_options_and_more.py | 125 + .../0005_reconcile_timeentry_schema.py | 64 + backend/time_tracking/models.py | 58 +- backend/time_tracking/serializers.py | 261 +- backend/time_tracking/services.py | 85 + backend/time_tracking/test_api.py | 169 + backend/time_tracking/urls.py | 4 +- backend/time_tracking/views.py | 936 +--- backend/users/serializers.py | 24 +- backend/users/views.py | 31 +- frontend/package.json | 3 +- frontend/src/App.jsx | 29 +- frontend/src/api/axios.js | 98 +- frontend/src/api/errors.js | 12 + frontend/src/api/event.js | 63 +- frontend/src/api/notes.js | 10 + frontend/src/api/time.js | 194 +- frontend/src/api/users.js | 55 +- frontend/src/components/AdminRoute.jsx | 4 +- frontend/src/components/ConfirmDialog.jsx | 47 +- frontend/src/components/HoverInfo.jsx | 102 + frontend/src/components/NavBar.jsx | 143 +- frontend/src/components/UnloggedRoute.jsx | 22 + frontend/src/context/AuthContext.jsx | 31 +- frontend/src/pages/AdminUsersPage.jsx | 78 +- frontend/src/pages/CalendarPage.jsx | 3939 ++++++++++++++++- frontend/src/pages/HomePage.jsx | 129 +- frontend/src/pages/MyspacePage.jsx | 74 + frontend/src/pages/NotesPage.jsx | 1686 +++++++ frontend/src/pages/TimeClockPage.jsx | 343 +- frontend/src/pages/WeeklySummaryPage.jsx | 320 +- 53 files changed, 7918 insertions(+), 2142 deletions(-) create mode 100644 backend/API.md create mode 100644 backend/events/__init__.py create mode 100644 backend/events/migrations/0002_event_color_alter_event_end_time_and_more.py create mode 100644 backend/events/migrations/0003_event_visibility.py create mode 100644 backend/events/tests.py create mode 100644 backend/notes/__init__.py create mode 100644 backend/notes/apps.py create mode 100644 backend/notes/migrations/0001_initial.py create mode 100644 backend/notes/migrations/0002_note_is_public_alter_note_kind.py create mode 100644 backend/notes/migrations/0003_remove_note_recipient_note_recipients.py create mode 100644 backend/notes/migrations/__init__.py create mode 100644 backend/notes/models.py create mode 100644 backend/notes/permissions.py create mode 100644 backend/notes/serializers.py create mode 100644 backend/notes/tests.py create mode 100644 backend/notes/urls.py create mode 100644 backend/notes/views.py create mode 100644 backend/time_tracking/migrations/0004_alter_timeentry_options_and_more.py create mode 100644 backend/time_tracking/migrations/0005_reconcile_timeentry_schema.py create mode 100644 backend/time_tracking/services.py create mode 100644 backend/time_tracking/test_api.py create mode 100644 frontend/src/api/errors.js create mode 100644 frontend/src/api/notes.js create mode 100644 frontend/src/components/HoverInfo.jsx create mode 100644 frontend/src/components/UnloggedRoute.jsx create mode 100644 frontend/src/pages/MyspacePage.jsx create mode 100644 frontend/src/pages/NotesPage.jsx diff --git a/backend/API.md b/backend/API.md new file mode 100644 index 0000000..c961e2e --- /dev/null +++ b/backend/API.md @@ -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`. diff --git a/backend/core/settings.py b/backend/core/settings.py index b9ba70b..6574775 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -41,11 +41,13 @@ INSTALLED_APPS = [ # Third party apps 'rest_framework', 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', 'corsheaders', # Local apps 'users', 'time_tracking', 'events', + 'notes', ] MIDDLEWARE = [ @@ -149,11 +151,9 @@ SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'ROTATE_REFRESH_TOKENS': True, - 'BLACKLIST_ROTTEN_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, 'AUTH_HEADER_TYPES': ('Bearer',), } # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field - - diff --git a/backend/core/urls.py b/backend/core/urls.py index 9686b2d..d3c95e9 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -28,4 +28,5 @@ urlpatterns = [ path('api/users/', include('users.urls')), path('api/time/', include('time_tracking.urls')), path('api/event/', include('events.urls')), + path('api/notes/', include('notes.urls')), ] diff --git a/backend/events/__init__.py b/backend/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/events/migrations/0002_event_color_alter_event_end_time_and_more.py b/backend/events/migrations/0002_event_color_alter_event_end_time_and_more.py new file mode 100644 index 0000000..4d0cf5b --- /dev/null +++ b/backend/events/migrations/0002_event_color_alter_event_end_time_and_more.py @@ -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), + ), + ] diff --git a/backend/events/migrations/0003_event_visibility.py b/backend/events/migrations/0003_event_visibility.py new file mode 100644 index 0000000..c6563e2 --- /dev/null +++ b/backend/events/migrations/0003_event_visibility.py @@ -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), + ), + ] diff --git a/backend/events/models.py b/backend/events/models.py index ce5fcb7..1eb5e13 100644 --- a/backend/events/models.py +++ b/backend/events/models.py @@ -18,10 +18,15 @@ class Event(models.Model): ) start_date = models.DateField() end_date = models.DateField() - start_time = models.TimeField() - end_time = models.TimeField() + start_time = models.TimeField(null=True, blank=True) + 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) 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) updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/events/serializers.py b/backend/events/serializers.py index 5a0943e..d3e5c59 100644 --- a/backend/events/serializers.py +++ b/backend/events/serializers.py @@ -17,30 +17,24 @@ class EventSerializer(serializers.ModelSerializer): 'end_date', 'start_time', 'end_time', + 'color', 'name', 'description', + 'visibility', '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 Meta: - model = Event - fields = [ - 'user', - 'linked_user', - 'start_date', - 'end_date', - 'start_time', - 'end_time', - 'name', - 'description' - ] + color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False, allow_null=True, allow_blank=True) - -class EventUpdateSerializer(serializers.ModelSerializer): class Meta: model = Event fields = [ @@ -49,6 +43,49 @@ class EventUpdateSerializer(serializers.ModelSerializer): 'end_date', 'start_time', 'end_time', + 'color', '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', ] diff --git a/backend/events/tests.py b/backend/events/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/events/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/events/views.py b/backend/events/views.py index 2e3d8bc..d0028fa 100644 --- a/backend/events/views.py +++ b/backend/events/views.py @@ -16,10 +16,17 @@ class EventListView(generics.ListCreateAPIView): def perform_create(self, serializer): 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): serializer_class = EventSerializer permission_classes = [IsAuthenticated] + lookup_url_kwarg = 'id' def get_queryset(self): user = self.request.user @@ -33,12 +40,13 @@ class EventDetailView(generics.RetrieveUpdateDestroyAPIView): serializer = EventUpdateSerializer( instance, data=request.data, - partial=partial + partial=partial, + context=self.get_serializer_context(), ) serializer.is_valid(raise_exception=True) self.perform_update(serializer) - return Response(serializer.data) + return Response(EventSerializer(serializer.instance).data) def destroy(self, request, *args, **kwargs): instance = self.get_object() @@ -49,9 +57,10 @@ class EventDetailView(generics.RetrieveUpdateDestroyAPIView): ) -class EventCreateView(generics.CreateAPIView): +class EventCreateView(EventListView): + http_method_names = ['post', 'options'] serializer_class = EventCreateSerializer permission_classes = [IsAuthenticated] def perform_create(self, serializer): - serializer.save(user=self.request.user) \ No newline at end of file + serializer.save(user=self.request.user) diff --git a/backend/notes/__init__.py b/backend/notes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/notes/apps.py b/backend/notes/apps.py new file mode 100644 index 0000000..d83bfc0 --- /dev/null +++ b/backend/notes/apps.py @@ -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' diff --git a/backend/notes/migrations/0001_initial.py b/backend/notes/migrations/0001_initial.py new file mode 100644 index 0000000..64bf346 --- /dev/null +++ b/backend/notes/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/backend/notes/migrations/0002_note_is_public_alter_note_kind.py b/backend/notes/migrations/0002_note_is_public_alter_note_kind.py new file mode 100644 index 0000000..0efb441 --- /dev/null +++ b/backend/notes/migrations/0002_note_is_public_alter_note_kind.py @@ -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), + ), + ] diff --git a/backend/notes/migrations/0003_remove_note_recipient_note_recipients.py b/backend/notes/migrations/0003_remove_note_recipient_note_recipients.py new file mode 100644 index 0000000..2b6a255 --- /dev/null +++ b/backend/notes/migrations/0003_remove_note_recipient_note_recipients.py @@ -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), + ), + ] diff --git a/backend/notes/migrations/__init__.py b/backend/notes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/notes/models.py b/backend/notes/models.py new file mode 100644 index 0000000..53be69b --- /dev/null +++ b/backend/notes/models.py @@ -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 \ No newline at end of file diff --git a/backend/notes/permissions.py b/backend/notes/permissions.py new file mode 100644 index 0000000..bab54da --- /dev/null +++ b/backend/notes/permissions.py @@ -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() + ) \ No newline at end of file diff --git a/backend/notes/serializers.py b/backend/notes/serializers.py new file mode 100644 index 0000000..b7fa22b --- /dev/null +++ b/backend/notes/serializers.py @@ -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() \ No newline at end of file diff --git a/backend/notes/tests.py b/backend/notes/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/notes/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/notes/urls.py b/backend/notes/urls.py new file mode 100644 index 0000000..df0806c --- /dev/null +++ b/backend/notes/urls.py @@ -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('/', NoteDetailView.as_view(), name='note-detail'), + path('/completion/', NoteCompletionView.as_view(), name='note-completion'), +] diff --git a/backend/notes/views.py b/backend/notes/views.py new file mode 100644 index 0000000..b2494ec --- /dev/null +++ b/backend/notes/views.py @@ -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', + ) + ) \ No newline at end of file diff --git a/backend/time_tracking/migrations/0004_alter_timeentry_options_and_more.py b/backend/time_tracking/migrations/0004_alter_timeentry_options_and_more.py new file mode 100644 index 0000000..56762f5 --- /dev/null +++ b/backend/time_tracking/migrations/0004_alter_timeentry_options_and_more.py @@ -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(), + ), + ], + ), + ] diff --git a/backend/time_tracking/migrations/0005_reconcile_timeentry_schema.py b/backend/time_tracking/migrations/0005_reconcile_timeentry_schema.py new file mode 100644 index 0000000..348a820 --- /dev/null +++ b/backend/time_tracking/migrations/0005_reconcile_timeentry_schema.py @@ -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', + ), + ), + ], + ), + ] diff --git a/backend/time_tracking/models.py b/backend/time_tracking/models.py index c19003e..97aaf0d 100644 --- a/backend/time_tracking/models.py +++ b/backend/time_tracking/models.py @@ -1,4 +1,3 @@ -import uuid from datetime import datetime from django.conf import settings @@ -20,17 +19,17 @@ class TimeEntry(models.Model): db_index=True, ) - date = models.DateField() + start_date = models.DateField() - start_time = models.TimeField( - null=True, - blank=True, - ) + start_time = models.TimeField() - end_time = models.TimeField( - null=True, - blank=True, - ) + # Chaque ligne représente une seule journée. + # Pour une entrée classique : + # 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( null=True, @@ -46,30 +45,49 @@ class TimeEntry(models.Model): ) class Meta: - ordering = ['-date', '-start_time'] + ordering = ['-start_date', '-start_time'] - unique_together = [ - 'user', - 'date', - 'start_time', + constraints = [ + models.UniqueConstraint( + fields=[ + 'user', + 'start_date', + 'start_time', + ], + name='unique_time_entry_start', + ), ] def __str__(self): + if self.end_time: + return ( + f"{self.user.username} - " + f"{self.start_date} " + f"({self.start_time} → {self.end_time})" + ) + return ( f"{self.user.username} - " - f"{self.date} " - f"({self.start_time} to {self.end_time})" + f"{self.start_date} " + f"({self.start_time} → en cours)" ) @property 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: return None - end_date = self.end_date or self.date + end_date = self.end_date or self.start_date start = datetime.combine( - self.date, + self.start_date, self.start_time, ) @@ -89,7 +107,7 @@ class TimeEntry(models.Model): @property def day_of_week(self): - return self.date.strftime('%A') + return self.start_date.strftime('%A') @property def is_complete(self): diff --git a/backend/time_tracking/serializers.py b/backend/time_tracking/serializers.py index d36e2de..f978854 100644 --- a/backend/time_tracking/serializers.py +++ b/backend/time_tracking/serializers.py @@ -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.db import transaction from rest_framework import serializers + from users.serializers import UserSerializer from .models import TimeEntry - - -User = get_user_model() +from .services import save_period, validate_period class TimeEntrySerializer(serializers.ModelSerializer): user = UserSerializer(read_only=True) - is_complete = serializers.ReadOnlyField() - is_incomplete = serializers.ReadOnlyField() - class Meta: model = TimeEntry - fields = [ - 'id', - 'user', - 'group_id', - 'date', - 'start_time', - 'end_time', - 'created_at', - 'updated_at', - 'duration', - 'day_of_week', - 'is_complete', - 'is_incomplete', + 'id', 'user', 'group_id', 'start_date', 'start_time', 'end_date', + 'end_time', 'created_at', 'updated_at', 'duration', 'day_of_week', + 'is_complete', 'is_incomplete', ] + read_only_fields = fields - read_only_fields = [ - 'id', - 'user', - 'created_at', - 'updated_at', - 'duration', - 'day_of_week', - 'is_complete', - 'is_incomplete', - ] + +class TimeEntryCreateSerializer(serializers.Serializer): + user_id = serializers.PrimaryKeyRelatedField( + source='user', queryset=get_user_model().objects.all(), required=False, + write_only=True, + ) + start_date = serializers.DateField() + start_time = serializers.TimeField() + 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): - validated_data['user'] = self.context['request'].user - return super().create(validated_data) + user = validated_data.pop('user', self.context['request'].user) + 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): - validated_data.pop('user', None) - return super().update(instance, validated_data) - - -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, + return save_period( + instance.user, validated_data, + self.context.get('entries', [instance]), ) - 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): user = serializers.CharField() username = serializers.CharField() total_hours = serializers.FloatField() - entries = serializers.ListField( - child=serializers.DictField(), - ) \ No newline at end of file + entries = serializers.ListField(child=serializers.DictField()) diff --git a/backend/time_tracking/services.py b/backend/time_tracking/services.py new file mode 100644 index 0000000..52ebbd5 --- /dev/null +++ b/backend/time_tracking/services.py @@ -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] diff --git a/backend/time_tracking/test_api.py b/backend/time_tracking/test_api.py new file mode 100644 index 0000000..db513f9 --- /dev/null +++ b/backend/time_tracking/test_api.py @@ -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) diff --git a/backend/time_tracking/urls.py b/backend/time_tracking/urls.py index 83e7ac4..7ebac98 100644 --- a/backend/time_tracking/urls.py +++ b/backend/time_tracking/urls.py @@ -5,6 +5,7 @@ from .views import ( TimeEntryScanView, TimeEntryDetailView, TimeEntryUpdateView, + TimeEntryGroupView, WeeklySummaryView, ) @@ -12,8 +13,9 @@ urlpatterns = [ path('entries/', TimeEntryListView.as_view(), name='time-entry-list'), path('entries/create/', TimeEntryCreateView.as_view(), name='time-entry-create'), path('entries/scan/', TimeEntryScanView.as_view(), name='time-entry-scan'), + path('groups//', TimeEntryGroupView.as_view(), name='time-entry-group'), path('entries//', TimeEntryDetailView.as_view(), name='time-entry-detail'), path('entries//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='weekly-summary'), -] \ No newline at end of file +] diff --git a/backend/time_tracking/views.py b/backend/time_tracking/views.py index 252865d..bc4a5e5 100644 --- a/backend/time_tracking/views.py +++ b/backend/time_tracking/views.py @@ -1,783 +1,234 @@ from datetime import timedelta -from datetime import time as datetime_time from decouple import config -from django.conf import settings from django.contrib.auth import get_user_model from django.db import transaction +from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import generics, status +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from .models import TimeEntry from .serializers import ( - TimeEntryCreateSerializer, - TimeEntrySerializer, - TimeEntryUpdateSerializer, - WeeklySummarySerializer, + TimeEntryCreateSerializer, TimeEntrySerializer, + TimeEntryUpdateSerializer, WeeklySummarySerializer, ) - +from .services import lock_user, save_period 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): serializer_class = TimeEntrySerializer permission_classes = [IsAuthenticated] def get_queryset(self): - user = self.request.user - - if user.is_staff or user.is_superuser: - return TimeEntry.objects.all() - - return TimeEntry.objects.filter( - user=user, - ) + return visible_entries(self.request.user) class TimeEntryCreateView(generics.CreateAPIView): serializer_class = TimeEntryCreateSerializer permission_classes = [IsAuthenticated] - def perform_create(self, serializer): - requested_user = serializer.validated_data.get('user') - - if requested_user is not None: - if not ( - self.request.user.is_staff - or self.request.user.is_superuser - ): - from rest_framework.exceptions import PermissionDenied - - raise PermissionDenied( - "Vous ne pouvez pas créer un pointage pour un autre utilisateur." - ) - - self.created_entry = serializer.save( - user=requested_user, - ) - - else: - self.created_entry = serializer.save( - user=self.request.user, - ) - def create(self, request, *args, **kwargs): - serializer = self.get_serializer( - data=request.data, - ) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data.get('user', request.user) + if user != request.user and not (request.user.is_staff or request.user.is_superuser): + raise PermissionDenied('Vous ne pouvez pas créer un pointage pour un autre utilisateur.') + entry = serializer.save(user=user) + return Response(TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED) - serializer.is_valid( - raise_exception=True, - ) - self.perform_create(serializer) - - output_serializer = TimeEntrySerializer( - self.created_entry, - context={ - 'request': request, - }, - ) - - headers = self.get_success_headers( - serializer.data, - ) - - return Response( - output_serializer.data, - status=status.HTTP_201_CREATED, - headers=headers, - ) - - -class TimeEntryScanView(APIView): - permission_classes = [IsAuthenticated] - - def _parse_code(self, raw_code): - code = str( - raw_code or '' - ).strip().strip('"\'"') - - if '=' in code: - code = ( - code - .split('=', 1)[1] - .strip() - .strip('"\'"') - ) - - return code - - def _parse_time(self, value): - if not value: - now = timezone.localtime() - - return now.time().replace( - second=0, - microsecond=0, - ) - - try: - return datetime_time.fromisoformat( - str(value)[:5], - ) - except ValueError: - return None - - def _serialize_entry(self, entry, request): - return TimeEntrySerializer( - entry, - context={ - 'request': request, - }, - ).data - - def post(self, request): - code = self._parse_code( - request.data.get('code'), - ) - - enter_code = config( - 'SECRET_POINTAGE_ENTER', - default='', - ).strip().strip('"\'"') - - exit_code = config( - 'SECRET_POINTAGE_EXIT', - default='', - ).strip().strip('"\'"') - - if code == enter_code: - action = 'enter' - - elif code == exit_code: - action = 'exit' - - else: - return Response( - { - 'error': 'QR code de pointage invalide.' - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - selected_time = self._parse_time( - request.data.get('time'), - ) - - if selected_time is None: - return Response( - { - 'error': 'Heure invalide.' - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - resolution = request.data.get( - 'resolution', - ) - - # Heure réelle du scan. - # - # Lors d'une résolution d'un conflit "sortie sans entrée", - # on utilise scan_time comme heure de sortie et "time" - # comme heure d'entrée. - scan_time_value = request.data.get( - 'scan_time', - ) - - scan_time = self._parse_time( - scan_time_value, - ) - - if scan_time is None: - scan_time = selected_time - - now = timezone.localtime() - today = now.date() - - with transaction.atomic(): - - # ===================================================== - # RÉSOLUTION : entrée déjà ouverte - # ===================================================== - - if resolution == 'complete_exit': - open_entry = ( - TimeEntry.objects - .select_for_update() - .filter( - user=request.user, - start_time__isnull=False, - end_time__isnull=True, - ) - .order_by( - '-date', - '-start_time', - ) - .first() - ) - - if not open_entry: - return Response( - { - 'error': 'Aucune entrée ouverte à compléter.' - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - if selected_time <= open_entry.start_time: - return Response( - { - 'error': 'L’heure de sortie doit être après l’entrée.' - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - open_entry.end_time = selected_time - - open_entry.save( - update_fields=[ - 'end_time', - 'updated_at', - ], - ) - - return Response( - { - 'action': 'exit', - 'message': 'Sortie enregistrée.', - 'entry': self._serialize_entry( - open_entry, - request, - ), - } - ) - - # ===================================================== - # RÉSOLUTION : sortie scannée sans entrée - # ===================================================== - - if resolution == 'complete_missing_entry': - missing_entry_time = selected_time - exit_time = scan_time - - if missing_entry_time >= exit_time: - return Response( - { - 'error': "L'heure d'entrée doit être avant l'heure de sortie." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - entry = TimeEntry.objects.create( - user=request.user, - date=today, - start_time=missing_entry_time, - end_time=exit_time, - ) - - return Response( - { - 'action': 'exit', - 'message': 'Entrée et sortie enregistrées.', - 'entry': self._serialize_entry( - entry, - request, - ), - } - ) - - # ===================================================== - # RÉSOLUTION : sortie existante sans entrée - # ===================================================== - - if resolution == 'complete_open_exit': - open_exit = ( - TimeEntry.objects - .select_for_update() - .filter( - user=request.user, - start_time__isnull=True, - end_time__isnull=False, - ) - .order_by( - '-date', - '-end_time', - ) - .first() - ) - - if not open_exit: - return Response( - { - 'error': 'Aucune sortie incomplète à compléter.' - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - if selected_time >= open_exit.end_time: - return Response( - { - 'error': "L'heure d'entrée doit être avant la sortie." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - open_exit.start_time = selected_time - - open_exit.save( - update_fields=[ - 'start_time', - 'updated_at', - ], - ) - - return Response( - { - 'action': 'enter', - 'message': 'Entrée enregistrée.', - 'entry': self._serialize_entry( - open_exit, - request, - ), - } - ) - - # ===================================================== - # POINTAGE EXISTANT DU JOUR - # ===================================================== - - open_entry = ( - TimeEntry.objects - .select_for_update() - .filter( - user=request.user, - date=today, - start_time__isnull=False, - end_time__isnull=True, - ) - .order_by( - '-start_time', - ) - .first() - ) - - open_exit = ( - TimeEntry.objects - .select_for_update() - .filter( - user=request.user, - date=today, - start_time__isnull=True, - end_time__isnull=False, - ) - .order_by( - '-end_time', - ) - .first() - ) - - # ===================================================== - # QR ENTRÉE - # ===================================================== - - if action == 'enter': - - # Une entrée est déjà ouverte. - # - # IMPORTANT : - # on ne supprime plus l'entrée si l'utilisateur - # refuse. Le frontend demande d'abord confirmation. - if open_entry: - return Response( - { - 'error': ( - 'Une entrée est déjà ouverte. ' - 'Voulez-vous enregistrer une sortie ?' - ), - 'conflict': 'open_entry', - 'resolution': 'complete_exit', - 'missing_time': 'end_time', - 'message': ( - 'Une entrée est déjà ouverte. ' - 'Voulez-vous enregistrer une sortie ?' - ), - 'scan_time': selected_time.strftime( - '%H:%M' - ), - }, - status=status.HTTP_409_CONFLICT, - ) - - # Une sortie existe sans entrée. - if open_exit: - return Response( - { - 'error': ( - 'Une sortie existe déjà sans entrée.' - ), - 'conflict': 'open_exit', - 'resolution': 'complete_open_exit', - 'missing_time': 'start_time', - 'message': ( - 'Une sortie existe déjà sans entrée. ' - 'Voulez-vous renseigner l’heure d’entrée ?' - ), - }, - status=status.HTTP_409_CONFLICT, - ) - - entry = TimeEntry.objects.create( - user=request.user, - date=today, - start_time=selected_time, - ) - - return Response( - { - 'action': 'enter', - 'message': 'Entrée enregistrée.', - 'entry': self._serialize_entry( - entry, - request, - ), - } - ) - - # ===================================================== - # QR SORTIE - # ===================================================== - - # Entrée ouverte aujourd'hui : - # sortie normale. - if open_entry: - if selected_time <= open_entry.start_time: - return Response( - { - 'error': ( - "L'heure de sortie doit être " - "après l'entrée." - ) - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - open_entry.end_time = selected_time - - open_entry.save( - update_fields=[ - 'end_time', - 'updated_at', - ], - ) - - return Response( - { - 'action': 'exit', - 'message': 'Sortie enregistrée.', - 'entry': self._serialize_entry( - open_entry, - request, - ), - } - ) - - # ===================================================== - # SORTIE SANS ENTRÉE - # ===================================================== - - if open_exit: - return Response( - { - 'error': ( - 'Une sortie existe déjà sans entrée.' - ), - 'conflict': 'open_exit', - 'resolution': 'complete_open_exit', - 'missing_time': 'start_time', - 'message': ( - 'Une sortie existe déjà sans entrée. ' - 'Voulez-vous renseigner l’heure d’entrée ?' - ), - }, - status=status.HTTP_409_CONFLICT, - ) - - # ===================================================== - # POINTAGE OUVERT SUR UN JOUR PRÉCÉDENT - # ===================================================== - - previous_open_entry = ( - TimeEntry.objects - .select_for_update() - .filter( - user=request.user, - start_time__isnull=False, - end_time__isnull=True, - ) - .exclude( - date=today, - ) - .order_by( - '-date', - '-start_time', - ) - .first() - ) - - if previous_open_entry: - last_date = previous_open_entry.date - - group_id = ( - previous_open_entry.group_id - or previous_open_entry.id - ) - - # Si l'ancien pointage n'avait pas encore de groupe, - # on lui attribue un UUID basé sur un nouvel objet. - if not previous_open_entry.group_id: - import uuid - - group_id = uuid.uuid4() - - previous_open_entry.group_id = group_id - - previous_open_entry.end_time = datetime_time( - 23, - 59, - 59, - ) - - previous_open_entry.save( - update_fields=[ - 'group_id', - 'end_time', - 'updated_at', - ], - ) - - current_date = ( - last_date - + timedelta(days=1) - ) - - while current_date < today: - TimeEntry.objects.create( - user=request.user, - group_id=group_id, - date=current_date, - start_time=datetime_time.min, - end_time=datetime_time( - 23, - 59, - 59, - ), - ) - - current_date += timedelta( - days=1, - ) - - entry = TimeEntry.objects.create( - user=request.user, - group_id=group_id, - date=today, - end_time=selected_time, - ) - - return Response( - { - 'action': 'exit', - 'message': 'Sortie enregistrée sur plusieurs jours.', - 'entry': self._serialize_entry( - entry, - request, - ), - } - ) - - # ===================================================== - # AUCUNE ENTRÉE - # - # On NE crée rien immédiatement. - # Le frontend demande d'abord confirmation. - # ===================================================== - - return Response( - { - 'error': ( - 'Aucune entrée ouverte. ' - 'Voulez-vous enregistrer une sortie ?' - ), - 'conflict': 'missing_entry', - 'resolution': 'complete_missing_entry', - 'missing_time': 'start_time', - 'message': ( - 'Aucune entrée ouverte. ' - 'Voulez-vous enregistrer une sortie ?' - ), - 'scan_time': selected_time.strftime( - '%H:%M' - ), - }, - status=status.HTTP_409_CONFLICT, - ) - - -class TimeEntryDetailView( - generics.RetrieveUpdateDestroyAPIView -): +class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView): serializer_class = TimeEntrySerializer permission_classes = [IsAuthenticated] lookup_url_kwarg = 'entry_id' 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, + @transaction.atomic + def update(self, request, *args, **kwargs): + entry = self.get_object() + lock_user(entry.user) + entry = self.get_queryset().select_for_update().get(pk=entry.pk) + if entry.group_id: + raise ValidationError({'detail': 'Modifiez le pointage entier avec la route groups.'}) + serializer = TimeEntryUpdateSerializer( + entry, data=request.data, partial=True, ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return Response(TimeEntrySerializer(result).data) + + @transaction.atomic + def destroy(self, request, *args, **kwargs): + entry = self.get_object() + lock_user(entry.user) + entry = self.get_queryset().select_for_update().get(pk=entry.pk) + if entry.group_id: + raise ValidationError({'detail': 'Supprimez le pointage entier avec la route groups.'}) + entry.delete() + return Response(status=status.HTTP_204_NO_CONTENT) -class TimeEntryUpdateView(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] - def put(self, request, entry_id): - try: - entry = TimeEntry.objects.get( - id=entry_id, - ) - - except TimeEntry.DoesNotExist: - return Response( - { - 'error': 'Pointage non trouvé' - }, - status=status.HTTP_404_NOT_FOUND, - ) - - if not ( - request.user.is_staff - or request.user.is_superuser - or entry.user == request.user - ): - return Response( - { - 'error': 'Accès refusé' - }, - status=status.HTTP_403_FORBIDDEN, - ) + def get_entries(self, request, group_id): + queryset = visible_entries(request.user).filter(group_id=group_id) + first = queryset.order_by('start_date', 'start_time').first() + if first is None: + from rest_framework.exceptions import NotFound + raise NotFound() + lock_user(first.user) + entries = list(queryset.filter(user=first.user).select_for_update().order_by('start_date', 'start_time')) + if not entries: + from rest_framework.exceptions import NotFound + raise NotFound() + return entries + @transaction.atomic + def patch(self, request, group_id): + entries = self.get_entries(request, group_id) serializer = TimeEntryUpdateSerializer( - entry, - data=request.data, + entries[0], data=request.data, partial=True, + context={'entries': entries}, ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return Response(TimeEntrySerializer(result).data) - if serializer.is_valid(): - serializer.save() + put = patch - return Response( - serializer.data, - ) + @transaction.atomic + def delete(self, request, group_id): + entries = self.get_entries(request, group_id) + TimeEntry.objects.filter(pk__in=[entry.pk for entry in entries]).delete() + return Response(status=status.HTTP_204_NO_CONTENT) - return Response( - serializer.errors, - status=status.HTTP_400_BAD_REQUEST, - ) + +class TimeEntryScanView(APIView): + permission_classes = [IsAuthenticated] + + def parse_time(self, value, default): + if value is None: + return default + from rest_framework import serializers + return serializers.TimeField(input_formats=['%H:%M', '%H:%M:%S']).run_validation(value) + + def conflict(self, kind, resolution, message, selected_time, today, entry=None): + return Response({ + 'error': message, 'message': message, 'conflict': kind, + 'resolution': resolution, + 'missing_time': 'end_time' if kind == 'open_entry' else 'start_time', + 'scan_time': selected_time.strftime('%H:%M'), + 'scan_date': today.isoformat(), + 'entry_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): today = timezone.localdate() - - start_of_week = ( - today - - timedelta( - days=today.weekday(), - ) - ) - - end_of_week = ( - start_of_week - + timedelta(days=6) - ) - - entries = list( - TimeEntry.objects.filter( - user=user, - date__range=( - start_of_week, - end_of_week, - ), - ).order_by( - 'date', - 'start_time', - ) - ) - - entries_by_date = {} - - for entry in entries: - entries_by_date.setdefault( - entry.date, - entry, - ) - + start_of_week = today - timedelta(days=today.weekday()) + entries = list(TimeEntry.objects.filter( + user=user, start_date__range=(start_of_week, start_of_week + timedelta(days=6)), + ).order_by('start_date', 'start_time')) days_data = [] - - for day_offset in range(7): - current_day = ( - start_of_week - + timedelta( - days=day_offset, - ) - ) - - entry = entries_by_date.get( - current_day, - ) - - days_data.append( - { - 'date': current_day.isoformat(), - 'day': current_day.strftime('%A'), - 'start_time': ( - str(entry.start_time) - if entry and entry.start_time - else None - ), - 'end_time': ( - str(entry.end_time) - if entry and entry.end_time - else None - ), - 'duration': ( - entry.duration - if entry - else 0 - ), - } - ) - - return { - 'user': user.username, - 'username': user.username, - 'total_hours': round( - sum( - entry.duration or 0 - for entry in entries - ), - 2, - ), - 'entries': days_data, - } + for offset in range(7): + day = start_of_week + timedelta(days=offset) + day_entries = [entry for entry in entries if entry.start_date == day] + completed = [entry for entry in day_entries if entry.end_time is not None] + days_data.append({ + 'date': day.isoformat(), 'day': day.strftime('%A'), + 'start_time': str(day_entries[0].start_time) if day_entries else None, + 'end_time': str(max(entry.end_time for entry in completed)) if completed and len(completed) == len(day_entries) else None, + 'duration': sum(entry.duration or 0 for entry in day_entries), + 'entries_count': len(day_entries), + }) + return dict(user=user.username, username=user.username, + total_hours=round(sum(entry.duration or 0 for entry in entries), 2), entries=days_data) class WeeklySummaryView(APIView): @@ -785,31 +236,8 @@ class WeeklySummaryView(APIView): def get(self, request, user_id=None): user = request.user - - if ( - user_id - and ( - request.user.is_staff - or request.user.is_superuser - ) - ): - try: - user = User.objects.get( - id=user_id, - ) - - except User.DoesNotExist: - return Response( - { - 'error': 'Utilisateur non trouvé' - }, - status=status.HTTP_404_NOT_FOUND, - ) - - serializer = WeeklySummarySerializer( - build_weekly_summary(user), - ) - - return Response( - serializer.data, - ) \ No newline at end of file + if user_id is not None and user_id != user.pk: + if not (user.is_staff or user.is_superuser): + raise PermissionDenied('Vous ne pouvez pas consulter ce résumé.') + user = get_object_or_404(User, pk=user_id) + return Response(WeeklySummarySerializer(build_weekly_summary(user)).data) diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 470120d..1aa2bc2 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -1,4 +1,6 @@ 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.utils import timezone from rest_framework import serializers @@ -11,6 +13,7 @@ User = get_user_model() class UserSerializer(serializers.ModelSerializer): is_admin = serializers.SerializerMethodField() + color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False) class Meta: model = User @@ -32,6 +35,20 @@ class UserSerializer(serializers.ModelSerializer): def get_is_admin(self, obj): 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): 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 def validate_invite(self, value): @@ -97,4 +119,4 @@ class RegisterSerializer(serializers.ModelSerializer): invitation.used_at = timezone.now() invitation.save(update_fields=['used_at']) - return user \ No newline at end of file + return user diff --git a/backend/users/views.py b/backend/users/views.py index e0a940f..f23b413 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -2,7 +2,7 @@ from rest_framework import generics from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated 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 datetime import timedelta from .models import Invitation, User @@ -48,6 +48,13 @@ class UserDetailView(generics.RetrieveUpdateDestroyAPIView): 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): permission_classes = (IsAuthenticated,) @@ -58,6 +65,8 @@ class UserPromoteView(APIView): try: 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.save() 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) -class UserDeleteView(APIView): - permission_classes = (IsAuthenticated,) - - 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 UserDeleteView(UserDetailView): + http_method_names = ['delete', 'options'] class InvitationCreateView(APIView): @@ -98,4 +92,3 @@ class InvitationCreateView(APIView): 'token': str(invitation.token), 'expires_at': invitation.expires_at, }, status=201) - diff --git a/frontend/package.json b/frontend/package.json index f8a2ec3..4963fa3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "lint": "oxlint", + "lint": "oxlint src vite.config.js", + "test": "node --test tests/*.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f69eca8..e1dcd7c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,14 +13,17 @@ import { import LoginPage from './pages/LoginPage'; import HomePage from './pages/HomePage'; +import MyspacePage from './pages/MyspacePage'; import ProtectedRoute from './components/ProtectedRoute'; import AdminRoute from './components/AdminRoute'; +import UnloggedRoute from './components/UnloggedRoute'; import NavBar from './components/NavBar'; import AdminUsersPage from './pages/AdminUsersPage'; import RegisterPage from './pages/RegisterPage'; import TimeClockPage from './pages/TimeClockPage'; import WeeklySummaryPage from './pages/WeeklySummaryPage'; import CalendarPage from './pages/CalendarPage'; +import NotesPage from './pages/NotesPage'; import { useAuth } from './context/useAuth'; // Composant personnalisé pour le trigger mobile avec icône menu @@ -65,12 +68,20 @@ function App() { } + element={ + + + + } /> } + element={ + + + + } /> + + + + } + /> + } /> + } /> + @@ -141,4 +163,5 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; + diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index 40b8798..bf79e91 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -1,46 +1,76 @@ import axios from 'axios'; - -const apiHost = window.location.hostname; +import { getApiErrorMessage } from './errors.js'; 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( (response) => response, - (error) => { - if (error.code === 'ECONNREFUSED') { - console.error('Connexion refusée : le serveur backend n\'est pas accessible'); - } else if (error.response && error.response.status === 401) { - // Token expired or invalid - localStorage.removeItem('token'); - localStorage.removeItem('user'); - // Rediriger vers la page de login - if (window.location.pathname !== '/login') { - window.location.href = '/login'; - } + async (error) => { + error.message = getApiErrorMessage(error); + const request = error.config; + if (error.response?.status !== 401 || !request || request._publicRequest) throw error; + if (request._retry || !localStorage.getItem('refresh')) { + expireSession(); + throw error; } - return Promise.reject(error); - } -); - -// Ajouter un intercepteur pour les requêtes -api.interceptors.request.use( - (config) => { - const token = localStorage.getItem('token'); - 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; + 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. + const access = currentToken && sentToken !== `Bearer ${currentToken}` + ? currentToken : await refreshAccessToken(); + request.headers.Authorization = `Bearer ${access}`; + return api(request); }, - (error) => { - return Promise.reject(error); - } ); export default api; - diff --git a/frontend/src/api/errors.js b/frontend/src/api/errors.js new file mode 100644 index 0000000..cb00c6d --- /dev/null +++ b/frontend/src/api/errors.js @@ -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; +} diff --git a/frontend/src/api/event.js b/frontend/src/api/event.js index 0882840..7da906e 100644 --- a/frontend/src/api/event.js +++ b/frontend/src/api/event.js @@ -1,58 +1,9 @@ -import axios from './axios'; +import api from './axios'; export const eventApi = { - // Récupérer tous les événements - getAllEvents: async () => { - try { - const response = await axios.get('/event/'); - return response.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; - } - } -}; \ No newline at end of file + getAllEvents: async () => (await api.get('/event/')).data, + createEvent: async (data) => (await api.post('/event/create/', data)).data, + updateEvent: async (id, data) => (await api.patch(`/event/${id}/`, data)).data, + deleteEvent: async (id) => (await api.delete(`/event/${id}/`)).data, + getEvent: async (id) => (await api.get(`/event/${id}/`)).data, +}; diff --git a/frontend/src/api/notes.js b/frontend/src/api/notes.js new file mode 100644 index 0000000..57fe9d7 --- /dev/null +++ b/frontend/src/api/notes.js @@ -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, +}; diff --git a/frontend/src/api/time.js b/frontend/src/api/time.js index dd204c0..278174e 100644 --- a/frontend/src/api/time.js +++ b/frontend/src/api/time.js @@ -1,176 +1,30 @@ -import api from './axios'; +import api from './axios.js'; 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 { - const response = await api.get('/time/entries/'); - return response.data; + return (await api.post('/time/entries/scan/', { code, ...options })).data; } catch (error) { - throw new Error( - 'Failed to fetch time entries: ' + - error.message, - ); + const data = error.response?.data || {}; + error.conflict = data.conflict; + error.resolution = data.resolution; + error.missingTime = data.missing_time; + error.scanTime = data.scan_time; + error.scanDate = data.scan_date; + error.entryId = data.entry_id; + error.code = error.response?.status; + throw error; } }, - - getUsers: async () => { - try { - 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, - ); - } - }, -}; \ No newline at end of file +}; diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js index 197ac96..0de77ac 100644 --- a/frontend/src/api/users.js +++ b/frontend/src/api/users.js @@ -1,52 +1,9 @@ -import api from './axios'; +import api from './axios'; export const userApi = { - // Get all users - getUsers: async () => { - try { - const response = await api.get('/users/'); - return response.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); - } - }, + getUsers: async () => (await api.get('/users/')).data, + deleteUser: async (id) => (await api.delete(`/users/${id}/`)).data, + promoteUser: async (id) => (await api.post(`/users/${id}/promote/`)).data, + updateUser: async (id, data) => (await api.patch(`/users/${id}/`, data)).data, + createInvitation: async () => (await api.post('/users/invitations/')).data, }; diff --git a/frontend/src/components/AdminRoute.jsx b/frontend/src/components/AdminRoute.jsx index 35be8d8..167456c 100644 --- a/frontend/src/components/AdminRoute.jsx +++ b/frontend/src/components/AdminRoute.jsx @@ -18,10 +18,10 @@ const AdminRoute = ({ children }) => { // If user is not admin, redirect to profile page if (!isAdmin) { - return ; + return ; } return children; }; -export default AdminRoute; \ No newline at end of file +export default AdminRoute; diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx index 4458ad2..14a5f91 100644 --- a/frontend/src/components/ConfirmDialog.jsx +++ b/frontend/src/components/ConfirmDialog.jsx @@ -8,22 +8,23 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger + AlertDialogTrigger, } from '@/components/ui/alert-dialog'; -const ConfirmDialog = ({ - title = "Confirmation", - description, - onConfirm, +const ConfirmDialog = ({ + title = 'Confirmation', + description, + onConfirm, onCancel, children, - confirmText = "Confirmer", - cancelText = "Annuler" + confirmText = 'Confirmer', + cancelText = 'Annuler', }) => { const [isOpen, setIsOpen] = useState(false); const handleConfirm = () => { setIsOpen(false); + if (onConfirm) { onConfirm(); } @@ -31,24 +32,40 @@ const ConfirmDialog = ({ const handleCancel = () => { setIsOpen(false); + if (onCancel) { onCancel(); } }; return ( - - e.stopPropagation()}> - {children} - + + + - {title} - {description} + + {title} + + + + {description} + + - {cancelText} - {confirmText} + + {cancelText} + + + + {confirmText} + diff --git a/frontend/src/components/HoverInfo.jsx b/frontend/src/components/HoverInfo.jsx new file mode 100644 index 0000000..5d76ea7 --- /dev/null +++ b/frontend/src/components/HoverInfo.jsx @@ -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 ( +
+
+ {color && ( +
+ )} + +
+ {title} +
+
+ + {description && ( +
+ {description} +
+ )} + + {hasTime && ( +
+ {formattedStartTime && ( + + Début : {formattedStartTime} + + )} + + {formattedStartTime && + formattedEndTime && ( + + • + + )} + + {formattedEndTime && ( + + Fin : {formattedEndTime} + + )} +
+ )} +
+ ); +}; + +export default HoverInfo; \ No newline at end of file diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx index 69d462b..70b6f83 100644 --- a/frontend/src/components/NavBar.jsx +++ b/frontend/src/components/NavBar.jsx @@ -23,6 +23,7 @@ import { LogOut, Wheat, Calendar, + ClipboardList, } from 'lucide-react'; const NavBar = () => { @@ -68,10 +69,13 @@ const NavBar = () => { } }; + /* + * Navigation principale + */ const navigation = [ { - title: 'Profil', - href: '/profile', + title: user.first_name, + href: '/myspace', 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 = ` w-full @@ -116,7 +148,6 @@ const NavBar = () => { px-2 " > - {/* Nom du groupe */}
); + /* + * 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 ( + + + + + + {item.title} + + + } + /> + + ); + }); + return ( { - {navigation.map((item) => { - const Icon = item.icon; - - return ( - - - - - - {item.title} - - - } - /> - - ); - })} + {renderMenuItems(navigation)} @@ -235,25 +272,7 @@ const NavBar = () => { - - - - - - Calendrier - - - } - /> - + {renderMenuItems(entreprise)} @@ -267,25 +286,7 @@ const NavBar = () => { - - - - - - Utilisateurs - - - } - /> - + {renderMenuItems(administration)} diff --git a/frontend/src/components/UnloggedRoute.jsx b/frontend/src/components/UnloggedRoute.jsx new file mode 100644 index 0000000..740bdd6 --- /dev/null +++ b/frontend/src/components/UnloggedRoute.jsx @@ -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 ; + } + + if (user) { + // Redirect them to the /login page, but save the current location they were trying to go to. + return ; + } + + return children; +}; + +export default UnloggedRoute; diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 68c608f..a007cee 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import api from '../api/axios'; +import api, { clearSession } from '../api/axios'; import { AuthContext } from './authContextValue'; export const AuthProvider = ({ children }) => { @@ -8,31 +8,40 @@ export const AuthProvider = ({ children }) => { const [isAdmin, setIsAdmin] = useState(false); useEffect(() => { + let cancelled = false; + const onLogout = () => { + setUser(null); + setIsAdmin(false); + }; + window.addEventListener('auth:logout', onLogout); const checkAuth = async () => { const token = localStorage.getItem('token'); - const savedUser = localStorage.getItem('user'); - if (token && savedUser) { + if (token) { try { // Verify token by fetching user profile const response = await api.get('/users/me/'); const userData = response.data; - setUser(userData); - setIsAdmin(userData.is_admin || false); + if (!cancelled && localStorage.getItem('token')) { + localStorage.setItem('user', JSON.stringify(userData)); + setUser(userData); + setIsAdmin(userData.is_admin || false); + } } catch (error) { console.error('Failed to verify authentication', error); - localStorage.removeItem('token'); - localStorage.removeItem('refresh'); - localStorage.removeItem('user'); } } - setLoading(false); + if (!cancelled) setLoading(false); }; checkAuth(); + return () => { + cancelled = true; + window.removeEventListener('auth:logout', onLogout); + }; }, []); const login = async (username, password) => { @@ -65,9 +74,7 @@ export const AuthProvider = ({ children }) => { }; const logout = () => { - localStorage.removeItem('token'); - localStorage.removeItem('refresh'); - localStorage.removeItem('user'); + clearSession(); setUser(null); setIsAdmin(false); diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index a900e52..a116d34 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -17,12 +17,12 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; 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 { useAuth } from '../context/useAuth'; import { Users} from 'lucide-react'; const AdminUsersPage = () => { - const { user, logout } = useAuth(); + const { user: currentUser } = useAuth(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -51,24 +51,6 @@ const AdminUsersPage = () => { 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 = () => { if (inviteLink) { navigator.clipboard.writeText(inviteLink).then(() => { @@ -102,32 +84,19 @@ const AdminUsersPage = () => { try { await userApi.deleteUser(userId); - if (String(userId) === String(getUserId(user))) { - logout(); - return; - } - setUsers(users.filter(user => String(user.id) !== String(userId))); } catch (err) { - setError('Erreur lors de la suppression de l\'utilisateur'); + setError(err.message); console.error(err); } }; const handleMakeAdmin = async (userId) => { - const confirmed = await new Promise((resolve) => { - resolve(true); - }); - - if (!confirmed) { - return; - } - try { await userApi.promoteUser(userId); window.location.reload(); } catch (err) { - setError('Erreur lors de la promotion de l\'utilisateur'); + setError(err.message); console.error(err); } }; @@ -139,10 +108,12 @@ const AdminUsersPage = () => { } 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(); } catch (err) { - setError('Erreur lors de la mise à jour de l\'utilisateur'); + setError(err.message); console.error(err); } }; @@ -260,20 +231,31 @@ const AdminUsersPage = () => { } }} > - - - + Modifier l'utilisateur + {error && {error}}
@@ -418,7 +400,7 @@ const AdminUsersPage = () => { description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?" onConfirm={() => handleDeleteUser(user.id)} > - @@ -434,4 +416,4 @@ const AdminUsersPage = () => { ); }; -export default AdminUsersPage; \ No newline at end of file +export default AdminUsersPage; diff --git a/frontend/src/pages/CalendarPage.jsx b/frontend/src/pages/CalendarPage.jsx index 0668b15..31aa16b 100644 --- a/frontend/src/pages/CalendarPage.jsx +++ b/frontend/src/pages/CalendarPage.jsx @@ -1,366 +1,3911 @@ import React, { + useEffect, + useLayoutEffect, + + useMemo, + useRef, + useState, + } from 'react'; + + + import LoadingCard from '@/components/LoadingCard'; + import MainCard from '@/components/MainCard'; + +import HoverInfo from '@/components/HoverInfo'; + + + import { + Card, - CardContent + + CardContent, + } from '@/components/ui/card'; + + import { Button } from '@/components/ui/button'; + + import { + + Dialog, + + DialogContent, + + DialogDescription, + + DialogFooter, + + DialogHeader, + + DialogTitle, + +} from '@/components/ui/dialog'; + + + +import { Input } from '@/components/ui/input'; + + + +import { Label } from '@/components/ui/label'; + + + +import { Textarea } from '@/components/ui/textarea'; + + + +import { + ChevronLeft, + ChevronRight, + Calendar, + + Plus, + + Pencil, + + Trash2, + } from 'lucide-react'; + + +import { eventApi } from '../api/event'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; + + + const MONTHS = [ + 'Janvier', + 'Février', + 'Mars', + 'Avril', + 'Mai', + 'Juin', + 'Juillet', + 'Août', + 'Septembre', + 'Octobre', + 'Novembre', + 'Décembre', + ]; + + const DAYS_IN_MONTH = [ + 31, + 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + + const HEADER_HEIGHT = 52; + const DAY_COLUMN_WIDTH = 52; + const MIN_MONTH_WIDTH = 90; + const MIN_ROW_HEIGHT = 28; + + +const DEFAULT_EVENT_COLOR = '#2563eb'; + +const EVENT_BAR_WIDTH = 8; +const EVENT_LANE_GAP = 11; +const EVENT_LEFT_PADDING = 5; + + + const CalendarPage = () => { - const [year, setYear] = useState(new Date().getFullYear()); - const [isMobile, setIsMobile] = useState(false); - const [availableHeight, setAvailableHeight] = useState(600); + + const [requestError, setRequestError] = useState(''); + + const [year, setYear] = useState( + + new Date().getFullYear(), + + ); + + + + const [isMobile, setIsMobile] = + + useState(false); + + + + const [availableHeight, setAvailableHeight] = + + useState(600); + + + + const [events, setEvents] = + + useState([]); + + + + const [loadingEvents, setLoadingEvents] = + + useState(true); + + + + const [hoveredEvent, setHoveredEvent] = + + useState(null); + + + + const [hoverPosition, setHoverPosition] = + + useState(null); + + + + /* + + * ============================================================ + + * MENU CONTEXTUEL + + * ============================================================ + + */ + + + + const [contextMenu, setContextMenu] = + + useState(null); + + + + /* + + * contextMenu : + + * + + * { + + * x: number, + + * y: number, + + * type: 'day' | 'event', + + * date?: 'YYYY-MM-DD', + + * event?: object + + * } + + */ + + + + /* + + * ============================================================ + + * DIALOG ÉVÉNEMENT + + * ============================================================ + + */ + + + + const [eventDialogOpen, setEventDialogOpen] = + + useState(false); + + + + const [editingEvent, setEditingEvent] = + + useState(null); + + + + const [savingEvent, setSavingEvent] = + + useState(false); + + + + const [eventForm, setEventForm] = + + useState({ + + name: '', + + description: '', + + start_date: '', + + end_date: '', + + start_time: '', + + end_time: '', + + color: DEFAULT_EVENT_COLOR, + + }); + + + const calendarRef = useRef(null); + /* SÉLECTION D'UNE PÉRIODE */ + const [selectionStart, setSelectionStart] = useState(null); + const [selectionEnd, setSelectionEnd] = useState(null); + const [isSelecting, setIsSelecting] = useState(false); + + const selectionEndRef = useRef(null); + const isSelectingRef = useRef(false); + + + + /* + + * ============================================================ + + * UTILITAIRES + + * ============================================================ + + */ + + + const isLeapYear = (value) => { + return ( - (value % 4 === 0 && value % 100 !== 0) || + + (value % 4 === 0 && + + value % 100 !== 0) || + value % 400 === 0 + + ); + + }; + + + + const getDaysInMonth = (monthIndex) => { + + if ( + + monthIndex === 1 && + + isLeapYear(year) + + ) { + + return 29; + + } + + + + return DAYS_IN_MONTH[monthIndex]; + + }; + + + + const formatDateForInput = ( + + monthIndex, + + day, + + ) => { + + return `${year}-${String( + + monthIndex + 1, + + ).padStart(2, '0')}-${String(day).padStart( + + 2, + + '0', + + )}`; + + }; + + + + const parseDate = (value) => { + + if (!value) return null; + + + + const [ + + yearValue, + + monthValue, + + dayValue, + + ] = String(value) + + .split('-') + + .map(Number); + + + + if ( + + !yearValue || + + !monthValue || + + !dayValue + + ) { + + return null; + + } + + + + return new Date( + + yearValue, + + monthValue - 1, + + dayValue, + + ); + + }; + + + + const getEventStartDate = ( + + event, + + ) => { + + return event?.start_date ?? ''; + + }; + + + + const getEventEndDate = ( + + event, + + ) => { + + return ( + + event?.end_date ?? + + event?.start_date ?? + + '' + + ); + + }; + + + + const isDateInEvent = ( + + event, + + monthIndex, + + day, + + ) => { + + const eventStart = + + parseDate( + + getEventStartDate(event), + + ); + + + + const eventEnd = + + parseDate( + + getEventEndDate(event), + + ); + + + + if ( + + !eventStart || + + !eventEnd + + ) { + + return false; + + } + + + + const cellDate = new Date( + + year, + + monthIndex, + + day, + + ); + + + + return ( + + cellDate >= eventStart && + + cellDate <= eventEnd + + ); + + }; + + + + const isSingleDayEvent = ( + + event, + + ) => { + + return ( + + getEventStartDate(event) === + + getEventEndDate(event) + + ); + + }; + + + + const getPeriodEventPosition = ( + + event, + + monthIndex, + + day, + + ) => { + + const startDate = + + parseDate( + + getEventStartDate(event), + + ); + + + + const endDate = + + parseDate( + + getEventEndDate(event), + + ); + + + + if ( + + !startDate || + + !endDate + + ) { + + return null; + + } + + + + const currentDate = new Date( + + year, + + monthIndex, + + day, + + ); + + + + return { + + isStart: + + currentDate.getTime() === + + startDate.getTime(), + + + + isEnd: + + currentDate.getTime() === + + endDate.getTime(), + + }; + + }; + + + + const getOrderedSelection = () => { + if (!selectionStart || !selectionEnd) return null; + + return selectionStart <= selectionEnd + ? { start: selectionStart, end: selectionEnd } + : { start: selectionEnd, end: selectionStart }; + }; + + const isDateSelected = (date) => { + const selection = getOrderedSelection(); + return Boolean( + selection && + date >= selection.start && + date <= selection.end, ); }; - const getDaysInMonth = (monthIndex) => { - if (monthIndex === 1 && isLeapYear(year)) { - return 29; - } - - return DAYS_IN_MONTH[monthIndex]; + const clearSelection = () => { + setSelectionStart(null); + setSelectionEnd(null); + setIsSelecting(false); + selectionEndRef.current = null; + isSelectingRef.current = false; }; + const beginSelection = (pointerEvent, date) => { + if (pointerEvent.button !== 0) return; + + pointerEvent.preventDefault(); + closeContextMenu(); + setHoveredEvent(null); + setHoverPosition(null); + + selectionEndRef.current = date; + isSelectingRef.current = true; + setSelectionStart(date); + setSelectionEnd(date); + setIsSelecting(true); + }; + + const updateSelectionFromPointer = (pointerEvent) => { + if (!isSelectingRef.current) return; + + const element = document.elementFromPoint( + pointerEvent.clientX, + pointerEvent.clientY, + ); + const cell = element?.closest?.('[data-calendar-date]'); + const date = cell?.dataset?.calendarDate; + + if (!date || date === selectionEndRef.current) return; + + selectionEndRef.current = date; + setSelectionEnd(date); + }; + + const finishSelection = () => { + if (!isSelectingRef.current) return; + isSelectingRef.current = false; + setIsSelecting(false); + }; + + /* + + * ============================================================ + + * CHARGEMENT DES ÉVÉNEMENTS + + * ============================================================ + + */ + + + + const loadEvents = async () => { + + try { + + setRequestError(''); + + setLoadingEvents(true); + + + + const data = + + await eventApi.getAllEvents(); + + + + const loadedEvents = + + Array.isArray(data) + + ? data + + : data?.results ?? []; + + + + setEvents(loadedEvents); + + } catch (error) { + + setRequestError(error.message); + + console.error( + + 'Erreur lors du chargement des événements :', + + error, + + ); + + + + setEvents([]); + + } finally { + + setLoadingEvents(false); + + } + + }; + + + useEffect(() => { + + loadEvents(); + + }, []); + + + + /* + + * ============================================================ + + * RESPONSIVE + + * ============================================================ + + */ + + + + useEffect(() => { + const updateMobile = () => { - setIsMobile(window.innerWidth < 768); + + setIsMobile( + + window.innerWidth < 768, + + ); + }; + + updateMobile(); - window.addEventListener('resize', updateMobile); + + + window.addEventListener( + + 'resize', + + updateMobile, + + ); + + return () => { - window.removeEventListener('resize', updateMobile); + + window.removeEventListener( + + 'resize', + + updateMobile, + + ); + + }; + + }, []); + + + + useEffect(() => { + const handlePointerMove = (pointerEvent) => { + updateSelectionFromPointer(pointerEvent); + }; + + const handlePointerUp = () => { + finishSelection(); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); + + return () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); }; }, []); + /* + + * ============================================================ + + * DIMENSIONS CALENDRIER + + * ============================================================ + + */ + + + useLayoutEffect(() => { + + if (loadingEvents) return; + + + const updateDimensions = () => { - const calendar = calendarRef.current; + + const calendar = + + calendarRef.current; + + if (!calendar) return; - const cardContent = calendar.closest( - '[data-slot="card-content"]', - ); + + + const cardContent = + + calendar.closest( + + '[data-slot="card-content"]', + + ); + + if (!cardContent) return; - const calendarTop = calendar.getBoundingClientRect().top; - const contentRect = cardContent.getBoundingClientRect(); - const footer = document.querySelector('footer'); + + const calendarTop = + + calendar.getBoundingClientRect() + + .top; + + + + const contentRect = + + cardContent.getBoundingClientRect(); + + + + const footer = + + document.querySelector('footer'); + + const footerTop = footer - ? footer.getBoundingClientRect().top + + ? footer.getBoundingClientRect() + + .top + : window.innerHeight; + + const bottom = Math.min( + contentRect.bottom, + footerTop, + window.innerHeight, + ); - const height = bottom - calendarTop - 4; - setAvailableHeight(Math.max(300, height)); + + const height = + + bottom - calendarTop - 4; + + + + setAvailableHeight( + + Math.max(300, height), + + ); + }; + + updateDimensions(); - window.addEventListener('resize', updateDimensions); + + + window.addEventListener( + + 'resize', + + updateDimensions, + + ); + + let observer = null; + + if ( - typeof ResizeObserver !== 'undefined' && + + typeof ResizeObserver !== + + 'undefined' && + calendarRef.current + ) { - observer = new ResizeObserver(updateDimensions); - observer.observe(calendarRef.current); + + observer = new ResizeObserver( + + updateDimensions, + + ); + + + + observer.observe( + + calendarRef.current, + + ); + } + + return () => { - window.removeEventListener('resize', updateDimensions); + + window.removeEventListener( + + 'resize', + + updateDimensions, + + ); + + + observer?.disconnect(); + }; - }, [year]); + + }, [year, loadingEvents]); + + + + /* + + * ============================================================ + + * ANNÉE + + * ============================================================ + + */ + + const previousYear = () => { - setYear((current) => current - 1); + + setYear( + + (current) => current - 1, + + ); + }; + + const nextYear = () => { - setYear((current) => current + 1); + + setYear( + + (current) => current + 1, + + ); + }; + + const currentYear = () => { - setYear(new Date().getFullYear()); + + setYear( + + new Date().getFullYear(), + + ); + }; + + + /* + + * ============================================================ + + * SURVOL + + * ============================================================ + + */ + + + + const handleEventMouseEnter = ( + + event, + + mouseEvent, + + ) => { + + setHoveredEvent(event); + + + + setHoverPosition({ + + x: mouseEvent.clientX, + + y: mouseEvent.clientY, + + }); + + }; + + + + const handleEventMouseMove = ( + + mouseEvent, + + ) => { + + setHoverPosition({ + + x: mouseEvent.clientX, + + y: mouseEvent.clientY, + + }); + + }; + + + + const handleEventMouseLeave = () => { + + setHoveredEvent(null); + + setHoverPosition(null); + + }; + + + + /* + + * ============================================================ + + * MENU CONTEXTUEL + + * ============================================================ + + */ + + + + const closeContextMenu = () => { + + setContextMenu(null); + + }; + + + + const handleDayContextMenu = ( + mouseEvent, + date, + ) => { + mouseEvent.preventDefault(); + mouseEvent.stopPropagation(); + + setHoveredEvent(null); + setHoverPosition(null); + + const selection = getOrderedSelection(); + const useSelection = Boolean( + selection && + date >= selection.start && + date <= selection.end, + ); + + setContextMenu({ + x: mouseEvent.clientX, + y: mouseEvent.clientY, + type: useSelection ? 'selection' : 'day', + date, + startDate: useSelection ? selection.start : date, + endDate: useSelection ? selection.end : date, + }); + }; + + + + const handleEventContextMenu = ( + + mouseEvent, + + event, + + ) => { + + mouseEvent.preventDefault(); + + mouseEvent.stopPropagation(); + + + + setHoveredEvent(null); + + setHoverPosition(null); + + + + setContextMenu({ + + x: mouseEvent.clientX, + + y: mouseEvent.clientY, + + type: 'event', + + event, + + }); + + }; + + + + useEffect(() => { + + if (!contextMenu) return; + + + + const handleOutsideClick = () => { + + closeContextMenu(); + + }; + + + + const handleEscape = ( + + keyboardEvent, + + ) => { + + if ( + + keyboardEvent.key === 'Escape' + + ) { + + closeContextMenu(); + + } + + }; + + + + document.addEventListener( + + 'mousedown', + + handleOutsideClick, + + ); + + + + document.addEventListener( + + 'keydown', + + handleEscape, + + ); + + + + return () => { + + document.removeEventListener( + + 'mousedown', + + handleOutsideClick, + + ); + + + + document.removeEventListener( + + 'keydown', + + handleEscape, + + ); + + }; + + }, [contextMenu]); + + + + /* + + * ============================================================ + + * FORMULAIRE ÉVÉNEMENT + + * ============================================================ + + */ + + + + const resetEventForm = () => { + + setEventForm({ + + name: '', + + description: '', + + start_date: '', + + end_date: '', + + start_time: '', + + end_time: '', + + color: DEFAULT_EVENT_COLOR, + + }); + + }; + + + + const openCreateDialog = ( + startDate = '', + endDate = startDate, + ) => { + setEditingEvent(null); + + setEventForm({ + name: '', + description: '', + start_date: startDate, + end_date: endDate || startDate, + start_time: '', + end_time: '', + color: DEFAULT_EVENT_COLOR, + }); + + clearSelection(); + closeContextMenu(); + setEventDialogOpen(true); + }; + + + + const openEditDialog = ( + + event, + + ) => { + + setEditingEvent(event); + + + + setEventForm({ + + name: event?.name ?? '', + + description: + + event?.description ?? '', + + start_date: + + event?.start_date ?? '', + + end_date: + + event?.end_date ?? + + event?.start_date ?? + + '', + + start_time: + + event?.start_time + + ? String( + + event.start_time, + + ).slice(0, 5) + + : '', + + end_time: + + event?.end_time + + ? String( + + event.end_time, + + ).slice(0, 5) + + : '', + + color: + + event?.color || + + DEFAULT_EVENT_COLOR, + + }); + + + + closeContextMenu(); + + setEventDialogOpen(true); + + }; + + + + const handleFormChange = ( + + field, + + value, + + ) => { + + setEventForm( + + (current) => ({ + + ...current, + + [field]: value, + + }), + + ); + + }; + + + + const handleStartDateChange = ( + + value, + + ) => { + + setEventForm( + + (current) => { + + const next = { + + ...current, + + start_date: value, + + }; + + + + /* + + * Si la date de fin est vide ou + + * antérieure à la nouvelle date + + * de début, on la recale sur + + * la date de début. + + */ + + if ( + + !current.end_date || + + current.end_date < value + + ) { + + next.end_date = value; + + } + + + + return next; + + }, + + ); + + }; + + + + const handleSaveEvent = async ( + + submitEvent, + + ) => { + + setRequestError(''); + + if (!eventForm.name.trim()) { + + return; + + } + + + + if (!eventForm.start_date) { + + return; + + } + + + + const startDate = + + parseDate( + + eventForm.start_date, + + ); + + + + const endDate = + + parseDate( + + eventForm.end_date || + + eventForm.start_date, + + ); + + + + if ( + + !startDate || + + !endDate || + + endDate < startDate + + ) { + + return; + + } + + + + try { + + setSavingEvent(true); + + + + const payload = { + + name: eventForm.name.trim(), + + description: + + eventForm.description.trim(), + + start_date: + + eventForm.start_date, + + end_date: + + eventForm.end_date || + + eventForm.start_date, + + start_time: + + eventForm.start_time || null, + + end_time: + + eventForm.end_time || null, + + color: + + eventForm.color || + + DEFAULT_EVENT_COLOR, + + }; + + + + if (submitEvent) { + + await eventApi.updateEvent( + + editingEvent.id, + + payload, + + ); + + } else { + + await eventApi.createEvent( + + payload, + + ); + + } + + + + setEventDialogOpen(false); + + setEditingEvent(null); + + resetEventForm(); + + + + await loadEvents(); + + } catch (error) { + + setRequestError(error.message); + + console.error( + + submitEvent + + ? 'Erreur lors de la modification de l’événement :' + + : 'Erreur lors de la création de l’événement :', + + error, + + ); + + } finally { + + setSavingEvent(false); + + } + + }; + + + + const handleDeleteEvent = async ( + + event, + + ) => { + + closeContextMenu(); + + + + const confirmed = + + window.confirm( + + `Supprimer l’événement « ${ + + event?.name || 'Sans nom' + + } » ?`, + + ); + + + + if (!confirmed) { + + return; + + } + + + + try { + + await eventApi.deleteEvent( + + event.id, + + ); + + + + await loadEvents(); + + } catch (error) { + + setRequestError(error.message); + + console.error( + + 'Erreur lors de la suppression de l’événement :', + + error, + + ); + + } + + }; + + + + /* + + * ============================================================ + + * AUJOURD'HUI + + * ============================================================ + + */ + + + const today = new Date(); - const isToday = (monthIndex, day) => { + + + const isToday = ( + + monthIndex, + + day, + + ) => { + return ( + today.getFullYear() === year && + today.getMonth() === monthIndex && + today.getDate() === day + ); + }; + + + /* + + * ============================================================ + + * ÉVÉNEMENTS DE L'ANNÉE + + * ============================================================ + + */ + + + + const yearEvents = events.filter( + + (event) => { + + const start = parseDate( + + getEventStartDate(event), + + ); + + + + const end = parseDate( + + getEventEndDate(event), + + ); + + + + if (!start || !end) { + + return false; + + } + + + + const yearStart = new Date( + + year, + + 0, + + 1, + + ); + + + + const yearEnd = new Date( + + year, + + 11, + + 31, + + ); + + + + return ( + + end >= yearStart && + + start <= yearEnd + + ); + + }, + + ); + + + + /* + + * ============================================================ + + * LANES DES ÉVÉNEMENTS CONTINUS + + * ============================================================ + + */ + + + + const eventLanes = useMemo(() => { + + const periodEvents = yearEvents + + .filter((event) => !isSingleDayEvent(event)) + + .map((event) => ({ + + event, + + start: parseDate(getEventStartDate(event)), + + end: parseDate(getEventEndDate(event)), + + })) + + .filter(({ start, end }) => start && end) + + .sort((a, b) => { + + const startDifference = a.start - b.start; + + if (startDifference !== 0) return startDifference; + + + + const endDifference = b.end - a.end; + + if (endDifference !== 0) return endDifference; + + + + return String(a.event.id).localeCompare(String(b.event.id)); + + }); + + + + const laneEndDates = []; + + const lanes = new Map(); + + + + periodEvents.forEach(({ event, start, end }) => { + + let lane = laneEndDates.findIndex( + + (laneEndDate) => laneEndDate < start, + + ); + + + + if (lane === -1) { + + lane = laneEndDates.length; + + } + + + + laneEndDates[lane] = end; + + lanes.set(String(event.id), lane); + + }); + + + + return lanes; + + }, [yearEvents]); + + + + /* + + * ============================================================ + + * DIMENSIONS + + * ============================================================ + + */ + + + const rowCount = 31; - const availableGridHeight = Math.max( - availableHeight - HEADER_HEIGHT, - rowCount * MIN_ROW_HEIGHT, - ); + + + const availableGridHeight = + + Math.max( + + availableHeight - + + HEADER_HEIGHT, + + rowCount * + + MIN_ROW_HEIGHT, + + ); + + const rowHeight = Math.max( + MIN_ROW_HEIGHT, - availableGridHeight / rowCount, + + availableGridHeight / + + rowCount, + ); + + const minimumGridWidth = - DAY_COLUMN_WIDTH + MONTHS.length * MIN_MONTH_WIDTH; + + DAY_COLUMN_WIDTH + + + MONTHS.length * + + MIN_MONTH_WIDTH; + + + + /* + + * ============================================================ + + * RENDER + + * ============================================================ + + */ + + + + if (loadingEvents) { + + return ( + + + + ); + + } + + return ( - - - + {/* ====================================================== - - }> + HOVER EVENT + + ====================================================== */} + + + + {hoveredEvent && ( + + + + )} + + + + {/* ====================================================== + + MENU CONTEXTUEL + + ====================================================== */} + + + + {contextMenu && ( + +
{ + + mouseEvent.stopPropagation(); + + }} + + onContextMenu={( + + mouseEvent, + + ) => { + + mouseEvent.preventDefault(); + + mouseEvent.stopPropagation(); + + }} + + > + + {(contextMenu.type === 'day' || + contextMenu.type === 'selection') && ( + + + + )} + + + + {contextMenu.type === + + 'event' && ( + + <> + + + + + +
+ + + + + + + + )} + +
+ + )} + + + + {/* ====================================================== + + DIALOG CRÉATION / MODIFICATION + + ====================================================== */} + + + + { + + if ( + + savingEvent && + + !open + + ) { + + return; + + } + + + + setEventDialogOpen(open); + + + + if (!open) { + + setEditingEvent(null); + + resetEventForm(); + + } + + }} + + > + + + + + + + + {editingEvent + + ? 'Modifier l’événement' + + : 'Créer un événement'} + + + + + + + + {editingEvent + + ? 'Modifie les informations de cet événement.' + + : 'Ajoute un nouvel événement au calendrier.'} + + + + + + {requestError && ( + + {requestError} + + )} + + + +
+ + {/* NOM */} + + + +
+ + + + + + + + handleFormChange( + + 'name', + + event.target.value, + + ) + + } + + placeholder="Nom de l'événement" + + autoFocus + + /> + +
+ + + + {/* DESCRIPTION */} + + + +
+ + + + + +