From ae4eb4696acf850d5319ff791928bacc71160649 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 25 Aug 2026 13:28:43 +0300 Subject: [PATCH 01/12] feat: add skill ranks and progression --- .../api/v1/employees/serializers/__init__.py | 4 + .../employee_serializers/detail.py | 7 +- .../employee_serializers/listing.py | 7 +- .../api/v1/employees/serializers/shared.py | 153 +++++++- .../views/employee_mixins/profile.py | 155 ++++++++- .../employees/views/employee_mixins/query.py | 12 +- backend/api/v1/employees/views/skills.py | 35 +- backend/api/v1/tasks/serializers/tasks.py | 19 +- backend/api/v1/tasks/viewsets/boards.py | 5 + backend/api/v1/tasks/viewsets/tasks.py | 1 + backend/employees/admin.py | 37 ++ ...lprogress_skillexperienceevent_and_more.py | 248 +++++++++++++ backend/employees/models.py | 236 ++++++++----- backend/employees/services/skill_progress.py | 326 ++++++++++++++++++ backend/employees/signals/__init__.py | 4 +- backend/employees/signals/skills.py | 27 ++ backend/tasks/migrations/0040_task_skills.py | 23 ++ backend/tasks/models.py | 6 + backend/tasks/queries.py | 2 + backend/tasks/signals.py | 35 ++ backend/tests/api/v1/employees/test_skills.py | 143 +++++++- frontend/src/app/profile/page.tsx | 1 + frontend/src/app/tasks/page.tsx | 81 +++++ frontend/src/app/users/[id]/page.tsx | 2 + .../src/components/users/ProfileSections.tsx | 218 +++++++++++- frontend/src/lib/api/employees.ts | 11 + frontend/src/types/api.ts | 50 +++ 27 files changed, 1715 insertions(+), 133 deletions(-) create mode 100644 backend/employees/migrations/0057_employeeskillprogress_skillexperienceevent_and_more.py create mode 100644 backend/employees/services/skill_progress.py create mode 100644 backend/employees/signals/skills.py create mode 100644 backend/tasks/migrations/0040_task_skills.py diff --git a/backend/api/v1/employees/serializers/__init__.py b/backend/api/v1/employees/serializers/__init__.py index f33da6e3..2e94a14c 100644 --- a/backend/api/v1/employees/serializers/__init__.py +++ b/backend/api/v1/employees/serializers/__init__.py @@ -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", diff --git a/backend/api/v1/employees/serializers/employee_serializers/detail.py b/backend/api/v1/employees/serializers/employee_serializers/detail.py index 6845197d..01a858ce 100644 --- a/backend/api/v1/employees/serializers/employee_serializers/detail.py +++ b/backend/api/v1/employees/serializers/employee_serializers/detail.py @@ -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,7 +40,7 @@ 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 ) @@ -123,6 +123,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 []: diff --git a/backend/api/v1/employees/serializers/employee_serializers/listing.py b/backend/api/v1/employees/serializers/employee_serializers/listing.py index b5605ba8..826914e5 100644 --- a/backend/api/v1/employees/serializers/employee_serializers/listing.py +++ b/backend/api/v1/employees/serializers/employee_serializers/listing.py @@ -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) diff --git a/backend/api/v1/employees/serializers/shared.py b/backend/api/v1/employees/serializers/shared.py index 8ad54e61..b7609325 100644 --- a/backend/api/v1/employees/serializers/shared.py +++ b/backend/api/v1/employees/serializers/shared.py @@ -3,7 +3,18 @@ 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 employees.models import ( + EmployeeAction, + EmployeeSkillProgress, + Position, + Skill, + SkillExperienceEvent, +) +from employees.services.skill_progress import ( + confirmed_holder_count, + rank_payload, + rarity_payload, +) from rest_framework import serializers @@ -25,9 +36,129 @@ 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() + class Meta: model = Skill - fields = ["id", "name"] + fields = [ + "id", + "name", + "experience", + "rank", + "rank_display", + "next_rank", + "next_rank_display", + "next_rank_experience", + "experience_to_next_rank", + "rank_progress_percent", + "confirmed_holders", + "rarity", + "rarity_display", + ] + + def _progress(self, obj): + progress_by_skill = self.context.get("skill_progress_by_skill", {}) + return progress_by_skill.get(obj.id) + + def _rank(self, obj): + return rank_payload(getattr(self._progress(obj), "experience", 0)) + + def _rarity(self, obj): + count = getattr(obj, "confirmed_holders", None) + if count is None: + count = confirmed_holder_count(obj) + return rarity_payload(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"] + + +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 +214,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer): raise serializers.ValidationError( { "date_to": ( - "Дата окончания доступна только для временных " - "кадровых событий." + "Дата окончания доступна только для временных кадровых событий." ) } ) @@ -102,8 +232,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer): raise serializers.ValidationError( { "date_to": ( - "Дата окончания обязательна для временных " - "кадровых событий." + "Дата окончания обязательна для временных кадровых событий." ) } ) @@ -168,18 +297,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, } ) diff --git a/backend/api/v1/employees/views/employee_mixins/profile.py b/backend/api/v1/employees/views/employee_mixins/profile.py index 7553a5f9..686bd697 100644 --- a/backend/api/v1/employees/views/employee_mixins/profile.py +++ b/backend/api/v1/employees/views/employee_mixins/profile.py @@ -4,7 +4,26 @@ 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, + rarity_payload, + confirmed_holder_count, + rank_payload, + record_skill_experience, + skill_queryset_with_rarity, +) +from api.v1.employees.serializers import SkillExperienceEventSerializer +from rest_framework import status class EmployeeProfileMixin: @@ -18,7 +37,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( @@ -76,8 +103,132 @@ class EmployeeProfileMixin: skill, _ = Skill.objects.get_or_create(name=skill_name) 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\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 = rank_payload(progress.experience) + holder_count = getattr(skill, "confirmed_holders", None) + if holder_count is None: + holder_count = confirmed_holder_count(skill) + rarity = rarity_payload(holder_count) + available_at = None + if 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, + "experience": progress.experience, + **rank, + **rarity, + "can_endorse": ( + 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\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, + ) + + 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"}: diff --git a/backend/api/v1/employees/views/employee_mixins/query.py b/backend/api/v1/employees/views/employee_mixins/query.py index cd582065..f55cad56 100644 --- a/backend/api/v1/employees/views/employee_mixins/query.py +++ b/backend/api/v1/employees/views/employee_mixins/query.py @@ -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", diff --git a/backend/api/v1/employees/views/skills.py b/backend/api/v1/employees/views/skills.py index bf042923..f24d078a 100644 --- a/backend/api/v1/employees/views/skills.py +++ b/backend/api/v1/employees/views/skills.py @@ -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"] @@ -66,9 +67,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 +96,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) @@ -125,6 +122,28 @@ class SkillViewSet(viewsets.ModelViewSet): 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 diff --git a/backend/api/v1/tasks/serializers/tasks.py b/backend/api/v1/tasks/serializers/tasks.py index 12cd39fb..d6363c6a 100644 --- a/backend/api/v1/tasks/serializers/tasks.py +++ b/backend/api/v1/tasks/serializers/tasks.py @@ -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.all(), + 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: diff --git a/backend/api/v1/tasks/viewsets/boards.py b/backend/api/v1/tasks/viewsets/boards.py index 7946a16b..45d8255b 100644 --- a/backend/api/v1/tasks/viewsets/boards.py +++ b/backend/api/v1/tasks/viewsets/boards.py @@ -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( diff --git a/backend/api/v1/tasks/viewsets/tasks.py b/backend/api/v1/tasks/viewsets/tasks.py index 795eb95f..de4377c5 100644 --- a/backend/api/v1/tasks/viewsets/tasks.py +++ b/backend/api/v1/tasks/viewsets/tasks.py @@ -136,6 +136,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"] ) diff --git a/backend/employees/admin.py b/backend/employees/admin.py index f83eb418..0aa2b645 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -18,8 +18,10 @@ from .models import ( Department, DepartmentRole, EmployeeDepartment, + EmployeeSkillProgress, Position, Skill, + SkillExperienceEvent, LdapSyncState, ) @@ -1907,6 +1909,41 @@ class SkillAdmin(admin.ModelAdmin): search_fields = ("name",) +@admin.register(EmployeeSkillProgress) +class EmployeeSkillProgressAdmin(admin.ModelAdmin): + list_display = ("employee", "skill", "experience", "is_active", "updated_at") + list_filter = ("is_active", "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): diff --git a/backend/employees/migrations/0057_employeeskillprogress_skillexperienceevent_and_more.py b/backend/employees/migrations/0057_employeeskillprogress_skillexperienceevent_and_more.py new file mode 100644 index 00000000..ac003401 --- /dev/null +++ b/backend/employees/migrations/0057_employeeskillprogress_skillexperienceevent_and_more.py @@ -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, + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index af27072f..fcfd62a9 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -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: @@ -756,6 +702,124 @@ 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) + 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 +852,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 +892,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 +937,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 +980,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.""" diff --git a/backend/employees/services/skill_progress.py b/backend/employees/services/skill_progress.py new file mode 100644 index 00000000..8d7bca9d --- /dev/null +++ b/backend/employees/services/skill_progress.py @@ -0,0 +1,326 @@ +"""Ранги, редкость и журнал опыта навыков сотрудников.""" + +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 +TASK_SKILL_EXPERIENCE = 25 + + +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( + employee_progresses__is_active=True, + employee_progresses__experience__gte=100, + 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: + return ( + EmployeeSkillProgress.objects.filter( + skill=skill, + is_active=True, + experience__gte=100, + employee__is_active=True, + employee__is_hidden_in_ui=False, + employee__skills=skill, + ) + .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]: + 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: + """Начисляет опыт исполнителю и участникам завершённой задачи. + + Баллы получают только сотрудники, у которых навык уже добавлен в профиль. + Идемпотентный ключ исключает повторное начисление после повторного save(). + """ + + 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 + + employee_ids = set(task.participants.values_list("id", flat=True)) + if task.assignee_id: + employee_ids.add(task.assignee_id) + if not employee_ids: + return 0 + + awarded = 0 + for skill in task.skills.all(): + recipients = Employee.objects.filter( + id__in=employee_ids, + is_active=True, + skills=skill, + ).distinct() + for employee in recipients: + _, _, created = record_skill_experience( + employee=employee, + skill=skill, + points=TASK_SKILL_EXPERIENCE, + source_type=SkillExperienceEvent.Source.TASK, + actor=task.created_by, + source_id=str(task.id), + reason=f"Завершена задача «{task.title}»", + idempotency_key=( + f"task:{task.id}:employee:{employee.id}:skill:{skill.id}" + ), + metadata={"task_id": task.id, "task_title": task.title}, + ) + 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 diff --git a/backend/employees/signals/__init__.py b/backend/employees/signals/__init__.py index 0ede9700..f025c35a 100644 --- a/backend/employees/signals/__init__.py +++ b/backend/employees/signals/__init__.py @@ -13,7 +13,7 @@ from importlib import import_module from django.conf import settings -from . import common, birthday +from . import birthday, common, skills from .common import ( IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING, @@ -30,6 +30,7 @@ if getattr(settings, "LDAP_ENABLED", False): __all__ = [ "common", "birthday", + "skills", "IMMEDIATE_ACTION_MAPPING", "SCHEDULED_ACTION_MAPPING", "_apply_action_effects", @@ -39,6 +40,7 @@ else: __all__ = [ "common", "birthday", + "skills", "IMMEDIATE_ACTION_MAPPING", "SCHEDULED_ACTION_MAPPING", "_apply_action_effects", diff --git a/backend/employees/signals/skills.py b/backend/employees/signals/skills.py new file mode 100644 index 00000000..fde32f1c --- /dev/null +++ b/backend/employees/signals/skills.py @@ -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) diff --git a/backend/tasks/migrations/0040_task_skills.py b/backend/tasks/migrations/0040_task_skills.py new file mode 100644 index 00000000..f16ec974 --- /dev/null +++ b/backend/tasks/migrations/0040_task_skills.py @@ -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="Применяемые навыки", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 0a3642a3..2baa3f5f 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -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, diff --git a/backend/tasks/queries.py b/backend/tasks/queries.py index 1a55f481..f7697be4 100644 --- a/backend/tasks/queries.py +++ b/backend/tasks/queries.py @@ -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( diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py index 18d20366..03bc5375 100644 --- a/backend/tasks/signals.py +++ b/backend/tasks/signals.py @@ -74,6 +74,41 @@ 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 + return + instance._skill_was_completed = sender.objects.filter( + pk=instance.pk, + completed_at__isnull=False, + ).exists() + + +@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) + + def sync_after_commit(): + from employees.services.skill_progress import ( + award_task_skill_experience, + revoke_task_skill_experience, + ) + + if is_completed: + award_task_skill_experience(task_id) + else: + revoke_task_skill_experience(task_id, actor=actor) + + 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) diff --git a/backend/tests/api/v1/employees/test_skills.py b/backend/tests/api/v1/employees/test_skills.py index ad8e9d5b..02e71651 100644 --- a/backend/tests/api/v1/employees/test_skills.py +++ b/backend/tests/api/v1/employees/test_skills.py @@ -1,13 +1,25 @@ 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, + rank_payload, + rarity_payload, + revoke_task_skill_experience, +) +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 +94,133 @@ def test_create_allowed_for_regular_user(api_client): assert Skill.objects.filter(name="Go").exists() +@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_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 == 25 + + assert revoke_task_skill_experience(task.id, actor=employee) == 1 + progress.refresh_from_db() + assert progress.experience == 0 + + @pytest.mark.django_db def test_update_delete_permissions(api_client): user = _mk_user() diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 0b94f09d..e18a5a47 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -161,6 +161,7 @@ export default function ProfilePage() {
void handleAddSkill()} diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 1ca14b50..7f09762f 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -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 = { @@ -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([]); const [departments, setDepartments] = useState([]); const [taskLabels, setTaskLabels] = useState([]); + const [availableSkills, setAvailableSkills] = useState([]); const [loading, setLoading] = useState(true); const [boardLoading, setBoardLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -4457,6 +4462,7 @@ function TasksPageContent() { employeesData, departmentsData, labelsData, + skillsData, ] = await Promise.all([ requestedBoardId > 0 ? apiClient.getTaskBoard(requestedBoardId) @@ -4466,6 +4472,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 +4497,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 && @@ -5308,6 +5316,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 +6201,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; @@ -6374,6 +6384,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(); @@ -9682,6 +9701,26 @@ function TasksPageContent() {
) : null} + {viewTask.skills && viewTask.skills.length > 0 ? ( +
+
+ + Навыки задачи +
+
+ {viewTask.skills.map((skill) => ( + + {skill.name} + + ))} +
+
+ ) : null} + {attachmentsBadgeCount > 0 || attachmentsOpen ? (
+
+
+
+ + Навыки задачи +
+ {form.skill_ids.length ? ( + + {form.skill_ids.length} + + ) : null} +
+ + {availableSkills.length ? availableSkills.map((skill) => { + const selected = form.skill_ids.includes(skill.id); + return ( + + ); + }) : ( + Навыки пока не созданы + )} + +

+ После завершения задачи участники с этими навыками получат опыт. +

+
+
diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index 38ca22aa..ff85b0f1 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -517,6 +517,7 @@ export default function UserDetailPage() { emptyText="Контакты не указаны" /> void handleAddSkill()} @@ -527,6 +528,7 @@ export default function UserDetailPage() { error={skillsError} loading={skillsLoading} emptyText="Навыки не указаны" + onSkillUpdated={() => refreshPerson()} />
} diff --git a/frontend/src/components/users/ProfileSections.tsx b/frontend/src/components/users/ProfileSections.tsx index 44101b48..74b350e1 100644 --- a/frontend/src/components/users/ProfileSections.tsx +++ b/frontend/src/components/users/ProfileSections.tsx @@ -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,35 @@ export type ProfileInfoItem = { value: string; }; +const skillRarityStyles: Record = { + 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", +}; + +function SkillRankMark({ rank = "none", size = 14 }: { rank?: SkillRank; size?: number }) { + const props = { size, strokeWidth: 2.2, "aria-hidden": true }; + if (rank === "bronze") return ; + if (rank === "silver") return ; + if (rank === "gold") return ; + if (rank === "ruby") return ; + if (rank === "diamond") return ; + return ; +} + +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 +745,7 @@ export function ProfileWorkScheduleCard({ } export function ProfileSkillsInline({ + employeeId, title = "Навыки", inputValue, onInputChange, @@ -721,7 +759,9 @@ export function ProfileSkillsInline({ onRemoveSkill, removeDisabled, emptyText = "Навыки не указаны", + onSkillUpdated, }: { + employeeId: number; title?: string; inputValue: string; onInputChange: (value: string) => void; @@ -735,12 +775,58 @@ export function ProfileSkillsInline({ onRemoveSkill?: (skillId: number) => void; removeDisabled?: boolean; emptyText?: string; + onSkillUpdated?: () => unknown | Promise; }) { const [modalOpen, setModalOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); + const [progressOpen, setProgressOpen] = useState(false); + const [progressLoading, setProgressLoading] = useState(false); + const [progress, setProgress] = useState(null); + const [progressError, setProgressError] = useState(null); + const [endorsementReason, setEndorsementReason] = useState(""); + const [endorsementSaving, setEndorsementSaving] = useState(false); const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`; const helpId = `${listId}-help`; + 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 ( <>
@@ -752,12 +838,18 @@ export function ProfileSkillsInline({ > {skills.length ? ( skills.map((skill) => ( - 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 || "Редкость не определена"}`} > + {skill.name} - + )) ) : ( @@ -891,6 +983,126 @@ export function ProfileSkillsInline({ ) : null}
+ + { + if (endorsementSaving) return; + setProgressOpen(false); + setProgress(null); + setProgressError(null); + setEndorsementReason(""); + }} + title={progress?.skill_name || "Прогресс навыка"} + size="sm" + closeOnClickOutside={!endorsementSaving} + > + {progressLoading ? ( +
+ +
+ ) : progress ? ( +
+
+
+
+
+ +
+
+

{progress.rank_display}

+

+ {progress.rarity_display} · у {progress.confirmed_holders} сотрудников +

+
+
+ + {progress.experience} XP + +
+
+
+
+
+ {progress.rank_progress_percent}% текущего ранга + + {progress.next_rank_display + ? `До «${progress.next_rank_display}»: ${progress.experience_to_next_rank} XP` + : "Высший ранг"} + +
+
+ + {progress.can_endorse ? ( +
+