/
systemsstrategyy
/
Treker
Обзор
Документация
Войти
/
systemsstrategyy
/
Treker
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
api/modules/auth/groups.py
131 строка
6 KB
HaGaSRus
style: ruff-чистота — 39 нарушений + формат 24 файлов (долг волн 1-4)
08 июл 2026, 08:35
08 июл 2026, 08:35
621824f
Код
Авторство
О чём код?
"""UserGroupService — CRUD групп и членства. Группы scoped по организации. Foundation предполагает что admin создаёт группы и управляет членством; teamlead тоже может (см. RBAC user:invite). """ from __future__ import annotations from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from api.core.errors import NotFoundError, ValidationError from api.modules.auth.models import User, UserGroup class UserGroupService: """Управление группами внутри организации.""" @staticmethod async def create( db: AsyncSession, *, organization_id: int, name: str, role: str = "member", ) -> UserGroup: """Создаёт группу. ValidationError если имя уже занято в орге.""" existing = await db.scalar( select(UserGroup).where( UserGroup.organization_id == organization_id, UserGroup.name == name, ) ) if existing is not None: raise ValidationError(f"Группа '{name}' уже существует в этой организации") group = UserGroup(organization_id=organization_id, name=name, role=role) db.add(group) await db.flush() return group @staticmethod async def list_in_org(db: AsyncSession, organization_id: int) -> list[UserGroup]: """Все группы организации с подгруженными участниками.""" result = await db.scalars( select(UserGroup) .where(UserGroup.organization_id == organization_id) .options(selectinload(UserGroup.members)) .order_by(UserGroup.name) ) return list(result.all()) @staticmethod async def get_by_uid( db: AsyncSession, uid: UUID, *, organization_id: int | None = None ) -> UserGroup: """Загружает группу по UID. NotFoundError если нет. `organization_id` — обязательный скоуп org-изоляции при вызове из HTTP-хендлеров: группа чужой организации маскируется под 404 (как эталон admin_update_user), а не «не найдена/чужая» → не подтверждаем существование чужого ресурса. None — только для внутренних вызовов без tenant-контекста. """ stmt = ( select(UserGroup).where(UserGroup.uid == uid).options(selectinload(UserGroup.members)) ) if organization_id is not None: stmt = stmt.where(UserGroup.organization_id == organization_id) group = await db.scalar(stmt) if group is None: raise NotFoundError("Группа не найдена") return group @staticmethod async def delete(db: AsyncSession, uid: UUID, *, organization_id: int | None = None) -> None: """Удаляет группу. Membership-записи каскадно удаляются FK ondelete=CASCADE. `organization_id` скоупит удаление своей организацией (чужая → 404).""" group = await UserGroupService.get_by_uid(db, uid, organization_id=organization_id) await db.delete(group) await db.flush() @staticmethod async def add_member( db: AsyncSession, *, group_uid: UUID, user_uid: UUID, organization_id: int | None = None ) -> UserGroup: """Добавляет пользователя в группу. ValidationError если уже в ней или юзер из другой организации. `organization_id` скоупит и группу, и резолв юзера своей организацией: группа чужой org → 404 (get_by_uid), юзер чужой org → 404. Так cross-tenant добавление невозможно (защита от IDOR).""" group = await UserGroupService.get_by_uid(db, group_uid, organization_id=organization_id) user_stmt = select(User).where(User.uid == user_uid) if organization_id is not None: user_stmt = user_stmt.where(User.organization_id == organization_id) user = await db.scalar(user_stmt) if user is None: raise NotFoundError("Пользователь не найден") if user.organization_id != group.organization_id: raise ValidationError("Пользователь и группа из разных организаций") if any(m.id == user.id for m in group.members): raise ValidationError("Пользователь уже состоит в этой группе") group.members.append(user) await db.flush() return group @staticmethod async def remove_member( db: AsyncSession, *, group_uid: UUID, user_uid: UUID, organization_id: int | None = None ) -> UserGroup: """Убирает пользователя из группы. Не падает если его там не было. `organization_id` скоупит и группу, и резолв юзера своей организацией (защита от cross-tenant IDOR — чужая группа/юзер → 404).""" group = await UserGroupService.get_by_uid(db, group_uid, organization_id=organization_id) user_stmt = select(User).where(User.uid == user_uid) if organization_id is not None: user_stmt = user_stmt.where(User.organization_id == organization_id) user = await db.scalar(user_stmt) if user is None: raise NotFoundError("Пользователь не найден") group.members = [m for m in group.members if m.id != user.id] await db.flush() return group