feat: weight task skill rewards by role
This commit is contained in:
parent
efc93b03e9
commit
53871a8f37
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user