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
|
# Third party apps
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework_simplejwt',
|
'rest_framework_simplejwt',
|
||||||
|
'rest_framework_simplejwt.token_blacklist',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
# Local apps
|
# Local apps
|
||||||
'users',
|
'users',
|
||||||
'time_tracking',
|
'time_tracking',
|
||||||
'events',
|
'events',
|
||||||
|
'notes',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@ -149,11 +151,9 @@ SIMPLE_JWT = {
|
|||||||
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
|
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||||
'ROTATE_REFRESH_TOKENS': True,
|
'ROTATE_REFRESH_TOKENS': True,
|
||||||
'BLACKLIST_ROTTEN_TOKENS': True,
|
'BLACKLIST_AFTER_ROTATION': True,
|
||||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# 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/users/', include('users.urls')),
|
||||||
path('api/time/', include('time_tracking.urls')),
|
path('api/time/', include('time_tracking.urls')),
|
||||||
path('api/event/', include('events.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()
|
start_date = models.DateField()
|
||||||
end_date = models.DateField()
|
end_date = models.DateField()
|
||||||
start_time = models.TimeField()
|
start_time = models.TimeField(null=True, blank=True)
|
||||||
end_time = models.TimeField()
|
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)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
visibility = models.CharField(max_length=10, default='public', choices=[
|
||||||
|
('public', 'Public'),
|
||||||
|
('private', 'Private'),
|
||||||
|
])
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|||||||
@ -17,30 +17,24 @@ class EventSerializer(serializers.ModelSerializer):
|
|||||||
'end_date',
|
'end_date',
|
||||||
'start_time',
|
'start_time',
|
||||||
'end_time',
|
'end_time',
|
||||||
|
'color',
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
|
'visibility',
|
||||||
'created_at',
|
'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 EventCreateSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False, allow_null=True, allow_blank=True)
|
||||||
model = Event
|
|
||||||
fields = [
|
|
||||||
'user',
|
|
||||||
'linked_user',
|
|
||||||
'start_date',
|
|
||||||
'end_date',
|
|
||||||
'start_time',
|
|
||||||
'end_time',
|
|
||||||
'name',
|
|
||||||
'description'
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class EventUpdateSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Event
|
model = Event
|
||||||
fields = [
|
fields = [
|
||||||
@ -49,6 +43,49 @@ class EventUpdateSerializer(serializers.ModelSerializer):
|
|||||||
'end_date',
|
'end_date',
|
||||||
'start_time',
|
'start_time',
|
||||||
'end_time',
|
'end_time',
|
||||||
|
'color',
|
||||||
'name',
|
'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):
|
def perform_create(self, serializer):
|
||||||
serializer.save(user=self.request.user)
|
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):
|
class EventDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
serializer_class = EventSerializer
|
serializer_class = EventSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
lookup_url_kwarg = 'id'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
@ -33,12 +40,13 @@ class EventDetailView(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer = EventUpdateSerializer(
|
serializer = EventUpdateSerializer(
|
||||||
instance,
|
instance,
|
||||||
data=request.data,
|
data=request.data,
|
||||||
partial=partial
|
partial=partial,
|
||||||
|
context=self.get_serializer_context(),
|
||||||
)
|
)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
self.perform_update(serializer)
|
self.perform_update(serializer)
|
||||||
|
|
||||||
return Response(serializer.data)
|
return Response(EventSerializer(serializer.instance).data)
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
instance = self.get_object()
|
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
|
serializer_class = EventCreateSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
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 datetime import datetime
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -20,17 +19,17 @@ class TimeEntry(models.Model):
|
|||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
date = models.DateField()
|
start_date = models.DateField()
|
||||||
|
|
||||||
start_time = models.TimeField(
|
start_time = models.TimeField()
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
end_time = models.TimeField(
|
# Chaque ligne représente une seule journée.
|
||||||
null=True,
|
# Pour une entrée classique :
|
||||||
blank=True,
|
# 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(
|
end_time = models.TimeField(
|
||||||
null=True,
|
null=True,
|
||||||
@ -46,30 +45,49 @@ class TimeEntry(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-date', '-start_time']
|
ordering = ['-start_date', '-start_time']
|
||||||
|
|
||||||
unique_together = [
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=[
|
||||||
'user',
|
'user',
|
||||||
'date',
|
'start_date',
|
||||||
'start_time',
|
'start_time',
|
||||||
|
],
|
||||||
|
name='unique_time_entry_start',
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
if self.end_time:
|
||||||
return (
|
return (
|
||||||
f"{self.user.username} - "
|
f"{self.user.username} - "
|
||||||
f"{self.date} "
|
f"{self.start_date} "
|
||||||
f"({self.start_time} to {self.end_time})"
|
f"({self.start_time} → {self.end_time})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{self.user.username} - "
|
||||||
|
f"{self.start_date} "
|
||||||
|
f"({self.start_time} → en cours)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def duration(self):
|
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:
|
if not self.start_time or not self.end_time:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
end_date = self.end_date or self.date
|
end_date = self.end_date or self.start_date
|
||||||
|
|
||||||
start = datetime.combine(
|
start = datetime.combine(
|
||||||
self.date,
|
self.start_date,
|
||||||
self.start_time,
|
self.start_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -89,7 +107,7 @@ class TimeEntry(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def day_of_week(self):
|
def day_of_week(self):
|
||||||
return self.date.strftime('%A')
|
return self.start_date.strftime('%A')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_complete(self):
|
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.contrib.auth import get_user_model
|
||||||
from django.db import transaction
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from users.serializers import UserSerializer
|
from users.serializers import UserSerializer
|
||||||
from .models import TimeEntry
|
from .models import TimeEntry
|
||||||
|
from .services import save_period, validate_period
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class TimeEntrySerializer(serializers.ModelSerializer):
|
class TimeEntrySerializer(serializers.ModelSerializer):
|
||||||
user = UserSerializer(read_only=True)
|
user = UserSerializer(read_only=True)
|
||||||
|
|
||||||
is_complete = serializers.ReadOnlyField()
|
|
||||||
is_incomplete = serializers.ReadOnlyField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TimeEntry
|
model = TimeEntry
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'id',
|
'id', 'user', 'group_id', 'start_date', 'start_time', 'end_date',
|
||||||
'user',
|
'end_time', 'created_at', 'updated_at', 'duration', 'day_of_week',
|
||||||
'group_id',
|
'is_complete', 'is_incomplete',
|
||||||
'date',
|
|
||||||
'start_time',
|
|
||||||
'end_time',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
'duration',
|
|
||||||
'day_of_week',
|
|
||||||
'is_complete',
|
|
||||||
'is_incomplete',
|
|
||||||
]
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
read_only_fields = [
|
|
||||||
'id',
|
class TimeEntryCreateSerializer(serializers.Serializer):
|
||||||
'user',
|
user_id = serializers.PrimaryKeyRelatedField(
|
||||||
'created_at',
|
source='user', queryset=get_user_model().objects.all(), required=False,
|
||||||
'updated_at',
|
write_only=True,
|
||||||
'duration',
|
)
|
||||||
'day_of_week',
|
start_date = serializers.DateField()
|
||||||
'is_complete',
|
start_time = serializers.TimeField()
|
||||||
'is_incomplete',
|
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):
|
def create(self, validated_data):
|
||||||
validated_data['user'] = self.context['request'].user
|
user = validated_data.pop('user', self.context['request'].user)
|
||||||
return super().create(validated_data)
|
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):
|
def update(self, instance, validated_data):
|
||||||
validated_data.pop('user', None)
|
return save_period(
|
||||||
return super().update(instance, validated_data)
|
instance.user, validated_data,
|
||||||
|
self.context.get('entries', [instance]),
|
||||||
|
|
||||||
class TimeEntryCreateSerializer(serializers.ModelSerializer):
|
|
||||||
user_id = serializers.PrimaryKeyRelatedField(
|
|
||||||
source='user',
|
|
||||||
queryset=User.objects.all(),
|
|
||||||
write_only=True,
|
|
||||||
required=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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):
|
class WeeklySummarySerializer(serializers.Serializer):
|
||||||
user = serializers.CharField()
|
user = serializers.CharField()
|
||||||
username = serializers.CharField()
|
username = serializers.CharField()
|
||||||
total_hours = serializers.FloatField()
|
total_hours = serializers.FloatField()
|
||||||
entries = serializers.ListField(
|
entries = serializers.ListField(child=serializers.DictField())
|
||||||
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,
|
TimeEntryScanView,
|
||||||
TimeEntryDetailView,
|
TimeEntryDetailView,
|
||||||
TimeEntryUpdateView,
|
TimeEntryUpdateView,
|
||||||
|
TimeEntryGroupView,
|
||||||
WeeklySummaryView,
|
WeeklySummaryView,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ urlpatterns = [
|
|||||||
path('entries/', TimeEntryListView.as_view(), name='time-entry-list'),
|
path('entries/', TimeEntryListView.as_view(), name='time-entry-list'),
|
||||||
path('entries/create/', TimeEntryCreateView.as_view(), name='time-entry-create'),
|
path('entries/create/', TimeEntryCreateView.as_view(), name='time-entry-create'),
|
||||||
path('entries/scan/', TimeEntryScanView.as_view(), name='time-entry-scan'),
|
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>/', TimeEntryDetailView.as_view(), name='time-entry-detail'),
|
||||||
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
|
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
|
||||||
path('weekly-summary/', WeeklySummaryView.as_view(), name='user-weekly-summary'),
|
path('weekly-summary/', WeeklySummaryView.as_view(), name='user-weekly-summary'),
|
||||||
|
|||||||
@ -1,783 +1,234 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from datetime import time as datetime_time
|
|
||||||
|
|
||||||
from decouple import config
|
from decouple import config
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import generics, status
|
from rest_framework import generics, status
|
||||||
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .models import TimeEntry
|
from .models import TimeEntry
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
TimeEntryCreateSerializer,
|
TimeEntryCreateSerializer, TimeEntrySerializer,
|
||||||
TimeEntrySerializer,
|
TimeEntryUpdateSerializer, WeeklySummarySerializer,
|
||||||
TimeEntryUpdateSerializer,
|
|
||||||
WeeklySummarySerializer,
|
|
||||||
)
|
)
|
||||||
|
from .services import lock_user, save_period
|
||||||
|
|
||||||
User = get_user_model()
|
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):
|
class TimeEntryListView(generics.ListAPIView):
|
||||||
serializer_class = TimeEntrySerializer
|
serializer_class = TimeEntrySerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get_queryset(self):
|
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TimeEntryCreateView(generics.CreateAPIView):
|
class TimeEntryCreateView(generics.CreateAPIView):
|
||||||
serializer_class = TimeEntryCreateSerializer
|
serializer_class = TimeEntryCreateSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
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):
|
def create(self, request, *args, **kwargs):
|
||||||
serializer = self.get_serializer(
|
serializer = self.get_serializer(data=request.data)
|
||||||
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)
|
class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
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
|
|
||||||
):
|
|
||||||
serializer_class = TimeEntrySerializer
|
serializer_class = TimeEntrySerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
lookup_url_kwarg = 'entry_id'
|
lookup_url_kwarg = 'entry_id'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
user = self.request.user
|
return visible_entries(self.request.user)
|
||||||
|
|
||||||
if user.is_staff or user.is_superuser:
|
@transaction.atomic
|
||||||
return TimeEntry.objects.all()
|
def update(self, request, *args, **kwargs):
|
||||||
|
entry = self.get_object()
|
||||||
return TimeEntry.objects.filter(
|
lock_user(entry.user)
|
||||||
user=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]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def put(self, request, entry_id):
|
def get_entries(self, request, group_id):
|
||||||
try:
|
queryset = visible_entries(request.user).filter(group_id=group_id)
|
||||||
entry = TimeEntry.objects.get(
|
first = queryset.order_by('start_date', 'start_time').first()
|
||||||
id=entry_id,
|
if first is None:
|
||||||
)
|
from rest_framework.exceptions import NotFound
|
||||||
|
raise NotFound()
|
||||||
except TimeEntry.DoesNotExist:
|
lock_user(first.user)
|
||||||
return Response(
|
entries = list(queryset.filter(user=first.user).select_for_update().order_by('start_date', 'start_time'))
|
||||||
{
|
if not entries:
|
||||||
'error': 'Pointage non trouvé'
|
from rest_framework.exceptions import NotFound
|
||||||
},
|
raise NotFound()
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
return entries
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def patch(self, request, group_id):
|
||||||
|
entries = self.get_entries(request, group_id)
|
||||||
serializer = TimeEntryUpdateSerializer(
|
serializer = TimeEntryUpdateSerializer(
|
||||||
entry,
|
entries[0], data=request.data, partial=True,
|
||||||
data=request.data,
|
context={'entries': entries},
|
||||||
)
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
result = serializer.save()
|
||||||
|
return Response(TimeEntrySerializer(result).data)
|
||||||
|
|
||||||
if serializer.is_valid():
|
put = patch
|
||||||
serializer.save()
|
|
||||||
|
|
||||||
return Response(
|
@transaction.atomic
|
||||||
serializer.data,
|
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,
|
class TimeEntryScanView(APIView):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
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):
|
def build_weekly_summary(user):
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
|
start_of_week = today - timedelta(days=today.weekday())
|
||||||
start_of_week = (
|
entries = list(TimeEntry.objects.filter(
|
||||||
today
|
user=user, start_date__range=(start_of_week, start_of_week + timedelta(days=6)),
|
||||||
- timedelta(
|
).order_by('start_date', 'start_time'))
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
days_data = []
|
days_data = []
|
||||||
|
for offset in range(7):
|
||||||
for day_offset in range(7):
|
day = start_of_week + timedelta(days=offset)
|
||||||
current_day = (
|
day_entries = [entry for entry in entries if entry.start_date == day]
|
||||||
start_of_week
|
completed = [entry for entry in day_entries if entry.end_time is not None]
|
||||||
+ timedelta(
|
days_data.append({
|
||||||
days=day_offset,
|
'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),
|
||||||
entry = entries_by_date.get(
|
'entries_count': len(day_entries),
|
||||||
current_day,
|
})
|
||||||
)
|
return dict(user=user.username, username=user.username,
|
||||||
|
total_hours=round(sum(entry.duration or 0 for entry in entries), 2), entries=days_data)
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WeeklySummaryView(APIView):
|
class WeeklySummaryView(APIView):
|
||||||
@ -785,31 +236,8 @@ class WeeklySummaryView(APIView):
|
|||||||
|
|
||||||
def get(self, request, user_id=None):
|
def get(self, request, user_id=None):
|
||||||
user = request.user
|
user = request.user
|
||||||
|
if user_id is not None and user_id != user.pk:
|
||||||
if (
|
if not (user.is_staff or user.is_superuser):
|
||||||
user_id
|
raise PermissionDenied('Vous ne pouvez pas consulter ce résumé.')
|
||||||
and (
|
user = get_object_or_404(User, pk=user_id)
|
||||||
request.user.is_staff
|
return Response(WeeklySummarySerializer(build_weekly_summary(user)).data)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
from django.contrib.auth import get_user_model
|
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.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@ -11,6 +13,7 @@ User = get_user_model()
|
|||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
is_admin = serializers.SerializerMethodField()
|
is_admin = serializers.SerializerMethodField()
|
||||||
|
color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@ -32,6 +35,20 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
def get_is_admin(self, obj):
|
def get_is_admin(self, obj):
|
||||||
return obj.is_superuser or obj.is_staff
|
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):
|
class RegisterSerializer(serializers.ModelSerializer):
|
||||||
password = serializers.CharField(write_only=True)
|
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
|
return attrs
|
||||||
|
|
||||||
def validate_invite(self, value):
|
def validate_invite(self, value):
|
||||||
|
|||||||
@ -2,7 +2,7 @@ from rest_framework import generics
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.views import APIView
|
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 django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from .models import Invitation, User
|
from .models import Invitation, User
|
||||||
@ -48,6 +48,13 @@ class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
return super().update(request, *args, **kwargs)
|
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):
|
class UserPromoteView(APIView):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
@ -58,6 +65,8 @@ class UserPromoteView(APIView):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
user_obj = User.objects.get(id=user)
|
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.is_staff = True
|
||||||
user_obj.save()
|
user_obj.save()
|
||||||
return Response({'message': 'Utilisateur promu avec succès'}, status=200)
|
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)
|
return Response({'error': 'Utilisateur non trouvé'}, status=404)
|
||||||
|
|
||||||
|
|
||||||
class UserDeleteView(APIView):
|
class UserDeleteView(UserDetailView):
|
||||||
permission_classes = (IsAuthenticated,)
|
http_method_names = ['delete', 'options']
|
||||||
|
|
||||||
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 InvitationCreateView(APIView):
|
class InvitationCreateView(APIView):
|
||||||
@ -98,4 +92,3 @@ class InvitationCreateView(APIView):
|
|||||||
'token': str(invitation.token),
|
'token': str(invitation.token),
|
||||||
'expires_at': invitation.expires_at,
|
'expires_at': invitation.expires_at,
|
||||||
}, status=201)
|
}, status=201)
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint src vite.config.js",
|
||||||
|
"test": "node --test tests/*.test.js",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -13,14 +13,17 @@ import {
|
|||||||
|
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
|
import MyspacePage from './pages/MyspacePage';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import AdminRoute from './components/AdminRoute';
|
import AdminRoute from './components/AdminRoute';
|
||||||
|
import UnloggedRoute from './components/UnloggedRoute';
|
||||||
import NavBar from './components/NavBar';
|
import NavBar from './components/NavBar';
|
||||||
import AdminUsersPage from './pages/AdminUsersPage';
|
import AdminUsersPage from './pages/AdminUsersPage';
|
||||||
import RegisterPage from './pages/RegisterPage';
|
import RegisterPage from './pages/RegisterPage';
|
||||||
import TimeClockPage from './pages/TimeClockPage';
|
import TimeClockPage from './pages/TimeClockPage';
|
||||||
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
||||||
import CalendarPage from './pages/CalendarPage';
|
import CalendarPage from './pages/CalendarPage';
|
||||||
|
import NotesPage from './pages/NotesPage';
|
||||||
import { useAuth } from './context/useAuth';
|
import { useAuth } from './context/useAuth';
|
||||||
|
|
||||||
// Composant personnalisé pour le trigger mobile avec icône menu
|
// Composant personnalisé pour le trigger mobile avec icône menu
|
||||||
@ -65,12 +68,20 @@ function App() {
|
|||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={<LoginPage />}
|
element={
|
||||||
|
<UnloggedRoute>
|
||||||
|
<LoginPage />
|
||||||
|
</UnloggedRoute>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/register"
|
path="/register"
|
||||||
element={<RegisterPage />}
|
element={
|
||||||
|
<UnloggedRoute>
|
||||||
|
<RegisterPage />
|
||||||
|
</UnloggedRoute>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
@ -82,6 +93,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/myspace"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MyspacePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
@ -123,6 +143,8 @@ function App() {
|
|||||||
element={<Navigate to="/" replace />}
|
element={<Navigate to="/" replace />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route path="/notes" element={<ProtectedRoute><NotesPage /></ProtectedRoute>} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -142,3 +164,4 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
|
|||||||
@ -1,46 +1,76 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { getApiErrorMessage } from './errors.js';
|
||||||
const apiHost = window.location.hostname;
|
|
||||||
|
|
||||||
const api = axios.create({
|
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
|
export function clearSession() {
|
||||||
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
|
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refresh');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
// Rediriger vers la page de login
|
window.dispatchEvent(new Event('auth:logout'));
|
||||||
if (window.location.pathname !== '/login') {
|
}
|
||||||
|
|
||||||
|
function expireSession() {
|
||||||
|
clearSession();
|
||||||
|
if (!['/login', '/register'].includes(window.location.pathname)) {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ajouter un intercepteur pour les requêtes
|
function refreshAccessToken() {
|
||||||
api.interceptors.request.use(
|
if (!refreshPromise) {
|
||||||
(config) => {
|
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');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
config._publicRequest = publicRequest;
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
if (token && !publicRequest) config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
else delete config.headers.Authorization;
|
||||||
// Ajouter un header pour les requêtes CORS
|
|
||||||
config.headers['Content-Type'] = 'application/json';
|
|
||||||
config.headers['Accept'] = 'application/json';
|
|
||||||
return config;
|
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;
|
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 = {
|
export const eventApi = {
|
||||||
// Récupérer tous les événements
|
getAllEvents: async () => (await api.get('/event/')).data,
|
||||||
getAllEvents: async () => {
|
createEvent: async (data) => (await api.post('/event/create/', data)).data,
|
||||||
try {
|
updateEvent: async (id, data) => (await api.patch(`/event/${id}/`, data)).data,
|
||||||
const response = await axios.get('/event/');
|
deleteEvent: async (id) => (await api.delete(`/event/${id}/`)).data,
|
||||||
return response.data;
|
getEvent: async (id) => (await api.get(`/event/${id}/`)).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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
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 = {
|
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 {
|
try {
|
||||||
const response = await api.get('/time/entries/');
|
return (await api.post('/time/entries/scan/', { code, ...options })).data;
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
const data = error.response?.data || {};
|
||||||
'Failed to fetch time entries: ' +
|
error.conflict = data.conflict;
|
||||||
error.message,
|
error.resolution = data.resolution;
|
||||||
);
|
error.missingTime = data.missing_time;
|
||||||
}
|
error.scanTime = data.scan_time;
|
||||||
},
|
error.scanDate = data.scan_date;
|
||||||
|
error.entryId = data.entry_id;
|
||||||
getUsers: async () => {
|
error.code = error.response?.status;
|
||||||
try {
|
throw error;
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -1,52 +1,9 @@
|
|||||||
import api from './axios';
|
import api from './axios';
|
||||||
|
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
// Get all users
|
getUsers: async () => (await api.get('/users/')).data,
|
||||||
getUsers: async () => {
|
deleteUser: async (id) => (await api.delete(`/users/${id}/`)).data,
|
||||||
try {
|
promoteUser: async (id) => (await api.post(`/users/${id}/promote/`)).data,
|
||||||
const response = await api.get('/users/');
|
updateUser: async (id, data) => (await api.patch(`/users/${id}/`, data)).data,
|
||||||
return response.data;
|
createInvitation: async () => (await api.post('/users/invitations/')).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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const AdminRoute = ({ children }) => {
|
|||||||
|
|
||||||
// If user is not admin, redirect to profile page
|
// If user is not admin, redirect to profile page
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return <Navigate to="/profile" state={{ from: location }} replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
|
|||||||
@ -8,22 +8,23 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
const ConfirmDialog = ({
|
const ConfirmDialog = ({
|
||||||
title = "Confirmation",
|
title = 'Confirmation',
|
||||||
description,
|
description,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
children,
|
children,
|
||||||
confirmText = "Confirmer",
|
confirmText = 'Confirmer',
|
||||||
cancelText = "Annuler"
|
cancelText = 'Annuler',
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
||||||
if (onConfirm) {
|
if (onConfirm) {
|
||||||
onConfirm();
|
onConfirm();
|
||||||
}
|
}
|
||||||
@ -31,24 +32,40 @@ const ConfirmDialog = ({
|
|||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
||||||
if (onCancel) {
|
if (onCancel) {
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
<AlertDialog
|
||||||
<AlertDialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
open={isOpen}
|
||||||
{children}
|
onOpenChange={setIsOpen}
|
||||||
</AlertDialogTrigger>
|
>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={children}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
{title}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{description}
|
||||||
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={handleCancel}>{cancelText}</AlertDialogCancel>
|
<AlertDialogCancel onClick={handleCancel}>
|
||||||
<AlertDialogAction onClick={handleConfirm}>{confirmText}</AlertDialogAction>
|
{cancelText}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
|
||||||
|
<AlertDialogAction onClick={handleConfirm}>
|
||||||
|
{confirmText}
|
||||||
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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,
|
LogOut,
|
||||||
Wheat,
|
Wheat,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
ClipboardList,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const NavBar = () => {
|
const NavBar = () => {
|
||||||
@ -68,10 +69,13 @@ const NavBar = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Navigation principale
|
||||||
|
*/
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
title: 'Profil',
|
title: user.first_name,
|
||||||
href: '/profile',
|
href: '/myspace',
|
||||||
icon: User,
|
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 = `
|
const buttonClass = `
|
||||||
w-full
|
w-full
|
||||||
@ -116,7 +148,6 @@ const NavBar = () => {
|
|||||||
px-2
|
px-2
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{/* Nom du groupe */}
|
|
||||||
<span
|
<span
|
||||||
className="
|
className="
|
||||||
truncate
|
truncate
|
||||||
@ -128,7 +159,6 @@ const NavBar = () => {
|
|||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Séparation en mode plié */}
|
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
hidden
|
hidden
|
||||||
@ -141,6 +171,40 @@ const NavBar = () => {
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
className="border-0"
|
className="border-0"
|
||||||
@ -195,34 +259,7 @@ const NavBar = () => {
|
|||||||
|
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{navigation.map((item) => {
|
{renderMenuItems(navigation)}
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@ -235,25 +272,7 @@ const NavBar = () => {
|
|||||||
|
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem className={menuItemClass}>
|
{renderMenuItems(entreprise)}
|
||||||
<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>
|
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@ -267,25 +286,7 @@ const NavBar = () => {
|
|||||||
|
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem className={menuItemClass}>
|
{renderMenuItems(administration)}
|
||||||
<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>
|
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</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 { useState, useEffect } from 'react';
|
||||||
import api from '../api/axios';
|
import api, { clearSession } from '../api/axios';
|
||||||
import { AuthContext } from './authContextValue';
|
import { AuthContext } from './authContextValue';
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
@ -8,31 +8,40 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const onLogout = () => {
|
||||||
|
setUser(null);
|
||||||
|
setIsAdmin(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('auth:logout', onLogout);
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const savedUser = localStorage.getItem('user');
|
|
||||||
|
|
||||||
if (token && savedUser) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
// Verify token by fetching user profile
|
// Verify token by fetching user profile
|
||||||
const response = await api.get('/users/me/');
|
const response = await api.get('/users/me/');
|
||||||
const userData = response.data;
|
const userData = response.data;
|
||||||
|
|
||||||
|
if (!cancelled && localStorage.getItem('token')) {
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
setIsAdmin(userData.is_admin || false);
|
setIsAdmin(userData.is_admin || false);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to verify authentication', error);
|
console.error('Failed to verify authentication', error);
|
||||||
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('refresh');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.removeEventListener('auth:logout', onLogout);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (username, password) => {
|
const login = async (username, password) => {
|
||||||
@ -65,9 +74,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('token');
|
clearSession();
|
||||||
localStorage.removeItem('refresh');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
|
|||||||
@ -17,12 +17,12 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
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 { Label } from '@/components/ui/label';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
import { Users} from 'lucide-react';
|
import { Users} from 'lucide-react';
|
||||||
const AdminUsersPage = () => {
|
const AdminUsersPage = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -51,24 +51,6 @@ const AdminUsersPage = () => {
|
|||||||
console.error(error);
|
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 = () => {
|
const copyToClipboard = () => {
|
||||||
if (inviteLink) {
|
if (inviteLink) {
|
||||||
navigator.clipboard.writeText(inviteLink).then(() => {
|
navigator.clipboard.writeText(inviteLink).then(() => {
|
||||||
@ -102,32 +84,19 @@ const AdminUsersPage = () => {
|
|||||||
try {
|
try {
|
||||||
await userApi.deleteUser(userId);
|
await userApi.deleteUser(userId);
|
||||||
|
|
||||||
if (String(userId) === String(getUserId(user))) {
|
|
||||||
logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUsers(users.filter(user => String(user.id) !== String(userId)));
|
setUsers(users.filter(user => String(user.id) !== String(userId)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Erreur lors de la suppression de l\'utilisateur');
|
setError(err.message);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMakeAdmin = async (userId) => {
|
const handleMakeAdmin = async (userId) => {
|
||||||
const confirmed = await new Promise((resolve) => {
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await userApi.promoteUser(userId);
|
await userApi.promoteUser(userId);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Erreur lors de la promotion de l\'utilisateur');
|
setError(err.message);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -139,10 +108,12 @@ const AdminUsersPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Erreur lors de la mise à jour de l\'utilisateur');
|
setError(err.message);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -260,20 +231,31 @@ const AdminUsersPage = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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
|
Éditer
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="border-b">
|
<DialogHeader className="border-b">
|
||||||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-2 gap-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 ?"
|
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
|
||||||
onConfirm={() => handleDeleteUser(user.id)}
|
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
|
Supprimer
|
||||||
</Button>
|
</Button>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,72 +1,77 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import LoadingCard from '@/components/LoadingCard';
|
import { Link } from 'react-router-dom';
|
||||||
import MainCard from '@/components/MainCard';
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import MainCard from "@/components/MainCard"
|
||||||
CardContent,
|
import { House } from 'lucide-react';
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { User } from 'lucide-react';
|
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { user } = useAuth();
|
const tabs = [
|
||||||
|
{
|
||||||
if (!user) {
|
name: 'Accueil',
|
||||||
return (
|
description: 'Page d\'accueil principale avec la liste des onglets et leurs utilités',
|
||||||
<LoadingCard
|
path: '/'
|
||||||
icon={User}
|
},
|
||||||
title="Profil"
|
{
|
||||||
message="Chargement du profil..."
|
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 (
|
return (
|
||||||
<MainCard title="Profil" icon={User}>
|
<MainCard title="Accueil" icon={House}>
|
||||||
|
|
||||||
<CardContent className="overflow-y-auto">
|
<main className="home-content">
|
||||||
<div className="space-y-4">
|
<div className="tabs-list">
|
||||||
<div className="flex items-center justify-between">
|
<h2>Liste des onglets</h2>
|
||||||
<span className="font-medium">Prénom:</span>
|
<div className="tabs-grid">
|
||||||
<span>{user.first_name}</span>
|
{tabs.map((tab, index) => (
|
||||||
</div>
|
<Card key={index} className="tab-card">
|
||||||
|
<CardHeader>
|
||||||
<Separator />
|
<CardTitle>{tab.name}</CardTitle>
|
||||||
|
<CardDescription>{tab.description}</CardDescription>
|
||||||
<div className="flex items-center justify-between">
|
</CardHeader>
|
||||||
<span className="font-medium">Nom:</span>
|
<CardContent>
|
||||||
<span>{user.last_name}</span>
|
<Button asChild variant="default">
|
||||||
</div>
|
<Link to={tab.path}>
|
||||||
|
Accéder à {tab.name}
|
||||||
<Separator />
|
</Link>
|
||||||
|
</Button>
|
||||||
<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>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="home-footer">
|
||||||
|
<p>© 2023 - Application</p>
|
||||||
|
</footer>
|
||||||
</MainCard>
|
</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
|
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
|
DATE PICKER
|
||||||
========================================================= */
|
========================================================= */
|
||||||
@ -446,11 +357,7 @@ const TimePicker = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (
|
const handleWheel = (event, type) => {
|
||||||
event,
|
|
||||||
type,
|
|
||||||
) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
const direction =
|
const direction =
|
||||||
@ -473,10 +380,7 @@ const TimePicker = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchMove = (
|
const handleTouchMove = (event, type) => {
|
||||||
event,
|
|
||||||
type,
|
|
||||||
) => {
|
|
||||||
if (!touchState.current.active) {
|
if (!touchState.current.active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -489,8 +393,7 @@ const TimePicker = ({
|
|||||||
|
|
||||||
touchState.current.lastY = currentY;
|
touchState.current.lastY = currentY;
|
||||||
|
|
||||||
touchState.current.accumulated +=
|
touchState.current.accumulated += deltaY;
|
||||||
deltaY;
|
|
||||||
|
|
||||||
const pixelsPerUnit = 18;
|
const pixelsPerUnit = 18;
|
||||||
|
|
||||||
@ -512,7 +415,6 @@ const TimePicker = ({
|
|||||||
changeMinute(steps);
|
changeMinute(steps);
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -792,7 +694,7 @@ const TimeClockPage = () => {
|
|||||||
newEntry,
|
newEntry,
|
||||||
setNewEntry,
|
setNewEntry,
|
||||||
] = useState({
|
] = useState({
|
||||||
date: today,
|
start_date: today,
|
||||||
end_date: today,
|
end_date: today,
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: '',
|
end_time: '',
|
||||||
@ -807,7 +709,7 @@ const TimeClockPage = () => {
|
|||||||
editForm,
|
editForm,
|
||||||
setEditForm,
|
setEditForm,
|
||||||
] = useState({
|
] = useState({
|
||||||
date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: '',
|
end_time: '',
|
||||||
@ -1005,24 +907,14 @@ const TimeClockPage = () => {
|
|||||||
isGrouped: false,
|
isGrouped: false,
|
||||||
sourceEntries: [entry],
|
sourceEntries: [entry],
|
||||||
displayStartDate:
|
displayStartDate:
|
||||||
entry.date,
|
entry.start_date,
|
||||||
displayEndDate:
|
displayEndDate:
|
||||||
entry.date,
|
entry.start_date,
|
||||||
displayStartTime:
|
displayStartTime:
|
||||||
entry.start_time,
|
entry.start_time,
|
||||||
displayEndTime:
|
displayEndTime:
|
||||||
entry.end_time,
|
entry.end_time,
|
||||||
displayDuration:
|
displayDuration: entry.duration,
|
||||||
calculateDuration({
|
|
||||||
startDate:
|
|
||||||
entry.date,
|
|
||||||
startTime:
|
|
||||||
entry.start_time,
|
|
||||||
endDate:
|
|
||||||
entry.date,
|
|
||||||
endTime:
|
|
||||||
entry.end_time,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -1059,10 +951,10 @@ const TimeClockPage = () => {
|
|||||||
(a, b) => {
|
(a, b) => {
|
||||||
const dateCompare =
|
const dateCompare =
|
||||||
String(
|
String(
|
||||||
a.date || '',
|
a.start_date || '',
|
||||||
).localeCompare(
|
).localeCompare(
|
||||||
String(
|
String(
|
||||||
b.date || '',
|
b.start_date || '',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1091,11 +983,11 @@ const TimeClockPage = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const startDate =
|
const startDate =
|
||||||
firstEntry?.date ||
|
firstEntry?.start_date ||
|
||||||
'';
|
'';
|
||||||
|
|
||||||
const endDate =
|
const endDate =
|
||||||
lastEntry?.date ||
|
lastEntry?.start_date ||
|
||||||
startDate;
|
startDate;
|
||||||
|
|
||||||
const startTime =
|
const startTime =
|
||||||
@ -1121,7 +1013,7 @@ const TimeClockPage = () => {
|
|||||||
sourceEntries:
|
sourceEntries:
|
||||||
sortedEntries,
|
sortedEntries,
|
||||||
|
|
||||||
date: startDate,
|
start_date: startDate,
|
||||||
|
|
||||||
end_date: endDate,
|
end_date: endDate,
|
||||||
|
|
||||||
@ -1143,13 +1035,9 @@ const TimeClockPage = () => {
|
|||||||
displayEndTime:
|
displayEndTime:
|
||||||
endTime,
|
endTime,
|
||||||
|
|
||||||
displayDuration:
|
displayDuration: sortedEntries.every((item) => item.duration != null)
|
||||||
calculateDuration({
|
? sortedEntries.reduce((total, item) => total + Number(item.duration), 0)
|
||||||
startDate,
|
: null,
|
||||||
startTime,
|
|
||||||
endDate,
|
|
||||||
endTime,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -1166,12 +1054,12 @@ const TimeClockPage = () => {
|
|||||||
const dateCompare =
|
const dateCompare =
|
||||||
String(
|
String(
|
||||||
b.displayStartDate ||
|
b.displayStartDate ||
|
||||||
b.date ||
|
b.start_date ||
|
||||||
'',
|
'',
|
||||||
).localeCompare(
|
).localeCompare(
|
||||||
String(
|
String(
|
||||||
a.displayStartDate ||
|
a.displayStartDate ||
|
||||||
a.date ||
|
a.start_date ||
|
||||||
'',
|
'',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -1393,12 +1281,12 @@ const TimeClockPage = () => {
|
|||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
||||||
const validateEntry = ({
|
const validateEntry = ({
|
||||||
date,
|
start_date,
|
||||||
end_date,
|
end_date,
|
||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
}) => {
|
}) => {
|
||||||
if (!date || !end_date) {
|
if (!start_date || !end_date) {
|
||||||
return 'Veuillez renseigner les dates.';
|
return 'Veuillez renseigner les dates.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1409,12 +1297,12 @@ const TimeClockPage = () => {
|
|||||||
return 'Veuillez renseigner une heure d’entrée et une heure de sortie.';
|
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.';
|
return 'La date de sortie doit être postérieure ou égale à la date d’entrée.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
date === end_date &&
|
start_date === end_date &&
|
||||||
end_time <= start_time
|
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.";
|
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 {
|
try {
|
||||||
await timeApi.createTimeEntry(
|
await timeApi.createTimeEntry(
|
||||||
{
|
{
|
||||||
date: newEntry.date,
|
start_date: newEntry.start_date,
|
||||||
|
|
||||||
end_date:
|
end_date:
|
||||||
newEntry.end_date,
|
newEntry.end_date,
|
||||||
@ -1485,16 +1373,15 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
setNewEntry({
|
setNewEntry({
|
||||||
date: today,
|
start_date: today,
|
||||||
end_date: today,
|
end_date: today,
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: '',
|
end_time: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
await loadTimeEntries();
|
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
await loadTimeEntries();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err.message ||
|
err.message ||
|
||||||
@ -1524,10 +1411,10 @@ const TimeClockPage = () => {
|
|||||||
(a, b) => {
|
(a, b) => {
|
||||||
const dateCompare =
|
const dateCompare =
|
||||||
String(
|
String(
|
||||||
a.date || '',
|
a.start_date || '',
|
||||||
).localeCompare(
|
).localeCompare(
|
||||||
String(
|
String(
|
||||||
b.date || '',
|
b.start_date || '',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1556,15 +1443,15 @@ const TimeClockPage = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
setEditForm({
|
setEditForm({
|
||||||
date:
|
start_date:
|
||||||
firstEntry?.date ||
|
firstEntry?.start_date ||
|
||||||
entry.date ||
|
entry.start_date ||
|
||||||
'',
|
'',
|
||||||
|
|
||||||
end_date:
|
end_date:
|
||||||
lastEntry?.date ||
|
lastEntry?.start_date ||
|
||||||
entry.end_date ||
|
entry.end_date ||
|
||||||
entry.date ||
|
entry.start_date ||
|
||||||
'',
|
'',
|
||||||
|
|
||||||
start_time:
|
start_time:
|
||||||
@ -1577,12 +1464,12 @@ const TimeClockPage = () => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setEditForm({
|
setEditForm({
|
||||||
date:
|
start_date:
|
||||||
entry.date || '',
|
entry.start_date || '',
|
||||||
|
|
||||||
end_date:
|
end_date:
|
||||||
entry.end_date ||
|
entry.end_date ||
|
||||||
entry.date ||
|
entry.start_date ||
|
||||||
'',
|
'',
|
||||||
|
|
||||||
start_time:
|
start_time:
|
||||||
@ -1618,122 +1505,23 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (
|
if (editingEntry.isGrouped) {
|
||||||
editingEntry.isGrouped
|
await timeApi.updateTimeEntryGroup(editingEntry.group_id, editForm);
|
||||||
) {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
await timeApi.updateTimeEntry(
|
await timeApi.updateTimeEntry(editingEntry.id, editForm);
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await timeApi.updateTimeEntry(
|
|
||||||
editingEntry.id,
|
|
||||||
{
|
|
||||||
start_time:
|
|
||||||
editForm.start_time,
|
|
||||||
|
|
||||||
end_time:
|
|
||||||
editForm.end_time,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
|
|
||||||
setEditForm({
|
setEditForm({
|
||||||
date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: '',
|
end_time: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
await loadTimeEntries();
|
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
await loadTimeEntries();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err.message ||
|
err.message ||
|
||||||
@ -1752,30 +1540,13 @@ const TimeClockPage = () => {
|
|||||||
const handleDeleteEntry =
|
const handleDeleteEntry =
|
||||||
async (entry) => {
|
async (entry) => {
|
||||||
try {
|
try {
|
||||||
if (
|
if (entry.isGrouped) {
|
||||||
entry?.isGrouped &&
|
await timeApi.deleteTimeEntryGroup(entry.group_id);
|
||||||
Array.isArray(
|
|
||||||
entry.sourceEntries,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
for (
|
|
||||||
const sourceEntry of
|
|
||||||
entry.sourceEntries
|
|
||||||
) {
|
|
||||||
await timeApi.deleteTimeEntry(
|
|
||||||
sourceEntry.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await timeApi.deleteTimeEntry(
|
await timeApi.deleteTimeEntry(entry.id);
|
||||||
entry.id,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
await loadTimeEntries();
|
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
await loadTimeEntries();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
'Erreur lors de la suppression du pointage',
|
'Erreur lors de la suppression du pointage',
|
||||||
@ -1897,6 +1668,8 @@ const TimeClockPage = () => {
|
|||||||
scanError.missingTime,
|
scanError.missingTime,
|
||||||
scanTime:
|
scanTime:
|
||||||
scanError.scanTime,
|
scanError.scanTime,
|
||||||
|
scanDate: scanError.scanDate,
|
||||||
|
entryId: scanError.entryId,
|
||||||
step: 'confirm',
|
step: 'confirm',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2017,6 +1790,8 @@ const TimeClockPage = () => {
|
|||||||
resolution:
|
resolution:
|
||||||
config.resolution,
|
config.resolution,
|
||||||
time: conflictTime,
|
time: conflictTime,
|
||||||
|
scan_date: scanConflict.scanDate,
|
||||||
|
entry_id: scanConflict.entryId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -2411,16 +2186,16 @@ const TimeClockPage = () => {
|
|||||||
const formatEntryDate = (
|
const formatEntryDate = (
|
||||||
entry,
|
entry,
|
||||||
) => {
|
) => {
|
||||||
if (!entry?.date) {
|
if (!entry?.start_date) {
|
||||||
return '────';
|
return '────';
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDate =
|
const startDate =
|
||||||
formatDate(entry.date);
|
formatDate(entry.start_date);
|
||||||
|
|
||||||
const endDate =
|
const endDate =
|
||||||
entry.end_date &&
|
entry.end_date &&
|
||||||
entry.end_date !== entry.date
|
entry.end_date !== entry.start_date
|
||||||
? formatDate(
|
? formatDate(
|
||||||
entry.end_date,
|
entry.end_date,
|
||||||
)
|
)
|
||||||
@ -2901,13 +2676,13 @@ const TimeClockPage = () => {
|
|||||||
{editingEntry.isGrouped ? (
|
{editingEntry.isGrouped ? (
|
||||||
<div className="flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
|
<div className="flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
|
||||||
{formatDateLabel(
|
{formatDateLabel(
|
||||||
editForm.date,
|
editForm.start_date,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={
|
value={
|
||||||
editForm.date
|
editForm.start_date
|
||||||
}
|
}
|
||||||
onChange={(
|
onChange={(
|
||||||
value,
|
value,
|
||||||
@ -2915,7 +2690,7 @@ const TimeClockPage = () => {
|
|||||||
setEditForm(
|
setEditForm(
|
||||||
{
|
{
|
||||||
...editForm,
|
...editForm,
|
||||||
date:
|
start_date:
|
||||||
value,
|
value,
|
||||||
|
|
||||||
end_date:
|
end_date:
|
||||||
@ -2980,7 +2755,7 @@ const TimeClockPage = () => {
|
|||||||
editForm.end_date
|
editForm.end_date
|
||||||
}
|
}
|
||||||
min={
|
min={
|
||||||
editForm.date
|
editForm.start_date
|
||||||
}
|
}
|
||||||
onChange={(
|
onChange={(
|
||||||
value,
|
value,
|
||||||
@ -3080,7 +2855,7 @@ const TimeClockPage = () => {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={
|
value={
|
||||||
newEntry.date
|
newEntry.start_date
|
||||||
}
|
}
|
||||||
onChange={(
|
onChange={(
|
||||||
value,
|
value,
|
||||||
@ -3088,7 +2863,7 @@ const TimeClockPage = () => {
|
|||||||
setNewEntry(
|
setNewEntry(
|
||||||
{
|
{
|
||||||
...newEntry,
|
...newEntry,
|
||||||
date:
|
start_date:
|
||||||
value,
|
value,
|
||||||
|
|
||||||
end_date:
|
end_date:
|
||||||
@ -3145,7 +2920,7 @@ const TimeClockPage = () => {
|
|||||||
newEntry.end_date
|
newEntry.end_date
|
||||||
}
|
}
|
||||||
min={
|
min={
|
||||||
newEntry.date
|
newEntry.start_date
|
||||||
}
|
}
|
||||||
onChange={(
|
onChange={(
|
||||||
value,
|
value,
|
||||||
@ -3396,7 +3171,7 @@ const TimeClockPage = () => {
|
|||||||
description={
|
description={
|
||||||
entry.isGrouped
|
entry.isGrouped
|
||||||
? `Êtes-vous sûr de vouloir supprimer ce pointage multi-jours du ${formatDate(
|
? `Êtes-vous sûr de vouloir supprimer ce pointage multi-jours du ${formatDate(
|
||||||
entry.date,
|
entry.start_date,
|
||||||
)} au ${formatDate(
|
)} au ${formatDate(
|
||||||
entry.end_date,
|
entry.end_date,
|
||||||
)} ?`
|
)} ?`
|
||||||
|
|||||||
@ -8,13 +8,9 @@ import { timeApi } from '../api/time';
|
|||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
import LoadingCard from '@/components/LoadingCard';
|
import LoadingCard from '@/components/LoadingCard';
|
||||||
import MainCard from '@/components/MainCard';
|
import MainCard from '@/components/MainCard';
|
||||||
|
import HoverInfo from '@/components/HoverInfo';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { CardContent } from '@/components/ui/card';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@ -29,15 +25,6 @@ const DEFAULT_END_HOUR = 18;
|
|||||||
const SCHEDULE_HEADER_HEIGHT = 58;
|
const SCHEDULE_HEADER_HEIGHT = 58;
|
||||||
const MIN_SCHEDULE_HEIGHT = 180;
|
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_MIN_HOUR_HEIGHT = 52;
|
||||||
|
|
||||||
const MOBILE_TIME_COLUMN_WIDTH = 64;
|
const MOBILE_TIME_COLUMN_WIDTH = 64;
|
||||||
@ -94,6 +81,55 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
const [mobileContainerWidth, setMobileContainerWidth] =
|
const [mobileContainerWidth, setMobileContainerWidth] =
|
||||||
useState(0);
|
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);
|
const scheduleTableRef = useRef(null);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -223,6 +259,16 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingUser = users.find(
|
||||||
|
(user) =>
|
||||||
|
getUserId(user) ===
|
||||||
|
String(entryUserId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return existingUser;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: entryUserId,
|
id: entryUserId,
|
||||||
first_name: entry.first_name,
|
first_name: entry.first_name,
|
||||||
@ -400,15 +446,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
scheduleTable.clientWidth,
|
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) {
|
if (window.innerWidth < 768) {
|
||||||
setMobileContainerHeight(
|
setMobileContainerHeight(
|
||||||
scheduleTable.clientHeight,
|
scheduleTable.clientHeight,
|
||||||
@ -417,10 +454,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* DESKTOP
|
|
||||||
*/
|
|
||||||
|
|
||||||
const cardContent =
|
const cardContent =
|
||||||
scheduleTable.closest(
|
scheduleTable.closest(
|
||||||
'[data-slot="card-content"]',
|
'[data-slot="card-content"]',
|
||||||
@ -634,7 +667,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
attendances,
|
attendances,
|
||||||
).filter(
|
).filter(
|
||||||
(attendance) =>
|
(attendance) =>
|
||||||
String(attendance.date).slice(
|
String(attendance.start_date).slice(
|
||||||
0,
|
0,
|
||||||
10,
|
10,
|
||||||
) === dateKey,
|
) === dateKey,
|
||||||
@ -694,6 +727,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setCurrentWeek(newWeek);
|
setCurrentWeek(newWeek);
|
||||||
|
hideHoverInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateDay = (
|
const navigateDay = (
|
||||||
@ -703,6 +737,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
selectedDayIndex +
|
selectedDayIndex +
|
||||||
direction;
|
direction;
|
||||||
|
|
||||||
|
hideHoverInfo();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
nextDayIndex < 0 ||
|
nextDayIndex < 0 ||
|
||||||
nextDayIndex > 6
|
nextDayIndex > 6
|
||||||
@ -746,7 +782,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
(attendance) =>
|
(attendance) =>
|
||||||
weekDateKeys.has(
|
weekDateKeys.has(
|
||||||
String(
|
String(
|
||||||
attendance.date,
|
attendance.start_date,
|
||||||
).slice(0, 10),
|
).slice(0, 10),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -926,7 +962,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
(attendance) =>
|
(attendance) =>
|
||||||
weekDateKeys.has(
|
weekDateKeys.has(
|
||||||
String(
|
String(
|
||||||
attendance.date,
|
attendance.start_date,
|
||||||
).slice(0, 10),
|
).slice(0, 10),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -1021,23 +1057,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
* ------------------------------------------------------------
|
* ------------------------------------------------------------
|
||||||
* Hauteur du planning
|
* 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 =
|
const mobileAvailableBodyHeight =
|
||||||
@ -1178,8 +1197,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
return (
|
return (
|
||||||
<MainCard
|
<MainCard
|
||||||
icon={CalendarDays}
|
icon={CalendarDays}
|
||||||
className=
|
className={
|
||||||
{isMobile
|
isMobile
|
||||||
? 'overflow-visible'
|
? 'overflow-visible'
|
||||||
: 'overflow-hidden'
|
: 'overflow-hidden'
|
||||||
}
|
}
|
||||||
@ -1299,29 +1318,23 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
event.target.value,
|
event.target.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
hideHoverInfo();
|
||||||
setError('');
|
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"
|
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 ? (
|
{users.length === 0 ? (
|
||||||
<option value="">
|
<option value="">
|
||||||
Aucun utilisateur
|
Aucun utilisateur disponible
|
||||||
disponible
|
|
||||||
</option>
|
</option>
|
||||||
) : (
|
) : (
|
||||||
users.map(
|
users.map(
|
||||||
(user) => (
|
(user) => (
|
||||||
<option
|
<option
|
||||||
key={getUserId(
|
key={getUserId(user)}
|
||||||
user,
|
value={getUserId(user)}
|
||||||
)}
|
|
||||||
value={getUserId(
|
|
||||||
user,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{getUserLabel(
|
{getUserLabel(user)}
|
||||||
user,
|
|
||||||
)}
|
|
||||||
</option>
|
</option>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -1338,6 +1351,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
event.target.checked,
|
event.target.checked,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
hideHoverInfo();
|
||||||
setError('');
|
setError('');
|
||||||
}}
|
}}
|
||||||
className="size-4 accent-primary"
|
className="size-4 accent-primary"
|
||||||
@ -1535,9 +1549,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
user,
|
user,
|
||||||
)}
|
)}
|
||||||
className="relative border-r border-border/30 last:border-r-0"
|
className="relative border-r border-border/30 last:border-r-0"
|
||||||
title={getUserLabel(
|
|
||||||
user,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{schedules.map(
|
{schedules.map(
|
||||||
({
|
({
|
||||||
@ -1565,13 +1576,32 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
backgroundColor:
|
backgroundColor:
|
||||||
color,
|
color,
|
||||||
}}
|
}}
|
||||||
title={`${getUserLabel(
|
onMouseEnter={(event) =>
|
||||||
|
showHoverInfo({
|
||||||
|
event,
|
||||||
|
title:
|
||||||
|
getUserLabel(
|
||||||
user,
|
user,
|
||||||
)} : ${formatSegmentTime(
|
),
|
||||||
start,
|
startTime:
|
||||||
)} - ${formatSegmentTime(
|
getTimeValue(
|
||||||
end,
|
attendance,
|
||||||
)}`}
|
'start_time',
|
||||||
|
),
|
||||||
|
endTime:
|
||||||
|
getTimeValue(
|
||||||
|
attendance,
|
||||||
|
'end_time',
|
||||||
|
),
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMouseMove={
|
||||||
|
moveHoverInfo
|
||||||
|
}
|
||||||
|
onMouseLeave={
|
||||||
|
hideHoverInfo
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@ -1631,6 +1661,12 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
hourHeight,
|
hourHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const attendanceUser =
|
||||||
|
getEntryUser(
|
||||||
|
attendance,
|
||||||
|
) ||
|
||||||
|
selectedUser;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={
|
||||||
@ -1645,7 +1681,33 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
style={
|
style={
|
||||||
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">
|
<div className="p-1 text-xs text-white">
|
||||||
{formatSegmentTime(
|
{formatSegmentTime(
|
||||||
@ -1799,7 +1861,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
user,
|
user,
|
||||||
);
|
);
|
||||||
|
|
||||||
const color = user.color;
|
const color =
|
||||||
|
user.color;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -1807,9 +1870,6 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
user,
|
user,
|
||||||
)}
|
)}
|
||||||
className="relative min-w-0 border-r border-border/30 last:border-r-0"
|
className="relative min-w-0 border-r border-border/30 last:border-r-0"
|
||||||
title={getUserLabel(
|
|
||||||
user,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{schedules.map(
|
{schedules.map(
|
||||||
({
|
({
|
||||||
@ -1839,13 +1899,32 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
backgroundColor:
|
backgroundColor:
|
||||||
color,
|
color,
|
||||||
}}
|
}}
|
||||||
title={`${getUserLabel(
|
onMouseEnter={(event) =>
|
||||||
|
showHoverInfo({
|
||||||
|
event,
|
||||||
|
title:
|
||||||
|
getUserLabel(
|
||||||
user,
|
user,
|
||||||
)} : ${formatSegmentTime(
|
),
|
||||||
start,
|
startTime:
|
||||||
)} - ${formatSegmentTime(
|
getTimeValue(
|
||||||
end,
|
attendance,
|
||||||
)}`}
|
'start_time',
|
||||||
|
),
|
||||||
|
endTime:
|
||||||
|
getTimeValue(
|
||||||
|
attendance,
|
||||||
|
'end_time',
|
||||||
|
),
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMouseMove={
|
||||||
|
moveHoverInfo
|
||||||
|
}
|
||||||
|
onMouseLeave={
|
||||||
|
hideHoverInfo
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@ -1904,6 +1983,12 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
hourHeight,
|
hourHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const attendanceUser =
|
||||||
|
getEntryUser(
|
||||||
|
attendance,
|
||||||
|
) ||
|
||||||
|
selectedUser;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={
|
||||||
@ -1916,7 +2001,33 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
style={
|
style={
|
||||||
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="p-2 text-xs text-white">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
@ -1941,6 +2052,15 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HoverInfo
|
||||||
|
title={hoverInfo?.title}
|
||||||
|
description={hoverInfo?.description}
|
||||||
|
startTime={hoverInfo?.startTime}
|
||||||
|
endTime={hoverInfo?.endTime}
|
||||||
|
color={hoverInfo?.color}
|
||||||
|
position={hoverInfo?.position}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</MainCard>
|
</MainCard>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user