This commit is contained in:
Louis Mazin 2026-09-15 20:00:10 +02:00
parent 3b6d99c825
commit a2fd47369d
7 changed files with 110 additions and 16 deletions

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('time_tracking', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='timeentry',
name='start_time',
field=models.TimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='timeentry',
name='end_time',
field=models.TimeField(blank=True, null=True),
),
]

View File

@ -5,8 +5,8 @@ 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(User, on_delete=models.CASCADE, related_name='time_entries')
date = models.DateField() 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)
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)
@ -24,7 +24,7 @@ class TimeEntry(models.Model):
start = datetime.combine(self.date, self.start_time) start = datetime.combine(self.date, self.start_time)
end = datetime.combine(self.date, self.end_time) end = datetime.combine(self.date, self.end_time)
return (end - start).total_seconds() / 3600 return (end - start).total_seconds() / 3600
return 0 return None
@property @property
def day_of_week(self): def day_of_week(self):

View File

@ -16,11 +16,21 @@ class TimeEntryCreateSerializer(serializers.ModelSerializer):
model = TimeEntry model = TimeEntry
fields = ['date', 'start_time', 'end_time'] fields = ['date', 'start_time', 'end_time']
def validate(self, attrs):
if not attrs.get('start_time') and not attrs.get('end_time'):
raise serializers.ValidationError('Une heure d’entrée ou de sortie est requise.')
return attrs
class TimeEntryUpdateSerializer(serializers.ModelSerializer): class TimeEntryUpdateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = TimeEntry model = TimeEntry
fields = ['start_time', 'end_time'] fields = ['start_time', 'end_time']
def validate(self, attrs):
if not attrs.get('start_time') and not attrs.get('end_time'):
raise serializers.ValidationError('Une heure d’entrée ou de sortie est requise.')
return attrs
class WeeklySummarySerializer(serializers.Serializer): class WeeklySummarySerializer(serializers.Serializer):
user = serializers.StringRelatedField() user = serializers.StringRelatedField()
username = serializers.CharField() username = serializers.CharField()

View File

