merge: secret skill gamification
This commit is contained in:
commit
22ecf40d46
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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"}:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"]
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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}"
|
||||
)
|
||||
)
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
92
backend/employees/migrations/0058_secret_skill_progress.py
Normal file
92
backend/employees/migrations/0058_secret_skill_progress.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
45
backend/employees/migrations/0060_task_execution_skill.py
Normal file
45
backend/employees/migrations/0060_task_execution_skill.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@ -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."""
|
||||
|
||||
1086
backend/employees/services/secret_skills.py
Normal file
1086
backend/employees/services/secret_skills.py
Normal file
File diff suppressed because it is too large
Load Diff
357
backend/employees/services/skill_progress.py
Normal file
357
backend/employees/services/skill_progress.py
Normal 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
|
||||
402
backend/employees/services/task_execution_skill.py
Normal file
402
backend/employees/services/task_execution_skill.py
Normal 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,
|
||||
}
|
||||
@ -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",
|
||||
|
||||
62
backend/employees/signals/secret_skills.py
Normal file
62
backend/employees/signals/secret_skills.py
Normal 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})
|
||||
27
backend/employees/signals/skills.py
Normal file
27
backend/employees/signals/skills.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
23
backend/tasks/migrations/0040_task_skills.py
Normal file
23
backend/tasks/migrations/0040_task_skills.py
Normal 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="Применяемые навыки",
|
||||
),
|
||||
),
|
||||
]
|
||||
139
backend/tasks/migrations/0041_taskexecutioncycle.py
Normal file
139
backend/tasks/migrations/0041_taskexecutioncycle.py
Normal 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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
423
backend/tests/employees/test_secret_skills.py
Normal file
423
backend/tests/employees/test_secret_skills.py
Normal 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)
|
||||
0
backend/tests/tasks/__init__.py
Normal file
0
backend/tests/tasks/__init__.py
Normal file
176
backend/tests/tasks/test_task_execution_skill.py
Normal file
176
backend/tests/tasks/test_task_execution_skill.py
Normal 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
|
||||
@ -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()}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user