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,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
return self.name

View File

@ -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

View File

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

View File

@ -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'}
</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 ===
'event' && (
'event' && canManageEvent(contextMenu.event) && (
<>
<button
type="button"
@ -1255,57 +1277,63 @@ const CalendarPage = () => {
/>
</div>
</div>
{/* COULEUR */}
<div className="grid gap-2">
<Label htmlFor="event-color">
Couleur
</Label>
<div className="flex items-center gap-3">
<input
id="event-color"
type="color"
value={
eventForm.color?.startsWith(
'#',
)
? eventForm.color
: '#3b82f6'
}
onChange={(
event,
) =>
handleFormChange(
'color',
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"
/>
{/* COULEUR ET VISIBILITÉ */}
<div className="flex items-start gap-4">
<div className="grid min-w-0 flex-1 gap-2">
<Label htmlFor="event-color">Couleur</Label>
<div className="flex min-w-0 items-center gap-2">
<input
id="event-color"
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(eventForm.color)
? eventForm.color : DEFAULT_EVENT_COLOR}
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"
/>
<Input
aria-label="Code hexadécimal de la couleur"
value={eventForm.color}
onChange={(event) => handleFormChange('color', event.target.value)}
placeholder="#2563eb"
className="min-w-0 flex-1"
/>
</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>
<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>
<DialogFooter>
<Button