nice calendar !
This commit is contained in:
parent
3f30729de9
commit
9008ba2c18
@ -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),
|
||||
),
|
||||
]
|
||||
@ -1,33 +1,23 @@
|
||||
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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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">
|
||||
{/* 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={
|
||||
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
|
||||
"
|
||||
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
|
||||
value={
|
||||
eventForm.color
|
||||
}
|
||||
onChange={(
|
||||
event,
|
||||
) =>
|
||||
handleFormChange(
|
||||
'color',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="#3b82f6"
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user