merge: secret skill gamification

This commit is contained in:
lizerk 2026-08-26 11:20:22 +03:00
commit 22ecf40d46
47 changed files with 5413 additions and 176 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

@ -20,7 +20,9 @@ from .shared import (
EmployeeActionSerializer,
PositionBriefSerializer,
PositionSerializer,
SkillExperienceEventSerializer,
SkillSerializer,
serialize_employee_skills,
)
__all__ = [
@ -42,6 +44,8 @@ __all__ = [
"SetMemberRoleInput",
# shared
"SkillSerializer",
"SkillExperienceEventSerializer",
"serialize_employee_skills",
"PositionBriefSerializer",
"PositionSerializer",
"EmployeeActionSerializer",

View File

@ -13,7 +13,7 @@ from employees.models import EmployeeDepartment, Position, Skill
from ..shared import (
EmployeeActionSerializer,
PositionBriefSerializer,
SkillSerializer,
serialize_employee_skills,
)
from .common import (
EmployeeAttendanceStatusSerializer,
@ -40,9 +40,12 @@ class EmployeeSerializer(serializers.ModelSerializer):
username = serializers.CharField(read_only=True, allow_blank=True)
last_activity_at = serializers.SerializerMethodField()
skills = SkillSerializer(many=True, read_only=True)
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)
@ -123,6 +126,9 @@ class EmployeeSerializer(serializers.ModelSerializer):
return None
return value
def get_skills(self, obj):
return serialize_employee_skills(obj, context=self.context)
def validate_attendance_aliases(self, value):
result = []
for alias in value or []:
@ -139,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

@ -8,7 +8,7 @@ from employees.models import EmployeeDepartment
from ..shared import (
PositionBriefSerializer,
SkillSerializer,
serialize_employee_skills,
)
from .common import (
EmployeeAttendanceStatusSerializer,
@ -33,7 +33,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
position = PositionBriefSerializer(read_only=True)
departments = serializers.SerializerMethodField()
full_name = serializers.SerializerMethodField()
skills = SkillSerializer(many=True, read_only=True)
skills = serializers.SerializerMethodField()
department_relation = serializers.SerializerMethodField()
personnel_state = serializers.SerializerMethodField(read_only=True)
attendance_status = serializers.SerializerMethodField(read_only=True)
@ -65,6 +65,9 @@ class EmployeeListSerializer(serializers.ModelSerializer):
def get_personnel_state(self, obj):
return _employee_personnel_state_payload(obj)
def get_skills(self, obj):
return serialize_employee_skills(obj, context=self.context)
@extend_schema_field(EmployeeAttendanceStatusSerializer(allow_null=True))
def get_attendance_status(self, obj):
return _employee_attendance_status_payload(obj)

View File

@ -1,11 +1,26 @@
"""Общие (shared) сериализаторы: Skill, Position, EmployeeAction."""
from drf_spectacular.utils import extend_schema_field
from django.contrib.auth.models import Group
from employees.constants import ACTION_CHOICES, TEMPORARY_START_ACTIONS
from employees.models import EmployeeAction, Position, Skill
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from employees.constants import ACTION_CHOICES, TEMPORARY_START_ACTIONS
from employees.models import (
EmployeeAction,
EmployeeSkillProgress,
Position,
Skill,
SkillExperienceEvent,
)
from employees.services.secret_skills import (
secret_skill_progress_payload,
skill_rarity_payload,
)
from employees.services.skill_progress import (
confirmed_holder_count,
rank_payload,
)
class GroupVerboseSerializer(serializers.Serializer):
id = serializers.IntegerField()
@ -25,9 +40,190 @@ class EmployeeActionHistorySerializer(serializers.Serializer):
class SkillSerializer(serializers.ModelSerializer):
experience = serializers.SerializerMethodField()
rank = serializers.SerializerMethodField()
rank_display = serializers.SerializerMethodField()
next_rank = serializers.SerializerMethodField()
next_rank_display = serializers.SerializerMethodField()
next_rank_experience = serializers.SerializerMethodField()
experience_to_next_rank = serializers.SerializerMethodField()
rank_progress_percent = serializers.SerializerMethodField()
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()
anti_streak = serializers.SerializerMethodField()
anti_streak_label = serializers.SerializerMethodField()
anti_streak_limit = serializers.SerializerMethodField()
anti_streak_remaining = serializers.SerializerMethodField()
recovery_streak = serializers.SerializerMethodField()
recovery_streak_target = serializers.SerializerMethodField()
penalty_levels = serializers.SerializerMethodField()
class Meta:
model = Skill
fields = ["id", "name"]
fields = [
"id",
"name",
"is_secret",
"progression_type",
"experience",
"rank",
"rank_display",
"next_rank",
"next_rank_display",
"next_rank_experience",
"experience_to_next_rank",
"rank_progress_percent",
"confirmed_holders",
"rarity",
"rarity_display",
"progress_value",
"progress_label",
"progress_data",
"next_rank_requirements",
"anti_streak",
"anti_streak_label",
"anti_streak_limit",
"anti_streak_remaining",
"recovery_streak",
"recovery_streak_target",
"penalty_levels",
]
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):
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 skill_rarity_payload(obj, count)
def get_experience(self, obj):
return getattr(self._progress(obj), "experience", 0)
def get_rank(self, obj):
return self._rank(obj)["rank"]
def get_rank_display(self, obj):
return self._rank(obj)["rank_display"]
def get_next_rank(self, obj):
return self._rank(obj)["next_rank"]
def get_next_rank_display(self, obj):
return self._rank(obj)["next_rank_display"]
def get_next_rank_experience(self, obj):
return self._rank(obj)["next_rank_experience"]
def get_experience_to_next_rank(self, obj):
return self._rank(obj)["experience_to_next_rank"]
def get_rank_progress_percent(self, obj):
return self._rank(obj)["rank_progress_percent"]
def get_confirmed_holders(self, obj):
return self._rarity(obj)["confirmed_holders"]
def get_rarity(self, obj):
return self._rarity(obj)["rarity"]
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", [])
def get_anti_streak(self, obj):
return self._rank(obj).get("anti_streak", 0)
def get_anti_streak_label(self, obj):
return self._rank(obj).get("anti_streak_label", "Антисерия")
def get_anti_streak_limit(self, obj):
return self._rank(obj).get("anti_streak_limit", 0)
def get_anti_streak_remaining(self, obj):
return self._rank(obj).get("anti_streak_remaining", 0)
def get_recovery_streak(self, obj):
return self._rank(obj).get("recovery_streak", 0)
def get_recovery_streak_target(self, obj):
return self._rank(obj).get("recovery_streak_target", 0)
def get_penalty_levels(self, obj):
return self._rank(obj).get("penalty_levels", 0)
class SkillExperienceEventSerializer(serializers.ModelSerializer):
source_display = serializers.CharField(
source="get_source_type_display",
read_only=True,
)
actor_name = serializers.SerializerMethodField()
class Meta:
model = SkillExperienceEvent
fields = [
"id",
"points",
"source_type",
"source_display",
"source_id",
"actor",
"actor_name",
"reason",
"metadata",
"revoked_at",
"created_at",
]
def get_actor_name(self, obj):
if obj.actor is None:
return None
return obj.actor.get_full_name() or obj.actor.email
def serialize_employee_skills(employee, *, context=None):
progress_items = getattr(employee, "_prefetched_skill_progresses", None)
if progress_items is None:
progress_items = EmployeeSkillProgress.objects.filter(
employee=employee,
is_active=True,
)
progress_by_skill = {
progress.skill_id: progress for progress in progress_items if progress.is_active
}
serializer_context = dict(context or {})
serializer_context["skill_progress_by_skill"] = progress_by_skill
return SkillSerializer(
employee.skills.all(),
many=True,
context=serializer_context,
).data
class PositionBriefSerializer(serializers.ModelSerializer):
@ -83,8 +279,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{
"date_to": (
"Дата окончания доступна только для временных "
"кадровых событий."
"Дата окончания доступна только для временных кадровых событий."
)
}
)
@ -102,8 +297,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{
"date_to": (
"Дата окончания обязательна для временных "
"кадровых событий."
"Дата окончания обязательна для временных кадровых событий."
)
}
)
@ -168,18 +362,12 @@ class EmployeeActionSerializer(serializers.ModelSerializer):
{
"history_id": cur.history_id,
"history_type": cur.history_type,
"history_type_display": history_type_labels.get(
cur.history_type
),
"history_type_display": history_type_labels.get(cur.history_type),
"history_date": cur.history_date.isoformat(),
"history_date_display": cur.history_date.strftime(
"%d.%m.%Y %H:%M"
),
"history_date_display": cur.history_date.strftime("%d.%m.%Y %H:%M"),
"history_user": getattr(cur.history_user, "email", None),
"action": getattr(cur, "action", None),
"action_display": action_labels.get(
getattr(cur, "action", None)
),
"action_display": action_labels.get(getattr(cur, "action", None)),
"changes": changes,
}
)

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

