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
# 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'

View File

@ -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)

View File

@ -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
entries = serializers.ListField(child=serializers.DictField())

View File

@ -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')
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
# Create the admin user
with transaction.atomic():
user = User.objects.create_superuser(
user, created = User.objects.get_or_create(
username=username,
email=email,
password=password
defaults={
'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:
# Don't fail the entire Django startup if there's an error
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.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=[

View File

@ -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()
)

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.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

View File

@ -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)

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 () => {
try {
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 { 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 (
<Card className="page-container page-layout admin-page">
@ -137,6 +180,7 @@ const AdminUsersPage = () => {
<TableHeader>
<TableRow>
<TableHead>Nom d'utilisateur</TableHead>
<TableHead>Couleur</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Inscrit le</TableHead>
<TableHead>Actions</TableHead>
@ -146,6 +190,12 @@ const AdminUsersPage = () => {
{users.map((user) => (
<TableRow key={user.id}>
<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>
<Badge variant={user.is_admin ? "default" : "secondary"}>
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
@ -154,6 +204,112 @@ const AdminUsersPage = () => {
<TableCell>{new Date(user.date_joined).toLocaleDateString('fr-FR')}</TableCell>
<TableCell>
<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 && (
<ConfirmDialog
title="Promouvoir l'utilisateur"

View File

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

View File

@ -548,16 +548,6 @@ const WeeklySummaryPage = ({ userId }) => {
Précédente
</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
variant="secondary"
className="text-sm"
@ -580,32 +570,6 @@ const WeeklySummaryPage = ({ userId }) => {
)}
</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
variant="outline"
size="sm"
@ -617,7 +581,6 @@ const WeeklySummaryPage = ({ userId }) => {
</Button>
</div>
)}
</div>
{isMobile && (
<div className="flex items-center justify-between gap-2 pt-2">
@ -652,6 +615,7 @@ const WeeklySummaryPage = ({ userId }) => {
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-4">