Merge pull request 'phase-directions: направления в фазах, год в зависимости от года формы или проекта, фикс с колонками для редактирования' (#66) from phase-directions into test

Reviewed-on: #66
This commit is contained in:
tsygankoviva 2026-08-04 09:23:49 +03:00
commit b270c69fbd
11 changed files with 1427 additions and 18 deletions

View File

@ -0,0 +1,82 @@
import os
import re
from alembic import op
revision = "0014"
down_revision = "0013"
branch_labels = None
depends_on = None
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
def _find_dollar_tag(line: str) -> str | None:
m = _DOLLAR_TAG_RE.search(line.strip())
return m.group(0) if m else None
def _split_statements(sql: str) -> list[str]:
statements: list[str] = []
current: list[str] = []
in_dollar = False
dollar_tag: str | None = None
for line in sql.split("\n"):
stripped = line.strip()
if stripped.startswith("--"):
continue
if not in_dollar:
tag = _find_dollar_tag(stripped)
if tag and tag.endswith("$") and tag.startswith("$"):
dollar_tag = tag
in_dollar = True
current.append(line)
continue
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
after = stripped[len(dollar_tag):].strip()
if after == ";" or after == "":
in_dollar = False
dollar_tag = None
if after == ";":
current.append(line)
statements.append("\n".join(current))
current = []
continue
if not in_dollar and stripped.rstrip().endswith(";"):
current.append(line)
statements.append("\n".join(current))
current = []
continue
current.append(line)
remaining = "\n".join(current).strip()
if remaining:
statements.append(remaining)
return statements
def upgrade() -> None:
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0014_direction.sql")
with open(ddl_path) as f:
content = f.read()
statements = _split_statements(content)
for stmt in statements:
stripped = stmt.strip().rstrip(";").strip()
if not stripped:
continue
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
continue
op.execute(stripped)
def downgrade() -> None:
pass

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,7 @@ def timezone_from_offset(offset: str) -> datetime.timezone | None:
async def get_form_phases(
form_id: int,
sheet: str | None = None,
direction: str | None = None,
phase_code: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
@ -57,6 +58,7 @@ async def get_form_phases(
budget_form_id=form_id,
user=current_user,
sheet=sheet,
direction=direction,
phase_code=phase_code,
)
if phases:
@ -117,6 +119,7 @@ async def update_form_phase(
sheet: str,
phase_code: str,
body: FormPhaseUpdate,
direction: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[FormPhaseResponse]:
@ -129,6 +132,7 @@ async def update_form_phase(
phase = await fp_service.update(
budget_form=form,
sheet=sheet,
direction=direction,
phase_code=phase_code,
body=body,
user=current_user,
@ -155,6 +159,7 @@ async def delete_form_phase(
form_id: int,
sheet: str,
phase_code: str,
direction: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
@ -167,6 +172,7 @@ async def delete_form_phase(
deleted = await fp_service.delete(
budget_form_id=form_id,
sheet=sheet,
direction=direction,
phase_code=phase_code,
user=current_user,
)

View File

@ -22,5 +22,6 @@ class FormPhase(Base):
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
direction: Mapped[str] = mapped_column(String)
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View File

@ -22,6 +22,7 @@ class PhaseTemplate(Base):
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
direction: Mapped[str] = mapped_column(String)
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))

View File

@ -416,6 +416,7 @@ class FormPhaseCreate(BaseModel):
column_keys: list[str]
opens_at: datetime
closes_at: datetime
direction: str | None = None
class FormPhaseUpdate(BaseModel):

View File

@ -14,23 +14,33 @@ class FormPhaseRepository:
self,
budget_form_id: int,
sheet: str | None = None,
direction: str | None = None,
phase_code: str | None = None,
) -> list[FormPhase]:
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
if sheet is not None:
query = query.where(FormPhase.sheet == sheet)
query = query.where(
FormPhase.sheet == sheet,
FormPhase.direction.is_(None) if direction is None else FormPhase.direction == direction
)
if phase_code is not None:
query = query.where(FormPhase.phase_code == phase_code)
return (await self.db.execute(query)).scalars().all()
async def get(
self, budget_form_id: int, sheet: str, phase_code: str
self,
budget_form_id: int,
sheet: str,
direction: str | None,
phase_code: str,
) -> FormPhase | None:
query = (
select(FormPhase)
.where(FormPhase.budget_form_id == budget_form_id)
.where(FormPhase.sheet == sheet)
.where(FormPhase.phase_code == phase_code)
.where(FormPhase.direction.is_(None) if direction is None else FormPhase.direction == direction)
.limit(1)
)
return (await self.db.execute(query)).scalar_one_or_none()
@ -39,6 +49,7 @@ class FormPhaseRepository:
self,
budget_form_id: int,
sheet: str,
direction: str | None,
phase_code: str,
role: str,
column_keys: list[str],
@ -52,6 +63,7 @@ class FormPhaseRepository:
(v3.add_form_phase(
:budget_form_id,
:sheet,
:direction,
:phase_code,
:role,
:column_keys,
@ -63,6 +75,7 @@ class FormPhaseRepository:
{
"budget_form_id": budget_form_id,
"sheet": sheet,
"direction": direction,
"phase_code": phase_code,
"role": role,
"column_keys": column_keys,
@ -72,10 +85,14 @@ class FormPhaseRepository:
)
if result.scalar_one_or_none() is None:
return None
return await self.get(budget_form_id, sheet, phase_code)
return await self.get(budget_form_id, sheet, direction, phase_code)
async def update(
self, budget_form_id: int, sheet: str, phase_code: str, data: dict
self, budget_form_id: int,
sheet: str,
direction: str | None,
phase_code: str,
data: dict,
) -> FormPhase | None:
result = await self.db.execute(
text(
@ -84,6 +101,7 @@ class FormPhaseRepository:
(v3.upd_form_phase(
:budget_form_id,
:sheet,
:direction,
:phase_code,
:role,
:column_keys,
@ -95,6 +113,7 @@ class FormPhaseRepository:
{
"budget_form_id": budget_form_id,
"sheet": sheet,
"direction": direction,
"phase_code": phase_code,
"role": data.get("role"),
"column_keys": data.get("column_keys"),
@ -104,18 +123,19 @@ class FormPhaseRepository:
)
if result.scalar_one_or_none() is None:
return None
return await self.get(budget_form_id, sheet, phase_code)
return await self.get(budget_form_id, sheet, direction, phase_code)
async def delete(
self, budget_form_id: int, sheet: str, phase_code: str
self, budget_form_id: int, sheet: str, direction: str | None, phase_code: str
) -> bool:
result = await self.db.execute(
text(
"SELECT v3.del_form_phase(:budget_form_id, :sheet, :phase_code)"
"SELECT v3.del_form_phase(:budget_form_id, :sheet, :direction, :phase_code)"
),
{
"budget_form_id": budget_form_id,
"sheet": sheet,
"direction": direction,
"phase_code": phase_code,
},
)

View File

@ -50,7 +50,6 @@ class SheetRepository:
func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)"
else:
func_query = "v3.v_form_view(:form_id, :sheet, :sections)"
return (
await self.db.execute(
text(

View File

@ -21,20 +21,23 @@ class FormPhaseService:
budget_form_id: int,
user: AppUser,
sheet: str | None = None,
direction: str | None = None,
phase_code: str | None = None,
) -> list[FormPhase]:
return await self.fp_repo.get_list(
budget_form_id=budget_form_id,
sheet=sheet,
direction=direction,
phase_code=phase_code,
)
async def get(
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
self, budget_form_id: int, sheet: str, direction: str | None, phase_code: str, user: AppUser
) -> FormPhase | None:
return await self.fp_repo.get(
budget_form_id=budget_form_id,
sheet=sheet,
direction=direction,
phase_code=phase_code,
)
@ -43,12 +46,16 @@ class FormPhaseService:
form_phase: FormPhaseCreate | FormPhaseUpdate,
budget_form: BudgetForm,
sheet: str | None = None,
direction: str | None = None,
) -> None:
errors = {}
if budget_form.form_type.sheet_list:
sheet = sheet or form_phase.sheet
if sheet not in budget_form.form_type.sheet_list:
errors["sheet"] = [f"У формы нет листа {sheet}"]
if budget_form.form_type.sheet_list_with_directions:
if sheet:
sheet = {"sheet_name": sheet, "direction": direction}
else:
sheet = {"sheet_name": form_phase.sheet, "direction": form_phase.direction}
if sheet not in budget_form.form_type.sheet_list_with_directions:
errors["sheet"] = [f"У формы нет листа {sheet['sheet_name']}/{sheet['direction']}"]
if form_phase.column_keys and budget_form.form_type.section_list:
for column_key in form_phase.column_keys:
section = column_key.split(".")
@ -84,6 +91,7 @@ class FormPhaseService:
return await self.fp_repo.create(
budget_form_id=budget_form_id or budget_form.id,
sheet=body.sheet,
direction=body.direction,
phase_code=body.phase_code,
role=body.role,
column_keys=body.column_keys,
@ -94,6 +102,7 @@ class FormPhaseService:
async def update(
self,
sheet: str,
direction: str | None,
phase_code: str,
body: FormPhaseUpdate,
user: AppUser,
@ -113,6 +122,7 @@ class FormPhaseService:
form_phase=body,
budget_form=budget_form,
sheet=sheet,
direction=direction,
)
data = body.model_dump(exclude_unset=True)
@ -120,20 +130,27 @@ class FormPhaseService:
return await self.fp_repo.get(
budget_form_id=budget_form_id or budget_form.id,
sheet=sheet,
direction=direction,
phase_code=phase_code,
)
return await self.fp_repo.update(
budget_form_id=budget_form_id or budget_form.id,
sheet=sheet,
direction=direction,
phase_code=phase_code,
data=data,
)
async def delete(
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
self, budget_form_id: int,
sheet: str,
direction: str | None,
phase_code: str,
user: AppUser,
) -> bool:
return await self.fp_repo.delete(
budget_form_id=budget_form_id,
sheet=sheet,
direction=direction,
phase_code=phase_code,
)

View File

@ -149,7 +149,7 @@ def test_form_phases_update(client, admin_tokens):
def test_form_phases_delete(client, admin_tokens):
response = client.delete(
"/api/v1/stages/form/1/AHR/test",
"/api/v1/stages/form/1/AHR/test?direction=Support",
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200

View File

@ -23,9 +23,10 @@ INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_or
(1,1,88,'1234',NULL,NULL,NULL,NULL,NULL,'2026-05-06 16:58:05.793083',NULL,'2026-06-22 17:41:54.255313','Support');
SELECT setval('v3.budget_line_id_seq', 1);
INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at) VALUES
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03'),
(4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05');
INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at,direction) VALUES
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03','Support'),
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03','Development'),
(4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05',NULL);
INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,rent_contract_num,rent_end_date) VALUES
(1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',1,'','','',NULL),