From 32750a0cd719c400aad7de58fb29962822a83e73 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Sat, 19 Sep 2026 14:38:51 +0200 Subject: [PATCH] nice --- backend/core/settings.py | 2 +- backend/time_tracking/models.py | 4 +- backend/time_tracking/serializers.py | 35 ++--- backend/users/apps.py | 50 +++++--- backend/users/migrations/0001_initial.py | 34 ++++- backend/users/models.py | 40 +++++- backend/users/serializers.py | 67 ++++++++-- backend/users/views.py | 18 +-- frontend/src/api/users.js | 9 ++ frontend/src/pages/AdminUsersPage.jsx | 156 +++++++++++++++++++++++ frontend/src/pages/LoginPage.jsx | 31 +++-- frontend/src/pages/WeeklySummaryPage.jsx | 82 ++++-------- 12 files changed, 379 insertions(+), 149 deletions(-) diff --git a/backend/core/settings.py b/backend/core/settings.py index 3d832d6..9e95823 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -105,6 +105,7 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +AUTH_USER_MODEL = 'users.User' # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ @@ -154,4 +155,3 @@ SIMPLE_JWT = { # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/backend/time_tracking/models.py b/backend/time_tracking/models.py index 7836b2d..d85ed99 100644 --- a/backend/time_tracking/models.py +++ b/backend/time_tracking/models.py @@ -1,9 +1,9 @@ from django.db import models -from django.contrib.auth.models import User +from django.conf import settings from datetime import datetime, date class TimeEntry(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='time_entries') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='time_entries') date = models.DateField() start_time = models.TimeField(null=True, blank=True) end_time = models.TimeField(null=True, blank=True) diff --git a/backend/time_tracking/serializers.py b/backend/time_tracking/serializers.py index 90b5823..495dc69 100644 --- a/backend/time_tracking/serializers.py +++ b/backend/time_tracking/serializers.py @@ -3,14 +3,20 @@ from django.contrib.auth.models import User from .models import TimeEntry class TimeEntrySerializer(serializers.ModelSerializer): - user = serializers.StringRelatedField(read_only=True) - username = serializers.CharField(source='user.username', read_only=True) - class Meta: model = TimeEntry - fields = ['id', 'user', 'username', 'date', 'start_time', 'end_time', 'duration', 'created_at'] - read_only_fields = ['user', 'duration', 'created_at'] + fields = '__all__' + read_only_fields = ('user',) + def create(self, validated_data): + # Set the user to the current request user + validated_data['user'] = self.context['request'].user + return super().create(validated_data) + + def update(self, instance, validated_data): + # Prevent updating the user field + validated_data.pop('user', None) + return super().update(instance, validated_data) class TimeEntryCreateSerializer(serializers.ModelSerializer): class Meta: model = TimeEntry @@ -36,22 +42,7 @@ class TimeEntryUpdateSerializer(serializers.ModelSerializer): return attrs class WeeklySummarySerializer(serializers.Serializer): - user = serializers.StringRelatedField() + user = serializers.CharField() username = serializers.CharField() total_hours = serializers.FloatField() - days = serializers.ListField() - - def to_representation(self, instance): - data = super().to_representation(instance) - # Format the days data for frontend consumption - formatted_days = [] - for day in data['days']: - formatted_days.append({ - 'date': day['date'], - 'day': day['day'], - 'start_time': day['start_time'], - 'end_time': day['end_time'], - 'duration': day['duration'] - }) - data['days'] = formatted_days - return data \ No newline at end of file + entries = serializers.ListField(child=serializers.DictField()) diff --git a/backend/users/apps.py b/backend/users/apps.py index 2b447c9..4b14488 100644 --- a/backend/users/apps.py +++ b/backend/users/apps.py @@ -14,26 +14,42 @@ class UsersConfig(AppConfig): if 'runserver' in sys.argv: try: # Import here to avoid issues during Django startup - from django.contrib.auth.models import User + from .models import User from decouple import config from django.db import transaction - # Check if any users exist - if not User.objects.exists(): - # Get credentials from environment variables or use defaults - username = config('ADMIN_USERNAME', default='admin') - password = config('ADMIN_PASSWORD', default='admin123') - email = config('ADMIN_EMAIL', default='admin@boulangerie.fr') - - # Create the admin user - with transaction.atomic(): - user = User.objects.create_superuser( - username=username, - email=email, - password=password - ) - - print(f'Successfully created admin user "{username}"') + username = config('ADMIN_USERNAME', default='').strip() + password = config('ADMIN_PASSWORD', default='') + first_name = config('ADMIN_FIRST_NAME', default='').strip() + last_name = config('ADMIN_LAST_NAME', default='').strip() + + if not username or not password: + print('ADMIN_USERNAME and ADMIN_PASSWORD are required; no admin was created.') + return + + with transaction.atomic(): + user, created = User.objects.get_or_create( + username=username, + defaults={ + 'first_name': first_name, + 'last_name': last_name, + 'is_staff': True, + 'is_superuser': True, + }, + ) + + if created: + user.set_password(password) + else: + user.first_name = first_name + user.last_name = last_name + user.is_staff = True + user.is_superuser = True + + user.save() + + action = 'created' if created else 'synchronized' + print(f'Admin user "{username}" {action}.') except Exception as e: # Don't fail the entire Django startup if there's an error print(f'Error creating admin user: {e}') diff --git a/backend/users/migrations/0001_initial.py b/backend/users/migrations/0001_initial.py index 1ca49d3..7812583 100644 --- a/backend/users/migrations/0001_initial.py +++ b/backend/users/migrations/0001_initial.py @@ -1,6 +1,9 @@ -# Generated by Django 5.2.17 on 2026-09-16 16:22 +# Generated by Django 5.2.17 on 2026-09-18 15:41 +import django.contrib.auth.models +import django.contrib.auth.validators import django.db.models.deletion +import django.utils.timezone import uuid from django.conf import settings from django.db import migrations, models @@ -11,10 +14,37 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('auth', '0012_alter_user_first_name_max_length'), ] operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('color', models.CharField(default='#0000ff', max_length=7)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), migrations.CreateModel( name='Invitation', fields=[ diff --git a/backend/users/models.py b/backend/users/models.py index b56459f..628270c 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,17 +1,43 @@ -from django.db import models -from django.contrib.auth.models import User import uuid -# Create your models here. +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class User(AbstractUser): + color = models.CharField( + max_length=7, + default='#0000ff', + ) + class Invitation(models.Model): - token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) - created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_invitations') + token = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + ) + + created_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='created_invitations', + ) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() - used_at = models.DateTimeField(null=True, blank=True) + + used_at = models.DateTimeField( + null=True, + blank=True, + ) @property def is_valid(self): from django.utils import timezone - return self.used_at is None and self.expires_at > timezone.now() + + return ( + self.used_at is None + and self.expires_at > timezone.now() + ) \ No newline at end of file diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 8d2c9e2..470120d 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.db import transaction from django.utils import timezone from rest_framework import serializers @@ -6,13 +6,28 @@ from rest_framework import serializers from .models import Invitation +User = get_user_model() + + class UserSerializer(serializers.ModelSerializer): is_admin = serializers.SerializerMethodField() class Meta: model = User - - fields = ['id', 'username', 'first_name', 'last_name', 'is_admin', 'date_joined'] + fields = [ + 'id', + 'username', + 'first_name', + 'last_name', + 'email', + 'is_active', + 'is_staff', + 'is_superuser', + 'is_admin', + 'date_joined', + 'color', + ] + read_only_fields = ['id', 'date_joined', 'is_admin'] def get_is_admin(self, obj): return obj.is_superuser or obj.is_staff @@ -25,31 +40,61 @@ class RegisterSerializer(serializers.ModelSerializer): class Meta: model = User - - fields = ['username', 'password', 'password_confirm', 'first_name', 'last_name', 'invite'] + fields = [ + 'username', + 'password', + 'password_confirm', + 'first_name', + 'last_name', + 'invite', + ] def validate(self, attrs): if attrs['password'] != attrs['password_confirm']: - raise serializers.ValidationError({'password_confirm': 'Les mots de passe ne correspondent pas.'}) + raise serializers.ValidationError({ + 'password_confirm': ( + 'Les mots de passe ne correspondent pas.' + ) + }) + return attrs def validate_invite(self, value): try: invitation = Invitation.objects.get(token=value) except Invitation.DoesNotExist as error: - raise serializers.ValidationError('Ce lien d’invitation est invalide.') from error + raise serializers.ValidationError( + 'Ce lien d’invitation est invalide.' + ) from error + if not invitation.is_valid: - raise serializers.ValidationError('Ce lien d’invitation est expire ou deja utilise.') + raise serializers.ValidationError( + 'Ce lien d’invitation est expiré ou déjà utilisé.' + ) + return value def create(self, validated_data): invitation_token = validated_data.pop('invite') validated_data.pop('password_confirm') + with transaction.atomic(): - invitation = Invitation.objects.select_for_update().get(token=invitation_token) + invitation = ( + Invitation.objects + .select_for_update() + .get(token=invitation_token) + ) + if not invitation.is_valid: - raise serializers.ValidationError({'invite': 'Ce lien d’invitation est expire ou deja utilise.'}) + raise serializers.ValidationError({ + 'invite': ( + 'Ce lien d’invitation est expiré ou déjà utilisé.' + ) + }) + user = User.objects.create_user(**validated_data) + invitation.used_at = timezone.now() invitation.save(update_fields=['used_at']) - return user + + return user \ No newline at end of file diff --git a/backend/users/views.py b/backend/users/views.py index 9bd4dd5..e0a940f 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -5,8 +5,7 @@ from rest_framework.views import APIView from rest_framework.exceptions import PermissionDenied from django.utils import timezone from datetime import timedelta -from django.contrib.auth.models import User -from .models import Invitation +from .models import Invitation, User from .serializers import UserSerializer, RegisterSerializer class RegisterView(generics.CreateAPIView): @@ -44,20 +43,10 @@ class UserDetailView(generics.RetrieveUpdateDestroyAPIView): return User.objects.all() def update(self, request, *args, **kwargs): - # For updating user permissions, we need to handle is_staff and is_superuser specially if not (request.user.is_staff or request.user.is_superuser): raise PermissionDenied('Seuls les administrateurs peuvent modifier les comptes.') - - user = self.get_object() - # Only allow updating specific fields - allowed_fields = ['is_staff', 'is_superuser'] - for field in allowed_fields: - if field in request.data: - setattr(user, field, request.data[field]) - - user.save() - serializer = self.get_serializer(user) - return Response(serializer.data) + + return super().update(request, *args, **kwargs) class UserPromoteView(APIView): @@ -109,3 +98,4 @@ class InvitationCreateView(APIView): 'token': str(invitation.token), 'expires_at': invitation.expires_at, }, status=201) + diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js index 9af5e17..197ac96 100644 --- a/frontend/src/api/users.js +++ b/frontend/src/api/users.js @@ -32,6 +32,15 @@ export const userApi = { } }, + 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/'); diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index 9b99703..562bca9 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -13,7 +13,10 @@ import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; import ConfirmDialog from '@/components/ConfirmDialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; const AdminUsersPage = () => { const [users, setUsers] = useState([]); @@ -21,6 +24,17 @@ const AdminUsersPage = () => { const [error, setError] = useState(''); const [inviteLink, setInviteLink] = useState(''); const [isCopied, setIsCopied] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [editForm, setEditForm] = useState({ + username: '', + first_name: '', + last_name: '', + email: '', + color: '#0000ff', + is_active: true, + is_staff: false, + is_superuser: false, + }); const generateInviteLink = async () => { try { @@ -106,6 +120,35 @@ const AdminUsersPage = () => { } }; + const handleEditUser = async (userId) => { + if (!/^#[0-9A-Fa-f]{6}$/.test(editForm.color)) { + setError('La couleur doit être au format hexadécimal, par exemple #2563eb.'); + return; + } + + try { + const updatedUser = await userApi.updateUser(userId, editForm); + const updatedUsers = users.map(user => + user.id === userId ? updatedUser : user + ); + setUsers(updatedUsers); + setEditingUser(null); + setEditForm({ + username: '', + first_name: '', + last_name: '', + email: '', + color: '#0000ff', + is_active: true, + is_staff: false, + is_superuser: false, + }); + } catch (err) { + setError('Erreur lors de la mise à jour de l\'utilisateur'); + console.error(err); + } + }; + if (loading) { return ( @@ -137,6 +180,7 @@ const AdminUsersPage = () => { Nom d'utilisateur + Couleur Rôle Inscrit le Actions @@ -146,6 +190,12 @@ const AdminUsersPage = () => { {users.map((user) => ( {user.username} + +
+
+ {user.color || 'Bleu'} +
+
{user.is_admin ? 'Administrateur' : 'Utilisateur'} @@ -154,6 +204,112 @@ const AdminUsersPage = () => { {new Date(user.date_joined).toLocaleDateString('fr-FR')}
+ { + if (!open) { + setEditingUser(null); + } else { + setEditingUser(user.id); + setEditForm({ + username: user.username || '', + 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, + }); + } + }}> + + + + + + Modifier l'utilisateur + +
+
+ + setEditForm({ ...editForm, username: event.target.value })} + /> +
+
+
+ + setEditForm({ ...editForm, first_name: event.target.value })} + /> +
+
+ + setEditForm({ ...editForm, last_name: event.target.value })} + /> +
+
+
+ + setEditForm({ ...editForm, email: event.target.value })} + /> +
+
+ +
+ setEditForm({ ...editForm, color: event.target.value })} + className="h-10 w-14 cursor-pointer p-1" + /> + setEditForm({ ...editForm, color: event.target.value })} + className="max-w-36 font-mono uppercase" + /> +
+
+ + +

