561 lines
20 KiB
Python
561 lines
20 KiB
Python
from datetime import timedelta
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
from django.utils import timezone
|
||
from rest_framework.test import APIClient
|
||
|
||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||
from commerce.models import ExternalProject, SourceSystem
|
||
from finance.enums import ApprovalStatus
|
||
from finance.models import EmployeePayRate, PayrollPeriod
|
||
from projects.models import (
|
||
AllocationReminder,
|
||
AllocationRevision,
|
||
DailyAllocation,
|
||
Project,
|
||
ProjectBoardLink,
|
||
ProjectExternalBinding,
|
||
ProjectMembership,
|
||
TimeAllocation,
|
||
)
|
||
from tasks.models import Task, TaskBoard, TaskColumn, TaskLinkedObject
|
||
|
||
pytestmark = pytest.mark.django_db
|
||
ROOT = "/api/v1/projects/"
|
||
|
||
|
||
@pytest.fixture
|
||
def setup_project(user_factory):
|
||
manager, worker, outsider = user_factory(staff=True), user_factory(), user_factory()
|
||
p = Project.objects.create(
|
||
name="Локальная разработка",
|
||
manager=manager,
|
||
created_by=manager,
|
||
status="active",
|
||
)
|
||
ProjectMembership.objects.create(project=p, employee=worker)
|
||
return manager, worker, outsider, p, APIClient()
|
||
|
||
|
||
def attendance(user, day, hours=8, status="success"):
|
||
run = AttendanceAnalysisRun.objects.create(
|
||
employee=user, period_start=day, period_end=day, status=status
|
||
)
|
||
return AttendanceRecord.objects.create(
|
||
employee=user, date=day, analysis_run=run, work_hours=hours
|
||
)
|
||
|
||
|
||
def payload(p, day=None, **kwargs):
|
||
return {
|
||
"date": str(day or timezone.localdate()),
|
||
"version": 0,
|
||
"status": "confirmed",
|
||
"allocations": [
|
||
{"project": p.pk, "percent": "60"},
|
||
{"activity": "department", "percent": "40"},
|
||
],
|
||
**kwargs,
|
||
}
|
||
|
||
|
||
def test_visibility_and_management(setup_project):
|
||
manager, worker, outsider, p, c = setup_project
|
||
c.force_authenticate(outsider)
|
||
assert c.get(f"{ROOT}{p.pk}/").status_code == 404
|
||
assert c.get(ROOT).data["count"] == 0
|
||
assert c.post(ROOT, {"name": "Нельзя", "manager": outsider.pk}).status_code == 403
|
||
c.force_authenticate(worker)
|
||
worker_detail = c.get(f"{ROOT}{p.pk}/")
|
||
assert worker_detail.status_code == 200
|
||
assert worker_detail.data["can_view_finances"] is False
|
||
assert c.patch(f"{ROOT}{p.pk}/", {"name": "Нельзя"}).status_code == 403
|
||
assert (
|
||
c.post(f"{ROOT}{p.pk}/members/", {"employee": outsider.pk}).status_code == 403
|
||
)
|
||
assert c.get(f"{ROOT}{p.pk}/bindings/").status_code == 403
|
||
assert c.get(f"{ROOT}{p.pk}/finances/").status_code == 403
|
||
c.force_authenticate(manager)
|
||
assert c.get(f"{ROOT}{p.pk}/").data["can_view_finances"] is True
|
||
assert c.delete(f"{ROOT}{p.pk}/").status_code == 400
|
||
assert c.patch(f"{ROOT}{p.pk}/", {"status": "archived"}).status_code == 200
|
||
assert p.events.filter(action="Карточка изменена").exists()
|
||
|
||
|
||
def test_project_links_real_tasks_and_boards_with_access_checks(setup_project):
|
||
manager, worker, outsider, project, client = setup_project
|
||
board = TaskBoard.objects.create(name="Рабочая доска", created_by=manager)
|
||
column = TaskColumn.objects.create(board=board, name="В работе")
|
||
task = Task.objects.create(
|
||
board=board,
|
||
column=column,
|
||
title="Связанная задача",
|
||
created_by=manager,
|
||
)
|
||
client.force_authenticate(manager)
|
||
|
||
task_url = f"{ROOT}{project.pk}/task-links/"
|
||
task_response = client.post(task_url, {"task_id": task.pk}, format="json")
|
||
assert task_response.status_code == 200
|
||
assert task_response.data[0]["id"] == task.pk
|
||
assert task_response.data[0]["board_id"] == board.pk
|
||
assert TaskLinkedObject.objects.filter(task=task, object_id=project.pk).exists()
|
||
|
||
board_url = f"{ROOT}{project.pk}/board-links/"
|
||
board_response = client.post(board_url, {"board_id": board.pk}, format="json")
|
||
assert board_response.status_code == 200
|
||
assert board_response.data == [
|
||
{
|
||
"id": ProjectBoardLink.objects.get().pk,
|
||
"board_id": board.pk,
|
||
"name": "Рабочая доска",
|
||
"url": f"/tasks?board={board.pk}",
|
||
}
|
||
]
|
||
|
||
client.force_authenticate(worker)
|
||
assert client.get(task_url).status_code == 200
|
||
assert client.get(board_url).status_code == 200
|
||
assert client.post(task_url, {"task_id": task.pk}).status_code == 403
|
||
|
||
client.force_authenticate(outsider)
|
||
assert client.get(task_url).status_code == 404
|
||
assert client.get(board_url).status_code == 404
|
||
|
||
client.force_authenticate(manager)
|
||
task_link_id = TaskLinkedObject.objects.get(task=task, object_id=project.pk).pk
|
||
assert client.delete(task_url, {"id": task_link_id}, format="json").data == []
|
||
board_link_id = ProjectBoardLink.objects.get().pk
|
||
assert client.delete(board_url, {"id": board_link_id}, format="json").data == []
|
||
|
||
|
||
def test_creation_and_dates(setup_project):
|
||
manager, _, _, _, c = setup_project
|
||
c.force_authenticate(manager)
|
||
assert (
|
||
c.post(
|
||
ROOT,
|
||
{
|
||
"name": "Новый",
|
||
"manager": manager.pk,
|
||
"starts_on": "2026-02-01",
|
||
"ends_on": "2026-01-01",
|
||
},
|
||
format="json",
|
||
).status_code
|
||
== 400
|
||
)
|
||
r = c.post(ROOT, {"name": "Новый", "manager": manager.pk}, format="json")
|
||
assert r.status_code == 201, r.data
|
||
p = Project.objects.get(pk=r.data["id"])
|
||
assert p.memberships.get().employee == manager
|
||
assert not p.bindings.exists()
|
||
|
||
|
||
def test_snapshots_hours_and_conflicts(setup_project):
|
||
_, worker, _, p, c = setup_project
|
||
c.force_authenticate(worker)
|
||
record = attendance(worker, timezone.localdate())
|
||
r = c.post(ROOT + "my-day/", payload(p), format="json")
|
||
assert r.status_code == 200, r.data
|
||
assert r.data["hours"] == Decimal("8.000") and r.data["version"] == 1
|
||
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 400
|
||
record.work_hours = 6
|
||
record.save()
|
||
assert c.get(ROOT + "my-day/").data["attendance_changed"] is True
|
||
r = c.post(ROOT + "my-day/", payload(p, version=1), format="json")
|
||
assert r.status_code == 200 and r.data["hours"] == Decimal("6.000")
|
||
assert AllocationRevision.objects.count() == 2
|
||
assert (
|
||
AllocationRevision.objects.order_by("id").first().snapshot["hours"] == "8.000"
|
||
)
|
||
assert DailyAllocation.objects.count() == 1
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"allocations",
|
||
[
|
||
[{"percent": "100"}],
|
||
[{"activity": "training", "percent": "101"}],
|
||
[{"activity": "training", "percent": "99"}],
|
||
[
|
||
{"activity": "training", "percent": "50"},
|
||
{"activity": "training", "percent": "50"},
|
||
],
|
||
],
|
||
)
|
||
def test_invalid_distribution_is_atomic(setup_project, allocations):
|
||
_, worker, _, p, c = setup_project
|
||
c.force_authenticate(worker)
|
||
assert (
|
||
c.post(
|
||
ROOT + "my-day/", payload(p, allocations=allocations), format="json"
|
||
).status_code
|
||
== 400
|
||
)
|
||
assert not DailyAllocation.objects.exists()
|
||
|
||
|
||
def test_membership_and_future_dates(setup_project):
|
||
_, worker, outsider, p, c = setup_project
|
||
c.force_authenticate(outsider)
|
||
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 400
|
||
c.force_authenticate(worker)
|
||
assert (
|
||
c.post(
|
||
ROOT + "my-day/",
|
||
payload(p, day=timezone.localdate() + timedelta(days=1)),
|
||
format="json",
|
||
).status_code
|
||
== 400
|
||
)
|
||
p.memberships.update(starts_on=timezone.localdate() + timedelta(days=1))
|
||
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 400
|
||
|
||
|
||
def test_unknown_hours_drafts_and_private_reports(setup_project):
|
||
manager, worker, _, p, c = setup_project
|
||
c.force_authenticate(worker)
|
||
r = c.post(ROOT + "my-day/", payload(p), format="json")
|
||
assert r.status_code == 200 and r.data["hours"] is None
|
||
report = c.get(f"{ROOT}{p.pk}/time-report/").data
|
||
assert (
|
||
report["unknown_days"] == 1
|
||
and report["confirmed_hours"] == 0
|
||
and report["scope"] == "self"
|
||
)
|
||
c.force_authenticate(manager)
|
||
attendance(manager, timezone.localdate(), 10)
|
||
r = c.post(
|
||
ROOT + "my-day/",
|
||
payload(p, status="draft", allocations=[{"project": p.pk, "percent": "30"}]),
|
||
format="json",
|
||
)
|
||
assert r.status_code == 200
|
||
report = c.get(f"{ROOT}{p.pk}/time-report/").data
|
||
assert report["draft_days"] == 1 and report["confirmed_hours"] == 0
|
||
c.force_authenticate(worker)
|
||
assert len(c.get(f"{ROOT}{p.pk}/time-report/").data["rows"]) == 1
|
||
|
||
|
||
def test_reminders_and_snooze(setup_project):
|
||
_, worker, _, p, c = setup_project
|
||
c.force_authenticate(worker)
|
||
yesterday = timezone.localdate() - timedelta(days=1)
|
||
attendance(worker, yesterday)
|
||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] == yesterday
|
||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
|
||
assert (
|
||
c.post(ROOT + "reminder/", {"snooze": True}, format="json").status_code == 200
|
||
)
|
||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
|
||
AllocationReminder.objects.update(
|
||
snoozed_until=timezone.now() - timedelta(seconds=1)
|
||
)
|
||
assert (
|
||
c.post(ROOT + "my-day/", payload(p, day=yesterday), format="json").status_code
|
||
== 200
|
||
)
|
||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
|
||
|
||
|
||
def test_failed_attendance_excluded(setup_project):
|
||
_, worker, _, _, c = setup_project
|
||
c.force_authenticate(worker)
|
||
yesterday = timezone.localdate() - timedelta(days=1)
|
||
attendance(worker, yesterday, status="failed")
|
||
assert c.get(ROOT + "my-day/", {"date": str(yesterday)}).data["hours"] is None
|
||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
|
||
|
||
|
||
def test_binding_identity(setup_project):
|
||
manager, _, _, p, c = setup_project
|
||
c.force_authenticate(manager)
|
||
a = SourceSystem.objects.create(code="test-project-a", name="A")
|
||
b = SourceSystem.objects.create(code="test-project-b", name="B")
|
||
data = {"source": a.pk, "external_id": "same", "external_name": "Внешний"}
|
||
assert c.post(f"{ROOT}{p.pk}/bindings/", data, format="json").status_code == 200
|
||
assert c.post(f"{ROOT}{p.pk}/bindings/", data, format="json").status_code == 400
|
||
data["source"] = b.pk
|
||
assert c.post(f"{ROOT}{p.pk}/bindings/", data, format="json").status_code == 200
|
||
assert (
|
||
ProjectExternalBinding.objects.count() == 2
|
||
and ExternalProject.objects.count() == 0
|
||
)
|
||
|
||
|
||
def test_resource_urls(setup_project):
|
||
manager, _, _, p, c = setup_project
|
||
c.force_authenticate(manager)
|
||
for url in [
|
||
"https://evil.test/tasks",
|
||
"//evil.test/tasks",
|
||
"/profile",
|
||
"/tasks\\evil",
|
||
]:
|
||
assert (
|
||
c.post(
|
||
f"{ROOT}{p.pk}/resources/",
|
||
{"title": "X", "kind": "task", "url": url},
|
||
format="json",
|
||
).status_code
|
||
== 400
|
||
)
|
||
assert (
|
||
c.post(
|
||
f"{ROOT}{p.pk}/resources/",
|
||
{"title": "Задача", "kind": "task", "url": "/tasks?task=12"},
|
||
format="json",
|
||
).status_code
|
||
== 200
|
||
)
|
||
|
||
|
||
def test_finances_use_source_scoped_bindings_and_valid_payments(setup_project):
|
||
from commerce.services.moysklad_projects import normalize_project_payment
|
||
|
||
manager, approver, _, p, c = setup_project
|
||
c.force_authenticate(manager)
|
||
source = SourceSystem.objects.create(code="finance-project-a", name="A")
|
||
other = SourceSystem.objects.create(code="finance-project-b", name="B")
|
||
|
||
def pay(src, ref, project_id, amount, applicable=True):
|
||
return normalize_project_payment(
|
||
src,
|
||
{
|
||
"id": ref,
|
||
"moment": f"{timezone.localdate()} 12:00:00",
|
||
"sum": amount,
|
||
"name": ref,
|
||
"applicable": applicable,
|
||
"project": {"id": project_id, "name": project_id},
|
||
},
|
||
"paymentout",
|
||
timezone.now(),
|
||
)
|
||
|
||
pay(source, "a", "first", 10000)
|
||
pay(source, "b", "second", 20000)
|
||
pay(other, "c", "first", 90000)
|
||
pay(source, "cancelled", "first", 50000, False)
|
||
stale = pay(source, "stale", "first", 60000)
|
||
stale.missing_from_source = True
|
||
stale.save()
|
||
for ref in ["first", "second"]:
|
||
ProjectExternalBinding.objects.create(project=p, source=source, external_id=ref)
|
||
today = timezone.localdate()
|
||
PayrollPeriod.objects.create(
|
||
code="project-finance-current",
|
||
date_from=today,
|
||
date_to=today,
|
||
currency="RUB",
|
||
created_by=manager,
|
||
)
|
||
EmployeePayRate.objects.create(
|
||
employee=manager,
|
||
amount=Decimal("800"),
|
||
effective_from=today,
|
||
status=ApprovalStatus.APPROVED,
|
||
created_by=manager,
|
||
approved_by=approver,
|
||
approved_at=timezone.now(),
|
||
)
|
||
attendance_record = attendance(manager, today)
|
||
attendance_record.expected_hours = 8
|
||
attendance_record.save(update_fields=["expected_hours"])
|
||
allocation_day = DailyAllocation.objects.create(
|
||
employee=manager,
|
||
date=today,
|
||
status="confirmed",
|
||
hours=Decimal("8"),
|
||
attendance_record=attendance_record,
|
||
)
|
||
TimeAllocation.objects.create(
|
||
day=allocation_day,
|
||
project=p,
|
||
percent=Decimal("50"),
|
||
)
|
||
r = c.get(f"{ROOT}{p.pk}/finances/")
|
||
assert r.status_code == 200, r.data
|
||
assert r.data["paid"] == Decimal("300")
|
||
assert r.data["payment_count"] == 2
|
||
assert r.data["external_project_count"] == 2
|
||
assert r.data["labor_hours"] == Decimal("4")
|
||
assert r.data["labor_cost"] == Decimal("400")
|
||
assert r.data["preliminary_labor_cost"] == Decimal("400")
|
||
assert r.data["exact_labor_cost"] == 0
|
||
assert r.data["labor_cost_status"] == "preliminary"
|
||
assert r.data["labor_hour_cost"] == Decimal("100")
|
||
assert r.data["labor_cost_complete"] is True
|
||
assert r.data["labor"][0]["employee_id"] == manager.pk
|
||
|
||
|
||
def test_confirmed_time_is_weighted_by_daily_hours(setup_project):
|
||
_, worker, _, p, c = setup_project
|
||
c.force_authenticate(worker)
|
||
for offset, hours in [(0, 8), (1, 4)]:
|
||
day = timezone.localdate() - timedelta(days=offset)
|
||
attendance(worker, day, hours)
|
||
assert (
|
||
c.post(ROOT + "my-day/", payload(p, day=day), format="json").status_code
|
||
== 200
|
||
)
|
||
r = c.get(
|
||
f"{ROOT}{p.pk}/time-report/",
|
||
{
|
||
"from": str(timezone.localdate() - timedelta(days=1)),
|
||
"to": str(timezone.localdate()),
|
||
},
|
||
)
|
||
assert r.data["confirmed_hours"] == Decimal("7.2")
|
||
|
||
|
||
def test_archived_project_preserves_existing_day_history(setup_project):
|
||
_, worker, _, p, c = setup_project
|
||
c.force_authenticate(worker)
|
||
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 200
|
||
p.status = "archived"
|
||
p.save()
|
||
assert c.get(ROOT + "my-day/").data["projects"] == []
|
||
assert (
|
||
c.post(ROOT + "my-day/", payload(p, version=1), format="json").status_code
|
||
== 200
|
||
)
|
||
assert (
|
||
c.post(
|
||
ROOT + "my-day/",
|
||
payload(p, day=timezone.localdate() - timedelta(days=1)),
|
||
format="json",
|
||
).status_code
|
||
== 400
|
||
)
|
||
|
||
|
||
def test_mine_filter_and_malformed_requests(setup_project):
|
||
manager, _, outsider, p, c = setup_project
|
||
Project.objects.create(name="Другой", manager=outsider, created_by=outsider)
|
||
c.force_authenticate(manager)
|
||
assert c.get(ROOT).data["count"] == 2
|
||
assert c.get(ROOT, {"mine": "true"}).data["count"] == 1
|
||
assert (
|
||
c.post(
|
||
f"{ROOT}{p.pk}/members/", {"employee": "invalid"}, format="json"
|
||
).status_code
|
||
== 400
|
||
)
|
||
assert (
|
||
c.delete(f"{ROOT}{p.pk}/stages/", {"id": "invalid"}, format="json").status_code
|
||
== 400
|
||
)
|
||
assert (
|
||
c.get(
|
||
f"{ROOT}{p.pk}/finances/", {"from": "9999-12-30", "to": "9999-12-31"}
|
||
).status_code
|
||
== 400
|
||
)
|
||
assert c.get(f"{ROOT}{p.pk}/finances/", {"currency": "РУБ"}).status_code == 400
|
||
|
||
|
||
def test_external_candidates_only_return_active_unbound_records(setup_project):
|
||
manager, _, _, project, client = setup_project
|
||
client.force_authenticate(manager)
|
||
source = SourceSystem.objects.create(code="candidate-source", name="МойСклад")
|
||
available = ExternalProject.objects.create(
|
||
source=source,
|
||
external_id="available",
|
||
name="Рабочий проект",
|
||
code="PROJECT-7",
|
||
description="Проект из внешней системы",
|
||
)
|
||
bound = ExternalProject.objects.create(
|
||
source=source, external_id="bound", name="Уже связан"
|
||
)
|
||
ExternalProject.objects.create(
|
||
source=source, external_id="archived", name="Архив", archived=True
|
||
)
|
||
ProjectExternalBinding.objects.create(
|
||
project=project,
|
||
source=source,
|
||
external_id=bound.external_id,
|
||
external_name=bound.name,
|
||
)
|
||
|
||
result = client.get(f"{ROOT}external-candidates/")
|
||
assert result.status_code == 200
|
||
assert result.data["count"] == 1
|
||
assert result.data["results"][0]["external_id"] == available.external_id
|
||
assert result["Cache-Control"] == "private, no-store"
|
||
detail = client.get(
|
||
f"{ROOT}external-project/",
|
||
{"source": source.pk, "external_id": available.external_id},
|
||
)
|
||
assert detail.status_code == 200
|
||
assert detail.data["external_name"] == available.name
|
||
assert detail.data["code"] == "PROJECT-7"
|
||
assert detail.data["description"] == "Проект из внешней системы"
|
||
assert detail.data["payment_count"] == 0
|
||
assert detail.data["payments"] == []
|
||
assert detail.data["sale_count"] == 0
|
||
assert detail.data["bound"] is False
|
||
assert (
|
||
client.get(
|
||
f"{ROOT}external-project/",
|
||
{"source": source.pk, "external_id": "missing"},
|
||
).status_code
|
||
== 404
|
||
)
|
||
assert (
|
||
client.get(f"{ROOT}external-candidates/", {"q": "не найдено"}).data["count"]
|
||
== 0
|
||
)
|
||
|
||
|
||
def test_create_from_external_is_atomic_and_preserves_source(setup_project):
|
||
manager, _, outsider, _, client = setup_project
|
||
source = SourceSystem.objects.create(code="import-source", name="МойСклад")
|
||
external = ExternalProject.objects.create(
|
||
source=source, external_id="external-project", name="Разработка автомата"
|
||
)
|
||
data = {
|
||
"source": source.pk,
|
||
"external_id": external.external_id,
|
||
"name": "Локальная разработка автомата",
|
||
"description": "Внутренняя карточка",
|
||
"customer": "Заказчик",
|
||
"manager": manager.pk,
|
||
"status": "active",
|
||
}
|
||
|
||
client.force_authenticate(outsider)
|
||
assert client.post(f"{ROOT}from-external/", data, format="json").status_code == 403
|
||
client.force_authenticate(manager)
|
||
result = client.post(f"{ROOT}from-external/", data, format="json")
|
||
assert result.status_code == 201, result.data
|
||
local = Project.objects.get(pk=result.data["id"])
|
||
assert local.name == data["name"]
|
||
assert local.bindings.get().external_id == external.external_id
|
||
assert local.memberships.get().employee == manager
|
||
assert local.events.get().action == "Проект создан из внешнего проекта"
|
||
external.refresh_from_db()
|
||
assert external.name == "Разработка автомата"
|
||
|
||
project_count = Project.objects.count()
|
||
duplicate = client.post(f"{ROOT}from-external/", data, format="json")
|
||
assert duplicate.status_code == 400
|
||
assert Project.objects.count() == project_count
|
||
|
||
|
||
def test_project_options_only_offer_manageable_projects(setup_project):
|
||
manager, worker, outsider, project, client = setup_project
|
||
other = Project.objects.create(name="Чужой", manager=outsider, created_by=outsider)
|
||
client.force_authenticate(worker)
|
||
options = client.get(f"{ROOT}options/").data
|
||
assert options["manageable_projects"] == []
|
||
client.force_authenticate(manager)
|
||
options = client.get(f"{ROOT}options/").data
|
||
assert {row["id"] for row in options["manageable_projects"]} == {
|
||
project.pk,
|
||
other.pk,
|
||
}
|