Corp/backend/tests/projects/test_projects.py

559 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 AttendanceRecord, AttendanceAnalysisRun
from commerce.models import SourceSystem, ExternalProject
from finance.enums import ApprovalStatus
from finance.models import EmployeePayRate, PayrollPeriod
from projects.models import (
Project,
ProjectMembership,
ProjectExternalBinding,
DailyAllocation,
AllocationRevision,
AllocationReminder,
TimeAllocation,
ProjectBoardLink,
)
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,
}