from datetime import timedelta from decimal import Decimal from django.contrib.auth import get_user_model from django.db import transaction from django.db.models import Q from django.utils import timezone from rest_framework.exceptions import ValidationError from attendance.models import AttendanceRecord from projects.models import ( AllocationReminder, AllocationRevision, DailyAllocation, Project, TimeAllocation, ) def available_projects(user, day): return ( Project.objects.filter(status__in=["planned", "active", "paused"]) .filter( Q(manager=user) | ( Q(memberships__employee=user) & ( Q(memberships__starts_on__isnull=True) | Q(memberships__starts_on__lte=day) ) & ( Q(memberships__ends_on__isnull=True) | Q(memberships__ends_on__gte=day) ) ) ) .distinct() ) def attendance_for(user, day): return AttendanceRecord.objects.filter( employee=user, date=day, analysis_run__status="success", work_hours__gt=0 ).first() def hours_for(record): return ( Decimal(str(record.work_hours)).quantize(Decimal("0.001")) if record else None ) def day_data(user, day): entry = ( DailyAllocation.objects.filter(employee=user, date=day) .prefetch_related("allocations__project") .first() ) record = attendance_for(user, day) return { "date": day, "status": entry.status if entry else "draft", "version": entry.version if entry else 0, "hours": entry.hours if entry else hours_for(record), "hours_source": entry.hours_source if entry else ("attendance" if record else "unknown"), "attendance_changed": bool(entry and entry.hours != hours_for(record)), "note": entry.note if entry else "", "allocations": [ { "project": a.project_id, "project_name": a.project.name if a.project else "", "activity": a.activity, "percent": str(a.percent), } for a in entry.allocations.all() ] if entry else [], "projects": list(available_projects(user, day).values("id", "name")), } @transaction.atomic def save_day(user, data): # Lock the owner as well as existing days: first saves must serialize too. get_user_model().objects.select_for_update().get(pk=user.pk) day = data["date"] if day > timezone.localdate() or day < timezone.localdate() - timedelta(days=366): raise ValidationError("Выберите прошедший день или сегодня, в пределах года.") entry = DailyAllocation.objects.filter(employee=user, date=day).first() if data["version"] != (entry.version if entry else 0): raise ValidationError( "Запись изменена в другом окне. Обновите данные перед сохранением." ) allocations = data["allocations"] total = sum((a["percent"] for a in allocations), Decimal(0)) if total > 100 or (data["status"] == "confirmed" and total != 100): raise ValidationError( "Для подтверждения распределите ровно 100%. В черновике допустимо меньше." ) allowed = set(available_projects(user, day).values_list("pk", flat=True)) # Existing history remains editable after project completion, without adding new projects. if entry: allowed.update( entry.allocations.exclude(project=None).values_list("project_id", flat=True) ) seen = set() for row in allocations: pid, activity = row.get("project"), row.get("activity", "") if bool(pid) == bool(activity): raise ValidationError("Выберите либо проект, либо вид общей деятельности.") if pid and pid not in allowed: raise ValidationError("Нет участия в выбранном проекте на эту дату.") key = (pid, activity) if key in seen: raise ValidationError( "Объедините повторяющиеся строки проекта или деятельности." ) seen.add(key) record = attendance_for(user, day) if not entry: entry = DailyAllocation(employee=user, date=day) entry.status, entry.note = data["status"], data.get("note", "") entry.hours, entry.hours_source = ( hours_for(record), "attendance" if record else "unknown", ) entry.attendance_record = record entry.version += 1 entry.save() entry.allocations.all().delete() TimeAllocation.objects.bulk_create( [ TimeAllocation( day=entry, project_id=a.get("project"), activity=a.get("activity", ""), percent=a["percent"], ) for a in allocations ] ) snapshot = { "version": entry.version, "status": entry.status, "hours": str(entry.hours) if entry.hours is not None else None, "hours_source": entry.hours_source, "note": entry.note, "allocations": [ { "project": a.get("project"), "activity": a.get("activity", ""), "percent": str(a["percent"]), } for a in allocations ], } AllocationRevision.objects.create(day=entry, actor=user, snapshot=snapshot) return day_data(user, day) @transaction.atomic def claim_reminder(user): get_user_model().objects.select_for_update().get(pk=user.pk) now, today = timezone.now(), timezone.localdate() reminder, _ = AllocationReminder.objects.get_or_create(employee=user) if ( not reminder.enabled or reminder.last_claim_date == today or (reminder.snoozed_until and reminder.snoozed_until > now) ): return None confirmed = DailyAllocation.objects.filter( employee=user, status="confirmed" ).values("date") # Completed past shifts only. Attendance, not a guessed Monday-Friday calendar, # determines working days including holidays, leave and overnight schedules. days = ( AttendanceRecord.objects.filter( employee=user, analysis_run__status="success", work_hours__gt=0, date__lt=today, date__gte=today - timedelta(days=14), ) .exclude(date__in=confirmed) .order_by("-date") ) for record in days: if available_projects(user, record.date).exists(): reminder.last_claim_date = today reminder.snoozed_until = None reminder.save() return record.date return None