From 9008ba2c18e8842b837da9fc0fb7982727917eab Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Fri, 25 Sep 2026 17:50:02 +0200 Subject: [PATCH] nice calendar ! --- ..._linked_user_remove_event_user_and_more.py | 39 ++++++ backend/events/models.py | 24 +--- backend/events/serializers.py | 85 ++++-------- backend/events/views.py | 66 ++++----- frontend/src/pages/CalendarPage.jsx | 128 +++++++++++------- 5 files changed, 182 insertions(+), 160 deletions(-) create mode 100644 backend/events/migrations/0004_remove_event_linked_user_remove_event_user_and_more.py diff --git a/backend/events/migrations/0004_remove_event_linked_user_remove_event_user_and_more.py b/backend/events/migrations/0004_remove_event_linked_user_remove_event_user_and_more.py new file mode 100644 index 0000000..e795415 --- /dev/null +++ b/backend/events/migrations/0004_remove_event_linked_user_remove_event_user_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.17 on 2026-09-25 15:45 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0003_event_visibility'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='event', + name='linked_user', + ), + migrations.RemoveField( + model_name='event', + name='user', + ), + migrations.RemoveField( + model_name='event', + name='visibility', + ), + migrations.AddField( + model_name='event', + name='created_by', + field=models.ForeignKey(default=2, editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='created_events', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='event', + name='is_public', + field=models.BooleanField(default=True), + ), + ] diff --git a/backend/events/models.py b/backend/events/models.py index 1eb5e13..ee68a04 100644 --- a/backend/events/models.py +++ b/backend/events/models.py @@ -1,35 +1,25 @@ +from django.conf import settings from django.db import models -from users.models import User class Event(models.Model): id = models.AutoField(primary_key=True) - user = models.ForeignKey( - User, + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='created_events', - ) - linked_user = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='linked_events', - null=True, - blank=True, + editable=False, ) start_date = models.DateField() end_date = models.DateField() start_time = models.TimeField(null=True, blank=True) end_time = models.TimeField(null=True, blank=True) - color = models.CharField(max_length=7, null=True, blank=True) # Hex color code + color = models.CharField(max_length=7, null=True, blank=True) name = models.CharField(max_length=255) description = models.TextField(blank=True) - visibility = models.CharField(max_length=10, default='public', choices=[ - ('public', 'Public'), - ('private', 'Private'), - ]) - + 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 \ No newline at end of file + return self.name diff --git a/backend/events/serializers.py b/backend/events/serializers.py index d3e5c59..e5e4680 100644 --- a/backend/events/serializers.py +++ b/backend/events/serializers.py @@ -1,91 +1,54 @@ from rest_framework import serializers from .models import Event -from users.serializers import UserSerializer class EventSerializer(serializers.ModelSerializer): - user = UserSerializer(read_only=True) - linked_user = UserSerializer(read_only=True) - class Meta: model = Event fields = [ - 'id', - 'user', - 'linked_user', - 'start_date', - 'end_date', - 'start_time', - 'end_time', - 'color', - 'name', - 'description', - 'visibility', - 'created_at', - 'updated_at', - ] - read_only_fields = [ - 'id', - 'user', - 'created_at', - 'updated_at', + 'id', 'created_by', 'start_date', 'end_date', + 'start_time', 'end_time', 'color', 'name', + 'description', 'is_public', 'created_at', 'updated_at', ] + read_only_fields = ['id', 'created_by', 'created_at', 'updated_at'] class EventCreateSerializer(serializers.ModelSerializer): - color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False, allow_null=True, allow_blank=True) + color = serializers.RegexField( + r'^#[0-9a-fA-F]{6}$', required=False, + allow_null=True, allow_blank=True, + ) class Meta: model = Event fields = [ - 'linked_user', - 'start_date', - 'end_date', - 'start_time', - 'end_time', - 'color', - 'name', - 'description', - 'visibility', + 'start_date', 'end_date', 'start_time', 'end_time', + 'color', 'name', 'description', 'is_public', ] def validate(self, attrs): + request = self.context.get('request') + if not getattr(getattr(request, 'user', None), 'is_admin', False): + # En création comme en PATCH, les événements non-admin restent privés. + attrs['is_public'] = False + values = { field: attrs.get(field, getattr(self.instance, field, None)) for field in ('start_date', 'end_date', 'start_time', 'end_time') } + if values['start_date'] is None or values['end_date'] is None: + raise serializers.ValidationError('Les dates de début et de fin sont requises.') if values['end_date'] < values['start_date']: raise serializers.ValidationError({'end_date': 'La fin doit suivre le début.'}) - if (values['start_date'] == values['end_date'] - and values['start_time'] is not None and values['end_time'] is not None - and values['end_time'] <= values['start_time']): + if ( + values['start_date'] == values['end_date'] + and values['start_time'] is not None + and values['end_time'] is not None + and values['end_time'] <= values['start_time'] + ): raise serializers.ValidationError({'end_time': 'La fin doit suivre le début.'}) return attrs - def create(self, validated_data): - request = self.context.get('request') - - if request is None or not request.user.is_authenticated: - raise serializers.ValidationError({ - 'user': 'Un utilisateur authentifié est requis.' - }) - - validated_data['user'] = request.user - - return Event.objects.create(**validated_data) - class EventUpdateSerializer(EventCreateSerializer): - class Meta: - model = Event - fields = [ - 'linked_user', - 'start_date', - 'end_date', - 'start_time', - 'end_time', - 'color', - 'name', - 'description', - 'visibility', - ] + pass diff --git a/backend/events/views.py b/backend/events/views.py index d0028fa..40b335c 100644 --- a/backend/events/views.py +++ b/backend/events/views.py @@ -1,66 +1,68 @@ +from django.db.models import Q from rest_framework import generics, status -from rest_framework.permissions import IsAuthenticated +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 -class EventListView(generics.ListCreateAPIView): +class IsEventCreatorOrReadOnly(BasePermission): + message = 'Seul le créateur peut modifier ou supprimer cet événement.' + + def has_object_permission(self, request, view, obj): + return request.method in SAFE_METHODS or obj.created_by_id == request.user.pk + + +class VisibleEventsMixin: + def get_queryset(self): + return Event.objects.filter( + Q(is_public=True) | Q(created_by=self.request.user) + ).order_by('start_date', 'start_time', 'pk') + + +class EventListView(VisibleEventsMixin, generics.ListCreateAPIView): serializer_class = EventSerializer permission_classes = [IsAuthenticated] - def get_queryset(self): - user = self.request.user - return Event.objects.filter(user=user).select_related('user', 'linked_user') - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - def create(self, request, *args, **kwargs): - serializer = EventCreateSerializer(data=request.data, context=self.get_serializer_context()) + serializer = EventCreateSerializer( + data=request.data, context=self.get_serializer_context(), + ) serializer.is_valid(raise_exception=True) - event = serializer.save(user=request.user) - return Response(EventSerializer(event).data, status=status.HTTP_201_CREATED) + event = serializer.save(created_by=request.user) + return Response( + EventSerializer(event, context=self.get_serializer_context()).data, + status=status.HTTP_201_CREATED, + ) -class EventDetailView(generics.RetrieveUpdateDestroyAPIView): +class EventDetailView(VisibleEventsMixin, generics.RetrieveUpdateDestroyAPIView): serializer_class = EventSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, IsEventCreatorOrReadOnly] lookup_url_kwarg = 'id' - def get_queryset(self): - user = self.request.user - return Event.objects.filter(user=user).select_related('user', 'linked_user') - def update(self, request, *args, **kwargs): partial = kwargs.pop('partial', False) instance = self.get_object() - - # Use the update serializer for updates serializer = EventUpdateSerializer( - instance, - data=request.data, - partial=partial, + instance, data=request.data, partial=partial, context=self.get_serializer_context(), ) serializer.is_valid(raise_exception=True) self.perform_update(serializer) - - return Response(EventSerializer(serializer.instance).data) + return Response( + EventSerializer(serializer.instance, context=self.get_serializer_context()).data, + ) def destroy(self, request, *args, **kwargs): - instance = self.get_object() - self.perform_destroy(instance) + self.perform_destroy(self.get_object()) return Response( {'message': 'Event deleted successfully'}, - status=status.HTTP_200_OK + status=status.HTTP_200_OK, ) class EventCreateView(EventListView): http_method_names = ['post', 'options'] serializer_class = EventCreateSerializer - permission_classes = [IsAuthenticated] - - def perform_create(self, serializer): - serializer.save(user=self.request.user) diff --git a/frontend/src/pages/CalendarPage.jsx b/frontend/src/pages/CalendarPage.jsx index c2bd38a..12036e1 100644 --- a/frontend/src/pages/CalendarPage.jsx +++ b/frontend/src/pages/CalendarPage.jsx @@ -50,6 +50,7 @@ import { } from 'lucide-react'; import { eventApi } from '../api/event'; +import { useAuth } from '../context/useAuth'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -104,6 +105,12 @@ const EVENT_LANE_GAP = 11; const EVENT_LEFT_PADDING = 3; const CalendarPage = () => { + const { user, isAdmin } = useAuth(); + const canManageEvent = (event) => { + const creatorId = event?.created_by?.id ?? event?.created_by; + return user?.id != null && creatorId != null && + String(creatorId) === String(user.id); + }; const [requestError, setRequestError] = useState(''); const [year, setYear] = useState( new Date().getFullYear(), @@ -163,6 +170,7 @@ const CalendarPage = () => { start_time: '', end_time: '', color: DEFAULT_EVENT_COLOR, + is_public: Boolean(isAdmin), }); const calendarViewportRef = useRef(null); /* SÉLECTION D'UNE PÉRIODE */ @@ -631,12 +639,14 @@ const CalendarPage = () => { start_time: '', end_time: '', color: DEFAULT_EVENT_COLOR, + is_public: Boolean(isAdmin), }); }; const openCreateDialog = ( startDate = '', endDate = startDate, ) => { + setRequestError(''); setEditingEvent(null); setEventForm({ name: '', @@ -646,6 +656,7 @@ const CalendarPage = () => { start_time: '', end_time: '', color: DEFAULT_EVENT_COLOR, + is_public: Boolean(isAdmin), }); clearSelection(); closeContextMenu(); @@ -654,6 +665,8 @@ const CalendarPage = () => { const openEditDialog = ( event, ) => { + if (!canManageEvent(event)) return; + setRequestError(''); setEditingEvent(event); setEventForm({ name: event?.name ?? '', @@ -680,6 +693,7 @@ const CalendarPage = () => { color: event?.color || DEFAULT_EVENT_COLOR, + is_public: Boolean(isAdmin) && event?.is_public === true, }); closeContextMenu(); setEventDialogOpen(true); @@ -724,6 +738,7 @@ const CalendarPage = () => { submitEvent, ) => { setRequestError(''); + if (savingEvent || (editingEvent && !canManageEvent(editingEvent))) return; if (!eventForm.name.trim()) { return; } @@ -764,6 +779,7 @@ const CalendarPage = () => { color: eventForm.color || DEFAULT_EVENT_COLOR, + is_public: Boolean(isAdmin) && eventForm.is_public, }; if (submitEvent) { await eventApi.updateEvent( @@ -795,6 +811,7 @@ const CalendarPage = () => { event, ) => { closeContextMenu(); + if (!canManageEvent(event)) return; const confirmed = window.confirm( `Supprimer l’événement « ${ @@ -1032,8 +1049,13 @@ const CalendarPage = () => { : 'Créer un événement'} )} + {contextMenu.type === 'event' && !canManageEvent(contextMenu.event) && ( +

+ Événement public · Lecture seule +

+ )} {contextMenu.type === - 'event' && ( + 'event' && canManageEvent(contextMenu.event) && ( <> + + + + )} +

+ {isAdmin && eventForm.is_public + ? 'Visible par tous les utilisateurs connectés. Seul toi peux le modifier ou le supprimer.' + : 'Visible uniquement par toi dans ton calendrier.'} +