196 lines
6.9 KiB
Python
196 lines
6.9 KiB
Python
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 (
|
||
DailyAllocation,
|
||
TimeAllocation,
|
||
AllocationRevision,
|
||
Project,
|
||
AllocationReminder,
|
||
)
|
||
|
||
|
||
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
|