fix(forms): перенумеровать миграцию 1936 в 0056, пересобрать del_budget_line поверх 0053, починить тесты
Миграция 0050_budget_line_delete_owner_guard -> 0056 (down 0055, стек поверх 0055 AURORA-1938). Тело v3.del_budget_line = актуальное тело из 0053_form2_ahr_source_sheet (AURORA-1907: запрет удаления АХР-строк с source_sheet IS NOT NULL, ручные удаляются) + только owner-guard; раньше копия опиралась на 0040 и откатывала 1907. Тесты: доступ к форме id=1 даёт отдельная пара исполнителей isp2/isp3 (isp1 больше не добавляется в org 1 — это ломало тесты «исполнитель из чужой организации»); окно фазы DFIP в фикстуре продлено (истекло 2026-06-06, add_line возвращал 403); маркер internal_order не приходит в INPUT-строке add_line — берём единственную INPUT-строку; добавлен тест 1907 (строка из сателлита не удаляется напрямую). Тест «владелец удаляет свою строку» помечен xfail(strict): add_budget_line не заполняет budget_line.created_by (O-1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7b3a4ae6ae
commit
d1922c9354
@ -1,14 +1,14 @@
|
|||||||
from sql_migration import run_sql_file_migration
|
from sql_migration import run_sql_file_migration
|
||||||
|
|
||||||
|
|
||||||
revision = "0050"
|
revision = "0056"
|
||||||
down_revision = "0049"
|
down_revision = "0055"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
run_sql_file_migration("0050_budget_line_delete_owner_guard.sql")
|
run_sql_file_migration("0056_budget_line_delete_owner_guard.sql")
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
@ -1,3 +1,7 @@
|
|||||||
|
-- AURORA-1936: v3.del_budget_line — исполнитель (не ADMIN) удаляет только строки, созданные им самим
|
||||||
|
-- (budget_line.created_by = текущий пользователь).
|
||||||
|
-- 0056: тело пересобрано поверх 0053_form2_ahr_source_sheet (AURORA-1907: блок удаления АХР-строк
|
||||||
|
-- по budget_line.source_sheet IS NOT NULL, ручные строки с NULL удаляются) + только owner-guard.
|
||||||
CREATE OR REPLACE FUNCTION v3.del_budget_line(p_id integer, p_direction character varying DEFAULT NULL::character varying, p_sheet character varying DEFAULT NULL::character varying)
|
CREATE OR REPLACE FUNCTION v3.del_budget_line(p_id integer, p_direction character varying DEFAULT NULL::character varying, p_sheet character varying DEFAULT NULL::character varying)
|
||||||
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
|
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
@ -10,6 +14,7 @@ DECLARE
|
|||||||
v_eid INT;
|
v_eid INT;
|
||||||
v_line_id INT;
|
v_line_id INT;
|
||||||
v_ei_path INT[];
|
v_ei_path INT[];
|
||||||
|
v_source_sheet VARCHAR;
|
||||||
v_actor_id INT;
|
v_actor_id INT;
|
||||||
v_role VARCHAR;
|
v_role VARCHAR;
|
||||||
v_created_by INT;
|
v_created_by INT;
|
||||||
@ -75,8 +80,8 @@ BEGIN
|
|||||||
RETURN;
|
RETURN;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
SELECT bl.budget_form_id, ei.sheet, bl.expense_item_id, bl.created_by
|
SELECT bl.budget_form_id, ei.sheet, bl.expense_item_id, bl.source_sheet, bl.created_by
|
||||||
INTO v_form_id, v_sheet, v_eid, v_created_by
|
INTO v_form_id, v_sheet, v_eid, v_source_sheet, v_created_by
|
||||||
FROM v3.budget_line bl
|
FROM v3.budget_line bl
|
||||||
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
|
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
|
||||||
WHERE bl.id = p_id;
|
WHERE bl.id = p_id;
|
||||||
@ -89,28 +94,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку';
|
RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку';
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
IF v_sheet = 'AHR' AND (
|
IF v_sheet = 'AHR' AND v_source_sheet IS NOT NULL THEN
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM v3.form2_ahr_satellite_line_source m
|
|
||||||
WHERE m.target_line_id = p_id
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM v3.budget_line bl
|
|
||||||
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
|
|
||||||
JOIN v3.limit_template lt
|
|
||||||
ON lt.row_type = 'LEAF'
|
|
||||||
AND (
|
|
||||||
(ei.depth = 3 AND ei.item_id = lt.expense_item_code)
|
|
||||||
OR ei.num_group_id = lt.expense_item_code
|
|
||||||
)
|
|
||||||
WHERE bl.id = p_id
|
|
||||||
AND bl.budget_form_id = v_form_id
|
|
||||||
AND ei.sheet = 'AHR'
|
|
||||||
AND ei.direction = 'Support'
|
|
||||||
)
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'Строку АХР, созданную из листа-сателлита или АХР Лимиты, нельзя удалить напрямую'
|
RAISE EXCEPTION 'Строку АХР, созданную из листа-сателлита или АХР Лимиты, нельзя удалить напрямую'
|
||||||
USING ERRCODE = '23503',
|
USING ERRCODE = '23503',
|
||||||
HINT = 'Удалите строку на исходном листе АХР Аренда / АХР Коммунальные услуги / АХР Безопасность либо измените строку на листе АХР Лимиты';
|
HINT = 'Удалите строку на исходном листе АХР Аренда / АХР Коммунальные услуги / АХР Безопасность либо измените строку на листе АХР Лимиты';
|
||||||
@ -90,7 +90,8 @@ def isp_tokens(client, isp_password: str):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def isp2_tokens(client, isp_password: str):
|
def isp2_tokens(client, isp_password: str):
|
||||||
# Второй исполнитель в том же org_unit, что и budget_form id=1 — нужен,
|
# Исполнитель в org_unit формы id=1 (isp1 в нём намеренно не состоит — на этом
|
||||||
|
# держатся тесты «исполнитель из чужой организации»). Нужен вместе с isp3_tokens,
|
||||||
# чтобы проверять, что исполнитель не может удалить чужую строку.
|
# чтобы проверять, что исполнитель не может удалить чужую строку.
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
@ -98,3 +99,14 @@ def isp2_tokens(client, isp_password: str):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isp3_tokens(client, isp_password: str):
|
||||||
|
# Второй исполнитель в том же org_unit, что и isp2 (форма id=1).
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "isp3", "password": isp_password},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()
|
||||||
|
|||||||
@ -760,3 +760,24 @@ def test_manual_line_add_still_works_for_ordinary_statja_on_ahr(client, admin_to
|
|||||||
assert manual_row["data"]["header"]["source"] == "MANUAL"
|
assert manual_row["data"]["header"]["source"] == "MANUAL"
|
||||||
assert manual_row["data"]["header"]["can_add_row"] is True
|
assert manual_row["data"]["header"]["can_add_row"] is True
|
||||||
assert manual_row["data"]["plan"]["q1"] == 500
|
assert manual_row["data"]["plan"]["q1"] == 500
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM))
|
||||||
|
def test_satellite_sourced_ahr_line_not_deletable_directly_after_owner_guard(
|
||||||
|
client, admin_tokens, auth_headers, sheet
|
||||||
|
):
|
||||||
|
"""AURORA-1907 + AURORA-1936: миграция 0056 (owner-guard в v3.del_budget_line)
|
||||||
|
пересобрана поверх 0053 — строку АХР, созданную из сателлита
|
||||||
|
(budget_line.source_sheet IS NOT NULL), по-прежнему нельзя удалить напрямую
|
||||||
|
даже ADMIN'у, а ручную строку (source_sheet NULL) — можно
|
||||||
|
(см. test_manual_line_stays_deletable_after_satellite_add)."""
|
||||||
|
form_id = _create_form2(client, admin_tokens, auth_headers, year=2061)
|
||||||
|
satellite_data = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=1)
|
||||||
|
line_id = satellite_data["line_id"]
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1/form/{form_id}/sheet/AHR/line/{line_id}",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code != 200, response.text
|
||||||
|
assert "нельзя удалить напрямую" in response.text, response.text
|
||||||
|
|||||||
@ -168,35 +168,58 @@ def test_forms_delete_line_not_found_error_shape(client, admin_tokens, auth_head
|
|||||||
|
|
||||||
|
|
||||||
def _added_line_id(payload: dict, marker: str) -> int:
|
def _added_line_id(payload: dict, marker: str) -> int:
|
||||||
for row in payload["result"]:
|
# add_line возвращает ветку дерева статьи и ровно одну INPUT-строку — только что созданную.
|
||||||
data = row.get("data") or {}
|
# internal_order в data INPUT-строки не приходит (лежит во вложенных/скрытых полях),
|
||||||
if data.get("internal_order") == marker:
|
# поэтому по маркеру строку не найти — marker оставлен для читаемости теста.
|
||||||
return data.get("line_id") or data.get("id")
|
input_rows = [row for row in payload["result"] if row.get("row_type") == "INPUT"]
|
||||||
raise AssertionError(f"строка с internal_order={marker!r} не найдена в ответе add_line")
|
assert len(input_rows) == 1, f"ожидалась одна INPUT-строка для {marker!r}, получено {len(input_rows)}"
|
||||||
|
return input_rows[0]["data"]["line_id"]
|
||||||
|
|
||||||
|
|
||||||
def test_forms_delete_line_forbidden_for_non_owner_executor(client, isp_tokens, isp2_tokens, auth_headers):
|
def test_forms_delete_line_forbidden_for_non_owner_executor(client, isp2_tokens, isp3_tokens, auth_headers):
|
||||||
# AURORA-1936: исполнитель может удалить только им же созданную строку.
|
# AURORA-1936: исполнитель не может удалить чужую строку.
|
||||||
|
# Ошибка БД откатывает общую транзакцию тестового клиента (см. conftest), поэтому
|
||||||
|
# «свою строку удалить можно» проверяется отдельным тестом ниже.
|
||||||
marker = "aurora-1936-owner-guard"
|
marker = "aurora-1936-owner-guard"
|
||||||
add_response = client.post(
|
add_response = client.post(
|
||||||
"/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary",
|
"/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary",
|
||||||
json={"expense_item_id": 120, "direction": "Support", "internal_order": marker},
|
json={"expense_item_id": 120, "direction": "Support", "internal_order": marker},
|
||||||
headers=auth_headers(isp_tokens),
|
headers=auth_headers(isp2_tokens),
|
||||||
)
|
)
|
||||||
assert add_response.status_code == 200
|
assert add_response.status_code == 200, add_response.text
|
||||||
line_id = _added_line_id(add_response.json(), marker)
|
line_id = _added_line_id(add_response.json(), marker)
|
||||||
|
|
||||||
forbidden_response = client.delete(
|
forbidden_response = client.delete(
|
||||||
f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support",
|
f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support",
|
||||||
headers=auth_headers(isp2_tokens),
|
headers=auth_headers(isp3_tokens),
|
||||||
)
|
)
|
||||||
assert forbidden_response.status_code == 403
|
assert forbidden_response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(
|
||||||
|
strict=True,
|
||||||
|
reason=(
|
||||||
|
"O-1 (AURORA-1936): v3.add_budget_line нигде не заполняет budget_line.created_by "
|
||||||
|
"(INSERT без created_by во всех версиях функции, триггера/default нет), поэтому owner-guard "
|
||||||
|
"из 0056 блокирует удаление исполнителю и своих строк. Нужно решение: проставлять created_by "
|
||||||
|
"в add_budget_line (+ как трактовать legacy-строки с NULL). Тест упадёт как XPASS, когда это починят."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_forms_delete_line_allowed_for_owner_executor(client, isp2_tokens, auth_headers):
|
||||||
|
marker = "aurora-1936-own-delete"
|
||||||
|
add_response = client.post(
|
||||||
|
"/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary",
|
||||||
|
json={"expense_item_id": 120, "direction": "Support", "internal_order": marker},
|
||||||
|
headers=auth_headers(isp2_tokens),
|
||||||
|
)
|
||||||
|
assert add_response.status_code == 200, add_response.text
|
||||||
|
line_id = _added_line_id(add_response.json(), marker)
|
||||||
|
|
||||||
own_delete_response = client.delete(
|
own_delete_response = client.delete(
|
||||||
f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support",
|
f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support",
|
||||||
headers=auth_headers(isp_tokens),
|
headers=auth_headers(isp2_tokens),
|
||||||
)
|
)
|
||||||
assert own_delete_response.status_code == 200
|
assert own_delete_response.status_code == 200, own_delete_response.text
|
||||||
|
|
||||||
|
|
||||||
def test_forms_delete_line_allowed_for_admin_regardless_of_owner(client, isp2_tokens, admin_tokens, auth_headers):
|
def test_forms_delete_line_allowed_for_admin_regardless_of_owner(client, isp2_tokens, admin_tokens, auth_headers):
|
||||||
@ -206,7 +229,7 @@ def test_forms_delete_line_allowed_for_admin_regardless_of_owner(client, isp2_to
|
|||||||
json={"expense_item_id": 120, "direction": "Support", "internal_order": marker},
|
json={"expense_item_id": 120, "direction": "Support", "internal_order": marker},
|
||||||
headers=auth_headers(isp2_tokens),
|
headers=auth_headers(isp2_tokens),
|
||||||
)
|
)
|
||||||
assert add_response.status_code == 200
|
assert add_response.status_code == 200, add_response.text
|
||||||
line_id = _added_line_id(add_response.json(), marker)
|
line_id = _added_line_id(add_response.json(), marker)
|
||||||
|
|
||||||
response = client.delete(
|
response = client.delete(
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
INSERT INTO v3.app_user (id,email,username,hashed_password,full_name,role_id,is_active,created_at,updated_at) VALUES
|
INSERT INTO v3.app_user (id,email,username,hashed_password,full_name,role_id,is_active,created_at,updated_at) VALUES
|
||||||
(1,'admin@rshb.ru','admin','{admin_password}','admin',1,true,'2026-05-20 11:09:16.987331+03',NULL),
|
(1,'admin@rshb.ru','admin','{admin_password}','admin',1,true,'2026-05-20 11:09:16.987331+03',NULL),
|
||||||
(2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'),
|
(2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'),
|
||||||
(3,'ispolnitel2@rshb.ru','isp2','{isp_password}','Роль Исполнитель Второй',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03');
|
(3,'ispolnitel2@rshb.ru','isp2','{isp_password}','Роль Исполнитель Второй',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'),
|
||||||
SELECT setval('v3.app_user_id_seq', 3);
|
(4,'ispolnitel3@rshb.ru','isp3','{isp_password}','Роль Исполнитель Третий',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03');
|
||||||
|
SELECT setval('v3.app_user_id_seq', 4);
|
||||||
|
|
||||||
INSERT INTO v3.org_unit (id,title,is_active,is_ssp,utc_offset) VALUES
|
INSERT INTO v3.org_unit (id,title,is_active,is_ssp,utc_offset) VALUES
|
||||||
(1,'Test_SSP',true,true,'+03'),
|
(1,'Test_SSP',true,true,'+03'),
|
||||||
@ -14,7 +15,7 @@ INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES
|
|||||||
(2,1,1),
|
(2,1,1),
|
||||||
(3,2,2),
|
(3,2,2),
|
||||||
(4,3,1),
|
(4,3,1),
|
||||||
(5,2,1);
|
(5,4,1);
|
||||||
SELECT setval('v3.user_org_id_seq', 5);
|
SELECT setval('v3.user_org_id_seq', 5);
|
||||||
|
|
||||||
INSERT INTO v3.budget_form (id,form_type_code,"year",created_by,created_at,updated_by,updated_at,org_unit_id) VALUES
|
INSERT INTO v3.budget_form (id,form_type_code,"year",created_by,created_at,updated_by,updated_at,org_unit_id) VALUES
|
||||||
@ -27,8 +28,8 @@ INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_or
|
|||||||
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,direction) 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','Support'),
|
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2099-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'),
|
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2099-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);
|
(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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user