diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py index f43048e..359bcc3 100644 --- a/api/src/api/v1/form_phases.py +++ b/api/src/api/v1/form_phases.py @@ -25,22 +25,11 @@ from src.api.v1.deps import ( require_admin, ) from src.db.session import get_db +from src.core.timezones import timezone_from_offset router = APIRouter(prefix="/stages", tags=["stages"]) -def timezone_from_offset(offset: str) -> datetime.timezone | None: - if not offset: - return None - - hours = int(offset[1:]) - return datetime.timezone( - datetime.timedelta( - hours=hours if offset[0] == '+' else -hours - ) - ) - - @router.get("/form/{form_id}") async def get_form_phases( form_id: int, @@ -100,6 +89,7 @@ async def create_form_phase( budget_form=form, body=body, user=current_user, + utc_offset=org_unit.utc_offset, ) result = FormPhaseResponse.model_validate(phase) @@ -128,6 +118,9 @@ async def update_form_phase( if not form: raise HTTPException(404, "Форма не найдена") + org_unit_service = OrgUnitService(db) + org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id) + fp_service = FormPhaseService(db) phase = await fp_service.update( budget_form=form, @@ -136,13 +129,12 @@ async def update_form_phase( phase_code=phase_code, body=body, user=current_user, + utc_offset=org_unit.utc_offset, ) if not phase: raise HTTPException(404, "Этап не найден") - org_unit_service = OrgUnitService(db) - org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id) result = FormPhaseResponse.model_validate(phase) if org_unit.utc_offset: @@ -255,6 +247,7 @@ async def create_form3_phase( report_id=report.id, body=body, user=current_user, + utc_offset=org_unit.utc_offset, ) result = Form3PhaseResponse.model_validate(phase) @@ -293,19 +286,21 @@ async def update_form3_phase( if not report: raise HTTPException(404, "Форма не найдена") + org_unit_service = OrgUnitService(db) + org_unit = await org_unit_service.get(user=current_user, org_unit_id=report.project.org_unit_id) + f3p_service = Form3PhaseService(db) phase = await f3p_service.update( report_id=report.id, phase_code=phase_code, body=body, user=current_user, + utc_offset=org_unit.utc_offset, ) if not phase: raise HTTPException(404, "Этап не найден") - org_unit_service = OrgUnitService(db) - org_unit = await org_unit_service.get(user=current_user, org_unit_id=report.project.org_unit_id) result = Form3PhaseResponse.model_validate(phase) if org_unit.utc_offset: diff --git a/api/src/core/timezones.py b/api/src/core/timezones.py new file mode 100644 index 0000000..1b1d6fc --- /dev/null +++ b/api/src/core/timezones.py @@ -0,0 +1,40 @@ +import datetime + +from fastapi import HTTPException + + +def timezone_from_offset(offset: str) -> datetime.timezone | None: + """Строка вида '+03' / '-05' (org_unit.utc_offset) -> datetime.timezone.""" + if not offset: + return None + + hours = int(offset[1:]) + return datetime.timezone( + datetime.timedelta( + hours=hours if offset[0] == '+' else -hours + ) + ) + + +def to_org_local_naive( + dt: datetime.datetime, utc_offset: str | None +) -> datetime.datetime: + """Приводит дату к местному naive-времени оргединицы. + + Naive-значение возвращается без изменений (оно уже местное). Aware-значение + переводится в пояс оргединицы, после чего tzinfo отбрасывается: SQL-функции + (v3.add_form_phase и др.) затем приписывают тот же offset, и момент времени + сохраняется. Если у оргединицы нет utc_offset, а дата aware - 422. + """ + if dt.tzinfo is None: + return dt + tz = timezone_from_offset(utc_offset) if utc_offset else None + if tz is None: + raise HTTPException( + status_code=422, + detail=( + "Дата передана с часовым поясом, но у оргединицы формы не " + "задан utc_offset. Передайте дату без часового пояса." + ), + ) + return dt.astimezone(tz).replace(tzinfo=None) diff --git a/api/src/services/form3_phase_service.py b/api/src/services/form3_phase_service.py index 969fd14..caca682 100644 --- a/api/src/services/form3_phase_service.py +++ b/api/src/services/form3_phase_service.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.form3_phase import Form3Phase from src.db.models.form_type import FormType, FormTypeEnum from src.repository.form3_phase_repository import Form3PhaseRepository +from src.core.timezones import to_org_local_naive from src.core.errors import AccessDeniedException, ValidationsError from src.db.models.role import UserRoleEnum from src.db.models.app_user import AppUser @@ -55,6 +56,7 @@ class Form3PhaseService: report_id: int, body: Form3PhaseCreate, user: AppUser, + utc_offset: str | None = None, ) -> Form3Phase: if user.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException @@ -62,13 +64,15 @@ class Form3PhaseService: self._validate_form3_phase( form_phase=body, ) + opens_at = to_org_local_naive(body.opens_at, utc_offset) + closes_at = to_org_local_naive(body.closes_at, utc_offset) return await self.f3p_repo.create( report_id=report_id, phase_code=body.phase_code, role=body.role, column_keys=body.column_keys, - opens_at=body.opens_at, - closes_at=body.closes_at, + opens_at=opens_at, + closes_at=closes_at, ) async def update( @@ -77,6 +81,7 @@ class Form3PhaseService: phase_code: str, body: Form3PhaseUpdate, user: AppUser, + utc_offset: str | None = None, ) -> Form3Phase: if user.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException @@ -86,6 +91,9 @@ class Form3PhaseService: ) data = body.model_dump(exclude_unset=True) + for key in ("opens_at", "closes_at"): + if data.get(key) is not None: + data[key] = to_org_local_naive(data[key], utc_offset) if not data: return await self.f3p_repo.get( report_id=report_id, diff --git a/api/src/services/form_phase_service.py b/api/src/services/form_phase_service.py index 5ca14dc..abc613b 100644 --- a/api/src/services/form_phase_service.py +++ b/api/src/services/form_phase_service.py @@ -1,5 +1,6 @@ from sqlalchemy.ext.asyncio import AsyncSession +from src.core.timezones import to_org_local_naive from src.core.errors import AccessDeniedException, ValidationsError from src.db.models.budget_form import BudgetForm from src.db.models.role import UserRoleEnum @@ -74,6 +75,7 @@ class FormPhaseService: user: AppUser, budget_form_id: int | None = None, budget_form: BudgetForm | None = None, + utc_offset: str | None = None, ) -> FormPhase: assert budget_form or budget_form_id if user.role_id != UserRoleEnum.ADMIN: @@ -88,6 +90,8 @@ class FormPhaseService: form_phase=body, budget_form=budget_form, ) + opens_at = to_org_local_naive(body.opens_at, utc_offset) + closes_at = to_org_local_naive(body.closes_at, utc_offset) return await self.fp_repo.create( budget_form_id=budget_form_id or budget_form.id, sheet=body.sheet, @@ -95,8 +99,8 @@ class FormPhaseService: phase_code=body.phase_code, role=body.role, column_keys=body.column_keys, - opens_at=body.opens_at, - closes_at=body.closes_at, + opens_at=opens_at, + closes_at=closes_at, ) async def update( @@ -108,6 +112,7 @@ class FormPhaseService: user: AppUser, budget_form_id: int | None = None, budget_form: BudgetForm | None = None, + utc_offset: str | None = None, ) -> FormPhase: assert budget_form or budget_form_id if user.role_id != UserRoleEnum.ADMIN: @@ -126,6 +131,9 @@ class FormPhaseService: ) data = body.model_dump(exclude_unset=True) + for key in ("opens_at", "closes_at"): + if data.get(key) is not None: + data[key] = to_org_local_naive(data[key], utc_offset) if not data: return await self.fp_repo.get( budget_form_id=budget_form_id or budget_form.id, diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py index 9a6938b..adffca8 100644 --- a/api/tests/integration/test_form_phases_api_smoke.py +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -326,3 +326,46 @@ def test_form3_phases_delete(client, admin_tokens): ) assert response.status_code == 200 + + +def _instant(value: str): + from datetime import datetime + + return datetime.fromisoformat(value) + + +def test_form_phases_create_aware_dates_keep_instant(client, admin_tokens): + body = { + "sheet": "AHR", + "phase_code": "tz_aware_phase", + "role": "DFIP", + "column_keys": ["plan.q1"], + "opens_at": "2026-10-26T10:00:00+03:00", + "closes_at": "2026-10-27T10:00:00+03:00", + "direction": None, + } + headers = _auth_headers(admin_tokens) + response = client.post("/api/v1/stages/form/4", json=body, headers=headers) + try: + assert response.status_code == 200, response.text + result = response.json()["result"] + assert _instant(result["opens_at"]) == _instant(body["opens_at"]) + assert _instant(result["closes_at"]) == _instant(body["closes_at"]) + + # GET -> PATCH тем же значением не меняет момент времени + listed = client.get( + "/api/v1/stages/form/4?sheet=AHR&phase_code=tz_aware_phase", headers=headers + ).json()["result"] + assert len(listed) == 1 + patch = client.patch( + "/api/v1/stages/form/4/AHR/tz_aware_phase", + json={"opens_at": listed[0]["opens_at"], "closes_at": listed[0]["closes_at"]}, + headers=headers, + ) + assert patch.status_code == 200, patch.text + patched = patch.json()["result"] + assert patched["opens_at"] == listed[0]["opens_at"] + assert patched["closes_at"] == listed[0]["closes_at"] + assert _instant(patched["opens_at"]) == _instant(body["opens_at"]) + finally: + client.delete("/api/v1/stages/form/4/AHR/tz_aware_phase", headers=headers) diff --git a/api/tests/unit/test_form_phase_service_tz.py b/api/tests/unit/test_form_phase_service_tz.py new file mode 100644 index 0000000..019a58a --- /dev/null +++ b/api/tests/unit/test_form_phase_service_tz.py @@ -0,0 +1,130 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from src.core.timezones import timezone_from_offset, to_org_local_naive +from src.db.models.role import UserRoleEnum +from src.domain.schemas import ( + Form3PhaseCreate, + Form3PhaseUpdate, + FormPhaseCreate, + FormPhaseUpdate, +) +from src.services.form3_phase_service import Form3PhaseService +from src.services.form_phase_service import FormPhaseService + + +@pytest.mark.parametrize( + "value, offset, expected", + [ + (datetime(2026, 10, 26, 7, 0, tzinfo=timezone.utc), "+05", datetime(2026, 10, 26, 12, 0)), + ( + datetime(2026, 10, 26, 10, 0, tzinfo=timezone(timedelta(hours=3))), + "+05", + datetime(2026, 10, 26, 12, 0), + ), + (datetime(2026, 10, 26, 10, 0), "+05", datetime(2026, 10, 26, 10, 0)), + (datetime(2026, 10, 26, 10, 0), None, datetime(2026, 10, 26, 10, 0)), + (datetime(2026, 10, 26, 10, 0, tzinfo=timezone.utc), "-03", datetime(2026, 10, 26, 7, 0)), + ], +) +def test_to_org_local_naive(value, offset, expected): + result = to_org_local_naive(value, offset) + assert result == expected + assert result.tzinfo is None + + +def test_to_org_local_naive_keeps_instant(): + value = datetime(2026, 10, 26, 10, 0, tzinfo=timezone(timedelta(hours=3))) + local = to_org_local_naive(value, "+05") + assert local.replace(tzinfo=timezone_from_offset("+05")) == value + + +def test_to_org_local_naive_aware_without_offset_is_422(): + with pytest.raises(HTTPException) as exc: + to_org_local_naive(datetime(2026, 10, 26, 10, 0, tzinfo=timezone.utc), None) + assert exc.value.status_code == 422 + + +def _admin(): + return SimpleNamespace(role_id=UserRoleEnum.ADMIN) + + +def _form_service(): + service = FormPhaseService(db=AsyncMock()) + service.fp_repo = AsyncMock() + form = SimpleNamespace( + id=1, + form_type=SimpleNamespace(section_list=["plan"], sheet_list_with_directions=None), + ) + return service, form + + +MSK = timezone(timedelta(hours=3)) + + +@pytest.mark.asyncio +async def test_form_phase_create_normalizes_to_org_local(): + service, form = _form_service() + body = FormPhaseCreate( + sheet="AHR", phase_code="p", role="DFIP", column_keys=["plan.q1"], + opens_at=datetime(2026, 10, 26, 10, 0, tzinfo=MSK), + closes_at=datetime(2026, 10, 27, 10, 0), + ) + await service.create(body=body, user=_admin(), budget_form=form, utc_offset="+05") + kwargs = service.fp_repo.create.await_args.kwargs + assert kwargs["opens_at"] == datetime(2026, 10, 26, 12, 0) + assert kwargs["opens_at"].tzinfo is None + assert kwargs["closes_at"] == datetime(2026, 10, 27, 10, 0) + + +@pytest.mark.asyncio +async def test_form_phase_update_normalizes_only_given_fields(): + service, form = _form_service() + body = FormPhaseUpdate(opens_at=datetime(2026, 10, 26, 10, 0, tzinfo=MSK)) + await service.update( + sheet="AHR", direction=None, phase_code="p", body=body, + user=_admin(), budget_form=form, utc_offset="+05", + ) + data = service.fp_repo.update.await_args.kwargs["data"] + assert data == {"opens_at": datetime(2026, 10, 26, 12, 0)} + + +@pytest.mark.asyncio +async def test_form_phase_create_aware_without_offset_is_422(): + service, form = _form_service() + body = FormPhaseCreate( + sheet="AHR", phase_code="p", role="DFIP", column_keys=["plan.q1"], + opens_at=datetime(2026, 10, 26, 10, 0, tzinfo=MSK), + closes_at=datetime(2026, 10, 27, 10, 0), + ) + with pytest.raises(HTTPException) as exc: + await service.create(body=body, user=_admin(), budget_form=form, utc_offset=None) + assert exc.value.status_code == 422 + service.fp_repo.create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_form3_phase_create_and_update_normalize(): + service = Form3PhaseService(db=AsyncMock()) + service.f3p_repo = AsyncMock() + body = Form3PhaseCreate( + report_type="PLAN", year=2026, phase_code="p", role="DFIP", column_keys=[], + opens_at=datetime(2026, 10, 26, 10, 0, tzinfo=MSK), + closes_at=datetime(2026, 10, 27, 10, 0, tzinfo=MSK), + ) + await service.create(report_id=1, body=body, user=_admin(), utc_offset="+05") + kwargs = service.f3p_repo.create.await_args.kwargs + assert kwargs["opens_at"] == datetime(2026, 10, 26, 12, 0) + assert kwargs["closes_at"] == datetime(2026, 10, 27, 12, 0) + + await service.update( + report_id=1, phase_code="p", + body=Form3PhaseUpdate(closes_at=datetime(2026, 10, 27, 10, 0, tzinfo=MSK)), + user=_admin(), utc_offset="+05", + ) + data = service.f3p_repo.update.await_args.kwargs["data"] + assert data == {"closes_at": datetime(2026, 10, 27, 12, 0)}