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

View File

@ -22,5 +22,6 @@ class FormPhase(Base):
phase_code: Mapped[str] = mapped_column(String, primary_key=True) phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code")) role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text)) column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
direction: Mapped[str] = mapped_column(String)
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_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) phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code")) role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text)) column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
direction: Mapped[str] = mapped_column(String)
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=False)) opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
closes_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] column_keys: list[str]
opens_at: datetime opens_at: datetime
closes_at: datetime closes_at: datetime
direction: str | None = None
class FormPhaseUpdate(BaseModel): class FormPhaseUpdate(BaseModel):

View File

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

View File

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

View File

@ -21,20 +21,23 @@ class FormPhaseService:
budget_form_id: int, budget_form_id: int,
user: AppUser, user: AppUser,
sheet: str | None = None, sheet: str | None = None,
direction: str | None = None,
phase_code: str | None = None, phase_code: str | None = None,
) -> list[FormPhase]: ) -> list[FormPhase]:
return await self.fp_repo.get_list( return await self.fp_repo.get_list(
budget_form_id=budget_form_id, budget_form_id=budget_form_id,
sheet=sheet, sheet=sheet,
direction=direction,
phase_code=phase_code, phase_code=phase_code,
) )
async def get( 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: ) -> FormPhase | None:
return await self.fp_repo.get( return await self.fp_repo.get(
budget_form_id=budget_form_id, budget_form_id=budget_form_id,
sheet=sheet, sheet=sheet,
direction=direction,
phase_code=phase_code, phase_code=phase_code,
) )
@ -43,12 +46,16 @@ class FormPhaseService:
form_phase: FormPhaseCreate | FormPhaseUpdate, form_phase: FormPhaseCreate | FormPhaseUpdate,
budget_form: BudgetForm, budget_form: BudgetForm,
sheet: str | None = None, sheet: str | None = None,
direction: str | None = None,
) -> None: ) -> None:
errors = {} errors = {}
if budget_form.form_type.sheet_list: if budget_form.form_type.sheet_list_with_directions:
sheet = sheet or form_phase.sheet if sheet:
if sheet not in budget_form.form_type.sheet_list: sheet = {"sheet_name": sheet, "direction": direction}
errors["sheet"] = [f"У формы нет листа {sheet}"] 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: if form_phase.column_keys and budget_form.form_type.section_list:
for column_key in form_phase.column_keys: for column_key in form_phase.column_keys:
section = column_key.split(".") section = column_key.split(".")
@ -84,6 +91,7 @@ class FormPhaseService:
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,
direction=body.direction,
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,
@ -94,6 +102,7 @@ class FormPhaseService:
async def update( async def update(
self, self,
sheet: str, sheet: str,
direction: str | None,
phase_code: str, phase_code: str,
body: FormPhaseUpdate, body: FormPhaseUpdate,
user: AppUser, user: AppUser,
@ -113,6 +122,7 @@ class FormPhaseService:
form_phase=body, form_phase=body,
budget_form=budget_form, budget_form=budget_form,
sheet=sheet, sheet=sheet,
direction=direction,
) )
data = body.model_dump(exclude_unset=True) data = body.model_dump(exclude_unset=True)
@ -120,20 +130,27 @@ class FormPhaseService:
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,
sheet=sheet, sheet=sheet,
direction=direction,
phase_code=phase_code, phase_code=phase_code,
) )
return await self.fp_repo.update( return await self.fp_repo.update(
budget_form_id=budget_form_id or budget_form.id, budget_form_id=budget_form_id or budget_form.id,
sheet=sheet, sheet=sheet,
direction=direction,
phase_code=phase_code, phase_code=phase_code,
data=data, data=data,
) )
async def delete( 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: ) -> bool:
return await self.fp_repo.delete( return await self.fp_repo.delete(
budget_form_id=budget_form_id, budget_form_id=budget_form_id,
sheet=sheet, sheet=sheet,
direction=direction,
phase_code=phase_code, 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): def test_form_phases_delete(client, admin_tokens):
response = client.delete( response = client.delete(
"/api/v1/stages/form/1/AHR/test", "/api/v1/stages/form/1/AHR/test?direction=Support",
headers=_auth_headers(admin_tokens), headers=_auth_headers(admin_tokens),
) )
assert response.status_code == 200 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'); (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); 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 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'), (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03','Support'),
(4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05'); (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 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), (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),