From 851cf3b9862a805509fa4fd5b8ff249508d5eff9 Mon Sep 17 00:00:00 2001 From: Artem Bratyashin Date: Fri, 14 Aug 2026 21:38:41 +0300 Subject: [PATCH 1/2] bug fixed --- auth_backend/routes/groups.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/auth_backend/routes/groups.py b/auth_backend/routes/groups.py index cfc851c..b964a94 100644 --- a/auth_backend/routes/groups.py +++ b/auth_backend/routes/groups.py @@ -13,6 +13,17 @@ groups = APIRouter(prefix="/group", tags=["Groups"]) +def _get_all_descendants(group: DbGroup) -> set[int]: + """ + Рекурсивно получить IDs всех потомков группы (детей, внуков и т.д.) + """ + descendants = set() + for child in group.child: + descendants.add(child.id) + descendants.update(_get_all_descendants(child)) + return descendants + + @groups.get("/{id}", response_model=GroupGet, response_model_exclude_unset=True) async def get_group( id: int, @@ -80,11 +91,15 @@ def patch_group_logic(id: int, group_inp: GroupPatch, session) -> DbGroup: ): raise AlreadyExists(Group, exists_check.id) group = DbGroup.get(id, session=session) - if group_inp.parent_id in (row.id for row in group.child): - raise HTTPException( - status_code=400, - detail=StatusResponseModel(status="Error", message="Cycle detected", ru="Найден цикл").model_dump(), - ) + + if group_inp.parent_id: + all_descendants = _get_all_descendants(group) + if group_inp.parent_id in all_descendants: + raise HTTPException( + status_code=400, + detail=StatusResponseModel(status="Error", message="Cycle detected", ru="Найден цикл").model_dump(), + ) + result = Group.model_validate( DbGroup.update(id, session=session, **group_inp.model_dump(exclude_unset=True, exclude={"scopes"})) ).model_dump(exclude_unset=True) From 7e5decaa80e22c89d85634783c491e25fe9c9818 Mon Sep 17 00:00:00 2001 From: Artem Bratyashin Date: Fri, 14 Aug 2026 21:38:52 +0300 Subject: [PATCH 2/2] Tests are ready --- tests/test_routes/test_groups.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_routes/test_groups.py b/tests/test_routes/test_groups.py index c5f23fb..8ba8bc1 100644 --- a/tests/test_routes/test_groups.py +++ b/tests/test_routes/test_groups.py @@ -154,6 +154,28 @@ def test_cycle_patch(client, dbsession): dbsession.commit() +def test_cycle_patch_indirect(client, dbsession): + time1 = datetime.datetime.utcnow() + body = {"name": f"group{time1}", "parent_id": None, "scopes": []} + group_a = client.post(url="/group", json=body).json()["id"] + time2 = datetime.datetime.utcnow() + body = {"name": f"group{time2}", "parent_id": group_a, "scopes": []} + group_b = client.post(url="/group", json=body).json()["id"] + time3 = datetime.datetime.utcnow() + body = {"name": f"group{time3}", "parent_id": group_b, "scopes": []} + group_c = client.post(url="/group", json=body).json()["id"] + response = client.patch(f"/group/{group_a}", json={"parent_id": group_c}) + assert response.status_code == 400 + + dbsession.query(UserGroup).filter(UserGroup.group_id == group_a).delete() + dbsession.query(UserGroup).filter(UserGroup.group_id == group_b).delete() + dbsession.query(UserGroup).filter(UserGroup.group_id == group_c).delete() + dbsession.query(Group).filter(Group.id == group_c).delete() + dbsession.query(Group).filter(Group.id == group_b).delete() + dbsession.query(Group).filter(Group.id == group_a).delete() + dbsession.commit() + + def test_delete(client, dbsession): time1 = datetime.datetime.utcnow() body = {"name": f"group{time1}", "parent_id": None, "scopes": []}