feat: weight task skill rewards by role

This commit is contained in:
lizerk 2026-08-25 14:57:15 +03:00
parent efc93b03e9
commit 53871a8f37
3 changed files with 136 additions and 13 deletions

View File

@ -29,7 +29,8 @@ SKILL_RANKS = (
)
ENDORSEMENT_COOLDOWN_DAYS = 90
TASK_SKILL_EXPERIENCE = 25
TASK_ASSIGNEE_SKILL_EXPERIENCE = 30
TASK_PARTICIPANT_SKILL_EXPERIENCE = 15
def skill_queryset_with_rarity(queryset=None):
@ -265,10 +266,13 @@ def endorsement_available_at(
def award_task_skill_experience(task_id: int) -> int:
"""Начисляет опыт исполнителю и участникам завершённой задачи.
"""Начисляет опыт по снимку ролей на момент завершения задачи.
Баллы получают только сотрудники, у которых навык уже добавлен в профиль.
Идемпотентный ключ исключает повторное начисление после повторного save().
Исполнитель получает больше участника. Создатель задачи, автоматически
добавленный в participants, не получает опыт, если не является исполнителем.
Время завершения в ключе сохраняет отдельную историю каждого повторного
завершения, а повторный вызов для одного снимка остаётся идемпотентным.
"""
from tasks.models import Task
@ -282,32 +286,53 @@ def award_task_skill_experience(task_id: int) -> int:
if task is None:
return 0
employee_ids = set(task.participants.values_list("id", flat=True))
reward_roles = {
employee_id: "participant"
for employee_id in task.participants.values_list("id", flat=True)
if employee_id not in {task.created_by_id, task.assignee_id}
}
if task.assignee_id:
employee_ids.add(task.assignee_id)
if not employee_ids:
reward_roles[task.assignee_id] = "assignee"
if not reward_roles:
return 0
completion_key = task.completed_at.isoformat()
awarded = 0
for skill in task.skills.all():
recipients = Employee.objects.filter(
id__in=employee_ids,
id__in=reward_roles,
is_active=True,
skills=skill,
).distinct()
for employee in recipients:
reward_role = reward_roles[employee.id]
is_assignee = reward_role == "assignee"
points = (
TASK_ASSIGNEE_SKILL_EXPERIENCE
if is_assignee
else TASK_PARTICIPANT_SKILL_EXPERIENCE
)
role_display = "Исполнитель" if is_assignee else "Участник"
_, _, created = record_skill_experience(
employee=employee,
skill=skill,
points=TASK_SKILL_EXPERIENCE,
points=points,
source_type=SkillExperienceEvent.Source.TASK,
actor=task.created_by,
source_id=str(task.id),
reason=f"Завершена задача «{task.title}»",
reason=f"Завершена задача «{task.title}» — {role_display.lower()}",
idempotency_key=(
f"task:{task.id}:employee:{employee.id}:skill:{skill.id}"
f"task:{task.id}:completed:{completion_key}:"
f"employee:{employee.id}:skill:{skill.id}"
),
metadata={"task_id": task.id, "task_title": task.title},
metadata={
"task_id": task.id,
"task_title": task.title,
"completed_at": completion_key,
"reward_role": reward_role,
"reward_role_display": role_display,
"points": points,
},
)
awarded += int(created)
return awarded

View File

@ -1,3 +1,5 @@
from datetime import timedelta
import pytest
from django.contrib.auth.models import Permission
from django.urls import reverse
@ -214,13 +216,109 @@ def test_task_skill_experience_is_idempotent_and_revocable():
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 progress.experience == 30
event = progress.experience_events.get()
assert event.metadata["reward_role"] == "assignee"
assert event.metadata["points"] == 30
assert revoke_task_skill_experience(task.id, actor=employee) == 1
progress.refresh_from_db()
assert progress.experience == 0
@pytest.mark.django_db
def test_task_skill_experience_snapshots_roles_for_each_completion():
creator = _mk_user()
assignee = _mk_user()
participant = _mk_user()
skill = Skill.objects.create(name="PostgreSQL")
creator.skills.add(skill)
assignee.skills.add(skill)
participant.skills.add(skill)
board = TaskBoard.objects.create(name="Роли", created_by=creator)
done = TaskColumn.objects.create(
board=board,
name="Готово",
position=1000,
is_done=True,
)
task = Task.objects.create(
board=board,
column=done,
title="Смена исполнителя",
created_by=creator,
assignee=assignee,
)
task.participants.add(participant)
task.skills.add(skill)
assert award_task_skill_experience(task.id) == 2
assert EmployeeSkillProgress.objects.get(
employee=assignee,
skill=skill,
).experience == 30
assert EmployeeSkillProgress.objects.get(
employee=participant,
skill=skill,
).experience == 15
creator_progress = EmployeeSkillProgress.objects.get(
employee=creator,
skill=skill,
)
assert creator_progress.experience == 0
assert not creator_progress.experience_events.filter(
source_type=SkillExperienceEvent.Source.TASK,
).exists()
task.assignee = participant
task.save(update_fields=["assignee", "updated_at"])
assert SkillExperienceEvent.objects.filter(
source_type=SkillExperienceEvent.Source.TASK,
source_id=str(task.id),
revoked_at__isnull=True,
).count() == 2
assert EmployeeSkillProgress.objects.get(
employee=assignee,
skill=skill,
).experience == 30
assert revoke_task_skill_experience(task.id, actor=creator) == 2
first_completed_at = task.completed_at
Task.objects.filter(id=task.id).update(
completed_at=first_completed_at + timedelta(minutes=1),
)
task.refresh_from_db()
assert award_task_skill_experience(task.id) == 2
assignee_progress = EmployeeSkillProgress.objects.get(
employee=assignee,
skill=skill,
)
participant_progress = EmployeeSkillProgress.objects.get(
employee=participant,
skill=skill,
)
assert assignee_progress.experience == 15
assert participant_progress.experience == 30
task_events = SkillExperienceEvent.objects.filter(
source_type=SkillExperienceEvent.Source.TASK,
source_id=str(task.id),
)
assert task_events.count() == 4
assert task_events.filter(revoked_at__isnull=False).count() == 2
assert task_events.filter(
revoked_at__isnull=True,
metadata__reward_role="assignee",
points=30,
).count() == 1
assert task_events.filter(
revoked_at__isnull=True,
metadata__reward_role="participant",
points=15,
).count() == 1
@pytest.mark.django_db
def test_update_delete_permissions(api_client):
user = _mk_user()

View File

@ -1065,7 +1065,7 @@ export function ProfileSkillsInline({
))}
</div>
<p className="app-text-muted mt-2 text-[11px] leading-relaxed">
Завершённая задача даёт 25 XP. Подтверждение коллеги — 5–15 XP в зависимости от его роли.
Завершённая задача даёт исполнителю 30 XP, участнику — 15 XP. Подтверждение коллеги — 5–15 XP в зависимости от его роли.
</p>
</section>