feat: add attendance secret skills

This commit is contained in:
lizerk 2026-08-26 08:50:00 +03:00
parent dec526898e
commit 2e807d83dd
20 changed files with 1039 additions and 74 deletions

View File

@ -1,5 +1,6 @@
import logging
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
@ -137,6 +138,15 @@ class AttendanceRecordDetailAPIView(APIView):
]
)
def recalculate_secret_skills():
from employees.services.secret_skills import (
recalculate_attendance_secret_skills,
)
recalculate_attendance_secret_skills(record.employee_id)
transaction.on_commit(recalculate_secret_skills)
record = (
_records_visible_to(request.user)
.select_related("employee", "analysis_run")

View File

@ -42,7 +42,10 @@ class EmployeeSerializer(serializers.ModelSerializer):
skills = serializers.SerializerMethodField()
skills_ids = serializers.PrimaryKeyRelatedField(
queryset=Skill.objects.all(), many=True, write_only=True, required=False
queryset=Skill.objects.filter(is_secret=False),
many=True,
write_only=True,
required=False,
)
position = PositionBriefSerializer(read_only=True)
@ -142,7 +145,8 @@ class EmployeeSerializer(serializers.ModelSerializer):
instance = super().update(instance, validated_data)
if skills is not None:
instance.skills.set(skills)
secret_skills = list(instance.skills.filter(is_secret=True))
instance.skills.set([*skills, *secret_skills])
return instance
@extend_schema_field(EmployeeDepartmentSerializer(many=True))

View File

@ -13,7 +13,10 @@ from employees.models import (
from employees.services.skill_progress import (
confirmed_holder_count,
rank_payload,
rarity_payload,
)
from employees.services.secret_skills import (
secret_skill_progress_payload,
skill_rarity_payload,
)
from rest_framework import serializers
@ -47,12 +50,18 @@ class SkillSerializer(serializers.ModelSerializer):
confirmed_holders = serializers.SerializerMethodField()
rarity = serializers.SerializerMethodField()
rarity_display = serializers.SerializerMethodField()
progress_value = serializers.SerializerMethodField()
progress_label = serializers.SerializerMethodField()
progress_data = serializers.SerializerMethodField()
next_rank_requirements = serializers.SerializerMethodField()
class Meta:
model = Skill
fields = [
"id",
"name",
"is_secret",
"progression_type",
"experience",
"rank",
"rank_display",
@ -64,20 +73,28 @@ class SkillSerializer(serializers.ModelSerializer):
"confirmed_holders",
"rarity",
"rarity_display",
"progress_value",
"progress_label",
"progress_data",
"next_rank_requirements",
]
read_only_fields = ["is_secret", "progression_type"]
def _progress(self, obj):
progress_by_skill = self.context.get("skill_progress_by_skill", {})
return progress_by_skill.get(obj.id)
def _rank(self, obj):
return rank_payload(getattr(self._progress(obj), "experience", 0))
progress = self._progress(obj)
if obj.is_secret:
return secret_skill_progress_payload(obj, progress)
return rank_payload(getattr(progress, "experience", 0))
def _rarity(self, obj):
count = getattr(obj, "confirmed_holders", None)
if count is None:
count = confirmed_holder_count(obj)
return rarity_payload(count)
return skill_rarity_payload(obj, count)
def get_experience(self, obj):
return getattr(self._progress(obj), "experience", 0)
@ -112,6 +129,18 @@ class SkillSerializer(serializers.ModelSerializer):
def get_rarity_display(self, obj):
return self._rarity(obj)["rarity_display"]
def get_progress_value(self, obj):
return self._rank(obj).get("progress_value")
def get_progress_label(self, obj):
return self._rank(obj).get("progress_label", "")
def get_progress_data(self, obj):
return self._rank(obj).get("progress_data", {})
def get_next_rank_requirements(self, obj):
return self._rank(obj).get("next_rank_requirements", [])
class SkillExperienceEventSerializer(serializers.ModelSerializer):
source_display = serializers.CharField(

View File

@ -5,6 +5,7 @@ import logging
from django.utils.crypto import get_random_string
from common.emails import send_templated_mail
from employees.models import Skill
logger = logging.getLogger(__name__)
@ -20,7 +21,9 @@ class EmployeePersistenceMixin:
skills_ids = self.request.data.get("skills_ids", [])
if skills_ids:
instance.skills.set(skills_ids)
instance.skills.set(
Skill.objects.filter(pk__in=skills_ids, is_secret=False)
)
def perform_update(self, serializer):
"""Update an employee and notify them when their email changes."""

View File

@ -16,12 +16,15 @@ from employees.services.skill_progress import (
endorsement_available_at,
endorsement_weight,
ensure_skill_progress,
rarity_payload,
confirmed_holder_count,
rank_payload,
record_skill_experience,
skill_queryset_with_rarity,
)
from employees.services.secret_skills import (
secret_skill_progress_payload,
skill_rarity_payload,
)
from api.v1.employees.serializers import SkillExperienceEventSerializer
from rest_framework import status
@ -102,6 +105,12 @@ class EmployeeProfileMixin:
else:
skill, _ = Skill.objects.get_or_create(name=skill_name)
if skill.is_secret:
return Response(
{"detail": "Секретный навык открывается только системным правилом."},
status=status.HTTP_400_BAD_REQUEST,
)
instance.skills.add(skill)
progress = ensure_skill_progress(
instance,
@ -135,13 +144,17 @@ class EmployeeProfileMixin:
"-created_at",
"-id",
)[:50]
rank = rank_payload(progress.experience)
rank = (
secret_skill_progress_payload(skill, progress)
if skill.is_secret
else rank_payload(progress.experience)
)
holder_count = getattr(skill, "confirmed_holders", None)
if holder_count is None:
holder_count = confirmed_holder_count(skill)
rarity = rarity_payload(holder_count)
rarity = skill_rarity_payload(skill, holder_count)
available_at = None
if request.user.id != employee.id:
if not skill.is_secret and request.user.id != employee.id:
available_at = endorsement_available_at(
progress=progress,
actor=request.user,
@ -151,11 +164,15 @@ class EmployeeProfileMixin:
"employee_id": employee.id,
"skill_id": skill.id,
"skill_name": skill.name,
"is_secret": skill.is_secret,
"progression_type": skill.progression_type,
"experience": progress.experience,
**rank,
**rarity,
"can_endorse": (
request.user.id != employee.id and available_at is None
not skill.is_secret
and request.user.id != employee.id
and available_at is None
),
"endorsement_available_at": available_at,
"events": SkillExperienceEventSerializer(events, many=True).data,
@ -183,6 +200,11 @@ class EmployeeProfileMixin:
{"detail": "Навык не добавлен сотруднику"},
status=404,
)
if skill.is_secret:
return Response(
{"detail": "Секретные навыки нельзя подтверждать вручную."},
status=status.HTTP_400_BAD_REQUEST,
)
reason = str(request.data.get("reason") or "").strip()
if len(reason) < 8:

View File

@ -38,6 +38,12 @@ class SkillViewSet(viewsets.ModelViewSet):
"merge": "employees.change_skill",
}
def get_queryset(self):
queryset = super().get_queryset()
if self.action in {"list", "create", "bulk_create"}:
return queryset.filter(is_secret=False)
return queryset
def get_permissions(self):
if self.action in ("create", "bulk_create", "list", "retrieve"):
return [IsAuthenticated()]
@ -116,6 +122,11 @@ class SkillViewSet(viewsets.ModelViewSet):
target = Skill.objects.get(pk=tid)
except Skill.DoesNotExist:
return Response({"detail": "Skill не найден"}, status=404)
if source.is_secret or target.is_secret:
return Response(
{"detail": "Секретные навыки нельзя объединять вручную"},
status=400,
)
moved_count = 0
if reassign:

View File

@ -56,7 +56,7 @@ class TaskSerializer(serializers.ModelSerializer):
skills = SkillSerializer(many=True, read_only=True)
skill_ids = serializers.PrimaryKeyRelatedField(
source="skills",
queryset=Skill.objects.all(),
queryset=Skill.objects.filter(is_secret=False),
many=True,
required=False,
write_only=True,

View File

@ -220,6 +220,15 @@ def save_logstorm_attendance_result(
defaults={"analysis_run": run, **record_defaults},
)
def recalculate_secret_skills():
from employees.services.secret_skills import (
recalculate_attendance_secret_skills,
)
recalculate_attendance_secret_skills(employee.id)
transaction.on_commit(recalculate_secret_skills)
return run

View File

@ -1905,14 +1905,22 @@ class EmployeeDepartmentAdmin(admin.ModelAdmin):
@admin.register(Skill)
class SkillAdmin(admin.ModelAdmin):
list_display = ("name",)
list_display = ("name", "is_secret", "progression_type", "system_code")
list_filter = ("is_secret", "progression_type")
search_fields = ("name",)
@admin.register(EmployeeSkillProgress)
class EmployeeSkillProgressAdmin(admin.ModelAdmin):
list_display = ("employee", "skill", "experience", "is_active", "updated_at")
list_filter = ("is_active", "skill")
list_display = (
"employee",
"skill",
"experience",
"special_rank",
"is_active",
"updated_at",
)
list_filter = ("is_active", "special_rank", "skill")
search_fields = (
"employee__email",
"employee__first_name",

View File

@ -0,0 +1,38 @@
from django.core.management.base import BaseCommand
from attendance.models import AttendanceRecord
from employees.services.secret_skills import recalculate_attendance_secret_skills
class Command(BaseCommand):
help = "Пересчитать секретные навыки по сохранённой посещаемости"
def add_arguments(self, parser):
parser.add_argument("--employee-id", type=int)
def handle(self, *args, **options):
employee_id = options.get("employee_id")
employee_ids = (
AttendanceRecord.objects.order_by()
.values_list(
"employee_id",
flat=True,
)
.distinct()
)
if employee_id:
employee_ids = employee_ids.filter(employee_id=employee_id)
processed = 0
unlocked_or_promoted = 0
for current_employee_id in employee_ids.iterator():
changes = recalculate_attendance_secret_skills(current_employee_id)
processed += 1
unlocked_or_promoted += len(changes)
self.stdout.write(
self.style.SUCCESS(
"Пересчитано сотрудников: "
f"{processed}; открыто или повышено навыков: {unlocked_or_promoted}"
)
)

View File

@ -0,0 +1,92 @@
# Generated by Django 5.2.4 on 2026-08-25 12:51
from django.db import migrations, models
def create_secret_attendance_skills(apps, schema_editor):
Skill = apps.get_model("employees", "Skill")
definitions = (
(
"attendance_punctuality",
"Пунктуальность",
"attendance_punctuality",
),
(
"attendance_self_organization",
"Самоорганизация",
"attendance_self_organization",
),
)
for system_code, name, progression_type in definitions:
skill, _ = Skill.objects.get_or_create(name=name)
skill.is_secret = True
skill.system_code = system_code
skill.progression_type = progression_type
skill.save(update_fields=["is_secret", "system_code", "progression_type"])
class Migration(migrations.Migration):
dependencies = [
("employees", "0057_employeeskillprogress_skillexperienceevent_and_more"),
]
operations = [
migrations.AddField(
model_name="employeeskillprogress",
name="progress_data",
field=models.JSONField(
blank=True, default=dict, verbose_name="Данные системного прогресса"
),
),
migrations.AddField(
model_name="employeeskillprogress",
name="special_rank",
field=models.CharField(
choices=[
("none", "Без ранга"),
("bronze", "Бронза"),
("silver", "Серебро"),
("gold", "Золото"),
("ruby", "Рубин"),
("diamond", "Бриллиант"),
],
default="none",
max_length=16,
verbose_name="Ранг системного навыка",
),
),
migrations.AddField(
model_name="skill",
name="is_secret",
field=models.BooleanField(default=False, verbose_name="Секретный навык"),
),
migrations.AddField(
model_name="skill",
name="progression_type",
field=models.CharField(
choices=[
("standard", "Обычный опыт"),
("attendance_punctuality", "Посещаемость: пунктуальность"),
("attendance_self_organization", "Посещаемость: самоорганизация"),
],
default="standard",
max_length=48,
verbose_name="Механика прогресса",
),
),
migrations.AddField(
model_name="skill",
name="system_code",
field=models.CharField(
blank=True,
max_length=64,
null=True,
unique=True,
verbose_name="Системный код",
),
),
migrations.RunPython(
create_secret_attendance_skills,
migrations.RunPython.noop,
),
]

View File

@ -692,7 +692,32 @@ class RoleAssignment(models.Model):
class Skill(models.Model):
class ProgressionType(models.TextChoices):
STANDARD = "standard", "Обычный опыт"
ATTENDANCE_PUNCTUALITY = (
"attendance_punctuality",
"Посещаемость: пунктуальность",
)
ATTENDANCE_SELF_ORGANIZATION = (
"attendance_self_organization",
"Посещаемость: самоорганизация",
)
name = models.CharField(max_length=100, unique=True)
is_secret = models.BooleanField("Секретный навык", default=False)
system_code = models.CharField(
"Системный код",
max_length=64,
unique=True,
null=True,
blank=True,
)
progression_type = models.CharField(
"Механика прогресса",
max_length=48,
choices=ProgressionType.choices,
default=ProgressionType.STANDARD,
)
class Meta:
verbose_name = "Навык"
@ -722,6 +747,24 @@ class EmployeeSkillProgress(models.Model):
verbose_name="Навык",
)
experience = models.PositiveIntegerField("Опыт", default=0)
special_rank = models.CharField(
"Ранг системного навыка",
max_length=16,
choices=(
("none", "Без ранга"),
("bronze", "Бронза"),
("silver", "Серебро"),
("gold", "Золото"),
("ruby", "Рубин"),
("diamond", "Бриллиант"),
),
default="none",
)
progress_data = models.JSONField(
"Данные системного прогресса",
default=dict,
blank=True,
)
is_active = models.BooleanField("Показывается в профиле", default=True)
created_by = models.ForeignKey(
Employee,

View File

@ -0,0 +1,404 @@
"""Автоматический прогресс секретных навыков по данным посещаемости."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from django.db import transaction
from django.utils import timezone
from employees.constants import ACTION_REMOTE
from employees.models import Employee, EmployeeSkillProgress, Skill
from employees.services.skill_progress import SKILL_RANKS, rarity_payload
RANK_CODES = tuple(rank["code"] for rank in SKILL_RANKS)
RANK_LABELS = {rank["code"]: rank["label"] for rank in SKILL_RANKS}
RANK_INDEX = {code: index for index, code in enumerate(RANK_CODES)}
PUNCTUALITY_REQUIREMENTS = {
"bronze": {"best_streak": 20},
"silver": {"best_streak": 45},
"gold": {"best_streak": 90},
"ruby": {"best_streak": 180},
"diamond": {"best_streak": 365},
}
SELF_ORGANIZATION_REQUIREMENTS = {
"bronze": {
"clean_days": 60,
"best_clean_streak": 20,
"off_schedule_days": 5,
"overtime_days": 10,
},
"silver": {
"clean_days": 120,
"best_clean_streak": 45,
"off_schedule_days": 10,
"overtime_days": 25,
},
"gold": {
"clean_days": 240,
"best_clean_streak": 90,
"off_schedule_days": 20,
"overtime_days": 50,
},
"ruby": {
"clean_days": 480,
"best_clean_streak": 180,
"off_schedule_days": 35,
"overtime_days": 90,
},
"diamond": {
"clean_days": 800,
"best_clean_streak": 365,
"off_schedule_days": 50,
"overtime_days": 150,
},
}
REQUIREMENT_LABELS = {
"best_streak": "Лучшая серия без опозданий",
"clean_days": "Чистые рабочие дни",
"best_clean_streak": "Лучшая чистая серия",
"off_schedule_days": "Выходы в нерабочий день",
"overtime_days": "Дни с переработкой от 1 часа",
}
@dataclass(frozen=True)
class SecretSkillDefinition:
system_code: str
name: str
progression_type: str
requirements: dict[str, dict[str, int]]
SECRET_SKILL_DEFINITIONS = (
SecretSkillDefinition(
system_code="attendance_punctuality",
name="Пунктуальность",
progression_type=Skill.ProgressionType.ATTENDANCE_PUNCTUALITY,
requirements=PUNCTUALITY_REQUIREMENTS,
),
SecretSkillDefinition(
system_code="attendance_self_organization",
name="Самоорганизация",
progression_type=Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION,
requirements=SELF_ORGANIZATION_REQUIREMENTS,
),
)
def _has_work(record) -> bool:
return bool(
record.arrival_time
or record.departure_time
or (record.work_hours is not None and record.work_hours > 0)
)
def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | str]:
"""Рассчитать фактические показатели по нормализованным записям."""
punctuality_streak = 0
best_punctuality_streak = 0
clean_streak = 0
best_clean_streak = 0
clean_days = 0
off_schedule_days = 0
overtime_days = 0
last_evaluated_date = ""
for record in sorted(records, key=lambda item: (item.date, item.id or 0)):
if record.technical_issues:
continue
has_work = _has_work(record)
if record.is_overtime and has_work and float(record.overtime_hours or 0) >= 1:
overtime_days += 1
if not record.effective_is_workday:
if (
not record.is_workday
and record.personnel_status == "normal"
and record.is_overtime
and has_work
):
off_schedule_days += 1
continue
if record.personnel_status == ACTION_REMOTE:
continue
last_evaluated_date = record.date.isoformat()
if record.is_absent or not has_work:
punctuality_streak = 0
clean_streak = 0
continue
if record.is_late:
punctuality_streak = 0
else:
punctuality_streak += 1
best_punctuality_streak = max(
best_punctuality_streak,
punctuality_streak,
)
is_clean = not any(
(
record.is_late,
record.is_early_leave,
record.is_underwork,
record.is_absent,
)
)
if is_clean:
clean_days += 1
clean_streak += 1
best_clean_streak = max(best_clean_streak, clean_streak)
else:
clean_streak = 0
return {
"current_streak": punctuality_streak,
"best_streak": best_punctuality_streak,
"current_clean_streak": clean_streak,
"best_clean_streak": best_clean_streak,
"clean_days": clean_days,
"off_schedule_days": off_schedule_days,
"overtime_days": overtime_days,
"last_evaluated_date": last_evaluated_date,
}
def _candidate_rank(
metrics: dict[str, int | str],
requirements: dict[str, dict[str, int]],
) -> str:
candidate = "none"
for rank_code in RANK_CODES[1:]:
rank_requirements = requirements[rank_code]
if all(
int(metrics.get(metric, 0) or 0) >= target
for metric, target in rank_requirements.items()
):
candidate = rank_code
else:
break
return candidate
def _definition_for_skill(skill: Skill) -> SecretSkillDefinition | None:
return next(
(
definition
for definition in SECRET_SKILL_DEFINITIONS
if definition.system_code == skill.system_code
),
None,
)
def _metrics_for_definition(
definition: SecretSkillDefinition,
metrics: dict[str, int | str],
) -> dict[str, int | str]:
if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY:
return {
"current_streak": metrics["current_streak"],
"best_streak": metrics["best_streak"],
"last_evaluated_date": metrics["last_evaluated_date"],
}
return {
"current_clean_streak": metrics["current_clean_streak"],
"best_clean_streak": metrics["best_clean_streak"],
"clean_days": metrics["clean_days"],
"off_schedule_days": metrics["off_schedule_days"],
"overtime_days": metrics["overtime_days"],
"last_evaluated_date": metrics["last_evaluated_date"],
}
@transaction.atomic
def recalculate_attendance_secret_skills(employee_id: int) -> list[str]:
"""Пересчитать и при необходимости открыть системные навыки сотрудника."""
from attendance.models import AttendanceRecord
employee = Employee.objects.select_for_update().filter(pk=employee_id).first()
if employee is None:
return []
records = list(
AttendanceRecord.objects.filter(employee_id=employee_id).order_by("date", "id")
)
metrics = calculate_attendance_secret_metrics(records)
changed_skills: list[str] = []
now = timezone.now()
skills = {
skill.system_code: skill
for skill in Skill.objects.filter(
system_code__in=[
definition.system_code for definition in SECRET_SKILL_DEFINITIONS
],
is_secret=True,
)
}
for definition in SECRET_SKILL_DEFINITIONS:
skill = skills.get(definition.system_code)
if skill is None:
continue
definition_metrics = _metrics_for_definition(definition, metrics)
candidate_rank = _candidate_rank(
definition_metrics,
definition.requirements,
)
progress = EmployeeSkillProgress.objects.filter(
employee=employee,
skill=skill,
).first()
previous_rank = progress.special_rank if progress else "none"
achieved_rank = (
candidate_rank
if RANK_INDEX[candidate_rank] > RANK_INDEX[previous_rank]
else previous_rank
)
if achieved_rank == "none":
if progress and progress.is_active:
progress.is_active = False
progress.experience = 0
progress.progress_data = definition_metrics
progress.save(
update_fields=[
"is_active",
"experience",
"progress_data",
"updated_at",
]
)
employee.skills.remove(skill)
continue
employee.skills.add(skill)
progress = EmployeeSkillProgress.objects.select_for_update().get(
employee=employee,
skill=skill,
)
rank_history = dict(progress.progress_data.get("rank_achieved_at") or {})
if RANK_INDEX[achieved_rank] > RANK_INDEX[previous_rank]:
rank_history[achieved_rank] = now.isoformat()
progress.special_rank = achieved_rank
progress.experience = 0
progress.is_active = True
progress.created_by = None
progress.progress_data = {
**definition_metrics,
"rank_achieved_at": rank_history,
"recalculated_at": now.isoformat(),
}
progress.save(
update_fields=[
"special_rank",
"experience",
"is_active",
"created_by",
"progress_data",
"updated_at",
]
)
progress.experience_events.filter(revoked_at__isnull=True).update(
revoked_at=now,
)
if achieved_rank != previous_rank:
changed_skills.append(skill.name)
return changed_skills
def secret_skill_progress_payload(
skill: Skill,
progress: EmployeeSkillProgress | None,
) -> dict:
definition = _definition_for_skill(skill)
if definition is None:
return {}
rank_code = progress.special_rank if progress else "none"
metrics = dict(progress.progress_data or {}) if progress else {}
rank_index = RANK_INDEX.get(rank_code, 0)
next_rank = RANK_CODES[rank_index + 1] if rank_index + 1 < len(RANK_CODES) else None
next_requirements = definition.requirements.get(next_rank or "", {})
requirement_payload = [
{
"key": metric,
"label": REQUIREMENT_LABELS[metric],
"current": int(metrics.get(metric, 0) or 0),
"target": target,
"complete": int(metrics.get(metric, 0) or 0) >= target,
}
for metric, target in next_requirements.items()
]
if next_requirements:
progress_percent = round(
min(
min(int(metrics.get(metric, 0) or 0) / max(target, 1), 1)
for metric, target in next_requirements.items()
)
* 100
)
else:
progress_percent = 100
if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY:
progress_value = int(metrics.get("best_streak", 0) or 0)
progress_label = f"Лучшая серия: {progress_value} дн."
else:
progress_value = int(metrics.get("clean_days", 0) or 0)
completed = sum(item["complete"] for item in requirement_payload)
progress_label = (
f"До следующего ранга: {completed}/{len(requirement_payload)} условий"
if requirement_payload
else "Все условия выполнены"
)
primary_target = next(iter(next_requirements.values()), None)
primary_current = (
int(metrics.get(next(iter(next_requirements), ""), 0) or 0)
if next_requirements
else 0
)
return {
"rank": rank_code,
"rank_display": RANK_LABELS.get(rank_code, "Без ранга"),
"rank_threshold": 0,
"next_rank": next_rank,
"next_rank_display": RANK_LABELS.get(next_rank) if next_rank else None,
"next_rank_experience": primary_target,
"experience_to_next_rank": (
max(int(primary_target or 0) - primary_current, 0)
if primary_target is not None
else 0
),
"rank_progress_percent": progress_percent,
"progress_value": progress_value,
"progress_label": progress_label,
"progress_data": metrics,
"next_rank_requirements": requirement_payload,
}
def skill_rarity_payload(skill: Skill, confirmed_holders: int) -> dict:
if skill.is_secret:
return {
"confirmed_holders": max(int(confirmed_holders or 0), 0),
"rarity": "secret",
"rarity_display": "Секретный",
}
return rarity_payload(confirmed_holders)

View File

@ -39,9 +39,12 @@ def skill_queryset_with_rarity(queryset=None):
return queryset.annotate(
confirmed_holders=Count(
"employee_progresses",
filter=Q(
filter=(
Q(is_secret=True)
| Q(employee_progresses__experience__gte=RARITY_CONFIRMATION_EXPERIENCE)
)
& Q(
employee_progresses__is_active=True,
employee_progresses__experience__gte=RARITY_CONFIRMATION_EXPERIENCE,
employee_progresses__employee__is_active=True,
employee_progresses__employee__is_hidden_in_ui=False,
),
@ -105,18 +108,16 @@ def rarity_payload(confirmed_holders: int) -> dict:
def confirmed_holder_count(skill: Skill) -> int:
return (
EmployeeSkillProgress.objects.filter(
skill=skill,
is_active=True,
experience__gte=RARITY_CONFIRMATION_EXPERIENCE,
employee__is_active=True,
employee__is_hidden_in_ui=False,
employee__skills=skill,
)
.distinct()
.count()
)
filters = {
"skill": skill,
"is_active": True,
"employee__is_active": True,
"employee__is_hidden_in_ui": False,
"employee__skills": skill,
}
if not skill.is_secret:
filters["experience__gte"] = RARITY_CONFIRMATION_EXPERIENCE
return EmployeeSkillProgress.objects.filter(**filters).distinct().count()
def ensure_skill_progress(
@ -158,6 +159,8 @@ def record_skill_experience(
idempotency_key: str = "",
metadata: dict | None = None,
) -> tuple[EmployeeSkillProgress, SkillExperienceEvent, bool]:
if skill.is_secret:
raise ValueError("Секретные навыки изменяются только системными правилами")
points = int(points)
if points <= 0:
raise ValueError("Баллы опыта должны быть положительным числом")
@ -300,6 +303,8 @@ def award_task_skill_experience(task_id: int) -> int:
completion_key = task.completed_at.isoformat()
awarded = 0
for skill in task.skills.all():
if skill.is_secret:
continue
recipients = Employee.objects.filter(
id__in=reward_roles,
is_active=True,

View File

@ -98,6 +98,69 @@ def test_create_allowed_for_regular_user(api_client):
assert Skill.objects.filter(name="Go").exists()
@pytest.mark.django_db
def test_secret_skills_are_hidden_and_cannot_be_added_or_endorsed(api_client):
actor = _mk_user()
target = _mk_user()
secret_skill = Skill.objects.get(system_code="attendance_punctuality")
api_client.force_authenticate(user=actor)
list_response = api_client.get(reverse("api:v1:skills-list"))
assert list_response.status_code == 200
assert secret_skill.id not in {item["id"] for item in list_response.data}
add_response = api_client.post(
reverse("api:v1:employees-add-skill", args=[target.id]),
{"skill_id": secret_skill.id},
format="json",
)
assert add_response.status_code == 400
target.skills.add(secret_skill)
progress = EmployeeSkillProgress.objects.get(
employee=target,
skill=secret_skill,
)
progress.special_rank = "bronze"
progress.progress_data = {
"current_streak": 20,
"best_streak": 20,
}
progress.save(
update_fields=["special_rank", "progress_data", "updated_at"],
)
progress_response = api_client.get(
reverse(
"api:v1:employees-skill-progress",
args=[target.id, secret_skill.id],
)
)
assert progress_response.status_code == 200
assert progress_response.data["rank"] == "bronze"
assert progress_response.data["rarity"] == "secret"
assert progress_response.data["can_endorse"] is False
assert progress_response.data["next_rank_requirements"] == [
{
"key": "best_streak",
"label": "Лучшая серия без опозданий",
"current": 20,
"target": 45,
"complete": False,
}
]
endorse_response = api_client.post(
reverse(
"api:v1:employees-endorse-skill",
args=[target.id, secret_skill.id],
),
{"reason": "Подтверждение не должно приниматься"},
format="json",
)
assert endorse_response.status_code == 400
@pytest.mark.django_db
def test_skill_rank_and_rarity_boundaries():
assert rank_payload(0)["rank"] == "none"
@ -128,7 +191,9 @@ def test_skill_rarity_counts_holders_from_fifty_experience():
progress.experience = 50
progress.save(update_fields=["experience", "updated_at"])
assert confirmed_holder_count(skill) == 1
annotated_skill = skill_queryset_with_rarity(Skill.objects.filter(pk=skill.pk)).get()
annotated_skill = skill_queryset_with_rarity(
Skill.objects.filter(pk=skill.pk)
).get()
assert annotated_skill.confirmed_holders == 1
@ -273,14 +338,20 @@ def test_task_skill_experience_snapshots_roles_for_each_completion():
task.skills.add(skill)
assert award_task_skill_experience(task.id) == 2
assert EmployeeSkillProgress.objects.get(
employee=assignee,
skill=skill,
).experience == 30
assert EmployeeSkillProgress.objects.get(
employee=participant,
skill=skill,
).experience == 15
assert (
EmployeeSkillProgress.objects.get(
employee=assignee,
skill=skill,
).experience
== 30
)
assert (
EmployeeSkillProgress.objects.get(
employee=participant,
skill=skill,
).experience
== 15
)
creator_progress = EmployeeSkillProgress.objects.get(
employee=creator,
skill=skill,
@ -292,15 +363,21 @@ def test_task_skill_experience_snapshots_roles_for_each_completion():
task.assignee = participant
task.save(update_fields=["assignee", "updated_at"])
assert SkillExperienceEvent.objects.filter(
source_type=SkillExperienceEvent.Source.TASK,
source_id=str(task.id),
revoked_at__isnull=True,
).count() == 2
assert EmployeeSkillProgress.objects.get(
employee=assignee,
skill=skill,
).experience == 30
assert (
SkillExperienceEvent.objects.filter(
source_type=SkillExperienceEvent.Source.TASK,
source_id=str(task.id),
revoked_at__isnull=True,
).count()
== 2
)
assert (
EmployeeSkillProgress.objects.get(
employee=assignee,
skill=skill,
).experience
== 30
)
assert revoke_task_skill_experience(task.id, actor=creator) == 2
first_completed_at = task.completed_at
@ -327,16 +404,22 @@ def test_task_skill_experience_snapshots_roles_for_each_completion():
)
assert task_events.count() == 4
assert task_events.filter(revoked_at__isnull=False).count() == 2
assert task_events.filter(
revoked_at__isnull=True,
metadata__reward_role="assignee",
points=30,
).count() == 1
assert task_events.filter(
revoked_at__isnull=True,
metadata__reward_role="participant",
points=15,
).count() == 1
assert (
task_events.filter(
revoked_at__isnull=True,
metadata__reward_role="assignee",
points=30,
).count()
== 1
)
assert (
task_events.filter(
revoked_at__isnull=True,
metadata__reward_role="participant",
points=15,
).count()
== 1
)
@pytest.mark.django_db

View File

@ -0,0 +1,144 @@
from datetime import date, timedelta
import pytest
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from employees.constants import ACTION_REMOTE
from employees.models import EmployeeSkillProgress, Skill
from employees.services.secret_skills import recalculate_attendance_secret_skills
pytestmark = pytest.mark.django_db
def _run(employee, start: date, end: date):
return AttendanceAnalysisRun.objects.create(
employee=employee,
period_start=start,
period_end=end,
status=AttendanceAnalysisRun.STATUS_SUCCESS,
)
def _record(run, employee, record_date, **overrides):
values = {
"arrival_time": "08:00",
"departure_time": "17:00",
"work_hours": 9,
"expected_hours": 9,
"is_workday": True,
"effective_is_workday": True,
"personnel_status": "normal",
}
values.update(overrides)
return AttendanceRecord.objects.create(
analysis_run=run,
employee=employee,
date=record_date,
**values,
)
def test_punctuality_uses_best_streak_and_pauses_unknown_days(user_factory):
employee = user_factory()
start = date(2026, 1, 1)
run = _run(employee, start, start + timedelta(days=21))
for offset in range(10):
_record(run, employee, start + timedelta(days=offset))
_record(
run,
employee,
start + timedelta(days=10),
arrival_time=None,
departure_time=None,
work_hours=0,
technical_issues=["Нет данных контроллера"],
)
_record(
run,
employee,
start + timedelta(days=11),
arrival_time=None,
departure_time=None,
work_hours=0,
personnel_status=ACTION_REMOTE,
)
for offset in range(12, 22):
_record(run, employee, start + timedelta(days=offset))
assert recalculate_attendance_secret_skills(employee.id) == ["Пунктуальность"]
skill = Skill.objects.get(system_code="attendance_punctuality")
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.special_rank == "bronze"
assert progress.progress_data["best_streak"] == 20
assert progress.progress_data["current_streak"] == 20
assert employee.skills.filter(pk=skill.pk).exists()
late_record = _record(
run,
employee,
start + timedelta(days=22),
is_late=True,
late_minutes=15,
)
assert recalculate_attendance_secret_skills(employee.id) == []
progress.refresh_from_db()
assert progress.special_rank == "bronze"
assert progress.progress_data["current_streak"] == 0
late_record.delete()
assert recalculate_attendance_secret_skills(employee.id) == []
progress.refresh_from_db()
assert progress.special_rank == "bronze"
def test_self_organization_requires_every_bronze_condition_and_never_demotes(
user_factory,
):
employee = user_factory()
start = date(2024, 1, 1)
run = _run(employee, start, start + timedelta(days=64))
for offset in range(60):
overtime = offset < 5
_record(
run,
employee,
start + timedelta(days=offset),
is_overtime=overtime,
overtime_hours=1.5 if overtime else None,
)
off_schedule_records = []
for offset in range(60, 65):
off_schedule_records.append(
_record(
run,
employee,
start + timedelta(days=offset),
is_workday=False,
effective_is_workday=False,
is_overtime=True,
overtime_hours=2,
)
)
changes = recalculate_attendance_secret_skills(employee.id)
assert "Самоорганизация" in changes
skill = Skill.objects.get(system_code="attendance_self_organization")
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.special_rank == "bronze"
assert progress.progress_data["clean_days"] == 60
assert progress.progress_data["best_clean_streak"] == 60
assert progress.progress_data["off_schedule_days"] == 5
assert progress.progress_data["overtime_days"] == 10
off_schedule_records[0].is_overtime = False
off_schedule_records[0].overtime_hours = None
off_schedule_records[0].save(update_fields=["is_overtime", "overtime_hours"])
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.progress_data["off_schedule_days"] == 4
assert progress.special_rank == "bronze"
assert progress.is_active is True
assert employee.skills.filter(pk=skill.pk).exists()

View File

@ -38,6 +38,7 @@ import {
import { useUser } from "@/contexts/UserContext";
import { useUserDetailPage } from "@/hooks/useUserDetailPage";
import { apiClient } from "@/lib/api";
import type { Skill } from "@/types/api";
import {
formatBirthdayWithYear,
formatProfileDate,
@ -122,12 +123,8 @@ export default function UserDetailPage() {
|| currentUser.auth?.is_superuser
),
);
const [availableSkills, setAvailableSkills] = useState<
Array<{ id: number; name: string; description?: string }>
>([]);
const [personSkills, setPersonSkills] = useState<
Array<{ id: number; name: string; description?: string }>
>([]);
const [availableSkills, setAvailableSkills] = useState<Skill[]>([]);
const [personSkills, setPersonSkills] = useState<Skill[]>([]);
const [skillName, setSkillName] = useState("");
const [skillsLoading, setSkillsLoading] = useState(false);
const [skillsSaving, setSkillsSaving] = useState(false);
@ -303,7 +300,7 @@ export default function UserDetailPage() {
const response = (await apiClient.addEmployeeSkill(
person.id,
existingSkill ? { skill_id: existingSkill.id } : { name: trimmedName },
)) as { skills?: Array<{ id: number; name: string; description?: string }> };
)) as { skills?: Skill[] };
setPersonSkills(response.skills || []);

View File

@ -65,6 +65,7 @@ const skillRarityStyles: Record<SkillRarity, string> = {
rare: "border-sky-500/70",
epic: "border-violet-500/70",
legendary: "border-amber-400/80",
secret: "border-fuchsia-400/80 bg-fuchsia-500/5",
};
const skillRarityBadgeStyles: Record<SkillRarity, string> = {
@ -74,6 +75,7 @@ const skillRarityBadgeStyles: Record<SkillRarity, string> = {
rare: "bg-sky-500/15 text-sky-300",
epic: "bg-violet-500/15 text-violet-300",
legendary: "bg-amber-400/15 text-amber-300",
secret: "bg-fuchsia-400/15 text-fuchsia-200",
};
const skillRankOrder: Record<SkillRank, number> = {
@ -92,6 +94,7 @@ const skillRarityOrder: Record<SkillRarity, number> = {
rare: 3,
epic: 4,
legendary: 5,
secret: 6,
};
function sortProfileSkills(skills: Skill[]) {
@ -826,6 +829,7 @@ export function ProfileSkillsInline({
const [endorsementSaving, setEndorsementSaving] = useState(false);
const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`;
const sortedSkills = sortProfileSkills(skills);
const editableSkills = skills.filter((skill) => !skill.is_secret);
const openSkillProgress = async (skill: Skill) => {
setProgressOpen(true);
@ -981,9 +985,9 @@ export function ProfileSkillsInline({
<div>
<p className="app-card-caption mb-2">Добавленные навыки</p>
{skills.length ? (
{editableSkills.length ? (
<div className="flex flex-wrap gap-2">
{skills.map((skill) =>
{editableSkills.map((skill) =>
onRemoveSkill ? (
<button
key={skill.id}
@ -1077,6 +1081,9 @@ export function ProfileSkillsInline({
<p className="app-text-muted mt-1 text-[11px] leading-relaxed">
Сотрудник считается подтверждённым обладателем навыка после достижения 50 XP — ранг «Бронза» для этого не требуется.
</p>
<p className="app-text-muted mt-1 text-[11px] leading-relaxed">
Секретные навыки открываются системой по рабочим показателям: их нельзя добавить, удалить или подтвердить вручную, а их редкость не зависит от числа обладателей.
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{[
{ rarity: "legendary" as const, label: "Легендарный · 1" },
@ -1084,6 +1091,7 @@ export function ProfileSkillsInline({
{ rarity: "rare" as const, label: "Редкий · 4–9" },
{ rarity: "uncommon" as const, label: "Необычный · 10–24" },
{ rarity: "common" as const, label: "Обычный · 25+" },
{ rarity: "secret" as const, label: "Секретный · системное условие" },
].map((item) => (
<span
key={item.rarity}
@ -1125,14 +1133,19 @@ export function ProfileSkillsInline({
<div className="min-w-0">
<p className="font-semibold text-[var(--foreground)]">{progress.rank_display}</p>
<p className="app-text-muted text-xs">
{progress.rarity_display} · у {progress.confirmed_holders} сотрудников
{progress.is_secret
? `${progress.rarity_display} системный навык`
: `${progress.rarity_display} · у ${progress.confirmed_holders} сотрудников`}
</p>
</div>
</div>
<span className="app-badge shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold">
{progress.experience} XP
{progress.is_secret ? "Автоматически" : `${progress.experience} XP`}
</span>
</div>
{progress.is_secret && progress.progress_label ? (
<p className="app-text-muted mt-3 text-xs">{progress.progress_label}</p>
) : null}
<div className="mt-4 h-2 overflow-hidden rounded-full bg-[var(--surface-primary)]">
<div
className="h-full rounded-full bg-[var(--accent-primary)] transition-[width]"
@ -1140,13 +1153,34 @@ export function ProfileSkillsInline({
/>
</div>
<div className="app-text-muted mt-1.5 flex justify-between gap-3 text-[11px]">
<span>{progress.rank_progress_percent}% текущего ранга</span>
<span>
{progress.rank_progress_percent}% {progress.is_secret ? "до следующего ранга" : "текущего ранга"}
</span>
<span>
{progress.next_rank_display
? `До «${progress.next_rank_display}»: ${progress.experience_to_next_rank} XP`
? progress.is_secret
? `Следующий: ${progress.next_rank_display}`
: `До «${progress.next_rank_display}»: ${progress.experience_to_next_rank} XP`
: "Высший ранг"}
</span>
</div>
{progress.is_secret && progress.next_rank_requirements?.length ? (
<div className="mt-3 grid gap-1.5">
{progress.next_rank_requirements.map((requirement) => (
<div
key={requirement.key}
className="app-surface flex items-center justify-between gap-3 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2 text-xs"
>
<span className="min-w-0 truncate text-[var(--foreground)]">
{requirement.label}
</span>
<span className={requirement.complete ? "text-emerald-300" : "app-text-muted"}>
{requirement.current}/{requirement.target}
</span>
</div>
))}
</div>
) : null}
</div>
{progress.can_endorse ? (
@ -1183,7 +1217,9 @@ export function ProfileSkillsInline({
) : null}
<div>
<p className="app-card-caption mb-2">История опыта</p>
<p className="app-card-caption mb-2">
{progress.is_secret ? "Системный прогресс" : "История опыта"}
</p>
{progress.events.length ? (
<div className="max-h-60 space-y-2 overflow-y-auto pr-1">
{progress.events.map((event) => (
@ -1203,7 +1239,9 @@ export function ProfileSkillsInline({
</div>
) : (
<p className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center text-xs text-[var(--muted-foreground)]">
Начислений пока нет
{progress.is_secret
? "Навык обновляется автоматически по подтверждённым данным посещаемости."
: "Начислений пока нет"}
</p>
)}
</div>

View File

@ -22,6 +22,7 @@ type SkillOption = {
id: number;
name: string;
description?: string;
is_secret?: boolean;
};
export type ProfileContactKind =
@ -224,6 +225,7 @@ export function useProfilePage(): ProfilePageController {
async (skillId: number) => {
if (!user) return;
const nextIds = profileSkills
.filter((skill) => !skill.is_secret)
.map((skill) => skill.id)
.filter((id) => id !== skillId);
await saveSkills(nextIds);
@ -265,7 +267,10 @@ export function useProfilePage(): ProfilePageController {
}
const nextIds = Array.from(
new Set([...profileSkills.map((skill) => skill.id), skillId]),
new Set([
...profileSkills.filter((skill) => !skill.is_secret).map((skill) => skill.id),
skillId,
]),
);
const response = (await apiClient.updateCurrentUserProfile({
skills_ids: nextIds,

View File

@ -2,6 +2,8 @@
export interface Skill {
id: number;
name: string;
is_secret?: boolean;
progression_type?: "standard" | "attendance_punctuality" | "attendance_self_organization";
description?: string;
experience?: number;
rank?: SkillRank;
@ -14,10 +16,22 @@ export interface Skill {
confirmed_holders?: number;
rarity?: SkillRarity;
rarity_display?: string;
progress_value?: number | null;
progress_label?: string;
progress_data?: Record<string, unknown>;
next_rank_requirements?: SkillRankRequirement[];
}
export type SkillRank = "none" | "bronze" | "silver" | "gold" | "ruby" | "diamond";
export type SkillRarity = "unrated" | "common" | "uncommon" | "rare" | "epic" | "legendary";
export type SkillRarity = "unrated" | "common" | "uncommon" | "rare" | "epic" | "legendary" | "secret";
export interface SkillRankRequirement {
key: string;
label: string;
current: number;
target: number;
complete: boolean;
}
export interface SkillExperienceEvent {
id: number;
@ -36,6 +50,8 @@ export interface EmployeeSkillProgress {
employee_id: number;
skill_id: number;
skill_name: string;
is_secret: boolean;
progression_type: "standard" | "attendance_punctuality" | "attendance_self_organization";
experience: number;
rank: SkillRank;
rank_display: string;
@ -47,6 +63,10 @@ export interface EmployeeSkillProgress {
confirmed_holders: number;
rarity: SkillRarity;
rarity_display: string;
progress_value?: number | null;
progress_label?: string;
progress_data?: Record<string, unknown>;
next_rank_requirements?: SkillRankRequirement[];
can_endorse: boolean;
endorsement_points?: number;
endorsement_role?: string;