feat: add skill ranks and progression
This commit is contained in:
parent
9d25de9336
commit
ae4eb4696a
@ -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,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 []:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@ -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<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 = 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<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,
|
||||
)
|
||||
|
||||
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"]
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"]
|
||||
)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
@ -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."""
|
||||
|
||||
326
backend/employees/services/skill_progress.py
Normal file
326
backend/employees/services/skill_progress.py
Normal file
@ -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
|
||||
@ -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",
|
||||
|
||||
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)
|
||||
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="Применяемые навыки",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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,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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 = {
|
||||
@ -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);
|
||||
@ -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<number>();
|
||||
@ -9682,6 +9701,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 +11286,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" />
|
||||
|
||||
@ -517,6 +517,7 @@ export default function UserDetailPage() {
|
||||
emptyText="Контакты не указаны"
|
||||
/>
|
||||
<ProfileSkillsInline
|
||||
employeeId={person.id}
|
||||
inputValue={skillName}
|
||||
onInputChange={setSkillName}
|
||||
onSubmit={() => void handleAddSkill()}
|
||||
@ -527,6 +528,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,35 @@ 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",
|
||||
};
|
||||
|
||||
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 +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<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 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>
|
||||
@ -752,12 +838,18 @@ export function ProfileSkillsInline({
|
||||
>
|
||||
{skills.length ? (
|
||||
skills.map((skill) => (
|
||||
<span
|
||||
<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 || "Редкость не определена"}`}
|
||||
>
|
||||
<SkillRankMark rank={skill.rank} />
|
||||
{skill.name}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="app-text-muted whitespace-nowrap px-1 text-xs">
|
||||
@ -891,6 +983,126 @@ export function ProfileSkillsInline({
|
||||
) : null}
|
||||
</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.rarity_display} · у {progress.confirmed_holders} сотрудников
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="app-badge shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold">
|
||||
{progress.experience} XP
|
||||
</span>
|
||||
</div>
|
||||
<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}% текущего ранга</span>
|
||||
<span>
|
||||
{progress.next_rank_display
|
||||
? `До «${progress.next_rank_display}»: ${progress.experience_to_next_rank} XP`
|
||||
: "Высший ранг"}
|
||||
</span>
|
||||
</div>
|
||||
</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">История опыта</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)]">
|
||||
Начислений пока нет
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -3,6 +3,55 @@ export interface Skill {
|
||||
id: number;
|
||||
name: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export type SkillRank = "none" | "bronze" | "silver" | "gold" | "ruby" | "diamond";
|
||||
export type SkillRarity = "unrated" | "common" | "uncommon" | "rare" | "epic" | "legendary";
|
||||
|
||||
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;
|
||||
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;
|
||||
can_endorse: boolean;
|
||||
endorsement_points?: number;
|
||||
endorsement_role?: string;
|
||||
endorsement_available_at?: string | null;
|
||||
events: SkillExperienceEvent[];
|
||||
}
|
||||
|
||||
export interface EmployeeAction {
|
||||
@ -318,6 +367,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