This commit is contained in:
Louis Mazin 2026-09-19 14:38:51 +02:00
parent 29d1facc83
commit 32750a0cd7
12 changed files with 379 additions and 149 deletions

View File

@ -105,6 +105,7 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
AUTH_USER_MODEL = 'users.User'
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # https://docs.djangoproject.com/en/5.2/topics/i18n/
@ -154,4 +155,3 @@ SIMPLE_JWT = {
# 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
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View File

@ -1,9 +1,9 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.conf import settings
from datetime import datetime, date from datetime import datetime, date
class TimeEntry(models.Model): 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() date = models.DateField()
start_time = models.TimeField(null=True, blank=True) start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField(null=True, blank=True) end_time = models.TimeField(null=True, blank=True)

View File

@ -3,14 +3,20 @@ from django.contrib.auth.models import User
from .models import TimeEntry from .models import TimeEntry
class TimeEntrySerializer(serializers.ModelSerializer): class TimeEntrySerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
username = serializers.CharField(source='user.username', read_only=True)
class Meta: class Meta:
model = TimeEntry model = TimeEntry
fields = ['id', 'user', 'username', 'date', 'start_time', 'end_time', 'duration', 'created_at'] fields = '__all__'
read_only_fields = ['user', 'duration', 'created_at'] 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 TimeEntryCreateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = TimeEntry model = TimeEntry
@ -36,22 +42,7 @@ class TimeEntryUpdateSerializer(serializers.ModelSerializer):
return attrs return attrs
class WeeklySummarySerializer(serializers.Serializer): class WeeklySummarySerializer(serializers.Serializer):
user = serializers.StringRelatedField() user = serializers.CharField()
username = serializers.CharField() username = serializers.CharField()
total_hours = serializers.FloatField() total_hours = serializers.FloatField()
days = serializers.ListField() entries = serializers.ListField(child=serializers.DictField())
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

View File

@ -14,26 +14,42 @@ class UsersConfig(AppConfig):
if 'runserver' in sys.argv: if 'runserver' in sys.argv:
try: try:
# Import here to avoid issues during Django startup # Import here to avoid issues during Django startup
from django.contrib.auth.models import User from .models import User
from decouple import config from decouple import config
from django.db import transaction from django.db import transaction
# Check if any users exist username = config('ADMIN_USERNAME', default='').strip()
if not User.objects.exists(): password = config('ADMIN_PASSWORD', default='')
# Get credentials from environment variables or use defaults first_name = config('ADMIN_FIRST_NAME', default='').strip()
username = config('ADMIN_USERNAME', default='admin') last_name = config('ADMIN_LAST_NAME', default='').strip()
password = config('ADMIN_PASSWORD', default='admin123')
email = config('ADMIN_EMAIL', default='admin@boulangerie.fr') if not username or not password:
print('ADMIN_USERNAME and ADMIN_PASSWORD are required; no admin was created.')
return
# Create the admin user
with transaction.atomic(): with transaction.atomic():
user = User.objects.create_superuser( user, created = User.objects.get_or_create(
username=username, username=username,
email=email, defaults={
password=password 'first_name': first_name,
'last_name': last_name,
'is_staff': True,
'is_superuser': True,
},
) )
print(f'Successfully created admin user "{username}"') 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: except Exception as e:
# Don't fail the entire Django startup if there's an error # Don't fail the entire Django startup if there's an error
print(f'Error creating admin user: {e}') print(f'Error creating admin user: {e}')

View File

@ -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.db.models.deletion
import django.utils.timezone
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -11,10 +14,37 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('auth', '0012_alter_user_first_name_max_length'),
] ]
operations = [ 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( migrations.CreateModel(
name='Invitation', name='Invitation',
fields=[ fields=[

View File

@ -1,17 +1,43 @@
from django.db import models
from django.contrib.auth.models import User
import uuid 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): class Invitation(models.Model):
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) token = models.UUIDField(
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_invitations') 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) created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField() expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
used_at = models.DateTimeField(
null=True,
blank=True,
)
@property @property
def is_valid(self): def is_valid(self):
from django.utils import timezone 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()
)

View File

@ -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.db import transaction
from django.utils import timezone from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
@ -6,13 +6,28 @@ from rest_framework import serializers
from .models import Invitation from .models import Invitation
User = get_user_model()
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
is_admin = serializers.SerializerMethodField() is_admin = serializers.SerializerMethodField()
class Meta: class Meta:
model = User model = User
fields = [
fields = ['id', 'username', 'first_name', 'last_name', 'is_admin', 'date_joined'] '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): def get_is_admin(self, obj):
return obj.is_superuser or obj.is_staff return obj.is_superuser or obj.is_staff
@ -25,31 +40,61 @@ class RegisterSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User model = User
fields = [
fields = ['username', 'password', 'password_confirm', 'first_name', 'last_name', 'invite'] 'username',
'password',
'password_confirm',
'first_name',
'last_name',
'invite',
]
def validate(self, attrs): def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']: 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 return attrs
def validate_invite(self, value): def validate_invite(self, value):
try: try:
invitation = Invitation.objects.get(token=value) invitation = Invitation.objects.get(token=value)
except Invitation.DoesNotExist as error: 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: 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 return value
def create(self, validated_data): def create(self, validated_data):
invitation_token = validated_data.pop('invite') invitation_token = validated_data.pop('invite')
validated_data.pop('password_confirm') validated_data.pop('password_confirm')
with transaction.atomic(): 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: 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) user = User.objects.create_user(**validated_data)
invitation.used_at = timezone.now() invitation.used_at = timezone.now()
invitation.save(update_fields=['used_at']) invitation.save(update_fields=['used_at'])
return user return user

View File

@ -5,8 +5,7 @@ from rest_framework.views import APIView
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from django.contrib.auth.models import User from .models import Invitation, User
from .models import Invitation
from .serializers import UserSerializer, RegisterSerializer from .serializers import UserSerializer, RegisterSerializer
class RegisterView(generics.CreateAPIView): class RegisterView(generics.CreateAPIView):
@ -44,20 +43,10 @@ class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
return User.objects.all() return User.objects.all()
def update(self, request, *args, **kwargs): 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): if not (request.user.is_staff or request.user.is_superuser):
raise PermissionDenied('Seuls les administrateurs peuvent modifier les comptes.') raise PermissionDenied('Seuls les administrateurs peuvent modifier les comptes.')
user = self.get_object() return super().update(request, *args, **kwargs)
# 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)
class UserPromoteView(APIView): class UserPromoteView(APIView):
@ -109,3 +98,4 @@ 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)

View File

@ -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 () => { createInvitation: async () => {
try { try {
const response = await api.post('/users/invitations/'); const response = await api.post('/users/invitations/');

View File

@ -13,7 +13,10 @@ import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert'; 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 ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
const AdminUsersPage = () => { const AdminUsersPage = () => {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
@ -21,6 +24,17 @@ const AdminUsersPage = () => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [inviteLink, setInviteLink] = useState(''); const [inviteLink, setInviteLink] = useState('');
const [isCopied, setIsCopied] = useState(false); 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 () => { const generateInviteLink = async () => {
try { 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) { if (loading) {
return ( return (
<Card className="page-container page-layout admin-page"> <Card className="page-container page-layout admin-page">
@ -137,6 +180,7 @@ const AdminUsersPage = () => {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Nom d'utilisateur</TableHead> <TableHead>Nom d'utilisateur</TableHead>
<TableHead>Couleur</TableHead>
<TableHead>Rôle</TableHead> <TableHead>Rôle</TableHead>
<TableHead>Inscrit le</TableHead> <TableHead>Inscrit le</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
@ -146,6 +190,12 @@ const AdminUsersPage = () => {
{users.map((user) => ( {users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell>{user.username}</TableCell> <TableCell>{user.username}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: user.color || '#0000ff' }}></div>
<span>{user.color || 'Bleu'}</span>
</div>
</TableCell>
<TableCell> <TableCell>
<Badge variant={user.is_admin ? "default" : "secondary"}> <Badge variant={user.is_admin ? "default" : "secondary"}>
{user.is_admin ? 'Administrateur' : 'Utilisateur'} {user.is_admin ? 'Administrateur' : 'Utilisateur'}
@ -154,6 +204,112 @@ const AdminUsersPage = () => {
<TableCell>{new Date(user.date_joined).toLocaleDateString('fr-FR')}</TableCell> <TableCell>{new Date(user.date_joined).toLocaleDateString('fr-FR')}</TableCell>
<TableCell> <TableCell>
<div className="flex gap-2"> <div className="flex gap-2">
<Dialog open={editingUser === user.id} onOpenChange={(open) => {
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,
});
}
}}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={() => setEditingUser(user.id)}>
Éditer
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier l'utilisateur</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor={`username-${user.id}`}>Nom d'utilisateur</Label>
<Input
id={`username-${user.id}`}
value={editForm.username}
onChange={(event) => setEditForm({ ...editForm, username: event.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor={`first-name-${user.id}`}>Prénom</Label>
<Input
id={`first-name-${user.id}`}
value={editForm.first_name}
onChange={(event) => setEditForm({ ...editForm, first_name: event.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`last-name-${user.id}`}>Nom</Label>
<Input
id={`last-name-${user.id}`}
value={editForm.last_name}
onChange={(event) => setEditForm({ ...editForm, last_name: event.target.value })}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`email-${user.id}`}>E-mail</Label>
<Input
id={`email-${user.id}`}
type="email"
value={editForm.email}
onChange={(event) => setEditForm({ ...editForm, email: event.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`color-${user.id}`}>Couleur</Label>
<div className="flex items-center gap-3">
<Input
id={`color-${user.id}`}
type="color"
value={/^#[0-9A-Fa-f]{6}$/.test(editForm.color) ? editForm.color : '#000000'}
onChange={(event) => setEditForm({ ...editForm, color: event.target.value })}
className="h-10 w-14 cursor-pointer p-1"
/>
<Input
aria-label="Valeur hexadécimale de la couleur"
value={editForm.color}
pattern="^#[0-9A-Fa-f]{6}$"
onChange={(event) => setEditForm({ ...editForm, color: event.target.value })}
className="max-w-36 font-mono uppercase"
/>
</div>
</div>
<label className="flex items-center gap-3 text-sm">
<Checkbox
checked={editForm.is_active}
onCheckedChange={(checked) => setEditForm({ ...editForm, is_active: checked })}
/>
Compte actif
</label>
<label className="flex items-center gap-3 text-sm">
<Checkbox
checked={editForm.is_staff}
onCheckedChange={(checked) => setEditForm({
...editForm,
is_staff: checked,
is_superuser: checked ? editForm.is_superuser : false,
})}
/>
Administrateur
</label>
<p className="text-sm text-muted-foreground">Le mot de passe ne peut pas être modifié ici.</p>
</div>
<Button onClick={() => handleEditUser(user.id)}>
Sauvegarder
</Button>
</DialogContent>
</Dialog>
{!user.is_admin && ( {!user.is_admin && (
<ConfirmDialog <ConfirmDialog
title="Promouvoir l'utilisateur" title="Promouvoir l'utilisateur"

View File

@ -47,6 +47,7 @@ const LoginPage = () => {
<Input <Input
id="username" id="username"
type="text" type="text"
autoComplete="username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
@ -55,8 +56,10 @@ const LoginPage = () => {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Mot de passe:</Label> <Label htmlFor="password">Mot de passe:</Label>
<Input <Input
className="current-password"
id="password" id="password"
type="password" type="password"
autoComplete="current-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required

View File

@ -548,16 +548,6 @@ const WeeklySummaryPage = ({ userId }) => {
Précédente Précédente
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => navigateDay(-1)}
className="flex items-center gap-1"
>
<ChevronLeft className="h-4 w-4" />
Jour précédent
</Button>
<Badge <Badge
variant="secondary" variant="secondary"
className="text-sm" className="text-sm"
@ -580,32 +570,6 @@ const WeeklySummaryPage = ({ userId }) => {
)} )}
</Badge> </Badge>
<Badge
variant="secondary"
className="text-sm"
>
{weekDays[
selectedDayIndex
].toLocaleDateString(
'fr-FR',
{
weekday: 'long',
day: '2-digit',
month: 'short',
},
)}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => navigateDay(1)}
className="flex items-center gap-1"
>
Jour suivant
<ChevronRight className="h-4 w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -617,7 +581,6 @@ const WeeklySummaryPage = ({ userId }) => {
</Button> </Button>
</div> </div>
)} )}
</div>
{isMobile && ( {isMobile && (
<div className="flex items-center justify-between gap-2 pt-2"> <div className="flex items-center justify-between gap-2 pt-2">
@ -652,6 +615,7 @@ const WeeklySummaryPage = ({ userId }) => {
</Button> </Button>
</div> </div>
)} )}
</div>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">