big maj
This commit is contained in:
parent
5298d69e1f
commit
d424689e12
84
backend/API.md
Normal file
84
backend/API.md
Normal file
@ -0,0 +1,84 @@
|
||||
# Contrat API
|
||||
|
||||
Toutes les routes métier exigent un JWT. Les listes de pointages et les routes
|
||||
de détail sont limitées au propriétaire ; les administrateurs peuvent consulter
|
||||
et gérer les pointages de tous les utilisateurs. Les événements restent privés
|
||||
à leur créateur, comme auparavant.
|
||||
|
||||
## Pointages
|
||||
|
||||
- `GET /api/time/entries/` renvoie une liste de lignes journalières, avec
|
||||
`start_date`, `end_date`, `start_time`, `end_time`, `group_id` et `duration` en heures.
|
||||
- `POST /api/time/entries/create/` accepte ces dates/heures et, pour un
|
||||
administrateur, `user_id`. Une entrée sans sortie est permise sur une journée.
|
||||
- `PATCH /api/time/entries/{id}/` modifie un pointage simple. Une période sur
|
||||
plusieurs jours est découpée côté serveur. L'ancienne route `.../{id}/update/`
|
||||
reste un alias avec les mêmes validations ; `PUT` accepte aussi les champs partiels.
|
||||
- `PATCH /api/time/groups/{uuid}/` modifie une période entière en transaction.
|
||||
Les dates/heures omises conservent les bornes existantes. La réponse est la
|
||||
première ligne, au même format que la création.
|
||||
- `DELETE /api/time/groups/{uuid}/` supprime toutes les lignes du groupe en
|
||||
transaction. Une ligne d'un groupe ne peut pas être modifiée/supprimée isolément.
|
||||
- `group_id` est attribué exclusivement par le serveur. Les chevauchements et
|
||||
les entrées ouvertes concurrentes sont refusés.
|
||||
|
||||
Le front recharge la liste après une mutation. Il utilise `duration` fourni par
|
||||
le serveur et additionne les lignes d'un groupe pour afficher sa durée.
|
||||
|
||||
Le scan conserve les conflits HTTP 409. Pour les résoudre, le client renvoie
|
||||
`resolution`, `time`, `scan_date` et, selon le conflit, `entry_id` ou `scan_time`.
|
||||
Une résolution devenue obsolète ou chevauchant un autre pointage est refusée.
|
||||
|
||||
## Événements et comptes
|
||||
|
||||
La création et la modification d'événements renvoient le même objet complet
|
||||
que la lecture. Dates, heures et couleurs sont validées côté serveur.
|
||||
|
||||
Un administrateur standard peut gérer les comptes ordinaires et les promouvoir
|
||||
administrateurs. Seul un superutilisateur peut gérer les comptes superutilisateurs
|
||||
ou accorder ce rôle. Un compte ne peut pas se supprimer, se désactiver ou retirer
|
||||
son propre accès administrateur.
|
||||
|
||||
## Tâches, informations et mémos
|
||||
|
||||
L'espace Entreprise → Tâches et mémos est partagé entre tous les utilisateurs
|
||||
authentifiés. Le destinataire désigne la personne concernée ; ce n'est pas une
|
||||
publication privée.
|
||||
|
||||
- Table `notes_note` : titre, contenu, type (`task`, `info`, `memo`), auteur,
|
||||
destinataire facultatif, date de clôture et dates de création/modification.
|
||||
- `GET /api/notes/` : liste paginée (`count`, `next`, `previous`, `results`,
|
||||
24 éléments par page). Filtres : `kind`, `scope=all|mine|to_me`,
|
||||
`state=all|pending|done`, `search`, `page`.
|
||||
- `POST /api/notes/` : `title`, `content` facultatif, `kind`, `recipient_id`
|
||||
facultatif ou `null` pour toute l'équipe. L'auteur est fixé par le serveur.
|
||||
- `GET|PATCH|DELETE /api/notes/{id}/` : consulter, modifier ou supprimer.
|
||||
Seuls l'auteur et les administrateurs peuvent modifier ou supprimer.
|
||||
- `PATCH /api/notes/{id}/completion/` : `{ "is_done": true|false }`.
|
||||
L'auteur, le destinataire et les administrateurs peuvent terminer ou rouvrir
|
||||
une tâche. Une tâche destinée à toute l'équipe peut être terminée par chacun.
|
||||
- `GET /api/notes/recipients/` : annuaire des comptes actifs limité à
|
||||
`id`, `username`, `first_name`, `last_name` et `color`.
|
||||
|
||||
Les réponses comprennent `can_edit` et `can_complete` pour l'interface ; les
|
||||
mêmes permissions sont contrôlées à chaque écriture côté serveur. La suppression
|
||||
d'un compte conserve ses publications en retirant la référence au compte.
|
||||
|
||||
## Session et migrations
|
||||
|
||||
Le client renouvelle un accès expiré avec `/api/auth/refresh/`, mutualise les
|
||||
renouvellements concurrents dans le même onglet, puis rejoue la requête une fois.
|
||||
Le nouveau refresh token remplace l'ancien, qui est placé sur liste noire.
|
||||
Les erreurs de réseau ne suppriment pas la session ; un refresh invalide la ferme.
|
||||
|
||||
Exécuter `python manage.py migrate` à chaque déploiement. La migration 0005
|
||||
réconcilie les contraintes physiques des pointages avec l'état Django. Elle
|
||||
refuse les anciens pointages sans heure d'entrée et les doublons : ces données
|
||||
doivent être corrigées explicitement, elles ne sont ni inventées ni supprimées.
|
||||
|
||||
## Vérification
|
||||
|
||||
- Backend : `python manage.py test time_tracking events users notes --noinput` avec
|
||||
une instance MySQL de test et un utilisateur autorisé à créer la base de test.
|
||||
Ne pas pointer `DATABASE_URL` vers une base de production pour ces tests.
|
||||
- Frontend : `npm test`, `npm run lint`, `npm run build` dans `frontend`.
|
||||
@ -41,11 +41,13 @@ INSTALLED_APPS = [
|
||||
# Third party apps
|
||||
'rest_framework',
|
||||
'rest_framework_simplejwt',
|
||||
'rest_framework_simplejwt.token_blacklist',
|
||||
'corsheaders',
|
||||
# Local apps
|
||||
'users',
|
||||
'time_tracking',
|
||||
'events',
|
||||
'notes',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -149,11 +151,9 @@ SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_ROTTEN_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
}
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
|
||||
|
||||
@ -28,4 +28,5 @@ urlpatterns = [
|
||||
path('api/users/', include('users.urls')),
|
||||
path('api/time/', include('time_tracking.urls')),
|
||||
path('api/event/', include('events.urls')),
|
||||
path('api/notes/', include('notes.urls')),
|
||||
]
|
||||
|
||||
0
backend/events/__init__.py
Normal file
0
backend/events/__init__.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-22 11:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='color',
|
||||
field=models.CharField(blank=True, max_length=7, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='end_time',
|
||||
field=models.TimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='start_time',
|
||||
field=models.TimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
18
backend/events/migrations/0003_event_visibility.py
Normal file
18
backend/events/migrations/0003_event_visibility.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-24 12:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0002_event_color_alter_event_end_time_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('public', 'Public'), ('private', 'Private')], default='public', max_length=10),
|
||||
),
|
||||
]
|
||||
@ -18,10 +18,15 @@ class Event(models.Model):
|
||||
)
|
||||
start_date = models.DateField()
|
||||
end_date = models.DateField()
|
||||
start_time = models.TimeField()
|
||||
end_time = models.TimeField()
|
||||
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
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
visibility = models.CharField(max_length=10, default='public', choices=[
|
||||
('public', 'Public'),
|
||||
('private', 'Private'),
|
||||
])
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@ -17,30 +17,24 @@ class EventSerializer(serializers.ModelSerializer):
|
||||
'end_date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'color',
|
||||
'name',
|
||||
'description',
|
||||
'visibility',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'user',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'user', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class EventCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = [
|
||||
'user',
|
||||
'linked_user',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'name',
|
||||
'description'
|
||||
]
|
||||
color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False, allow_null=True, allow_blank=True)
|
||||
|
||||
|
||||
class EventUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = [
|
||||
@ -49,6 +43,49 @@ class EventUpdateSerializer(serializers.ModelSerializer):
|
||||
'end_date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'color',
|
||||
'name',
|
||||
'description'
|
||||
'description',
|
||||
'visibility',
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
values = {
|
||||
field: attrs.get(field, getattr(self.instance, field, None))
|
||||
for field in ('start_date', 'end_date', 'start_time', 'end_time')
|
||||
}
|
||||
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']):
|
||||
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',
|
||||
]
|
||||
|
||||
3
backend/events/tests.py
Normal file
3
backend/events/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -16,10 +16,17 @@ class EventListView(generics.ListCreateAPIView):
|
||||
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.is_valid(raise_exception=True)
|
||||
event = serializer.save(user=request.user)
|
||||
return Response(EventSerializer(event).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class EventDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = EventSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_url_kwarg = 'id'
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
@ -33,12 +40,13 @@ class EventDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer = EventUpdateSerializer(
|
||||
instance,
|
||||
data=request.data,
|
||||
partial=partial
|
||||
partial=partial,
|
||||
context=self.get_serializer_context(),
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
|
||||
return Response(serializer.data)
|
||||
return Response(EventSerializer(serializer.instance).data)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
@ -49,7 +57,8 @@ class EventDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
)
|
||||
|
||||
|
||||
class EventCreateView(generics.CreateAPIView):
|
||||
class EventCreateView(EventListView):
|
||||
http_method_names = ['post', 'options']
|
||||
serializer_class = EventCreateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
0
backend/notes/__init__.py
Normal file
0
backend/notes/__init__.py
Normal file
7
backend/notes/apps.py
Normal file
7
backend/notes/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'notes'
|
||||
verbose_name = 'Tâches et mémos'
|
||||
35
backend/notes/migrations/0001_initial.py
Normal file
35
backend/notes/migrations/0001_initial.py
Normal file
@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-23 15:04
|
||||
|
||||
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='Note',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=160)),
|
||||
('content', models.TextField(blank=True, max_length=5000)),
|
||||
('kind', models.CharField(choices=[('task', 'Tâche'), ('info', 'Information'), ('memo', 'Mémo')], default='memo', max_length=4)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authored_notes', to=settings.AUTH_USER_MODEL)),
|
||||
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='addressed_notes', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at', '-pk'],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('kind', 'task'), ('completed_at__isnull', True), _connector='OR'), name='only_tasks_can_be_completed')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-24 16:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notes', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='note',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='note',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('task', 'Tâche'), ('info', 'Information'), ('memo', 'Mémo'), ('sugg', 'Suggestion')], default='memo', max_length=4),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-24 16:53
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notes', '0002_note_is_public_alter_note_kind'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='note',
|
||||
name='recipient',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='note',
|
||||
name='recipients',
|
||||
field=models.ManyToManyField(blank=True, related_name='addressed_notes', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
0
backend/notes/migrations/__init__.py
Normal file
0
backend/notes/migrations/__init__.py
Normal file
80
backend/notes/models.py
Normal file
80
backend/notes/models.py
Normal file
@ -0,0 +1,80 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
class Kind(models.TextChoices):
|
||||
TASK = 'task', 'Tâche'
|
||||
INFO = 'info', 'Information'
|
||||
MEMO = 'memo', 'Mémo'
|
||||
SUGGESTION = 'sugg', 'Suggestion'
|
||||
|
||||
title = models.CharField(
|
||||
max_length=160,
|
||||
)
|
||||
|
||||
content = models.TextField(
|
||||
blank=True,
|
||||
max_length=5000,
|
||||
)
|
||||
|
||||
kind = models.CharField(
|
||||
max_length=4,
|
||||
choices=Kind.choices,
|
||||
default=Kind.MEMO,
|
||||
)
|
||||
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='authored_notes',
|
||||
)
|
||||
|
||||
recipients = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name='addressed_notes',
|
||||
)
|
||||
|
||||
is_public = models.BooleanField(
|
||||
default=True,
|
||||
)
|
||||
|
||||
completed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = [
|
||||
'-created_at',
|
||||
'-pk',
|
||||
]
|
||||
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=(
|
||||
models.Q(kind='task')
|
||||
| models.Q(
|
||||
completed_at__isnull=True,
|
||||
)
|
||||
),
|
||||
name='only_tasks_can_be_completed',
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def is_done(self):
|
||||
return self.completed_at is not None
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
34
backend/notes/permissions.py
Normal file
34
backend/notes/permissions.py
Normal file
@ -0,0 +1,34 @@
|
||||
def is_admin(user):
|
||||
return (
|
||||
user.is_authenticated
|
||||
and (
|
||||
user.is_staff
|
||||
or user.is_superuser
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def can_edit_note(user, note):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
return (
|
||||
is_admin(user)
|
||||
or note.author_id == user.id
|
||||
)
|
||||
|
||||
|
||||
def can_complete_note(user, note):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if note.kind != 'task':
|
||||
return False
|
||||
|
||||
return (
|
||||
is_admin(user)
|
||||
or note.author_id == user.id
|
||||
or note.recipients.filter(
|
||||
pk=user.pk,
|
||||
).exists()
|
||||
)
|
||||
148
backend/notes/serializers.py
Normal file
148
backend/notes/serializers.py
Normal file
@ -0,0 +1,148 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Note
|
||||
from .permissions import (
|
||||
can_complete_note,
|
||||
can_edit_note,
|
||||
)
|
||||
|
||||
|
||||
class NoteUserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Annuaire minimal accessible à l'équipe
|
||||
sans exposer la gestion des comptes.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
|
||||
fields = [
|
||||
'id',
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'color',
|
||||
]
|
||||
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class NoteSerializer(serializers.ModelSerializer):
|
||||
author = NoteUserSerializer(
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
recipients = NoteUserSerializer(
|
||||
many=True,
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
recipient_ids = serializers.PrimaryKeyRelatedField(
|
||||
source='recipients',
|
||||
queryset=get_user_model().objects.filter(
|
||||
is_active=True,
|
||||
),
|
||||
many=True,
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
is_done = serializers.ReadOnlyField()
|
||||
|
||||
can_edit = serializers.SerializerMethodField()
|
||||
can_complete = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Note
|
||||
|
||||
fields = [
|
||||
'id',
|
||||
'title',
|
||||
'content',
|
||||
'kind',
|
||||
'author',
|
||||
'recipients',
|
||||
'recipient_ids',
|
||||
'is_public',
|
||||
'is_done',
|
||||
'completed_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'can_edit',
|
||||
'can_complete',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'completed_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_can_edit(self, obj):
|
||||
return can_edit_note(
|
||||
self.context['request'].user,
|
||||
obj,
|
||||
)
|
||||
|
||||
def get_can_complete(self, obj):
|
||||
return can_complete_note(
|
||||
self.context['request'].user,
|
||||
obj,
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
is_public = attrs.get(
|
||||
'is_public',
|
||||
self.instance.is_public
|
||||
if self.instance
|
||||
else True,
|
||||
)
|
||||
|
||||
if 'recipients' in attrs:
|
||||
recipients = attrs['recipients']
|
||||
has_recipients = bool(recipients)
|
||||
|
||||
elif self.instance:
|
||||
has_recipients = (
|
||||
self.instance.recipients.exists()
|
||||
)
|
||||
|
||||
else:
|
||||
has_recipients = False
|
||||
|
||||
if not is_public and not has_recipients:
|
||||
raise serializers.ValidationError({
|
||||
'recipient_ids':
|
||||
'Une publication privée doit avoir au moins un destinataire.',
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
def update(
|
||||
self,
|
||||
instance,
|
||||
validated_data,
|
||||
):
|
||||
if (
|
||||
validated_data.get(
|
||||
'kind',
|
||||
instance.kind,
|
||||
)
|
||||
!= Note.Kind.TASK
|
||||
):
|
||||
validated_data[
|
||||
'completed_at'
|
||||
] = None
|
||||
|
||||
return super().update(
|
||||
instance,
|
||||
validated_data,
|
||||
)
|
||||
|
||||
|
||||
class NoteCompletionSerializer(
|
||||
serializers.Serializer,
|
||||
):
|
||||
is_done = serializers.BooleanField()
|
||||
3
backend/notes/tests.py
Normal file
3
backend/notes/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
backend/notes/urls.py
Normal file
10
backend/notes/urls.py
Normal file
@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import NoteCompletionView, NoteDetailView, NoteListView, NoteRecipientListView
|
||||
|
||||
urlpatterns = [
|
||||
path('', NoteListView.as_view(), name='note-list'),
|
||||
path('recipients/', NoteRecipientListView.as_view(), name='note-recipients'),
|
||||
path('<int:pk>/', NoteDetailView.as_view(), name='note-detail'),
|
||||
path('<int:pk>/completion/', NoteCompletionView.as_view(), name='note-completion'),
|
||||
]
|
||||
320
backend/notes/views.py
Normal file
320
backend/notes/views.py
Normal file
@ -0,0 +1,320 @@
|
||||
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
|
||||
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 .models import Note
|
||||
from .permissions import (
|
||||
can_complete_note,
|
||||
can_edit_note,
|
||||
)
|
||||
from .serializers import (
|
||||
NoteCompletionSerializer,
|
||||
NoteSerializer,
|
||||
NoteUserSerializer,
|
||||
)
|
||||
|
||||
|
||||
def visible_notes(user):
|
||||
"""
|
||||
Retourne uniquement les publications
|
||||
que l'utilisateur a le droit de voir.
|
||||
|
||||
Publique :
|
||||
visible par toute l'équipe.
|
||||
|
||||
Privée :
|
||||
visible par l'auteur et les destinataires.
|
||||
"""
|
||||
|
||||
return (
|
||||
Note.objects
|
||||
.select_related('author')
|
||||
.prefetch_related('recipients')
|
||||
.filter(
|
||||
Q(is_public=True)
|
||||
| Q(author=user)
|
||||
| Q(recipients=user)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class NotePagination(PageNumberPagination):
|
||||
page_size = 24
|
||||
|
||||
|
||||
class NoteListView(
|
||||
generics.ListCreateAPIView,
|
||||
):
|
||||
permission_classes = [
|
||||
IsAuthenticated,
|
||||
]
|
||||
|
||||
serializer_class = NoteSerializer
|
||||
pagination_class = NotePagination
|
||||
|
||||
def get_queryset(self):
|
||||
notes = visible_notes(
|
||||
self.request.user,
|
||||
)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
kind = params.get(
|
||||
'kind',
|
||||
'',
|
||||
)
|
||||
|
||||
scope = params.get(
|
||||
'scope',
|
||||
'all',
|
||||
)
|
||||
|
||||
state = params.get(
|
||||
'state',
|
||||
'all',
|
||||
)
|
||||
|
||||
# -----------------------------
|
||||
# Type
|
||||
# -----------------------------
|
||||
|
||||
if kind:
|
||||
if kind not in Note.Kind.values:
|
||||
raise ValidationError({
|
||||
'kind':
|
||||
'Type de publication inconnu.',
|
||||
})
|
||||
|
||||
notes = notes.filter(
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
# -----------------------------
|
||||
# Portée
|
||||
# -----------------------------
|
||||
|
||||
if scope == 'mine':
|
||||
notes = notes.filter(
|
||||
author=self.request.user,
|
||||
)
|
||||
|
||||
elif scope == 'to_me':
|
||||
notes = notes.filter(
|
||||
recipients=self.request.user,
|
||||
)
|
||||
|
||||
elif scope != 'all':
|
||||
raise ValidationError({
|
||||
'scope':
|
||||
'Filtre inconnu.',
|
||||
})
|
||||
|
||||
# -----------------------------
|
||||
# État des tâches
|
||||
# -----------------------------
|
||||
|
||||
if state in (
|
||||
'pending',
|
||||
'done',
|
||||
):
|
||||
notes = notes.filter(
|
||||
kind=Note.Kind.TASK,
|
||||
completed_at__isnull=(
|
||||
state == 'pending'
|
||||
),
|
||||
)
|
||||
|
||||
elif state != 'all':
|
||||
raise ValidationError({
|
||||
'state':
|
||||
'État inconnu.',
|
||||
})
|
||||
|
||||
# -----------------------------
|
||||
# Recherche
|
||||
# -----------------------------
|
||||
|
||||
search = params.get(
|
||||
'search',
|
||||
'',
|
||||
).strip()
|
||||
|
||||
if search:
|
||||
notes = notes.filter(
|
||||
Q(
|
||||
title__icontains=search,
|
||||
)
|
||||
| Q(
|
||||
content__icontains=search,
|
||||
)
|
||||
)
|
||||
|
||||
return notes.distinct()
|
||||
|
||||
def perform_create(
|
||||
self,
|
||||
serializer,
|
||||
):
|
||||
serializer.save(
|
||||
author=self.request.user,
|
||||
)
|
||||
|
||||
|
||||
class NoteDetailView(
|
||||
generics.RetrieveUpdateDestroyAPIView,
|
||||
):
|
||||
permission_classes = [
|
||||
IsAuthenticated,
|
||||
]
|
||||
|
||||
serializer_class = NoteSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return visible_notes(
|
||||
self.request.user,
|
||||
)
|
||||
|
||||
def get_object(self):
|
||||
note = super().get_object()
|
||||
|
||||
if (
|
||||
self.request.method
|
||||
not in (
|
||||
'GET',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
)
|
||||
and not can_edit_note(
|
||||
self.request.user,
|
||||
note,
|
||||
)
|
||||
):
|
||||
raise PermissionDenied(
|
||||
'Seuls l’auteur et les administrateurs '
|
||||
'peuvent modifier ou supprimer '
|
||||
'cette publication.'
|
||||
)
|
||||
|
||||
return note
|
||||
|
||||
@transaction.atomic
|
||||
def update(
|
||||
self,
|
||||
request,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
return super().update(
|
||||
request,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class NoteCompletionView(APIView):
|
||||
permission_classes = [
|
||||
IsAuthenticated,
|
||||
]
|
||||
|
||||
@transaction.atomic
|
||||
def patch(
|
||||
self,
|
||||
request,
|
||||
pk,
|
||||
):
|
||||
data = NoteCompletionSerializer(
|
||||
data=request.data,
|
||||
)
|
||||
|
||||
data.is_valid(
|
||||
raise_exception=True,
|
||||
)
|
||||
|
||||
note = get_object_or_404(
|
||||
visible_notes(request.user)
|
||||
.select_for_update(),
|
||||
pk=pk,
|
||||
)
|
||||
|
||||
if note.kind != Note.Kind.TASK:
|
||||
raise ValidationError({
|
||||
'kind':
|
||||
'Seules les tâches peuvent être terminées.',
|
||||
})
|
||||
|
||||
if not can_complete_note(
|
||||
request.user,
|
||||
note,
|
||||
):
|
||||
raise PermissionDenied(
|
||||
'Seuls l’auteur, les destinataires '
|
||||
'et les administrateurs peuvent '
|
||||
'terminer cette tâche.'
|
||||
)
|
||||
|
||||
is_done = (
|
||||
data.validated_data[
|
||||
'is_done'
|
||||
]
|
||||
)
|
||||
|
||||
if note.is_done != is_done:
|
||||
note.completed_at = (
|
||||
timezone.now()
|
||||
if is_done
|
||||
else None
|
||||
)
|
||||
|
||||
note.save(
|
||||
update_fields=[
|
||||
'completed_at',
|
||||
'updated_at',
|
||||
],
|
||||
)
|
||||
|
||||
return Response(
|
||||
NoteSerializer(
|
||||
note,
|
||||
context={
|
||||
'request': request,
|
||||
},
|
||||
).data
|
||||
)
|
||||
|
||||
|
||||
class NoteRecipientListView(
|
||||
generics.ListAPIView,
|
||||
):
|
||||
permission_classes = [
|
||||
IsAuthenticated,
|
||||
]
|
||||
|
||||
serializer_class = (
|
||||
NoteUserSerializer
|
||||
)
|
||||
|
||||
queryset = (
|
||||
get_user_model()
|
||||
.objects
|
||||
.filter(
|
||||
is_active=True,
|
||||
)
|
||||
.order_by(
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
)
|
||||
)
|
||||
@ -0,0 +1,125 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_timeentry_schema(apps, schema_editor):
|
||||
"""
|
||||
Migration tolérante aux deux états possibles de la BDD :
|
||||
|
||||
Ancien état :
|
||||
date
|
||||
start_time
|
||||
end_time
|
||||
...
|
||||
|
||||
Nouvel état déjà présent :
|
||||
start_date
|
||||
start_time
|
||||
end_date
|
||||
...
|
||||
|
||||
On adapte physiquement la BDD uniquement si nécessaire.
|
||||
"""
|
||||
|
||||
connection = schema_editor.connection
|
||||
table = "time_tracking_timeentry"
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
columns = {
|
||||
column.name
|
||||
for column in connection.introspection.get_table_description(
|
||||
cursor,
|
||||
table,
|
||||
)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ANCIENNE BDD
|
||||
# date existe encore
|
||||
# ---------------------------------------------------------
|
||||
if "date" in columns and "start_date" not in columns:
|
||||
schema_editor.execute(
|
||||
f"""
|
||||
ALTER TABLE `{table}`
|
||||
CHANGE COLUMN `date` `start_date` DATE NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
columns.remove("date")
|
||||
columns.add("start_date")
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# end_date n'existe pas encore
|
||||
# On l'ajoute temporairement nullable.
|
||||
# ---------------------------------------------------------
|
||||
if "end_date" not in columns:
|
||||
schema_editor.execute(
|
||||
f"""
|
||||
ALTER TABLE `{table}`
|
||||
ADD COLUMN `end_date` DATE NULL
|
||||
AFTER `start_date`
|
||||
"""
|
||||
)
|
||||
|
||||
# Les anciennes entrées sont des entrées mono-journée.
|
||||
schema_editor.execute(
|
||||
f"""
|
||||
UPDATE `{table}`
|
||||
SET `end_date` = `start_date`
|
||||
WHERE `end_date` IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("time_tracking", "0003_timeentry_group_id"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_timeentry_schema,
|
||||
migrations.RunPython.noop,
|
||||
atomic=False,
|
||||
),
|
||||
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
|
||||
state_operations=[
|
||||
migrations.RenameField(
|
||||
model_name="timeentry",
|
||||
old_name="date",
|
||||
new_name="start_date",
|
||||
),
|
||||
|
||||
migrations.AddField(
|
||||
model_name="timeentry",
|
||||
name="end_date",
|
||||
field=models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
|
||||
migrations.AlterModelOptions(
|
||||
name="timeentry",
|
||||
options={
|
||||
"ordering": ["-start_date", "-start_time"],
|
||||
},
|
||||
),
|
||||
|
||||
migrations.AlterField(
|
||||
model_name="timeentry",
|
||||
name="start_time",
|
||||
field=models.TimeField(),
|
||||
),
|
||||
|
||||
migrations.AlterUniqueTogether(
|
||||
name="timeentry",
|
||||
unique_together=set(),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,64 @@
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Count, F
|
||||
|
||||
|
||||
def reconcile_schema(apps, schema_editor):
|
||||
"""Réconcilie la base réelle après les changements d'état seuls de 0004."""
|
||||
entry = apps.get_model('time_tracking', 'TimeEntry')
|
||||
rows = entry.objects.using(schema_editor.connection.alias)
|
||||
# Ne jamais inventer une heure ni supprimer un doublon historique.
|
||||
if rows.filter(start_time__isnull=True).exists():
|
||||
raise RuntimeError('Corrigez les pointages sans heure d’entrée avant la migration 0005.')
|
||||
if rows.values('user_id', 'start_date', 'start_time').annotate(n=Count('pk')).filter(n__gt=1).exists():
|
||||
raise RuntimeError('Corrigez les doublons de pointages avant la migration 0005.')
|
||||
rows.filter(end_date__isnull=True).update(end_date=F('start_date'))
|
||||
|
||||
connection = schema_editor.connection
|
||||
with connection.cursor() as cursor:
|
||||
columns = {
|
||||
column.name: column
|
||||
for column in connection.introspection.get_table_description(cursor, entry._meta.db_table)
|
||||
}
|
||||
constraints = connection.introspection.get_constraints(cursor, entry._meta.db_table)
|
||||
|
||||
for field_name in ('start_time', 'end_date'):
|
||||
if columns[field_name].null_ok:
|
||||
old_field = entry._meta.get_field(field_name).clone()
|
||||
old_field.set_attributes_from_name(field_name)
|
||||
old_field.model = entry
|
||||
old_field.null = True
|
||||
new_field = old_field.clone()
|
||||
new_field.set_attributes_from_name(field_name)
|
||||
new_field.model = entry
|
||||
new_field.null = False
|
||||
schema_editor.alter_field(entry, old_field, new_field, strict=True)
|
||||
|
||||
fields = ['user', 'start_date', 'start_time']
|
||||
target = 'unique_time_entry_start'
|
||||
if target not in constraints:
|
||||
schema_editor.add_constraint(entry, models.UniqueConstraint(fields=fields, name=target))
|
||||
# L'ancien unique_together a été retiré de l'état, mais peut encore exister en SQL.
|
||||
for name, constraint in constraints.items():
|
||||
if (name != target and constraint['unique']
|
||||
and constraint['columns'] == ['user_id', 'start_date', 'start_time']):
|
||||
schema_editor.remove_constraint(entry, models.UniqueConstraint(fields=fields, name=name))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
dependencies = [('time_tracking', '0004_alter_timeentry_options_and_more')]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[migrations.RunPython(reconcile_schema, atomic=False)],
|
||||
state_operations=[
|
||||
migrations.AlterField(model_name='timeentry', name='end_date', field=models.DateField()),
|
||||
migrations.AddConstraint(
|
||||
model_name='timeentry',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('user', 'start_date', 'start_time'), name='unique_time_entry_start',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -1,4 +1,3 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
@ -20,17 +19,17 @@ class TimeEntry(models.Model):
|
||||
db_index=True,
|
||||
)
|
||||
|
||||
date = models.DateField()
|
||||
start_date = models.DateField()
|
||||
|
||||
start_time = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
start_time = models.TimeField()
|
||||
|
||||
end_time = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
# Chaque ligne représente une seule journée.
|
||||
# Pour une entrée classique :
|
||||
# start_date = end_date
|
||||
#
|
||||
# Pour un pointage multi-jours :
|
||||
# une ligne par jour, toutes liées par group_id.
|
||||
end_date = models.DateField()
|
||||
|
||||
end_time = models.TimeField(
|
||||
null=True,
|
||||
@ -46,30 +45,49 @@ class TimeEntry(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-start_time']
|
||||
ordering = ['-start_date', '-start_time']
|
||||
|
||||
unique_together = [
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=[
|
||||
'user',
|
||||
'date',
|
||||
'start_date',
|
||||
'start_time',
|
||||
],
|
||||
name='unique_time_entry_start',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
if self.end_time:
|
||||
return (
|
||||
f"{self.user.username} - "
|
||||
f"{self.date} "
|
||||
f"({self.start_time} to {self.end_time})"
|
||||
f"{self.start_date} "
|
||||
f"({self.start_time} → {self.end_time})"
|
||||
)
|
||||
|
||||
return (
|
||||
f"{self.user.username} - "
|
||||
f"{self.start_date} "
|
||||
f"({self.start_time} → en cours)"
|
||||
)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
"""
|
||||
Durée de CETTE ligne.
|
||||
|
||||
Chaque TimeEntry représente une seule journée.
|
||||
Les différentes journées d'un même pointage sont reliées
|
||||
par group_id.
|
||||
"""
|
||||
if not self.start_time or not self.end_time:
|
||||
return None
|
||||
|
||||
end_date = self.end_date or self.date
|
||||
end_date = self.end_date or self.start_date
|
||||
|
||||
start = datetime.combine(
|
||||
self.date,
|
||||
self.start_date,
|
||||
self.start_time,
|
||||
)
|
||||
|
||||
@ -89,7 +107,7 @@ class TimeEntry(models.Model):
|
||||
|
||||
@property
|
||||
def day_of_week(self):
|
||||
return self.date.strftime('%A')
|
||||
return self.start_date.strftime('%A')
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
|
||||
@ -1,242 +1,69 @@
|
||||
from datetime import time as datetime_time
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from users.serializers import UserSerializer
|
||||
from .models import TimeEntry
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
from .services import save_period, validate_period
|
||||
|
||||
|
||||
class TimeEntrySerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
is_complete = serializers.ReadOnlyField()
|
||||
is_incomplete = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = TimeEntry
|
||||
|
||||
fields = [
|
||||
'id',
|
||||
'user',
|
||||
'group_id',
|
||||
'date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'duration',
|
||||
'day_of_week',
|
||||
'is_complete',
|
||||
'is_incomplete',
|
||||
'id', 'user', 'group_id', 'start_date', 'start_time', 'end_date',
|
||||
'end_time', 'created_at', 'updated_at', 'duration', 'day_of_week',
|
||||
'is_complete', 'is_incomplete',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'user',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'duration',
|
||||
'day_of_week',
|
||||
'is_complete',
|
||||
'is_incomplete',
|
||||
]
|
||||
|
||||
class TimeEntryCreateSerializer(serializers.Serializer):
|
||||
user_id = serializers.PrimaryKeyRelatedField(
|
||||
source='user', queryset=get_user_model().objects.all(), required=False,
|
||||
write_only=True,
|
||||
)
|
||||
start_date = serializers.DateField()
|
||||
start_time = serializers.TimeField()
|
||||
end_date = serializers.DateField(required=False)
|
||||
end_time = serializers.TimeField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
validate_period(attrs)
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
user = validated_data.pop('user', self.context['request'].user)
|
||||
return save_period(user, validated_data)
|
||||
|
||||
|
||||
class TimeEntryUpdateSerializer(serializers.Serializer):
|
||||
start_date = serializers.DateField()
|
||||
start_time = serializers.TimeField()
|
||||
end_date = serializers.DateField()
|
||||
end_time = serializers.TimeField(allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
entries = self.context.get('entries', [self.instance])
|
||||
first, last = entries[0], entries[-1]
|
||||
values = dict(
|
||||
start_date=first.start_date, start_time=first.start_time,
|
||||
end_date=last.end_date, end_time=last.end_time,
|
||||
)
|
||||
values.update(attrs)
|
||||
validate_period(values)
|
||||
return values
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data.pop('user', None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class TimeEntryCreateSerializer(serializers.ModelSerializer):
|
||||
user_id = serializers.PrimaryKeyRelatedField(
|
||||
source='user',
|
||||
queryset=User.objects.all(),
|
||||
write_only=True,
|
||||
required=False,
|
||||
return save_period(
|
||||
instance.user, validated_data,
|
||||
self.context.get('entries', [instance]),
|
||||
)
|
||||
|
||||
end_date = serializers.DateField(
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = TimeEntry
|
||||
|
||||
fields = [
|
||||
'user_id',
|
||||
'date',
|
||||
'end_date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
start_time = attrs.get('start_time')
|
||||
end_time = attrs.get('end_time')
|
||||
date = attrs.get('date')
|
||||
end_date = attrs.get('end_date') or date
|
||||
|
||||
if not start_time and not end_time:
|
||||
raise serializers.ValidationError(
|
||||
'Une heure d’entrée ou de sortie est requise.'
|
||||
)
|
||||
|
||||
if end_date < date:
|
||||
raise serializers.ValidationError(
|
||||
'La date de sortie doit être postérieure ou égale à la date d’entrée.'
|
||||
)
|
||||
|
||||
# Une entrée et une sortie le même jour doivent être cohérentes.
|
||||
if (
|
||||
date == end_date
|
||||
and start_time
|
||||
and end_time
|
||||
and end_time <= start_time
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
'L’heure de sortie doit être après l’entrée.'
|
||||
)
|
||||
|
||||
# Pour un pointage sur plusieurs jours, les deux extrémités
|
||||
# doivent être connues.
|
||||
if (
|
||||
date != end_date
|
||||
and (
|
||||
not start_time
|
||||
or not end_time
|
||||
)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
'Un pointage sur plusieurs jours nécessite une heure d’entrée et une heure de sortie.'
|
||||
)
|
||||
|
||||
attrs['end_date'] = end_date
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
user = validated_data.pop('user', None)
|
||||
end_date = validated_data.pop('end_date')
|
||||
|
||||
if user is None:
|
||||
user = self.context['request'].user
|
||||
|
||||
start_date = validated_data['date']
|
||||
start_time = validated_data.get('start_time')
|
||||
end_time = validated_data.get('end_time')
|
||||
|
||||
# Pointage classique sur une seule journée.
|
||||
if start_date == end_date:
|
||||
return TimeEntry.objects.create(
|
||||
user=user,
|
||||
date=start_date,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
# Pointage multi-jours :
|
||||
#
|
||||
# Exemple :
|
||||
# 20/09 14:00 -> 22/09 10:00
|
||||
#
|
||||
# 20/09 14:00 -> 23:59:59
|
||||
# 21/09 00:00 -> 23:59:59
|
||||
# 22/09 00:00 -> 10:00
|
||||
group_id = uuid4()
|
||||
|
||||
created_entries = []
|
||||
|
||||
current_date = start_date
|
||||
|
||||
with transaction.atomic():
|
||||
while current_date <= end_date:
|
||||
if current_date == start_date:
|
||||
entry_start = start_time
|
||||
entry_end = datetime_time(
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
)
|
||||
|
||||
elif current_date == end_date:
|
||||
entry_start = datetime_time.min
|
||||
entry_end = end_time
|
||||
|
||||
else:
|
||||
entry_start = datetime_time.min
|
||||
entry_end = datetime_time(
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
)
|
||||
|
||||
created_entries.append(
|
||||
TimeEntry.objects.create(
|
||||
user=user,
|
||||
group_id=group_id,
|
||||
date=current_date,
|
||||
start_time=entry_start,
|
||||
end_time=entry_end,
|
||||
)
|
||||
)
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return created_entries[0]
|
||||
|
||||
|
||||
class TimeEntryUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TimeEntry
|
||||
|
||||
fields = [
|
||||
'start_time',
|
||||
'end_time',
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
start_time = attrs.get(
|
||||
'start_time',
|
||||
self.instance.start_time,
|
||||
)
|
||||
|
||||
end_time = attrs.get(
|
||||
'end_time',
|
||||
self.instance.end_time,
|
||||
)
|
||||
|
||||
if not start_time and not end_time:
|
||||
raise serializers.ValidationError(
|
||||
'Une heure d’entrée ou de sortie est requise.'
|
||||
)
|
||||
|
||||
if (
|
||||
start_time
|
||||
and end_time
|
||||
and end_time <= start_time
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
'L’heure de sortie doit être après l’entrée.'
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class WeeklySummarySerializer(serializers.Serializer):
|
||||
user = serializers.CharField()
|
||||
username = serializers.CharField()
|
||||
total_hours = serializers.FloatField()
|
||||
entries = serializers.ListField(
|
||||
child=serializers.DictField(),
|
||||
)
|
||||
entries = serializers.ListField(child=serializers.DictField())
|
||||
|
||||
85
backend/time_tracking/services.py
Normal file
85
backend/time_tracking/services.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""Règles communes aux saisies manuelles, aux groupes et aux scans."""
|
||||
from datetime import datetime, time, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from .models import TimeEntry
|
||||
|
||||
|
||||
def validate_period(data):
|
||||
if data.get('start_time') is None:
|
||||
raise ValidationError({'start_time': "L'heure d'entrée est requise."})
|
||||
data['end_date'] = data.get('end_date') or data['start_date']
|
||||
start = datetime.combine(data['start_date'], data['start_time'])
|
||||
end = None
|
||||
if data['end_date'] < data['start_date']:
|
||||
raise ValidationError({'end_date': 'La sortie doit suivre l’entrée.'})
|
||||
if data.get('end_time') is not None:
|
||||
end = datetime.combine(data['end_date'], data['end_time'])
|
||||
if end <= start:
|
||||
raise ValidationError({'end_time': 'La sortie doit suivre l’entrée.'})
|
||||
elif data['end_date'] != data['start_date']:
|
||||
raise ValidationError({'end_time': 'Une sortie est requise pour un pointage multi-jours.'})
|
||||
return start, end
|
||||
|
||||
|
||||
def lock_user(user):
|
||||
# Verrou stable, même lorsqu'il n'existe encore aucun pointage.
|
||||
get_user_model().objects.select_for_update().get(pk=user.pk)
|
||||
|
||||
|
||||
def validate_overlap(user, data, excluded_ids=()):
|
||||
start, end = validate_period(data)
|
||||
entries = TimeEntry.objects.filter(user=user).exclude(pk__in=excluded_ids)
|
||||
entries = entries.filter(Q(end_date__gte=start.date()) | Q(end_time__isnull=True))
|
||||
if end is not None:
|
||||
entries = entries.filter(start_date__lte=end.date())
|
||||
for entry in entries:
|
||||
if entry.start_time is None:
|
||||
raise ValidationError({'detail': 'Un ancien pointage sans entrée doit être corrigé.'})
|
||||
existing_start = datetime.combine(entry.start_date, entry.start_time)
|
||||
existing_end = (
|
||||
datetime.combine(entry.end_date or entry.start_date, entry.end_time)
|
||||
if entry.end_time is not None else None
|
||||
)
|
||||
if (end is None or existing_start < end) and (existing_end is None or start < existing_end):
|
||||
raise ValidationError({'detail': 'Ce pointage chevauche un pointage existant ou une entrée ouverte.'})
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def save_period(user, data, entries=()):
|
||||
"""Enregistre un pointage logique entier, sans laisser de groupe partiel."""
|
||||
lock_user(user)
|
||||
data = dict(data)
|
||||
validate_overlap(user, data, [entry.pk for entry in entries])
|
||||
group_id = None
|
||||
if data['start_date'] != data['end_date']:
|
||||
group_id = next((entry.group_id for entry in entries if entry.group_id), None) or uuid4()
|
||||
|
||||
first = entries[0] if entries else None
|
||||
if entries:
|
||||
TimeEntry.objects.filter(pk__in=[entry.pk for entry in entries[1:]]).delete()
|
||||
current_date = data['start_date']
|
||||
created = []
|
||||
while current_date <= data['end_date']:
|
||||
values = dict(
|
||||
user=user,
|
||||
group_id=group_id,
|
||||
start_date=current_date,
|
||||
end_date=current_date,
|
||||
start_time=data['start_time'] if current_date == data['start_date'] else time.min,
|
||||
end_time=data.get('end_time') if current_date == data['end_date'] else time(23, 59, 59),
|
||||
)
|
||||
if first is not None and not created:
|
||||
for key, value in values.items():
|
||||
setattr(first, key, value)
|
||||
first.save()
|
||||
created.append(first)
|
||||
else:
|
||||
created.append(TimeEntry.objects.create(**values))
|
||||
current_date += timedelta(days=1)
|
||||
return created[0]
|
||||
169
backend/time_tracking/test_api.py
Normal file
169
backend/time_tracking/test_api.py
Normal file
@ -0,0 +1,169 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, time
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connections
|
||||
from django.test import TransactionTestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient, APITestCase
|
||||
|
||||
from .models import TimeEntry
|
||||
|
||||
|
||||
class TimeEntryAPITests(APITestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username='employee')
|
||||
self.other = get_user_model().objects.create_user(username='other')
|
||||
self.client.force_authenticate(self.user)
|
||||
self.payload = dict(start_date='2026-09-23', end_date='2026-09-23', start_time='08:00', end_time='12:00')
|
||||
|
||||
def create_entry(self, **changes):
|
||||
response = self.client.post('/api/time/entries/create/', {**self.payload, **changes}, format='json')
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
return response.data
|
||||
|
||||
def test_partial_updates_on_both_routes_and_date_changes(self):
|
||||
entry = self.create_entry()
|
||||
for suffix in ('', 'update/'):
|
||||
response = self.client.put(f'/api/time/entries/{entry["id"]}/{suffix}', {'start_time': '09:00'}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data['start_time'], '09:00:00')
|
||||
response = self.client.patch(f'/api/time/entries/{entry["id"]}/{suffix}', {'end_time': '07:00'}, format='json')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response = self.client.patch(f'/api/time/entries/{entry["id"]}/',
|
||||
{'start_date': '2026-09-24', 'end_date': '2026-09-24'}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data['start_date'], '2026-09-24')
|
||||
|
||||
def test_owner_permissions(self):
|
||||
entry = self.create_entry()
|
||||
self.client.force_authenticate(self.other)
|
||||
self.assertEqual(self.client.get('/api/time/entries/').data, [])
|
||||
self.assertEqual(self.client.patch(f'/api/time/entries/{entry["id"]}/', {'end_time': '13:00'}, format='json').status_code, 404)
|
||||
self.assertEqual(self.client.delete(f'/api/time/entries/{entry["id"]}/').status_code, 404)
|
||||
self.assertEqual(self.client.get(f'/api/time/weekly-summary/{self.user.pk}/').status_code, 403)
|
||||
response = self.client.post('/api/time/entries/create/', {**self.payload, 'user_id': self.user.pk}, format='json')
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_staff_can_create_for_employee(self):
|
||||
self.other.is_staff = True
|
||||
self.other.save()
|
||||
self.client.force_authenticate(self.other)
|
||||
entry = self.create_entry(user_id=self.user.pk)
|
||||
self.assertEqual(entry['user']['id'], self.user.pk)
|
||||
|
||||
def test_overlap_rejected_but_adjacent_period_allowed(self):
|
||||
self.create_entry()
|
||||
for changes in ({}, {'start_time': '10:00', 'end_time': '14:00'}):
|
||||
response = self.client.post('/api/time/entries/create/', {**self.payload, **changes}, format='json')
|
||||
self.assertEqual(response.status_code, 400, response.data)
|
||||
self.create_entry(start_time='12:00', end_time='14:00')
|
||||
self.assertEqual(TimeEntry.objects.count(), 2)
|
||||
|
||||
def test_group_update_delete_and_individual_route_protection(self):
|
||||
entry = self.create_entry(start_time='22:00', end_date='2026-09-25', end_time='06:00')
|
||||
url = f'/api/time/groups/{entry["group_id"]}/'
|
||||
self.assertEqual(TimeEntry.objects.count(), 3)
|
||||
self.assertEqual(self.client.patch(f'/api/time/entries/{entry["id"]}/', {'end_time': '23:00'}, format='json').status_code, 400)
|
||||
self.assertEqual(self.client.delete(f'/api/time/entries/{entry["id"]}/').status_code, 400)
|
||||
response = self.client.patch(url, {'start_time': '21:00', 'end_time': '07:00'}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
entries = list(TimeEntry.objects.order_by('start_date'))
|
||||
self.assertEqual(entries[0].start_time, time(21))
|
||||
self.assertEqual(entries[-1].end_time, time(7))
|
||||
self.client.force_authenticate(self.other)
|
||||
self.assertEqual(self.client.delete(url).status_code, 404)
|
||||
self.client.force_authenticate(self.user)
|
||||
self.assertEqual(self.client.delete(url).status_code, 204)
|
||||
self.assertFalse(TimeEntry.objects.exists())
|
||||
|
||||
def test_group_validation_does_not_partially_modify_rows(self):
|
||||
entry = self.create_entry(start_time='22:00', end_date='2026-09-24', end_time='06:00')
|
||||
self.create_entry(start_date='2026-09-24', end_date='2026-09-24', start_time='07:00', end_time='12:00')
|
||||
before = list(TimeEntry.objects.values())
|
||||
response = self.client.patch(f'/api/time/groups/{entry["group_id"]}/',
|
||||
{'start_time': '21:00', 'end_time': '08:00'}, format='json')
|
||||
self.assertEqual(response.status_code, 400, response.data)
|
||||
self.assertEqual(list(TimeEntry.objects.values()), before)
|
||||
|
||||
def test_group_write_failure_rolls_back(self):
|
||||
entry = self.create_entry(start_time='22:00', end_date='2026-09-24', end_time='06:00')
|
||||
before = list(TimeEntry.objects.values())
|
||||
with patch('time_tracking.services.TimeEntry.objects.create', side_effect=RuntimeError('simulated failure')):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.client.patch(f'/api/time/groups/{entry["group_id"]}/', {'start_time': '21:00'}, format='json')
|
||||
self.assertEqual(list(TimeEntry.objects.values()), before)
|
||||
|
||||
def test_single_entry_can_become_multiday(self):
|
||||
entry = self.create_entry()
|
||||
response = self.client.patch(f'/api/time/entries/{entry["id"]}/',
|
||||
{'end_date': '2026-09-24', 'end_time': '06:00'}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertTrue(response.data['group_id'])
|
||||
self.assertEqual(TimeEntry.objects.count(), 2)
|
||||
|
||||
def test_only_one_open_entry_allowed(self):
|
||||
self.create_entry(end_time=None)
|
||||
response = self.client.post('/api/time/entries/create/',
|
||||
{**self.payload, 'start_date': '2026-09-24', 'end_date': '2026-09-24', 'end_time': None}, format='json')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_scan_handles_previous_day_and_stale_resolution(self):
|
||||
now = timezone.make_aware(datetime(2026, 9, 24, 6))
|
||||
entry = self.create_entry(start_time='22:00', end_time=None)
|
||||
codes = {'SECRET_POINTAGE_ENTER': 'enter-test', 'SECRET_POINTAGE_EXIT': 'exit-test'}
|
||||
with patch('time_tracking.views.config', side_effect=lambda key, default='': codes.get(key, default)), patch('time_tracking.views.timezone.localtime', return_value=now):
|
||||
response = self.client.post('/api/time/entries/scan/', {'code': 'enter-test'}, format='json')
|
||||
self.assertEqual(response.status_code, 409, response.data)
|
||||
self.assertEqual(TimeEntry.objects.count(), 1)
|
||||
conflict = response.data
|
||||
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'],
|
||||
}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(TimeEntry.objects.count(), 2)
|
||||
self.assertFalse(TimeEntry.objects.filter(end_time__isnull=True).exists())
|
||||
|
||||
def test_scan_missing_entry_requires_original_scan_and_rejects_replay(self):
|
||||
now = timezone.make_aware(datetime(2026, 9, 23, 12))
|
||||
codes = {'SECRET_POINTAGE_ENTER': 'enter-test', 'SECRET_POINTAGE_EXIT': 'exit-test'}
|
||||
with patch('time_tracking.views.config', side_effect=lambda key, default='': codes.get(key, default)), patch('time_tracking.views.timezone.localtime', return_value=now):
|
||||
conflict = self.client.post('/api/time/entries/scan/', {'code': 'exit-test'}, format='json')
|
||||
self.assertEqual(conflict.status_code, 409)
|
||||
data = dict(code='exit-test', resolution='complete_missing_entry', time='08:00',
|
||||
scan_time=conflict.data['scan_time'], scan_date=conflict.data['scan_date'])
|
||||
self.assertEqual(self.client.post('/api/time/entries/scan/', data, format='json').status_code, 200)
|
||||
self.assertEqual(self.client.post('/api/time/entries/scan/', data, format='json').status_code, 400)
|
||||
self.assertEqual(TimeEntry.objects.count(), 1)
|
||||
data['scan_date'] = '2026-09-22'
|
||||
self.assertEqual(self.client.post('/api/time/entries/scan/', data, format='json').status_code, 400)
|
||||
|
||||
def test_unconfigured_scan_fails_closed(self):
|
||||
with patch('time_tracking.views.config', return_value=''):
|
||||
self.assertEqual(self.client.post('/api/time/entries/scan/', {}, format='json').status_code, 503)
|
||||
self.assertFalse(TimeEntry.objects.exists())
|
||||
|
||||
|
||||
class ConcurrentTimeEntryTests(TransactionTestCase):
|
||||
def test_concurrent_first_entries_are_serialized_per_user(self):
|
||||
user = get_user_model().objects.create_user(username='concurrent')
|
||||
ready = Barrier(2)
|
||||
|
||||
def create(start_time):
|
||||
try:
|
||||
client = APIClient()
|
||||
client.force_authenticate(user)
|
||||
ready.wait(timeout=10)
|
||||
return client.post('/api/time/entries/create/', {
|
||||
'start_date': '2026-09-23', 'start_time': start_time,
|
||||
}, format='json').status_code
|
||||
finally:
|
||||
connections.close_all()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(create, ['08:00', '08:01']))
|
||||
self.assertEqual(sorted(results), [201, 400])
|
||||
self.assertEqual(TimeEntry.objects.filter(end_time__isnull=True).count(), 1)
|
||||
@ -5,6 +5,7 @@ from .views import (
|
||||
TimeEntryScanView,
|
||||
TimeEntryDetailView,
|
||||
TimeEntryUpdateView,
|
||||
TimeEntryGroupView,
|
||||
WeeklySummaryView,
|
||||
)
|
||||
|
||||
@ -12,6 +13,7 @@ urlpatterns = [
|
||||
path('entries/', TimeEntryListView.as_view(), name='time-entry-list'),
|
||||
path('entries/create/', TimeEntryCreateView.as_view(), name='time-entry-create'),
|
||||
path('entries/scan/', TimeEntryScanView.as_view(), name='time-entry-scan'),
|
||||
path('groups/<uuid:group_id>/', TimeEntryGroupView.as_view(), name='time-entry-group'),
|
||||
path('entries/<int:entry_id>/', TimeEntryDetailView.as_view(), name='time-entry-detail'),
|
||||
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
|
||||
path('weekly-summary/', WeeklySummaryView.as_view(), name='user-weekly-summary'),
|
||||
|
||||
@ -1,783 +1,234 @@
|
||||
from datetime import timedelta
|
||||
from datetime import time as datetime_time
|
||||
|
||||
from decouple import config
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework import generics, status
|
||||
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 TimeEntry
|
||||
from .serializers import (
|
||||
TimeEntryCreateSerializer,
|
||||
TimeEntrySerializer,
|
||||
TimeEntryUpdateSerializer,
|
||||
WeeklySummarySerializer,
|
||||
TimeEntryCreateSerializer, TimeEntrySerializer,
|
||||
TimeEntryUpdateSerializer, WeeklySummarySerializer,
|
||||
)
|
||||
|
||||
from .services import lock_user, save_period
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class TimeEntryListView(generics.ListAPIView):
|
||||
serializer_class = TimeEntrySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
|
||||
if user.is_staff or user.is_superuser:
|
||||
return TimeEntry.objects.all()
|
||||
|
||||
return TimeEntry.objects.filter(
|
||||
user=user,
|
||||
)
|
||||
return visible_entries(self.request.user)
|
||||
|
||||
|
||||
class TimeEntryCreateView(generics.CreateAPIView):
|
||||
serializer_class = TimeEntryCreateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
requested_user = serializer.validated_data.get('user')
|
||||
|
||||
if requested_user is not None:
|
||||
if not (
|
||||
self.request.user.is_staff
|
||||
or self.request.user.is_superuser
|
||||
):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
raise PermissionDenied(
|
||||
"Vous ne pouvez pas créer un pointage pour un autre utilisateur."
|
||||
)
|
||||
|
||||
self.created_entry = serializer.save(
|
||||
user=requested_user,
|
||||
)
|
||||
|
||||
else:
|
||||
self.created_entry = serializer.save(
|
||||
user=self.request.user,
|
||||
)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(
|
||||
data=request.data,
|
||||
)
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.validated_data.get('user', request.user)
|
||||
if user != request.user and not (request.user.is_staff or request.user.is_superuser):
|
||||
raise PermissionDenied('Vous ne pouvez pas créer un pointage pour un autre utilisateur.')
|
||||
entry = serializer.save(user=user)
|
||||
return Response(TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
serializer.is_valid(
|
||||
raise_exception=True,
|
||||
)
|
||||
|
||||
self.perform_create(serializer)
|
||||
|
||||
output_serializer = TimeEntrySerializer(
|
||||
self.created_entry,
|
||||
context={
|
||||
'request': request,
|
||||
},
|
||||
)
|
||||
|
||||
headers = self.get_success_headers(
|
||||
serializer.data,
|
||||
)
|
||||
|
||||
return Response(
|
||||
output_serializer.data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
class TimeEntryScanView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def _parse_code(self, raw_code):
|
||||
code = str(
|
||||
raw_code or ''
|
||||
).strip().strip('"\'"')
|
||||
|
||||
if '=' in code:
|
||||
code = (
|
||||
code
|
||||
.split('=', 1)[1]
|
||||
.strip()
|
||||
.strip('"\'"')
|
||||
)
|
||||
|
||||
return code
|
||||
|
||||
def _parse_time(self, value):
|
||||
if not value:
|
||||
now = timezone.localtime()
|
||||
|
||||
return now.time().replace(
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
|
||||
try:
|
||||
return datetime_time.fromisoformat(
|
||||
str(value)[:5],
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _serialize_entry(self, entry, request):
|
||||
return TimeEntrySerializer(
|
||||
entry,
|
||||
context={
|
||||
'request': request,
|
||||
},
|
||||
).data
|
||||
|
||||
def post(self, request):
|
||||
code = self._parse_code(
|
||||
request.data.get('code'),
|
||||
)
|
||||
|
||||
enter_code = config(
|
||||
'SECRET_POINTAGE_ENTER',
|
||||
default='',
|
||||
).strip().strip('"\'"')
|
||||
|
||||
exit_code = config(
|
||||
'SECRET_POINTAGE_EXIT',
|
||||
default='',
|
||||
).strip().strip('"\'"')
|
||||
|
||||
if code == enter_code:
|
||||
action = 'enter'
|
||||
|
||||
elif code == exit_code:
|
||||
action = 'exit'
|
||||
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
'error': 'QR code de pointage invalide.'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
selected_time = self._parse_time(
|
||||
request.data.get('time'),
|
||||
)
|
||||
|
||||
if selected_time is None:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Heure invalide.'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
resolution = request.data.get(
|
||||
'resolution',
|
||||
)
|
||||
|
||||
# Heure réelle du scan.
|
||||
#
|
||||
# Lors d'une résolution d'un conflit "sortie sans entrée",
|
||||
# on utilise scan_time comme heure de sortie et "time"
|
||||
# comme heure d'entrée.
|
||||
scan_time_value = request.data.get(
|
||||
'scan_time',
|
||||
)
|
||||
|
||||
scan_time = self._parse_time(
|
||||
scan_time_value,
|
||||
)
|
||||
|
||||
if scan_time is None:
|
||||
scan_time = selected_time
|
||||
|
||||
now = timezone.localtime()
|
||||
today = now.date()
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
# =====================================================
|
||||
# RÉSOLUTION : entrée déjà ouverte
|
||||
# =====================================================
|
||||
|
||||
if resolution == 'complete_exit':
|
||||
open_entry = (
|
||||
TimeEntry.objects
|
||||
.select_for_update()
|
||||
.filter(
|
||||
user=request.user,
|
||||
start_time__isnull=False,
|
||||
end_time__isnull=True,
|
||||
)
|
||||
.order_by(
|
||||
'-date',
|
||||
'-start_time',
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not open_entry:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Aucune entrée ouverte à compléter.'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if selected_time <= open_entry.start_time:
|
||||
return Response(
|
||||
{
|
||||
'error': 'L’heure de sortie doit être après l’entrée.'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
open_entry.end_time = selected_time
|
||||
|
||||
open_entry.save(
|
||||
update_fields=[
|
||||
'end_time',
|
||||
'updated_at',
|
||||
],
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'action': 'exit',
|
||||
'message': 'Sortie enregistrée.',
|
||||
'entry': self._serialize_entry(
|
||||
open_entry,
|
||||
request,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# RÉSOLUTION : sortie scannée sans entrée
|
||||
# =====================================================
|
||||
|
||||
if resolution == 'complete_missing_entry':
|
||||
missing_entry_time = selected_time
|
||||
exit_time = scan_time
|
||||
|
||||
if missing_entry_time >= exit_time:
|
||||
return Response(
|
||||
{
|
||||
'error': "L'heure d'entrée doit être avant l'heure de sortie."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
entry = TimeEntry.objects.create(
|
||||
user=request.user,
|
||||
date=today,
|
||||
start_time=missing_entry_time,
|
||||
end_time=exit_time,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'action': 'exit',
|
||||
'message': 'Entrée et sortie enregistrées.',
|
||||
'entry': self._serialize_entry(
|
||||
entry,
|
||||
request,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# RÉSOLUTION : sortie existante sans entrée
|
||||
# =====================================================
|
||||
|
||||
if resolution == 'complete_open_exit':
|
||||
open_exit = (
|
||||
TimeEntry.objects
|
||||
.select_for_update()
|
||||
.filter(
|
||||
user=request.user,
|
||||
start_time__isnull=True,
|
||||
end_time__isnull=False,
|
||||
)
|
||||
.order_by(
|
||||
'-date',
|
||||
'-end_time',
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not open_exit:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Aucune sortie incomplète à compléter.'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if selected_time >= open_exit.end_time:
|
||||
return Response(
|
||||
{
|
||||
'error': "L'heure d'entrée doit être avant la sortie."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
open_exit.start_time = selected_time
|
||||
|
||||
open_exit.save(
|
||||
update_fields=[
|
||||
'start_time',
|
||||
'updated_at',
|
||||
],
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'action': 'enter',
|
||||
'message': 'Entrée enregistrée.',
|
||||
'entry': self._serialize_entry(
|
||||
open_exit,
|
||||
request,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# POINTAGE EXISTANT DU JOUR
|
||||
# =====================================================
|
||||
|
||||
open_entry = (
|
||||
TimeEntry.objects
|
||||
.select_for_update()
|
||||
.filter(
|
||||
user=request.user,
|
||||
date=today,
|
||||
start_time__isnull=False,
|
||||
end_time__isnull=True,
|
||||
)
|
||||
.order_by(
|
||||
'-start_time',
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
open_exit = (
|
||||
TimeEntry.objects
|
||||
.select_for_update()
|
||||
.filter(
|
||||
user=request.user,
|
||||
date=today,
|
||||
start_time__isnull=True,
|
||||
end_time__isnull=False,
|
||||
)
|
||||
.order_by(
|
||||
'-end_time',
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# QR ENTRÉE
|
||||
# =====================================================
|
||||
|
||||
if action == 'enter':
|
||||
|
||||
# Une entrée est déjà ouverte.
|
||||
#
|
||||
# IMPORTANT :
|
||||
# on ne supprime plus l'entrée si l'utilisateur
|
||||
# refuse. Le frontend demande d'abord confirmation.
|
||||
if open_entry:
|
||||
return Response(
|
||||
{
|
||||
'error': (
|
||||
'Une entrée est déjà ouverte. '
|
||||
'Voulez-vous enregistrer une sortie ?'
|
||||
),
|
||||
'conflict': 'open_entry',
|
||||
'resolution': 'complete_exit',
|
||||
'missing_time': 'end_time',
|
||||
'message': (
|
||||
'Une entrée est déjà ouverte. '
|
||||
'Voulez-vous enregistrer une sortie ?'
|
||||
),
|
||||
'scan_time': selected_time.strftime(
|
||||
'%H:%M'
|
||||
),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
# Une sortie existe sans entrée.
|
||||
if open_exit:
|
||||
return Response(
|
||||
{
|
||||
'error': (
|
||||
'Une sortie existe déjà sans entrée.'
|
||||
),
|
||||
'conflict': 'open_exit',
|
||||
'resolution': 'complete_open_exit',
|
||||
'missing_time': 'start_time',
|
||||
'message': (
|
||||
'Une sortie existe déjà sans entrée. '
|
||||
'Voulez-vous renseigner l’heure d’entrée ?'
|
||||
),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
entry = TimeEntry.objects.create(
|
||||
user=request.user,
|
||||
date=today,
|
||||
start_time=selected_time,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'action': 'enter',
|
||||
'message': 'Entrée enregistrée.',
|
||||
'entry': self._serialize_entry(
|
||||
entry,
|
||||
request,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# QR SORTIE
|
||||
# =====================================================
|
||||
|
||||
# Entrée ouverte aujourd'hui :
|
||||
# sortie normale.
|
||||
if open_entry:
|
||||
if selected_time <= open_entry.start_time:
|
||||
return Response(
|
||||
{
|
||||
'error': (
|
||||
"L'heure de sortie doit être "
|
||||
"après l'entrée."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
open_entry.end_time = selected_time
|
||||
|
||||
open_entry.save(
|
||||
update_fields=[
|
||||
'end_time',
|
||||
'updated_at',
|
||||
],
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'action': 'exit',
|
||||
'message': 'Sortie enregistrée.',
|
||||
'entry': self._serialize_entry(
|
||||
open_entry,
|
||||
request,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# SORTIE SANS ENTRÉE
|
||||
# =====================================================
|
||||
|
||||
if open_exit:
|
||||
return Response(
|
||||
{
|
||||
'error': (
|
||||
'Une sortie existe déjà sans entrée.'
|
||||
),
|
||||
'conflict': 'open_exit',
|
||||
'resolution': 'complete_open_exit',
|
||||
'missing_time': 'start_time',
|
||||
'message': (
|
||||
'Une sortie existe déjà sans entrée. '
|
||||
'Voulez-vous renseigner l’heure d’entrée ?'
|
||||
),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# POINTAGE OUVERT SUR UN JOUR PRÉCÉDENT
|
||||
# =====================================================
|
||||
|
||||
previous_open_entry = (
|
||||
TimeEntry.objects
|
||||
.select_for_update()
|
||||
.filter(
|
||||
user=request.user,
|
||||
start_time__isnull=False,
|
||||
end_time__isnull=True,
|
||||
)
|
||||
.exclude(
|
||||
date=today,
|
||||
)
|
||||
.order_by(
|
||||
'-date',
|
||||
'-start_time',
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if previous_open_entry:
|
||||
last_date = previous_open_entry.date
|
||||
|
||||
group_id = (
|
||||
previous_open_entry.group_id
|
||||
or previous_open_entry.id
|
||||
)
|
||||
|
||||
# Si l'ancien pointage n'avait pas encore de groupe,
|
||||
# on lui attribue un UUID basé sur un nouvel objet.
|
||||
if not previous_open_entry.group_id:
|
||||
import uuid
|
||||
|
||||
group_id = uuid.uuid4()
|
||||
|
||||
previous_open_entry.group_id = group_id
|
||||
|
||||
previous_open_entry.end_time = datetime_time(
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
)
|
||||
|
||||
previous_open_entry.save(
|
||||
update_fields=[
|
||||
'group_id',
|
||||
'end_time',
|
||||
'updated_at',
|
||||
],
|
||||
)
|
||||
|
||||
current_date = (
|
||||
last_date
|
||||
+ timedelta(days=1)
|
||||
)
|
||||
|
||||
while current_date < today:
|
||||
TimeEntry.objects.create(
|
||||
user=request.user,
|
||||
group_id=group_id,
|
||||
date=current_date,
|
||||
start_time=datetime_time.min,
|
||||
end_time=datetime_time(
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
),
|
||||
)
|
||||
|
||||
current_date += timedelta(
|
||||
days=1,
|
||||
)
|
||||
|
||||
entry = TimeEntry.objects.create(
|
||||
user=request.user,
|
||||
group_id=group_id,
|
||||
date=today,
|
||||
end_time=selected_time,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'action': 'exit',
|
||||
'message': 'Sortie enregistrée sur plusieurs jours.',
|
||||
'entry': self._serialize_entry(
|
||||
entry,
|
||||
request,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# AUCUNE ENTRÉE
|
||||
#
|
||||
# On NE crée rien immédiatement.
|
||||
# Le frontend demande d'abord confirmation.
|
||||
# =====================================================
|
||||
|
||||
return Response(
|
||||
{
|
||||
'error': (
|
||||
'Aucune entrée ouverte. '
|
||||
'Voulez-vous enregistrer une sortie ?'
|
||||
),
|
||||
'conflict': 'missing_entry',
|
||||
'resolution': 'complete_missing_entry',
|
||||
'missing_time': 'start_time',
|
||||
'message': (
|
||||
'Aucune entrée ouverte. '
|
||||
'Voulez-vous enregistrer une sortie ?'
|
||||
),
|
||||
'scan_time': selected_time.strftime(
|
||||
'%H:%M'
|
||||
),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
|
||||
class TimeEntryDetailView(
|
||||
generics.RetrieveUpdateDestroyAPIView
|
||||
):
|
||||
class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = TimeEntrySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_url_kwarg = 'entry_id'
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return visible_entries(self.request.user)
|
||||
|
||||
if user.is_staff or user.is_superuser:
|
||||
return TimeEntry.objects.all()
|
||||
|
||||
return TimeEntry.objects.filter(
|
||||
user=user,
|
||||
@transaction.atomic
|
||||
def update(self, request, *args, **kwargs):
|
||||
entry = self.get_object()
|
||||
lock_user(entry.user)
|
||||
entry = self.get_queryset().select_for_update().get(pk=entry.pk)
|
||||
if entry.group_id:
|
||||
raise ValidationError({'detail': 'Modifiez le pointage entier avec la route groups.'})
|
||||
serializer = TimeEntryUpdateSerializer(
|
||||
entry, data=request.data, partial=True,
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = serializer.save()
|
||||
return Response(TimeEntrySerializer(result).data)
|
||||
|
||||
@transaction.atomic
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
entry = self.get_object()
|
||||
lock_user(entry.user)
|
||||
entry = self.get_queryset().select_for_update().get(pk=entry.pk)
|
||||
if entry.group_id:
|
||||
raise ValidationError({'detail': 'Supprimez le pointage entier avec la route groups.'})
|
||||
entry.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class TimeEntryUpdateView(APIView):
|
||||
class TimeEntryUpdateView(TimeEntryDetailView):
|
||||
# Alias conservé pour les anciens clients, avec les mêmes validations.
|
||||
http_method_names = ['put', 'patch', 'options']
|
||||
|
||||
|
||||
class TimeEntryGroupView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def put(self, request, entry_id):
|
||||
try:
|
||||
entry = TimeEntry.objects.get(
|
||||
id=entry_id,
|
||||
)
|
||||
|
||||
except TimeEntry.DoesNotExist:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Pointage non trouvé'
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not (
|
||||
request.user.is_staff
|
||||
or request.user.is_superuser
|
||||
or entry.user == request.user
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
'error': 'Accès refusé'
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
def get_entries(self, request, group_id):
|
||||
queryset = visible_entries(request.user).filter(group_id=group_id)
|
||||
first = queryset.order_by('start_date', 'start_time').first()
|
||||
if first is None:
|
||||
from rest_framework.exceptions import NotFound
|
||||
raise NotFound()
|
||||
lock_user(first.user)
|
||||
entries = list(queryset.filter(user=first.user).select_for_update().order_by('start_date', 'start_time'))
|
||||
if not entries:
|
||||
from rest_framework.exceptions import NotFound
|
||||
raise NotFound()
|
||||
return entries
|
||||
|
||||
@transaction.atomic
|
||||
def patch(self, request, group_id):
|
||||
entries = self.get_entries(request, group_id)
|
||||
serializer = TimeEntryUpdateSerializer(
|
||||
entry,
|
||||
data=request.data,
|
||||
entries[0], data=request.data, partial=True,
|
||||
context={'entries': entries},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = serializer.save()
|
||||
return Response(TimeEntrySerializer(result).data)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
put = patch
|
||||
|
||||
return Response(
|
||||
serializer.data,
|
||||
)
|
||||
@transaction.atomic
|
||||
def delete(self, request, group_id):
|
||||
entries = self.get_entries(request, group_id)
|
||||
TimeEntry.objects.filter(pk__in=[entry.pk for entry in entries]).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
return Response(
|
||||
serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
class TimeEntryScanView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def parse_time(self, value, default):
|
||||
if value is None:
|
||||
return default
|
||||
from rest_framework import serializers
|
||||
return serializers.TimeField(input_formats=['%H:%M', '%H:%M:%S']).run_validation(value)
|
||||
|
||||
def conflict(self, kind, resolution, message, selected_time, today, entry=None):
|
||||
return Response({
|
||||
'error': message, 'message': message, 'conflict': kind,
|
||||
'resolution': resolution,
|
||||
'missing_time': 'end_time' if kind == 'open_entry' else 'start_time',
|
||||
'scan_time': selected_time.strftime('%H:%M'),
|
||||
'scan_date': today.isoformat(),
|
||||
'entry_id': entry.pk if entry else None,
|
||||
}, status=status.HTTP_409_CONFLICT)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
code = str(request.data.get('code') or '').strip().strip('\"\'')
|
||||
if '=' in code:
|
||||
code = code.split('=', 1)[1].strip().strip('\"\'')
|
||||
enter_code = config('SECRET_POINTAGE_ENTER', default='').strip().strip('\"\'')
|
||||
exit_code = config('SECRET_POINTAGE_EXIT', default='').strip().strip('\"\'')
|
||||
if not enter_code or not exit_code or enter_code == exit_code:
|
||||
return Response({'error': 'Les codes de pointage ne sont pas configurés.'}, status=503)
|
||||
if code not in (enter_code, exit_code):
|
||||
raise ValidationError({'error': 'QR code de pointage invalide.'})
|
||||
action = 'enter' if code == enter_code else 'exit'
|
||||
now = timezone.localtime()
|
||||
today = now.date()
|
||||
selected_time = self.parse_time(request.data.get('time'), now.time().replace(second=0, microsecond=0))
|
||||
resolution = request.data.get('resolution')
|
||||
if resolution not in (None, 'complete_exit', 'complete_missing_entry'):
|
||||
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.'})
|
||||
|
||||
lock_user(request.user)
|
||||
open_entries = list(TimeEntry.objects.select_for_update().filter(
|
||||
user=request.user, end_time__isnull=True,
|
||||
).order_by('start_date', 'start_time'))
|
||||
if len(open_entries) > 1:
|
||||
raise ValidationError({'error': 'Plusieurs entrées sont ouvertes. Corrigez les pointages avant de scanner.'})
|
||||
open_entry = open_entries[0] if open_entries else None
|
||||
|
||||
if resolution == 'complete_missing_entry':
|
||||
if action != 'exit' or open_entry:
|
||||
raise ValidationError({'error': 'La situation a changé. Scannez à nouveau le code.'})
|
||||
scan_time = self.parse_time(request.data.get('scan_time'), None)
|
||||
if scan_time is None:
|
||||
raise ValidationError({'scan_time': 'L’heure de sortie scannée est requise.'})
|
||||
entry = save_period(request.user, dict(
|
||||
start_date=today, end_date=today,
|
||||
start_time=selected_time, end_time=scan_time,
|
||||
))
|
||||
action = 'exit'
|
||||
elif resolution == 'complete_exit' or action == 'exit':
|
||||
if not open_entry:
|
||||
if resolution:
|
||||
raise ValidationError({'error': 'Aucune entrée ouverte à compléter.'})
|
||||
return self.conflict('missing_entry', 'complete_missing_entry',
|
||||
'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.'})
|
||||
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,
|
||||
), [open_entry])
|
||||
action = 'exit'
|
||||
else:
|
||||
if open_entry:
|
||||
return self.conflict('open_entry', 'complete_exit',
|
||||
'Une entrée est déjà ouverte. Voulez-vous enregistrer une sortie ?',
|
||||
selected_time, today, open_entry)
|
||||
entry = save_period(request.user, dict(
|
||||
start_date=today, end_date=today, start_time=selected_time, end_time=None,
|
||||
))
|
||||
return Response({
|
||||
'action': action,
|
||||
'message': 'Entrée enregistrée.' if action == 'enter' else 'Sortie enregistrée.',
|
||||
'entry': TimeEntrySerializer(entry).data,
|
||||
})
|
||||
|
||||
|
||||
def build_weekly_summary(user):
|
||||
today = timezone.localdate()
|
||||
|
||||
start_of_week = (
|
||||
today
|
||||
- timedelta(
|
||||
days=today.weekday(),
|
||||
)
|
||||
)
|
||||
|
||||
end_of_week = (
|
||||
start_of_week
|
||||
+ timedelta(days=6)
|
||||
)
|
||||
|
||||
entries = list(
|
||||
TimeEntry.objects.filter(
|
||||
user=user,
|
||||
date__range=(
|
||||
start_of_week,
|
||||
end_of_week,
|
||||
),
|
||||
).order_by(
|
||||
'date',
|
||||
'start_time',
|
||||
)
|
||||
)
|
||||
|
||||
entries_by_date = {}
|
||||
|
||||
for entry in entries:
|
||||
entries_by_date.setdefault(
|
||||
entry.date,
|
||||
entry,
|
||||
)
|
||||
|
||||
start_of_week = today - timedelta(days=today.weekday())
|
||||
entries = list(TimeEntry.objects.filter(
|
||||
user=user, start_date__range=(start_of_week, start_of_week + timedelta(days=6)),
|
||||
).order_by('start_date', 'start_time'))
|
||||
days_data = []
|
||||
|
||||
for day_offset in range(7):
|
||||
current_day = (
|
||||
start_of_week
|
||||
+ timedelta(
|
||||
days=day_offset,
|
||||
)
|
||||
)
|
||||
|
||||
entry = entries_by_date.get(
|
||||
current_day,
|
||||
)
|
||||
|
||||
days_data.append(
|
||||
{
|
||||
'date': current_day.isoformat(),
|
||||
'day': current_day.strftime('%A'),
|
||||
'start_time': (
|
||||
str(entry.start_time)
|
||||
if entry and entry.start_time
|
||||
else None
|
||||
),
|
||||
'end_time': (
|
||||
str(entry.end_time)
|
||||
if entry and entry.end_time
|
||||
else None
|
||||
),
|
||||
'duration': (
|
||||
entry.duration
|
||||
if entry
|
||||
else 0
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'user': user.username,
|
||||
'username': user.username,
|
||||
'total_hours': round(
|
||||
sum(
|
||||
entry.duration or 0
|
||||
for entry in entries
|
||||
),
|
||||
2,
|
||||
),
|
||||
'entries': days_data,
|
||||
}
|
||||
for offset in range(7):
|
||||
day = start_of_week + timedelta(days=offset)
|
||||
day_entries = [entry for entry in entries if entry.start_date == day]
|
||||
completed = [entry for entry in day_entries if entry.end_time is not None]
|
||||
days_data.append({
|
||||
'date': day.isoformat(), 'day': day.strftime('%A'),
|
||||
'start_time': str(day_entries[0].start_time) if day_entries else None,
|
||||
'end_time': str(max(entry.end_time for entry in completed)) if completed and len(completed) == len(day_entries) else None,
|
||||
'duration': sum(entry.duration or 0 for entry in day_entries),
|
||||
'entries_count': len(day_entries),
|
||||
})
|
||||
return dict(user=user.username, username=user.username,
|
||||
total_hours=round(sum(entry.duration or 0 for entry in entries), 2), entries=days_data)
|
||||
|
||||
|
||||
class WeeklySummaryView(APIView):
|
||||
@ -785,31 +236,8 @@ class WeeklySummaryView(APIView):
|
||||
|
||||
def get(self, request, user_id=None):
|
||||
user = request.user
|
||||
|
||||
if (
|
||||
user_id
|
||||
and (
|
||||
request.user.is_staff
|
||||
or request.user.is_superuser
|
||||
)
|
||||
):
|
||||
try:
|
||||
user = User.objects.get(
|
||||
id=user_id,
|
||||
)
|
||||
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Utilisateur non trouvé'
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = WeeklySummarySerializer(
|
||||
build_weekly_summary(user),
|
||||
)
|
||||
|
||||
return Response(
|
||||
serializer.data,
|
||||
)
|
||||
if user_id is not None and user_id != user.pk:
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
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)
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
@ -11,6 +13,7 @@ User = get_user_model()
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
is_admin = serializers.SerializerMethodField()
|
||||
color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@ -32,6 +35,20 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
def get_is_admin(self, obj):
|
||||
return obj.is_superuser or obj.is_staff
|
||||
|
||||
def validate(self, attrs):
|
||||
actor = self.context['request'].user
|
||||
if not actor.is_superuser:
|
||||
if self.instance.is_superuser:
|
||||
raise serializers.ValidationError('Seul un superutilisateur peut modifier ce compte.')
|
||||
if attrs.get('is_superuser', False):
|
||||
raise serializers.ValidationError({'is_superuser': 'Seul un superutilisateur peut accorder ce rôle.'})
|
||||
if self.instance.pk == actor.pk:
|
||||
if not attrs.get('is_active', self.instance.is_active):
|
||||
raise serializers.ValidationError({'is_active': 'Vous ne pouvez pas désactiver votre propre compte.'})
|
||||
if not (attrs.get('is_staff', self.instance.is_staff) or attrs.get('is_superuser', self.instance.is_superuser)):
|
||||
raise serializers.ValidationError('Vous ne pouvez pas retirer votre propre accès administrateur.')
|
||||
return attrs
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True)
|
||||
@ -57,6 +74,11 @@ class RegisterSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
})
|
||||
|
||||
candidate = User(**{field: attrs.get(field, '') for field in ('username', 'first_name', 'last_name')})
|
||||
try:
|
||||
validate_password(attrs['password'], candidate)
|
||||
except DjangoValidationError as error:
|
||||
raise serializers.ValidationError({'password': error.messages}) from error
|
||||
return attrs
|
||||
|
||||
def validate_invite(self, value):
|
||||
|
||||
@ -2,7 +2,7 @@ from rest_framework import generics
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from .models import Invitation, User
|
||||
@ -48,6 +48,13 @@ class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.pk == self.request.user.pk:
|
||||
raise ValidationError({'error': 'Vous ne pouvez pas supprimer votre propre compte.'})
|
||||
if instance.is_superuser and not self.request.user.is_superuser:
|
||||
raise PermissionDenied('Seul un superutilisateur peut supprimer ce compte.')
|
||||
instance.delete()
|
||||
|
||||
|
||||
class UserPromoteView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
@ -58,6 +65,8 @@ class UserPromoteView(APIView):
|
||||
|
||||
try:
|
||||
user_obj = User.objects.get(id=user)
|
||||
if user_obj.is_superuser and not request.user.is_superuser:
|
||||
raise PermissionDenied('Seul un superutilisateur peut modifier ce compte.')
|
||||
user_obj.is_staff = True
|
||||
user_obj.save()
|
||||
return Response({'message': 'Utilisateur promu avec succès'}, status=200)
|
||||
@ -65,23 +74,8 @@ class UserPromoteView(APIView):
|
||||
return Response({'error': 'Utilisateur non trouvé'}, status=404)
|
||||
|
||||
|
||||
class UserDeleteView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def delete(self, request, user):
|
||||
if not (request.user.is_staff or request.user.is_superuser):
|
||||
raise PermissionDenied('Seuls les administrateurs peuvent supprimer des utilisateurs.')
|
||||
|
||||
try:
|
||||
user_obj = User.objects.get(id=user)
|
||||
# Ne pas permettre de supprimer l'utilisateur courant
|
||||
if user_obj == request.user:
|
||||
return Response({'error': 'Vous ne pouvez pas supprimer votre propre compte'}, status=400)
|
||||
|
||||
user_obj.delete()
|
||||
return Response({'message': 'Utilisateur supprimé avec succès'}, status=200)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'Utilisateur non trouvé'}, status=404)
|
||||
class UserDeleteView(UserDetailView):
|
||||
http_method_names = ['delete', 'options']
|
||||
|
||||
|
||||
class InvitationCreateView(APIView):
|
||||
@ -98,4 +92,3 @@ class InvitationCreateView(APIView):
|
||||
'token': str(invitation.token),
|
||||
'expires_at': invitation.expires_at,
|
||||
}, status=201)
|
||||
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "oxlint",
|
||||
"lint": "oxlint src vite.config.js",
|
||||
"test": "node --test tests/*.test.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -13,14 +13,17 @@ import {
|
||||
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import MyspacePage from './pages/MyspacePage';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import AdminRoute from './components/AdminRoute';
|
||||
import UnloggedRoute from './components/UnloggedRoute';
|
||||
import NavBar from './components/NavBar';
|
||||
import AdminUsersPage from './pages/AdminUsersPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import TimeClockPage from './pages/TimeClockPage';
|
||||
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
||||
import CalendarPage from './pages/CalendarPage';
|
||||
import NotesPage from './pages/NotesPage';
|
||||
import { useAuth } from './context/useAuth';
|
||||
|
||||
// Composant personnalisé pour le trigger mobile avec icône menu
|
||||
@ -65,12 +68,20 @@ function App() {
|
||||
|
||||
<Route
|
||||
path="/login"
|
||||
element={<LoginPage />}
|
||||
element={
|
||||
<UnloggedRoute>
|
||||
<LoginPage />
|
||||
</UnloggedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/register"
|
||||
element={<RegisterPage />}
|
||||
element={
|
||||
<UnloggedRoute>
|
||||
<RegisterPage />
|
||||
</UnloggedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
@ -82,6 +93,15 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/myspace"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MyspacePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@ -123,6 +143,8 @@ function App() {
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
|
||||
<Route path="/notes" element={<ProtectedRoute><NotesPage /></ProtectedRoute>} />
|
||||
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@ -142,3 +164,4 @@ function App() {
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@ -1,46 +1,76 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const apiHost = window.location.hostname;
|
||||
import { getApiErrorMessage } from './errors.js';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.PROD ? '/api' : `http://${apiHost}:1029/api`,
|
||||
baseURL: import.meta.env?.PROD ? '/api' : `http://${window.location.hostname}:1029/api`,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
});
|
||||
const refreshClient = axios.create({ baseURL: api.defaults.baseURL });
|
||||
let refreshPromise = null;
|
||||
|
||||
// Ajouter un intercepteur pour gérer les erreurs de réseau
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
console.error('Connexion refusée : le serveur backend n\'est pas accessible');
|
||||
} else if (error.response && error.response.status === 401) {
|
||||
// Token expired or invalid
|
||||
export function clearSession() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh');
|
||||
localStorage.removeItem('user');
|
||||
// Rediriger vers la page de login
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.dispatchEvent(new Event('auth:logout'));
|
||||
}
|
||||
|
||||
function expireSession() {
|
||||
clearSession();
|
||||
if (!['/login', '/register'].includes(window.location.pathname)) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Ajouter un intercepteur pour les requêtes
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
function refreshAccessToken() {
|
||||
if (!refreshPromise) {
|
||||
const refresh = localStorage.getItem('refresh');
|
||||
refreshPromise = refreshClient.post('/auth/refresh/', { refresh }).then(({ data }) => {
|
||||
// Une déconnexion ou une nouvelle connexion peut avoir eu lieu pendant la requête.
|
||||
if (localStorage.getItem('refresh') !== refresh) {
|
||||
throw new Error('La session a changé.');
|
||||
}
|
||||
localStorage.setItem('token', data.access);
|
||||
if (data.refresh) localStorage.setItem('refresh', data.refresh);
|
||||
return data.access;
|
||||
}).catch((error) => {
|
||||
if ([400, 401].includes(error.response?.status) && localStorage.getItem('refresh') === refresh) {
|
||||
expireSession();
|
||||
}
|
||||
throw error;
|
||||
}).finally(() => { refreshPromise = null; });
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const publicRequest = ['/auth/login/', '/users/register/'].includes(config.url);
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
// Ajouter un header pour les requêtes CORS
|
||||
config.headers['Content-Type'] = 'application/json';
|
||||
config.headers['Accept'] = 'application/json';
|
||||
config._publicRequest = publicRequest;
|
||||
if (token && !publicRequest) config.headers.Authorization = `Bearer ${token}`;
|
||||
else delete config.headers.Authorization;
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
error.message = getApiErrorMessage(error);
|
||||
const request = error.config;
|
||||
if (error.response?.status !== 401 || !request || request._publicRequest) throw error;
|
||||
if (request._retry || !localStorage.getItem('refresh')) {
|
||||
expireSession();
|
||||
throw error;
|
||||
}
|
||||
request._retry = true;
|
||||
const currentToken = localStorage.getItem('token');
|
||||
const sentToken = request.headers.Authorization;
|
||||
// Une autre requête a peut-être déjà renouvelé la session.
|
||||
const access = currentToken && sentToken !== `Bearer ${currentToken}`
|
||||
? currentToken : await refreshAccessToken();
|
||||
request.headers.Authorization = `Bearer ${access}`;
|
||||
return api(request);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
|
||||
12
frontend/src/api/errors.js
Normal file
12
frontend/src/api/errors.js
Normal file
@ -0,0 +1,12 @@
|
||||
export function getApiErrorMessage(error, fallback = 'La requête a échoué.') {
|
||||
const flatten = (value, prefix = '') => {
|
||||
if (Array.isArray(value)) return value.flatMap((item) => flatten(item, prefix));
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value).flatMap(([key, item]) =>
|
||||
flatten(item, ['detail', 'error', 'non_field_errors'].includes(key) ? prefix : key),
|
||||
);
|
||||
}
|
||||
return typeof value === 'string' ? [`${prefix ? `${prefix} : ` : ''}${value}`] : [];
|
||||
};
|
||||
return flatten(error.response?.data).join(' ') || error.message || fallback;
|
||||
}
|
||||
@ -1,58 +1,9 @@
|
||||
import axios from './axios';
|
||||
import api from './axios';
|
||||
|
||||
export const eventApi = {
|
||||
// Récupérer tous les événements
|
||||
getAllEvents: async () => {
|
||||
try {
|
||||
const response = await axios.get('/event/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Créer un nouvel événement
|
||||
createEvent: async (eventData) => {
|
||||
try {
|
||||
const response = await axios.post('/event/create/', eventData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Mettre à jour un événement
|
||||
updateEvent: async (eventId, eventData) => {
|
||||
try {
|
||||
const response = await axios.put(`/event/${eventId}/`, eventData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error updating event:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Supprimer un événement
|
||||
deleteEvent: async (eventId) => {
|
||||
try {
|
||||
const response = await axios.delete(`/event/${eventId}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Récupérer un événement spécifique
|
||||
getEvent: async (eventId) => {
|
||||
try {
|
||||
const response = await axios.get(`/event/${eventId}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching event:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
getAllEvents: async () => (await api.get('/event/')).data,
|
||||
createEvent: async (data) => (await api.post('/event/create/', data)).data,
|
||||
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,
|
||||
};
|
||||
10
frontend/src/api/notes.js
Normal file
10
frontend/src/api/notes.js
Normal file
@ -0,0 +1,10 @@
|
||||
import api from './axios.js';
|
||||
|
||||
export const notesApi = {
|
||||
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,
|
||||
update: async (id, data) => (await api.patch(`/notes/${id}/`, data)).data,
|
||||
remove: async (id) => (await api.delete(`/notes/${id}/`)).data,
|
||||
complete: async (id, isDone) => (await api.patch(`/notes/${id}/completion/`, { is_done: isDone })).data,
|
||||
};
|
||||
@ -1,176 +1,30 @@
|
||||
import api from './axios';
|
||||
import api from './axios.js';
|
||||
|
||||
export const timeApi = {
|
||||
getTimeEntries: async () => {
|
||||
getTimeEntries: async () => (await api.get('/time/entries/')).data,
|
||||
getAttendances: async () => (await api.get('/time/entries/')).data,
|
||||
getUsers: async () => (await api.get('/users/')).data,
|
||||
createTimeEntry: async (data) => (await api.post('/time/entries/create/', data)).data,
|
||||
getTimeEntry: async (id) => (await api.get(`/time/entries/${id}/`)).data,
|
||||
updateTimeEntry: async (id, data) => (await api.patch(`/time/entries/${id}/`, data)).data,
|
||||
deleteTimeEntry: async (id) => (await api.delete(`/time/entries/${id}/`)).data,
|
||||
updateTimeEntryGroup: async (id, data) => (await api.patch(`/time/groups/${id}/`, data)).data,
|
||||
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,
|
||||
scanTimeEntry: async (code, options = {}) => {
|
||||
try {
|
||||
const response = await api.get('/time/entries/');
|
||||
return response.data;
|
||||
return (await api.post('/time/entries/scan/', { code, ...options })).data;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'Failed to fetch time entries: ' +
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
getUsers: async () => {
|
||||
try {
|
||||
const response = await api.get('/users/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'Failed to fetch users: ' +
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
createTimeEntry: async (entryData) => {
|
||||
try {
|
||||
const response = await api.post(
|
||||
'/time/entries/create/',
|
||||
entryData,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.error ||
|
||||
error.message;
|
||||
|
||||
throw new Error(
|
||||
"Erreur lors de la création du pointage : " +
|
||||
message,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
scanTimeEntry: async (
|
||||
code,
|
||||
options = {},
|
||||
) => {
|
||||
try {
|
||||
const response = await api.post(
|
||||
'/time/entries/scan/',
|
||||
{
|
||||
code,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const data =
|
||||
error.response?.data || {};
|
||||
|
||||
const scanError = new Error(
|
||||
data.error ||
|
||||
`Erreur lors du pointage: ${error.message}`,
|
||||
);
|
||||
|
||||
scanError.conflict =
|
||||
data.conflict;
|
||||
|
||||
scanError.resolution =
|
||||
data.resolution;
|
||||
|
||||
scanError.missingTime =
|
||||
data.missing_time;
|
||||
|
||||
scanError.scanTime =
|
||||
data.scan_time;
|
||||
|
||||
scanError.code =
|
||||
error.response?.status;
|
||||
|
||||
throw scanError;
|
||||
}
|
||||
},
|
||||
|
||||
getTimeEntry: async (id) => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/time/entries/${id}/`,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'Failed to fetch time entry: ' +
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
updateTimeEntry: async (
|
||||
id,
|
||||
entryData,
|
||||
) => {
|
||||
try {
|
||||
const response = await api.put(
|
||||
`/time/entries/${id}/update/`,
|
||||
entryData,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.error ||
|
||||
error.message;
|
||||
|
||||
throw new Error(
|
||||
'Erreur lors de la mise à jour : ' +
|
||||
message,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
deleteTimeEntry: async (id) => {
|
||||
try {
|
||||
const response = await api.delete(
|
||||
`/time/entries/${id}/`,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
`Erreur lors de la suppression: ${error.message}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
getWeeklySummary: async () => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
'/time/weekly-summary/',
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'Failed to fetch weekly summary: ' +
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
getUserWeeklySummary: async (
|
||||
userId,
|
||||
) => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/time/weekly-summary/${userId}/`,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'Failed to fetch user weekly summary: ' +
|
||||
error.message,
|
||||
);
|
||||
const data = error.response?.data || {};
|
||||
error.conflict = data.conflict;
|
||||
error.resolution = data.resolution;
|
||||
error.missingTime = data.missing_time;
|
||||
error.scanTime = data.scan_time;
|
||||
error.scanDate = data.scan_date;
|
||||
error.entryId = data.entry_id;
|
||||
error.code = error.response?.status;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -1,52 +1,9 @@
|
||||
import api from './axios';
|
||||
import api from './axios';
|
||||
|
||||
export const userApi = {
|
||||
// Get all users
|
||||
getUsers: async () => {
|
||||
try {
|
||||
const response = await api.get('/users/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch users: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a user
|
||||
deleteUser: async (id) => {
|
||||
try {
|
||||
// Assurer que l'URL se termine par un slash
|
||||
const response = await api.delete(`/users/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to delete user: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Promote a user to admin
|
||||
promoteUser: async (id) => {
|
||||
try {
|
||||
const response = await api.post(`/users/${id}/promote/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to promote user: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
updateUser: async (id, data) => {
|
||||
try {
|
||||
const response = await api.patch(`/users/${id}/`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to update user: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
createInvitation: async () => {
|
||||
try {
|
||||
const response = await api.post('/users/invitations/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to create invitation: ' + error.message);
|
||||
}
|
||||
},
|
||||
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,
|
||||
updateUser: async (id, data) => (await api.patch(`/users/${id}/`, data)).data,
|
||||
createInvitation: async () => (await api.post('/users/invitations/')).data,
|
||||
};
|
||||
|
||||
@ -18,7 +18,7 @@ const AdminRoute = ({ children }) => {
|
||||
|
||||
// If user is not admin, redirect to profile page
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/profile" state={{ from: location }} replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
|
||||
@ -8,22 +8,23 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const ConfirmDialog = ({
|
||||
title = "Confirmation",
|
||||
title = 'Confirmation',
|
||||
description,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
children,
|
||||
confirmText = "Confirmer",
|
||||
cancelText = "Annuler"
|
||||
confirmText = 'Confirmer',
|
||||
cancelText = 'Annuler',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleConfirm = () => {
|
||||
setIsOpen(false);
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
}
|
||||
@ -31,24 +32,40 @@ const ConfirmDialog = ({
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialog
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<AlertDialogTrigger
|
||||
render={children}
|
||||
/>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
<AlertDialogTitle>
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancel}>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm}>{confirmText}</AlertDialogAction>
|
||||
<AlertDialogCancel onClick={handleCancel}>
|
||||
{cancelText}
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction onClick={handleConfirm}>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
102
frontend/src/components/HoverInfo.jsx
Normal file
102
frontend/src/components/HoverInfo.jsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return null;
|
||||
|
||||
return String(time).slice(0, 5);
|
||||
};
|
||||
|
||||
const HoverInfo = ({
|
||||
title,
|
||||
description,
|
||||
startTime,
|
||||
endTime,
|
||||
color,
|
||||
position,
|
||||
}) => {
|
||||
if (!position || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedStartTime = formatTime(startTime);
|
||||
const formattedEndTime = formatTime(endTime);
|
||||
|
||||
const hasTime =
|
||||
formattedStartTime ||
|
||||
formattedEndTime;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
pointer-events-none
|
||||
fixed
|
||||
z-[9999]
|
||||
max-w-[320px]
|
||||
rounded-lg
|
||||
border
|
||||
border-border
|
||||
bg-popover
|
||||
px-3
|
||||
py-2.5
|
||||
text-popover-foreground
|
||||
shadow-lg
|
||||
"
|
||||
style={{
|
||||
left: `${position.x + 12}px`,
|
||||
top: `${position.y + 12}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{color && (
|
||||
<div
|
||||
className="
|
||||
mt-0.5
|
||||
h-3
|
||||
w-3
|
||||
shrink-0
|
||||
rounded-full
|
||||
"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-sm font-semibold">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasTime && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{formattedStartTime && (
|
||||
<span>
|
||||
Début : {formattedStartTime}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{formattedStartTime &&
|
||||
formattedEndTime && (
|
||||
<span className="mx-1">
|
||||
•
|
||||
</span>
|
||||
)}
|
||||
|
||||
{formattedEndTime && (
|
||||
<span>
|
||||
Fin : {formattedEndTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoverInfo;
|
||||
@ -23,6 +23,7 @@ import {
|
||||
LogOut,
|
||||
Wheat,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
|
||||
const NavBar = () => {
|
||||
@ -68,10 +69,13 @@ const NavBar = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Navigation principale
|
||||
*/
|
||||
const navigation = [
|
||||
{
|
||||
title: 'Profil',
|
||||
href: '/profile',
|
||||
title: user.first_name,
|
||||
href: '/myspace',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
@ -86,7 +90,35 @@ const NavBar = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const menuItemClass = 'h-12 min-h-12 flex items-center';
|
||||
/*
|
||||
* Entreprise
|
||||
*/
|
||||
const entreprise = [
|
||||
{
|
||||
title: 'Calendrier',
|
||||
href: '/calendar',
|
||||
icon: Calendar,
|
||||
},
|
||||
{
|
||||
title: 'Espace d\'échanges',
|
||||
href: '/notes',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
];
|
||||
|
||||
/*
|
||||
* Administration
|
||||
*/
|
||||
const administration = [
|
||||
{
|
||||
title: 'Utilisateurs',
|
||||
href: '/users',
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
const menuItemClass =
|
||||
'h-12 min-h-12 flex items-center';
|
||||
|
||||
const buttonClass = `
|
||||
w-full
|
||||
@ -116,7 +148,6 @@ const NavBar = () => {
|
||||
px-2
|
||||
"
|
||||
>
|
||||
{/* Nom du groupe */}
|
||||
<span
|
||||
className="
|
||||
truncate
|
||||
@ -128,7 +159,6 @@ const NavBar = () => {
|
||||
{children}
|
||||
</span>
|
||||
|
||||
{/* Séparation en mode plié */}
|
||||
<div
|
||||
className="
|
||||
hidden
|
||||
@ -141,6 +171,40 @@ const NavBar = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
/*
|
||||
* Permet d'éviter de répéter le rendu
|
||||
* de chaque élément de navigation.
|
||||
*/
|
||||
const renderMenuItems = (items) =>
|
||||
items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={item.href}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
onClick={handleCloseMenu}
|
||||
className={buttonClass}
|
||||
render={
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={linkClass}
|
||||
>
|
||||
<Icon className="size-6 shrink-0" />
|
||||
|
||||
<span className="truncate text-base">
|
||||
{item.title}
|
||||
</span>
|
||||
</NavLink>
|
||||
}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
className="border-0"
|
||||
@ -195,34 +259,7 @@ const NavBar = () => {
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={item.href}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
onClick={handleCloseMenu}
|
||||
className={buttonClass}
|
||||
render={
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={linkClass}
|
||||
>
|
||||
<Icon className="size-6 shrink-0" />
|
||||
|
||||
<span className="truncate text-base">
|
||||
{item.title}
|
||||
</span>
|
||||
</NavLink>
|
||||
}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
{renderMenuItems(navigation)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@ -235,25 +272,7 @@ const NavBar = () => {
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className={menuItemClass}>
|
||||
<SidebarMenuButton
|
||||
tooltip="Calendrier"
|
||||
onClick={handleCloseMenu}
|
||||
className={buttonClass}
|
||||
render={
|
||||
<NavLink
|
||||
to="/calendar"
|
||||
className={linkClass}
|
||||
>
|
||||
<Calendar className="size-6 shrink-0" />
|
||||
|
||||
<span className="truncate text-base">
|
||||
Calendrier
|
||||
</span>
|
||||
</NavLink>
|
||||
}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
{renderMenuItems(entreprise)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@ -267,25 +286,7 @@ const NavBar = () => {
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className={menuItemClass}>
|
||||
<SidebarMenuButton
|
||||
tooltip="Comptes existants"
|
||||
onClick={handleCloseMenu}
|
||||
className={buttonClass}
|
||||
render={
|
||||
<NavLink
|
||||
to="/users"
|
||||
className={linkClass}
|
||||
>
|
||||
<Users className="size-6 shrink-0" />
|
||||
|
||||
<span className="truncate text-base">
|
||||
Utilisateurs
|
||||
</span>
|
||||
</NavLink>
|
||||
}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
{renderMenuItems(administration)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
22
frontend/src/components/UnloggedRoute.jsx
Normal file
22
frontend/src/components/UnloggedRoute.jsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const UnloggedRoute = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <Skeleton className="w-full h-16" />;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
// Redirect them to the /login page, but save the current location they were trying to go to.
|
||||
return <Navigate to="/myspace" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default UnloggedRoute;
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../api/axios';
|
||||
import api, { clearSession } from '../api/axios';
|
||||
import { AuthContext } from './authContextValue';
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
@ -8,31 +8,40 @@ export const AuthProvider = ({ children }) => {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const onLogout = () => {
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
};
|
||||
window.addEventListener('auth:logout', onLogout);
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const savedUser = localStorage.getItem('user');
|
||||
|
||||
if (token && savedUser) {
|
||||
if (token) {
|
||||
try {
|
||||
// Verify token by fetching user profile
|
||||
const response = await api.get('/users/me/');
|
||||
const userData = response.data;
|
||||
|
||||
if (!cancelled && localStorage.getItem('token')) {
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
setUser(userData);
|
||||
setIsAdmin(userData.is_admin || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to verify authentication', error);
|
||||
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
if (!cancelled) setLoading(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('auth:logout', onLogout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async (username, password) => {
|
||||
@ -65,9 +74,7 @@ export const AuthProvider = ({ children }) => {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh');
|
||||
localStorage.removeItem('user');
|
||||
clearSession();
|
||||
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
|
||||
@ -17,12 +17,12 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { Users} from 'lucide-react';
|
||||
const AdminUsersPage = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@ -51,24 +51,6 @@ const AdminUsersPage = () => {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const getUserId = (user) => {
|
||||
if (!user) return '';
|
||||
|
||||
if (typeof user === 'number') return String(user);
|
||||
if (typeof user === 'string') {
|
||||
return /^\d+$/.test(user) ? user : '';
|
||||
}
|
||||
|
||||
return String(
|
||||
user.id ??
|
||||
user.pk ??
|
||||
user.user_id ??
|
||||
user.userId ??
|
||||
user.user?.id ??
|
||||
user.user?.pk ??
|
||||
''
|
||||
);
|
||||
};
|
||||
const copyToClipboard = () => {
|
||||
if (inviteLink) {
|
||||
navigator.clipboard.writeText(inviteLink).then(() => {
|
||||
@ -102,32 +84,19 @@ const AdminUsersPage = () => {
|
||||
try {
|
||||
await userApi.deleteUser(userId);
|
||||
|
||||
if (String(userId) === String(getUserId(user))) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers(users.filter(user => String(user.id) !== String(userId)));
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la suppression de l\'utilisateur');
|
||||
setError(err.message);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMakeAdmin = async (userId) => {
|
||||
const confirmed = await new Promise((resolve) => {
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userApi.promoteUser(userId);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la promotion de l\'utilisateur');
|
||||
setError(err.message);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
@ -139,10 +108,12 @@ const AdminUsersPage = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await userApi.updateUser(userId, editForm);
|
||||
const payload = { ...editForm };
|
||||
if (!currentUser.is_superuser) delete payload.is_superuser;
|
||||
await userApi.updateUser(userId, payload);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la mise à jour de l\'utilisateur');
|
||||
setError(err.message);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
@ -260,20 +231,31 @@ const AdminUsersPage = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingUser(user.id)}
|
||||
disabled={user.is_superuser && !currentUser.is_superuser}
|
||||
onClick={() => {
|
||||
setEditingUser(user.id);
|
||||
setEditForm({
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email || '',
|
||||
color: user.color || '#0000ff',
|
||||
is_active: user.is_active,
|
||||
is_staff: user.is_staff || user.is_admin,
|
||||
is_superuser: user.is_superuser,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Éditer
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="border-b">
|
||||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||
</DialogHeader>
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@ -418,7 +400,7 @@ const AdminUsersPage = () => {
|
||||
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
|
||||
onConfirm={() => handleDeleteUser(user.id)}
|
||||
>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Button variant="destructive" size="sm" disabled={String(user.id) === String(currentUser.id) || (user.is_superuser && !currentUser.is_superuser)}>
|
||||
Supprimer
|
||||
</Button>
|
||||
</ConfirmDialog>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,72 +1,77 @@
|
||||
import React from 'react';
|
||||
import LoadingCard from '@/components/LoadingCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import {
|
||||
CardContent,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import MainCard from "@/components/MainCard"
|
||||
import { House } from 'lucide-react';
|
||||
const HomePage = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<LoadingCard
|
||||
icon={User}
|
||||
title="Profil"
|
||||
message="Chargement du profil..."
|
||||
/>
|
||||
);
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Accueil',
|
||||
description: 'Page d\'accueil principale avec la liste des onglets et leurs utilités',
|
||||
path: '/'
|
||||
},
|
||||
{
|
||||
name: 'Profil',
|
||||
description: 'Ton profil',
|
||||
path: '/profile'
|
||||
},
|
||||
{
|
||||
name: 'Utilisateurs',
|
||||
description: 'Gestion des utilisateurs et permissions',
|
||||
path: '/users'
|
||||
},
|
||||
{
|
||||
name: 'Produits',
|
||||
description: 'Gestion des produits et catégories',
|
||||
path: '/notes'
|
||||
},
|
||||
{
|
||||
name: 'Commandes',
|
||||
description: 'Suivi et gestion des commandes',
|
||||
path: '/calendar'
|
||||
},
|
||||
{
|
||||
name: 'Statistiques',
|
||||
description: 'Vue d\'ensemble des statistiques et analyses',
|
||||
path: '/weekly-summary'
|
||||
},
|
||||
{
|
||||
name: 'Paramètres',
|
||||
description: 'Configuration générale de l\'application',
|
||||
path: '/pointage'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<MainCard title="Profil" icon={User}>
|
||||
<MainCard title="Accueil" icon={House}>
|
||||
|
||||
<CardContent className="overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Prénom:</span>
|
||||
<span>{user.first_name}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Nom:</span>
|
||||
<span>{user.last_name}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Admin:</span>
|
||||
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
|
||||
{user.is_admin ? 'Oui' : 'Non'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Nom d'utilisateur:</span>
|
||||
<span>{user.username}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Date d'inscription:</span>
|
||||
<span>
|
||||
{user.date_joined
|
||||
? new Date(user.date_joined).toLocaleDateString('fr-FR')
|
||||
: 'Non spécifié'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<main className="home-content">
|
||||
<div className="tabs-list">
|
||||
<h2>Liste des onglets</h2>
|
||||
<div className="tabs-grid">
|
||||
{tabs.map((tab, index) => (
|
||||
<Card key={index} className="tab-card">
|
||||
<CardHeader>
|
||||
<CardTitle>{tab.name}</CardTitle>
|
||||
<CardDescription>{tab.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="default">
|
||||
<Link to={tab.path}>
|
||||
Accéder à {tab.name}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="home-footer">
|
||||
<p>© 2023 - Application</p>
|
||||
</footer>
|
||||
</MainCard>
|
||||
);
|
||||
};
|
||||
|
||||
74
frontend/src/pages/MyspacePage.jsx
Normal file
74
frontend/src/pages/MyspacePage.jsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import LoadingCard from '@/components/LoadingCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import {
|
||||
CardContent,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
const MyspacePage = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<LoadingCard
|
||||
icon={User}
|
||||
title="Profil"
|
||||
message="Chargement du profil..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MainCard title={user.first_name} icon={User}>
|
||||
|
||||
<CardContent className="overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Prénom:</span>
|
||||
<span>{user.first_name}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Nom:</span>
|
||||
<span>{user.last_name}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Admin:</span>
|
||||
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
|
||||
{user.is_admin ? 'Oui' : 'Non'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Nom d'utilisateur:</span>
|
||||
<span>{user.username}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Date d'inscription:</span>
|
||||
<span>
|
||||
{user.date_joined
|
||||
? new Date(user.date_joined).toLocaleDateString('fr-FR')
|
||||
: 'Non spécifié'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</MainCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyspacePage;
|
||||
1686
frontend/src/pages/NotesPage.jsx
Normal file
1686
frontend/src/pages/NotesPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -179,95 +179,6 @@ const formatDate = (
|
||||
DATE/TIME DURATION HELPERS
|
||||
========================================================= */
|
||||
|
||||
const parseDateTime = (
|
||||
dateValue,
|
||||
timeValue,
|
||||
) => {
|
||||
if (!dateValue || !timeValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
] = dateValue.split('-').map(Number);
|
||||
|
||||
const [
|
||||
hours,
|
||||
minutes,
|
||||
seconds = 0,
|
||||
] = timeValue
|
||||
.split(':')
|
||||
.map(Number);
|
||||
|
||||
if (
|
||||
!year ||
|
||||
!month ||
|
||||
!day ||
|
||||
Number.isNaN(hours) ||
|
||||
Number.isNaN(minutes)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(
|
||||
year,
|
||||
month - 1,
|
||||
day,
|
||||
hours,
|
||||
minutes,
|
||||
Number(seconds) || 0,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const calculateDuration = ({
|
||||
startDate,
|
||||
startTime,
|
||||
endDate,
|
||||
endTime,
|
||||
}) => {
|
||||
if (
|
||||
!startDate ||
|
||||
!startTime ||
|
||||
!endDate ||
|
||||
!endTime
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start =
|
||||
parseDateTime(
|
||||
startDate,
|
||||
startTime,
|
||||
);
|
||||
|
||||
const end =
|
||||
parseDateTime(
|
||||
endDate,
|
||||
endTime,
|
||||
);
|
||||
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const difference =
|
||||
end.getTime() -
|
||||
start.getTime();
|
||||
|
||||
if (difference < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
difference /
|
||||
(1000 * 60 * 60)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/* =========================================================
|
||||
DATE PICKER
|
||||
========================================================= */
|
||||
@ -446,11 +357,7 @@ const TimePicker = ({
|
||||
);
|
||||
};
|
||||
|
||||
const handleWheel = (
|
||||
event,
|
||||
type,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
const handleWheel = (event, type) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const direction =
|
||||
@ -473,10 +380,7 @@ const TimePicker = ({
|
||||
};
|
||||
};
|
||||
|
||||
const handleTouchMove = (
|
||||
event,
|
||||
type,
|
||||
) => {
|
||||
const handleTouchMove = (event, type) => {
|
||||
if (!touchState.current.active) {
|
||||
return;
|
||||
}
|
||||
@ -489,8 +393,7 @@ const TimePicker = ({
|
||||
|
||||
touchState.current.lastY = currentY;
|
||||
|
||||
touchState.current.accumulated +=
|
||||
deltaY;
|
||||
touchState.current.accumulated += deltaY;
|
||||
|
||||
const pixelsPerUnit = 18;
|
||||
|
||||
@ -512,7 +415,6 @@ const TimePicker = ({
|
||||
changeMinute(steps);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
@ -792,7 +694,7 @@ const TimeClockPage = () => {
|
||||
newEntry,
|
||||
setNewEntry,
|
||||
] = useState({
|
||||
date: today,
|
||||
start_date: today,
|
||||
end_date: today,
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
@ -807,7 +709,7 @@ const TimeClockPage = () => {
|
||||
editForm,
|
||||
setEditForm,
|
||||
] = useState({
|
||||
date: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
@ -1005,24 +907,14 @@ const TimeClockPage = () => {
|
||||
isGrouped: false,
|
||||
sourceEntries: [entry],
|
||||
displayStartDate:
|
||||
entry.date,
|
||||
entry.start_date,
|
||||
displayEndDate:
|
||||
entry.date,
|
||||
entry.start_date,
|
||||
displayStartTime:
|
||||
entry.start_time,
|
||||
displayEndTime:
|
||||
entry.end_time,
|
||||
displayDuration:
|
||||
calculateDuration({
|
||||
startDate:
|
||||
entry.date,
|
||||
startTime:
|
||||
entry.start_time,
|
||||
endDate:
|
||||
entry.date,
|
||||
endTime:
|
||||
entry.end_time,
|
||||
}),
|
||||
displayDuration: entry.duration,
|
||||
});
|
||||
|
||||
return;
|
||||
@ -1059,10 +951,10 @@ const TimeClockPage = () => {
|
||||
(a, b) => {
|
||||
const dateCompare =
|
||||
String(
|
||||
a.date || '',
|
||||
a.start_date || '',
|
||||
).localeCompare(
|
||||
String(
|
||||
b.date || '',
|
||||
b.start_date || '',
|
||||
),
|
||||
);
|
||||
|
||||
@ -1091,11 +983,11 @@ const TimeClockPage = () => {
|
||||
];
|
||||
|
||||
const startDate =
|
||||
firstEntry?.date ||
|
||||
firstEntry?.start_date ||
|
||||
'';
|
||||
|
||||
const endDate =
|
||||
lastEntry?.date ||
|
||||
lastEntry?.start_date ||
|
||||
startDate;
|
||||
|
||||
const startTime =
|
||||
@ -1121,7 +1013,7 @@ const TimeClockPage = () => {
|
||||
sourceEntries:
|
||||
sortedEntries,
|
||||
|
||||
date: startDate,
|
||||
start_date: startDate,
|
||||
|
||||
end_date: endDate,
|
||||
|
||||
@ -1143,13 +1035,9 @@ const TimeClockPage = () => {
|
||||
displayEndTime:
|
||||
endTime,
|
||||
|
||||
displayDuration:
|
||||
calculateDuration({
|
||||
startDate,
|
||||
startTime,
|
||||
endDate,
|
||||
endTime,
|
||||
}),
|
||||
displayDuration: sortedEntries.every((item) => item.duration != null)
|
||||
? sortedEntries.reduce((total, item) => total + Number(item.duration), 0)
|
||||
: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
@ -1166,12 +1054,12 @@ const TimeClockPage = () => {
|
||||
const dateCompare =
|
||||
String(
|
||||
b.displayStartDate ||
|
||||
b.date ||
|
||||
b.start_date ||
|
||||
'',
|
||||
).localeCompare(
|
||||
String(
|
||||
a.displayStartDate ||
|
||||
a.date ||
|
||||
a.start_date ||
|
||||
'',
|
||||
),
|
||||
);
|
||||
@ -1393,12 +1281,12 @@ const TimeClockPage = () => {
|
||||
========================================================= */
|
||||
|
||||
const validateEntry = ({
|
||||
date,
|
||||
start_date,
|
||||
end_date,
|
||||
start_time,
|
||||
end_time,
|
||||
}) => {
|
||||
if (!date || !end_date) {
|
||||
if (!start_date || !end_date) {
|
||||
return 'Veuillez renseigner les dates.';
|
||||
}
|
||||
|
||||
@ -1409,12 +1297,12 @@ const TimeClockPage = () => {
|
||||
return 'Veuillez renseigner une heure d’entrée et une heure de sortie.';
|
||||
}
|
||||
|
||||
if (end_date < date) {
|
||||
if (end_date < start_date) {
|
||||
return 'La date de sortie doit être postérieure ou égale à la date d’entrée.';
|
||||
}
|
||||
|
||||
if (
|
||||
date === end_date &&
|
||||
start_date === end_date &&
|
||||
end_time <= start_time
|
||||
) {
|
||||
return "L'heure de sortie doit être après l'heure d'entrée lorsque le pointage est sur la même journée.";
|
||||
@ -1458,7 +1346,7 @@ const TimeClockPage = () => {
|
||||
try {
|
||||
await timeApi.createTimeEntry(
|
||||
{
|
||||
date: newEntry.date,
|
||||
start_date: newEntry.start_date,
|
||||
|
||||
end_date:
|
||||
newEntry.end_date,
|
||||
@ -1485,16 +1373,15 @@ const TimeClockPage = () => {
|
||||
|
||||
|
||||
setNewEntry({
|
||||
date: today,
|
||||
start_date: today,
|
||||
end_date: today,
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
});
|
||||
|
||||
|
||||
await loadTimeEntries();
|
||||
|
||||
setError('');
|
||||
await loadTimeEntries();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err.message ||
|
||||
@ -1524,10 +1411,10 @@ const TimeClockPage = () => {
|
||||
(a, b) => {
|
||||
const dateCompare =
|
||||
String(
|
||||
a.date || '',
|
||||
a.start_date || '',
|
||||
).localeCompare(
|
||||
String(
|
||||
b.date || '',
|
||||
b.start_date || '',
|
||||
),
|
||||
);
|
||||
|
||||
@ -1556,15 +1443,15 @@ const TimeClockPage = () => {
|
||||
];
|
||||
|
||||
setEditForm({
|
||||
date:
|
||||
firstEntry?.date ||
|
||||
entry.date ||
|
||||
start_date:
|
||||
firstEntry?.start_date ||
|
||||
entry.start_date ||
|
||||
'',
|
||||
|
||||
end_date:
|
||||
lastEntry?.date ||
|
||||
lastEntry?.start_date ||
|
||||
entry.end_date ||
|
||||
entry.date ||
|
||||
entry.start_date ||
|
||||
'',
|
||||
|
||||
start_time:
|
||||
@ -1577,12 +1464,12 @@ const TimeClockPage = () => {
|
||||
});
|
||||
} else {
|
||||
setEditForm({
|
||||
date:
|
||||
entry.date || '',
|
||||
start_date:
|
||||
entry.start_date || '',
|
||||
|
||||
end_date:
|
||||
entry.end_date ||
|
||||
entry.date ||
|
||||
entry.start_date ||
|
||||
'',
|
||||
|
||||
start_time:
|
||||
@ -1618,122 +1505,23 @@ const TimeClockPage = () => {
|
||||
|
||||
|
||||
try {
|
||||
if (
|
||||
editingEntry.isGrouped
|
||||
) {
|
||||
const sortedEntries =
|
||||
[...(
|
||||
editingEntry.sourceEntries ||
|
||||
[]
|
||||
)].sort(
|
||||
(a, b) => {
|
||||
const dateCompare =
|
||||
String(
|
||||
a.date || '',
|
||||
).localeCompare(
|
||||
String(
|
||||
b.date || '',
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
dateCompare !== 0
|
||||
) {
|
||||
return dateCompare;
|
||||
}
|
||||
|
||||
return String(
|
||||
a.start_time || '',
|
||||
).localeCompare(
|
||||
String(
|
||||
b.start_time || '',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
const firstEntry =
|
||||
sortedEntries[0];
|
||||
|
||||
const lastEntry =
|
||||
sortedEntries[
|
||||
sortedEntries.length - 1
|
||||
];
|
||||
|
||||
|
||||
if (
|
||||
!firstEntry ||
|
||||
!lastEntry
|
||||
) {
|
||||
throw new Error(
|
||||
'Impossible de déterminer les lignes du pointage multi-jours.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
firstEntry.id ===
|
||||
lastEntry.id
|
||||
) {
|
||||
await timeApi.updateTimeEntry(
|
||||
firstEntry.id,
|
||||
{
|
||||
start_time:
|
||||
editForm.start_time,
|
||||
end_time:
|
||||
editForm.end_time,
|
||||
},
|
||||
);
|
||||
if (editingEntry.isGrouped) {
|
||||
await timeApi.updateTimeEntryGroup(editingEntry.group_id, editForm);
|
||||
} else {
|
||||
await timeApi.updateTimeEntry(
|
||||
firstEntry.id,
|
||||
{
|
||||
start_time:
|
||||
editForm.start_time,
|
||||
end_time:
|
||||
firstEntry.end_time,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
await timeApi.updateTimeEntry(
|
||||
lastEntry.id,
|
||||
{
|
||||
start_time:
|
||||
lastEntry.start_time,
|
||||
end_time:
|
||||
editForm.end_time,
|
||||
},
|
||||
);
|
||||
await timeApi.updateTimeEntry(editingEntry.id, editForm);
|
||||
}
|
||||
} else {
|
||||
await timeApi.updateTimeEntry(
|
||||
editingEntry.id,
|
||||
{
|
||||
start_time:
|
||||
editForm.start_time,
|
||||
|
||||
end_time:
|
||||
editForm.end_time,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
setEditingEntry(null);
|
||||
|
||||
setEditForm({
|
||||
date: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
});
|
||||
|
||||
|
||||
await loadTimeEntries();
|
||||
|
||||
setError('');
|
||||
await loadTimeEntries();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err.message ||
|
||||
@ -1752,30 +1540,13 @@ const TimeClockPage = () => {
|
||||
const handleDeleteEntry =
|
||||
async (entry) => {
|
||||
try {
|
||||
if (
|
||||
entry?.isGrouped &&
|
||||
Array.isArray(
|
||||
entry.sourceEntries,
|
||||
)
|
||||
) {
|
||||
for (
|
||||
const sourceEntry of
|
||||
entry.sourceEntries
|
||||
) {
|
||||
await timeApi.deleteTimeEntry(
|
||||
sourceEntry.id,
|
||||
);
|
||||
}
|
||||
if (entry.isGrouped) {
|
||||
await timeApi.deleteTimeEntryGroup(entry.group_id);
|
||||
} else {
|
||||
await timeApi.deleteTimeEntry(
|
||||
entry.id,
|
||||
);
|
||||
await timeApi.deleteTimeEntry(entry.id);
|
||||
}
|
||||
|
||||
|
||||
await loadTimeEntries();
|
||||
|
||||
setError('');
|
||||
await loadTimeEntries();
|
||||
} catch (err) {
|
||||
setError(
|
||||
'Erreur lors de la suppression du pointage',
|
||||
@ -1897,6 +1668,8 @@ const TimeClockPage = () => {
|
||||
scanError.missingTime,
|
||||
scanTime:
|
||||
scanError.scanTime,
|
||||
scanDate: scanError.scanDate,
|
||||
entryId: scanError.entryId,
|
||||
step: 'confirm',
|
||||
});
|
||||
|
||||
@ -2017,6 +1790,8 @@ const TimeClockPage = () => {
|
||||
resolution:
|
||||
config.resolution,
|
||||
time: conflictTime,
|
||||
scan_date: scanConflict.scanDate,
|
||||
entry_id: scanConflict.entryId,
|
||||
};
|
||||
|
||||
if (
|
||||
@ -2411,16 +2186,16 @@ const TimeClockPage = () => {
|
||||
const formatEntryDate = (
|
||||
entry,
|
||||
) => {
|
||||
if (!entry?.date) {
|
||||
if (!entry?.start_date) {
|
||||
return '────';
|
||||
}
|
||||
|
||||
const startDate =
|
||||
formatDate(entry.date);
|
||||
formatDate(entry.start_date);
|
||||
|
||||
const endDate =
|
||||
entry.end_date &&
|
||||
entry.end_date !== entry.date
|
||||
entry.end_date !== entry.start_date
|
||||
? formatDate(
|
||||
entry.end_date,
|
||||
)
|
||||
@ -2901,13 +2676,13 @@ const TimeClockPage = () => {
|
||||
{editingEntry.isGrouped ? (
|
||||
<div className="flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
|
||||
{formatDateLabel(
|
||||
editForm.date,
|
||||
editForm.start_date,
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<DatePicker
|
||||
value={
|
||||
editForm.date
|
||||
editForm.start_date
|
||||
}
|
||||
onChange={(
|
||||
value,
|
||||
@ -2915,7 +2690,7 @@ const TimeClockPage = () => {
|
||||
setEditForm(
|
||||
{
|
||||
...editForm,
|
||||
date:
|
||||
start_date:
|
||||
value,
|
||||
|
||||
end_date:
|
||||
@ -2980,7 +2755,7 @@ const TimeClockPage = () => {
|
||||
editForm.end_date
|
||||
}
|
||||
min={
|
||||
editForm.date
|
||||
editForm.start_date
|
||||
}
|
||||
onChange={(
|
||||
value,
|
||||
@ -3080,7 +2855,7 @@ const TimeClockPage = () => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<DatePicker
|
||||
value={
|
||||
newEntry.date
|
||||
newEntry.start_date
|
||||
}
|
||||
onChange={(
|
||||
value,
|
||||
@ -3088,7 +2863,7 @@ const TimeClockPage = () => {
|
||||
setNewEntry(
|
||||
{
|
||||
...newEntry,
|
||||
date:
|
||||
start_date:
|
||||
value,
|
||||
|
||||
end_date:
|
||||
@ -3145,7 +2920,7 @@ const TimeClockPage = () => {
|
||||
newEntry.end_date
|
||||
}
|
||||
min={
|
||||
newEntry.date
|
||||
newEntry.start_date
|
||||
}
|
||||
onChange={(
|
||||
value,
|
||||
@ -3396,7 +3171,7 @@ const TimeClockPage = () => {
|
||||
description={
|
||||
entry.isGrouped
|
||||
? `Êtes-vous sûr de vouloir supprimer ce pointage multi-jours du ${formatDate(
|
||||
entry.date,
|
||||
entry.start_date,
|
||||
)} au ${formatDate(
|
||||
entry.end_date,
|
||||
)} ?`
|
||||
|
||||
@ -8,13 +8,9 @@ import { timeApi } from '../api/time';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import LoadingCard from '@/components/LoadingCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import HoverInfo from '@/components/HoverInfo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ChevronLeft,
|
||||
@ -29,15 +25,6 @@ const DEFAULT_END_HOUR = 18;
|
||||
const SCHEDULE_HEADER_HEIGHT = 58;
|
||||
const MIN_SCHEDULE_HEIGHT = 180;
|
||||
|
||||
/*
|
||||
* Hauteur minimale d'une heure sur mobile.
|
||||
*
|
||||
* Si l'espace disponible permet de faire plus grand :
|
||||
* les heures prennent toute la place disponible.
|
||||
*
|
||||
* Si l'espace disponible est trop petit :
|
||||
* le planning devient scrollable.
|
||||
*/
|
||||
const MOBILE_MIN_HOUR_HEIGHT = 52;
|
||||
|
||||
const MOBILE_TIME_COLUMN_WIDTH = 64;
|
||||
@ -94,6 +81,55 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
const [mobileContainerWidth, setMobileContainerWidth] =
|
||||
useState(0);
|
||||
|
||||
/*
|
||||
* ------------------------------------------------------------
|
||||
* HoverInfo
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const [hoverInfo, setHoverInfo] = useState(null);
|
||||
|
||||
const showHoverInfo = ({
|
||||
event,
|
||||
title,
|
||||
description,
|
||||
startTime,
|
||||
endTime,
|
||||
color,
|
||||
}) => {
|
||||
setHoverInfo({
|
||||
title,
|
||||
description,
|
||||
startTime,
|
||||
endTime,
|
||||
color,
|
||||
position: {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const moveHoverInfo = (event) => {
|
||||
setHoverInfo((current) => {
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
position: {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const hideHoverInfo = () => {
|
||||
setHoverInfo(null);
|
||||
};
|
||||
|
||||
const scheduleTableRef = useRef(null);
|
||||
|
||||
/*
|
||||
@ -223,6 +259,16 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingUser = users.find(
|
||||
(user) =>
|
||||
getUserId(user) ===
|
||||
String(entryUserId),
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
return {
|
||||
id: entryUserId,
|
||||
first_name: entry.first_name,
|
||||
@ -400,15 +446,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
scheduleTable.clientWidth,
|
||||
);
|
||||
|
||||
/*
|
||||
* MOBILE
|
||||
*
|
||||
* Le conteneur est maintenant un flex-item qui prend
|
||||
* toute la hauteur disponible.
|
||||
*
|
||||
* On mesure donc sa vraie hauteur au lieu d'utiliser
|
||||
* une valeur arbitraire comme 60vh.
|
||||
*/
|
||||
if (window.innerWidth < 768) {
|
||||
setMobileContainerHeight(
|
||||
scheduleTable.clientHeight,
|
||||
@ -417,10 +454,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* DESKTOP
|
||||
*/
|
||||
|
||||
const cardContent =
|
||||
scheduleTable.closest(
|
||||
'[data-slot="card-content"]',
|
||||
@ -634,7 +667,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
attendances,
|
||||
).filter(
|
||||
(attendance) =>
|
||||
String(attendance.date).slice(
|
||||
String(attendance.start_date).slice(
|
||||
0,
|
||||
10,
|
||||
) === dateKey,
|
||||
@ -694,6 +727,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
);
|
||||
|
||||
setCurrentWeek(newWeek);
|
||||
hideHoverInfo();
|
||||
};
|
||||
|
||||
const navigateDay = (
|
||||
@ -703,6 +737,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
selectedDayIndex +
|
||||
direction;
|
||||
|
||||
hideHoverInfo();
|
||||
|
||||
if (
|
||||
nextDayIndex < 0 ||
|
||||
nextDayIndex > 6
|
||||
@ -746,7 +782,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
(attendance) =>
|
||||
weekDateKeys.has(
|
||||
String(
|
||||
attendance.date,
|
||||
attendance.start_date,
|
||||
).slice(0, 10),
|
||||
),
|
||||
);
|
||||
@ -926,7 +962,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
(attendance) =>
|
||||
weekDateKeys.has(
|
||||
String(
|
||||
attendance.date,
|
||||
attendance.start_date,
|
||||
).slice(0, 10),
|
||||
),
|
||||
);
|
||||
@ -1021,23 +1057,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
* ------------------------------------------------------------
|
||||
* Hauteur du planning
|
||||
* ------------------------------------------------------------
|
||||
*
|
||||
* MOBILE
|
||||
*
|
||||
* Le conteneur visible prend maintenant toute la place
|
||||
* disponible grâce au flex.
|
||||
*
|
||||
* Si suffisamment de place est disponible :
|
||||
* -> les heures sont réparties sur toute la hauteur.
|
||||
*
|
||||
* Si la hauteur disponible est insuffisante :
|
||||
* -> chaque heure garde au minimum
|
||||
* MOBILE_MIN_HOUR_HEIGHT.
|
||||
* -> le contenu devient alors scrollable.
|
||||
*
|
||||
* DESKTOP
|
||||
*
|
||||
* On conserve le calcul dynamique existant.
|
||||
*/
|
||||
|
||||
const mobileAvailableBodyHeight =
|
||||
@ -1178,8 +1197,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
return (
|
||||
<MainCard
|
||||
icon={CalendarDays}
|
||||
className=
|
||||
{isMobile
|
||||
className={
|
||||
isMobile
|
||||
? 'overflow-visible'
|
||||
: 'overflow-hidden'
|
||||
}
|
||||
@ -1299,29 +1318,23 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
event.target.value,
|
||||
);
|
||||
|
||||
hideHoverInfo();
|
||||
setError('');
|
||||
}}
|
||||
className="h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{users.length === 0 ? (
|
||||
<option value="">
|
||||
Aucun utilisateur
|
||||
disponible
|
||||
Aucun utilisateur disponible
|
||||
</option>
|
||||
) : (
|
||||
users.map(
|
||||
(user) => (
|
||||
<option
|
||||
key={getUserId(
|
||||
user,
|
||||
)}
|
||||
value={getUserId(
|
||||
user,
|
||||
)}
|
||||
key={getUserId(user)}
|
||||
value={getUserId(user)}
|
||||
>
|
||||
{getUserLabel(
|
||||
user,
|
||||
)}
|
||||
{getUserLabel(user)}
|
||||
</option>
|
||||
),
|
||||
)
|
||||
@ -1338,6 +1351,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
event.target.checked,
|
||||
);
|
||||
|
||||
hideHoverInfo();
|
||||
setError('');
|
||||
}}
|
||||
className="size-4 accent-primary"
|
||||
@ -1535,9 +1549,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
user,
|
||||
)}
|
||||
className="relative border-r border-border/30 last:border-r-0"
|
||||
title={getUserLabel(
|
||||
user,
|
||||
)}
|
||||
>
|
||||
{schedules.map(
|
||||
({
|
||||
@ -1565,13 +1576,32 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
backgroundColor:
|
||||
color,
|
||||
}}
|
||||
title={`${getUserLabel(
|
||||
onMouseEnter={(event) =>
|
||||
showHoverInfo({
|
||||
event,
|
||||
title:
|
||||
getUserLabel(
|
||||
user,
|
||||
)} : ${formatSegmentTime(
|
||||
start,
|
||||
)} - ${formatSegmentTime(
|
||||
end,
|
||||
)}`}
|
||||
),
|
||||
startTime:
|
||||
getTimeValue(
|
||||
attendance,
|
||||
'start_time',
|
||||
),
|
||||
endTime:
|
||||
getTimeValue(
|
||||
attendance,
|
||||
'end_time',
|
||||
),
|
||||
color,
|
||||
})
|
||||
}
|
||||
onMouseMove={
|
||||
moveHoverInfo
|
||||
}
|
||||
onMouseLeave={
|
||||
hideHoverInfo
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@ -1631,6 +1661,12 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
hourHeight,
|
||||
);
|
||||
|
||||
const attendanceUser =
|
||||
getEntryUser(
|
||||
attendance,
|
||||
) ||
|
||||
selectedUser;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
@ -1645,7 +1681,33 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
style={
|
||||
style
|
||||
}
|
||||
title={`${formatSegmentTime(start)} - ${formatSegmentTime(end)}`}
|
||||
onMouseEnter={(event) =>
|
||||
showHoverInfo({
|
||||
event,
|
||||
title:
|
||||
getUserLabel(
|
||||
attendanceUser,
|
||||
),
|
||||
startTime:
|
||||
getTimeValue(
|
||||
attendance,
|
||||
'start_time',
|
||||
),
|
||||
endTime:
|
||||
getTimeValue(
|
||||
attendance,
|
||||
'end_time',
|
||||
),
|
||||
color:
|
||||
attendanceUser?.color,
|
||||
})
|
||||
}
|
||||
onMouseMove={
|
||||
moveHoverInfo
|
||||
}
|
||||
onMouseLeave={
|
||||
hideHoverInfo
|
||||
}
|
||||
>
|
||||
<div className="p-1 text-xs text-white">
|
||||
{formatSegmentTime(
|
||||
@ -1799,7 +1861,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
user,
|
||||
);
|
||||
|
||||
const color = user.color;
|
||||
const color =
|
||||
user.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -1807,9 +1870,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
user,
|
||||
)}
|
||||
className="relative min-w-0 border-r border-border/30 last:border-r-0"
|
||||
title={getUserLabel(
|
||||
user,
|
||||
)}
|
||||
>
|
||||
{schedules.map(
|
||||
({
|
||||
@ -1839,13 +1899,32 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
backgroundColor:
|
||||
color,
|
||||
}}
|
||||
title={`${getUserLabel(
|
||||
onMouseEnter={(event) =>
|
||||
showHoverInfo({
|
||||
event,
|
||||
title:
|
||||
getUserLabel(
|
||||
user,
|
||||
)} : ${formatSegmentTime(
|
||||
start,
|
||||
)} - ${formatSegmentTime(
|
||||
end,
|
||||
)}`}
|
||||
),
|
||||
startTime:
|
||||
getTimeValue(
|
||||
attendance,
|
||||
'start_time',
|
||||
),
|
||||
endTime:
|
||||
getTimeValue(
|
||||
attendance,
|
||||
'end_time',
|
||||
),
|
||||
color,
|
||||
})
|
||||
}
|
||||
onMouseMove={
|
||||
moveHoverInfo
|
||||
}
|
||||
onMouseLeave={
|
||||
hideHoverInfo
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@ -1904,6 +1983,12 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
hourHeight,
|
||||
);
|
||||
|
||||
const attendanceUser =
|
||||
getEntryUser(
|
||||
attendance,
|
||||
) ||
|
||||
selectedUser;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
@ -1916,7 +2001,33 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
style={
|
||||
style
|
||||
}
|
||||
title={`${formatSegmentTime(start)} - ${formatSegmentTime(end)}`}
|
||||
onMouseEnter={(event) =>
|
||||
showHoverInfo({
|
||||
event,
|
||||
title:
|
||||
getUserLabel(
|
||||
attendanceUser,
|
||||
),
|
||||
startTime:
|
||||
getTimeValue(
|
||||
attendance,
|
||||
'start_time',
|
||||
),
|
||||
endTime:
|
||||
getTimeValue(
|
||||
attendance,
|
||||
'end_time',
|
||||
),
|
||||
color:
|
||||
attendanceUser?.color,
|
||||
})
|
||||
}
|
||||
onMouseMove={
|
||||
moveHoverInfo
|
||||
}
|
||||
onMouseLeave={
|
||||
hideHoverInfo
|
||||
}
|
||||
>
|
||||
<div className="p-2 text-xs text-white">
|
||||
<div className="font-medium">
|
||||
@ -1941,6 +2052,15 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HoverInfo
|
||||
title={hoverInfo?.title}
|
||||
description={hoverInfo?.description}
|
||||
startTime={hoverInfo?.startTime}
|
||||
endTime={hoverInfo?.endTime}
|
||||
color={hoverInfo?.color}
|
||||
position={hoverInfo?.position}
|
||||
/>
|
||||
</CardContent>
|
||||
</MainCard>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user