AURORA-1832: aware-даты этапов форм и проектов (часовой пояс) #228
@ -25,22 +25,11 @@ from src.api.v1.deps import (
|
|||||||
require_admin,
|
require_admin,
|
||||||
)
|
)
|
||||||
from src.db.session import get_db
|
from src.db.session import get_db
|
||||||
|
from src.core.timezones import timezone_from_offset
|
||||||
|
|
||||||
router = APIRouter(prefix="/stages", tags=["stages"])
|
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}")
|
@router.get("/form/{form_id}")
|
||||||
async def get_form_phases(
|
async def get_form_phases(
|
||||||
form_id: int,
|
form_id: int,
|
||||||
@ -100,6 +89,7 @@ async def create_form_phase(
|
|||||||
budget_form=form,
|
budget_form=form,
|
||||||
body=body,
|
body=body,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
|
utc_offset=org_unit.utc_offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = FormPhaseResponse.model_validate(phase)
|
result = FormPhaseResponse.model_validate(phase)
|
||||||
@ -128,6 +118,9 @@ async def update_form_phase(
|
|||||||
if not form:
|
if not form:
|
||||||
raise HTTPException(404, "Форма не найдена")
|
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)
|
fp_service = FormPhaseService(db)
|
||||||
phase = await fp_service.update(
|
phase = await fp_service.update(
|
||||||
budget_form=form,
|
budget_form=form,
|
||||||
@ -136,13 +129,12 @@ async def update_form_phase(
|
|||||||
phase_code=phase_code,
|
phase_code=phase_code,
|
||||||
body=body,
|
body=body,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
|
utc_offset=org_unit.utc_offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not phase:
|
if not phase:
|
||||||
raise HTTPException(404, "Этап не найден")
|
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)
|
result = FormPhaseResponse.model_validate(phase)
|
||||||
|
|
||||||
if org_unit.utc_offset:
|
if org_unit.utc_offset:
|
||||||
@ -255,6 +247,7 @@ async def create_form3_phase(
|
|||||||
report_id=report.id,
|
report_id=report.id,
|
||||||
body=body,
|
body=body,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
|
utc_offset=org_unit.utc_offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = Form3PhaseResponse.model_validate(phase)
|
result = Form3PhaseResponse.model_validate(phase)
|
||||||
@ -293,19 +286,21 @@ async def update_form3_phase(
|
|||||||
if not report:
|
if not report:
|
||||||
raise HTTPException(404, "Форма не найдена")
|
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)
|
f3p_service = Form3PhaseService(db)
|
||||||
phase = await f3p_service.update(
|
phase = await f3p_service.update(
|
||||||
report_id=report.id,
|
report_id=report.id,
|
||||||
phase_code=phase_code,
|
phase_code=phase_code,
|
||||||
body=body,
|
body=body,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
|
utc_offset=org_unit.utc_offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not phase:
|
if not phase:
|
||||||
raise HTTPException(404, "Этап не найден")
|
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)
|
result = Form3PhaseResponse.model_validate(phase)
|
||||||
|
|
||||||
if org_unit.utc_offset:
|
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.form3_phase import Form3Phase
|
||||||
from src.db.models.form_type import FormType, FormTypeEnum
|
from src.db.models.form_type import FormType, FormTypeEnum
|
||||||
from src.repository.form3_phase_repository import Form3PhaseRepository
|
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.core.errors import AccessDeniedException, ValidationsError
|
||||||
from src.db.models.role import UserRoleEnum
|
from src.db.models.role import UserRoleEnum
|
||||||
from src.db.models.app_user import AppUser
|
from src.db.models.app_user import AppUser
|
||||||
@ -55,6 +56,7 @@ class Form3PhaseService:
|
|||||||
report_id: int,
|
report_id: int,
|
||||||
body: Form3PhaseCreate,
|
body: Form3PhaseCreate,
|
||||||
user: AppUser,
|
user: AppUser,
|
||||||
|
utc_offset: str | None = None,
|
||||||
) -> Form3Phase:
|
) -> Form3Phase:
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
if user.role_id != UserRoleEnum.ADMIN:
|
||||||
raise AccessDeniedException
|
raise AccessDeniedException
|
||||||
@ -62,13 +64,15 @@ class Form3PhaseService:
|
|||||||
self._validate_form3_phase(
|
self._validate_form3_phase(
|
||||||
form_phase=body,
|
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(
|
return await self.f3p_repo.create(
|
||||||
report_id=report_id,
|
report_id=report_id,
|
||||||
phase_code=body.phase_code,
|
phase_code=body.phase_code,
|
||||||
role=body.role,
|
role=body.role,
|
||||||
column_keys=body.column_keys,
|
column_keys=body.column_keys,
|
||||||
opens_at=body.opens_at,
|
opens_at=opens_at,
|
||||||
closes_at=body.closes_at,
|
closes_at=closes_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def update(
|
async def update(
|
||||||
@ -77,6 +81,7 @@ class Form3PhaseService:
|
|||||||
phase_code: str,
|
phase_code: str,
|
||||||
body: Form3PhaseUpdate,
|
body: Form3PhaseUpdate,
|
||||||
user: AppUser,
|
user: AppUser,
|
||||||
|
utc_offset: str | None = None,
|
||||||
) -> Form3Phase:
|
) -> Form3Phase:
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
if user.role_id != UserRoleEnum.ADMIN:
|
||||||
raise AccessDeniedException
|
raise AccessDeniedException
|
||||||
@ -86,6 +91,9 @@ class Form3PhaseService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
data = body.model_dump(exclude_unset=True)
|
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:
|
if not data:
|
||||||
return await self.f3p_repo.get(
|
return await self.f3p_repo.get(
|
||||||
report_id=report_id,
|
report_id=report_id,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.core.timezones import to_org_local_naive
|
||||||
from src.core.errors import AccessDeniedException, ValidationsError
|
from src.core.errors import AccessDeniedException, ValidationsError
|
||||||
from src.db.models.budget_form import BudgetForm
|
from src.db.models.budget_form import BudgetForm
|
||||||
from src.db.models.role import UserRoleEnum
|
from src.db.models.role import UserRoleEnum
|
||||||
@ -74,6 +75,7 @@ class FormPhaseService:
|
|||||||
user: AppUser,
|
user: AppUser,
|
||||||
budget_form_id: int | None = None,
|
budget_form_id: int | None = None,
|
||||||
budget_form: BudgetForm | None = None,
|
budget_form: BudgetForm | None = None,
|
||||||
|
utc_offset: str | None = None,
|
||||||
) -> FormPhase:
|
) -> FormPhase:
|
||||||
assert budget_form or budget_form_id
|
assert budget_form or budget_form_id
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
if user.role_id != UserRoleEnum.ADMIN:
|
||||||
@ -88,6 +90,8 @@ class FormPhaseService:
|
|||||||
form_phase=body,
|
form_phase=body,
|
||||||
budget_form=budget_form,
|
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(
|
return await self.fp_repo.create(
|
||||||
budget_form_id=budget_form_id or budget_form.id,
|
budget_form_id=budget_form_id or budget_form.id,
|
||||||
sheet=body.sheet,
|
sheet=body.sheet,
|
||||||
@ -95,8 +99,8 @@ class FormPhaseService:
|
|||||||
phase_code=body.phase_code,
|
phase_code=body.phase_code,
|
||||||
role=body.role,
|
role=body.role,
|
||||||
column_keys=body.column_keys,
|
column_keys=body.column_keys,
|
||||||
opens_at=body.opens_at,
|
opens_at=opens_at,
|
||||||
closes_at=body.closes_at,
|
closes_at=closes_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def update(
|
async def update(
|
||||||
@ -108,6 +112,7 @@ class FormPhaseService:
|
|||||||
user: AppUser,
|
user: AppUser,
|
||||||
budget_form_id: int | None = None,
|
budget_form_id: int | None = None,
|
||||||
budget_form: BudgetForm | None = None,
|
budget_form: BudgetForm | None = None,
|
||||||
|
utc_offset: str | None = None,
|
||||||
) -> FormPhase:
|
) -> FormPhase:
|
||||||
assert budget_form or budget_form_id
|
assert budget_form or budget_form_id
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
if user.role_id != UserRoleEnum.ADMIN:
|
||||||
@ -126,6 +131,9 @@ class FormPhaseService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
data = body.model_dump(exclude_unset=True)
|
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:
|
if not data:
|
||||||
return await self.fp_repo.get(
|
return await self.fp_repo.get(
|
||||||
budget_form_id=budget_form_id or budget_form.id,
|
budget_form_id=budget_form_id or budget_form.id,
|
||||||
|
|||||||
@ -326,3 +326,46 @@ def test_form3_phases_delete(client, admin_tokens):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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)
|
||||||
|
|||||||
130
api/tests/unit/test_form_phase_service_tz.py
Normal file
130
api/tests/unit/test_form_phase_service_tz.py
Normal file
@ -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)}
|
||||||
Loading…
x
Reference in New Issue
Block a user