Le mot de passe ne peut pas être modifié ici.

+
+ +
+
{!user.is_admin && ( {
setUsername(e.target.value)} - required - /> -
+ autoComplete="username" + value={username} + onChange={(e) => setUsername(e.target.value)} + required + /> +
setPassword(e.target.value)} - required - /> -
+ autoComplete="current-password" + value={password} + onChange={(e) => setPassword(e.target.value)} + required + /> + - +
- + ); }; diff --git a/frontend/src/pages/WeeklySummaryPage.jsx b/frontend/src/pages/WeeklySummaryPage.jsx index 0299ac9..6bca675 100644 --- a/frontend/src/pages/WeeklySummaryPage.jsx +++ b/frontend/src/pages/WeeklySummaryPage.jsx @@ -548,16 +548,6 @@ const WeeklySummaryPage = ({ userId }) => { Précédente - - { )} + + + )} + + {isMobile && ( +
+ + - {weekDays[ - selectedDayIndex - ].toLocaleDateString( + {displayDay.toLocaleDateString( 'fr-FR', { weekday: 'long', @@ -600,58 +610,12 @@ const WeeklySummaryPage = ({ userId }) => { variant="outline" size="sm" onClick={() => navigateDay(1)} - className="flex items-center gap-1" > - Jour suivant - - - -
)} - - - {isMobile && ( -
- - - - {displayDay.toLocaleDateString( - 'fr-FR', - { - weekday: 'long', - day: '2-digit', - month: 'short', - }, - )} - - -
- )}