feat: add skill ranks and progression

This commit is contained in:
lizerk 2026-08-25 13:28:43 +03:00
parent 9d25de9336
commit ae4eb4696a
27 changed files with 1715 additions and 133 deletions

View File

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

View File

@ -13,7 +13,7 @@ from employees.models import EmployeeDepartment, Position, Skill
from ..shared import (
EmployeeActionSerializer,
PositionBriefSerializer,
SkillSerializer,
serialize_employee_skills,
)
from .common import (
EmployeeAttendanceStatusSerializer,
@ -40,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 []:

View File

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

View File

@ -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,
}
)

View File

@ -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"}:

View File

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

View File

@ -3,7 +3,8 @@
from __future__ import annotations
from django.db import transaction
from employees.models import Skill
from employees.models import EmployeeSkillProgress, Skill
from employees.services.skill_progress import skill_queryset_with_rarity
from rest_framework import filters, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
@ -26,7 +27,7 @@ class SkillViewSet(viewsets.ModelViewSet):
- POST /skills/merge
"""
queryset = Skill.objects.all().order_by("name")
queryset = skill_queryset_with_rarity().order_by("name")
serializer_class = SkillSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
@ -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

View File

@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from api.v1.employees.serializers import EmployeeBriefSerializer
from api.v1.employees.serializers import EmployeeBriefSerializer, SkillSerializer
from communications.comments_helpers import get_comment_count
from tasks.access import (
user_can_access_task_board,
@ -15,6 +15,7 @@ from tasks.models import (
TaskLabel,
TaskLinkedObjectKind,
)
from employees.models import Skill
from .linked import TaskCoverSerializer
from .structure import TaskLabelSerializer
@ -52,6 +53,14 @@ class TaskSerializer(serializers.ModelSerializer):
required=False,
write_only=True,
)
skills = SkillSerializer(many=True, read_only=True)
skill_ids = serializers.PrimaryKeyRelatedField(
source="skills",
queryset=Skill.objects.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:

View File

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

View File

@ -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"]
)

View File

@ -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):

View File

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

View File

@ -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."""

View 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

View File

@ -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",

View File

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

View File

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

View File

@ -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,

View File

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

View File

@ -74,6 +74,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)

View 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()

View File

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

View File

@ -57,6 +57,7 @@ import {
RefreshCw,
Rows3,
Search,
Sparkles,
ShoppingCart,
Tag,
Trash2,
@ -108,6 +109,7 @@ import type {
Department,
Document,
Message,
Skill,
TaskActivity,
TaskAttachment,
TaskAutomation,
@ -152,6 +154,7 @@ type TaskFormState = {
priority: TaskPriority;
due_date: string;
label_ids: number[];
skill_ids: number[];
};
type BoardFormState = {
@ -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" />

View File

@ -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>
}

View File

@ -10,17 +10,22 @@ import {
type ReactNode,
} from "react";
import {
Award,
CalendarClock,
Check,
ChevronDown,
CircleHelp,
Clock3,
Crown,
Gem,
Info,
Link2,
Loader2,
Pencil,
Plus,
Shield,
Sparkles,
Trophy,
X,
} from "lucide-react";
import { apiClient } from "@/lib/api";
@ -31,7 +36,10 @@ import type {
EmployeeAttendanceStatus,
EmployeeDepartment,
EmployeePersonnelState,
EmployeeSkillProgress,
Skill,
SkillRank,
SkillRarity,
} from "@/types/api";
import { getEmployeeActionTone } from "@/lib/users/userDetailUtils";
@ -50,6 +58,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>
</>
);
}

View File

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

View File

@ -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;