fix(stages): нормализовать aware-даты этапов к поясу оргединицы
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 <noreply@anthropic.com>
This commit is contained in:
parent
2a287480b6
commit
bc89789026
@ -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:
|
||||
|
||||
40
api/src/core/timezones.py
Normal file
40
api/src/core/timezones.py
Normal file
@ -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)
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user