diff --git a/backend/core/settings.py b/backend/core/settings.py index 79e243d..19a1a14 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -47,6 +47,7 @@ INSTALLED_APPS = [ # Local apps 'users', 'time_tracking', + 'planning', 'events', 'notes', 'file', # Ajout du module de gestion des fichiers diff --git a/backend/core/urls.py b/backend/core/urls.py index e7dc65f..b9432b7 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -26,6 +26,7 @@ urlpatterns = [ path('api/auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/users/', include('users.urls')), + path('api/planning/', include('planning.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/migrations/0005_eventprofile.py b/backend/events/migrations/0005_eventprofile.py new file mode 100644 index 0000000..94178da --- /dev/null +++ b/backend/events/migrations/0005_eventprofile.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0004_remove_event_linked_user_remove_event_user_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EventProfile', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('start_time', models.TimeField(blank=True, null=True)), + ('end_time', models.TimeField(blank=True, null=True)), + ('color', models.CharField(blank=True, max_length=7, null=True)), + ('is_public', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='event_profiles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['name', 'start_time', 'pk'], + }, + ), + ] diff --git a/backend/events/migrations/0006_remove_event_descriptions.py b/backend/events/migrations/0006_remove_event_descriptions.py new file mode 100644 index 0000000..f802441 --- /dev/null +++ b/backend/events/migrations/0006_remove_event_descriptions.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0005_eventprofile'), + ] + + operations = [ + migrations.RemoveField( + model_name='event', + name='description', + ), + migrations.RemoveField( + model_name='eventprofile', + name='description', + ), + ] diff --git a/backend/events/models.py b/backend/events/models.py index ee68a04..a81c83b 100644 --- a/backend/events/models.py +++ b/backend/events/models.py @@ -16,10 +16,34 @@ class Event(models.Model): end_time = models.TimeField(null=True, blank=True) color = models.CharField(max_length=7, null=True, blank=True) name = models.CharField(max_length=255) - description = models.TextField(blank=True) is_public = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return self.name + + +class EventProfile(models.Model): + """Reusable event defaults owned by one user.""" + + id = models.AutoField(primary_key=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='event_profiles', + editable=False, + ) + name = models.CharField(max_length=255) + 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) + is_public = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['name', 'start_time', 'pk'] + + def __str__(self): + return self.name diff --git a/backend/events/serializers.py b/backend/events/serializers.py index e5e4680..868ed67 100644 --- a/backend/events/serializers.py +++ b/backend/events/serializers.py @@ -1,5 +1,8 @@ from rest_framework import serializers -from .models import Event + +from users.permissions import is_admin + +from .models import Event, EventProfile class EventSerializer(serializers.ModelSerializer): @@ -8,7 +11,7 @@ class EventSerializer(serializers.ModelSerializer): fields = [ 'id', 'created_by', 'start_date', 'end_date', 'start_time', 'end_time', 'color', 'name', - 'description', 'is_public', 'created_at', 'updated_at', + 'is_public', 'created_at', 'updated_at', ] read_only_fields = ['id', 'created_by', 'created_at', 'updated_at'] @@ -23,12 +26,12 @@ class EventCreateSerializer(serializers.ModelSerializer): model = Event fields = [ 'start_date', 'end_date', 'start_time', 'end_time', - 'color', 'name', 'description', 'is_public', + 'color', 'name', 'is_public', ] def validate(self, attrs): request = self.context.get('request') - if not getattr(getattr(request, 'user', None), 'is_admin', False): + if request is None or not is_admin(request.user): # En création comme en PATCH, les événements non-admin restent privés. attrs['is_public'] = False @@ -52,3 +55,29 @@ class EventCreateSerializer(serializers.ModelSerializer): class EventUpdateSerializer(EventCreateSerializer): pass + + +class EventProfileSerializer(serializers.ModelSerializer): + color = serializers.RegexField( + r'^#[0-9a-fA-F]{6}$', required=False, + allow_null=True, allow_blank=True, + ) + + class Meta: + model = EventProfile + fields = [ + 'id', 'created_by', 'name', + 'start_time', 'end_time', 'color', 'is_public', + 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'created_by', 'created_at', 'updated_at'] + + def validate(self, attrs): + request = self.context.get('request') + if request is None or not is_admin(request.user): + # Un utilisateur non-admin ne peut pas mémoriser un profil public. + attrs['is_public'] = False + # Les dates ne font volontairement pas partie d'un profil. On ne peut donc + # pas conclure qu'une heure de fin plus petite correspond à une erreur : + # le profil peut être utilisé pour un événement qui se termine le lendemain. + return attrs diff --git a/backend/events/urls.py b/backend/events/urls.py index 991e4cd..8a374f2 100644 --- a/backend/events/urls.py +++ b/backend/events/urls.py @@ -3,6 +3,8 @@ from . import views urlpatterns = [ path('', views.EventListView.as_view(), name='event-list'), - path('/', views.EventDetailView.as_view(), name='event-detail'), path('create/', views.EventCreateView.as_view(), name='event-create'), -] \ No newline at end of file + path('profiles/', views.EventProfileListCreateView.as_view(), name='event-profile-list'), + path('profiles//', views.EventProfileDetailView.as_view(), name='event-profile-detail'), + path('/', views.EventDetailView.as_view(), name='event-detail'), +] diff --git a/backend/events/views.py b/backend/events/views.py index 40b335c..6b9e479 100644 --- a/backend/events/views.py +++ b/backend/events/views.py @@ -3,8 +3,14 @@ from rest_framework import generics, status from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS from rest_framework.response import Response -from .models import Event -from .serializers import EventSerializer, EventCreateSerializer, EventUpdateSerializer +from .models import Event, EventProfile +from users.permissions import require_permission +from .serializers import ( + EventSerializer, + EventCreateSerializer, + EventUpdateSerializer, + EventProfileSerializer, +) class IsEventCreatorOrReadOnly(BasePermission): @@ -26,6 +32,7 @@ class EventListView(VisibleEventsMixin, generics.ListCreateAPIView): permission_classes = [IsAuthenticated] def create(self, request, *args, **kwargs): + require_permission(request.user, 'create_personal_event') serializer = EventCreateSerializer( data=request.data, context=self.get_serializer_context(), ) @@ -66,3 +73,42 @@ class EventDetailView(VisibleEventsMixin, generics.RetrieveUpdateDestroyAPIView) class EventCreateView(EventListView): http_method_names = ['post', 'options'] serializer_class = EventCreateSerializer + + +class EventProfileListCreateView(generics.ListCreateAPIView): + serializer_class = EventProfileSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return EventProfile.objects.filter(created_by=self.request.user) + + def list(self, request, *args, **kwargs): + require_permission(request.user, 'create_personal_event') + return super().list(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + require_permission(request.user, 'create_personal_event') + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + profile = serializer.save(created_by=request.user) + return Response( + self.get_serializer(profile).data, + status=status.HTTP_201_CREATED, + ) + + +class EventProfileDetailView(generics.RetrieveUpdateDestroyAPIView): + serializer_class = EventProfileSerializer + permission_classes = [IsAuthenticated] + lookup_url_kwarg = 'id' + + def get_queryset(self): + return EventProfile.objects.filter(created_by=self.request.user) + + def update(self, request, *args, **kwargs): + require_permission(request.user, 'create_personal_event') + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + require_permission(request.user, 'create_personal_event') + return super().destroy(request, *args, **kwargs) diff --git a/backend/notes/migrations/0004_alter_note_kind_poll_polloption_pollvote_and_more.py b/backend/notes/migrations/0004_alter_note_kind_poll_polloption_pollvote_and_more.py new file mode 100644 index 0000000..2e175e0 --- /dev/null +++ b/backend/notes/migrations/0004_alter_note_kind_poll_polloption_pollvote_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.17 on 2026-09-30 15:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notes', '0003_remove_note_recipient_note_recipients'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='note', + name='kind', + field=models.CharField(choices=[('task', 'Tâche'), ('info', 'Information'), ('memo', 'Mémo'), ('sugg', 'Suggestion'), ('poll', 'Sondage')], default='memo', max_length=4), + ), + migrations.CreateModel( + name='Poll', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vote_mode', models.CharField(choices=[('single', 'Un seul choix'), ('multiple', 'Plusieurs choix libres'), ('exact', 'Nombre précis de choix')], default='single', max_length=8)), + ('exact_choices', models.PositiveSmallIntegerField(default=1)), + ('is_anonymous', models.BooleanField(default=True)), + ('closes_at', models.DateTimeField(blank=True, null=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('locked_at', models.DateTimeField(blank=True, null=True)), + ('note', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='poll', to='notes.note')), + ], + ), + migrations.CreateModel( + name='PollOption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(max_length=240)), + ('position', models.PositiveSmallIntegerField()), + ('poll', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='notes.poll')), + ], + options={ + 'ordering': ['position', 'pk'], + }, + ), + migrations.CreateModel( + name='PollVote', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('options', models.ManyToManyField(related_name='votes', to='notes.polloption')), + ('poll', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='notes.poll')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poll_votes', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='polloption', + constraint=models.UniqueConstraint(fields=('poll', 'position'), name='poll_option_position_unique'), + ), + migrations.AddConstraint( + model_name='pollvote', + constraint=models.UniqueConstraint(fields=('poll', 'user'), name='poll_user_vote_unique'), + ), + ] diff --git a/backend/notes/migrations/0005_normalize_polls_public.py b/backend/notes/migrations/0005_normalize_polls_public.py new file mode 100644 index 0000000..dd51861 --- /dev/null +++ b/backend/notes/migrations/0005_normalize_polls_public.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +def normalize_polls(apps, schema_editor): + Note = apps.get_model('notes', 'Note') + Poll = apps.get_model('notes', 'Poll') + + polls = Note.objects.filter(kind='poll') + polls.update(is_public=True) + for note in polls.iterator(): + note.recipients.clear() + Poll.objects.update(closes_at=None) + + +class Migration(migrations.Migration): + dependencies = [('notes', '0004_alter_note_kind_poll_polloption_pollvote_and_more')] + + operations = [ + migrations.RunPython(normalize_polls, migrations.RunPython.noop), + ] diff --git a/backend/notes/models.py b/backend/notes/models.py index 53be69b..af75b8b 100644 --- a/backend/notes/models.py +++ b/backend/notes/models.py @@ -8,6 +8,7 @@ class Note(models.Model): INFO = 'info', 'Information' MEMO = 'memo', 'Mémo' SUGGESTION = 'sugg', 'Suggestion' + POLL = 'poll', 'Sondage' title = models.CharField( max_length=160, @@ -77,4 +78,39 @@ class Note(models.Model): return self.completed_at is not None def __str__(self): - return self.title \ No newline at end of file + return self.title + +class Poll(models.Model): + class VoteMode(models.TextChoices): + SINGLE = 'single', 'Un seul choix' + MULTIPLE = 'multiple', 'Plusieurs choix libres' + EXACT = 'exact', 'Nombre précis de choix' + + note = models.OneToOneField(Note, on_delete=models.CASCADE, related_name='poll') + vote_mode = models.CharField(max_length=8, choices=VoteMode.choices, default=VoteMode.SINGLE) + exact_choices = models.PositiveSmallIntegerField(default=1) + is_anonymous = models.BooleanField(default=True) + closes_at = models.DateTimeField(null=True, blank=True) + closed_at = models.DateTimeField(null=True, blank=True) + locked_at = models.DateTimeField(null=True, blank=True) + + +class PollOption(models.Model): + poll = models.ForeignKey(Poll, on_delete=models.CASCADE, related_name='options') + label = models.CharField(max_length=240) + position = models.PositiveSmallIntegerField() + + class Meta: + ordering = ['position', 'pk'] + constraints = [models.UniqueConstraint(fields=['poll', 'position'], name='poll_option_position_unique')] + + +class PollVote(models.Model): + poll = models.ForeignKey(Poll, on_delete=models.CASCADE, related_name='votes') + user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='poll_votes') + options = models.ManyToManyField(PollOption, related_name='votes') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [models.UniqueConstraint(fields=['poll', 'user'], name='poll_user_vote_unique')] diff --git a/backend/notes/poll_views.py b/backend/notes/poll_views.py new file mode 100644 index 0000000..facb104 --- /dev/null +++ b/backend/notes/poll_views.py @@ -0,0 +1,61 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import serializers +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 Note, Poll, PollVote +from .permissions import can_edit_note +from .polls import can_vote, is_closed +from .serializers import NoteSerializer +from .views import visible_notes + + +class PollVoteView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def put(self, request, pk): + note = get_object_or_404(visible_notes(request.user), pk=pk, kind='poll') + get_object_or_404(Note.objects.select_for_update(), pk=note.pk) + # Relire après le verrou : règles, droits et clôture peuvent avoir changé. + note = get_object_or_404(visible_notes(request.user), pk=pk, kind='poll') + poll = get_object_or_404(Poll, note=note) + if is_closed(poll): + raise ValidationError('Ce sondage est clôturé.') + if not can_vote(request.user, note): + raise PermissionDenied('Ce sondage est réservé aux destinataires indiqués.') + choices = serializers.ListField(child=serializers.IntegerField(min_value=1), min_length=1, max_length=30).run_validation(request.data.get('option_ids')) + if len(set(choices)) != len(choices) or poll.options.filter(pk__in=choices).count() != len(choices): + raise ValidationError('Choisissez des réponses distinctes appartenant à ce sondage.') + expected = 1 if poll.vote_mode == 'single' else poll.exact_choices if poll.vote_mode == 'exact' else None + if expected is not None and len(choices) != expected: + raise ValidationError(f'Sélectionnez exactement {expected} réponse(s).') + vote, _ = PollVote.objects.get_or_create(poll=poll, user=request.user) + vote.options.set(choices) + vote.save() + if not poll.locked_at: + poll.locked_at = timezone.now() + poll.save(update_fields=['locked_at']) + fresh = visible_notes(request.user).get(pk=pk) + return Response(NoteSerializer(fresh, context={'request': request}).data) + + +class PollCloseView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def post(self, request, pk): + note = get_object_or_404(visible_notes(request.user), pk=pk, kind='poll') + get_object_or_404(Note.objects.select_for_update(), pk=note.pk) + note = get_object_or_404(visible_notes(request.user), pk=pk, kind='poll') + if not can_edit_note(request.user, note): + raise PermissionDenied('Seuls l’auteur et les administrateurs autorisés peuvent clôturer ce sondage.') + poll = get_object_or_404(Poll, note=note) + if not poll.closed_at: + poll.closed_at = timezone.now() + poll.save(update_fields=['closed_at']) + return Response(NoteSerializer(visible_notes(request.user).get(pk=pk), context={'request': request}).data) diff --git a/backend/notes/polls.py b/backend/notes/polls.py new file mode 100644 index 0000000..71c81f5 --- /dev/null +++ b/backend/notes/polls.py @@ -0,0 +1,111 @@ +"""Règles des sondages. L'anonymat est appliqué avant toute sérialisation API.""" +from rest_framework import serializers + +from .models import Poll, PollOption + + +class PollSettingsSerializer(serializers.Serializer): + vote_mode = serializers.ChoiceField(choices=Poll.VoteMode.choices, default='single') + exact_choices = serializers.IntegerField(min_value=1, max_value=30, default=1) + is_anonymous = serializers.BooleanField(default=True) + # Compatibilité API : une ancienne app peut encore envoyer closes_at, mais il est ignoré. + closes_at = serializers.DateTimeField(allow_null=True, required=False, write_only=True) + options = serializers.ListField(child=serializers.CharField(max_length=240, trim_whitespace=True), min_length=2, max_length=30) + + def validate(self, attrs): + attrs.pop('closes_at', None) + # DRF n'applique pas les valeurs par défaut imbriquées lors d'un PATCH. + existing = getattr(getattr(self.root, 'instance', None), 'poll', None) + defaults = dict(vote_mode='single', exact_choices=1, is_anonymous=True) + if existing: + defaults = {key: getattr(existing, key) for key in defaults} + defaults['options'] = [option.label for option in existing.options.all()] + attrs = {**defaults, **attrs} + if 'options' not in attrs: + raise serializers.ValidationError({'options': 'Ajoutez au moins deux réponses.'}) + labels = [label.casefold() for label in attrs['options']] + if len(set(labels)) != len(labels): + raise serializers.ValidationError({'options': 'Chaque réponse proposée doit être différente.'}) + if attrs['vote_mode'] == 'exact' and attrs['exact_choices'] > len(labels): + raise serializers.ValidationError({'exact_choices': 'Le nombre de choix ne peut pas dépasser le nombre de réponses proposées.'}) + if attrs['vote_mode'] != 'exact': + attrs['exact_choices'] = 1 + return attrs + + +def is_closed(poll): + return bool(poll.closed_at) + + +def can_vote(user, note): + recipients = list(note.recipients.all()) + return bool(user.is_authenticated and user.is_active and + (any(person.pk == user.pk for person in recipients) if recipients else note.is_public)) + + +def person(user): + if user is None: + return {'id': None, 'username': 'Compte supprimé', 'first_name': '', 'last_name': ''} + return {key: getattr(user, key) for key in ['id', 'username', 'first_name', 'last_name']} + + +def poll_representation(note, user): + if note.kind != 'poll': + return None + try: + poll = note.poll + except Poll.DoesNotExist: + return None + votes = list(poll.votes.all()) + total = len(votes) + selections = {vote.pk: {option.pk for option in vote.options.all()} for vote in votes} + own = next((vote for vote in votes if vote.user_id == user.pk), None) + options = [] + for option in poll.options.all(): + selected = [vote for vote in votes if option.pk in selections[vote.pk]] + item = dict(id=option.pk, label=option.label, count=len(selected), percent=round(len(selected) * 100 / total, 1) if total else 0) + if not poll.is_anonymous: + item['voters'] = [person(vote.user) for vote in selected] + options.append(item) + return dict(vote_mode=poll.vote_mode, exact_choices=poll.exact_choices, is_anonymous=poll.is_anonymous, + closes_at=None, is_closed=is_closed(poll), is_locked=bool(poll.locked_at), + can_vote=can_vote(user, note) and not is_closed(poll), total_voters=total, + my_option_ids=sorted(selections[own.pk]) if own else [], options=options) + + +def validate_poll_note(instance, attrs): + kind = attrs.get('kind', instance.kind if instance else 'memo') + data = attrs.get('poll_settings') + poll = getattr(instance, 'poll', None) if instance else None + if kind == 'poll' and not poll and data is None: + raise serializers.ValidationError({'poll_settings': 'Renseignez les paramètres et les réponses du sondage.'}) + if kind != 'poll' and data is not None: + raise serializers.ValidationError({'poll_settings': 'Ces paramètres sont réservés aux sondages.'}) + if poll and poll.locked_at: + if kind != 'poll' or attrs.get('title', instance.title) != instance.title: + raise serializers.ValidationError('La question et le type sont figés après le premier vote.') + if attrs.get('is_public', instance.is_public) != instance.is_public: + raise serializers.ValidationError('La visibilité est figée après le premier vote.') + if 'recipients' in attrs and {u.pk for u in attrs['recipients']} != {u.pk for u in instance.recipients.all()}: + raise serializers.ValidationError('Les participants sont figés après le premier vote.') + if data is not None: + for key in ['vote_mode', 'exact_choices', 'is_anonymous']: + if data[key] != getattr(poll, key): + raise serializers.ValidationError('Les règles et l’anonymat sont figés après le premier vote.') + if data['options'] != [o.label for o in poll.options.all()]: + raise serializers.ValidationError('Les réponses proposées sont figées après le premier vote.') + + +def configure_poll(note, data): + if note.kind != 'poll': + Poll.objects.filter(note=note).delete() + return + if data is None: + return + values = {key: data[key] for key in ['vote_mode', 'exact_choices', 'is_anonymous']} + values['closes_at'] = None + poll, _ = Poll.objects.update_or_create(note=note, defaults=values) + current = [o.label for o in poll.options.all()] + if current != data['options']: + poll.options.all().delete() + PollOption.objects.bulk_create([PollOption(poll=poll, label=label, position=i) for i, label in enumerate(data['options'])]) diff --git a/backend/notes/serializers.py b/backend/notes/serializers.py index c46b000..f81e2ad 100644 --- a/backend/notes/serializers.py +++ b/backend/notes/serializers.py @@ -1,7 +1,9 @@ from django.contrib.auth import get_user_model +from django.db import transaction from rest_framework import serializers from .models import Note +from .polls import PollSettingsSerializer, poll_representation, validate_poll_note, configure_poll from .permissions import ( can_complete_note, can_edit_note, @@ -29,6 +31,12 @@ class NoteUserSerializer(serializers.ModelSerializer): class NoteSerializer(serializers.ModelSerializer): + poll_settings = PollSettingsSerializer(required=False, write_only=True) + poll = serializers.SerializerMethodField() + + def get_poll(self, obj): + return poll_representation(obj, self.context['request'].user) + author = NoteUserSerializer( read_only=True, ) @@ -71,6 +79,8 @@ class NoteSerializer(serializers.ModelSerializer): 'updated_at', 'can_edit', 'can_complete', + 'poll', + 'poll_settings', ] read_only_fields = [ @@ -93,6 +103,11 @@ class NoteSerializer(serializers.ModelSerializer): ) def validate(self, attrs): + kind = attrs.get('kind', self.instance.kind if self.instance else Note.Kind.MEMO) + if kind == Note.Kind.POLL: + attrs['is_public'] = True + attrs['recipients'] = [] + validate_poll_note(self.instance, attrs) is_public = attrs.get( 'is_public', self.instance.is_public @@ -120,26 +135,25 @@ class NoteSerializer(serializers.ModelSerializer): return attrs - def update( - self, - instance, - validated_data, - ): - if ( - validated_data.get( - 'kind', - instance.kind, - ) - != Note.Kind.TASK - ): - validated_data[ - 'completed_at' - ] = None + @transaction.atomic + def create(self, validated_data): + poll_settings = validated_data.pop('poll_settings', None) + note = super().create(validated_data) + configure_poll(note, poll_settings) + return note - return super().update( - instance, - validated_data, - ) + @transaction.atomic + def update(self, instance, validated_data): + poll_settings = validated_data.pop('poll_settings', None) + if validated_data.get('kind', instance.kind) != Note.Kind.TASK: + validated_data['completed_at'] = None + note = super().update(instance, validated_data) + configure_poll(note, poll_settings) + # Éviter une représentation basée sur les anciennes options préchargées. + note._prefetched_objects_cache = {} + if 'poll' in note._state.fields_cache: + del note._state.fields_cache['poll'] + return note class NoteCompletionSerializer( diff --git a/backend/notes/tests.py b/backend/notes/tests.py index 7ce503c..dd01a4e 100644 --- a/backend/notes/tests.py +++ b/backend/notes/tests.py @@ -1,3 +1,123 @@ -from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APITestCase -# Create your tests here. +from .models import Note, Poll, PollVote + + +class PollAPITests(APITestCase): + def setUp(self): + self.author = get_user_model().objects.create_user(username='author') + self.voter = get_user_model().objects.create_user(username='voter') + self.other = get_user_model().objects.create_user(username='other') + self.admin = get_user_model().objects.create_user(username='admin', is_staff=True) + self.client.force_authenticate(self.author) + + def create_poll(self, **settings): + payload = dict(title='Question ?', content='', kind='poll', is_public=True, recipient_ids=[], + poll_settings=dict(vote_mode='single', exact_choices=1, is_anonymous=True, closes_at=None, options=['A', 'B', 'C'])) + payload['poll_settings'].update(settings) + response = self.client.post('/api/notes/', payload, format='json') + self.assertEqual(response.status_code, 201, response.data) + return response.data + + def vote(self, note, choices, user=None): + self.client.force_authenticate(user or self.voter) + return self.client.put(f"/api/notes/{note['id']}/vote/", {'option_ids': [note['poll']['options'][i]['id'] for i in choices]}, format='json') + + def test_single_vote_is_replaced_and_results_stay_anonymous(self): + note = self.create_poll() + self.assertEqual(self.vote(note, [0, 1]).status_code, 400) + result = self.vote(note, [0]) + self.assertEqual(result.status_code, 200, result.data) + result = self.vote(note, [1]) + self.assertEqual(result.data['poll']['total_voters'], 1) + self.assertEqual([o['count'] for o in result.data['poll']['options']], [0, 1, 0]) + for viewer in [self.author, self.admin, self.other]: + self.client.force_authenticate(viewer) + data = self.client.get(f"/api/notes/{note['id']}/").data['poll'] + self.assertEqual(data['total_voters'], 1) + self.assertFalse(any('voters' in option for option in data['options'])) + + def test_anonymous_api_never_contains_voter_identity(self): + note = self.create_poll() + self.vote(note, [0]) + for viewer in [self.author, self.admin, self.other]: + self.client.force_authenticate(viewer) + poll = self.client.get(f"/api/notes/{note['id']}/").data['poll'] + self.assertEqual(poll['my_option_ids'], []) + for option in poll['options']: + self.assertNotIn('voters', option) + self.assertNotIn('user', option) + self.assertNotIn('username', str(poll)) + + def test_named_multiple_votes(self): + note = self.create_poll(vote_mode='multiple', is_anonymous=False) + result = self.vote(note, [0, 2]) + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual(result.data['poll']['options'][0]['voters'][0]['id'], self.voter.pk) + self.assertEqual(result.data['poll']['options'][0]['percent'], 100) + self.assertEqual(result.data['poll']['total_voters'], 1) + + def test_exact_count_and_cross_poll_option_validation(self): + note = self.create_poll(vote_mode='exact', exact_choices=2) + self.assertEqual(self.vote(note, [0]).status_code, 400) + self.assertEqual(self.vote(note, [0, 1, 2]).status_code, 400) + self.assertEqual(self.vote(note, [0, 1]).status_code, 200) + self.client.force_authenticate(self.author) + other = self.create_poll() + self.client.force_authenticate(self.voter) + result = self.client.put(f"/api/notes/{note['id']}/vote/", {'option_ids': [note['poll']['options'][0]['id'], other['poll']['options'][0]['id']]}, format='json') + self.assertEqual(result.status_code, 400) + + def test_rules_question_and_visibility_lock_after_first_vote(self): + note = self.create_poll() + self.vote(note, [0]) + self.client.force_authenticate(self.author) + for payload in [dict(kind='info'), dict(title='Autre question'), + dict(poll_settings=dict(vote_mode='single', exact_choices=1, is_anonymous=False, closes_at=None, options=['A', 'B', 'C']))]: + result = self.client.patch(f"/api/notes/{note['id']}/", payload, format='json') + self.assertEqual(result.status_code, 400, result.data) + self.assertTrue(Poll.objects.get().is_anonymous) + self.assertEqual(Note.objects.get().kind, 'poll') + self.assertEqual(self.client.patch(f"/api/notes/{note['id']}/", {'content': 'Précision'}, format='json').status_code, 200) + + def test_closed_and_deadline_polls_reject_votes(self): + note = self.create_poll() + self.client.force_authenticate(self.other) + self.assertEqual(self.client.post(f"/api/notes/{note['id']}/close/").status_code, 403) + self.client.force_authenticate(self.author) + self.assertEqual(self.client.post(f"/api/notes/{note['id']}/close/").status_code, 200) + self.assertEqual(self.vote(note, [0]).status_code, 400) + self.client.force_authenticate(self.author) + expired = self.create_poll(closes_at='2020-01-01T00:00:00Z') + self.assertEqual(self.vote(expired, [0]).status_code, 200) + + def test_poll_scope_and_recipients_are_always_public_for_everyone(self): + note = self.create_poll() + response = self.client.patch(f"/api/notes/{note['id']}/", {'is_public': False, 'recipient_ids': [self.voter.pk]}, format='json') + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data['is_public']) + self.assertEqual(response.data['recipients'], []) + self.client.force_authenticate(self.other) + self.assertEqual(self.client.get(f"/api/notes/{note['id']}/").status_code, 200) + self.assertEqual(self.vote(note, [0], self.other).status_code, 200) + + def test_new_user_can_participate_in_public_poll_for_everyone(self): + note = self.create_poll() + new_user = get_user_model().objects.create_user(username='new') + self.assertEqual(self.vote(note, [1], new_user).status_code, 200) + + def test_convert_existing_memo_to_poll_and_back_before_votes(self): + note = Note.objects.create(author=self.author, title='Mémo') + result = self.client.patch(f'/api/notes/{note.pk}/', dict(kind='poll', poll_settings=dict(options=['Oui', 'Non'])), format='json') + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual(len(result.data['poll']['options']), 2) + result = self.client.patch(f'/api/notes/{note.pk}/', dict(kind='memo'), format='json') + self.assertEqual(result.status_code, 200) + self.assertIsNone(result.data['poll']) + self.assertFalse(Poll.objects.exists()) + + def test_invalid_poll_does_not_leave_note_behind(self): + result = self.client.post('/api/notes/', dict(kind='poll', title='Test', poll_settings=dict(options=['A', 'A'])), format='json') + self.assertEqual(result.status_code, 400) + self.assertFalse(Note.objects.exists()) diff --git a/backend/notes/urls.py b/backend/notes/urls.py index df0806c..649749b 100644 --- a/backend/notes/urls.py +++ b/backend/notes/urls.py @@ -2,7 +2,11 @@ from django.urls import path from .views import NoteCompletionView, NoteDetailView, NoteListView, NoteRecipientListView +from .poll_views import PollVoteView, PollCloseView + urlpatterns = [ + path('/vote/', PollVoteView.as_view(), name='poll-vote'), + path('/close/', PollCloseView.as_view(), name='poll-close'), path('', NoteListView.as_view(), name='note-list'), path('recipients/', NoteRecipientListView.as_view(), name='note-recipients'), path('/', NoteDetailView.as_view(), name='note-detail'), diff --git a/backend/notes/views.py b/backend/notes/views.py index 42fbf1f..842311a 100644 --- a/backend/notes/views.py +++ b/backend/notes/views.py @@ -15,6 +15,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from .models import Note +from users.permissions import require_permission from .permissions import ( can_complete_note, can_edit_note, @@ -40,8 +41,8 @@ def visible_notes(user): return ( Note.objects - .select_related('author') - .prefetch_related('recipients') + .select_related('author', 'poll') + .prefetch_related('recipients', 'poll__options', 'poll__votes__options', 'poll__votes__user') .filter( Q(is_public=True) | Q(author=user) @@ -169,6 +170,7 @@ class NoteListView( self, serializer, ): + require_permission(self.request.user, 'create_publication') serializer.save( author=self.request.user, ) @@ -218,6 +220,7 @@ class NoteDetailView( *args, **kwargs, ): + Note.objects.select_for_update().get(pk=self.get_object().pk) return super().update( request, *args, diff --git a/backend/planning/__init__.py b/backend/planning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/planning/apps.py b/backend/planning/apps.py new file mode 100644 index 0000000..76c6ad2 --- /dev/null +++ b/backend/planning/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PlanningConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'planning' diff --git a/backend/planning/migrations/0001_initial.py b/backend/planning/migrations/0001_initial.py new file mode 100644 index 0000000..3244590 --- /dev/null +++ b/backend/planning/migrations/0001_initial.py @@ -0,0 +1,124 @@ +# Generated by Django 5.2.17 on 2026-09-30 15:14 + +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='DayChangeRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('leave', 'Congé / absence'), ('hours', 'Modification d’horaires'), ('swap', 'Inversion de journées')], max_length=8)), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('target_date', models.DateField(blank=True, null=True)), + ('reason', models.TextField(blank=True, max_length=2000)), + ('proposed_slots', models.JSONField(default=list)), + ('before', models.JSONField(default=list)), + ('after', models.JSONField(default=list)), + ('status', models.CharField(choices=[('pending', 'En attente'), ('approved', 'Acceptée'), ('rejected', 'Refusée'), ('cancelled', 'Annulée')], default='pending', max_length=10)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('response', models.TextField(blank=True, max_length=2000)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('target_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='received_swaps', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='day_requests', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at', '-pk'], + }, + ), + migrations.CreateModel( + name='PlanningState', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('revision', models.PositiveIntegerField(default=0)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ScheduleException', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(blank=True, max_length=160)), + ('kind', models.CharField(choices=[('work', 'Horaires exceptionnels'), ('rest', 'Repos'), ('absence', 'Congé / absence')], default='work', max_length=8)), + ('start_date', models.DateField()), + ('end_date', models.DateField(blank=True, null=True)), + ('recurring', models.BooleanField(default=False)), + ('weekdays', models.JSONField(default=list)), + ('every_weeks', models.PositiveSmallIntegerField(default=1)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('request', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applied_exceptions', to='planning.daychangerequest')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedule_exceptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-pk'], + }, + ), + migrations.CreateModel( + name='ExceptionSlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('end_day_offset', models.PositiveSmallIntegerField(default=0)), + ('exception', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='planning.scheduleexception')), + ], + options={ + 'ordering': ['start_time'], + }, + ), + migrations.CreateModel( + name='WorkSchedule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('valid_from', models.DateField()), + ('valid_to', models.DateField(blank=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_schedules', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-valid_from', '-pk'], + }, + ), + migrations.CreateModel( + name='WorkScheduleSlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('weekday', models.PositiveSmallIntegerField()), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('end_day_offset', models.PositiveSmallIntegerField(default=0)), + ('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='planning.workschedule')), + ], + options={ + 'ordering': ['weekday', 'start_time'], + }, + ), + migrations.AddIndex( + model_name='daychangerequest', + index=models.Index(fields=['status', 'user'], name='request_status_user_idx'), + ), + migrations.AddIndex( + model_name='scheduleexception', + index=models.Index(fields=['user', 'start_date'], name='exception_user_date_idx'), + ), + migrations.AddConstraint( + model_name='workschedule', + constraint=models.UniqueConstraint(fields=('user', 'valid_from'), name='schedule_user_start_unique'), + ), + ] diff --git a/backend/planning/migrations/0002_alter_workscheduleslot_options_and_more.py b/backend/planning/migrations/0002_alter_workscheduleslot_options_and_more.py new file mode 100644 index 0000000..3badca9 --- /dev/null +++ b/backend/planning/migrations/0002_alter_workscheduleslot_options_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.17 on 2026-09-30 23:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('planning', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='workscheduleslot', + options={'ordering': ['week_index', 'weekday', 'start_time']}, + ), + migrations.AddField( + model_name='workschedule', + name='cycle_weeks', + field=models.PositiveSmallIntegerField(default=1), + ), + migrations.AddField( + model_name='workscheduleslot', + name='week_index', + field=models.PositiveSmallIntegerField(default=0), + ), + ] diff --git a/backend/planning/migrations/0003_alter_daychangerequest_kind.py b/backend/planning/migrations/0003_alter_daychangerequest_kind.py new file mode 100644 index 0000000..2e40eed --- /dev/null +++ b/backend/planning/migrations/0003_alter_daychangerequest_kind.py @@ -0,0 +1,13 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [('planning', '0002_alter_workscheduleslot_options_and_more')] + + operations = [ + migrations.AlterField( + model_name='daychangerequest', + name='kind', + field=models.CharField(choices=[('leave', 'Congé / absence'), ('hours', 'Modification d’horaires'), ('move', 'Déplacement de journée'), ('swap', 'Échange de journée')], max_length=8), + ), + ] diff --git a/backend/planning/migrations/0004_schedule_modes_and_targets.py b/backend/planning/migrations/0004_schedule_modes_and_targets.py new file mode 100644 index 0000000..274fcfb --- /dev/null +++ b/backend/planning/migrations/0004_schedule_modes_and_targets.py @@ -0,0 +1,50 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('planning', '0003_alter_daychangerequest_kind'), + ] + + operations = [ + migrations.AddField( + model_name='workschedule', + name='mode', + field=models.CharField( + choices=[ + ('precise', 'Horaires précis'), + ('daily_hours', 'Nombre d’heures par jour'), + ('weekly_hours', 'Nombre d’heures par semaine'), + ], + default='precise', + max_length=16, + ), + ), + migrations.AddField( + model_name='workschedule', + name='day_targets', + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name='workschedule', + name='week_targets', + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name='scheduleexception', + name='mode', + field=models.CharField( + choices=[ + ('precise', 'Horaires précis'), + ('daily_hours', 'Nombre d’heures à effectuer'), + ], + default='precise', + max_length=16, + ), + ), + migrations.AddField( + model_name='scheduleexception', + name='target_minutes', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/backend/planning/migrations/__init__.py b/backend/planning/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/planning/models.py b/backend/planning/models.py new file mode 100644 index 0000000..609851b --- /dev/null +++ b/backend/planning/models.py @@ -0,0 +1,115 @@ +from django.conf import settings +from django.db import models + + +class PlanningState(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + revision = models.PositiveIntegerField(default=0) + + +class WorkSchedule(models.Model): + class Mode(models.TextChoices): + PRECISE = 'precise', 'Horaires précis' + DAILY_HOURS = 'daily_hours', 'Nombre d’heures par jour' + WEEKLY_HOURS = 'weekly_hours', 'Nombre d’heures par semaine' + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='work_schedules') + valid_from = models.DateField() + cycle_weeks = models.PositiveSmallIntegerField(default=1) + mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.PRECISE) + day_targets = models.JSONField(default=list, blank=True) + week_targets = models.JSONField(default=list, blank=True) + valid_to = models.DateField(null=True, blank=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='+') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-valid_from', '-pk'] + constraints = [models.UniqueConstraint(fields=['user', 'valid_from'], name='schedule_user_start_unique')] + + +class WorkScheduleSlot(models.Model): + schedule = models.ForeignKey(WorkSchedule, on_delete=models.CASCADE, related_name='slots') + weekday = models.PositiveSmallIntegerField() # lundi = 0 + week_index = models.PositiveSmallIntegerField(default=0) + start_time = models.TimeField() + end_time = models.TimeField() + end_day_offset = models.PositiveSmallIntegerField(default=0) + + class Meta: + ordering = ['week_index', 'weekday', 'start_time'] + + +class ScheduleException(models.Model): + class Mode(models.TextChoices): + PRECISE = 'precise', 'Horaires précis' + DAILY_HOURS = 'daily_hours', 'Nombre d’heures à effectuer' + + class Kind(models.TextChoices): + WORK = 'work', 'Horaires exceptionnels' + REST = 'rest', 'Repos' + ABSENCE = 'absence', 'Congé / absence' + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='schedule_exceptions') + label = models.CharField(max_length=160, blank=True) + kind = models.CharField(max_length=8, choices=Kind.choices, default=Kind.WORK) + mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.PRECISE) + target_minutes = models.PositiveSmallIntegerField(null=True, blank=True) + start_date = models.DateField() + end_date = models.DateField(null=True, blank=True) + recurring = models.BooleanField(default=False) + weekdays = models.JSONField(default=list) + every_weeks = models.PositiveSmallIntegerField(default=1) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='+') + request = models.ForeignKey('DayChangeRequest', null=True, blank=True, on_delete=models.SET_NULL, related_name='applied_exceptions') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-pk'] + indexes = [models.Index(fields=['user', 'start_date'], name='exception_user_date_idx')] + + +class ExceptionSlot(models.Model): + exception = models.ForeignKey(ScheduleException, on_delete=models.CASCADE, related_name='slots') + start_time = models.TimeField() + end_time = models.TimeField() + end_day_offset = models.PositiveSmallIntegerField(default=0) + + class Meta: + ordering = ['start_time'] + + +class DayChangeRequest(models.Model): + class Kind(models.TextChoices): + LEAVE = 'leave', 'Congé / absence' + HOURS = 'hours', 'Modification d’horaires' + MOVE = 'move', 'Déplacement de journée' + SWAP = 'swap', 'Échange de journée' + + class Status(models.TextChoices): + PENDING = 'pending', 'En attente' + APPROVED = 'approved', 'Acceptée' + REJECTED = 'rejected', 'Refusée' + CANCELLED = 'cancelled', 'Annulée' + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='day_requests') + kind = models.CharField(max_length=8, choices=Kind.choices) + start_date = models.DateField() + end_date = models.DateField() + target_date = models.DateField(null=True, blank=True) + target_user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='received_swaps') + reason = models.TextField(blank=True, max_length=2000) + proposed_slots = models.JSONField(default=list) + before = models.JSONField(default=list) + after = models.JSONField(default=list) + status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING) + reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='+') + reviewed_at = models.DateTimeField(null=True, blank=True) + response = models.TextField(blank=True, max_length=2000) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at', '-pk'] + indexes = [models.Index(fields=['status', 'user'], name='request_status_user_idx')] diff --git a/backend/planning/serializers.py b/backend/planning/serializers.py new file mode 100644 index 0000000..b5ab4c3 --- /dev/null +++ b/backend/planning/serializers.py @@ -0,0 +1,194 @@ +from datetime import date + +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from notes.serializers import NoteUserSerializer +from users.permissions import require_permission +from .models import WorkSchedule, WorkScheduleSlot, ScheduleException, ExceptionSlot, DayChangeRequest + + +def checked_dates(start, end): + if start < date(2000, 1, 1) or start > date(2099, 1, 1) or (end and (end < start or end > date(2099, 12, 31))): + raise serializers.ValidationError('Choisissez des dates valides, dans l’ordre, entre 2000 et 2099.') + + +class SlotInput(serializers.Serializer): + start_time = serializers.TimeField(input_formats=['%H:%M', '%H:%M:%S']) + end_time = serializers.TimeField(input_formats=['%H:%M', '%H:%M:%S']) + end_day_offset = serializers.IntegerField(min_value=0, max_value=1, default=0) + + def validate(self, attrs): + start, end = attrs['start_time'], attrs['end_time'] + if start.second or end.second or start.microsecond or end.microsecond: + raise serializers.ValidationError('Les horaires doivent être précis à la minute.') + duration = (end.hour * 60 + end.minute) + attrs['end_day_offset'] * 1440 - (start.hour * 60 + start.minute) + if not 0 < duration <= 1440: + raise serializers.ValidationError('Une plage de travail doit durer entre une minute et 24 heures. Vérifiez le jour et l’heure de fin.') + return attrs + + +class WeeklySlotInput(SlotInput): + weekday = serializers.IntegerField(min_value=0, max_value=6) + week_index = serializers.IntegerField(min_value=0, max_value=51, default=0) + + + + +class DayTargetInput(serializers.Serializer): + weekday = serializers.IntegerField(min_value=0, max_value=6) + week_index = serializers.IntegerField(min_value=0, max_value=51, default=0) + minutes = serializers.IntegerField(min_value=0, max_value=1440) + + +class WeekTargetInput(serializers.Serializer): + week_index = serializers.IntegerField(min_value=0, max_value=51) + minutes = serializers.IntegerField(min_value=0, max_value=10080) + + +class ScheduleInput(serializers.Serializer): + user_id = serializers.PrimaryKeyRelatedField(source='user', queryset=get_user_model().objects.filter(is_active=True)) + valid_from = serializers.DateField() + cycle_weeks = serializers.IntegerField(min_value=1, max_value=52, default=1) + mode = serializers.ChoiceField(choices=WorkSchedule.Mode.choices, default=WorkSchedule.Mode.PRECISE) + day_targets = DayTargetInput(many=True, allow_empty=True, max_length=364, default=list) + week_targets = WeekTargetInput(many=True, allow_empty=True, max_length=52, default=list) + expected_revision = serializers.IntegerField(min_value=0) + slots = WeeklySlotInput(many=True, allow_empty=True, max_length=3640) + + def validate(self, attrs): + checked_dates(attrs['valid_from'], None) + counts = {} + for slot in attrs['slots']: + if slot['week_index'] >= attrs['cycle_weeks']: + raise serializers.ValidationError('Une plage appartient à une semaine absente du cycle.') + key = (slot['week_index'], slot['weekday']) + counts[key] = counts.get(key, 0) + 1 + if counts[key] > 10: + raise serializers.ValidationError('Une journée peut contenir au maximum 10 plages de travail.') + day_keys = set() + for target in attrs['day_targets']: + if target['week_index'] >= attrs['cycle_weeks']: + raise serializers.ValidationError({'day_targets': 'Un objectif journalier appartient à une semaine absente du cycle.'}) + key = (target['week_index'], target['weekday']) + if key in day_keys: + raise serializers.ValidationError({'day_targets': 'Un jour ne peut avoir qu’un seul nombre d’heures prévu.'}) + day_keys.add(key) + week_keys = set() + for target in attrs['week_targets']: + if target['week_index'] >= attrs['cycle_weeks']: + raise serializers.ValidationError({'week_targets': 'Un objectif hebdomadaire appartient à une semaine absente du cycle.'}) + if target['week_index'] in week_keys: + raise serializers.ValidationError({'week_targets': 'Une semaine ne peut avoir qu’un seul total d’heures prévu.'}) + week_keys.add(target['week_index']) + return attrs + + +class SlotSerializer(serializers.ModelSerializer): + class Meta: + model = WorkScheduleSlot + fields = ['week_index', 'weekday', 'start_time', 'end_time', 'end_day_offset'] + + +class ScheduleSerializer(serializers.ModelSerializer): + slots = SlotSerializer(many=True, read_only=True) + + class Meta: + model = WorkSchedule + fields = ['id', 'user_id', 'valid_from', 'valid_to', 'cycle_weeks', 'mode', 'day_targets', 'week_targets', 'slots', 'updated_at'] + + +class ExceptionSlotSerializer(serializers.ModelSerializer): + class Meta: + model = ExceptionSlot + fields = ['start_time', 'end_time', 'end_day_offset'] + + +class ExceptionSerializer(serializers.ModelSerializer): + slots = ExceptionSlotSerializer(many=True, read_only=True) + + class Meta: + model = ScheduleException + fields = ['id', 'user_id', 'label', 'kind', 'mode', 'target_minutes', 'start_date', 'end_date', 'recurring', 'weekdays', 'every_weeks', 'slots', 'request_id'] + + +class ExceptionInput(serializers.Serializer): + user_id = serializers.PrimaryKeyRelatedField(source='user', queryset=get_user_model().objects.filter(is_active=True)) + expected_revision = serializers.IntegerField(min_value=0) + label = serializers.CharField(max_length=160, allow_blank=True, default='') + kind = serializers.ChoiceField(choices=ScheduleException.Kind.choices) + start_date = serializers.DateField() + end_date = serializers.DateField(allow_null=True, default=None) + recurring = serializers.BooleanField(default=False) + weekdays = serializers.ListField(child=serializers.IntegerField(min_value=0, max_value=6), max_length=7, default=list) + every_weeks = serializers.IntegerField(min_value=1, max_value=4, default=1) + slots = SlotInput(many=True, allow_empty=True, max_length=10, default=list) + + def validate(self, attrs): + if not attrs['recurring'] and not attrs['end_date']: + attrs['end_date'] = attrs['start_date'] + checked_dates(attrs['start_date'], attrs['end_date']) + if attrs['recurring'] and not attrs['weekdays']: + raise serializers.ValidationError({'weekdays': 'Choisissez au moins un jour de répétition.'}) + attrs['weekdays'] = sorted(set(attrs['weekdays'])) if attrs['recurring'] else [] + if attrs['kind'] == 'work' and not attrs['slots']: + raise serializers.ValidationError({'slots': 'Ajoutez au moins un créneau.'}) + if attrs['kind'] != 'work' and attrs['slots']: + raise serializers.ValidationError({'slots': 'Un repos ou un congé ne comporte aucun créneau de travail.'}) + return attrs + + +class RequestInput(serializers.Serializer): + expected_before = serializers.JSONField(required=False, write_only=True) + kind = serializers.ChoiceField(choices=DayChangeRequest.Kind.choices) + start_date = serializers.DateField() + end_date = serializers.DateField(required=False) + target_date = serializers.DateField(required=False, allow_null=True) + target_user_id = serializers.PrimaryKeyRelatedField(source='target_user', queryset=get_user_model().objects.filter(is_active=True), required=False, allow_null=True) + reason = serializers.CharField(max_length=2000, allow_blank=True, default='') + proposed_slots = SlotInput(many=True, allow_empty=True, max_length=10, default=list) + + def validate(self, attrs): + if attrs['kind'] == 'leave': + require_permission(self.context['request'].user, 'request_leave') + attrs['end_date'] = attrs.get('end_date') or attrs['start_date'] + checked_dates(attrs['start_date'], attrs['end_date']) + if (attrs['end_date'] - attrs['start_date']).days > 365: + raise serializers.ValidationError('Une demande peut couvrir au maximum 366 jours.') + if attrs['kind'] == 'swap': + if attrs['end_date'] != attrs['start_date']: + raise serializers.ValidationError('Un échange concerne une seule journée de votre planning.') + other = attrs.get('target_user') + if not other or other.pk == self.context['request'].user.pk: + raise serializers.ValidationError({'target_user_id': 'Choisissez un autre employé pour l’échange.'}) + if attrs.get('target_date'): + checked_dates(attrs['target_date'], None) + attrs['target_user'] = other + elif attrs['kind'] == 'move': + if attrs['end_date'] != attrs['start_date'] or not attrs.get('target_date'): + raise serializers.ValidationError('Un déplacement concerne une journée de départ et une journée d’arrivée précises.') + checked_dates(attrs['target_date'], None) + if attrs['target_date'] == attrs['start_date']: + raise serializers.ValidationError({'target_date': 'Choisissez un autre jour pour déplacer ces heures.'}) + if attrs.get('target_user'): + raise serializers.ValidationError({'target_user_id': 'Un déplacement reste sur votre propre planning.'}) + elif attrs.get('target_date') or attrs.get('target_user'): + raise serializers.ValidationError('La seconde journée est réservée aux échanges et déplacements.') + if attrs['kind'] in ('hours', 'move') and not attrs['proposed_slots']: + raise serializers.ValidationError({'proposed_slots': 'Ajoutez les horaires souhaités.'}) + if attrs['kind'] not in ('hours', 'move') and attrs['proposed_slots']: + raise serializers.ValidationError('Ces créneaux ne sont pas utilisés pour ce type de demande.') + # Le JSON stocké utilise les mêmes horaires locaux que le planning. + attrs['proposed_slots'] = [dict(start_time=s['start_time'].strftime('%H:%M'), end_time=s['end_time'].strftime('%H:%M'), end_day_offset=s['end_day_offset']) for s in attrs['proposed_slots']] + return attrs + + +class RequestSerializer(serializers.ModelSerializer): + user = NoteUserSerializer(read_only=True) + target_user = NoteUserSerializer(read_only=True) + reviewed_by = NoteUserSerializer(read_only=True) + + class Meta: + model = DayChangeRequest + fields = ['id', 'user', 'kind', 'start_date', 'end_date', 'target_date', 'target_user', 'reason', 'proposed_slots', 'before', 'after', 'status', 'reviewed_by', 'reviewed_at', 'response', 'created_at', 'updated_at'] + read_only_fields = fields diff --git a/backend/planning/services.py b/backend/planning/services.py new file mode 100644 index 0000000..9e947c9 --- /dev/null +++ b/backend/planning/services.py @@ -0,0 +1,356 @@ +"""Calcul unique des horaires prévus, partagé par la gestion et le récapitulatif.""" +from copy import deepcopy +from datetime import date, datetime, time, timedelta +from math import lcm + +from django.contrib.auth import get_user_model +from django.db import transaction +from rest_framework.exceptions import APIException, ValidationError + +from .models import PlanningState, WorkSchedule, WorkScheduleSlot, ScheduleException, ExceptionSlot + + +class PlanningConflict(APIException): + status_code = 409 + default_detail = 'Le planning a changé. Actualisez avant de réessayer.' + + +def lock_users(ids): + # Ordre stable pour un échange entre deux salariés (évite les interblocages). + list(get_user_model().objects.select_for_update().filter(pk__in=sorted(set(ids))).order_by('pk')) + + +def state_for(user_id): + return PlanningState.objects.get_or_create(user_id=user_id)[0] + + +def check_revision(user_id, expected): + state = state_for(user_id) + if state.revision != expected: + raise PlanningConflict() + return state + + +def bump_revision(user_id): + state = state_for(user_id) + state.revision += 1 + state.save(update_fields=['revision']) + + +def slot_data(slot): + return dict(start_time=slot.start_time.strftime('%H:%M'), end_time=slot.end_time.strftime('%H:%M'), end_day_offset=slot.end_day_offset) + + +def slot_minutes(slot): + start_minutes = int(slot['start_time'][:2]) * 60 + int(slot['start_time'][3:5]) + end_minutes = int(slot['end_time'][:2]) * 60 + int(slot['end_time'][3:5]) + int(slot.get('end_day_offset', 0)) * 1440 + return end_minutes - start_minutes + + +def date_range(start, end): + while start <= end: + yield start + start += timedelta(days=1) + + +def load_plan(user_id): + return ( + list(WorkSchedule.objects.filter(user_id=user_id).prefetch_related('slots')), + list(ScheduleException.objects.filter(user_id=user_id).prefetch_related('slots')), + ) + + +def rule_matches(rule, day): + if day < rule.start_date or (rule.end_date and day > rule.end_date): + return False + if not rule.recurring: + return True + anchor = rule.start_date - timedelta(days=rule.start_date.weekday()) + return day.weekday() in rule.weekdays and ((day - anchor).days // 7) % rule.every_weeks == 0 + + +def schedule_week_index(schedule, day): + anchor = schedule.valid_from - timedelta(days=schedule.valid_from.weekday()) + return ((day - anchor).days // 7) % schedule.cycle_weeks + + +def target_for_day(schedule, week_index, weekday): + row = next((item for item in schedule.day_targets if int(item.get('week_index', 0)) == week_index and int(item.get('weekday', -1)) == weekday), None) + return int(row.get('minutes', 0)) if row else 0 + + +def target_for_week(schedule, week_index): + row = next((item for item in schedule.week_targets if int(item.get('week_index', -1)) == week_index), None) + return int(row.get('minutes', 0)) if row else 0 + + +def effective_day(user_id, day, plan=None): + schedules, rules = plan if plan is not None else load_plan(user_id) + matches = [r for r in rules if rule_matches(r, day)] + # Ponctuel > récurrent > semaine type ; à priorité égale, dernière création. + rule = max(matches, key=lambda r: (not r.recurring, r.pk), default=None) + if rule: + source = 'exception' + week_target_minutes = None + if rule.kind == ScheduleException.Kind.WORK and rule.mode == ScheduleException.Mode.DAILY_HOURS: + target_minutes = int(rule.target_minutes or 0) + kind = 'work' if target_minutes > 0 else 'rest' + label = rule.label or ('Heures à effectuer' if target_minutes else 'Repos') + slots = [] + schedule_mode = WorkSchedule.Mode.DAILY_HOURS + else: + kind = rule.kind + label = rule.label or rule.get_kind_display() + slots = [slot_data(s) for s in rule.slots.all()] if kind == ScheduleException.Kind.WORK else [] + target_minutes = sum(slot_minutes(slot) for slot in slots) if slots else 0 + schedule_mode = WorkSchedule.Mode.PRECISE + else: + schedule = next((s for s in schedules if s.valid_from <= day and (not s.valid_to or s.valid_to >= day)), None) + if not schedule: + return dict( + user_id=user_id, + date=day.isoformat(), + kind='undefined', + label='Emploi du temps non défini', + source='none', + schedule_mode='none', + target_minutes=None, + week_target_minutes=None, + slots=[], + ) + + source = 'schedule' + week_index = schedule_week_index(schedule, day) + if schedule.mode == WorkSchedule.Mode.DAILY_HOURS: + target_minutes = target_for_day(schedule, week_index, day.weekday()) + week_target_minutes = sum(target_for_day(schedule, week_index, weekday) for weekday in range(7)) + kind = 'work' if target_minutes > 0 else 'rest' + label = 'Heures à effectuer' if target_minutes else 'Repos' + slots = [] + schedule_mode = WorkSchedule.Mode.DAILY_HOURS + elif schedule.mode == WorkSchedule.Mode.WEEKLY_HOURS: + week_target_minutes = target_for_week(schedule, week_index) + target_minutes = None + kind = 'weekly_target' if week_target_minutes > 0 else 'rest' + label = 'Total d’heures à effectuer cette semaine' if week_target_minutes > 0 else 'Repos' + slots = [] + schedule_mode = WorkSchedule.Mode.WEEKLY_HOURS + else: + slots = [slot_data(s) for s in schedule.slots.all() if s.weekday == day.weekday() and s.week_index == week_index] + kind = 'work' if slots else 'rest' + label = 'Emploi du temps habituel' if slots else 'Repos' + target_minutes = sum(slot_minutes(slot) for slot in slots) + week_target_minutes = None + schedule_mode = WorkSchedule.Mode.PRECISE + + return dict( + user_id=user_id, + date=day.isoformat(), + kind=kind, + label=label, + source=source, + schedule_mode=schedule_mode, + target_minutes=target_minutes, + week_target_minutes=week_target_minutes, + slots=slots, + ) + + +def period(slot, day): + return (datetime.combine(day, time.fromisoformat(slot['start_time'])), + datetime.combine(day + timedelta(days=slot.get('end_day_offset', 0)), time.fromisoformat(slot['end_time']))) + + +def validate_calendar(user_id): + """Vérifie une période complète de répétition entre chaque changement de règle. + + Le PPCM combine le cycle actif et les exceptions récurrentes. On contrôle + aussi la jonction avec le jour précédent et le cycle suivant. + """ + plan = load_plan(user_id) + boundaries = set() + for schedule in plan[0]: + boundaries.add(schedule.valid_from) + if schedule.valid_to: + boundaries.add(schedule.valid_to + timedelta(days=1)) + for rule in plan[1]: + boundaries.add(rule.start_date) + if rule.end_date: + boundaries.add(rule.end_date + timedelta(days=1)) + days = set() + ordered = sorted(boundaries) + horizon = date(2100, 1, 1) + for index, boundary in enumerate(ordered): + following = ordered[index + 1] if index + 1 < len(ordered) else horizon + active = next((s for s in plan[0] if s.valid_from <= boundary and (not s.valid_to or s.valid_to >= boundary)), None) + cycle = active.cycle_weeks if active else 1 + for rule in plan[1]: + if rule.recurring and rule.start_date <= boundary and (not rule.end_date or rule.end_date >= boundary): + cycle = lcm(cycle, rule.every_weeks) + finish = min(following, boundary + timedelta(weeks=cycle)) + days.update(date_range(boundary - timedelta(days=1), finish)) + cache = {} + + def periods(day): + if day not in cache: + cache[day] = [period(s, day) for s in effective_day(user_id, day, plan)['slots']] + return cache[day] + + for day in sorted(days): + intervals = sorted(periods(day) + periods(day + timedelta(days=1))) + for previous, current in zip(intervals, intervals[1:]): + if current[0] < previous[1]: + raise ValidationError({'slots': f'Chevauchement d’horaires le {current[0]:%d/%m/%Y}. Vérifiez aussi la veille et les horaires après minuit.'}) + + +def effective_period(user_id, start, end): + plan = load_plan(user_id) + days = [effective_day(user_id, day, plan) for day in date_range(start, end)] + segments = [] + # Une vacation commencée la veille peut continuer dans la période demandée. + for day in date_range(start - timedelta(days=1), end): + data = effective_day(user_id, day, plan) + for index, slot in enumerate(data['slots']): + begin, finish = period(slot, day) + cursor = begin + while cursor < finish: + boundary = datetime.combine(cursor.date() + timedelta(days=1), time.min) + stop = min(finish, boundary) + if start <= cursor.date() <= end: + segments.append(dict( + id=f'{user_id}-{day}-{index}-{cursor.date()}', date=cursor.date().isoformat(), + start_time=cursor.strftime('%H:%M'), end_time='24:00' if stop == boundary else stop.strftime('%H:%M'), + label=data['label'], source=data['source'], user_id=user_id, + )) + cursor = stop + return dict(user_id=user_id, days=days, segments=segments) + + +@transaction.atomic +def save_schedule(actor, data): + user = data['user'] + lock_users([user.pk]) + check_revision(user.pk, data['expected_revision']) + start = data['valid_from'] + schedule = WorkSchedule.objects.filter(user=user, valid_from=start).first() + if schedule is None: + previous = WorkSchedule.objects.filter(user=user, valid_from__lt=start).order_by('-valid_from').first() + following = WorkSchedule.objects.filter(user=user, valid_from__gt=start).order_by('valid_from').first() + if previous: + previous.valid_to = start - timedelta(days=1) + previous.save(update_fields=['valid_to', 'updated_at']) + schedule = WorkSchedule.objects.create(user=user, valid_from=start, valid_to=following.valid_from - timedelta(days=1) if following else None, created_by=actor) + schedule.slots.all().delete() + schedule.cycle_weeks = data.get('cycle_weeks', 1) + schedule.mode = data.get('mode', WorkSchedule.Mode.PRECISE) + schedule.day_targets = data.get('day_targets', []) + schedule.week_targets = data.get('week_targets', []) + WorkScheduleSlot.objects.bulk_create([WorkScheduleSlot(schedule=schedule, **slot) for slot in data['slots']]) + schedule.save() + validate_calendar(user.pk) + bump_revision(user.pk) + return schedule + + +@transaction.atomic +def save_exception(actor, data, instance=None): + user = instance.user if instance else data['user'] + lock_users([user.pk]) + check_revision(user.pk, data['expected_revision']) + values = {key: data[key] for key in ['label', 'kind', 'start_date', 'end_date', 'recurring', 'weekdays', 'every_weeks']} + # Les changements créés manuellement dans la gestion utilisent des plages précises. + values['mode'] = ScheduleException.Mode.PRECISE + values['target_minutes'] = None + if instance: + instance = ScheduleException.objects.select_for_update().get(pk=instance.pk) + for key, value in values.items(): + setattr(instance, key, value) + instance.save() + else: + instance = ScheduleException.objects.create(user=user, created_by=actor, **values) + instance.slots.all().delete() + ExceptionSlot.objects.bulk_create([ExceptionSlot(exception=instance, **slot) for slot in data['slots']]) + validate_calendar(user.pk) + bump_revision(user.pk) + return instance + + +def snapshot(user_id, day): + result = effective_day(user_id, day) + # L'identité de la règle ne change pas le sens de la demande. + return {key: result[key] for key in ['user_id', 'date', 'kind', 'schedule_mode', 'target_minutes', 'week_target_minutes', 'slots']} + + +def request_changes(user, data): + def ensure_no_overlap(slots): + periods = [] + for slot in slots: + start_minutes = int(slot['start_time'][:2]) * 60 + int(slot['start_time'][3:5]) + end_minutes = start_minutes + slot_minutes(slot) + periods.append((start_minutes, end_minutes)) + periods.sort() + if any(current[0] < previous[1] for previous, current in zip(periods, periods[1:])): + raise ValidationError({'proposed_slots': 'Les horaires ajoutés chevauchent des horaires déjà prévus ce jour-là.'}) + + def worked_minutes(row): + if row.get('schedule_mode') == WorkSchedule.Mode.DAILY_HOURS: + return int(row.get('target_minutes') or 0) + return sum(slot_minutes(slot) for slot in row.get('slots', [])) + + def swappable(row): + return row['kind'] not in ('absence', 'undefined', 'weekly_target') + + before = [snapshot(user.pk, day) for day in date_range(data['start_date'], data['end_date'])] + if data['kind'] == 'swap': + source = before[0] + if not swappable(source): + raise ValidationError('Cette journée ne peut pas être échangée : elle doit être définie dans le planning.') + target_date = data.get('target_date') + if not target_date: + # L’employé choisit uniquement le collègue. Le responsable choisira + # la journée précise de ce collègue au moment de la validation. + return before, [] + other = data['target_user'] + target = snapshot(other.pk, target_date) + before.append(target) + if not all(swappable(row) for row in before): + raise ValidationError('Un échange nécessite deux journées définies, travaillées ou de repos.') + if not any(worked_minutes(row) > 0 for row in before): + raise ValidationError('Au moins une des deux journées doit être travaillée.') + after = deepcopy(before) + transferable = ['kind', 'slots', 'schedule_mode', 'target_minutes'] + for key in transferable: + after[0][key] = deepcopy(before[1].get(key)) + after[1][key] = deepcopy(before[0].get(key)) + after[0]['week_target_minutes'] = None + after[1]['week_target_minutes'] = None + elif data['kind'] == 'move': + source = before[0] + target = snapshot(user.pk, data['target_date']) + before.append(target) + if source['kind'] != 'work' or worked_minutes(source) <= 0: + raise ValidationError('Choisissez une journée travaillée à déplacer.') + if target['kind'] in ('absence', 'undefined', 'weekly_target'): + raise ValidationError('Les heures ne peuvent pas être déplacées vers un jour en congé ou sans journée définie.') + if target.get('schedule_mode') == WorkSchedule.Mode.DAILY_HOURS and worked_minutes(target) > 0: + raise ValidationError('Le jour d’arrivée contient un nombre d’heures sans plage précise. Choisissez un jour de repos ou un jour avec des horaires précis.') + required = worked_minutes(source) + added = sum(slot_minutes(slot) for slot in data['proposed_slots']) + if required != added: + difference = required - added + raise ValidationError({'proposed_slots': f'Il reste {abs(difference)} minute(s) à {"ajouter" if difference > 0 else "retirer"}. Le total déplacé doit rester identique.'}) + combined = [*target['slots'], *data['proposed_slots']] + if len(combined) > 10: + raise ValidationError({'proposed_slots': 'La journée d’arrivée ne peut pas contenir plus de 10 plages de travail.'}) + ensure_no_overlap(combined) + after = deepcopy(before) + after[0].update(kind='rest', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=0, week_target_minutes=None, slots=[]) + after[1].update(kind='work', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=sum(slot_minutes(slot) for slot in combined), week_target_minutes=None, slots=combined) + else: + after = deepcopy(before) + for row in after: + if data['kind'] == 'leave': + row.update(kind='absence', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=0, week_target_minutes=None, slots=[]) + else: + row.update(kind='work', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=sum(slot_minutes(slot) for slot in data['proposed_slots']), week_target_minutes=None, slots=data['proposed_slots']) + return before, after diff --git a/backend/planning/tests.py b/backend/planning/tests.py new file mode 100644 index 0000000..b20eab1 --- /dev/null +++ b/backend/planning/tests.py @@ -0,0 +1,301 @@ +from datetime import date + +from django.contrib.auth import get_user_model +from rest_framework.test import APITestCase + +from time_tracking.models import TimeEntry +from .models import PlanningState, WorkSchedule, ScheduleException, DayChangeRequest +from .services import effective_period + + +class PlanningAPITests(APITestCase): + def setUp(self): + self.admin = get_user_model().objects.create_user(username='admin', is_staff=True) + self.alice = get_user_model().objects.create_user(username='alice') + self.bob = get_user_model().objects.create_user(username='bob') + self.stranger = get_user_model().objects.create_user(username='other') + self.client.force_authenticate(self.admin) + + def slot(self, start='08:00', end='12:00', weekday=None, offset=0): + result = dict(start_time=start, end_time=end, end_day_offset=offset) + if weekday is not None: + result['weekday'] = weekday + return result + + def schedule(self, user, slots=None, start='2030-01-07'): + state = PlanningState.objects.filter(user=user).first() + return self.client.post('/api/planning/schedules/', dict(user_id=user.pk, valid_from=start, + expected_revision=state.revision if state else 0, slots=slots if slots is not None else [self.slot(weekday=0)]), format='json') + + def exception(self, **values): + state = PlanningState.objects.filter(user=self.alice).first() + data = dict(user_id=self.alice.pk, expected_revision=state.revision if state else 0, + label='Exception', kind='work', start_date='2030-01-07', end_date=None, + recurring=True, weekdays=[0], every_weeks=2, slots=[self.slot('10:00', '14:00')]) + data.update(values) + return self.client.post('/api/planning/exceptions/', data, format='json') + + def request(self, **values): + self.client.force_authenticate(self.alice) + data = dict(kind='leave', start_date='2030-01-07', end_date='2030-01-07', reason='Test', proposed_slots=[]) + data.update(values) + return self.client.post('/api/planning/requests/', data, format='json') + + def decide(self, row_id, action='approve', target_date=None): + self.client.force_authenticate(self.admin) + payload = {'action': action, 'response': 'Réponse admin'} + if target_date: + payload['target_date'] = target_date + return self.client.post(f'/api/planning/requests/{row_id}/decision/', payload, format='json') + + def test_permissions_and_minimal_people_directory(self): + self.client.force_authenticate(self.alice) + self.assertEqual(self.client.get('/api/planning/config/').status_code, 403) + self.assertEqual(self.client.get('/api/planning/effective/', {'user_id': self.bob.pk}).status_code, 403) + self.assertEqual(self.schedule(self.alice).status_code, 403) + directory = self.client.get('/api/planning/people/').data + self.assertNotIn('email', directory[0]) + self.assertNotIn('is_staff', directory[0]) + + def test_new_effective_date_preserves_history_and_inserts_between_versions(self): + self.assertEqual(self.schedule(self.alice).status_code, 201) + self.assertEqual(self.schedule(self.alice, [self.slot('10:00', '14:00', 0)], '2030-02-04').status_code, 201) + self.assertEqual(self.schedule(self.alice, [self.slot('09:00', '13:00', 0)], '2030-01-21').status_code, 201) + for day, expected in [('2030-01-14', '08:00'), ('2030-01-28', '09:00'), ('2030-02-11', '10:00')]: + result = effective_period(self.alice.pk, date.fromisoformat(day), date.fromisoformat(day)) + self.assertEqual(result['segments'][0]['start_time'], expected) + self.assertEqual(WorkSchedule.objects.get(user=self.alice, valid_from='2030-01-07').valid_to.isoformat(), '2030-01-20') + + def test_revision_rejects_lost_admin_update(self): + self.schedule(self.alice) + result = self.client.post('/api/planning/schedules/', dict(user_id=self.alice.pk, valid_from='2030-01-07', expected_revision=0, slots=[]), format='json') + self.assertEqual(result.status_code, 409) + self.assertEqual(WorkSchedule.objects.get(user=self.alice).slots.count(), 1) + + def test_recurring_exceptions_and_priority(self): + self.schedule(self.alice) + self.assertEqual(self.exception().status_code, 201) + self.assertEqual(self.exception(recurring=False, start_date='2030-01-21', end_date='2030-01-21', kind='absence', slots=[]).status_code, 201) + for day, expected in [('2030-01-07', '10:00'), ('2030-01-14', '08:00'), ('2030-01-28', '08:00'), ('2030-02-04', '10:00')]: + data = effective_period(self.alice.pk, date.fromisoformat(day), date.fromisoformat(day)) + self.assertEqual(data['segments'][0]['start_time'], expected) + data = effective_period(self.alice.pk, date(2030, 1, 21), date(2030, 1, 21)) + self.assertEqual(data['days'][0]['kind'], 'absence') + self.assertEqual(data['segments'], []) + + def test_overnight_previous_day_and_week_boundary(self): + self.assertEqual(self.schedule(self.alice, [self.slot('22:00', '06:00', 6, 1)]).status_code, 201) + data = effective_period(self.alice.pk, date(2030, 1, 14), date(2030, 1, 14)) + self.assertEqual([(s['start_time'], s['end_time']) for s in data['segments']], [('00:00', '06:00')]) + result = self.schedule(self.alice, [self.slot('22:00', '06:00', 6, 1), self.slot('05:00', '09:00', 0)]) + self.assertEqual(result.status_code, 400) + self.assertEqual(WorkSchedule.objects.get(user=self.alice).slots.count(), 1) + + def test_far_future_recurring_conflict_rolls_back(self): + self.schedule(self.alice, [self.slot('22:00', '06:00', 6, 1)]) + result = self.exception(start_date='2037-01-01', weekdays=[0], every_weeks=3, slots=[self.slot('04:00', '08:00')]) + self.assertEqual(result.status_code, 400) + self.assertFalse(ScheduleException.objects.exists()) + + def test_leave_approval_preserves_real_time_entries_and_prevents_replay(self): + self.schedule(self.alice) + actual = TimeEntry.objects.create(user=self.alice, start_date='2030-01-07', end_date='2030-01-07', start_time='08:03', end_time='12:01') + row = self.request() + self.assertEqual(row.status_code, 201, row.data) + self.assertEqual(self.decide(row.data['id']).status_code, 200) + actual.refresh_from_db() + self.assertEqual(str(actual.start_time), '08:03:00') + self.assertEqual(self.decide(row.data['id']).status_code, 409) + self.assertEqual(ScheduleException.objects.count(), 1) + self.assertEqual(effective_period(self.alice.pk, date(2030, 1, 7), date(2030, 1, 7))['days'][0]['kind'], 'absence') + + def test_stale_request_is_not_applied(self): + self.schedule(self.alice) + row = self.request() + self.client.force_authenticate(self.admin) + self.schedule(self.alice, [self.slot('09:00', '13:00', 0)]) + self.assertEqual(self.decide(row.data['id']).status_code, 409) + self.assertFalse(ScheduleException.objects.exists()) + self.assertEqual(DayChangeRequest.objects.get().status, 'pending') + + def test_move_requires_same_total_and_moves_work_to_another_day(self): + self.schedule(self.alice) + row = self.request(kind='move', target_date='2030-01-08', proposed_slots=[self.slot('13:00', '17:00')]) + self.assertEqual(row.status_code, 201, row.data) + self.assertEqual(self.decide(row.data['id']).status_code, 200) + days = effective_period(self.alice.pk, date(2030, 1, 7), date(2030, 1, 8))['days'] + self.assertEqual([d['kind'] for d in days], ['rest', 'work']) + self.assertEqual(days[1]['slots'][0]['start_time'], '13:00') + + self.client.force_authenticate(self.alice) + wrong_total = self.client.post('/api/planning/requests/', dict( + kind='move', start_date='2030-01-14', end_date='2030-01-14', target_date='2030-01-15', + proposed_slots=[self.slot('13:00', '16:00')], + ), format='json') + self.assertEqual(wrong_total.status_code, 400) + + def test_two_employee_swap_and_request_visibility(self): + self.schedule(self.alice) + self.schedule(self.bob, [self.slot('13:00', '18:00', 0)]) + row = self.request(kind='swap', target_user_id=self.bob.pk) + self.assertEqual(row.status_code, 201, row.data) + self.assertIsNone(row.data['target_date']) + self.assertEqual(row.data['after'], []) + self.client.force_authenticate(self.bob) + self.assertEqual(self.client.get('/api/planning/requests/').data['count'], 1) + self.assertEqual(self.client.post(f"/api/planning/requests/{row.data['id']}/decision/", {'action': 'cancel'}, format='json').status_code, 403) + self.client.force_authenticate(self.stranger) + self.assertEqual(self.client.get('/api/planning/requests/').data['count'], 0) + # Le responsable choisit une autre journée du collègue au moment de valider. + self.assertEqual(self.decide(row.data['id'], target_date='2030-01-14').status_code, 200) + self.assertEqual(effective_period(self.alice.pk, date(2030, 1, 7), date(2030, 1, 7))['segments'][0]['start_time'], '13:00') + self.assertEqual(effective_period(self.bob.pk, date(2030, 1, 14), date(2030, 1, 14))['segments'][0]['start_time'], '08:00') + + def test_swap_target_is_chosen_by_admin_and_move_rejects_overlap(self): + self.schedule(self.alice, [self.slot('08:00', '12:00', 0), self.slot('13:00', '18:00', 1)]) + self.schedule(self.bob, [self.slot('13:00', '18:00', 0)]) + row = self.request(kind='swap', target_user_id=self.bob.pk) + self.assertEqual(row.status_code, 201, row.data) + # Avant le début du planning de Bob : journée non définie, donc impossible à échanger. + self.assertEqual(self.decide(row.data['id'], target_date='2030-01-06').status_code, 400) + self.assertEqual(DayChangeRequest.objects.get(pk=row.data['id']).status, 'pending') + + self.client.force_authenticate(self.alice) + overlap = self.client.post('/api/planning/requests/', dict( + kind='move', start_date='2030-01-08', end_date='2030-01-08', target_date='2030-01-09', + proposed_slots=[self.slot('14:00', '18:00')], + ), format='json') + self.assertEqual(overlap.status_code, 400) + + def test_duplicate_cancel_and_reject_do_not_modify_schedule(self): + self.schedule(self.alice) + row = self.request() + self.assertEqual(self.request().status_code, 409) + self.assertEqual(self.client.post(f"/api/planning/requests/{row.data['id']}/decision/", {'action': 'approve'}, format='json').status_code, 403) + self.assertEqual(self.client.post(f"/api/planning/requests/{row.data['id']}/decision/", {'action': 'cancel'}, format='json').status_code, 200) + row = self.request() + self.assertEqual(self.decide(row.data['id'], 'reject').status_code, 200) + self.assertFalse(ScheduleException.objects.exists()) + + def test_preview_changes_are_rechecked_when_request_is_submitted(self): + self.schedule(self.alice) + self.client.force_authenticate(self.alice) + payload = dict(kind='leave', start_date='2030-01-07', end_date='2030-01-07') + preview = self.client.post('/api/planning/requests/preview/', payload, format='json') + self.assertEqual(preview.status_code, 200) + self.client.force_authenticate(self.admin) + self.schedule(self.alice, [self.slot('10:00', '14:00', 0)]) + self.client.force_authenticate(self.alice) + payload['expected_before'] = preview.data['before'] + response = self.client.post('/api/planning/requests/', payload, format='json') + self.assertEqual(response.status_code, 409) + self.assertFalse(DayChangeRequest.objects.exists()) + + def test_weekly_slots_validate_order_and_dates(self): + response = self.schedule(self.alice, [self.slot('12:00', '08:00', 0)]) + self.assertEqual(response.status_code, 400) + self.assertFalse(WorkSchedule.objects.exists()) + self.assertEqual(self.exception(weekdays=[], recurring=True).status_code, 400) + + def test_daily_and_weekly_hour_targets_are_exposed_by_effective_api(self): + daily = self.client.post('/api/planning/schedules/', dict( + user_id=self.alice.pk, + valid_from='2030-01-07', + cycle_weeks=1, + mode='daily_hours', + day_targets=[ + {'week_index': 0, 'weekday': 0, 'minutes': 450}, + {'week_index': 0, 'weekday': 1, 'minutes': 360}, + ], + week_targets=[], + slots=[], + expected_revision=0, + ), format='json') + self.assertEqual(daily.status_code, 201, daily.data) + result = self.client.get('/api/planning/effective/', dict(user_id=self.alice.pk, start='2030-01-07', end='2030-01-08')).data + self.assertEqual(result['days'][0]['schedule_mode'], 'daily_hours') + self.assertEqual(result['days'][0]['target_minutes'], 450) + self.assertEqual(result['days'][0]['week_target_minutes'], 810) + self.assertEqual(result['days'][0]['slots'], []) + self.assertEqual(result['days'][1]['target_minutes'], 360) + + revision = PlanningState.objects.get(user=self.alice).revision + weekly = self.client.post('/api/planning/schedules/', dict( + user_id=self.alice.pk, + valid_from='2030-01-14', + cycle_weeks=1, + mode='weekly_hours', + day_targets=[], + week_targets=[{'week_index': 0, 'minutes': 2100}], + slots=[], + expected_revision=revision, + ), format='json') + self.assertEqual(weekly.status_code, 201, weekly.data) + result = self.client.get('/api/planning/effective/', dict(user_id=self.alice.pk, start='2030-01-14', end='2030-01-20')).data + self.assertTrue(all(day['schedule_mode'] == 'weekly_hours' for day in result['days'])) + self.assertTrue(all(day['week_target_minutes'] == 2100 for day in result['days'])) + self.assertEqual(result['segments'], []) + + def rotating_schedule(self, weeks, slots, start='2030-01-07'): + state = PlanningState.objects.filter(user=self.alice).first() + return self.client.post('/api/planning/schedules/', dict(user_id=self.alice.pk, valid_from=start, + cycle_weeks=weeks, expected_revision=state.revision if state else 0, slots=slots), format='json') + + def cycle_slot(self, week, weekday=0, start='08:00', end='12:00', offset=0): + return {**self.slot(start, end, weekday, offset), 'week_index': week} + + def test_two_weeks_alternate_in_effective_api(self): + result = self.rotating_schedule(2, [self.cycle_slot(0), self.cycle_slot(1, start='13:00', end='17:00')]) + self.assertEqual(result.status_code, 201, result.data) + self.assertEqual(result.data['cycle_weeks'], 2) + for day, hour in [('2030-01-07', '08:00'), ('2030-01-14', '13:00'), ('2030-01-21', '08:00')]: + response = self.client.get('/api/planning/effective/', dict(user_id=self.alice.pk, start=day, end=day)) + self.assertEqual(response.data['segments'][0]['start_time'], hour) + + def test_three_week_cycle_includes_empty_rest_week(self): + self.assertEqual(self.rotating_schedule(3, [self.cycle_slot(0), self.cycle_slot(2, start='10:00', end='14:00')]).status_code, 201) + days = effective_period(self.alice.pk, date(2030, 1, 7), date(2030, 1, 28))['days'] + self.assertEqual([days[index]['kind'] for index in [0, 7, 14, 21]], ['work', 'rest', 'work', 'work']) + self.assertEqual(days[14]['slots'][0]['start_time'], '10:00') + + def test_midweek_effective_date_anchors_a_to_containing_monday(self): + self.rotating_schedule(2, [self.cycle_slot(0, 2), self.cycle_slot(1, 0, '13:00', '17:00')], start='2030-01-09') + days = effective_period(self.alice.pk, date(2030, 1, 8), date(2030, 1, 14))['days'] + self.assertEqual(days[0]['kind'], 'undefined') + self.assertEqual(days[1]['slots'][0]['start_time'], '08:00') + self.assertEqual(days[-1]['slots'][0]['start_time'], '13:00') + + def test_cycle_change_preserves_previous_period(self): + self.rotating_schedule(2, [self.cycle_slot(0), self.cycle_slot(1, start='13:00', end='17:00')]) + self.rotating_schedule(3, [self.cycle_slot(0, start='09:00', end='12:00')], start='2030-01-21') + self.assertEqual(effective_period(self.alice.pk, date(2030, 1, 14), date(2030, 1, 14))['segments'][0]['start_time'], '13:00') + self.assertEqual(effective_period(self.alice.pk, date(2030, 1, 21), date(2030, 1, 21))['segments'][0]['start_time'], '09:00') + + def test_cycle_wrap_rejects_overnight_collision_and_keeps_previous_version(self): + slots = [self.cycle_slot(2, 6, '22:00', '06:00', 1), self.cycle_slot(0, 0, '07:00', '12:00')] + self.assertEqual(self.rotating_schedule(3, slots).status_code, 201) + data = effective_period(self.alice.pk, date(2030, 1, 28), date(2030, 1, 28)) + self.assertEqual([(s['start_time'], s['end_time']) for s in data['segments']], [('00:00', '06:00'), ('07:00', '12:00')]) + slots[1]['start_time'] = '05:00' + self.assertEqual(self.rotating_schedule(3, slots).status_code, 400) + self.assertEqual(WorkSchedule.objects.get().slots.get(week_index=0).start_time.hour, 7) + + def test_cycle_and_exception_conflict_after_52_weeks_is_detected(self): + self.assertEqual(self.rotating_schedule(13, [self.cycle_slot(12, 6, '22:00', '06:00', 1)]).status_code, 201) + result = self.exception(every_weeks=4, weekdays=[0], slots=[self.slot('04:00', '08:00')]) + self.assertEqual(result.status_code, 400, result.data) + self.assertFalse(ScheduleException.objects.exists()) + + def test_cycle_validation_rejects_nonexistent_weeks(self): + self.assertEqual(self.rotating_schedule(2, [self.cycle_slot(2)]).status_code, 400) + self.assertEqual(self.rotating_schedule(0, []).status_code, 400) + self.assertEqual(self.rotating_schedule(53, []).status_code, 400) + self.assertFalse(WorkSchedule.objects.exists()) + + def test_approved_leave_overrides_only_one_day_in_cycle(self): + self.rotating_schedule(2, [self.cycle_slot(0), self.cycle_slot(1, start='13:00', end='17:00')]) + row = self.request(start_date='2030-01-14', end_date='2030-01-14') + self.assertEqual(self.decide(row.data['id']).status_code, 200) + days = effective_period(self.alice.pk, date(2030, 1, 14), date(2030, 1, 28))['days'] + self.assertEqual(days[0]['kind'], 'absence') + self.assertEqual(days[-1]['slots'][0]['start_time'], '13:00') diff --git a/backend/planning/urls.py b/backend/planning/urls.py new file mode 100644 index 0000000..e7b7a2d --- /dev/null +++ b/backend/planning/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from .views import PeopleView, ConfigurationView, EffectiveView, ScheduleView, ExceptionView, RequestListView, RequestPreviewView, RequestDecisionView, RequestSwapTargetDayView + +urlpatterns = [ + path('people/', PeopleView.as_view()), + path('config/', ConfigurationView.as_view()), + path('effective/', EffectiveView.as_view()), + path('schedules/', ScheduleView.as_view()), + path('exceptions/', ExceptionView.as_view()), + path('exceptions//', ExceptionView.as_view()), + path('requests/', RequestListView.as_view()), + path('requests/preview/', RequestPreviewView.as_view()), + path('requests//swap-target-day/', RequestSwapTargetDayView.as_view()), + path('requests//decision/', RequestDecisionView.as_view()), +] diff --git a/backend/planning/views.py b/backend/planning/views.py new file mode 100644 index 0000000..6b936bd --- /dev/null +++ b/backend/planning/views.py @@ -0,0 +1,340 @@ +from datetime import date, timedelta + +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, serializers, status +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 users.permissions import ( + has_management_permission, is_admin, management_target_ids, + require_management_permission, +) +from notes.serializers import NoteUserSerializer +from .models import PlanningState, WorkSchedule, ScheduleException, ExceptionSlot, DayChangeRequest +from .serializers import ScheduleInput, ScheduleSerializer, ExceptionInput, ExceptionSerializer, RequestInput, RequestSerializer, checked_dates +from .services import (PlanningConflict, lock_users, check_revision, bump_revision, save_schedule, save_exception, + effective_period, request_changes, snapshot, validate_calendar) + + +def admin_only(user): + if not is_admin(user): + raise PermissionDenied('Cette action est réservée aux administrateurs.') + + +def requested_user(request, admin=False, management_permission=None): + if admin: + admin_only(request.user) + raw = request.query_params.get('user_id', request.user.pk) + user_id = serializers.IntegerField(min_value=1).run_validation(raw) + if user_id != request.user.pk and not is_admin(request.user): + if management_permission: + require_management_permission( + request.user, management_permission, user_id, + 'Vous n’avez pas accès à l’emploi du temps de ce salarié.', + ) + else: + raise PermissionDenied('Vous pouvez uniquement consulter votre emploi du temps.') + return get_object_or_404(get_user_model(), pk=user_id) + + +def snapshot_matches(stored, current): + """Compare aussi les demandes créées avant l'ajout des nouveaux champs de planning.""" + return all(current.get(key) == value for key, value in stored.items()) + + +def snapshot_list_matches(stored, current): + return len(stored) == len(current) and all(snapshot_matches(old, new) for old, new in zip(stored, current)) + + +class PeopleView(generics.ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = NoteUserSerializer + queryset = get_user_model().objects.filter(is_active=True).order_by('first_name', 'last_name', 'username') + pagination_class = None + + +class ConfigurationView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = requested_user(request, management_permission='manage_schedules') + state = PlanningState.objects.filter(user=user).first() + return Response(dict(user=NoteUserSerializer(user).data, revision=state.revision if state else 0, + schedules=ScheduleSerializer(WorkSchedule.objects.filter(user=user).prefetch_related('slots'), many=True).data, + exceptions=ExceptionSerializer(ScheduleException.objects.filter(user=user).prefetch_related('slots'), many=True).data)) + + +class EffectiveView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = requested_user(request) + field = serializers.DateField() + start = field.run_validation(request.query_params.get('start', timezone.localdate().isoformat())) + end = field.run_validation(request.query_params.get('end', (start + timedelta(days=6)).isoformat())) + checked_dates(start, end) + if (end - start).days > 365: + raise ValidationError('La période demandée dépasse 366 jours.') + return Response(effective_period(user.pk, start, end)) + + +class ScheduleView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + data = ScheduleInput(data=request.data) + data.is_valid(raise_exception=True) + require_management_permission( + request.user, 'manage_schedules', data.validated_data['user'].pk, + 'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.', + ) + result = save_schedule(request.user, data.validated_data) + return Response(ScheduleSerializer(result).data, status=status.HTTP_201_CREATED) + + +class ExceptionView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + data = ExceptionInput(data=request.data) + data.is_valid(raise_exception=True) + require_management_permission( + request.user, 'manage_schedules', data.validated_data['user'].pk, + 'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.', + ) + result = save_exception(request.user, data.validated_data) + return Response(ExceptionSerializer(result).data, status=status.HTTP_201_CREATED) + + def put(self, request, pk): + instance = get_object_or_404(ScheduleException, pk=pk) + require_management_permission( + request.user, 'manage_schedules', instance.user_id, + 'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.', + ) + data = ExceptionInput(data=request.data) + data.is_valid(raise_exception=True) + if data.validated_data['user'].pk != instance.user_id: + raise ValidationError('Une exception ne peut pas changer de salarié.') + result = save_exception(request.user, data.validated_data, instance) + return Response(ExceptionSerializer(result).data) + + @transaction.atomic + def delete(self, request, pk): + instance = get_object_or_404(ScheduleException, pk=pk) + require_management_permission( + request.user, 'manage_schedules', instance.user_id, + 'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.', + ) + lock_users([instance.user_id]) + revision = serializers.IntegerField(min_value=0).run_validation(request.data.get('expected_revision')) + check_revision(instance.user_id, revision) + user_id = instance.user_id + instance.delete() + validate_calendar(user_id) + bump_revision(user_id) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class RequestPagination(PageNumberPagination): + page_size = 20 + + +class RequestListView(generics.ListCreateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = RequestSerializer + pagination_class = RequestPagination + + def get_queryset(self): + rows = DayChangeRequest.objects.select_related('user', 'target_user', 'reviewed_by') + if not is_admin(self.request.user): + approval_targets = management_target_ids(self.request.user, 'approve_requests') + review_mode = self.request.query_params.get('review') in ('1', 'true', 'yes') + personal = Q(user=self.request.user) | Q(target_user=self.request.user) + if review_mode: + if approval_targets is None: + pass + elif approval_targets: + rows = rows.filter(user_id__in=approval_targets) + else: + rows = rows.none() + elif approval_targets is None: + pass + elif approval_targets: + rows = rows.filter(personal | Q(user_id__in=approval_targets)) + else: + rows = rows.filter(personal) + state = self.request.query_params.get('status') + kind = self.request.query_params.get('kind') + user_id = self.request.query_params.get('user_id') + if state: + state = serializers.ChoiceField(choices=DayChangeRequest.Status.choices).run_validation(state) + rows = rows.filter(status=state) + if kind: + kind = serializers.ChoiceField(choices=DayChangeRequest.Kind.choices).run_validation(kind) + rows = rows.filter(kind=kind) + if user_id: + user_id = serializers.IntegerField(min_value=1).run_validation(user_id) + rows = rows.filter(Q(user_id=user_id) | Q(target_user_id=user_id)) + return rows + + @transaction.atomic + def create(self, request, *args, **kwargs): + data = RequestInput(data=request.data, context={'request': request}) + data.is_valid(raise_exception=True) + values = data.validated_data + ids = [request.user.pk] + if values.get('target_user'): + ids.append(values['target_user'].pk) + lock_users(ids) + expected_before = values.pop('expected_before', None) + before, after = request_changes(request.user, values) + if expected_before is not None and expected_before != before: + raise PlanningConflict('Le planning a changé depuis l’aperçu. Vérifiez à nouveau votre demande.') + duplicate = DayChangeRequest.objects.filter( + user=request.user, + status='pending', + kind=values['kind'], + before=before, + after=after, + target_user=values.get('target_user'), + target_date=values.get('target_date'), + ) + if duplicate.exists(): + raise PlanningConflict('Une demande identique est déjà en attente.') + row = DayChangeRequest.objects.create(user=request.user, before=before, after=after, **values) + return Response(RequestSerializer(row).data, status=status.HTTP_201_CREATED) + + +class RequestPreviewView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + data = RequestInput(data=request.data, context={'request': request}) + data.is_valid(raise_exception=True) + before, after = request_changes(request.user, data.validated_data) + return Response(dict(before=before, after=after)) + + +class RequestSwapTargetDayView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, pk): + row = get_object_or_404( + DayChangeRequest.objects.select_related('target_user'), + pk=pk, + kind=DayChangeRequest.Kind.SWAP, + ) + if not has_management_permission(request.user, 'approve_requests', row.user_id): + raise PermissionDenied('Vous n’avez pas l’autorisation de traiter cette demande.') + if not row.target_user_id: + raise ValidationError('Le collègue de cet échange n’est plus disponible.') + day = serializers.DateField().run_validation(request.query_params.get('date')) + checked_dates(day, None) + return Response(effective_period(row.target_user_id, day, day)) + + +class RequestDecisionView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def post(self, request, pk): + action = serializers.ChoiceField(choices=['approve', 'reject', 'cancel']).run_validation(request.data.get('action')) + response = serializers.CharField(max_length=2000, allow_blank=True).run_validation(request.data.get('response', '')) + row = get_object_or_404(DayChangeRequest, pk=pk) + if action == 'cancel' and row.user_id != request.user.pk: + raise PermissionDenied('Seul l’auteur peut annuler cette demande.') + if action != 'cancel' and not has_management_permission(request.user, 'approve_requests', row.user_id): + raise PermissionDenied('Vous n’avez pas l’autorisation de traiter les demandes de ce salarié.') + + ids = {row.user_id} + if row.target_user_id: + ids.add(row.target_user_id) + ids.update(item['user_id'] for item in row.before) + lock_users(ids) + row = DayChangeRequest.objects.select_for_update().get(pk=pk) + if row.status != 'pending': + raise PlanningConflict('Cette demande a déjà été traitée.') + + if action == 'approve': + before = row.before + after = row.after + + if row.kind == DayChangeRequest.Kind.SWAP: + raw_target_date = request.data.get('target_date') or row.target_date + if not raw_target_date: + raise ValidationError({'target_date': 'Choisissez la journée de l’autre employé à échanger avant d’accepter la demande.'}) + target_date = serializers.DateField().run_validation(raw_target_date) + checked_dates(target_date, None) + if not row.target_user_id: + raise ValidationError('Le collègue concerné par cet échange n’est plus disponible.') + + # La journée demandée par le salarié doit être restée identique depuis l’envoi. + current_source = snapshot(row.user_id, row.start_date) + if not row.before or not snapshot_matches(row.before[0], current_source): + raise PlanningConflict('Le planning du salarié a changé depuis cette demande. Faites annuler puis renouveler la demande.') + + values = dict( + kind=DayChangeRequest.Kind.SWAP, + start_date=row.start_date, + end_date=row.end_date, + target_date=target_date, + target_user=row.target_user, + reason=row.reason, + proposed_slots=[], + ) + before, after = request_changes(row.user, values) + row.target_date = target_date + row.before = before + row.after = after + ids = {item['user_id'] for item in before} + else: + current = [snapshot(item['user_id'], date.fromisoformat(item['date'])) for item in row.before] + if not snapshot_list_matches(row.before, current): + raise PlanningConflict('Le planning a changé depuis cette demande. Faites annuler puis renouveler la demande avec les horaires actuels.') + ids = {item['user_id'] for item in row.before} + + if get_user_model().objects.filter(pk__in=ids, is_active=True).count() != len(ids): + raise ValidationError('Un des salariés concernés n’est plus actif.') + + for item in after: + schedule_mode = item.get('schedule_mode', 'precise') + target_minutes = item.get('target_minutes') + exception_mode = ( + ScheduleException.Mode.DAILY_HOURS + if item['kind'] == 'work' and schedule_mode == 'daily_hours' and not item.get('slots') + else ScheduleException.Mode.PRECISE + ) + exception = ScheduleException.objects.create( + user_id=item['user_id'], + start_date=item['date'], + end_date=item['date'], + kind=item['kind'], + mode=exception_mode, + target_minutes=target_minutes if exception_mode == ScheduleException.Mode.DAILY_HOURS else None, + label=row.get_kind_display(), + request=row, + created_by=request.user, + ) + ExceptionSlot.objects.bulk_create([ExceptionSlot(exception=exception, **slot) for slot in item.get('slots', [])]) + for user_id in sorted(ids): + validate_calendar(user_id) + bump_revision(user_id) + row.status = 'approved' + else: + row.status = 'rejected' if action == 'reject' else 'cancelled' + + row.reviewed_by = request.user + row.reviewed_at = timezone.now() + row.response = response + update_fields = ['status', 'reviewed_by', 'reviewed_at', 'response', 'updated_at'] + if action == 'approve' and row.kind == DayChangeRequest.Kind.SWAP: + update_fields.extend(['target_date', 'before', 'after']) + row.save(update_fields=update_fields) + return Response(RequestSerializer(row).data) diff --git a/backend/time_tracking/test_api.py b/backend/time_tracking/test_api.py index db513f9..b0b0beb 100644 --- a/backend/time_tracking/test_api.py +++ b/backend/time_tracking/test_api.py @@ -119,12 +119,17 @@ class TimeEntryAPITests(APITestCase): self.assertEqual(response.status_code, 409, response.data) self.assertEqual(TimeEntry.objects.count(), 1) conflict = response.data + self.assertEqual(conflict['entry_date'], '2026-09-23') 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'], + 'code': 'enter-test', 'resolution': conflict['resolution'], 'time': '23:00', + 'scan_date': conflict['scan_date'], 'correction_date': conflict['entry_date'], + 'entry_id': conflict['entry_id'], }, format='json') self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(TimeEntry.objects.count(), 2) + self.assertEqual(TimeEntry.objects.count(), 1) + entry = TimeEntry.objects.get() + self.assertEqual(entry.end_date.isoformat(), '2026-09-23') + self.assertEqual(str(entry.end_time), '23:00:00') self.assertFalse(TimeEntry.objects.filter(end_time__isnull=True).exists()) def test_scan_missing_entry_requires_original_scan_and_rejects_replay(self): diff --git a/backend/time_tracking/urls.py b/backend/time_tracking/urls.py index 7ebac98..009319e 100644 --- a/backend/time_tracking/urls.py +++ b/backend/time_tracking/urls.py @@ -7,6 +7,8 @@ from .views import ( TimeEntryUpdateView, TimeEntryGroupView, WeeklySummaryView, + TimeStatsView, + MonthlyRaceView, ) urlpatterns = [ @@ -18,4 +20,6 @@ urlpatterns = [ 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'), + path('stats/', TimeStatsView.as_view(), name='time-stats'), + path('monthly-race/', MonthlyRaceView.as_view(), name='monthly-race'), ] diff --git a/backend/time_tracking/views.py b/backend/time_tracking/views.py index bc4a5e5..01568f6 100644 --- a/backend/time_tracking/views.py +++ b/backend/time_tracking/views.py @@ -1,4 +1,5 @@ -from datetime import timedelta +from calendar import monthrange +from datetime import date, timedelta from decouple import config from django.contrib.auth import get_user_model @@ -17,10 +18,32 @@ from .serializers import ( TimeEntryUpdateSerializer, WeeklySummarySerializer, ) from .services import lock_user, save_period +from users.permissions import is_admin, require_management_permission +from planning.services import effective_day, load_plan User = get_user_model() +def planned_minutes_for_period(user_id, start, end): + """Total planifié sur la période. + + Les objectifs hebdomadaires n'imposent aucun jour précis : pour les + statistiques, leur total est réparti uniformément sur les sept jours afin + qu'un jour, un mois et une année restent comparables et additionnables. + """ + plan = load_plan(user_id) + total = 0.0 + day = start + while day <= end: + planned = effective_day(user_id, day, plan) + if planned.get('schedule_mode') == 'weekly_hours': + total += float(planned.get('week_target_minutes') or 0) / 7 + else: + total += float(planned.get('target_minutes') or 0) + day += timedelta(days=1) + return round(total) + + def visible_entries(user): entries = TimeEntry.objects.select_related('user') return entries if user.is_staff or user.is_superuser else entries.filter(user=user) @@ -138,6 +161,7 @@ class TimeEntryScanView(APIView): 'missing_time': 'end_time' if kind == 'open_entry' else 'start_time', 'scan_time': selected_time.strftime('%H:%M'), 'scan_date': today.isoformat(), + 'entry_date': entry.start_date.isoformat() if entry else None, 'entry_id': entry.pk if entry else None, }, status=status.HTTP_409_CONFLICT) @@ -161,6 +185,14 @@ class TimeEntryScanView(APIView): raise ValidationError({'error': 'Résolution de conflit inconnue.'}) if resolution and request.data.get('scan_date') != today.isoformat(): raise ValidationError({'error': 'Le jour a changé. Scannez à nouveau le code.'}) + correction_date = today + if resolution: + from rest_framework import serializers + correction_date = serializers.DateField().run_validation( + request.data.get('correction_date', today.isoformat()) + ) + if correction_date > today: + raise ValidationError({'correction_date': 'La date corrigée ne peut pas être dans le futur.'}) lock_user(request.user) open_entries = list(TimeEntry.objects.select_for_update().filter( @@ -189,9 +221,11 @@ class TimeEntryScanView(APIView): 'Aucune entrée ouverte. Renseignez l’heure d’entrée.', selected_time, today) if resolution and str(request.data.get('entry_id')) != str(open_entry.pk): raise ValidationError({'error': 'Le pointage a changé. Scannez à nouveau le code.'}) + if correction_date < open_entry.start_date: + raise ValidationError({'correction_date': "La sortie ne peut pas être antérieure à l’entrée."}) entry = save_period(request.user, dict( start_date=open_entry.start_date, start_time=open_entry.start_time, - end_date=today, end_time=selected_time, + end_date=correction_date, end_time=selected_time, ), [open_entry]) action = 'exit' else: @@ -241,3 +275,173 @@ class WeeklySummaryView(APIView): raise PermissionDenied('Vous ne pouvez pas consulter ce résumé.') user = get_object_or_404(User, pk=user_id) return Response(WeeklySummarySerializer(build_weekly_summary(user)).data) + + +class TimeStatsView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + period = request.query_params.get('period', 'month') + if period not in ('day', 'month', 'year'): + raise ValidationError({'period': 'Vue inconnue. Choisissez jour, mois ou année.'}) + + anchor_raw = request.query_params.get('anchor') or timezone.localdate().isoformat() + try: + anchor = date.fromisoformat(anchor_raw[:10]) + except (TypeError, ValueError): + raise ValidationError({'anchor': 'Date invalide.'}) + + raw_user_id = request.query_params.get('user_id') or request.user.pk + try: + user_id = int(raw_user_id) + except (TypeError, ValueError): + raise ValidationError({'user_id': 'Utilisateur invalide.'}) + user = get_object_or_404(User, pk=user_id, is_active=True) + if user.pk != request.user.pk and not is_admin(request.user): + require_management_permission( + request.user, + 'view_statistics', + user.pk, + 'Vous n’avez pas l’autorisation de consulter les statistiques de ce salarié.', + ) + + if period == 'day': + start = end = anchor + elif period == 'month': + start = anchor.replace(day=1) + end = anchor.replace(day=monthrange(anchor.year, anchor.month)[1]) + else: + start = date(anchor.year, 1, 1) + end = date(anchor.year, 12, 31) + + entries = list( + TimeEntry.objects.filter(user=user, start_date__range=(start, end)) + .order_by('start_date', 'start_time', 'pk') + ) + + total_minutes = 0 + sunday_minutes = 0 + sunday_dates = set() + incomplete = 0 + logical_trips = set() + daily = {} + + for entry in entries: + duration = entry.duration + minutes = round(duration * 60) if duration is not None else 0 + day = entry.start_date + row = daily.setdefault(day, {'minutes': 0, 'trip_keys': set(), 'incomplete': 0}) + + if duration is None: + incomplete += 1 + row['incomplete'] += 1 + continue + + total_minutes += minutes + row['minutes'] += minutes + trip_key = f'g:{entry.group_id}' if entry.group_id else f'e:{entry.pk}' + logical_trips.add(trip_key) + row['trip_keys'].add(trip_key) + + if day.weekday() == 6: + sunday_minutes += minutes + sunday_dates.add(day) + + planned_minutes = planned_minutes_for_period(user.pk, start, end) + + breakdown = [ + { + 'date': day.isoformat(), + 'minutes': row['minutes'], + 'round_trips': len(row['trip_keys']), + 'incomplete_entries': row['incomplete'], + 'is_sunday': day.weekday() == 6, + } + for day, row in sorted(daily.items()) + ] + + return Response({ + 'user': { + 'id': user.pk, + 'username': user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'color': user.color, + }, + 'period': period, + 'anchor': anchor.isoformat(), + 'start': start.isoformat(), + 'end': end.isoformat(), + 'total_minutes': total_minutes, + 'planned_minutes': planned_minutes, + 'round_trips': len(logical_trips), + 'sundays_worked': len(sunday_dates), + 'sunday_minutes': sunday_minutes, + 'incomplete_entries': incomplete, + 'breakdown': breakdown, + }) + +class MonthlyRaceView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + current_month_start = timezone.localdate().replace(day=1) + end = current_month_start - timedelta(days=1) + start = end.replace(day=1) + days_count = end.day + + users = list( + User.objects.filter(is_active=True) + .order_by('first_name', 'last_name', 'username', 'pk') + ) + totals = {user.pk: 0 for user in users} + daily_totals = {user.pk: [0] * days_count for user in users} + + entries = TimeEntry.objects.filter( + user_id__in=totals.keys(), + start_date__range=(start, end), + ).only( + 'id', 'user_id', 'start_date', 'start_time', 'end_date', 'end_time', + 'group_id', + ) + + for entry in entries: + duration = entry.duration + if duration is None: + continue + minutes = round(duration * 60) + totals[entry.user_id] = totals.get(entry.user_id, 0) + minutes + day_index = entry.start_date.day - 1 + if 0 <= day_index < days_count: + daily_totals[entry.user_id][day_index] += minutes + + rows = [ + { + 'id': user.pk, + 'username': user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'color': user.color, + 'total_minutes': totals.get(user.pk, 0), + 'daily_minutes': daily_totals.get(user.pk, [0] * days_count), + } + for user in users + ] + rows.sort(key=lambda row: ( + -row['total_minutes'], + (row['first_name'] or '').lower(), + (row['last_name'] or '').lower(), + (row['username'] or '').lower(), + row['id'], + )) + + for index, row in enumerate(rows, start=1): + row['rank'] = index + + return Response({ + 'start': start.isoformat(), + 'end': end.isoformat(), + 'month': start.strftime('%Y-%m'), + 'days_count': days_count, + 'participants': rows, + }) diff --git a/backend/users/migrations/0002_permissiondefaults_userpermissions.py b/backend/users/migrations/0002_permissiondefaults_userpermissions.py new file mode 100644 index 0000000..a6208d6 --- /dev/null +++ b/backend/users/migrations/0002_permissiondefaults_userpermissions.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.17 on 2026-09-30 23:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PermissionDefaults', + fields=[ + ('id', models.PositiveSmallIntegerField(default=1, editable=False, primary_key=True, serialize=False)), + ('request_leave', models.BooleanField(default=True)), + ('create_personal_event', models.BooleanField(default=True)), + ('create_publication', models.BooleanField(default=True)), + ('revision', models.PositiveIntegerField(default=0)), + ], + options={ + 'constraints': [models.CheckConstraint(condition=models.Q(('id', 1)), name='single_permission_defaults')], + }, + ), + migrations.CreateModel( + name='UserPermissions', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('request_leave', models.BooleanField(blank=True, default=None, null=True)), + ('create_personal_event', models.BooleanField(blank=True, default=None, null=True)), + ('create_publication', models.BooleanField(blank=True, default=None, null=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feature_permissions', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/users/migrations/0003_delegatedpermission.py b/backend/users/migrations/0003_delegatedpermission.py new file mode 100644 index 0000000..936f052 --- /dev/null +++ b/backend/users/migrations/0003_delegatedpermission.py @@ -0,0 +1,27 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_permissiondefaults_userpermissions'), + ] + + operations = [ + migrations.CreateModel( + name='DelegatedPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(choices=[('approve_requests', 'Approuver des demandes'), ('manage_schedules', 'Gérer des emplois du temps'), ('view_statistics', 'Consulter des statistiques')], max_length=32)), + ('all_users', models.BooleanField(default=False)), + ('targets', models.ManyToManyField(blank=True, related_name='delegated_permission_targets', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='delegated_permissions', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='delegatedpermission', + constraint=models.UniqueConstraint(fields=('user', 'code'), name='unique_delegated_permission'), + ), + ] diff --git a/backend/users/migrations/0004_alter_delegatedpermission_id.py b/backend/users/migrations/0004_alter_delegatedpermission_id.py new file mode 100644 index 0000000..70d42e5 --- /dev/null +++ b/backend/users/migrations/0004_alter_delegatedpermission_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-10-05 14:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_delegatedpermission'), + ] + + operations = [ + migrations.AlterField( + model_name='delegatedpermission', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index 628270c..64a3988 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -11,6 +11,55 @@ class User(AbstractUser): ) +class PermissionDefaults(models.Model): + id = models.PositiveSmallIntegerField(primary_key=True, default=1, editable=False) + request_leave = models.BooleanField(default=True) + create_personal_event = models.BooleanField(default=True) + create_publication = models.BooleanField(default=True) + revision = models.PositiveIntegerField(default=0) + + class Meta: + constraints = [models.CheckConstraint(condition=models.Q(id=1), name='single_permission_defaults')] + + +class UserPermissions(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='feature_permissions') + # None : appliquer la règle de l'équipe. False : refuser. True : autoriser. + request_leave = models.BooleanField(null=True, blank=True, default=None) + create_personal_event = models.BooleanField(null=True, blank=True, default=None) + create_publication = models.BooleanField(null=True, blank=True, default=None) + + +class DelegatedPermission(models.Model): + """Responsabilité de gestion accordée à un utilisateur sur un périmètre précis.""" + + class Code(models.TextChoices): + APPROVE_REQUESTS = 'approve_requests', 'Approuver des demandes' + MANAGE_SCHEDULES = 'manage_schedules', 'Gérer des emplois du temps' + VIEW_STATISTICS = 'view_statistics', 'Consulter des statistiques' + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='delegated_permissions', + ) + code = models.CharField(max_length=32, choices=Code.choices) + all_users = models.BooleanField(default=False) + targets = models.ManyToManyField( + User, + blank=True, + related_name='delegated_permission_targets', + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['user', 'code'], + name='unique_delegated_permission', + ), + ] + + class Invitation(models.Model): token = models.UUIDField( default=uuid.uuid4, @@ -40,4 +89,4 @@ class Invitation(models.Model): return ( self.used_at is None and self.expires_at > timezone.now() - ) \ No newline at end of file + ) diff --git a/backend/users/permission_views.py b/backend/users/permission_views.py new file mode 100644 index 0000000..5a9cf5f --- /dev/null +++ b/backend/users/permission_views.py @@ -0,0 +1,158 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from rest_framework import serializers +from rest_framework.exceptions import APIException, PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import DelegatedPermission, PermissionDefaults, UserPermissions, User +from .permissions import ( + MANAGEMENT_PERMISSION_NAMES, + PERMISSION_NAMES, + default_permissions, + effective_permissions, + is_admin, + management_permissions, +) + + +class PermissionValues(serializers.Serializer): + request_leave = serializers.BooleanField(allow_null=True) + create_personal_event = serializers.BooleanField(allow_null=True) + create_publication = serializers.BooleanField(allow_null=True) + + +class ResponsibilityValue(serializers.Serializer): + enabled = serializers.BooleanField(default=False) + all_users = serializers.BooleanField(default=False) + user_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + allow_empty=True, + default=list, + ) + + def validate(self, attrs): + if attrs['enabled'] and not attrs['all_users'] and not attrs['user_ids']: + raise serializers.ValidationError('Choisissez au moins un salarié ou activez « Toute l’équipe ».') + return attrs + + +class ResponsibilityValues(serializers.Serializer): + approve_requests = ResponsibilityValue(required=False) + manage_schedules = ResponsibilityValue(required=False) + view_statistics = ResponsibilityValue(required=False) + + +class PermissionInput(serializers.Serializer): + scope = serializers.ChoiceField(choices=['team', 'user']) + user_id = serializers.IntegerField(min_value=1, required=False) + expected_revision = serializers.IntegerField(min_value=0) + permissions = PermissionValues() + responsibilities = ResponsibilityValues(required=False) + reset_individual = serializers.BooleanField(default=False) + + def validate(self, attrs): + if attrs['scope'] == 'team' and any(v is None for v in attrs['permissions'].values()): + raise serializers.ValidationError('Choisissez Autorisé ou Non autorisé pour chaque règle de l’équipe.') + if attrs['scope'] == 'user' and (not attrs.get('user_id') or attrs['reset_individual']): + raise serializers.ValidationError('Choisissez le salarié concerné.') + if attrs['scope'] == 'team' and attrs.get('responsibilities'): + raise serializers.ValidationError('Les responsabilités de gestion sont attribuées individuellement.') + return attrs + + +class PermissionConflict(APIException): + status_code = 409 + default_detail = 'Les autorisations ont changé. Actualisez cette page avant d’enregistrer.' + + +def configuration(): + defaults = default_permissions() + state = PermissionDefaults.objects.filter(pk=1).first() + users = [] + for user in User.objects.select_related('feature_permissions').order_by('first_name', 'last_name', 'username'): + try: + override = user.feature_permissions + except UserPermissions.DoesNotExist: + override = None + users.append(dict( + id=user.pk, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + is_admin=is_admin(user), + is_active=user.is_active, + overrides={key: getattr(override, key, None) for key in PERMISSION_NAMES}, + permissions=effective_permissions(user, defaults), + responsibilities=management_permissions(user), + )) + return dict(defaults=defaults, revision=state.revision if state else 0, users=users) + + +class PermissionConfigurationView(APIView): + permission_classes = [IsAuthenticated] + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if not is_admin(request.user): + raise PermissionDenied('Seuls les administrateurs peuvent gérer les autorisations.') + + @transaction.atomic + def get(self, request): + PermissionDefaults.objects.get_or_create(pk=1) + PermissionDefaults.objects.select_for_update().get(pk=1) + return Response(configuration()) + + @transaction.atomic + def put(self, request): + serializer = PermissionInput(data=request.data) + serializer.is_valid(raise_exception=True) + values = serializer.validated_data + PermissionDefaults.objects.get_or_create(pk=1) + state = PermissionDefaults.objects.select_for_update().get(pk=1) + if state.revision != values['expected_revision']: + raise PermissionConflict() + + if values['scope'] == 'team': + for key, value in values['permissions'].items(): + setattr(state, key, value) + if values['reset_individual']: + UserPermissions.objects.all().update(**dict.fromkeys(PERMISSION_NAMES, None)) + else: + user = get_object_or_404(User, pk=values['user_id']) + if is_admin(user): + raise serializers.ValidationError('Les administrateurs ont déjà tous les accès de gestion.') + + UserPermissions.objects.update_or_create(user=user, defaults=values['permissions']) + + responsibilities = values.get('responsibilities', {}) + for code in MANAGEMENT_PERMISSION_NAMES: + if code not in responsibilities: + continue + rule = responsibilities[code] + if not rule['enabled']: + DelegatedPermission.objects.filter(user=user, code=code).delete() + continue + + target_ids = set(rule.get('user_ids', [])) + if not rule['all_users']: + existing_ids = set(User.objects.filter(pk__in=target_ids, is_active=True).values_list('pk', flat=True)) + missing = target_ids - existing_ids + if missing: + raise serializers.ValidationError({ + 'responsibilities': f'Un salarié sélectionné pour « {code} » est introuvable ou désactivé.' + }) + row, _created = DelegatedPermission.objects.update_or_create( + user=user, + code=code, + defaults={'all_users': rule['all_users']}, + ) + if rule['all_users']: + row.targets.clear() + else: + row.targets.set(sorted(target_ids)) + + state.revision += 1 + state.save() + return Response(configuration()) diff --git a/backend/users/permissions.py b/backend/users/permissions.py new file mode 100644 index 0000000..0626e62 --- /dev/null +++ b/backend/users/permissions.py @@ -0,0 +1,105 @@ +from rest_framework.exceptions import PermissionDenied + +from .models import DelegatedPermission, PermissionDefaults, UserPermissions + + +PERMISSION_NAMES = ('request_leave', 'create_personal_event', 'create_publication') +MANAGEMENT_PERMISSION_NAMES = tuple(value for value, _label in DelegatedPermission.Code.choices) +DENIAL_MESSAGES = { + 'request_leave': 'Les demandes de congés ne sont pas activées pour ton compte. Contacte un responsable.', + 'create_personal_event': 'L’ajout d’événements personnels n’est pas activé pour ton compte. Contacte un responsable.', + 'create_publication': 'La création de publications n’est pas activée pour ton compte. Contacte un responsable.', +} + + +def is_admin(user): + return bool(user.is_authenticated and user.is_active and (user.is_staff or user.is_superuser)) + + +def default_permissions(): + row = PermissionDefaults.objects.filter(pk=1).first() + return {key: getattr(row, key, True) for key in PERMISSION_NAMES} + + +def effective_permissions(user, defaults=None): + if not user.is_authenticated or not user.is_active: + return dict.fromkeys(PERMISSION_NAMES, False) + if is_admin(user): + return dict.fromkeys(PERMISSION_NAMES, True) + values = dict(defaults) if defaults is not None else default_permissions() + # Ne pas réutiliser une relation mise en cache après un changement de droits. + overrides = UserPermissions.objects.filter(user_id=user.pk).values(*PERMISSION_NAMES).first() + if overrides: + for key in PERMISSION_NAMES: + value = overrides[key] + if value is not None: + values[key] = value + return values + + +def require_permission(user, name): + if not effective_permissions(user)[name]: + raise PermissionDenied(DENIAL_MESSAGES[name]) + + +def management_permissions(user): + """Retourne les responsabilités de gestion exposées au frontend.""" + if not getattr(user, 'is_authenticated', False) or not user.is_active: + return { + key: {'enabled': False, 'all_users': False, 'user_ids': []} + for key in MANAGEMENT_PERMISSION_NAMES + } + if is_admin(user): + return { + key: {'enabled': True, 'all_users': True, 'user_ids': []} + for key in MANAGEMENT_PERMISSION_NAMES + } + + rows = { + row.code: row + for row in DelegatedPermission.objects.filter(user_id=user.pk).prefetch_related('targets') + } + result = {} + for key in MANAGEMENT_PERMISSION_NAMES: + row = rows.get(key) + user_ids = sorted(target.pk for target in row.targets.all()) if row and not row.all_users else [] + result[key] = { + 'enabled': bool(row and (row.all_users or user_ids)), + 'all_users': bool(row and row.all_users), + 'user_ids': user_ids, + } + return result + + +def management_target_ids(user, name): + """ + None = accès illimité (admin ou délégation « toute l'équipe »). + set() = aucun accès. + Sinon, ids des utilisateurs autorisés. + """ + if name not in MANAGEMENT_PERMISSION_NAMES: + raise ValueError(f'Permission de gestion inconnue : {name}') + if not getattr(user, 'is_authenticated', False) or not user.is_active: + return set() + if is_admin(user): + return None + row = DelegatedPermission.objects.filter(user_id=user.pk, code=name).first() + if row is None: + return set() + if row.all_users: + return None + return set(row.targets.values_list('pk', flat=True)) + + +def has_management_permission(user, name, target_user_id=None): + targets = management_target_ids(user, name) + if targets is None: + return True + if target_user_id is None: + return bool(targets) + return int(target_user_id) in targets + + +def require_management_permission(user, name, target_user_id=None, message=None): + if not has_management_permission(user, name, target_user_id): + raise PermissionDenied(message or 'Vous n’avez pas l’autorisation d’effectuer cette action.') diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 1aa2bc2..656c1b8 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -6,6 +6,7 @@ from django.utils import timezone from rest_framework import serializers from .models import Invitation +from .permissions import default_permissions, effective_permissions, management_permissions User = get_user_model() @@ -13,6 +14,8 @@ User = get_user_model() class UserSerializer(serializers.ModelSerializer): is_admin = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + management_permissions = serializers.SerializerMethodField() color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False) class Meta: @@ -27,6 +30,8 @@ class UserSerializer(serializers.ModelSerializer): 'is_staff', 'is_superuser', 'is_admin', + 'permissions', + 'management_permissions', 'date_joined', 'color', ] @@ -35,6 +40,14 @@ class UserSerializer(serializers.ModelSerializer): def get_is_admin(self, obj): return obj.is_superuser or obj.is_staff + def get_permissions(self, obj): + if not hasattr(self, '_permission_defaults'): + self._permission_defaults = default_permissions() + return effective_permissions(obj, self._permission_defaults) + + def get_management_permissions(self, obj): + return management_permissions(obj) + def validate(self, attrs): actor = self.context['request'].user if not actor.is_superuser: diff --git a/backend/users/test_permissions.py b/backend/users/test_permissions.py new file mode 100644 index 0000000..a3fac41 --- /dev/null +++ b/backend/users/test_permissions.py @@ -0,0 +1,136 @@ +from django.contrib.auth import get_user_model +from rest_framework.test import APITestCase + +from .models import PermissionDefaults, UserPermissions +from .permissions import PERMISSION_NAMES + + +class PermissionAPITests(APITestCase): + def setUp(self): + self.admin = get_user_model().objects.create_user(username='manager', is_staff=True) + self.alice = get_user_model().objects.create_user(username='alice') + self.bob = get_user_model().objects.create_user(username='bob') + self.event = dict(name='Rendez-vous', start_date='2030-01-07', end_date='2030-01-07', is_public=False) + self.note = dict(title='Information', content='', kind='info', is_public=True, recipient_ids=[]) + self.leave = dict(kind='leave', start_date='2030-01-07', proposed_slots=[]) + + def edit_permissions(self, scope='team', user=None, reset=False, **values): + self.client.force_authenticate(self.admin) + data = self.client.get('/api/users/permissions/').data + settings = dict(data['defaults']) if scope == 'team' else dict.fromkeys(PERMISSION_NAMES, None) + settings.update(values) + return self.client.put('/api/users/permissions/', dict(scope=scope, user_id=(user or self.alice).pk, + expected_revision=data['revision'], reset_individual=reset, permissions=settings), format='json') + + def test_current_and_future_users_inherit_team_rules(self): + self.assertEqual(self.edit_permissions(request_leave=False, create_personal_event=False, create_publication=False).status_code, 200) + newcomer = get_user_model().objects.create_user(username='new') + for person in [self.alice, newcomer]: + self.client.force_authenticate(person) + self.assertEqual(self.client.get('/api/users/me/').data['permissions'], dict.fromkeys(PERMISSION_NAMES, False)) + + def test_individual_allow_deny_and_return_to_team(self): + self.edit_permissions(request_leave=False) + self.edit_permissions(scope='user', request_leave=True, create_publication=False) + self.client.force_authenticate(self.alice) + self.assertEqual(self.client.get('/api/users/me/').data['permissions'], dict(request_leave=True, create_personal_event=True, create_publication=False)) + self.edit_permissions(scope='user') + self.client.force_authenticate(self.alice) + self.assertFalse(self.client.get('/api/users/me/').data['permissions']['request_leave']) + self.assertTrue(self.client.get('/api/users/me/').data['permissions']['create_publication']) + + def test_team_change_preserves_individual_overrides_unless_reset(self): + self.edit_permissions(scope='user', create_publication=True) + self.edit_permissions(create_publication=False) + self.client.force_authenticate(self.alice) + self.assertTrue(self.client.get('/api/users/me/').data['permissions']['create_publication']) + self.edit_permissions(reset=True, create_publication=False) + self.client.force_authenticate(self.alice) + self.assertFalse(self.client.get('/api/users/me/').data['permissions']['create_publication']) + self.assertIsNone(UserPermissions.objects.get(user=self.alice).create_publication) + + def test_employee_cannot_manage_permissions(self): + self.client.force_authenticate(self.alice) + self.assertEqual(self.client.get('/api/users/permissions/').status_code, 403) + self.assertEqual(self.client.put('/api/users/permissions/', {}, format='json').status_code, 403) + self.assertEqual(self.client.patch('/api/users/me/', {'permissions': {'create_publication': True}}, format='json').status_code, 405) + + def test_admin_always_allowed_and_permissions_not_mass_assignable(self): + self.edit_permissions(request_leave=False, create_personal_event=False, create_publication=False) + self.client.force_authenticate(self.admin) + self.assertEqual(self.client.get('/api/users/me/').data['permissions'], dict.fromkeys(PERMISSION_NAMES, True)) + self.assertEqual(self.client.post('/api/notes/', self.note, format='json').status_code, 201) + result = self.client.patch(f'/api/users/{self.alice.pk}/', {'permissions': dict.fromkeys(PERMISSION_NAMES, True)}, format='json') + self.assertEqual(result.status_code, 200) + self.assertFalse(result.data['permissions']['create_publication']) + + def test_stale_permission_update_returns_conflict(self): + result = self.edit_permissions(request_leave=False) + old_revision = result.data['revision'] + self.edit_permissions(request_leave=True) + result = self.client.put('/api/users/permissions/', dict(scope='team', expected_revision=old_revision, permissions=dict.fromkeys(PERMISSION_NAMES, False)), format='json') + self.assertEqual(result.status_code, 409) + self.assertTrue(PermissionDefaults.objects.get().request_leave) + + def test_team_cannot_inherit_and_invalid_requests_do_not_save(self): + result = self.edit_permissions(request_leave=None) + self.assertEqual(result.status_code, 400) + self.assertTrue(PermissionDefaults.objects.get().request_leave) + + def test_leave_creation_and_preview_blocked_but_hours_allowed(self): + self.edit_permissions(request_leave=False) + self.client.force_authenticate(self.alice) + for path in ['/api/planning/requests/', '/api/planning/requests/preview/']: + self.assertEqual(self.client.post(path, self.leave, format='json').status_code, 403) + request = dict(kind='hours', start_date='2030-01-07', proposed_slots=[dict(start_time='08:00', end_time='12:00', end_day_offset=0)]) + self.assertEqual(self.client.post('/api/planning/requests/', request, format='json').status_code, 201) + + def test_pending_leave_can_be_read_and_cancelled_after_revocation(self): + self.client.force_authenticate(self.alice) + request = self.client.post('/api/planning/requests/', self.leave, format='json') + self.assertEqual(request.status_code, 201) + self.edit_permissions(request_leave=False) + self.client.force_authenticate(self.alice) + self.assertEqual(self.client.get('/api/planning/requests/').data['count'], 1) + self.assertEqual(self.client.post(f"/api/planning/requests/{request.data['id']}/decision/", {'action': 'cancel'}, format='json').status_code, 200) + + def test_both_event_creation_routes_enforce_permission(self): + self.edit_permissions(create_personal_event=False) + self.client.force_authenticate(self.alice) + for path in ['/api/event/', '/api/event/create/']: + self.assertEqual(self.client.post(path, self.event, format='json').status_code, 403) + self.edit_permissions(scope='user', create_personal_event=True) + self.client.force_authenticate(self.alice) + self.assertEqual(self.client.post('/api/event/', self.event, format='json').status_code, 201) + + def test_employee_events_stay_private_and_admin_can_publish(self): + self.client.force_authenticate(self.alice) + result = self.client.post('/api/event/', {**self.event, 'is_public': True}, format='json') + self.assertEqual(result.status_code, 201) + self.assertFalse(result.data['is_public']) + self.client.force_authenticate(self.admin) + result = self.client.post('/api/event/', {**self.event, 'is_public': True}, format='json') + self.assertTrue(result.data['is_public']) + + def test_existing_personal_event_can_be_edited_after_creation_revoked(self): + self.client.force_authenticate(self.alice) + event = self.client.post('/api/event/', self.event, format='json').data + self.edit_permissions(create_personal_event=False) + self.client.force_authenticate(self.alice) + self.assertEqual(self.client.patch(f"/api/event/{event['id']}/", {'name': 'Rendez-vous modifié'}, format='json').status_code, 200) + + def test_publications_blocked_but_reading_and_voting_still_allowed(self): + self.client.force_authenticate(self.admin) + poll = self.client.post('/api/notes/', dict(title='Question', kind='poll', is_public=True, recipient_ids=[], poll_settings=dict(vote_mode='single', options=['Oui', 'Non'])), format='json') + self.assertEqual(poll.status_code, 201, poll.data) + self.edit_permissions(create_publication=False) + self.client.force_authenticate(self.alice) + self.assertEqual(self.client.post('/api/notes/', self.note, format='json').status_code, 403) + self.assertEqual(self.client.get('/api/notes/').status_code, 200) + result = self.client.put(f"/api/notes/{poll.data['id']}/vote/", {'option_ids': [poll.data['poll']['options'][0]['id']]}, format='json') + self.assertEqual(result.status_code, 200, result.data) + + def test_default_permissions_preserve_existing_behaviour(self): + self.client.force_authenticate(self.alice) + self.assertEqual(self.client.get('/api/users/me/').data['permissions'], dict.fromkeys(PERMISSION_NAMES, True)) + self.assertEqual(self.client.post('/api/notes/', self.note, format='json').status_code, 201) diff --git a/backend/users/urls.py b/backend/users/urls.py index a0917ff..d7bdb34 100644 --- a/backend/users/urls.py +++ b/backend/users/urls.py @@ -1,7 +1,9 @@ from django.urls import path +from .permission_views import PermissionConfigurationView from .views import InvitationCreateView, RegisterView, UserListView, UserMeView, UserDetailView, UserPromoteView, UserDeleteView, ChangePasswordView urlpatterns = [ + path('permissions/', PermissionConfigurationView.as_view(), name='permission-configuration'), path('register/', RegisterView.as_view(), name='register'), path('me/', UserMeView.as_view(), name='me'), path('', UserListView.as_view(), name='user-list'), diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c3f0c12..f16d980 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,6 +25,8 @@ import WeeklySummaryPage from './pages/WeeklySummaryPage'; import CalendarPage from './pages/CalendarPage'; import NotesPage from './pages/NotesPage'; import ManageUsersPage from './pages/ManageUsersPage'; +import UserStatisticsPage from './pages/UserStatisticsPage'; +import ManagementRoute from './components/ManagementRoute'; import { useAuth } from './context/useAuth'; // Composant personnalisé pour le trigger mobile avec icône menu @@ -121,12 +123,21 @@ function App() { } /> + + + + } + /> + + - + } /> diff --git a/frontend/src/api/event.js b/frontend/src/api/event.js index 7da906e..9838bcd 100644 --- a/frontend/src/api/event.js +++ b/frontend/src/api/event.js @@ -6,4 +6,9 @@ export const eventApi = { 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, + + getProfiles: async () => (await api.get('/event/profiles/')).data, + createProfile: async (data) => (await api.post('/event/profiles/', data)).data, + updateProfile: async (id, data) => (await api.patch(`/event/profiles/${id}/`, data)).data, + deleteProfile: async (id) => (await api.delete(`/event/profiles/${id}/`)).data, }; diff --git a/frontend/src/api/notes.js b/frontend/src/api/notes.js index 57fe9d7..e16ae44 100644 --- a/frontend/src/api/notes.js +++ b/frontend/src/api/notes.js @@ -1,6 +1,8 @@ import api from './axios.js'; export const notesApi = { + vote: async (id, optionIds) => (await api.put(`/notes/${id}/vote/`, { option_ids: optionIds })).data, + closePoll: async (id) => (await api.post(`/notes/${id}/close/`)).data, 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, diff --git a/frontend/src/api/planning.js b/frontend/src/api/planning.js new file mode 100644 index 0000000..881c4b8 --- /dev/null +++ b/frontend/src/api/planning.js @@ -0,0 +1,15 @@ +import api from './axios'; + +export const planningApi = { + people: async (signal) => (await api.get('/planning/people/', { signal })).data, + config: async (userId, signal) => (await api.get('/planning/config/', { params: { user_id: userId }, signal })).data, + effective: async (userId, start, end, signal) => (await api.get('/planning/effective/', { params: { user_id: userId, start, end }, signal })).data, + saveSchedule: async (data) => (await api.post('/planning/schedules/', data)).data, + saveException: async (data, id) => (await (id ? api.put(`/planning/exceptions/${id}/`, data) : api.post('/planning/exceptions/', data))).data, + removeException: async (id, revision) => api.delete(`/planning/exceptions/${id}/`, { data: { expected_revision: revision } }), + requests: async (params, signal) => (await api.get('/planning/requests/', { params, signal })).data, + preview: async (data) => (await api.post('/planning/requests/preview/', data)).data, + createRequest: async (data) => (await api.post('/planning/requests/', data)).data, + swapTargetDay: async (id, date, signal) => (await api.get(`/planning/requests/${id}/swap-target-day/`, { params: { date }, signal })).data, + decide: async (id, action, response = '', extra = {}) => (await api.post(`/planning/requests/${id}/decision/`, { action, response, ...extra })).data, +}; diff --git a/frontend/src/api/time.js b/frontend/src/api/time.js index 278174e..8439998 100644 --- a/frontend/src/api/time.js +++ b/frontend/src/api/time.js @@ -12,6 +12,8 @@ export const timeApi = { 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, + getStats: async (params, signal) => (await api.get('/time/stats/', { params, signal })).data, + getMonthlyRace: async (signal) => (await api.get('/time/monthly-race/', { signal })).data, scanTimeEntry: async (code, options = {}) => { try { return (await api.post('/time/entries/scan/', { code, ...options })).data; @@ -22,6 +24,7 @@ export const timeApi = { error.missingTime = data.missing_time; error.scanTime = data.scan_time; error.scanDate = data.scan_date; + error.entryDate = data.entry_date; error.entryId = data.entry_id; error.code = error.response?.status; throw error; diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js index 0de77ac..56591bf 100644 --- a/frontend/src/api/users.js +++ b/frontend/src/api/users.js @@ -1,6 +1,12 @@ import api from './axios'; export const userApi = { + getPermissions: async (signal) => (await api.get('/users/permissions/', { signal })).data, + savePermissions: async (data) => { + const result = (await api.put('/users/permissions/', data)).data; + window.dispatchEvent(new Event('auth:permissions-changed')); + return result; + }, 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, diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx index 14a5f91..b269d63 100644 --- a/frontend/src/components/ConfirmDialog.jsx +++ b/frontend/src/components/ConfirmDialog.jsx @@ -5,7 +5,6 @@ import { AlertDialogCancel, AlertDialogContent, AlertDialogDescription, - AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, @@ -58,7 +57,7 @@ const ConfirmDialog = ({ - +
{cancelText} @@ -66,7 +65,7 @@ const ConfirmDialog = ({ {confirmText} - +
); diff --git a/frontend/src/components/ManagementRoute.jsx b/frontend/src/components/ManagementRoute.jsx new file mode 100644 index 0000000..62b1dfd --- /dev/null +++ b/frontend/src/components/ManagementRoute.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAuth } from '@/context/useAuth'; + +const hasPermission = (user, permission) => { + if (!user) return false; + if (user.is_admin) return true; + const permissions = Array.isArray(permission) ? permission : [permission]; + return permissions.some((key) => user.management_permissions?.[key]?.enabled); +}; + +export default function ManagementRoute({ permission, children }) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) return ; + if (!user) return ; + if (!hasPermission(user, permission)) return ; + return children; +} diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx index d9d0475..79cf696 100644 --- a/frontend/src/components/NavBar.jsx +++ b/frontend/src/components/NavBar.jsx @@ -5,7 +5,6 @@ import { useAuth } from '../context/useAuth'; import { Sidebar, SidebarContent, - SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, @@ -126,23 +125,26 @@ const NavBar = () => { /* * Administration */ + const canViewStatistics = user.is_admin || user.management_permissions?.view_statistics?.enabled; + const canManageTeam = user.is_admin || user.management_permissions?.manage_schedules?.enabled || user.management_permissions?.approve_requests?.enabled; + const userNavigation = [ - { + ...(user.is_admin ? [{ title: 'Liste', href: '/users', icon: List, end: true, - }, - { + }] : []), + ...(canViewStatistics ? [{ title: 'Statistiques', href: '/users/statistics', icon: BarChart3, - }, - { + }] : []), + ...(canManageTeam ? [{ title: 'Gestion', href: '/users/manage', icon: UserCog, - }, + }] : []), ]; const menuItemClass = @@ -306,7 +308,7 @@ const NavBar = () => { {/* Administration */} - {user.is_admin && ( + {userNavigation.length > 0 && ( Administration @@ -393,7 +395,7 @@ const NavBar = () => { {/* Déconnexion */} - +
{ /> - +
); }; diff --git a/frontend/src/components/PermissionManager.jsx b/frontend/src/components/PermissionManager.jsx new file mode 100644 index 0000000..ed87626 --- /dev/null +++ b/frontend/src/components/PermissionManager.jsx @@ -0,0 +1,253 @@ +import { useEffect, useState } from 'react'; +import { RefreshCw, Save, ShieldCheck, UserRoundCog } from 'lucide-react'; +import { userApi } from '@/api/users'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { inputClass, personLabel } from '@/components/planning/utils'; + +const RULES = [ + { key: 'request_leave', title: 'Demander un congé', description: 'Envoyer une demande de congé ou d’absence depuis Mon espace. Les changements d’horaires et échanges de journées restent disponibles.' }, + { key: 'create_personal_event', title: 'Ajouter un événement personnel', description: 'Ajouter un rendez-vous ou un rappel dans son calendrier. Seul le salarié voit ses événements personnels.' }, + { key: 'create_publication', title: 'Créer une publication', description: 'Publier une information, une tâche, un mémo, une suggestion ou un sondage.' }, +]; + +const RESPONSIBILITIES = [ + { key: 'approve_requests', title: 'Approuver les demandes', description: 'Accepter ou refuser les congés, échanges et déplacements envoyés par les salariés sélectionnés.' }, + { key: 'manage_schedules', title: 'Gérer les emplois du temps', description: 'Créer et modifier le planning habituel et les exceptions des salariés sélectionnés.' }, + { key: 'view_statistics', title: 'Consulter les statistiques', description: 'Voir les heures, aller-retours et dimanches travaillés des salariés sélectionnés.' }, +]; + +const emptyResponsibility = () => ({ enabled: false, all_users: false, user_ids: [] }); + +export default function PermissionManager() { + const [config, setConfig] = useState(null); + const [scope, setScope] = useState('team'); + const [values, setValues] = useState({}); + const [responsibilities, setResponsibilities] = useState({}); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [reload, setReload] = useState(0); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [resetOpen, setResetOpen] = useState(false); + + const applyScope = (data, value) => { + if (value === 'team') { + setValues(data.defaults); + setResponsibilities({}); + return; + } + const selected = data.users.find((row) => String(row.id) === String(value)); + setValues(selected?.overrides || {}); + setResponsibilities(selected?.responsibilities || {}); + }; + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(''); + userApi.getPermissions(controller.signal) + .then((data) => { + setConfig(data); + setScope('team'); + applyScope(data, 'team'); + }) + .catch((e) => { if (!controller.signal.aborted) setError(e.message); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [reload]); + + const person = config?.users.find((row) => String(row.id) === String(scope)); + const targetPeople = (config?.users || []).filter((row) => row.is_active && String(row.id) !== String(scope)); + + const select = (value) => { + setScope(value); + setError(''); + setNotice(''); + applyScope(config, value); + }; + + const updateResponsibility = (key, patch) => { + setResponsibilities((old) => ({ + ...old, + [key]: { ...(old[key] || emptyResponsibility()), ...patch }, + })); + setNotice(''); + }; + + const toggleTarget = (key, userId, checked) => { + const current = responsibilities[key] || emptyResponsibility(); + const ids = new Set((current.user_ids || []).map(Number)); + if (checked) ids.add(Number(userId)); + else ids.delete(Number(userId)); + updateResponsibility(key, { user_ids: [...ids].sort((a, b) => a - b) }); + }; + + const save = async (reset = false) => { + if (busy) return; + setBusy(true); + setError(''); + setNotice(''); + try { + const payload = { + scope: scope === 'team' ? 'team' : 'user', + ...(scope !== 'team' ? { user_id: Number(scope), responsibilities } : {}), + permissions: values, + expected_revision: config.revision, + reset_individual: reset, + }; + const updated = await userApi.savePermissions(payload); + setConfig(updated); + applyScope(updated, scope); + setResetOpen(false); + setNotice( + reset + ? 'Les mêmes autorisations s’appliquent maintenant à tous les salariés, actuels et futurs.' + : scope === 'team' + ? 'Les autorisations de l’équipe sont enregistrées.' + : `Les autorisations de ${personLabel(person)} sont enregistrées.`, + ); + } catch (e) { + setError(e.message); + setResetOpen(false); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

Autorisations

+

Choisis ce que chaque salarié peut faire, puis éventuellement les personnes dont il peut gérer les demandes, plannings ou statistiques.

+
+ +
+ + {error &&

{error}

} + {notice &&

{notice}

} + + {loading ?

Chargement des autorisations…

: config && ( + <> + + +

+ {scope === 'team' + ? 'Ces règles générales s’appliquent aux salariés actuels et aux nouveaux comptes. Les responsabilités de gestion se règlent ensuite salarié par salarié.' + : person?.is_admin + ? 'Ce compte est administrateur : il possède déjà toutes les autorisations et peut gérer toute l’équipe.' + : 'Les autorisations ci-dessous concernent ce salarié. Les responsabilités permettent de lui confier une partie de la gestion sans le rendre administrateur.'} +

+ +
+ {RULES.map(({ key, title, description }) => { + const allowed = person?.is_admin || (values[key] ?? config.defaults[key]); + return ( +
+
+ +

{description}

+
+
+ {scope === 'team' ? ( + <> + {values[key] ? 'Autorisé' : 'Non autorisé'} + { setValues((old) => ({ ...old, [key]: checked })); setNotice(''); }} /> + + ) : person?.is_admin ? Autorisé : ( +
+ +

{allowed ? 'Autorisé' : 'Non autorisé'}{values[key] === null ? ' par la règle de l’équipe' : ' pour ce salarié'}

+
+ )} +
+
+ ); + })} +
+ + {scope !== 'team' && !person?.is_admin && ( +
+
+

Responsabilités de gestion

+

Active un droit puis choisis exactement quels salariés sont concernés. « Toute l’équipe » inclura aussi les futurs salariés.

+
+ + {RESPONSIBILITIES.map(({ key, title, description }) => { + const rule = responsibilities[key] || emptyResponsibility(); + return ( +
+
+
+ +

{description}

+
+ updateResponsibility(key, { enabled: checked, ...(checked ? {} : { all_users: false, user_ids: [] }) })} /> +
+ + {rule.enabled && ( +
+ + + {!rule.all_users && ( +
+ {targetPeople.length ? targetPeople.map((target) => ( + + )) :

Aucun autre salarié actif.

} +
+ )} +
+ )} +
+ ); + })} +
+ )} + + {!person?.is_admin && ( +
+ {scope === 'team' && } + +
+ )} + + )} + + { if (!busy) setResetOpen(open); }}> + + + Appliquer ces règles à tout le monde ? + Les réglages individuels des trois autorisations générales seront remplacés par les règles affichées. Les responsabilités de gestion restent inchangées. + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ResponsivePickers.jsx b/frontend/src/components/ResponsivePickers.jsx index 5206151..f258706 100644 --- a/frontend/src/components/ResponsivePickers.jsx +++ b/frontend/src/components/ResponsivePickers.jsx @@ -3,7 +3,7 @@ import { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { CalendarDays, Clock } from 'lucide-react'; +import { CalendarDays, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; import { fr } from 'date-fns/locale'; const parseDateValue = (value) => { @@ -91,7 +91,10 @@ const DesktopDatePicker = ({ value, onChange, min, + max, placeholder = 'Choisir une date', + disabled = false, + className = '', }) => { const [open, setOpen] = useState(false); @@ -102,6 +105,9 @@ const DesktopDatePicker = ({ const minDate = parseDateValue(min); + const maxDate = + parseDateValue(max); + return ( } > @@ -146,10 +153,11 @@ const DesktopDatePicker = ({ new Date() } disabled={ - minDate - ? { - before: minDate, - } + minDate || maxDate + ? (date) => ( + (minDate && date < minDate) || + (maxDate && date > maxDate) + ) : undefined } onSelect={(date) => { @@ -168,13 +176,246 @@ const DesktopDatePicker = ({ }; +const MONTH_NAMES = [ + 'Janvier', + 'Février', + 'Mars', + 'Avril', + 'Mai', + 'Juin', + 'Juillet', + 'Août', + 'Septembre', + 'Octobre', + 'Novembre', + 'Décembre', +]; + +const parseMonthValue = (value) => { + if (!value) return null; + const [year, month] = String(value).split('-').map(Number); + if (!year || !month || month < 1 || month > 12) return null; + return { year, month: month - 1 }; +}; + +const formatMonthValue = (year, month) => `${year}-${String(month + 1).padStart(2, '0')}`; +const monthIndex = (year, month) => year * 12 + month; + +const yearFromBound = (value, fallback) => { + const year = Number(String(value || '').slice(0, 4)); + return Number.isFinite(year) && year > 0 ? year : fallback; +}; + +const DesktopMonthPicker = ({ + value, + onChange, + min, + max, + placeholder = 'Choisir un mois', + disabled = false, + className = '', +}) => { + const selected = parseMonthValue(value); + const current = new Date(); + const [open, setOpen] = useState(false); + const [displayYear, setDisplayYear] = useState(selected?.year || current.getFullYear()); + const minMonth = parseMonthValue(min); + const maxMonth = parseMonthValue(max); + const minIndex = minMonth ? monthIndex(minMonth.year, minMonth.month) : null; + const maxIndex = maxMonth ? monthIndex(maxMonth.year, maxMonth.month) : null; + + const isAllowed = (year, month) => { + const index = monthIndex(year, month); + return (minIndex === null || index >= minIndex) && (maxIndex === null || index <= maxIndex); + }; + + const canShowYear = (year) => Array.from({ length: 12 }, (_, month) => isAllowed(year, month)).some(Boolean); + + return ( + { + setOpen(nextOpen); + if (nextOpen) setDisplayYear(selected?.year || current.getFullYear()); + }} + > + + } + > + + + {selected + ? new Date(selected.year, selected.month, 1).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }) + : placeholder} + + + + +
+ + {displayYear} + +
+ +
+ {MONTH_NAMES.map((name, month) => { + const active = selected?.year === displayYear && selected?.month === month; + return ( + + ); + })} +
+
+
+ ); +}; + +const DesktopYearPicker = ({ + value, + onChange, + min, + max, + placeholder = 'Choisir une année', + disabled = false, + className = '', +}) => { + const currentYear = new Date().getFullYear(); + const selectedYear = Number(value) || null; + const minYear = yearFromBound(min, 1900); + const maxYear = yearFromBound(max, 2100); + const baseYear = selectedYear || Math.min(Math.max(currentYear, minYear), maxYear); + const [open, setOpen] = useState(false); + const [pageStart, setPageStart] = useState(baseYear - (baseYear % 12)); + const years = Array.from({ length: 12 }, (_, index) => pageStart + index); + + return ( + { + setOpen(nextOpen); + if (nextOpen) { + const year = selectedYear || Math.min(Math.max(currentYear, minYear), maxYear); + setPageStart(year - (year % 12)); + } + }} + > + + } + > + + + {selectedYear || placeholder} + + + + +
+ + + {Math.max(pageStart, minYear)} – {Math.min(pageStart + 11, maxYear)} + + +
+ +
+ {years.map((year) => ( + + ))} +
+
+
+ ); +}; + + /* ========================================================= TIME PICKER ========================================================= */ const DesktopTimePicker = ({ value, - onChange + onChange, + disabled = false, + className = '', }) => { const [open, setOpen] = useState(false); const timeRef = useRef(value); @@ -330,7 +571,8 @@ const DesktopTimePicker = ({ }{note.can_edit && !closed && mutate('close')}>} + ; +} diff --git a/frontend/src/components/notes/PollEditor.jsx b/frontend/src/components/notes/PollEditor.jsx new file mode 100644 index 0000000..6813e40 --- /dev/null +++ b/frontend/src/components/notes/PollEditor.jsx @@ -0,0 +1,21 @@ +import { Plus, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { inputClass } from '@/components/planning/utils'; + + +export default function PollEditor({ value, onChange, disabled, locked = false }) { + const update = (change) => onChange({ ...value, ...change }); + return
+

Préparer le sondage

+ {locked &&

Quelqu’un a déjà voté. La question, les réponses et les règles ne peuvent plus être changées.

} +
+

Combien de réponses peut-on choisir ?

update({ vote_mode })}>{[['single', 'Une seule réponse'], ['multiple', 'Plusieurs réponses, au choix'], ['exact', 'Un nombre précis de réponses']].map(([mode, label]) => )}
+ {value.vote_mode === 'exact' && } +

{value.is_anonymous ? 'Les noms ne seront pas affichés avec les réponses.' : 'Les noms seront affichés à côté des réponses choisies.'}

+

Réponses proposées

{value.options.map((option, i) =>
update({ options: value.options.map((text, index) => index === i ? e.target.value : text) })} />
)}
+
+

Le sondage est toujours public : toute l’équipe peut voter. Le vote reste modifiable tant qu’un responsable ne clôture pas le sondage.

+
; +} diff --git a/frontend/src/components/notes/pollUtils.js b/frontend/src/components/notes/pollUtils.js new file mode 100644 index 0000000..fdb2d1e --- /dev/null +++ b/frontend/src/components/notes/pollUtils.js @@ -0,0 +1,2 @@ +export const emptyPoll = () => ({ vote_mode: 'single', exact_choices: 2, is_anonymous: true, options: ['', ''] }); +export const pollForm = (poll) => poll ? { vote_mode: poll.vote_mode, exact_choices: poll.exact_choices, is_anonymous: poll.is_anonymous, options: poll.options.map((o) => o.label) } : emptyPoll(); diff --git a/frontend/src/components/planning/ChangePreview.jsx b/frontend/src/components/planning/ChangePreview.jsx new file mode 100644 index 0000000..2b4b892 --- /dev/null +++ b/frontend/src/components/planning/ChangePreview.jsx @@ -0,0 +1,17 @@ +import { dateLabel, dayLabel, personLabel } from './utils'; + +export default function ChangePreview({ before = [], after = [], people = [] }) { + return ( +
+ {before.map((day, index) => ( +
+

{personLabel(people.find((p) => String(p.id) === String(day.user_id)))} · {dateLabel(day.date)}

+
+
Avant
{dayLabel(day)}
+
Après
{dayLabel(after[index] || day)}
+
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/planning/MyRequestsCard.jsx b/frontend/src/components/planning/MyRequestsCard.jsx new file mode 100644 index 0000000..8a30afa --- /dev/null +++ b/frontend/src/components/planning/MyRequestsCard.jsx @@ -0,0 +1,386 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + ArrowLeftRight, + CalendarClock, + CalendarOff, + ChevronLeft, + ChevronRight, + MoveRight, +} from 'lucide-react'; +import { planningApi } from '@/api/planning'; +import { can } from '@/lib/permissions'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ResponsivePickers'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import ChangePreview from './ChangePreview'; +import RequestsList from './RequestsList'; +import SlotsEditor from './SlotsEditor'; +import { dateKey, dateLabel, inputClass, personLabel, slotLabel } from './utils'; + +const DAY_MS = 86400000; + +const parseDate = (value) => new Date(`${value}T12:00:00`); +const addDays = (value, amount) => dateKey(new Date(parseDate(value).getTime() + amount * DAY_MS)); +const mondayOf = (value) => { + const date = parseDate(value); + const offset = (date.getDay() + 6) % 7; + return dateKey(new Date(date.getTime() - offset * DAY_MS)); +}; +const longDate = (value) => parseDate(value).toLocaleDateString('fr-FR', { + weekday: 'long', day: 'numeric', month: 'long', +}); +const shortDate = (value) => parseDate(value).toLocaleDateString('fr-FR', { + day: 'numeric', month: 'short', +}); +const slotMinutes = (slot) => { + const [sh, sm] = slot.start_time.slice(0, 5).split(':').map(Number); + const [eh, em] = slot.end_time.slice(0, 5).split(':').map(Number); + return Math.max(0, (eh * 60 + em + Number(slot.end_day_offset || 0) * 1440) - (sh * 60 + sm)); +}; +const dayMinutes = (day) => day?.schedule_mode === 'daily_hours' ? Number(day?.target_minutes || 0) : (day?.slots || []).reduce((total, slot) => total + slotMinutes(slot), 0); +const formatMinutes = (minutes) => { + const sign = minutes < 0 ? '−' : ''; + const absolute = Math.abs(minutes); + const hours = Math.floor(absolute / 60); + const mins = absolute % 60; + return `${sign}${hours} h${mins ? ` ${String(mins).padStart(2, '0')}` : ''}`; +}; + +export default function MyRequestsCard({ user }) { + const mobile = useIsMobile(); + const [cursor, setCursor] = useState(dateKey()); + const [schedule, setSchedule] = useState({ days: [] }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [reload, setReload] = useState(0); + const [people, setPeople] = useState([user]); + const [peopleError, setPeopleError] = useState(''); + const [selected, setSelected] = useState(null); + const [mode, setMode] = useState('choice'); + const [targetUserId, setTargetUserId] = useState(''); + const [targetDate, setTargetDate] = useState(addDays(dateKey(), 1)); + const [targetDay, setTargetDay] = useState(null); + const [targetLoading, setTargetLoading] = useState(false); + const [proposedSlots, setProposedSlots] = useState([]); + const [reason, setReason] = useState(''); + const [preview, setPreview] = useState(null); + const [requestError, setRequestError] = useState(''); + const [busy, setBusy] = useState(false); + + const range = useMemo(() => { + const start = mobile ? cursor : mondayOf(cursor); + return { start, end: mobile ? start : addDays(start, 6) }; + }, [cursor, mobile]); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(''); + planningApi.effective(user.id, range.start, range.end, controller.signal) + .then(setSchedule) + .catch((e) => { if (!controller.signal.aborted) setError(e.message); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [user.id, range.start, range.end, reload]); + + useEffect(() => { + const controller = new AbortController(); + planningApi.people(controller.signal) + .then((rows) => setPeople(rows)) + .catch((e) => { if (!controller.signal.aborted) setPeopleError(e.message); }); + return () => controller.abort(); + }, []); + + useEffect(() => { + if (!selected || mode !== 'move' || !targetDate) { + setTargetDay(null); + setTargetLoading(false); + return undefined; + } + const controller = new AbortController(); + setTargetLoading(true); + planningApi.effective(user.id, targetDate, targetDate, controller.signal) + .then((result) => setTargetDay(result.days?.[0] || null)) + .catch(() => { if (!controller.signal.aborted) setTargetDay(null); }) + .finally(() => { if (!controller.signal.aborted) setTargetLoading(false); }); + return () => controller.abort(); + }, [selected, mode, targetDate, user.id]); + + const otherPeople = people.filter((person) => String(person.id) !== String(user.id)); + const sourceMinutes = dayMinutes(selected); + const addedMinutes = proposedSlots.reduce((total, slot) => total + slotMinutes(slot), 0); + const remainingMinutes = sourceMinutes - addedMinutes; + const targetUnavailable = mode === 'move' && targetDay && (['absence', 'undefined', 'weekly_target'].includes(targetDay.kind) || (targetDay.schedule_mode === 'daily_hours' && dayMinutes(targetDay) > 0)); + + const resetRequest = (day = null) => { + setSelected(day); + setMode('choice'); + setTargetUserId(otherPeople[0] ? String(otherPeople[0].id) : ''); + setTargetDate(day ? addDays(day.date, 1) : addDays(dateKey(), 1)); + setTargetDay(null); + setProposedSlots(day?.slots?.map((slot) => ({ + start_time: slot.start_time.slice(0, 5), + end_time: slot.end_time.slice(0, 5), + end_day_offset: Number(slot.end_day_offset || 0), + })) || []); + setReason(''); + setPreview(null); + setRequestError(''); + }; + + const changeMode = (value) => { + if (value === 'swap' && !targetUserId && otherPeople[0]) { + setTargetUserId(String(otherPeople[0].id)); + } + setMode(value); + setPreview(null); + setRequestError(''); + }; + + const payload = () => { + const base = { + kind: mode, + start_date: selected.date, + end_date: selected.date, + reason, + proposed_slots: mode === 'move' ? proposedSlots : [], + }; + if (mode === 'swap') { + return { ...base, target_user_id: Number(targetUserId) }; + } + if (mode === 'move') { + return { ...base, target_date: targetDate }; + } + return base; + }; + + const submit = async () => { + if (busy || !selected || mode === 'choice') return; + if (mode === 'move' && targetDate === selected.date) { + setRequestError('Choisis un autre jour pour déplacer cette journée.'); + return; + } + if (mode === 'move' && targetUnavailable) { + setRequestError('Choisis un jour de repos ou un jour avec des horaires précis.'); + return; + } + if (mode === 'move' && remainingMinutes !== 0) { + setRequestError('Répartis exactement le même nombre d’heures avant de continuer.'); + return; + } + setBusy(true); + setRequestError(''); + try { + if (mode === 'swap') { + await planningApi.createRequest(payload()); + resetRequest(null); + setReload((value) => value + 1); + } else if (!preview) { + setPreview(await planningApi.preview(payload())); + } else { + await planningApi.createRequest({ ...payload(), expected_before: preview.before }); + resetRequest(null); + setReload((value) => value + 1); + } + } catch (e) { + setRequestError(e.message); + if (e.response?.status === 409) setPreview(null); + } finally { + setBusy(false); + } + }; + + const days = schedule.days || []; + const weeklyTarget = Number(days.find((day) => day.schedule_mode === 'weekly_hours')?.week_target_minutes || 0); + + return ( +
+
+
+
+

+ Mon planning +

+

+ {mobile + ? 'Parcours ton planning jour par jour et touche une journée travaillée pour faire une demande.' + : 'Parcours ton planning semaine par semaine et clique sur une journée travaillée pour faire une demande.'} +

+
+
+ + + +
+
+ +
+ {mobile ? longDate(range.start) : `${shortDate(range.start)} – ${shortDate(range.end)}`} +
+ + {error &&

{error}

} + {!loading && !error && weeklyTarget > 0 &&

Objectif de la semaine

{formatMinutes(weeklyTarget)}

Les heures ne sont pas réparties sur des jours précis, donc les demandes d’échange ou de déplacement se font uniquement sur les journées définies autrement.

} + {loading ? ( +

Chargement du planning…

+ ) : !error && ( +
+ {days.map((day) => { + const worked = day.kind === 'work' && dayMinutes(day) > 0; + const dailyTarget = day.schedule_mode === 'daily_hours' && Number(day.target_minutes || 0) > 0; + return ( + + ); + })} +
+ )} + +
+

Mes demandes

+ +
+
+ + { if (!open && !busy) resetRequest(null); }}> + + {selected && ( + <> + + {mode === 'choice' ? `Que veux-tu faire le ${dateLabel(selected.date)} ?` : mode === 'leave' ? 'Demander un congé' : mode === 'swap' ? 'Échanger cette journée' : 'Déplacer cette journée'} + {selected.slots?.length ? `${selected.slots.map(slotLabel).join(' · ')} · ` : selected.schedule_mode === 'daily_hours' ? 'Horaires non imposés · ' : ''}{formatMinutes(sourceMinutes)} + + + {requestError &&

{requestError}

} + + {mode === 'choice' ? ( +
+ {can(user, 'request_leave') && ( + + )} + + + {!can(user, 'request_leave') &&

La demande de congé n’est pas activée pour ton compte.

} + {peopleError &&

Annuaire indisponible : {peopleError}

} +
+ ) : ( +
+ {mode === 'swap' && ( +
+ +

Tu demandes à échanger ta journée du {dateLabel(selected.date)} avec ce collègue. Tu n’as pas à choisir sa journée : ce sera fait par le responsable lors de la validation.

+
+ )} + + {mode === 'move' && ( + <> + +
+

Déjà prévu ce jour-là

+

+ {targetLoading + ? 'Chargement…' + : targetDay?.slots?.length + ? targetDay.slots.map(slotLabel).join(' · ') + : targetDay?.kind === 'absence' + ? 'Congé / absence' + : targetDay?.kind === 'undefined' + ? 'Planning non défini' + : targetDay?.kind === 'weekly_target' + ? `Objectif hebdomadaire : ${formatMinutes(targetDay.week_target_minutes || 0)}` + : targetDay?.schedule_mode === 'daily_hours' && dayMinutes(targetDay) > 0 + ? `${formatMinutes(dayMinutes(targetDay))} à faire, sans plage précise` + : 'Repos'} +

+
+ {targetUnavailable &&

Choisis un jour de repos ou un jour avec des horaires précis. Un objectif hebdomadaire ou un nombre d’heures déjà prévu sans plage précise ne peut pas recevoir ce déplacement.

} +
+

Horaires à ajouter ce jour-là

+ { setProposedSlots(slots); setPreview(null); setRequestError(''); }} /> +
+
+
À déplacer{formatMinutes(sourceMinutes)}
+
Ajouté{formatMinutes(addedMinutes)}
+
{remainingMinutes === 0 ? 'Répartition complète' : remainingMinutes > 0 ? 'Encore à ajouter' : 'À retirer'}{formatMinutes(Math.abs(remainingMinutes))}
+
+ + )} + +