From bc897890267d180425d2956ae0702049eea99788 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:13:13 +0400 Subject: [PATCH] =?UTF-8?q?fix(stages):=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20aware-=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D1=8B=20=D1=8D=D1=82=D0=B0=D0=BF=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=BE=D1=8F=D1=81=D1=83=20=D0=BE=D1=80=D0=B3?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=BD=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asyncpg не принимает aware-datetime для CAST AS TIMESTAMP WITHOUT TIME ZONE (400). Даты переводятся в пояс оргединицы и становятся naive, SQL приписывает тот же offset. timezone_from_offset вынесен в src/core/timezones.py, добавлен to_org_local_naive. Aware-дата при NULL utc_offset оргединицы даёт 422. Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/form_phases.py | 27 +++++++---------- api/src/core/timezones.py | 40 +++++++++++++++++++++++++ api/src/services/form3_phase_service.py | 12 ++++++-- api/src/services/form_phase_service.py | 12 ++++++-- 4 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 api/src/core/timezones.py 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,