@ -2,6 +2,7 @@ from django.urls import path
from .views import ( from .views import (
TimeEntryListView, TimeEntryListView,
TimeEntryCreateView, TimeEntryCreateView,
TimeEntryScanView,
TimeEntryDetailView, TimeEntryDetailView,
TimeEntryUpdateView, TimeEntryUpdateView,
WeeklySummaryView, WeeklySummaryView,
@ -11,6 +12,7 @@ from .views import (
urlpatterns = [ 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/<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/', UserWeeklySummaryView.as_view(), name='user-weekly-summary'), path('weekly-summary/', UserWeeklySummaryView.as_view(), name='user-weekly-summary'),

View File

@ -3,8 +3,10 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView from rest_framework.views import APIView
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import transaction
from django.utils import timezone from django.utils import timezone
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from decouple import config
from .models import TimeEntry from .models import TimeEntry
from .serializers import ( from .serializers import (
TimeEntrySerializer, TimeEntrySerializer,
@ -31,6 +33,43 @@ class TimeEntryCreateView(generics.CreateAPIView):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user=self.request.user) serializer.save(user=self.request.user)
class TimeEntryScanView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
code = request.data.get('code')
if code == config('SECRET_POINTAGE_ENTER', default=''):
action = 'enter'
elif code == config('SECRET_POINTAGE_EXIT', default=''):
action = 'exit'
else:
return Response({'error': 'QR code de pointage invalide.'}, status=status.HTTP_400_BAD_REQUEST)
now = timezone.localtime()
with transaction.atomic():
open_entry = TimeEntry.objects.select_for_update().filter(
user=request.user,
date=now.date(),
start_time__isnull=False,
end_time__isnull=True,
).order_by('-start_time').first()
if action == 'enter':
if open_entry:
return Response({'error': 'Une entrée est déjà ouverte pour aujourd’hui.'}, status=status.HTTP_400_BAD_REQUEST)
entry = TimeEntry.objects.create(user=request.user, date=now.date(), start_time=now.time())
message = 'Entrée enregistrée.'
elif open_entry:
open_entry.end_time = now.time()
open_entry.save(update_fields=['end_time', 'updated_at'])
entry = open_entry
message = 'Sortie enregistrée.'
else:
entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=now.time())
message = 'Sortie enregistrée sans entrée correspondante.'
return Response({'action': action, 'message': message, 'entry': TimeEntrySerializer(entry).data})
class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView): class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = TimeEntrySerializer serializer_class = TimeEntrySerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -107,8 +146,8 @@ class WeeklySummaryView(APIView):
days_data.append({ days_data.append({
'date': entry.date.isoformat(), 'date': entry.date.isoformat(),
'day': entry.date.strftime('%A'), 'day': entry.date.strftime('%A'),
'start_time': str(entry.start_time), 'start_time': str(entry.start_time) if entry.start_time else None,
'end_time': str(entry.end_time), 'end_time': str(entry.end_time) if entry.end_time else None,
'duration': entry.duration 'duration': entry.duration
}) })
else: else:
@ -120,7 +159,7 @@ class WeeklySummaryView(APIView):
'duration': 0 'duration': 0
}) })
total_hours = sum(entry.duration for entry in entries) total_hours = sum(entry.duration or 0 for entry in entries)
summary = { summary = {
'user': user.username, 'user': user.username,
@ -163,8 +202,8 @@ class UserWeeklySummaryView(APIView):
days_data.append({ days_data.append({
'date': entry.date.isoformat(), 'date': entry.date.isoformat(),
'day': entry.date.strftime('%A'), 'day': entry.date.strftime('%A'),
'start_time': str(entry.start_time), 'start_time': str(entry.start_time) if entry.start_time else None,
'end_time': str(entry.end_time), 'end_time': str(entry.end_time) if entry.end_time else None,
'duration': entry.duration 'duration': entry.duration
}) })
else: else:
@ -176,7 +215,7 @@ class UserWeeklySummaryView(APIView):
'duration': 0 'duration': 0
}) })
total_hours = sum(entry.duration for entry in entries) total_hours = sum(entry.duration or 0 for entry in entries)
summary = { summary = {
'user': user.username, 'user': user.username,

View File

@ -21,6 +21,15 @@ export const timeApi = {
} }
}, },
scanTimeEntry: async (code) => {
try {
const response = await api.post('/time/entries/scan/', { code });
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || `Erreur lors du pointage: ${error.message}`);
}
},
// Get a specific time entry // Get a specific time entry
getTimeEntry: async (id) => { getTimeEntry: async (id) => {
try { try {

View File

@ -12,6 +12,7 @@ const TimeClockPage = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [isScanning, setIsScanning] = useState(false); const [isScanning, setIsScanning] = useState(false);
const [scanResult, setScanResult] = useState(''); const [scanResult, setScanResult] = useState('');
const [scanMessage, setScanMessage] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
const [timeEntries, setTimeEntries] = useState([]); const [timeEntries, setTimeEntries] = useState([]);
@ -87,7 +88,7 @@ const TimeClockPage = () => {
const handleUpdateEntry = async (e) => { const handleUpdateEntry = async (e) => {
e.preventDefault(); e.preventDefault();
if (!editForm.start_time || !editForm.end_time) { if (!editForm.start_time && !editForm.end_time) {
setError('Veuillez remplir tous les champs'); setError('Veuillez remplir tous les champs');
return; return;
} }
@ -141,6 +142,19 @@ const TimeClockPage = () => {
setIsScanning(false); setIsScanning(false);
}; };
const handleQrResult = async (code) => {
setScanResult(code);
stopScanner();
try {
const response = await timeApi.scanTimeEntry(code);
setScanMessage(response.message);
await loadTimeEntries();
} catch (scanError) {
setError(scanError.message);
}
};
useEffect(() => () => stopScanner(), []); useEffect(() => () => stopScanner(), []);
useEffect(() => { useEffect(() => {
@ -156,8 +170,7 @@ const TimeClockPage = () => {
videoRef.current, videoRef.current,
(result) => { (result) => {
if (result && !cancelled) { if (result && !cancelled) {
setScanResult(result.getText()); handleQrResult(result.getText());
stopScanner();
} }
}, },
); );
@ -195,6 +208,7 @@ const TimeClockPage = () => {
const startScanner = async () => { const startScanner = async () => {
setError(''); setError('');
setScanResult(''); setScanResult('');
setScanMessage('');
if (!window.isSecureContext && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') { if (!window.isSecureContext && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
setError('La caméra nécessite une connexion HTTPS sur cette adresse réseau.'); setError('La caméra nécessite une connexion HTTPS sur cette adresse réseau.');
@ -256,6 +270,8 @@ const TimeClockPage = () => {
</button> </button>
)} )}
{scanMessage && <div className="alert alert-success">{scanMessage}</div>}
{/* Time tracking section */} {/* Time tracking section */}
<div className="time-tracking-section"> <div className="time-tracking-section">
@ -276,7 +292,6 @@ const TimeClockPage = () => {
type="time" type="time"
value={editForm.start_time} value={editForm.start_time}
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })} onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })}
required
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
@ -286,7 +301,6 @@ const TimeClockPage = () => {
type="time" type="time"
value={editForm.end_time} value={editForm.end_time}
onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })} onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })}
required
/> />
</div> </div>
<div className="btn-group"> <div className="btn-group">
@ -369,8 +383,8 @@ const TimeClockPage = () => {
<td>{entry.user}</td> <td>{entry.user}</td>
} }
<td>{formatDate(entry.date)}</td> <td>{formatDate(entry.date)}</td>
<td>{formatTime(entry.start_time)}</td> <td>{entry.start_time ? formatTime(entry.start_time) : 'Non renseignée'}</td>
<td>{formatTime(entry.end_time)}</td> <td>{entry.end_time ? formatTime(entry.end_time) : 'Non renseignée'}</td>
<td>{formatDuration(entry.duration)}</td> <td>{formatDuration(entry.duration)}</td>
{isAdmin && {isAdmin &&
<td> <td>