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