nice calendar !

This commit is contained in:
Louis Mazin 2026-09-25 17:50:02 +02:00
parent 3f30729de9
commit 9008ba2c18
5 changed files with 182 additions and 160 deletions

View File

@ -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),
),
]

View File

@ -1,33 +1,23 @@
from django.conf import settings
from django.db import models from django.db import models
from users.models import User
class Event(models.Model): class Event(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
user = models.ForeignKey( created_by = models.ForeignKey(
User, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='created_events', related_name='created_events',
) editable=False,
linked_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='linked_events',
null=True,
blank=True,
) )
start_date = models.DateField() start_date = models.DateField()
end_date = models.DateField() end_date = models.DateField()
start_time = models.TimeField(null=True, blank=True) start_time = models.TimeField(null=True, blank=True)
end_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) name = models.CharField(max_length=255)
description = models.TextField(blank=True) description = models.TextField(blank=True)
visibility = models.CharField(max_length=10, default='public', choices=[ is_public = models.BooleanField(default=True)
('public', 'Public'),
('private', 'Private'),
])
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View File

@ -1,91 +1,54 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Event from .models import Event
from users.serializers import UserSerializer
class EventSerializer(serializers.ModelSerializer): class EventSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
linked_user = UserSerializer(read_only=True)
class Meta: class Meta:
model = Event model = Event
fields = [ fields = [
'id', 'id', 'created_by', 'start_date', 'end_date',
'user', 'start_time', 'end_time', 'color', 'name',
'linked_user', 'description', 'is_public', 'created_at', 'updated_at',
'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',
] ]
read_only_fields = ['id', 'created_by', 'created_at', 'updated_at']
class EventCreateSerializer(serializers.ModelSerializer): 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: class Meta:
model = Event model = Event
fields = [ fields = [
'linked_user', 'start_date', 'end_date', 'start_time', 'end_time',
'start_date', 'color', 'name', 'description', 'is_public',
'end_date',
'start_time',
'end_time',
'color',
'name',
'description',
'visibility',
] ]
def validate(self, attrs): 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 = { values = {
field: attrs.get(field, getattr(self.instance, field, None)) field: attrs.get(field, getattr(self.instance, field, None))
for field in ('start_date', 'end_date', 'start_time', 'end_time') 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']: if values['end_date'] < values['start_date']:
raise serializers.ValidationError({'end_date': 'La fin doit suivre le début.'}) raise serializers.ValidationError({'end_date': 'La fin doit suivre le début.'})
if (values['start_date'] == values['end_date'] if (
and values['start_time'] is not None and values['end_time'] is not None values['start_date'] == values['end_date']
and values['end_time'] <= values['start_time']): 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.'}) raise serializers.ValidationError({'end_time': 'La fin doit suivre le début.'})
return attrs 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 EventUpdateSerializer(EventCreateSerializer):
class Meta: pass
model = Event
fields = [
'linked_user',
'start_date',
'end_date',
'start_time',
'end_time',
'color',
'name',
'description',
'visibility',
]

View File

@ -1,66 +1,68 @@
from django.db.models import Q
from rest_framework import generics, status 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 rest_framework.response import Response
from .models import Event from .models import Event
from .serializers import EventSerializer, EventCreateSerializer, EventUpdateSerializer 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 serializer_class = EventSerializer
permission_classes = [IsAuthenticated] 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): 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) serializer.is_valid(raise_exception=True)
event = serializer.save(user=request.user) event = serializer.save(created_by=request.user)
return Response(EventSerializer(event).data, status=status.HTTP_201_CREATED) 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 serializer_class = EventSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated, IsEventCreatorOrReadOnly]
lookup_url_kwarg = 'id' 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): def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False) partial = kwargs.pop('partial', False)
instance = self.get_object() instance = self.get_object()
# Use the update serializer for updates
serializer = EventUpdateSerializer( serializer = EventUpdateSerializer(
instance, instance, data=request.data, partial=partial,
data=request.data,
partial=partial,
context=self.get_serializer_context(), context=self.get_serializer_context(),
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_update(serializer) self.perform_update(serializer)
return Response(
return Response(EventSerializer(serializer.instance).data) EventSerializer(serializer.instance, context=self.get_serializer_context()).data,
)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
instance = self.get_object() self.perform_destroy(self.get_object())
self.perform_destroy(instance)
return Response( return Response(
{'message': 'Event deleted successfully'}, {'message': 'Event deleted successfully'},
status=status.HTTP_200_OK status=status.HTTP_200_OK,
) )
class EventCreateView(EventListView): class EventCreateView(EventListView):
http_method_names = ['post', 'options'] http_method_names = ['post', 'options']
serializer_class = EventCreateSerializer serializer_class = EventCreateSerializer
permission_classes = [IsAuthenticated]
def perform_create(self, serializer):
serializer.save(user=self.request.user)

View File

@ -50,6 +50,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { eventApi } from '../api/event'; import { eventApi } from '../api/event';
import { useAuth } from '../context/useAuth';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
@ -104,6 +105,12 @@ const EVENT_LANE_GAP = 11;
const EVENT_LEFT_PADDING = 3; const EVENT_LEFT_PADDING = 3;
const CalendarPage = () => { 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 [requestError, setRequestError] = useState('');
const [year, setYear] = useState( const [year, setYear] = useState(
new Date().getFullYear(), new Date().getFullYear(),
@ -163,6 +170,7 @@ const CalendarPage = () => {
start_time: '', start_time: '',
end_time: '', end_time: '',
color: DEFAULT_EVENT_COLOR, color: DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin),
}); });
const calendarViewportRef = useRef(null); const calendarViewportRef = useRef(null);
/* SÉLECTION D'UNE PÉRIODE */ /* SÉLECTION D'UNE PÉRIODE */
@ -631,12 +639,14 @@ const CalendarPage = () => {
start_time: '', start_time: '',
end_time: '', end_time: '',
color: DEFAULT_EVENT_COLOR, color: DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin),
}); });
}; };
const openCreateDialog = ( const openCreateDialog = (
startDate = '', startDate = '',
endDate = startDate, endDate = startDate,
) => { ) => {
setRequestError('');
setEditingEvent(null); setEditingEvent(null);
setEventForm({ setEventForm({
name: '', name: '',
@ -646,6 +656,7 @@ const CalendarPage = () => {
start_time: '', start_time: '',
end_time: '', end_time: '',
color: DEFAULT_EVENT_COLOR, color: DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin),
}); });
clearSelection(); clearSelection();
closeContextMenu(); closeContextMenu();
@ -654,6 +665,8 @@ const CalendarPage = () => {
const openEditDialog = ( const openEditDialog = (
event, event,
) => { ) => {
if (!canManageEvent(event)) return;
setRequestError('');
setEditingEvent(event); setEditingEvent(event);
setEventForm({ setEventForm({
name: event?.name ?? '', name: event?.name ?? '',
@ -680,6 +693,7 @@ const CalendarPage = () => {
color: color:
event?.color || event?.color ||
DEFAULT_EVENT_COLOR, DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin) && event?.is_public === true,
}); });
closeContextMenu(); closeContextMenu();
setEventDialogOpen(true); setEventDialogOpen(true);
@ -724,6 +738,7 @@ const CalendarPage = () => {
submitEvent, submitEvent,
) => { ) => {
setRequestError(''); setRequestError('');
if (savingEvent || (editingEvent && !canManageEvent(editingEvent))) return;
if (!eventForm.name.trim()) { if (!eventForm.name.trim()) {
return; return;
} }
@ -764,6 +779,7 @@ const CalendarPage = () => {
color: color:
eventForm.color || eventForm.color ||
DEFAULT_EVENT_COLOR, DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin) && eventForm.is_public,
}; };
if (submitEvent) { if (submitEvent) {
await eventApi.updateEvent( await eventApi.updateEvent(
@ -795,6 +811,7 @@ const CalendarPage = () => {
event, event,
) => { ) => {
closeContextMenu(); closeContextMenu();
if (!canManageEvent(event)) return;
const confirmed = const confirmed =
window.confirm( window.confirm(
`Supprimer l’événement « ${ `Supprimer l’événement « ${
@ -1032,8 +1049,13 @@ const CalendarPage = () => {
: 'Créer un événement'} : 'Créer un événement'}
</button> </button>
)} )}
{contextMenu.type === 'event' && !canManageEvent(contextMenu.event) && (
<p className="px-2 py-1.5 text-sm text-muted-foreground">
Événement public · Lecture seule
</p>
)}
{contextMenu.type === {contextMenu.type ===
'event' && ( 'event' && canManageEvent(contextMenu.event) && (
<> <>
<button <button
type="button" type="button"
@ -1255,57 +1277,63 @@ const CalendarPage = () => {
/> />
</div> </div>
</div> </div>
{/* COULEUR */} {/* COULEUR ET VISIBILITÉ */}
<div className="grid gap-2"> <div className="flex items-start gap-4">
<Label htmlFor="event-color"> <div className="grid min-w-0 flex-1 gap-2">
Couleur <Label htmlFor="event-color">Couleur</Label>
</Label> <div className="flex min-w-0 items-center gap-2">
<div className="flex items-center gap-3"> <input
<input id="event-color"
id="event-color" type="color"
type="color" value={/^#[0-9a-fA-F]{6}$/.test(eventForm.color)
value={ ? eventForm.color : DEFAULT_EVENT_COLOR}
eventForm.color?.startsWith( onChange={(event) => handleFormChange('color', event.target.value)}
'#', className="h-10 w-14 shrink-0 cursor-pointer rounded-md border border-input bg-background p-1"
) />
? eventForm.color <Input
: '#3b82f6' aria-label="Code hexadécimal de la couleur"
} value={eventForm.color}
onChange={( onChange={(event) => handleFormChange('color', event.target.value)}
event, placeholder="#2563eb"
) => className="min-w-0 flex-1"
handleFormChange( />
'color', </div>
event.target.value,
)
}
className="
h-10
w-14
cursor-pointer
rounded-md
border
border-input
bg-background
p-1
"
/>
<Input
value={
eventForm.color
}
onChange={(
event,
) =>
handleFormChange(
'color',
event.target.value,
)
}
placeholder="#3b82f6"
/>
</div> </div>
{isAdmin && (
<div className="grid shrink-0 gap-2">
<Label htmlFor="event-is-public">Visibilité</Label>
<div className="flex h-10 items-center gap-2">
<button
id="event-is-public"
type="button"
role="switch"
aria-label="Événement public"
aria-checked={eventForm.is_public}
aria-describedby="event-visibility-help"
disabled={savingEvent}
onClick={() => setEventForm((current) => ({
...current,
is_public: !current.is_public,
}))}
className={`inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 ${eventForm.is_public ? 'bg-primary' : 'bg-input'}`}
>
<span
aria-hidden="true"
className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${eventForm.is_public ? 'translate-x-5' : 'translate-x-0'}`}
/>
</button>
<Label htmlFor="event-is-public" className="w-10 cursor-pointer">
{eventForm.is_public ? 'Public' : 'Privé'}
</Label>
</div>
</div>
)}
</div> </div>
<p id="event-visibility-help" className="text-xs text-muted-foreground">
{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.'}
</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button