@ -4,7 +4,29 @@ from django.db.models import Prefetch
from rest_framework.decorators import action
from rest_framework.response import Response
from employees.models import Employee, EmployeeAction, EmployeeDepartment, Skill
from employees.models import (
Employee,
EmployeeAction,
EmployeeDepartment,
EmployeeSkillProgress,
Skill,
SkillExperienceEvent,
)
from employees.services.skill_progress import (
endorsement_available_at,
endorsement_weight,
ensure_skill_progress,
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
class EmployeeProfileMixin:
@ -18,7 +40,15 @@ class EmployeeProfileMixin:
instance = (
self._with_last_activity(Employee.objects.select_related("position"))
.prefetch_related(
"skills",
Prefetch(
"skills",
queryset=skill_queryset_with_rarity().order_by("name"),
),
Prefetch(
"skill_progresses",
queryset=EmployeeSkillProgress.objects.select_related("skill"),
to_attr="_prefetched_skill_progresses",
),
Prefetch(
"departments_links",
queryset=EmployeeDepartment.objects.filter(
@ -75,9 +105,152 @@ 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,
skill,
created_by=request.user,
)
if progress.created_by_id is None:
progress.created_by = request.user
progress.save(update_fields=["created_by", "updated_at"])
return Response(self.get_serializer(instance).data, status=200)
@action(
detail=True,
methods=["get"],
url_path=r"skills/(?P<skill_id>\d+)/progress",
)
def skill_progress(self, request, pk=None, skill_id=None):
employee = self.get_object()
try:
skill = skill_queryset_with_rarity().get(pk=int(skill_id))
except (TypeError, ValueError, Skill.DoesNotExist):
return Response({"detail": "Навык не найден"}, status=404)
if not employee.skills.filter(pk=skill.pk).exists():
return Response(
{"detail": "Навык не добавлен сотруднику"},
status=404,
)
progress = ensure_skill_progress(employee, skill)
events = progress.experience_events.select_related("actor").order_by(
"-created_at",
"-id",
)[:50]
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 = skill_rarity_payload(skill, holder_count)
available_at = None
if not skill.is_secret and request.user.id != employee.id:
available_at = endorsement_available_at(
progress=progress,
actor=request.user,
)
return Response(
{
"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": (
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,
}
)
@action(
detail=True,
methods=["post"],
url_path=r"skills/(?P<skill_id>\d+)/endorse",
)
def endorse_skill(self, request, pk=None, skill_id=None):
employee = self.get_object()
if employee.id == request.user.id:
return Response(
{"detail": "Нельзя подтверждать собственный навык."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
skill = Skill.objects.get(pk=int(skill_id))
except (TypeError, ValueError, Skill.DoesNotExist):
return Response({"detail": "Навык не найден"}, status=404)
if not employee.skills.filter(pk=skill.pk).exists():
return Response(
{"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:
return Response(
{"reason": ["Опишите, где сотрудник применил навык."]},
status=status.HTTP_400_BAD_REQUEST,
)
progress = ensure_skill_progress(employee, skill)
available_at = endorsement_available_at(
progress=progress,
actor=request.user,
)
if available_at is not None:
return Response(
{
"detail": "Этот навык уже подтверждался вами недавно.",
"available_at": available_at,
},
status=status.HTTP_409_CONFLICT,
)
points, role_label = endorsement_weight(request.user, employee)
progress, event, _ = record_skill_experience(
employee=employee,
skill=skill,
points=points,
source_type=SkillExperienceEvent.Source.ENDORSEMENT,
actor=request.user,
source_id=str(request.user.id),
reason=reason,
metadata={"endorser_role": role_label},
)
rank = rank_payload(progress.experience)
return Response(
{
"ok": True,
"points": points,
"endorser_role": role_label,
"experience": progress.experience,
**rank,
"event": SkillExperienceEventSerializer(event).data,
},
status=status.HTTP_201_CREATED,
)
def get_serializer_context(self):
context = super().get_serializer_context()
if getattr(self, "action", None) in {"retrieve", "me"}:

View File

@ -13,8 +13,10 @@ from employees.models import (
Employee,
EmployeeAction,
EmployeeDepartment,
EmployeeSkillProgress,
RoleAssignment,
)
from employees.services.skill_progress import skill_queryset_with_rarity
from employees.utils import _to_bool
@ -70,7 +72,15 @@ class EmployeeQueryMixin:
return (
Employee.objects.select_related("position")
.prefetch_related(
"skills",
Prefetch(
"skills",
queryset=skill_queryset_with_rarity().order_by("name"),
),
Prefetch(
"skill_progresses",
queryset=EmployeeSkillProgress.objects.select_related("skill"),
to_attr="_prefetched_skill_progresses",
),
active_departments,
Prefetch(
"actions",

View File

@ -3,7 +3,8 @@
from __future__ import annotations
from django.db import transaction
from employees.models import Skill
from employees.models import EmployeeSkillProgress, Skill
from employees.services.skill_progress import skill_queryset_with_rarity
from rest_framework import filters, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
@ -26,7 +27,7 @@ class SkillViewSet(viewsets.ModelViewSet):
- POST /skills/merge
"""
queryset = Skill.objects.all().order_by("name")
queryset = skill_queryset_with_rarity().order_by("name")
serializer_class = SkillSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
@ -37,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()]
@ -66,9 +73,7 @@ class SkillViewSet(viewsets.ModelViewSet):
cleaned.append(s)
existing = set(
Skill.objects.filter(name__in=cleaned).values_list(
"name", flat=True
)
Skill.objects.filter(name__in=cleaned).values_list("name", flat=True)
)
to_create = [Skill(name=n) for n in cleaned if n not in existing]
Skill.objects.bulk_create(to_create, ignore_conflicts=True)
@ -97,9 +102,7 @@ class SkillViewSet(viewsets.ModelViewSet):
delete_source = bool(request.data.get("delete_source", True))
if not sid or not tid:
return Response(
{"detail": "source_id и target_id обязательны"}, status=400
)
return Response({"detail": "source_id и target_id обязательны"}, status=400)
try:
sid = int(sid)
tid = int(tid)
@ -119,12 +122,39 @@ 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:
qs = Employee.objects.filter(skills=source).only("id").distinct()
for emp in qs:
emp.skills.add(target)
source_progress = EmployeeSkillProgress.objects.filter(
employee=emp,
skill=source,
).first()
target_progress = EmployeeSkillProgress.objects.filter(
employee=emp,
skill=target,
).first()
if source_progress and target_progress:
source_progress.experience_events.update(progress=target_progress)
target_progress.experience += source_progress.experience
target_progress.is_active = True
target_progress.save(
update_fields=["experience", "is_active", "updated_at"]
)
source_progress.delete()
elif source_progress:
source_progress.skill = target
source_progress.is_active = True
source_progress.save(
update_fields=["skill", "is_active", "updated_at"]
)
emp.skills.remove(source)
moved_count += 1

View File

@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from api.v1.employees.serializers import EmployeeBriefSerializer
from api.v1.employees.serializers import EmployeeBriefSerializer, SkillSerializer
from communications.comments_helpers import get_comment_count
from tasks.access import (
user_can_access_task_board,
@ -15,6 +15,7 @@ from tasks.models import (
TaskLabel,
TaskLinkedObjectKind,
)
from employees.models import Skill
from .linked import TaskCoverSerializer
from .structure import TaskLabelSerializer
@ -52,6 +53,14 @@ class TaskSerializer(serializers.ModelSerializer):
required=False,
write_only=True,
)
skills = SkillSerializer(many=True, read_only=True)
skill_ids = serializers.PrimaryKeyRelatedField(
source="skills",
queryset=Skill.objects.filter(is_secret=False),
many=True,
required=False,
write_only=True,
)
priority_display = serializers.CharField(
source="get_priority_display",
read_only=True,
@ -100,6 +109,8 @@ class TaskSerializer(serializers.ModelSerializer):
"participant_ids",
"labels",
"label_ids",
"skills",
"skill_ids",
"priority",
"priority_display",
"due_date",
@ -348,11 +359,7 @@ class TaskSerializer(serializers.ModelSerializer):
self.instance.assignee_id if self.instance is not None else None
)
board_changed = bool(
board
and (
self.instance is None
or self.instance.board_id != board.id
)
board and (self.instance is None or self.instance.board_id != board.id)
)
assignee = attrs.get("assignee")
if "assignee" in attrs and assignee is not None:
@ -431,6 +438,7 @@ class TaskSerializer(serializers.ModelSerializer):
def create(self, validated_data):
labels = validated_data.pop("labels", [])
skills = validated_data.pop("skills", [])
participants = list(validated_data.pop("participants", []))
if "position" not in validated_data:
column = validated_data["column"]
@ -450,11 +458,14 @@ class TaskSerializer(serializers.ModelSerializer):
task.participants.set(participant_ids)
if labels:
task.labels.set(labels)
if skills:
task.skills.set(skills)
return task
def update(self, instance, validated_data):
previous_assignee_id = instance.assignee_id
labels = validated_data.pop("labels", None)
skills = validated_data.pop("skills", None)
participants = validated_data.pop("participants", None)
task = super().update(instance, validated_data)
if participants is not None:
@ -476,4 +487,6 @@ class TaskSerializer(serializers.ModelSerializer):
)
if labels is not None:
task.labels.set(labels)
if skills is not None:
task.skills.set(skills)
return task

View File

@ -19,6 +19,7 @@ from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from communications.models import Message
from employees.services.skill_progress import skill_queryset_with_rarity
from tasks.access import task_board_access_q
from tasks.models import (
Task,
@ -165,6 +166,10 @@ class TaskBoardViewSet(TaskBoardActionsMixin, viewsets.ModelViewSet):
.prefetch_related(
"participants",
"labels",
Prefetch(
"skills",
queryset=skill_queryset_with_rarity(),
),
Prefetch(
"linked_objects",
queryset=TaskLinkedObject.objects.only(

View File

@ -81,6 +81,7 @@ class TaskViewSet(
"due_date": instance.due_date.isoformat() if instance.due_date else None,
"row_id": instance.row_id,
"label_ids": sorted(instance.labels.values_list("id", flat=True)),
"skill_ids": sorted(instance.skills.values_list("id", flat=True)),
"participant_ids": sorted(
instance.participants.values_list("id", flat=True)
),
@ -94,6 +95,7 @@ class TaskViewSet(
"due_date": task.due_date.isoformat() if task.due_date else None,
"row_id": task.row_id,
"label_ids": sorted(task.labels.values_list("id", flat=True)),
"skill_ids": sorted(task.skills.values_list("id", flat=True)),
"participant_ids": sorted(task.participants.values_list("id", flat=True)),
}
changed_fields = [
@ -136,6 +138,7 @@ class TaskViewSet(
task.column = column
task.row = row
task.position = position
task._skill_completion_actor = actor
task.save(
update_fields=["column", "row", "position", "completed_at", "updated_at"]
)

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

@ -18,8 +18,10 @@ from .models import (
Department,
DepartmentRole,
EmployeeDepartment,
EmployeeSkillProgress,
Position,
Skill,
SkillExperienceEvent,
LdapSyncState,
)
@ -1903,10 +1905,53 @@ 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",
"special_rank",
"is_active",
"updated_at",
)
list_filter = ("is_active", "special_rank", "skill")
search_fields = (
"employee__email",
"employee__first_name",
"employee__last_name",
"skill__name",
)
autocomplete_fields = ("employee", "skill", "created_by")
readonly_fields = ("created_at", "updated_at")
@admin.register(SkillExperienceEvent)
class SkillExperienceEventAdmin(admin.ModelAdmin):
list_display = (
"progress",
"points",
"source_type",
"actor",
"created_at",
"revoked_at",
)
list_filter = ("source_type", "revoked_at")
search_fields = (
"progress__employee__email",
"progress__skill__name",
"reason",
"idempotency_key",
)
autocomplete_fields = ("progress", "actor", "revoked_by")
readonly_fields = ("created_at",)
# Полезно иметь Permission в админке
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):

View File

@ -0,0 +1,169 @@
from collections import Counter, defaultdict
from django.core.management.base import BaseCommand
from django.db import transaction
from employees.models import Employee, EmployeeSkillProgress
from employees.services.secret_skills import recalculate_attendance_secret_skills
from employees.services.task_execution_skill import refresh_task_execution_skills
RANK_LABELS = {
"none": "без ранга",
"bronze": "бронза",
"silver": "серебро",
"gold": "золото",
"ruby": "рубин",
"diamond": "бриллиант",
}
def _progress_snapshot(employee_ids):
return {
(progress.employee_id, progress.skill_id): {
"rank": progress.special_rank,
"active": progress.is_active,
"skill_name": progress.skill.name,
"system_code": progress.skill.system_code,
"progress_data": dict(progress.progress_data or {}),
}
for progress in EmployeeSkillProgress.objects.filter(
employee_id__in=employee_ids,
skill__is_secret=True,
).select_related("skill")
}
def _change_kind(before, after):
if not before["active"] and after["active"]:
return "получит"
if before["active"] and not after["active"]:
return "потеряет"
rank_order = ["none", "bronze", "silver", "gold", "ruby", "diamond"]
before_index = rank_order.index(before["rank"])
after_index = rank_order.index(after["rank"])
return "повысит" if after_index > before_index else "понизит"
def _metric_summary(state):
metrics = state["progress_data"]
system_code = state["system_code"]
if system_code == "attendance_punctuality":
return f"лучшая серия {metrics.get('best_streak', 0)} дн."
if system_code == "attendance_self_organization":
return (
f"чистых дней {metrics.get('clean_days', 0)}, "
f"выходов {metrics.get('off_schedule_days', 0)}, "
f"переработок {metrics.get('overtime_days', 0)}"
)
if system_code == "attendance_reliability":
return f"без отпуска и отгула {metrics.get('worked_days', 0)} дн."
if system_code == "personnel_resilience":
return (
"лучшая серия без больничного "
f"{metrics.get('best_resilience_streak', 0)} дн."
)
return (
f"подтверждено {metrics.get('confirmed_tasks', 0)} задач, "
f"вовремя {metrics.get('on_time_percent', 0)}%, "
f"серия {metrics.get('best_on_time_streak', 0)}"
)
class Command(BaseCommand):
help = "Пересчитать системные секретные навыки сотрудников"
def add_arguments(self, parser):
parser.add_argument("--employee-id", type=int)
parser.add_argument(
"--dry-run",
action="store_true",
help="Показать изменения без записи в базу",
)
def _recalculate(self, employee_ids, employee_id=None):
processed = 0
rank_changes = 0
task_result = refresh_task_execution_skills(employee_id=employee_id)
rank_changes += task_result["rank_changes"]
for current_employee_id in employee_ids:
changes = recalculate_attendance_secret_skills(current_employee_id)
processed += 1
rank_changes += len(changes)
return processed, rank_changes
def _print_dry_run(self, employee_ids, before, after, processed):
employees = {
employee.id: str(employee)
for employee in Employee.objects.filter(id__in=employee_ids)
}
empty_state = {
"rank": "none",
"active": False,
"skill_name": "",
"system_code": "",
"progress_data": {},
}
changes = []
summary = defaultdict(Counter)
for key in sorted(set(before) | set(after)):
old = before.get(key, empty_state)
new = after.get(key, empty_state)
if old["rank"] == new["rank"] and old["active"] == new["active"]:
continue
kind = _change_kind(old, new)
state = new if new["skill_name"] else old
summary[state["skill_name"]][kind] += 1
changes.append((key[0], kind, old, new, state))
self.stdout.write(self.style.WARNING("СУХОЙ ПРОГОН — изменения не сохранены"))
self.stdout.write(f"Проверено сотрудников: {processed}")
if not changes:
self.stdout.write("Изменений навыков и рангов нет.")
return
self.stdout.write("\nСводка:")
for skill_name in sorted(summary):
counts = summary[skill_name]
self.stdout.write(
f"- {skill_name}: получат {counts['получит']}, "
f"повысят {counts['повысит']}, понизят {counts['понизит']}, "
f"потеряют {counts['потеряет']}"
)
self.stdout.write("\nСотрудники:")
for current_employee_id, kind, old, new, state in changes:
self.stdout.write(
f"- #{current_employee_id} {employees.get(current_employee_id, '—')} — "
f"{state['skill_name']}: {kind} "
f"({RANK_LABELS[old['rank']]} → {RANK_LABELS[new['rank']]}); "
f"{_metric_summary(state)}"
)
def handle(self, *args, **options):
employee_id = options.get("employee_id")
dry_run = options.get("dry_run", False)
if employee_id:
employee_ids = [employee_id]
else:
employee_ids = list(
Employee.objects.order_by("id").values_list("id", flat=True)
)
if dry_run:
with transaction.atomic():
before = _progress_snapshot(employee_ids)
processed, _ = self._recalculate(employee_ids, employee_id)
after = _progress_snapshot(employee_ids)
transaction.set_rollback(True)
self._print_dry_run(employee_ids, before, after, processed)
return
processed, rank_changes = self._recalculate(employee_ids, employee_id)
self.stdout.write(
self.style.SUCCESS(
"Пересчитано сотрудников: "
f"{processed}; изменений рангов и навыков: {rank_changes}"
)
)

View File

@ -0,0 +1,248 @@
# Generated by Django 5.2.4 on 2026-08-25 10:10
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def create_existing_skill_progresses(apps, schema_editor):
Employee = apps.get_model("employees", "Employee")
EmployeeSkillProgress = apps.get_model(
"employees",
"EmployeeSkillProgress",
)
through = Employee.skills.through
employee_field = next(
field
for field in through._meta.fields
if field.remote_field and field.remote_field.model == Employee
)
Skill = apps.get_model("employees", "Skill")
skill_field = next(
field
for field in through._meta.fields
if field.remote_field and field.remote_field.model == Skill
)
rows = through.objects.values_list(
employee_field.attname,
skill_field.attname,
).iterator(chunk_size=1000)
EmployeeSkillProgress.objects.bulk_create(
[
EmployeeSkillProgress(
employee_id=employee_id,
skill_id=skill_id,
is_active=True,
)
for employee_id, skill_id in rows
],
batch_size=1000,
ignore_conflicts=True,
)
class Migration(migrations.Migration):
dependencies = [
("employees", "0056_employee_hidden_in_ui"),
]
operations = [
migrations.CreateModel(
name="EmployeeSkillProgress",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"experience",
models.PositiveIntegerField(default=0, verbose_name="Опыт"),
),
(
"is_active",
models.BooleanField(
default=True, verbose_name="Показывается в профиле"
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_skill_progresses",
to=settings.AUTH_USER_MODEL,
verbose_name="Добавил навык",
),
),
(
"employee",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="skill_progresses",
to=settings.AUTH_USER_MODEL,
verbose_name="Сотрудник",
),
),
(
"skill",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="employee_progresses",
to="employees.skill",
verbose_name="Навык",
),
),
],
options={
"verbose_name": "Прогресс навыка сотрудника",
"verbose_name_plural": "Прогресс навыков сотрудников",
},
),
migrations.CreateModel(
name="SkillExperienceEvent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("points", models.PositiveIntegerField(verbose_name="Баллы опыта")),
(
"source_type",
models.CharField(
choices=[
("endorsement", "Подтверждение сотрудником"),
("task", "Завершённая задача"),
("training", "Обучение"),
("certification", "Аттестация"),
("manual", "Ручное начисление"),
],
max_length=24,
verbose_name="Источник",
),
),
(
"source_id",
models.CharField(
blank=True, max_length=100, verbose_name="ID источника"
),
),
("reason", models.TextField(blank=True, verbose_name="Причина")),
(
"metadata",
models.JSONField(
blank=True, default=dict, verbose_name="Дополнительные данные"
),
),
(
"idempotency_key",
models.CharField(
blank=True,
default="",
max_length=180,
verbose_name="Ключ идемпотентности",
),
),
(
"revoked_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Отозвано"
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"actor",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="skill_experience_events_given",
to=settings.AUTH_USER_MODEL,
verbose_name="Автор начисления",
),
),
(
"progress",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="experience_events",
to="employees.employeeskillprogress",
verbose_name="Прогресс навыка",
),
),
(
"revoked_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="skill_experience_events_revoked",
to=settings.AUTH_USER_MODEL,
verbose_name="Кто отозвал",
),
),
],
options={
"verbose_name": "Начисление опыта навыка",
"verbose_name_plural": "Начисления опыта навыков",
"ordering": ["-created_at", "-id"],
},
),
migrations.AddIndex(
model_name="employeeskillprogress",
index=models.Index(
fields=["skill", "is_active", "experience"],
name="employees_e_skill_i_94f7eb_idx",
),
),
migrations.AddIndex(
model_name="employeeskillprogress",
index=models.Index(
fields=["employee", "is_active"], name="employees_e_employe_0f2ad0_idx"
),
),
migrations.AddConstraint(
model_name="employeeskillprogress",
constraint=models.UniqueConstraint(
fields=("employee", "skill"), name="uniq_employee_skill_progress"
),
),
migrations.AddIndex(
model_name="skillexperienceevent",
index=models.Index(
fields=["progress", "source_type", "revoked_at"],
name="employees_s_progres_195da6_idx",
),
),
migrations.AddIndex(
model_name="skillexperienceevent",
index=models.Index(
fields=["actor", "source_type", "created_at"],
name="employees_s_actor_i_cf6da7_idx",
),
),
migrations.AddConstraint(
model_name="skillexperienceevent",
constraint=models.UniqueConstraint(
condition=models.Q(("idempotency_key", ""), _negated=True),
fields=("idempotency_key",),
name="uniq_skill_xp_idempotency_key",
),
),
migrations.RunPython(
create_existing_skill_progresses,
migrations.RunPython.noop,
),
]

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

@ -0,0 +1,57 @@
# Generated by Django 5.2.4 on 2026-08-26
from django.db import migrations, models
def create_reliability_and_resilience_skills(apps, schema_editor):
Skill = apps.get_model("employees", "Skill")
definitions = (
(
"attendance_reliability",
"Надёжность",
"attendance_reliability",
),
(
"personnel_resilience",
"Стойкость",
"personnel_resilience",
),
)
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", "0058_secret_skill_progress"),
]
operations = [
migrations.AlterField(
model_name="skill",
name="progression_type",
field=models.CharField(
choices=[
("standard", "Обычный опыт"),
("attendance_punctuality", "Посещаемость: пунктуальность"),
(
"attendance_self_organization",
"Посещаемость: самоорганизация",
),
("attendance_reliability", "Посещаемость: надёжность"),
("personnel_resilience", "Кадровые события: стойкость"),
],
default="standard",
max_length=48,
verbose_name="Механика прогресса",
),
),
migrations.RunPython(
create_reliability_and_resilience_skills,
migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,45 @@
# Generated by Django 5.2.4 on 2026-08-26
from django.db import migrations, models
def create_task_execution_skill(apps, schema_editor):
Skill = apps.get_model("employees", "Skill")
skill, _ = Skill.objects.get_or_create(name="Исполнительность")
skill.is_secret = True
skill.system_code = "task_execution"
skill.progression_type = "task_execution"
skill.save(update_fields=["is_secret", "system_code", "progression_type"])
class Migration(migrations.Migration):
dependencies = [
("employees", "0059_reliability_and_resilience_skills"),
]
operations = [
migrations.AlterField(
model_name="skill",
name="progression_type",
field=models.CharField(
choices=[
("standard", "Обычный опыт"),
("attendance_punctuality", "Посещаемость: пунктуальность"),
(
"attendance_self_organization",
"Посещаемость: самоорганизация",
),
("attendance_reliability", "Посещаемость: надёжность"),
("personnel_resilience", "Кадровые события: стойкость"),
("task_execution", "Задачи: исполнительность"),
],
default="standard",
max_length=48,
verbose_name="Механика прогресса",
),
),
migrations.RunPython(
create_task_execution_skill,
migrations.RunPython.noop,
),
]

View File

@ -34,14 +34,8 @@ class DateRangeMixin(models.Model):
def clean(self):
super().clean()
if hasattr(self, "date_from") and hasattr(self, "date_to"):
if (
self.date_from
and self.date_to
and self.date_from >= self.date_to
):
raise ValidationError(
"Дата начала должна быть раньше даты окончания"
)
if self.date_from and self.date_to and self.date_from >= self.date_to:
raise ValidationError("Дата начала должна быть раньше даты окончания")
class EmployeeQuerySet(models.QuerySet):
@ -66,9 +60,7 @@ class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)):
def create_user(self, email, password=None, **extra_fields):
send_activation_email = extra_fields.pop("send_activation_email", True)
if not email:
raise ValueError(
"Пользователь должен иметь email для подтверждения"
)
raise ValueError("Пользователь должен иметь email для подтверждения")
email = self.normalize_email(email).lower()
phone_number = extra_fields.pop("phone_number", None)
@ -107,11 +99,7 @@ class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)):
)
if "phone" in msg or "phone_number" in msg:
raise ValidationError(
{
"phone_number": (
"Пользователь с таким номером уже существует"
)
}
{"phone_number": ("Пользователь с таким номером уже существует")}
)
raise
@ -131,13 +119,9 @@ class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)):
extra_fields.setdefault("email_verified", True)
if extra_fields.get("is_staff") is not True:
raise ValueError(
"Суперпользователь должен иметь is_staff=True"
)
raise ValueError("Суперпользователь должен иметь is_staff=True")
if extra_fields.get("is_superuser") is not True:
raise ValueError(
"Суперпользователь должен иметь is_superuser=True"
)
raise ValueError("Суперпользователь должен иметь is_superuser=True")
# для суперпользователя не отправляем код, просто создаём
user = self.model(
@ -147,9 +131,7 @@ class EmployeeManager(BaseUserManager.from_queryset(EmployeeQuerySet)):
**extra_fields,
)
if not user.phone_number:
raise ValueError(
"Для суперпользователя также укажите phone_number"
)
raise ValueError("Для суперпользователя также укажите phone_number")
user.set_password(password)
user.email_activation_code = None
user.email_activation_sent_at = None
@ -190,8 +172,7 @@ class Position(models.Model):
blank=True,
default="",
help_text=(
"Напр.: CN=POS_engineer,OU=Position,OU=company,"
"DC=robotail,DC=local"
"Напр.: CN=POS_engineer,OU=Position,OU=company,DC=robotail,DC=local"
),
)
history = HistoricalRecords()
@ -223,12 +204,8 @@ class Employee(AbstractUser):
gender = models.PositiveSmallIntegerField(
"Пол", choices=GENDER_CHOICES, default=0, blank=True
)
avatar = models.ImageField(
"Фото", upload_to="users/avatars", blank=True, null=True
)
phone_number = PhoneNumberField(
"Номер телефона", max_length=100, unique=True
)
avatar = models.ImageField("Фото", upload_to="users/avatars", blank=True, null=True)
phone_number = PhoneNumberField("Номер телефона", max_length=100, unique=True)
patronymic = models.CharField("Отчество", max_length=100, blank=True)
birth_date = models.DateField("Дата рождения", blank=True, null=True)
position = models.ForeignKey(
@ -259,9 +236,7 @@ class Employee(AbstractUser):
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
skills = models.ManyToManyField(
"Skill", related_name="employees", blank=True
)
skills = models.ManyToManyField("Skill", related_name="employees", blank=True)
is_active = models.BooleanField(default=False)
is_hidden_in_ui = models.BooleanField(
"Скрыт в интерфейсе",
@ -275,9 +250,7 @@ class Employee(AbstractUser):
max_length=6, blank=True, null=True
) # Для будущей SMS-активации в bots (fallback к email)
email_verified = models.BooleanField("Email подтверждён", default=False)
email_activation_code = models.CharField(
max_length=6, blank=True, null=True
)
email_activation_code = models.CharField(max_length=6, blank=True, null=True)
email_activation_sent_at = models.DateTimeField(
"Код подтверждения email отправлен",
blank=True,
@ -341,9 +314,7 @@ class Employee(AbstractUser):
employee=self, is_active=True
).values_list("department_id", flat=True)
# Плюс отделы, где сотрудник начальник
head_ids = Department.objects.filter(head=self).values_list(
"id", flat=True
)
head_ids = Department.objects.filter(head=self).values_list("id", flat=True)
return Department.objects.filter(
models.Q(id__in=department_ids) | models.Q(id__in=head_ids)
).distinct()
@ -391,9 +362,7 @@ class EmployeeAction(models.Model):
employee = models.ForeignKey(
Employee, related_name="actions", on_delete=models.CASCADE
)
action = models.CharField(
"Кадровое событие", max_length=50, choices=ACTION_CHOICES
)
action = models.CharField("Кадровое событие", max_length=50, choices=ACTION_CHOICES)
source_request = models.ForeignKey(
"requests_app.Request",
related_name="employee_actions",
@ -425,10 +394,7 @@ class EmployeeAction(models.Model):
]
def __str__(self):
return (
f"{self.employee} — {self.get_action_display()} — "
f"{self.date:%d.%m.%Y}"
)
return f"{self.employee} — {self.get_action_display()} — {self.date:%d.%m.%Y}"
class Department(models.Model):
@ -452,10 +418,7 @@ class Department(models.Model):
max_length=512,
blank=True,
default="",
help_text=(
"Напр.: CN=DEP_it,OU=Department,OU=company,"
"DC=robotail,DC=local"
),
help_text=("Напр.: CN=DEP_it,OU=Department,OU=company,DC=robotail,DC=local"),
)
class Meta:
@ -503,9 +466,7 @@ class Department(models.Model):
if not link.date_from:
updates["date_from"] = timezone.now().date()
if updates:
EmployeeDepartment.objects.filter(pk=link.pk).update(
**updates
)
EmployeeDepartment.objects.filter(pk=link.pk).update(**updates)
except IntegrityError:
# На случай гонки
EmployeeDepartment.objects.filter(
@ -556,18 +517,10 @@ class Department(models.Model):
Все активные сотрудники отдела (через EmployeeDepartment),
включая руководителя, если он существует и активен.
"""
qs = self.employeedepartment.filter(is_active=True).select_related(
"employee"
)
employees = [
ed.employee for ed in qs if ed.employee.is_actually_active
]
qs = self.employeedepartment.filter(is_active=True).select_related("employee")
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
if (
self.head
and self.head.is_actually_active
and self.head not in employees
):
if self.head and self.head.is_actually_active and self.head not in employees:
employees.append(self.head)
return employees
@ -582,9 +535,7 @@ class Department(models.Model):
is_active=True, date_from__gte=month_ago
).select_related("employee")
employees = [
ed.employee for ed in qs if ed.employee.is_actually_active
]
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
# Добавляем руководителя, если он недавно назначен и активен
if (
@ -600,9 +551,7 @@ class Department(models.Model):
class DepartmentPermission(models.Model):
code = models.CharField(
max_length=64, choices=DeptPerm.CHOICES, unique=True
)
code = models.CharField(max_length=64, choices=DeptPerm.CHOICES, unique=True)
name = models.CharField(max_length=150)
def __str__(self):
@ -627,10 +576,7 @@ class DepartmentRole(models.Model):
max_length=512,
blank=True,
default="",
help_text=(
"Напр.: CN=ROLE_it__oncall,OU=Role,OU=company,"
"DC=robotail,DC=local"
),
help_text=("Напр.: CN=ROLE_it__oncall,OU=Role,OU=company,DC=robotail,DC=local"),
)
class Meta:
@ -746,7 +692,44 @@ class RoleAssignment(models.Model):
class Skill(models.Model):
class ProgressionType(models.TextChoices):
STANDARD = "standard", "Обычный опыт"
ATTENDANCE_PUNCTUALITY = (
"attendance_punctuality",
"Посещаемость: пунктуальность",
)
ATTENDANCE_SELF_ORGANIZATION = (
"attendance_self_organization",
"Посещаемость: самоорганизация",
)
ATTENDANCE_RELIABILITY = (
"attendance_reliability",
"Посещаемость: надёжность",
)
PERSONNEL_RESILIENCE = (
"personnel_resilience",
"Кадровые события: стойкость",
)
TASK_EXECUTION = (
"task_execution",
"Задачи: исполнительность",
)
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 = "Навык"
@ -756,6 +739,142 @@ class Skill(models.Model):
return self.name
class EmployeeSkillProgress(models.Model):
"""Прогресс конкретного сотрудника по навыку.
Связь ``Employee.skills`` остаётся обратно совместимой, а эта модель
хранит игровое состояние и может быть восстановлена по журналу опыта.
"""
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name="skill_progresses",
verbose_name="Сотрудник",
)
skill = models.ForeignKey(
Skill,
on_delete=models.CASCADE,
related_name="employee_progresses",
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,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="created_skill_progresses",
verbose_name="Добавил навык",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Прогресс навыка сотрудника"
verbose_name_plural = "Прогресс навыков сотрудников"
constraints = [
models.UniqueConstraint(
fields=["employee", "skill"],
name="uniq_employee_skill_progress",
),
]
indexes = [
models.Index(fields=["skill", "is_active", "experience"]),
models.Index(fields=["employee", "is_active"]),
]
def __str__(self):
return f"{self.employee} — {self.skill}: {self.experience} XP"
class SkillExperienceEvent(models.Model):
class Source(models.TextChoices):
ENDORSEMENT = "endorsement", "Подтверждение сотрудником"
TASK = "task", "Завершённая задача"
TRAINING = "training", "Обучение"
CERTIFICATION = "certification", "Аттестация"
MANUAL = "manual", "Ручное начисление"
progress = models.ForeignKey(
EmployeeSkillProgress,
on_delete=models.CASCADE,
related_name="experience_events",
verbose_name="Прогресс навыка",
)
points = models.PositiveIntegerField("Баллы опыта")
source_type = models.CharField(
"Источник",
max_length=24,
choices=Source.choices,
)
source_id = models.CharField("ID источника", max_length=100, blank=True)
actor = models.ForeignKey(
Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="skill_experience_events_given",
verbose_name="Автор начисления",
)
reason = models.TextField("Причина", blank=True)
metadata = models.JSONField("Дополнительные данные", default=dict, blank=True)
idempotency_key = models.CharField(
"Ключ идемпотентности",
max_length=180,
blank=True,
default="",
)
revoked_at = models.DateTimeField("Отозвано", null=True, blank=True)
revoked_by = models.ForeignKey(
Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="skill_experience_events_revoked",
verbose_name="Кто отозвал",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Начисление опыта навыка"
verbose_name_plural = "Начисления опыта навыков"
ordering = ["-created_at", "-id"]
constraints = [
models.UniqueConstraint(
fields=["idempotency_key"],
condition=~models.Q(idempotency_key=""),
name="uniq_skill_xp_idempotency_key",
),
]
indexes = [
models.Index(fields=["progress", "source_type", "revoked_at"]),
models.Index(fields=["actor", "source_type", "created_at"]),
]
def __str__(self):
return f"{self.progress}: +{self.points} ({self.get_source_type_display()})"
# --- LDAP sync state -------------------------------------------------
@ -788,9 +907,7 @@ class LdapSyncState(models.Model):
object_pk = models.CharField(max_length=64, db_index=True)
ldap_dn = models.TextField(blank=True, default="")
ldap_guid = models.CharField(
max_length=64, null=True, blank=True, db_index=True
)
ldap_guid = models.CharField(max_length=64, null=True, blank=True, db_index=True)
last_ldap_modify_ts = models.DateTimeField(null=True, blank=True)
last_django_modify_ts = models.DateTimeField(null=True, blank=True)
@ -830,9 +947,7 @@ class LdapSyncState(models.Model):
ValueError: Если sync_dir не из допустимого набора.
"""
if sync_dir and sync_dir not in ("ldap", "django", "auto", "manual"):
raise ValueError(
"sync_dir должен быть 'ldap'|'django'|'auto'|'manual'"
)
raise ValueError("sync_dir должен быть 'ldap'|'django'|'auto'|'manual'")
if ldap_dn is not None:
self.ldap_dn = ldap_dn
if ldap_guid is not None:
@ -877,10 +992,7 @@ class LdapSyncQueue(models.Model):
operation = models.CharField(
max_length=64,
db_index=True,
help_text=(
"Имя операции: employee_save, department_delete, "
"group_m2m и т.д."
),
help_text=("Имя операции: employee_save, department_delete, group_m2m и т.д."),
)
# Модель и объект
@ -923,10 +1035,7 @@ class LdapSyncQueue(models.Model):
]
def __str__(self):
return (
f"[{self.status}] {self.operation} "
f"{self.model_name}:{self.object_pk}"
)
return f"[{self.status}] {self.operation} {self.model_name}:{self.object_pk}"
def schedule_retry(self, error: str):
"""Планирует следующую попытку с экспоненциальным backoff."""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,357 @@
"""Ранги, редкость и журнал опыта навыков сотрудников."""
from __future__ import annotations
from datetime import timedelta
from django.db import transaction
from django.db.models import Count, Q, Sum
from django.utils import timezone
from employees.models import (
Department,
Employee,
EmployeeDepartment,
EmployeeSkillProgress,
RoleAssignment,
Skill,
SkillExperienceEvent,
)
SKILL_RANKS = (
{"code": "none", "label": "Без ранга", "threshold": 0},
{"code": "bronze", "label": "Бронза", "threshold": 100},
{"code": "silver", "label": "Серебро", "threshold": 300},
{"code": "gold", "label": "Золото", "threshold": 800},
{"code": "ruby", "label": "Рубин", "threshold": 1800},
{"code": "diamond", "label": "Бриллиант", "threshold": 4000},
)
ENDORSEMENT_COOLDOWN_DAYS = 90
RARITY_CONFIRMATION_EXPERIENCE = 50
TASK_ASSIGNEE_SKILL_EXPERIENCE = 30
TASK_PARTICIPANT_SKILL_EXPERIENCE = 15
def skill_queryset_with_rarity(queryset=None):
queryset = queryset if queryset is not None else Skill.objects.all()
return queryset.annotate(
confirmed_holders=Count(
"employee_progresses",
filter=(
Q(is_secret=True)
| Q(employee_progresses__experience__gte=RARITY_CONFIRMATION_EXPERIENCE)
)
& Q(
employee_progresses__is_active=True,
employee_progresses__employee__is_active=True,
employee_progresses__employee__is_hidden_in_ui=False,
),
distinct=True,
)
)
def rank_payload(experience: int) -> dict:
experience = max(int(experience or 0), 0)
current = SKILL_RANKS[0]
next_rank = None
for index, rank in enumerate(SKILL_RANKS):
if experience >= rank["threshold"]:
current = rank
next_rank = SKILL_RANKS[index + 1] if index + 1 < len(SKILL_RANKS) else None
else:
break
current_threshold = int(current["threshold"])
next_threshold = int(next_rank["threshold"]) if next_rank else None
if next_threshold is None:
percent = 100
remaining = 0
else:
span = max(next_threshold - current_threshold, 1)
percent = round(min(max((experience - current_threshold) / span, 0), 1) * 100)
remaining = max(next_threshold - experience, 0)
return {
"rank": current["code"],
"rank_display": current["label"],
"rank_threshold": current_threshold,
"next_rank": next_rank["code"] if next_rank else None,
"next_rank_display": next_rank["label"] if next_rank else None,
"next_rank_experience": next_threshold,
"experience_to_next_rank": remaining,
"rank_progress_percent": percent,
}
def rarity_payload(confirmed_holders: int) -> dict:
count = max(int(confirmed_holders or 0), 0)
if count == 0:
code, label = "unrated", "Не определена"
elif count == 1:
code, label = "legendary", "Легендарный"
elif count <= 3:
code, label = "epic", "Эпический"
elif count <= 9:
code, label = "rare", "Редкий"
elif count <= 24:
code, label = "uncommon", "Необычный"
else:
code, label = "common", "Обычный"
return {
"confirmed_holders": count,
"rarity": code,
"rarity_display": label,
}
def confirmed_holder_count(skill: Skill) -> int:
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(
employee: Employee,
skill: Skill,
*,
created_by: Employee | None = None,
) -> EmployeeSkillProgress:
progress, created = EmployeeSkillProgress.objects.get_or_create(
employee=employee,
skill=skill,
defaults={"created_by": created_by, "is_active": True},
)
update_fields = []
if not progress.is_active:
progress.is_active = True
update_fields.append("is_active")
if created_by and progress.created_by_id is None:
progress.created_by = created_by
update_fields.append("created_by")
if update_fields:
update_fields.append("updated_at")
progress.save(update_fields=update_fields)
if created:
employee.skills.add(skill)
return progress
@transaction.atomic
def record_skill_experience(
*,
employee: Employee,
skill: Skill,
points: int,
source_type: str,
actor: Employee | None,
reason: str,
source_id: str = "",
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("Баллы опыта должны быть положительным числом")
progress = ensure_skill_progress(employee, skill, created_by=actor)
progress = EmployeeSkillProgress.objects.select_for_update().get(pk=progress.pk)
existing = None
if idempotency_key:
existing = SkillExperienceEvent.objects.filter(
idempotency_key=idempotency_key
).first()
if existing:
if existing.revoked_at is not None:
existing.revoked_at = None
existing.revoked_by = None
existing.save(update_fields=["revoked_at", "revoked_by"])
progress.experience += existing.points
progress.save(update_fields=["experience", "updated_at"])
return progress, existing, False
event = SkillExperienceEvent.objects.create(
progress=progress,
points=points,
source_type=source_type,
source_id=str(source_id or ""),
actor=actor,
reason=reason,
metadata=metadata or {},
idempotency_key=idempotency_key,
)
progress.experience += points
progress.save(update_fields=["experience", "updated_at"])
return progress, event, True
@transaction.atomic
def revoke_skill_experience_event(
event: SkillExperienceEvent,
*,
actor: Employee | None = None,
) -> EmployeeSkillProgress:
event = SkillExperienceEvent.objects.select_for_update().get(pk=event.pk)
progress = EmployeeSkillProgress.objects.select_for_update().get(
pk=event.progress_id
)
if event.revoked_at is not None:
return progress
event.revoked_at = timezone.now()
event.revoked_by = actor
event.save(update_fields=["revoked_at", "revoked_by"])
progress.experience = max(progress.experience - event.points, 0)
progress.save(update_fields=["experience", "updated_at"])
return progress
def recalculate_skill_progress(progress: EmployeeSkillProgress) -> int:
total = (
progress.experience_events.filter(revoked_at__isnull=True).aggregate(
total=Sum("points")
)["total"]
or 0
)
if progress.experience != total:
progress.experience = total
progress.save(update_fields=["experience", "updated_at"])
return total
def endorsement_weight(actor: Employee, target: Employee) -> tuple[int, str]:
target_department_ids = EmployeeDepartment.objects.filter(
employee=target,
is_active=True,
).values_list("department_id", flat=True)
if Department.objects.filter(
id__in=target_department_ids,
head=actor,
).exists():
return 15, "Руководитель отдела"
if RoleAssignment.objects.filter(
employee=actor,
role__department_id__in=target_department_ids,
is_active=True,
).exists():
return 10, "Роль в отделе"
return 5, "Коллега"
def endorsement_available_at(
*,
progress: EmployeeSkillProgress,
actor: Employee,
):
latest = (
progress.experience_events.filter(
source_type=SkillExperienceEvent.Source.ENDORSEMENT,
actor=actor,
revoked_at__isnull=True,
)
.order_by("-created_at")
.first()
)
if latest is None:
return None
available_at = latest.created_at + timedelta(days=ENDORSEMENT_COOLDOWN_DAYS)
return available_at if available_at > timezone.now() else None
def award_task_skill_experience(task_id: int) -> int:
"""Начисляет опыт по снимку ролей на момент завершения задачи.
Баллы получают только сотрудники, у которых навык уже добавлен в профиль.
Исполнитель получает больше участника. Создатель задачи, автоматически
добавленный в participants, не получает опыт, если не является исполнителем.
Время завершения в ключе сохраняет отдельную историю каждого повторного
завершения, а повторный вызов для одного снимка остаётся идемпотентным.
"""
from tasks.models import Task
task = (
Task.objects.select_related("assignee")
.prefetch_related("participants", "skills")
.filter(pk=task_id, completed_at__isnull=False)
.first()
)
if task is None:
return 0
reward_roles = {
employee_id: "participant"
for employee_id in task.participants.values_list("id", flat=True)
if employee_id not in {task.created_by_id, task.assignee_id}
}
if task.assignee_id:
reward_roles[task.assignee_id] = "assignee"
if not reward_roles:
return 0
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,
skills=skill,
).distinct()
for employee in recipients:
reward_role = reward_roles[employee.id]
is_assignee = reward_role == "assignee"
points = (
TASK_ASSIGNEE_SKILL_EXPERIENCE
if is_assignee
else TASK_PARTICIPANT_SKILL_EXPERIENCE
)
role_display = "Исполнитель" if is_assignee else "Участник"
_, _, created = record_skill_experience(
employee=employee,
skill=skill,
points=points,
source_type=SkillExperienceEvent.Source.TASK,
actor=task.created_by,
source_id=str(task.id),
reason=f"Завершена задача «{task.title}» — {role_display.lower()}",
idempotency_key=(
f"task:{task.id}:completed:{completion_key}:"
f"employee:{employee.id}:skill:{skill.id}"
),
metadata={
"task_id": task.id,
"task_title": task.title,
"completed_at": completion_key,
"reward_role": reward_role,
"reward_role_display": role_display,
"points": points,
},
)
awarded += int(created)
return awarded
def revoke_task_skill_experience(task_id: int, *, actor: Employee | None = None) -> int:
events = SkillExperienceEvent.objects.filter(
source_type=SkillExperienceEvent.Source.TASK,
source_id=str(task_id),
revoked_at__isnull=True,
)
revoked = 0
for event in events:
revoke_skill_experience_event(event, actor=actor)
revoked += 1
return revoked

View File

@ -0,0 +1,402 @@
"""System-owned progress for the secret skill ``Исполнительность``."""
from __future__ import annotations
from datetime import date, timedelta
from django.db import transaction
from django.db.models import Exists, F, OuterRef
from django.utils import timezone
from employees.models import Employee, EmployeeSkillProgress, Skill
from employees.services.secret_skills import (
RANK_CODES,
RANK_INDEX,
TASK_SECRET_SKILL_DEFINITIONS,
)
from tasks.models import Task, TaskExecutionCycle
PROTECTION_DAYS = 7
OVERDUE_GRACE_BUSINESS_DAYS = 3
VIOLATION_WINDOW_DAYS = 60
def _local_date(value) -> date:
if timezone.is_aware(value):
return timezone.localtime(value).date()
return value.date()
def _add_business_days(value: date, amount: int) -> date:
current = value
added = 0
while added < amount:
current += timedelta(days=1)
if current.weekday() < 5:
added += 1
return current
def _cycle_key(task_id: int, completed_at) -> str:
return f"task:{task_id}:completed:{completed_at.isoformat()}"
@transaction.atomic
def capture_task_execution_cycle(task_id: int) -> TaskExecutionCycle | None:
"""Create the immutable completion snapshot if the task is currently done."""
task = (
Task.objects.select_related("assignee")
.filter(pk=task_id, completed_at__isnull=False)
.first()
)
if task is None:
return None
completed_at = task.completed_at
protection_until = completed_at + timedelta(days=PROTECTION_DAYS)
is_self_assigned = bool(task.assignee_id and task.assignee_id == task.created_by_id)
exclusion_reason = ""
if task.assignee_id is None:
exclusion_reason = "missing_assignee"
elif task.due_date is None:
exclusion_reason = "missing_due_date"
elif is_self_assigned:
exclusion_reason = "self_assigned"
elif not task.assignee.is_active:
exclusion_reason = "inactive_assignee"
if exclusion_reason:
status = TaskExecutionCycle.Status.EXCLUDED
elif _local_date(completed_at) > task.due_date:
status = TaskExecutionCycle.Status.LATE
elif protection_until <= timezone.now():
status = TaskExecutionCycle.Status.CONFIRMED
else:
status = TaskExecutionCycle.Status.PENDING
cycle, _ = TaskExecutionCycle.objects.get_or_create(
cycle_key=_cycle_key(task.id, completed_at),
defaults={
"task": task,
"task_id_snapshot": task.id,
"employee_id": task.assignee_id,
"task_title": task.title,
"due_date_snapshot": task.due_date,
"completed_at_snapshot": completed_at,
"protection_until": protection_until,
"status": status,
"is_self_assigned": is_self_assigned,
"exclusion_reason": exclusion_reason,
},
)
if cycle.employee_id:
recalculate_task_execution_skill(cycle.employee_id)
return cycle
@transaction.atomic
def reopen_task_execution_cycle(
task_id: int,
completed_at,
) -> TaskExecutionCycle | None:
"""Turn the matching positive/pending completion into one violation."""
if completed_at is None:
return None
cycle = (
TaskExecutionCycle.objects.select_for_update()
.filter(cycle_key=_cycle_key(task_id, completed_at))
.first()
)
if cycle is None or cycle.status == TaskExecutionCycle.Status.EXCLUDED:
return cycle
cycle.status = TaskExecutionCycle.Status.REOPENED
cycle.reopened_at = timezone.now()
cycle.save(update_fields=["status", "reopened_at", "updated_at"])
if cycle.employee_id:
recalculate_task_execution_skill(cycle.employee_id)
return cycle
def _open_overdue_events(employee_id: int, today: date):
tasks = (
Task.objects.filter(
assignee_id=employee_id,
completed_at__isnull=True,
due_date__isnull=False,
)
.exclude(created_by_id=F("assignee_id"))
.prefetch_related("execution_cycles")
)
events = []
for task in tasks:
violation_date = _add_business_days(
task.due_date,
OVERDUE_GRACE_BUSINESS_DAYS,
)
if violation_date > today:
continue
latest_cycle = max(
task.execution_cycles.all(),
key=lambda item: (item.completed_at_snapshot, item.id),
default=None,
)
if (
latest_cycle
and latest_cycle.due_date_snapshot == task.due_date
and latest_cycle.status
in {
TaskExecutionCycle.Status.LATE,
TaskExecutionCycle.Status.REOPENED,
}
):
continue
events.append((violation_date, "overdue", task.id))
return events
def calculate_task_execution_metrics(employee_id: int, *, today: date | None = None):
today = today or timezone.localdate()
cycles = list(
TaskExecutionCycle.objects.filter(employee_id=employee_id)
.exclude(
status__in=[
TaskExecutionCycle.Status.PENDING,
TaskExecutionCycle.Status.EXCLUDED,
]
)
.order_by("completed_at_snapshot", "id")
)
events = []
late_tasks = 0
reopened_tasks = 0
for cycle in cycles:
if cycle.status == TaskExecutionCycle.Status.CONFIRMED:
events.append(
(_local_date(cycle.completed_at_snapshot), "confirmed", cycle.id)
)
elif cycle.status == TaskExecutionCycle.Status.LATE:
late_tasks += 1
events.append((_local_date(cycle.completed_at_snapshot), "late", cycle.id))
elif cycle.status == TaskExecutionCycle.Status.REOPENED:
reopened_tasks += 1
violation_at = cycle.reopened_at or cycle.completed_at_snapshot
events.append((_local_date(violation_at), "reopened", cycle.id))
overdue_events = _open_overdue_events(employee_id, today)
events.extend(overdue_events)
events.sort(key=lambda item: (item[0], str(item[2])))
confirmed_tasks = 0
current_on_time_streak = 0
best_on_time_streak = 0
for _, event_type, _ in events:
if event_type == "confirmed":
confirmed_tasks += 1
current_on_time_streak += 1
best_on_time_streak = max(
best_on_time_streak,
current_on_time_streak,
)
else:
current_on_time_streak = 0
attempts = len(events)
on_time_percent = round(confirmed_tasks / attempts * 100) if attempts else 0
violation_cutoff = today - timedelta(days=VIOLATION_WINDOW_DAYS)
violations_last_60 = sum(
event_type != "confirmed" and event_date >= violation_cutoff
for event_date, event_type, _ in events
)
base_penalty_levels = violations_last_60 // 3
recovered_levels = current_on_time_streak // 10
penalty_levels = max(base_penalty_levels - recovered_levels, 0)
definition = TASK_SECRET_SKILL_DEFINITIONS[0]
rank_metrics = {
"confirmed_tasks": confirmed_tasks,
"on_time_percent": on_time_percent,
"best_on_time_streak": best_on_time_streak,
}
earned_rank = "none"
for rank_code in RANK_CODES[1:]:
requirements = definition.requirements[rank_code]
if all(rank_metrics[key] >= target for key, target in requirements.items()):
earned_rank = rank_code
else:
break
effective_index = max(RANK_INDEX[earned_rank] - penalty_levels, 0)
effective_rank = RANK_CODES[effective_index]
return {
**rank_metrics,
"attempted_tasks": attempts,
"current_on_time_streak": current_on_time_streak,
"late_tasks": late_tasks,
"reopened_tasks": reopened_tasks,
"open_overdue_tasks": len(overdue_events),
"pending_tasks": TaskExecutionCycle.objects.filter(
employee_id=employee_id,
status=TaskExecutionCycle.Status.PENDING,
).count(),
"violations_last_60": violations_last_60,
"anti_streak": violations_last_60 % 3,
"anti_streak_limit": 3,
"recovery_streak": current_on_time_streak % 10 if penalty_levels else 0,
"recovery_streak_target": 10,
"penalty_levels": penalty_levels,
"earned_rank": earned_rank,
"effective_rank": effective_rank,
"last_evaluated_date": today.isoformat(),
}
@transaction.atomic
def recalculate_task_execution_skill(employee_id: int) -> list[str]:
employee = Employee.objects.select_for_update().filter(pk=employee_id).first()
if employee is None:
return []
skill = Skill.objects.filter(
system_code="task_execution",
is_secret=True,
).first()
if skill is None:
return []
metrics = calculate_task_execution_metrics(employee_id)
calculated_rank = metrics["effective_rank"]
progress = EmployeeSkillProgress.objects.filter(
employee=employee,
skill=skill,
).first()
previous_rank = progress.special_rank if progress else "none"
previous_active = progress.is_active if progress else False
if progress is None and calculated_rank == "none":
return []
progress, _ = EmployeeSkillProgress.objects.get_or_create(
employee=employee,
skill=skill,
defaults={"is_active": calculated_rank != "none"},
)
now = timezone.now()
rank_history = dict(progress.progress_data.get("rank_achieved_at") or {})
rank_losses = list(progress.progress_data.get("rank_losses") or [])
if RANK_INDEX[calculated_rank] > RANK_INDEX[previous_rank]:
rank_history[calculated_rank] = now.isoformat()
elif RANK_INDEX[calculated_rank] < RANK_INDEX[previous_rank]:
rank_losses.append(
{"from": previous_rank, "to": calculated_rank, "at": now.isoformat()}
)
progress.special_rank = calculated_rank
progress.experience = metrics["confirmed_tasks"]
progress.is_active = calculated_rank != "none"
progress.created_by = None
progress.progress_data = {
**metrics,
"rank_achieved_at": rank_history,
"rank_losses": rank_losses[-20:],
"recalculated_at": now.isoformat(),
}
progress.save(
update_fields=[
"special_rank",
"experience",
"is_active",
"created_by",
"progress_data",
"updated_at",
]
)
if calculated_rank == "none":
employee.skills.remove(skill)
else:
employee.skills.add(skill)
changed = calculated_rank != previous_rank or progress.is_active != previous_active
return [skill.name] if changed else []
def refresh_task_execution_skills( # noqa: C901
employee_id: int | None = None,
) -> dict[str, int]:
"""Reconcile missed transitions, mature pending cycles and recalculate users."""
now = timezone.now()
existing_cycle = TaskExecutionCycle.objects.filter(
task_id=OuterRef("pk"),
completed_at_snapshot=OuterRef("completed_at"),
)
completed_tasks = Task.objects.filter(completed_at__isnull=False)
if employee_id is not None:
completed_tasks = completed_tasks.filter(assignee_id=employee_id)
missing_task_ids = list(
completed_tasks.annotate(has_cycle=Exists(existing_cycle))
.filter(has_cycle=False)
.values_list("id", flat=True)
)
for task_id in missing_task_ids:
capture_task_execution_cycle(task_id)
changed_cycles = 0
employee_ids: set[int] = set()
cycles = TaskExecutionCycle.objects.filter(
status__in=[
TaskExecutionCycle.Status.PENDING,
TaskExecutionCycle.Status.CONFIRMED,
]
).select_related("task")
if employee_id is not None:
cycles = cycles.filter(employee_id=employee_id)
for cycle in cycles:
task = cycle.task
if task is None:
if cycle.status == TaskExecutionCycle.Status.PENDING:
cycle.status = TaskExecutionCycle.Status.EXCLUDED
cycle.exclusion_reason = "task_deleted_during_protection"
else:
continue
elif task.completed_at != cycle.completed_at_snapshot:
cycle.status = TaskExecutionCycle.Status.REOPENED
cycle.reopened_at = now
elif (
cycle.status == TaskExecutionCycle.Status.PENDING
and cycle.protection_until <= now
):
cycle.status = TaskExecutionCycle.Status.CONFIRMED
else:
continue
cycle.save(
update_fields=[
"status",
"exclusion_reason",
"reopened_at",
"updated_at",
]
)
changed_cycles += 1
if cycle.employee_id:
employee_ids.add(cycle.employee_id)
cycle_employees = TaskExecutionCycle.objects.exclude(employee_id=None)
open_tasks = Task.objects.filter(
completed_at__isnull=True,
assignee__isnull=False,
due_date__isnull=False,
)
if employee_id is not None:
cycle_employees = cycle_employees.filter(employee_id=employee_id)
open_tasks = open_tasks.filter(assignee_id=employee_id)
employee_ids.update(cycle_employees.values_list("employee_id", flat=True))
employee_ids.update(open_tasks.values_list("assignee_id", flat=True))
rank_changes = 0
for employee_id in employee_ids:
rank_changes += len(recalculate_task_execution_skill(employee_id))
return {
"captured_cycles": len(missing_task_ids),
"changed_cycles": changed_cycles,
"processed_employees": len(employee_ids),
"rank_changes": rank_changes,
}

View File

@ -13,7 +13,7 @@ from importlib import import_module
from django.conf import settings
from . import common, birthday
from . import birthday, common, secret_skills, skills
from .common import (
IMMEDIATE_ACTION_MAPPING,
SCHEDULED_ACTION_MAPPING,
@ -30,6 +30,8 @@ if getattr(settings, "LDAP_ENABLED", False):
__all__ = [
"common",
"birthday",
"skills",
"secret_skills",
"IMMEDIATE_ACTION_MAPPING",
"SCHEDULED_ACTION_MAPPING",
"_apply_action_effects",
@ -39,6 +41,8 @@ else:
__all__ = [
"common",
"birthday",
"skills",
"secret_skills",
"IMMEDIATE_ACTION_MAPPING",
"SCHEDULED_ACTION_MAPPING",
"_apply_action_effects",

View File

@ -0,0 +1,62 @@
"""Пересчёт системных навыков после изменения кадровых событий."""
import logging
from django.db import transaction
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from employees.models import EmployeeAction
logger = logging.getLogger(__name__)
def _schedule_recalculation(employee_ids) -> None:
normalized_ids = {employee_id for employee_id in employee_ids if employee_id}
if not normalized_ids:
return
def recalculate():
from employees.services.secret_skills import (
recalculate_attendance_secret_skills,
)
for employee_id in normalized_ids:
try:
recalculate_attendance_secret_skills(employee_id)
except Exception:
logger.exception(
"Secret skill recalculation after personnel action failed",
extra={
"event": "employee.secret_skills.recalculation_failed",
"employee_id": employee_id,
},
)
transaction.on_commit(recalculate)
@receiver(pre_save, sender=EmployeeAction)
def remember_previous_action_employee(sender, instance, **kwargs):
if not instance.pk:
return
instance._secret_skills_previous_employee_id = (
sender.objects.filter(pk=instance.pk)
.values_list("employee_id", flat=True)
.first()
)
@receiver(post_save, sender=EmployeeAction)
def recalculate_secret_skills_after_action_save(sender, instance, **kwargs):
_schedule_recalculation(
{
instance.employee_id,
getattr(instance, "_secret_skills_previous_employee_id", None),
}
)
@receiver(post_delete, sender=EmployeeAction)
def recalculate_secret_skills_after_action_delete(sender, instance, **kwargs):
_schedule_recalculation({instance.employee_id})

View File

@ -0,0 +1,27 @@
"""Синхронизация обычной M2M-связи навыков с игровым прогрессом."""
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from employees.models import Employee, EmployeeSkillProgress
@receiver(m2m_changed, sender=Employee.skills.through)
def sync_employee_skill_progress(sender, instance, action, pk_set, **kwargs):
if action == "post_add":
for skill_id in pk_set or ():
progress, _ = EmployeeSkillProgress.objects.get_or_create(
employee=instance,
skill_id=skill_id,
defaults={"is_active": True},
)
if not progress.is_active:
progress.is_active = True
progress.save(update_fields=["is_active", "updated_at"])
elif action == "post_remove":
EmployeeSkillProgress.objects.filter(
employee=instance,
skill_id__in=pk_set or (),
).update(is_active=False)
elif action == "pre_clear":
EmployeeSkillProgress.objects.filter(employee=instance).update(is_active=False)

View File

@ -827,6 +827,10 @@ CELERY_BEAT_SCHEDULE = {
"task": "tasks.dispatch_scheduled_automations",
"schedule": 60.0,
},
"tasks-refresh-execution-skills": {
"task": "tasks.refresh_task_execution_skills",
"schedule": crontab(hour=0, minute=20),
},
"process-ldap-sync-queue": {
"task": "employees.tasks.process_ldap_queue",
"schedule": 60.0, # Каждую минуту проверяем очередь LDAP retry

View File

@ -15,6 +15,7 @@ from .models import (
TaskColumnRow,
TaskCover,
TaskExternalLink,
TaskExecutionCycle,
TaskLabel,
TaskLinkedObject,
TaskUserSettings,
@ -223,6 +224,27 @@ class TaskActivityAdmin(admin.ModelAdmin):
search_fields = ["task__title", "actor__email", "object_id"]
@admin.register(TaskExecutionCycle)
class TaskExecutionCycleAdmin(admin.ModelAdmin):
list_display = [
"task_title",
"employee",
"due_date_snapshot",
"completed_at_snapshot",
"status",
"protection_until",
]
list_filter = ["status", "is_self_assigned", "completed_at_snapshot"]
search_fields = ["task_title", "employee__email", "cycle_key"]
readonly_fields = [
"task_id_snapshot",
"cycle_key",
"completed_at_snapshot",
"created_at",
"updated_at",
]
@admin.register(TaskUserSettings)
class TaskUserSettingsAdmin(admin.ModelAdmin):
list_display = ["user", "default_board", "default_group", "updated_at"]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2026-08-25 10:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("employees", "0057_employeeskillprogress_skillexperienceevent_and_more"),
("tasks", "0039_taskboardgroupmembership_rank_key"),
]
operations = [
migrations.AddField(
model_name="task",
name="skills",
field=models.ManyToManyField(
blank=True,
related_name="tasks",
to="employees.skill",
verbose_name="Применяемые навыки",
),
),
]

View File

@ -0,0 +1,139 @@
# Generated by Django 5.2.4 on 2026-08-26 07:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tasks", "0040_task_skills"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="TaskExecutionCycle",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"task_id_snapshot",
models.PositiveBigIntegerField(verbose_name="ID задачи"),
),
(
"cycle_key",
models.CharField(
max_length=180,
unique=True,
verbose_name="Ключ цикла",
),
),
(
"task_title",
models.CharField(max_length=255, verbose_name="Название задачи"),
),
(
"due_date_snapshot",
models.DateField(
null=True, verbose_name="Срок на момент завершения"
),
),
(
"completed_at_snapshot",
models.DateTimeField(verbose_name="Время завершения"),
),
(
"protection_until",
models.DateTimeField(verbose_name="Подтвердить после"),
),
(
"status",
models.CharField(
choices=[
("pending", "Ожидает подтверждения"),
("confirmed", "Подтверждено"),
("late", "Завершено после срока"),
("reopened", "Возвращено в работу"),
("excluded", "Не участвует в расчёте"),
],
default="pending",
max_length=16,
verbose_name="Состояние",
),
),
(
"is_self_assigned",
models.BooleanField(default=False, verbose_name="Самоназначение"),
),
(
"exclusion_reason",
models.CharField(
blank=True,
max_length=80,
verbose_name="Причина исключения",
),
),
(
"reopened_at",
models.DateTimeField(
blank=True,
null=True,
verbose_name="Возвращено в работу",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
),
(
"employee",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="task_execution_cycles",
to=settings.AUTH_USER_MODEL,
verbose_name="Исполнитель на момент завершения",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="execution_cycles",
to="tasks.task",
verbose_name="Задача",
),
),
],
options={
"verbose_name": "Цикл исполнения задачи",
"verbose_name_plural": "Циклы исполнения задач",
"ordering": ["completed_at_snapshot", "id"],
"indexes": [
models.Index(
fields=["employee", "status", "completed_at_snapshot"],
name="tasks_taske_employe_e1d1f9_idx",
),
models.Index(
fields=["task", "status"],
name="tasks_taske_task_id_d09903_idx",
),
],
},
),
]

View File

@ -407,6 +407,12 @@ class Task(models.Model):
related_name="tasks",
verbose_name="Метки",
)
skills = models.ManyToManyField(
"employees.Skill",
blank=True,
related_name="tasks",
verbose_name="Применяемые навыки",
)
priority = models.CharField(
"Приоритет",
max_length=20,
@ -443,6 +449,69 @@ class Task(models.Model):
super().save(*args, **kwargs)
class TaskExecutionCycle(models.Model):
"""Immutable role/deadline snapshot for one task completion cycle.
The task may be reopened, reassigned or deleted later. Secret skill
progress must still belong to the employee who was the assignee when the
task entered the final column, so the mutable task itself is not a safe
source of historical truth.
"""
class Status(models.TextChoices):
PENDING = "pending", "Ожидает подтверждения"
CONFIRMED = "confirmed", "Подтверждено"
LATE = "late", "Завершено после срока"
REOPENED = "reopened", "Возвращено в работу"
EXCLUDED = "excluded", "Не участвует в расчёте"
task = models.ForeignKey(
Task,
on_delete=models.SET_NULL,
related_name="execution_cycles",
null=True,
blank=True,
verbose_name="Задача",
)
task_id_snapshot = models.PositiveBigIntegerField("ID задачи")
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="task_execution_cycles",
null=True,
blank=True,
verbose_name="Исполнитель на момент завершения",
)
cycle_key = models.CharField("Ключ цикла", max_length=180, unique=True)
task_title = models.CharField("Название задачи", max_length=255)
due_date_snapshot = models.DateField("Срок на момент завершения", null=True)
completed_at_snapshot = models.DateTimeField("Время завершения")
protection_until = models.DateTimeField("Подтвердить после")
status = models.CharField(
"Состояние",
max_length=16,
choices=Status.choices,
default=Status.PENDING,
)
is_self_assigned = models.BooleanField("Самоназначение", default=False)
exclusion_reason = models.CharField("Причина исключения", max_length=80, blank=True)
reopened_at = models.DateTimeField("Возвращено в работу", null=True, blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Цикл исполнения задачи"
verbose_name_plural = "Циклы исполнения задач"
ordering = ["completed_at_snapshot", "id"]
indexes = [
models.Index(fields=["employee", "status", "completed_at_snapshot"]),
models.Index(fields=["task", "status"]),
]
def __str__(self):
return f"{self.task_title}: {self.get_status_display()}"
class TaskAutomation(models.Model):
name = models.CharField("Название", max_length=160)
description = models.TextField("Описание", blank=True)

View File

@ -5,6 +5,7 @@ from django.db.models import Count, IntegerField, OuterRef, Prefetch, Q, Subquer
from django.db.models.functions import Coalesce
from communications.models import Message
from employees.services.skill_progress import skill_queryset_with_rarity
from tasks.access import task_board_access_q
from tasks.models import (
Task,
@ -48,6 +49,7 @@ def task_api_queryset(*, user, params):
)
.prefetch_related(
"labels",
Prefetch("skills", queryset=skill_queryset_with_rarity()),
"participants",
"checklist_items",
Prefetch(

View File

@ -74,6 +74,59 @@ def ensure_task_core_participants(sender, instance, **kwargs):
instance.participants.add(*participant_ids)
@receiver(pre_save, sender=Task)
def remember_task_completion_state(sender, instance, **kwargs):
if not instance.pk:
instance._skill_was_completed = False
instance._skill_previous_completed_at = None
return
previous_completed_at = (
sender.objects.filter(pk=instance.pk)
.values_list(
"completed_at",
flat=True,
)
.first()
)
instance._skill_previous_completed_at = previous_completed_at
instance._skill_was_completed = previous_completed_at is not None
@receiver(post_save, sender=Task)
def sync_task_skill_experience(sender, instance, **kwargs):
was_completed = bool(getattr(instance, "_skill_was_completed", False))
is_completed = instance.completed_at is not None
if was_completed == is_completed:
return
task_id = instance.id
actor = getattr(instance, "_skill_completion_actor", None)
previous_completed_at = getattr(
instance,
"_skill_previous_completed_at",
None,
)
def sync_after_commit():
from employees.services.skill_progress import (
award_task_skill_experience,
revoke_task_skill_experience,
)
from employees.services.task_execution_skill import (
capture_task_execution_cycle,
reopen_task_execution_cycle,
)
if is_completed:
award_task_skill_experience(task_id)
capture_task_execution_cycle(task_id)
else:
revoke_task_skill_experience(task_id, actor=actor)
reopen_task_execution_cycle(task_id, previous_completed_at)
transaction.on_commit(sync_after_commit)
@receiver(post_delete, sender=TaskAttachment)
def delete_task_attachment_file(sender, instance, **kwargs):
_delete_file_after_commit(instance.file)

View File

@ -20,3 +20,12 @@ def process_task_automation_event_task(activity_id):
@shared_task(name="tasks.dispatch_scheduled_automations")
def dispatch_scheduled_automations_task():
return dispatch_scheduled_automations()
@shared_task(name="tasks.refresh_task_execution_skills")
def refresh_task_execution_skills_task():
from employees.services.task_execution_skill import (
refresh_task_execution_skills,
)
return refresh_task_execution_skills()

View File

@ -1,13 +1,29 @@
from datetime import timedelta
import pytest
from django.contrib.auth.models import Permission
from django.urls import reverse
from employees.models import Employee, Skill
from rest_framework import status
from employees.models import (
Employee,
EmployeeSkillProgress,
Skill,
SkillExperienceEvent,
)
from employees.services.skill_progress import (
award_task_skill_experience,
confirmed_holder_count,
rank_payload,
rarity_payload,
revoke_task_skill_experience,
skill_queryset_with_rarity,
)
from tasks.models import Task, TaskBoard, TaskColumn
from tests.conftest import _unique_email, _unique_phone
from tests.test_config import DEFAULT_PASSWORD
# --- helpers ---
def _mk_user(staff=False, superuser=False) -> Employee:
u = Employee.objects.create_user(
email=_unique_email(),
@ -82,6 +98,334 @@ 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["anti_streak"] == 0
assert progress_response.data["anti_streak_limit"] == 3
assert progress_response.data["anti_streak_remaining"] == 3
assert progress_response.data["recovery_streak_target"] == 20
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"
assert rank_payload(99)["rank"] == "none"
assert rank_payload(100)["rank"] == "bronze"
assert rank_payload(300)["rank"] == "silver"
assert rank_payload(800)["rank"] == "gold"
assert rank_payload(1800)["rank"] == "ruby"
assert rank_payload(4000)["rank"] == "diamond"
assert rarity_payload(1)["rarity"] == "legendary"
assert rarity_payload(3)["rarity"] == "epic"
assert rarity_payload(9)["rarity"] == "rare"
assert rarity_payload(24)["rarity"] == "uncommon"
assert rarity_payload(25)["rarity"] == "common"
@pytest.mark.django_db
def test_skill_rarity_counts_holders_from_fifty_experience():
employee = _mk_user()
skill = Skill.objects.create(name="Redis")
employee.skills.add(skill)
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
progress.experience = 49
progress.save(update_fields=["experience", "updated_at"])
assert confirmed_holder_count(skill) == 0
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()
assert annotated_skill.confirmed_holders == 1
@pytest.mark.django_db
def test_adding_skill_creates_active_progress(api_client):
actor = _mk_user()
target = _mk_user()
skill = Skill.objects.create(name="TypeScript")
api_client.force_authenticate(user=actor)
response = api_client.post(
reverse("api:v1:employees-add-skill", args=[target.id]),
{"skill_id": skill.id},
format="json",
)
assert response.status_code == 200
progress = EmployeeSkillProgress.objects.get(employee=target, skill=skill)
assert progress.is_active is True
assert progress.created_by == actor
payload = next(row for row in response.data["skills"] if row["id"] == skill.id)
assert payload["rank"] == "none"
assert payload["experience"] == 0
@pytest.mark.django_db
def test_endorsement_adds_weighted_experience_and_has_cooldown(api_client):
actor = _mk_user()
target = _mk_user()
skill = Skill.objects.create(name="Python")
target.skills.add(skill)
api_client.force_authenticate(user=actor)
url = reverse(
"api:v1:employees-endorse-skill",
kwargs={"pk": target.id, "skill_id": skill.id},
)
response = api_client.post(
url,
{"reason": "Применил навык при разработке внутреннего сервиса"},
format="json",
)
assert response.status_code == 201
assert response.data["points"] == 5
assert response.data["experience"] == 5
progress = EmployeeSkillProgress.objects.get(employee=target, skill=skill)
assert progress.experience == 5
assert (
SkillExperienceEvent.objects.filter(
progress=progress,
actor=actor,
source_type=SkillExperienceEvent.Source.ENDORSEMENT,
).count()
== 1
)
repeated = api_client.post(
url,
{"reason": "Повторное подтверждение того же навыка"},
format="json",
)
assert repeated.status_code == 409
@pytest.mark.django_db
def test_employee_cannot_endorse_own_skill(api_client):
employee = _mk_user()
skill = Skill.objects.create(name="SQL")
employee.skills.add(skill)
api_client.force_authenticate(user=employee)
url = reverse(
"api:v1:employees-endorse-skill",
kwargs={"pk": employee.id, "skill_id": skill.id},
)
response = api_client.post(
url,
{"reason": "Самостоятельно подтвердил применение навыка"},
format="json",
)
assert response.status_code == 400
@pytest.mark.django_db
def test_task_skill_experience_is_idempotent_and_revocable():
employee = _mk_user()
skill = Skill.objects.create(name="Django")
employee.skills.add(skill)
board = TaskBoard.objects.create(name="Проект", created_by=employee)
done = TaskColumn.objects.create(
board=board,
name="Готово",
position=1000,
is_done=True,
)
task = Task.objects.create(
board=board,
column=done,
title="Сервис отчётов",
created_by=employee,
assignee=employee,
)
task.skills.add(skill)
assert award_task_skill_experience(task.id) == 1
assert award_task_skill_experience(task.id) == 0
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.experience == 30
event = progress.experience_events.get()
assert event.metadata["reward_role"] == "assignee"
assert event.metadata["points"] == 30
assert revoke_task_skill_experience(task.id, actor=employee) == 1
progress.refresh_from_db()
assert progress.experience == 0
@pytest.mark.django_db
def test_task_skill_experience_snapshots_roles_for_each_completion():
creator = _mk_user()
assignee = _mk_user()
participant = _mk_user()
skill = Skill.objects.create(name="PostgreSQL")
creator.skills.add(skill)
assignee.skills.add(skill)
participant.skills.add(skill)
board = TaskBoard.objects.create(name="Роли", created_by=creator)
done = TaskColumn.objects.create(
board=board,
name="Готово",
position=1000,
is_done=True,
)
task = Task.objects.create(
board=board,
column=done,
title="Смена исполнителя",
created_by=creator,
assignee=assignee,
)
task.participants.add(participant)
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
)
creator_progress = EmployeeSkillProgress.objects.get(
employee=creator,
skill=skill,
)
assert creator_progress.experience == 0
assert not creator_progress.experience_events.filter(
source_type=SkillExperienceEvent.Source.TASK,
).exists()
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 revoke_task_skill_experience(task.id, actor=creator) == 2
first_completed_at = task.completed_at
Task.objects.filter(id=task.id).update(
completed_at=first_completed_at + timedelta(minutes=1),
)
task.refresh_from_db()
assert award_task_skill_experience(task.id) == 2
assignee_progress = EmployeeSkillProgress.objects.get(
employee=assignee,
skill=skill,
)
participant_progress = EmployeeSkillProgress.objects.get(
employee=participant,
skill=skill,
)
assert assignee_progress.experience == 15
assert participant_progress.experience == 30
task_events = SkillExperienceEvent.objects.filter(
source_type=SkillExperienceEvent.Source.TASK,
source_id=str(task.id),
)
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
)
@pytest.mark.django_db
def test_update_delete_permissions(api_client):
user = _mk_user()

View File

@ -19,7 +19,7 @@ from rest_framework.test import APIClient
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from communications.models import Chat, ChatMembership, Message
from documents.models import Document
from employees.models import Department, EmployeeDepartment, RoleAssignment
from employees.models import Department, EmployeeDepartment, RoleAssignment, Skill
from feed.models import Post
from guests.models import Guest, GuestVisit
from procurement.constants import ProcurementStatus, UrgencyLevel
@ -1975,6 +1975,45 @@ def test_create_task_and_move_to_done_column(api_client, user):
assert task.completed_at is not None
def test_create_and_update_task_skills(api_client, user):
api_client.force_authenticate(user=user)
board_response = api_client.get(reverse("api:v1:tasks:task-board-default"))
board_id = board_response.data["id"]
column_id = board_response.data["columns"][0]["id"]
python = Skill.objects.create(name="Python")
analytics = Skill.objects.create(name="Аналитика")
create_response = api_client.post(
reverse("api:v1:tasks:task-list"),
{
"board": board_id,
"column": column_id,
"title": "Задача с навыком",
"skill_ids": [python.id],
},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
task_id = create_response.data["id"]
assert [skill["id"] for skill in create_response.data["skills"]] == [python.id]
assert list(Task.objects.get(id=task_id).skills.values_list("id", flat=True)) == [
python.id
]
update_response = api_client.patch(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task_id}),
{"skill_ids": [analytics.id]},
format="json",
)
assert update_response.status_code == status.HTTP_200_OK
assert [skill["id"] for skill in update_response.data["skills"]] == [analytics.id]
assert list(Task.objects.get(id=task_id).skills.values_list("id", flat=True)) == [
analytics.id
]
def test_task_participants_can_change_assignee_in_same_update(api_client, user):
previous_assignee = make_user(
"task-previous-assignee@example.com",

View File

@ -0,0 +1,64 @@
from datetime import timedelta
from io import StringIO
import pytest
from django.core.management import call_command
from django.utils import timezone
from employees.models import EmployeeSkillProgress, Skill
from tasks.models import TaskExecutionCycle
pytestmark = pytest.mark.django_db
def _confirmed_cycles(employee, amount=10):
for index in range(amount):
completed_at = timezone.now() - timedelta(days=20 - index)
TaskExecutionCycle.objects.create(
task_id_snapshot=20000 + index,
employee=employee,
cycle_key=f"command-test:{employee.id}:{index}",
task_title=f"Командная задача {index}",
due_date_snapshot=timezone.localdate(completed_at),
completed_at_snapshot=completed_at,
protection_until=completed_at + timedelta(days=7),
status=TaskExecutionCycle.Status.CONFIRMED,
)
def test_dry_run_reports_skill_and_rolls_back(user_factory):
employee = user_factory(first_name="Гарик", last_name="Мусик")
_confirmed_cycles(employee)
skill = Skill.objects.get(system_code="task_execution")
output = StringIO()
call_command(
"recalculate_secret_skills",
employee_id=employee.id,
dry_run=True,
stdout=output,
)
result = output.getvalue()
assert "СУХОЙ ПРОГОН" in result
assert "Исполнительность: получат 1" in result
assert f"#{employee.id} Мусик Гарик" in result
assert "без ранга → бронза" in result
assert not EmployeeSkillProgress.objects.filter(
employee=employee,
skill=skill,
).exists()
assert not employee.skills.filter(pk=skill.pk).exists()
def test_regular_run_persists_the_same_calculation(user_factory):
employee = user_factory()
_confirmed_cycles(employee)
skill = Skill.objects.get(system_code="task_execution")
call_command("recalculate_secret_skills", employee_id=employee.id)
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.special_rank == "bronze"
assert progress.is_active is True
assert employee.skills.filter(pk=skill.pk).exists()

View File

@ -0,0 +1,423 @@
from datetime import date, datetime, time, timedelta
from unittest.mock import patch
import pytest
from django.utils import timezone
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from employees.constants import (
ACTION_DISMISSED,
ACTION_HIRED,
ACTION_ON_DAY_OFF,
ACTION_ON_SICK_LEAVE,
ACTION_REMOTE,
)
from employees.models import EmployeeAction, 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 _action(employee, action, action_date, **overrides):
values = {
"date": timezone.make_aware(datetime.combine(action_date, time.min)),
}
values.update(overrides)
return EmployeeAction.objects.create(
employee=employee,
action=action,
**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
assert progress.progress_data["anti_streak"] == 1
late_record.delete()
assert recalculate_attendance_secret_skills(employee.id) == []
progress.refresh_from_db()
assert progress.special_rank == "bronze"
assert progress.progress_data["anti_streak"] == 0
def test_self_organization_requires_every_bronze_condition(
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 == "none"
assert progress.is_active is False
assert not employee.skills.filter(pk=skill.pk).exists()
def test_punctuality_anti_streak_demotes_revokes_and_recovers(user_factory):
employee = user_factory()
start = date(2025, 1, 1)
run = _run(employee, start, start + timedelta(days=90))
for offset in range(45):
_record(run, employee, start + timedelta(days=offset))
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 == "silver"
for offset in range(45, 48):
_record(
run,
employee,
start + timedelta(days=offset),
is_late=True,
late_minutes=10,
)
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "bronze"
assert progress.progress_data["penalty_levels"] == 1
assert progress.progress_data["anti_streak"] == 0
for offset in range(48, 51):
_record(
run,
employee,
start + timedelta(days=offset),
is_late=True,
late_minutes=10,
)
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "none"
assert progress.is_active is False
assert progress.progress_data["penalty_levels"] == 2
assert not employee.skills.filter(pk=skill.pk).exists()
for offset in range(51, 71):
_record(run, employee, start + timedelta(days=offset))
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "bronze"
assert progress.is_active is True
assert progress.progress_data["penalty_levels"] == 1
assert employee.skills.filter(pk=skill.pk).exists()
for offset in range(71, 91):
_record(run, employee, start + timedelta(days=offset))
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "silver"
assert progress.progress_data["penalty_levels"] == 0
def test_self_organization_anti_streak_revokes_and_recovers(user_factory):
employee = user_factory()
start = date(2024, 1, 1)
run = _run(employee, start, start + timedelta(days=99))
for offset in range(60):
_record(
run,
employee,
start + timedelta(days=offset),
is_overtime=offset < 5,
overtime_hours=1.5 if offset < 5 else None,
)
for offset in range(60, 65):
_record(
run,
employee,
start + timedelta(days=offset),
is_workday=False,
effective_is_workday=False,
is_overtime=True,
overtime_hours=2,
)
recalculate_attendance_secret_skills(employee.id)
skill = Skill.objects.get(system_code="attendance_self_organization")
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.special_rank == "bronze"
for offset in range(65, 70):
_record(
run,
employee,
start + timedelta(days=offset),
is_late=True,
late_minutes=10,
)
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "none"
assert progress.is_active is False
assert progress.progress_data["penalty_levels"] == 1
for offset in range(70, 100):
_record(run, employee, start + timedelta(days=offset))
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "bronze"
assert progress.is_active is True
assert progress.progress_data["penalty_levels"] == 0
def test_reliability_uses_only_personnel_events_and_recovers_rank(user_factory):
employee = user_factory()
start = date(2025, 1, 1)
_action(employee, ACTION_HIRED, start)
with patch(
"employees.services.secret_skills.timezone.localdate",
return_value=start + timedelta(days=59),
):
recalculate_attendance_secret_skills(employee.id)
skill = Skill.objects.get(system_code="attendance_reliability")
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.special_rank == "bronze"
assert progress.progress_data["worked_days"] == 60
for offset in (60, 65, 70):
_action(
employee,
ACTION_ON_DAY_OFF,
start + timedelta(days=offset),
date_to=start + timedelta(days=offset),
)
with patch(
"employees.services.secret_skills.timezone.localdate",
return_value=start + timedelta(days=70),
):
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "none"
assert progress.is_active is False
assert progress.progress_data["penalty_levels"] == 1
with patch(
"employees.services.secret_skills.timezone.localdate",
return_value=start + timedelta(days=130),
):
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "bronze"
assert progress.is_active is True
assert progress.progress_data["worked_days"] == 128
assert progress.progress_data["leave_or_day_off_episodes"] == 3
assert progress.progress_data["penalty_levels"] == 0
def test_remote_reactivates_employment_without_resetting_active_period(user_factory):
employee = user_factory()
start = date(2025, 1, 1)
_action(employee, ACTION_HIRED, start)
_action(employee, ACTION_REMOTE, start + timedelta(days=30))
with patch(
"employees.services.secret_skills.timezone.localdate",
return_value=start + timedelta(days=59),
):
recalculate_attendance_secret_skills(employee.id)
skill = Skill.objects.get(system_code="attendance_reliability")
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.special_rank == "bronze"
assert progress.progress_data["worked_days"] == 60
_action(employee, ACTION_DISMISSED, start + timedelta(days=60))
_action(employee, ACTION_REMOTE, start + timedelta(days=70))
with patch(
"employees.services.secret_skills.timezone.localdate",
return_value=start + timedelta(days=129),
):
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "bronze"
assert progress.progress_data["worked_days"] == 60
assert (
progress.progress_data["last_evaluated_date"]
== (start + timedelta(days=129)).isoformat()
)
def test_resilience_counts_sick_episodes_and_recovers_rank(user_factory):
employee = user_factory()
start = date(2024, 1, 1)
_action(employee, ACTION_HIRED, start)
with patch(
"employees.services.secret_skills.timezone.localdate",
return_value=start + timedelta(days=59),
):
recalculate_attendance_secret_skills(employee.id)
skill = Skill.objects.get(system_code="personnel_resilience")
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.special_rank == "bronze"
assert progress.progress_data["best_resilience_streak"] == 60
_action(
employee,
ACTION_ON_SICK_LEAVE,
start + timedelta(days=60),
date_to=start + timedelta(days=79),
)
_action(employee, ACTION_ON_SICK_LEAVE, start + timedelta(days=90))
_action(employee, ACTION_ON_SICK_LEAVE, start + timedelta(days=120))
with patch(
"employees.services.secret_skills.timezone.localdate",
return_value=start + timedelta(days=120),
):
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "none"
assert progress.is_active is False
assert progress.progress_data["sick_leave_episodes"] == 3
assert progress.progress_data["penalty_levels"] == 1
with patch(
"employees.services.secret_skills.timezone.localdate",
return_value=start + timedelta(days=210),
):
recalculate_attendance_secret_skills(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "bronze"
assert progress.is_active is True
assert progress.progress_data["current_resilience_streak"] == 90
assert progress.progress_data["penalty_levels"] == 0
def test_personnel_action_schedules_secret_skill_recalculation(
user_factory,
django_capture_on_commit_callbacks,
):
employee = user_factory()
with patch(
"employees.services.secret_skills.recalculate_attendance_secret_skills"
) as recalculate:
with django_capture_on_commit_callbacks(execute=True):
_action(employee, ACTION_ON_SICK_LEAVE, date(2026, 8, 1))
recalculate.assert_called_once_with(employee.id)

View File

View File

@ -0,0 +1,176 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from employees.models import EmployeeSkillProgress, Skill
from employees.services.task_execution_skill import (
capture_task_execution_cycle,
recalculate_task_execution_skill,
refresh_task_execution_skills,
reopen_task_execution_cycle,
)
from tasks.models import Task, TaskBoard, TaskColumn, TaskExecutionCycle
pytestmark = pytest.mark.django_db
def _board_columns(creator):
board = TaskBoard.objects.create(name="Исполнительность", created_by=creator)
source = TaskColumn.objects.create(board=board, name="В работе", position=1000)
done = TaskColumn.objects.create(
board=board,
name="Готово",
position=2000,
is_done=True,
)
return board, source, done
def _cycle(employee, index, status, *, days_ago):
completed_at = timezone.now() - timedelta(days=days_ago, minutes=index)
return TaskExecutionCycle.objects.create(
task_id_snapshot=10000 + index,
employee=employee,
cycle_key=f"test-execution:{employee.id}:{index}:{status}",
task_title=f"Задача {index}",
due_date_snapshot=timezone.localdate(completed_at),
completed_at_snapshot=completed_at,
protection_until=completed_at + timedelta(days=7),
status=status,
reopened_at=(
completed_at if status == TaskExecutionCycle.Status.REOPENED else None
),
)
def test_completion_snapshot_keeps_original_assignee(user_factory):
creator = user_factory()
assignee = user_factory()
replacement = user_factory()
board, source, done = _board_columns(creator)
task = Task.objects.create(
board=board,
column=done,
title="Закрытая задача",
created_by=creator,
assignee=assignee,
due_date=timezone.localdate() + timedelta(days=1),
)
cycle = capture_task_execution_cycle(task.id)
assert cycle.status == TaskExecutionCycle.Status.PENDING
assert cycle.employee_id == assignee.id
task.assignee = replacement
task.save(update_fields=["assignee", "updated_at"])
cycle.refresh_from_db()
assert cycle.employee_id == assignee.id
previous_completed_at = task.completed_at
task.column = source
task.save(update_fields=["column", "completed_at", "updated_at"])
reopen_task_execution_cycle(task.id, previous_completed_at)
cycle.refresh_from_db()
assert cycle.status == TaskExecutionCycle.Status.REOPENED
assert cycle.employee_id == assignee.id
def test_self_assigned_and_unscheduled_tasks_are_excluded(user_factory):
employee = user_factory()
board, _, done = _board_columns(employee)
self_assigned = Task.objects.create(
board=board,
column=done,
title="Сам себе исполнитель",
created_by=employee,
assignee=employee,
due_date=timezone.localdate(),
)
no_due_date = Task.objects.create(
board=board,
column=done,
title="Без срока",
created_by=user_factory(),
assignee=employee,
)
self_cycle = capture_task_execution_cycle(self_assigned.id)
no_due_cycle = capture_task_execution_cycle(no_due_date.id)
assert self_cycle.status == TaskExecutionCycle.Status.EXCLUDED
assert self_cycle.exclusion_reason == "self_assigned"
assert no_due_cycle.status == TaskExecutionCycle.Status.EXCLUDED
assert no_due_cycle.exclusion_reason == "missing_due_date"
def test_ten_confirmed_tasks_unlock_bronze(user_factory):
employee = user_factory()
for index in range(10):
_cycle(
employee,
index,
TaskExecutionCycle.Status.CONFIRMED,
days_ago=20 - index,
)
assert recalculate_task_execution_skill(employee.id) == ["Исполнительность"]
skill = Skill.objects.get(system_code="task_execution")
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.special_rank == "bronze"
assert progress.progress_data["confirmed_tasks"] == 10
assert progress.progress_data["on_time_percent"] == 100
assert progress.progress_data["best_on_time_streak"] == 10
assert employee.skills.filter(pk=skill.pk).exists()
def test_three_recent_violations_demote_silver_to_bronze(user_factory):
employee = user_factory()
for index in range(30):
_cycle(
employee,
index,
TaskExecutionCycle.Status.CONFIRMED,
days_ago=50 - index,
)
recalculate_task_execution_skill(employee.id)
skill = Skill.objects.get(system_code="task_execution")
progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill)
assert progress.special_rank == "silver"
for index in range(30, 33):
_cycle(
employee,
index,
TaskExecutionCycle.Status.REOPENED,
days_ago=3 - (index - 30),
)
recalculate_task_execution_skill(employee.id)
progress.refresh_from_db()
assert progress.special_rank == "bronze"
assert progress.progress_data["violations_last_60"] == 3
assert progress.progress_data["penalty_levels"] == 1
def test_refresh_captures_old_completion_and_confirms_after_protection(user_factory):
creator = user_factory()
assignee = user_factory()
board, _, done = _board_columns(creator)
task = Task.objects.create(
board=board,
column=done,
title="Старая завершённая задача",
created_by=creator,
assignee=assignee,
due_date=timezone.localdate() - timedelta(days=8),
)
old_completion = timezone.now() - timedelta(days=8)
TaskExecutionCycle.objects.filter(task=task).delete()
Task.objects.filter(pk=task.pk).update(completed_at=old_completion)
result = refresh_task_execution_skills(employee_id=assignee.id)
cycle = TaskExecutionCycle.objects.get(task=task)
assert result["captured_cycles"] == 1
assert cycle.completed_at_snapshot == old_completion
assert cycle.status == TaskExecutionCycle.Status.CONFIRMED

View File

@ -161,6 +161,7 @@ export default function ProfilePage() {
<div className="space-y-3">
<ProfileContactsPanel rows={contactRows} />
<ProfileSkillsInline
employeeId={user.id}
inputValue={skillName}
onInputChange={onInputSkillName}
onSubmit={() => void handleAddSkill()}

View File

@ -57,6 +57,7 @@ import {
RefreshCw,
Rows3,
Search,
Sparkles,
ShoppingCart,
Tag,
Trash2,
@ -108,6 +109,7 @@ import type {
Department,
Document,
Message,
Skill,
TaskActivity,
TaskAttachment,
TaskAutomation,
@ -152,6 +154,7 @@ type TaskFormState = {
priority: TaskPriority;
due_date: string;
label_ids: number[];
skill_ids: number[];
};
type BoardFormState = {
@ -190,7 +193,7 @@ type ColumnRowFormState = {
type LinkedItemMode = "document" | "calendar_event" | "external_link";
type LinkedItemTarget = "view" | "form";
type TaskAddPanel = "due_date" | "labels" | "priority" | null;
type TaskAddPanel = "due_date" | "labels" | "priority" | "skills" | null;
type TaskCoverLinkedOption = {
id: number;
@ -281,6 +284,7 @@ const emptyForm: TaskFormState = {
priority: "medium",
due_date: "",
label_ids: [],
skill_ids: [],
};
const emptyBoardForm: BoardFormState = {
@ -3762,6 +3766,7 @@ function TasksPageContent() {
const [taskParticipantCandidates, setTaskParticipantCandidates] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [taskLabels, setTaskLabels] = useState<TaskLabel[]>([]);
const [availableSkills, setAvailableSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [boardLoading, setBoardLoading] = useState(false);
const [saving, setSaving] = useState(false);
@ -3796,6 +3801,7 @@ function TasksPageContent() {
const [taskAddMenuPlacement, setTaskAddMenuPlacement] = useState<"top" | "bottom">("top");
const [taskAddPanel, setTaskAddPanel] = useState<TaskAddPanel>(null);
const [viewTaskDueDateDraft, setViewTaskDueDateDraft] = useState("");
const [viewTaskSkillSearch, setViewTaskSkillSearch] = useState("");
const [viewColumnMenuOpen, setViewColumnMenuOpen] = useState(false);
const [cardMenuTaskId, setCardMenuTaskId] = useState<number | null>(null);
const [columnMenuId, setColumnMenuId] = useState<number | null>(null);
@ -4457,6 +4463,7 @@ function TasksPageContent() {
employeesData,
departmentsData,
labelsData,
skillsData,
] = await Promise.all([
requestedBoardId > 0
? apiClient.getTaskBoard(requestedBoardId)
@ -4466,6 +4473,7 @@ function TasksPageContent() {
apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }),
apiClient.getDepartments({ limit: 200 }),
apiClient.getTaskLabels(),
apiClient.getSkills(),
]);
if (!mounted) return;
const boardList = Array.isArray(boardsData) ? boardsData : boardsData.results || [];
@ -4490,6 +4498,7 @@ function TasksPageContent() {
setEmployees(employeesData.results || employeesData || []);
setDepartments(departmentsData.results || departmentsData || []);
setTaskLabels(Array.isArray(labelsData) ? labelsData : labelsData.results || []);
setAvailableSkills(Array.isArray(skillsData) ? skillsData : skillsData.results || []);
if (
boardData &&
requestedTaskId > 0 &&
@ -4742,6 +4751,16 @@ function TasksPageContent() {
() => (board?.tasks || []).find((task) => task.id === viewTaskId) || null,
[board?.tasks, viewTaskId],
);
const filteredViewTaskSkills = useMemo(() => {
const query = viewTaskSkillSearch.trim().toLowerCase();
const selectedIds = new Set((viewTask?.skills || []).map((skill) => skill.id));
return availableSkills
.filter((skill) => !query || skill.name.toLowerCase().includes(query))
.sort((left, right) => {
const selectedDifference = Number(selectedIds.has(right.id)) - Number(selectedIds.has(left.id));
return selectedDifference || left.name.localeCompare(right.name, "ru");
});
}, [availableSkills, viewTask?.skills, viewTaskSkillSearch]);
const viewTaskColumn = useMemo(
() => activeColumns.find((column) => column.id === viewTask?.column) || null,
@ -5110,6 +5129,7 @@ function TasksPageContent() {
setTaskAddMenuOpen(false);
setTaskAddPanel(null);
setTaskLabelSearch("");
setViewTaskSkillSearch("");
setViewTaskDueDateDraft(task.due_date || "");
setViewColumnMenuOpen(false);
setLinkedObjectsOpen(false);
@ -5172,6 +5192,7 @@ function TasksPageContent() {
setTaskAddMenuOpen(false);
setTaskAddPanel(null);
setTaskLabelSearch("");
setViewTaskSkillSearch("");
setViewTaskDueDateDraft("");
setViewColumnMenuOpen(false);
setLinkedMessages([]);
@ -5308,6 +5329,7 @@ function TasksPageContent() {
priority: task.priority,
due_date: task.due_date || "",
label_ids: (task.labels || []).map((label) => label.id),
skill_ids: (task.skills || []).map((skill) => skill.id),
});
setLabelName("");
setTaskLabelSearch("");
@ -6192,6 +6214,7 @@ function TasksPageContent() {
priority: form.priority,
due_date: form.due_date || null,
label_ids: form.label_ids,
skill_ids: form.skill_ids,
};
let savedTaskId = form.id || null;
let primaryDataSaved = false;
@ -6328,6 +6351,27 @@ function TasksPageContent() {
}
}, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]);
const toggleViewedTaskSkill = useCallback(async (skillId: number) => {
if (!board || !viewTask || saving) return;
const currentSkillIds = viewTask.skills?.map((skill) => skill.id) || [];
const nextSkillIds = currentSkillIds.includes(skillId)
? currentSkillIds.filter((id) => id !== skillId)
: [...currentSkillIds, skillId];
setSaving(true);
setError(null);
try {
await apiClient.updateTask(viewTask.id, { skill_ids: nextSkillIds });
await loadBoard(board.id);
if (activityOpen) await loadTaskActivity(viewTask.id);
} catch (skillError) {
setError(getTaskError(skillError, "Не удалось изменить навыки задачи"));
} finally {
setSaving(false);
}
}, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]);
const createViewedTaskLabel = useCallback(async () => {
if (!board || !viewTask || !labelName.trim() || saving) return;
@ -6374,6 +6418,15 @@ function TasksPageContent() {
}));
}, []);
const toggleTaskSkill = useCallback((skillId: number) => {
setForm((current) => ({
...current,
skill_ids: current.skill_ids.includes(skillId)
? current.skill_ids.filter((id) => id !== skillId)
: [...current.skill_ids, skillId],
}));
}, []);
const toggleTaskParticipant = useCallback((employeeId: number) => {
setForm((current) => {
const requiredIds = new Set<number>();
@ -7615,6 +7668,18 @@ function TasksPageContent() {
<Tag size={15} className="app-text-muted" />
Метки
</button>
<button
type="button"
onClick={() => {
setViewTaskSkillSearch("");
setTaskAddPanel("skills");
setTaskAddMenuOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Sparkles size={15} className="app-text-muted" />
Навыки
</button>
<button
type="button"
onClick={() => void openTaskCoverPicker()}
@ -9473,6 +9538,72 @@ function TasksPageContent() {
</div>
) : null}
{taskAddPanel === "skills" ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
<Sparkles size={15} className="app-text-muted" />
Навыки задачи
{(viewTask.skills?.length || 0) > 0 ? (
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
{viewTask.skills?.length}
</span>
) : null}
</span>
<button
type="button"
onClick={() => setTaskAddPanel(null)}
className="app-icon-button flex h-7 w-7 items-center justify-center rounded-lg"
title="Закрыть"
aria-label="Закрыть выбор навыков"
>
<X size={14} />
</button>
</div>
<label className="relative mb-3 block">
<Search size={14} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={viewTaskSkillSearch}
onChange={(event) => setViewTaskSkillSearch(event.target.value)}
className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-sm"
placeholder="Поиск навыка"
autoFocus
/>
</label>
{filteredViewTaskSkills.length ? (
<div className="tasks-column-scroll flex max-h-52 flex-wrap content-start gap-2 overflow-y-auto pr-1">
{filteredViewTaskSkills.map((skill) => {
const selected = Boolean(viewTask.skills?.some((item) => item.id === skill.id));
return (
<button
key={skill.id}
type="button"
onClick={() => void toggleViewedTaskSkill(skill.id)}
disabled={saving}
className={`app-pill app-badge-interactive inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium disabled:opacity-50 ${
selected ? "app-selected" : ""
}`}
aria-pressed={selected}
>
{selected ? <Check size={13} /> : null}
<span>{skill.name}</span>
</button>
);
})}
</div>
) : (
<div className="app-surface rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center">
<p className="app-text-muted text-xs">
{availableSkills.length ? "Навыки не найдены" : "Навыки пока не созданы"}
</p>
</div>
)}
<p className="app-text-muted mt-2 text-[11px] leading-relaxed">
После завершения задачи исполнители и участники с выбранными навыками получат опыт.
</p>
</div>
) : null}
{viewTask.description ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<p className="app-text-wrap whitespace-pre-wrap text-sm text-[var(--foreground)]">
@ -9682,6 +9813,26 @@ function TasksPageContent() {
</div>
) : null}
{viewTask.skills && viewTask.skills.length > 0 ? (
<div>
<div className="mb-2 flex items-center gap-2">
<Sparkles size={15} className="app-text-muted" />
<span className="app-text-muted text-xs font-medium">Навыки задачи</span>
</div>
<div className="flex flex-wrap gap-2">
{viewTask.skills.map((skill) => (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
title="Участники с этим навыком получат опыт после завершения задачи"
>
{skill.name}
</span>
))}
</div>
</div>
) : null}
{attachmentsBadgeCount > 0 || attachmentsOpen ? (
<div
ref={taskAttachmentsSectionRef}
@ -11247,6 +11398,48 @@ function TasksPageContent() {
) : null}
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles size={15} className="app-text-muted" />
<span className="app-text-muted text-xs font-medium">Навыки задачи</span>
</div>
{form.skill_ids.length ? (
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
{form.skill_ids.length}
</span>
) : null}
</div>
<HorizontalChipScroller
ariaLabel="Навыки, применяемые в задаче"
fadeColor="var(--surface-primary)"
>
{availableSkills.length ? availableSkills.map((skill) => {
const selected = form.skill_ids.includes(skill.id);
return (
<button
key={skill.id}
type="button"
onClick={() => toggleTaskSkill(skill.id)}
disabled={saving}
className={`app-pill app-badge-interactive inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
selected ? "app-selected" : ""
}`}
aria-pressed={selected}
>
{selected ? <Check size={13} /> : null}
{skill.name}
</button>
);
}) : (
<span className="app-text-muted px-1 text-xs">Навыки пока не созданы</span>
)}
</HorizontalChipScroller>
<p className="app-text-muted mt-1.5 text-[11px]">
После завершения задачи участники с этими навыками получат опыт.
</p>
</div>
<div>
<div className="mb-2 flex items-center gap-2">
<Tag size={15} className="app-text-muted" />

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 || []);
@ -517,6 +514,7 @@ export default function UserDetailPage() {
emptyText="Контакты не указаны"
/>
<ProfileSkillsInline
employeeId={person.id}
inputValue={skillName}
onInputChange={setSkillName}
onSubmit={() => void handleAddSkill()}
@ -527,6 +525,7 @@ export default function UserDetailPage() {
error={skillsError}
loading={skillsLoading}
emptyText="Навыки не указаны"
onSkillUpdated={() => refreshPerson()}
/>
</div>
}

View File

@ -10,17 +10,22 @@ import {
type ReactNode,
} from "react";
import {
Award,
CalendarClock,
Check,
ChevronDown,
CircleHelp,
Clock3,
Crown,
Gem,
Info,
Link2,
Loader2,
Pencil,
Plus,
Shield,
Sparkles,
Trophy,
X,
} from "lucide-react";
import { apiClient } from "@/lib/api";
@ -31,7 +36,10 @@ import type {
EmployeeAttendanceStatus,
EmployeeDepartment,
EmployeePersonnelState,
EmployeeSkillProgress,
Skill,
SkillRank,
SkillRarity,
} from "@/types/api";
import { getEmployeeActionTone } from "@/lib/users/userDetailUtils";
@ -50,6 +58,77 @@ export type ProfileInfoItem = {
value: string;
};
const skillRarityStyles: Record<SkillRarity, string> = {
unrated: "border-[var(--border-subtle)]",
common: "border-slate-500/60",
uncommon: "border-emerald-500/60",
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> = {
unrated: "bg-slate-500/10 text-slate-400",
common: "bg-slate-500/15 text-slate-300",
uncommon: "bg-emerald-500/15 text-emerald-300",
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> = {
none: 0,
bronze: 1,
silver: 2,
gold: 3,
ruby: 4,
diamond: 5,
};
const skillRarityOrder: Record<SkillRarity, number> = {
unrated: 0,
common: 1,
uncommon: 2,
rare: 3,
epic: 4,
legendary: 5,
secret: 6,
};
function sortProfileSkills(skills: Skill[]) {
return [...skills].sort((left, right) => {
const rankDifference = skillRankOrder[right.rank || "none"] - skillRankOrder[left.rank || "none"];
if (rankDifference) return rankDifference;
const experienceDifference = (right.experience || 0) - (left.experience || 0);
if (experienceDifference) return experienceDifference;
const rarityDifference = skillRarityOrder[right.rarity || "unrated"] - skillRarityOrder[left.rarity || "unrated"];
if (rarityDifference) return rarityDifference;
return left.name.localeCompare(right.name, "ru");
});
}
function SkillRankMark({ rank = "none", size = 14 }: { rank?: SkillRank; size?: number }) {
const props = { size, strokeWidth: 2.2, "aria-hidden": true };
if (rank === "bronze") return <Shield {...props} className="text-amber-700" />;
if (rank === "silver") return <Award {...props} className="text-slate-300" />;
if (rank === "gold") return <Trophy {...props} className="text-amber-400" />;
if (rank === "ruby") return <Gem {...props} className="text-rose-400" />;
if (rank === "diamond") return <Sparkles {...props} className="text-cyan-300" />;
return <span className="h-2 w-2 rounded-full border border-dashed border-[var(--muted-foreground)]" />;
}
function formatSkillEventDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
}
const workScheduleDays = [
{ value: "Monday", label: "Пн" },
{ value: "Tuesday", label: "Вт" },
@ -708,6 +787,7 @@ export function ProfileWorkScheduleCard({
}
export function ProfileSkillsInline({
employeeId,
title = "Навыки",
inputValue,
onInputChange,
@ -721,7 +801,9 @@ export function ProfileSkillsInline({
onRemoveSkill,
removeDisabled,
emptyText = "Навыки не указаны",
onSkillUpdated,
}: {
employeeId: number;
title?: string;
inputValue: string;
onInputChange: (value: string) => void;
@ -735,29 +817,112 @@ export function ProfileSkillsInline({
onRemoveSkill?: (skillId: number) => void;
removeDisabled?: boolean;
emptyText?: string;
onSkillUpdated?: () => unknown | Promise<unknown>;
}) {
const [modalOpen, setModalOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [progressOpen, setProgressOpen] = useState(false);
const [progressLoading, setProgressLoading] = useState(false);
const [progress, setProgress] = useState<EmployeeSkillProgress | null>(null);
const [progressError, setProgressError] = useState<string | null>(null);
const [endorsementReason, setEndorsementReason] = useState("");
const [endorsementSaving, setEndorsementSaving] = useState(false);
const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`;
const helpId = `${listId}-help`;
const sortedSkills = sortProfileSkills(skills);
const editableSkills = skills.filter((skill) => !skill.is_secret);
const openSkillProgress = async (skill: Skill) => {
setProgressOpen(true);
setProgressLoading(true);
setProgressError(null);
setProgress(null);
setEndorsementReason("");
try {
const response = await apiClient.getEmployeeSkillProgress(employeeId, skill.id);
setProgress(response as EmployeeSkillProgress);
} catch (requestError) {
setProgressError(
requestError instanceof Error ? requestError.message : "Не удалось загрузить прогресс навыка",
);
} finally {
setProgressLoading(false);
}
};
const endorseSkill = async () => {
if (!progress || endorsementReason.trim().length < 8 || endorsementSaving) return;
setEndorsementSaving(true);
setProgressError(null);
try {
await apiClient.endorseEmployeeSkill(employeeId, progress.skill_id, {
reason: endorsementReason.trim(),
});
const refreshed = await apiClient.getEmployeeSkillProgress(employeeId, progress.skill_id);
setProgress(refreshed as EmployeeSkillProgress);
setEndorsementReason("");
await onSkillUpdated?.();
} catch (requestError) {
setProgressError(
requestError instanceof Error ? requestError.message : "Не удалось подтвердить навык",
);
} finally {
setEndorsementSaving(false);
}
};
return (
<>
<div>
<div className="flex min-w-0 items-center gap-2">
<HorizontalChipScroller
key={sortedSkills
.map(
(skill) =>
`${skill.id}:${skill.rank}:${skill.experience}:${skill.rarity}:${skill.anti_streak}:${skill.penalty_levels}`,
)
.join("|")}
className="min-w-0 flex-1"
ariaLabel="Навыки сотрудника"
fadeColor="var(--surface-primary)"
>
{skills.length ? (
skills.map((skill) => (
<span
{sortedSkills.length ? (
sortedSkills.map((skill) => (
<button
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-2.5 py-1.5 text-xs font-medium"
type="button"
onClick={() => void openSkillProgress(skill)}
className={`app-pill app-badge-interactive inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs font-medium ${
skillRarityStyles[skill.rarity || "unrated"]
}`}
title={`${skill.rank_display || "Без ранга"} · ${skill.rarity_display || "Редкость не определена"}`}
aria-label={`${skill.name}. Ранг: ${skill.rank_display || "Без ранга"}. Редкость: ${skill.rarity_display || "Не определена"}`}
>
{skill.name}
</span>
<SkillRankMark rank={skill.rank} />
<span>{skill.name}</span>
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none ${
skillRarityBadgeStyles[skill.rarity || "unrated"]
}`}
>
{skill.rarity_display || "Не определена"}
</span>
{(skill.anti_streak || 0) > 0 ? (
<span
className="rounded-full bg-red-500/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-red-200"
title={`${skill.anti_streak_label || "Антисерия"}: ${skill.anti_streak}/${skill.anti_streak_limit}`}
>
! {skill.anti_streak}/{skill.anti_streak_limit}
</span>
) : null}
{(skill.penalty_levels || 0) > 0 ? (
<span
className="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-amber-200"
title={`Ранг снижен на ${skill.penalty_levels} уровней`}
>
−{skill.penalty_levels}
</span>
) : null}
</button>
))
) : (
<span className="app-text-muted whitespace-nowrap px-1 text-xs">
@ -788,12 +953,12 @@ export function ProfileSkillsInline({
<span>{title}</span>
<button
type="button"
onClick={() => setHelpOpen((value) => !value)}
onClick={() => setHelpOpen(true)}
className="app-accent-text inline-flex h-5 w-5 items-center justify-center rounded-full transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
aria-label="Как правильно заполнять навыки"
aria-label="Открыть справку о навыках"
aria-haspopup="dialog"
aria-expanded={helpOpen}
aria-controls={helpId}
title="Показать правила заполнения"
title="Как работают навыки"
>
<CircleHelp size={14} strokeWidth={2} />
</button>
@ -803,23 +968,6 @@ export function ProfileSkillsInline({
closeOnClickOutside
>
<div className="space-y-4">
{helpOpen ? (
<div
id={helpId}
className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-3 text-sm"
>
<p className="font-semibold text-[var(--foreground)]">Как заполнять навыки</p>
<ul className="app-text-muted mt-2 space-y-1.5 text-xs leading-relaxed">
<li>• Один навык — одна конкретная компетенция или технология.</li>
<li>• Выбирайте существующее название из списка, если оно уже есть.</li>
<li>• Не объединяйте несколько навыков и не указывайте должность или уровень владения.</li>
</ul>
<p className="app-text-muted mt-2 text-xs">
Например: «Python», «1С», «Электромонтаж».
</p>
</div>
) : null}
<div className="flex min-w-0 gap-2">
<input
list={listId}
@ -856,9 +1004,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}
@ -891,6 +1039,264 @@ export function ProfileSkillsInline({
) : null}
</div>
</Modal>
<Modal
isOpen={helpOpen}
onClose={() => setHelpOpen(false)}
title="Как работают навыки"
size="sm"
stackLevel={1}
closeOnClickOutside
>
<div className="space-y-3 text-sm">
<section className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<h3 className="font-semibold text-[var(--foreground)]">Как добавлять</h3>
<ul className="app-text-muted mt-2 space-y-1.5 text-xs leading-relaxed">
<li>• Один навык — одна конкретная компетенция или технология.</li>
<li>• Выбирайте существующее название, если оно уже есть в списке.</li>
<li>• Не объединяйте навыки и не указывайте должность или уровень владения.</li>
</ul>
<p className="app-text-muted mt-2 text-xs">
Например: «Python», «1С», «Электромонтаж».
</p>
</section>
<section className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<h3 className="font-semibold text-[var(--foreground)]">Опыт и ранг</h3>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
Ранг показывает личный уровень сотрудника в конкретном навыке. Опыт начисляется за завершённые задачи с этим навыком и подтверждения коллег.
</p>
<div className="mt-3 grid grid-cols-2 gap-1.5 text-xs sm:grid-cols-3">
{[
{ rank: "none" as const, label: "Без ранга", xp: "0 XP" },
{ rank: "bronze" as const, label: "Бронза", xp: "100 XP" },
{ rank: "silver" as const, label: "Серебро", xp: "300 XP" },
{ rank: "gold" as const, label: "Золото", xp: "800 XP" },
{ rank: "ruby" as const, label: "Рубин", xp: "1 800 XP" },
{ rank: "diamond" as const, label: "Бриллиант", xp: "4 000 XP" },
].map((item) => (
<div
key={item.rank}
className="app-surface flex min-w-0 items-center gap-2 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2"
>
<SkillRankMark rank={item.rank} />
<div className="min-w-0">
<p className="truncate font-medium text-[var(--foreground)]">{item.label}</p>
<p className="app-text-muted text-[10px]">{item.xp}</p>
</div>
</div>
))}
</div>
<p className="app-text-muted mt-2 text-[11px] leading-relaxed">
Завершённая задача даёт исполнителю 30 XP, участнику — 15 XP. Подтверждение коллеги — 5–15 XP в зависимости от его роли.
</p>
</section>
<section className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<h3 className="font-semibold text-[var(--foreground)]">Редкость</h3>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
Редкость относится к самому навыку и показывает, насколько редко он встречается среди подтверждённых обладателей в компании. Ранг при этом остаётся личным для каждого сотрудника.
</p>
<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" },
{ rarity: "epic" as const, label: "Эпический · 2–3" },
{ 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}
className={`rounded-full px-2 py-1 text-[11px] font-semibold ${skillRarityBadgeStyles[item.rarity]}`}
>
{item.label}
</span>
))}
</div>
</section>
</div>
</Modal>
<Modal
isOpen={progressOpen}
onClose={() => {
if (endorsementSaving) return;
setProgressOpen(false);
setProgress(null);
setProgressError(null);
setEndorsementReason("");
}}
title={progress?.skill_name || "Прогресс навыка"}
size="sm"
closeOnClickOutside={!endorsementSaving}
>
{progressLoading ? (
<div className="py-12 text-center">
<Loader2 size={24} className="mx-auto animate-spin text-sky-400" />
</div>
) : progress ? (
<div className="space-y-4">
<div className={`app-surface-muted rounded-xl border p-4 ${skillRarityStyles[progress.rarity]}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="app-surface flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-subtle)]">
<SkillRankMark rank={progress.rank} size={21} />
</div>
<div className="min-w-0">
<p className="font-semibold text-[var(--foreground)]">{progress.rank_display}</p>
<p className="app-text-muted text-xs">
{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.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}
{progress.is_secret && (progress.anti_streak || 0) > 0 ? (
<div className="mt-3 rounded-xl border border-red-400/40 bg-red-500/10 px-3 py-2.5 text-xs text-red-200">
<p className="font-semibold">
{progress.anti_streak_label || "Антисерия"}: {progress.anti_streak}/
{progress.anti_streak_limit}
</p>
<p className="mt-1 text-red-200/80">
{progress.anti_streak_remaining === 1
? "Следующий случай понизит ранг."
: `До понижения ранга: ${progress.anti_streak_remaining}.`}
</p>
</div>
) : null}
{progress.is_secret && (progress.penalty_levels || 0) > 0 ? (
<div className="mt-3 rounded-xl border border-amber-400/40 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-200">
<p className="font-semibold">
Ранг снижен на {progress.penalty_levels} ур.
</p>
<p className="mt-1 text-amber-200/80">
Восстановление: {progress.recovery_streak || 0}/
{progress.recovery_streak_target} положительных дней.
</p>
</div>
) : 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]"
style={{ width: `${progress.rank_progress_percent}%` }}
/>
</div>
<div className="app-text-muted mt-1.5 flex justify-between gap-3 text-[11px]">
<span>
{progress.rank_progress_percent}% {progress.is_secret ? "до следующего ранга" : "текущего ранга"}
</span>
<span>
{progress.next_rank_display
? 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 ? (
<div className="space-y-2">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">
Где сотрудник применил навык
</span>
<textarea
value={endorsementReason}
onChange={(event) => setEndorsementReason(event.target.value)}
rows={3}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
placeholder="Опишите конкретную работу или результат"
/>
</label>
<button
type="button"
onClick={() => void endorseSkill()}
disabled={endorsementSaving || endorsementReason.trim().length < 8}
className="app-action-primary inline-flex w-full items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
{endorsementSaving ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
Подтвердить навык
</button>
<p className="app-text-muted text-center text-[11px]">
Вес подтверждения зависит от вашей роли: 5–15 XP.
</p>
</div>
) : progress.endorsement_available_at ? (
<p className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
Повторное подтверждение доступно после {formatSkillEventDate(progress.endorsement_available_at)}.
</p>
) : null}
<div>
<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) => (
<div key={event.id} className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-[var(--foreground)]">
{event.source_display}
</span>
<span className="shrink-0 text-xs font-semibold text-emerald-400">+{event.points} XP</span>
</div>
{event.reason ? <p className="app-text-muted mt-1 text-xs">{event.reason}</p> : null}
<p className="app-text-muted mt-1 text-[11px]">
{[event.actor_name, formatSkillEventDate(event.created_at)].filter(Boolean).join(" · ")}
</p>
</div>
))}
</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>
{progressError ? <p className="text-sm text-red-400">{progressError}</p> : null}
</div>
) : (
<p className="py-8 text-center text-sm text-red-400">
{progressError || "Прогресс навыка недоступен"}
</p>
)}
</Modal>
</>
);
}

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

@ -36,6 +36,17 @@ export function createEmployeesApi(request: RequestFn) {
method: 'POST',
body: JSON.stringify(data),
}),
getEmployeeSkillProgress: (id: number | string, skillId: number | string) =>
request(`/api/v1/employees/${id}/skills/${skillId}/progress/`),
endorseEmployeeSkill: (
id: number | string,
skillId: number | string,
data: { reason: string },
) =>
request(`/api/v1/employees/${id}/skills/${skillId}/endorse/`, {
method: 'POST',
body: JSON.stringify(data),
}),
updateEmployee: (id: number | string, data: Record<string, any>) =>
request(`/api/v1/employees/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
uploadEmployeeAvatar: (id: number | string, file: File) => {

View File

@ -2,7 +2,100 @@
export interface Skill {
id: number;
name: string;
is_secret?: boolean;
progression_type?:
| "standard"
| "attendance_punctuality"
| "attendance_self_organization"
| "attendance_reliability"
| "personnel_resilience";
description?: string;
experience?: number;
rank?: SkillRank;
rank_display?: string;
next_rank?: SkillRank | null;
next_rank_display?: string | null;
next_rank_experience?: number | null;
experience_to_next_rank?: number;
rank_progress_percent?: number;
confirmed_holders?: number;
rarity?: SkillRarity;
rarity_display?: string;
progress_value?: number | null;
progress_label?: string;
progress_data?: Record<string, unknown>;
next_rank_requirements?: SkillRankRequirement[];
anti_streak?: number;
anti_streak_label?: string;
anti_streak_limit?: number;
anti_streak_remaining?: number;
recovery_streak?: number;
recovery_streak_target?: number;
penalty_levels?: number;
}
export type SkillRank = "none" | "bronze" | "silver" | "gold" | "ruby" | "diamond";
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;
points: number;
source_type: "endorsement" | "task" | "training" | "certification" | "manual";
source_display: string;
source_id?: string;
actor?: number | null;
actor_name?: string | null;
reason?: string;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface EmployeeSkillProgress {
employee_id: number;
skill_id: number;
skill_name: string;
is_secret: boolean;
progression_type:
| "standard"
| "attendance_punctuality"
| "attendance_self_organization"
| "attendance_reliability"
| "personnel_resilience";
experience: number;
rank: SkillRank;
rank_display: string;
next_rank: SkillRank | null;
next_rank_display: string | null;
next_rank_experience: number | null;
experience_to_next_rank: number;
rank_progress_percent: number;
confirmed_holders: number;
rarity: SkillRarity;
rarity_display: string;
progress_value?: number | null;
progress_label?: string;
progress_data?: Record<string, unknown>;
next_rank_requirements?: SkillRankRequirement[];
anti_streak?: number;
anti_streak_label?: string;
anti_streak_limit?: number;
anti_streak_remaining?: number;
recovery_streak?: number;
recovery_streak_target?: number;
penalty_levels?: number;
can_endorse: boolean;
endorsement_points?: number;
endorsement_role?: string;
endorsement_available_at?: string | null;
events: SkillExperienceEvent[];
}
export interface EmployeeAction {
@ -318,6 +411,7 @@ export interface TaskCard {
assignee?: User | null;
participants?: User[];
labels?: TaskLabel[];
skills?: Skill[];
priority: TaskPriority;
priority_display?: string;
can_open?: boolean;