/
systemsstrategyy
/
Treker
Обзор
Документация
Войти
/
systemsstrategyy
/
Treker
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
api/modules/billing/counters.py
136 строк
7 KB
SystemsStrategy
Initial import
02 июл 2026, 15:15
02 июл 2026, 15:15
9624ed1
Код
Авторство
О чём код?
"""Подсчёт ресурсов организации через raw SQL. Counter'ы используются QuotaService для проверки лимитов. Зачем raw SQL, а не ORM-импорт из spaces/cards/users? Правило проекта #4 запрещает импорт ORM-моделей из чужих модулей. Raw SQL c известными table-names сохраняет module boundary: billing знает имена таблиц (это контракт БД), но не имеет хардкод-зависимости от Space/Card/User Python-классов. Альтернатива — публичные `count_in_org(org_id)` методы в Service Layer каждого модуля. Это полноценный refactor, требует PR-ов к Foundation и Kanban; делаем после стабилизации MVP. Soft-delete фильтр обязателен — иначе deleted ресурсы съедают лимит, и пользователь не может ничего создать после restore'а из корзины. Helper-методы: - `count(db, resource, organization_id=...)` — org-wide count (users, spaces, cards) - `count_cards_in_space(db, organization_id=, space_uid=)` — per-space count (для лимита `cards_per_space` — другая по сигнатуре, не входит в `count`) - `count_files_mb(db, organization_id=)` — сумма размеров attachments в MB (для лимита `files_mb` — единица измерения не count, а megabytes) """ from __future__ import annotations from typing import Final from uuid import UUID from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession # Маппинг resource → SQL-запрос с :org_id плейсхолдером. SQL hardcoded, # table-names — стабильный контракт между модулями (через миграции). # При переименовании таблицы — обновить здесь + alembic-revision. _COUNT_SQL: Final[dict[str, str]] = { "users": ("SELECT COUNT(*) FROM auth.users WHERE organization_id = :org_id"), "spaces": ( "SELECT COUNT(*) FROM app.spaces WHERE organization_id = :org_id AND deleted_at IS NULL" ), "cards": ( # JOIN spaces — карта принадлежит space-у, org-связь через space. # Двойной фильтр soft-delete: и карта живая, и её space живой. "SELECT COUNT(*) FROM app.cards c " "INNER JOIN app.spaces s ON c.space_id = s.id " "WHERE s.organization_id = :org_id " "AND c.deleted_at IS NULL " "AND s.deleted_at IS NULL" ), } # SQL для cards-в-space — фильтрует ОДНОВРЕМЕННО по org_id и space_uid. # Двойной фильтр критичен для безопасности: без org_id-проверки атакующий # мог бы передать space_uid чужой org через URL и обойти tenant isolation. # UUID space-а — внешний (UUIDMixin.uid), не internal.id. _CARDS_IN_SPACE_SQL: Final[str] = ( "SELECT COUNT(*) FROM app.cards c " "INNER JOIN app.spaces s ON c.space_id = s.id " "WHERE s.organization_id = :org_id " "AND s.uid = :space_uid " "AND c.deleted_at IS NULL " "AND s.deleted_at IS NULL" ) # SQL для суммарного размера attachments. CAST + integer-division возвращает # целое число MB (округление вниз). Org-связь через app.cards → app.spaces. # Тройной soft-delete фильтр: и attachment, и card, и space должны быть живы. _FILES_MB_SQL: Final[str] = ( "SELECT COALESCE(SUM(a.size_bytes), 0) / (1024 * 1024) FROM app.attachments a " "INNER JOIN app.cards c ON a.card_id = c.id " "INNER JOIN app.spaces s ON c.space_id = s.id " "WHERE s.organization_id = :org_id " "AND a.deleted_at IS NULL " "AND c.deleted_at IS NULL " "AND s.deleted_at IS NULL" ) class ResourceCounter: """Stateless подсчёт активных ресурсов организации. Методы возвращают int. Неизвестный resource в `count` — ValueError (typo-защита). `count_cards_in_space` и `count_files_mb` — выделенные методы для ресурсов с особой сигнатурой / единицей измерения. """ @staticmethod async def count(db: AsyncSession, resource: str, *, organization_id: int) -> int: """Возвращает количество активных ресурсов указанного типа в org. Raises: ValueError: если resource не из набора {users, spaces, cards}. """ sql = _COUNT_SQL.get(resource) if sql is None: raise ValueError(f"Unknown resource: {resource!r}") result = await db.execute(text(sql), {"org_id": organization_id}) return int(result.scalar() or 0) @staticmethod async def count_cards_in_space( db: AsyncSession, *, organization_id: int, space_uid: UUID ) -> int: """Cards в конкретном space (для лимита `cards_per_space`). Фильтрует И по org_id, И по space_uid — чтобы атакующий не мог передать чужой space_uid в URL и обойти tenant isolation. Soft-deleted cards / spaces исключены. Returns: Количество живых cards в указанном space. 0 если space не найден. """ result = await db.execute( text(_CARDS_IN_SPACE_SQL), {"org_id": organization_id, "space_uid": str(space_uid)}, ) return int(result.scalar() or 0) @staticmethod async def count_files_mb(db: AsyncSession, *, organization_id: int) -> int: """Суммарный размер attachments в org (в megabytes, floored). Используется для лимита `files_mb` в `Plan.limits`. Integer-division в SQL — округление вниз: 1.5 МБ → 1 МБ. Это означает, что юзер с лимитом 100 МБ может реально загрузить ~100.9 МБ — приемлемый trade-off в обмен на простой integer math (Plan.limits — int). Тройной soft-delete фильтр: attachment.deleted_at + card.deleted_at + space.deleted_at — иначе trash съедает квоту. Returns: Сумма size_bytes по живым attachments / 1024² (MB, integer). """ result = await db.execute(text(_FILES_MB_SQL), {"org_id": organization_id}) return int(result.scalar() or 0)