commit 85fb2f4bb90bc68d9445d7c333631497f3e18483 Author: adminko Date: Thu Mar 19 13:39:32 2026 +0300 Initial commit: svg backend diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0a0617 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +APP_NAME=svg-service +APP_ENV=development +APP_DEBUG=false +BACKEND_PORT=9020 +API_V1_PREFIX=/api/v1 + +AUTH_HEADER_NAME=X-API-Key +API_KEYS_ADMIN=admin-local-dev-key +API_KEYS_OPERATOR=operator-local-dev-key +API_KEYS_VIEWER=viewer-local-dev-key + +SVG_MAX_FILE_SIZE_BYTES=10485760 +SVG_MAX_ELEMENTS=25000 + +STORAGE_ROOT=/data +STORAGE_ORIGINAL_DIR=original +STORAGE_SANITIZED_DIR=sanitized +STORAGE_NORMALIZED_DIR=normalized + +SVG_ALLOW_INTERNAL_USE_REFERENCES_ONLY=true +SVG_FORBID_FOREIGN_OBJECT_V1=true +SVG_FORBID_STYLE_V1=true +SVG_FORBID_IMAGE_V1=true + +POSTGRES_DB=svg_service +POSTGRES_USER=svg_service +POSTGRES_PASSWORD=svg_service_dev_password +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +DATABASE_URL=postgresql+asyncpg://svg_service:svg_service_dev_password@postgres:5432/svg_service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c945b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# secrets +.env +.env.* +!.env.example + +# python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.python-version +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# alembic / local db artifacts +*.db +*.sqlite3 + +# local storage / runtime data +storage/ +postgres-data/ +uploads/ +artifacts/ + +# editors / os +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# logs +*.log + +# local ad-hoc fixtures in project root +dirty.svg +fake.svg +not_svg_root.svg +sample-contract.svg +sample-map.svg +test.svg +test.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6773dd --- /dev/null +++ b/README.md @@ -0,0 +1,467 @@ +# SVG Service + +Backend-сервис для загрузки, sanitization, normalization, хранения и базовой эксплуатации SVG-схем с местами, секторами, группами и pricing. + +## Текущее состояние + +Реализовано: + +- загрузка SVG-файла +- безопасная первичная обработка SVG +- sanitization запрещённых элементов и атрибутов +- normalization в машиночитаемый JSON snapshot +- хранение original / sanitized / normalized файлов +- PostgreSQL persistence +- registry uploads / schemes / versions +- lifecycle схем: + - draft + - publish + - unpublish + - rollback + - create next draft version +- persistence доменных сущностей: + - sectors + - groups + - seats +- pricing v1: + - pricing categories + - price rules + - effective price resolution +- test mode preview API +- audit/history API + +--- + +## Стек + +- Python 3.11+ +- FastAPI +- Pydantic +- SQLAlchemy Async +- PostgreSQL +- Alembic +- Docker Compose + +--- + +## Запуск + +### 1. Сборка и запуск + +docker compose build --no-cache +docker compose up -d + +### 2. Применение миграций + +docker compose run --rm svg-service alembic -c /app/alembic.ini upgrade head + +### 3. Проверка + +curl -s http://127.0.0.1:9020/healthz + +Ожидаемый ответ: + +{"status":"ok"} + +--- + +## Аутентификация + +Во все защищённые эндпойнты передаётся заголовок: + +X-API-Key: + +Локально использовались ключи из `.env`. + +--- + +## Основные env-переменные + +Примерно используются такие параметры: + +- `APP_NAME` +- `APP_ENV` +- `APP_PORT` +- `API_V1_PREFIX` +- `AUTH_HEADER_NAME` +- `ADMIN_API_KEY` +- `VIEWER_API_KEY` +- `SVG_MAX_FILE_SIZE_BYTES` +- `SVG_MAX_ELEMENTS` +- `SVG_ALLOW_INTERNAL_USE_REFERENCES_ONLY` +- `SVG_FORBID_FOREIGN_OBJECT_V1` +- `SVG_FORBID_STYLE_V1` +- `SVG_FORBID_IMAGE_V1` +- `POSTGRES_HOST` +- `POSTGRES_PORT` +- `POSTGRES_DB` +- `POSTGRES_USER` +- `POSTGRES_PASSWORD` +- `DATABASE_URL` + +--- + +## Storage layout + +На файловой системе используются каталоги: + +- `storage/original` +- `storage/sanitized` +- `storage/normalized` + +Каждая загрузка сохраняется в свой каталог по `upload_id`. + +--- + +# API + +## 1. Service / auth / diagnostics + +### GET `/` +Базовая информация о сервисе. + +### GET `/healthz` +Healthcheck. + +### GET `/api/v1/ping` +Проверка API и auth. + +### GET `/api/v1/auth/me` +Возвращает текущую роль по API key. + +### GET `/api/v1/db/ping` +Проверка доступности PostgreSQL. + +### GET `/api/v1/manifest` +Возвращает manifest сервиса: +- лимиты SVG +- правила sanitization +- extraction contract + +--- + +## 2. Uploads + +### POST `/api/v1/schemes/upload` +Загружает SVG, выполняет: +- validate +- inspect +- sanitize +- normalize +- storage save +- create upload record +- create scheme +- create version 1 +- persist sectors/groups/seats +- audit event + +#### Form-data +- `file`: SVG-файл + +#### Ответ +Возвращает: +- `upload_id` +- имя файла +- размеры +- counts +- storage paths +- accepted flag + +--- + +### GET `/api/v1/uploads` +Список upload records. + +### GET `/api/v1/uploads/{upload_id}` +Детали upload record. + +### GET `/api/v1/uploads/{upload_id}/normalized` +Чтение normalized JSON snapshot. + +--- + +## 3. Schemes + +### GET `/api/v1/schemes` +Список схем. + +### GET `/api/v1/schemes/{scheme_id}` +Детали схемы. + +### GET `/api/v1/schemes/{scheme_id}/current` +Текущая version схемы. + +### GET `/api/v1/schemes/{scheme_id}/versions` +Список всех version схемы. + +### POST `/api/v1/schemes/{scheme_id}/versions` +Создаёт новую draft version из current version. + +Что делает: +- создаёт `version_number = current + 1` +- копирует metadata snapshot +- копирует sectors/groups/seats +- переключает `current_version_number` +- сбрасывает схему в `draft` +- пишет audit event + +--- + +## 4. Scheme lifecycle + +### POST `/api/v1/schemes/{scheme_id}/publish` +Публикует current version. + +Что делает: +- `scheme.status = published` +- `published_at = now()` +- current version получает статус `published` +- пишет audit event + +### POST `/api/v1/schemes/{scheme_id}/unpublish` +Снимает публикацию. + +Что делает: +- `scheme.status = draft` +- `published_at = null` +- current version переводится в `draft` +- пишет audit event + +### POST `/api/v1/schemes/{scheme_id}/rollback` +Откатывает current version на указанную version. + +#### JSON body +{ + "target_version_number": 1 +} + +Что делает: +- переключает `current_version_number` +- сбрасывает публикацию +- схема становится `draft` +- пишет audit event + +--- + +## 5. Current domain data + +### GET `/api/v1/schemes/{scheme_id}/current/sectors` +Сектора текущей версии. + +### GET `/api/v1/schemes/{scheme_id}/current/groups` +Группы текущей версии. + +### GET `/api/v1/schemes/{scheme_id}/current/seats` +Места текущей версии. + +Seat содержит: +- `seat_id` +- `sector_id` +- `group_id` +- `row_label` +- `seat_number` +- geometry fields +- tag / classes + +--- + +## 6. Pricing + +## 6.1 Pricing categories + +### POST `/api/v1/schemes/{scheme_id}/pricing/categories` +Создать pricing category. + +#### JSON body +{ + "name": "VIP", + "code": "VIP" +} + +### PUT `/api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}` +Обновить pricing category. + +#### JSON body +{ + "name": "Econom Plus", + "code": "ECO_PLUS" +} + +### DELETE `/api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}` +Удалить pricing category. + +--- + +## 6.2 Price rules + +### POST `/api/v1/schemes/{scheme_id}/pricing/rules` +Создать price rule. + +#### JSON body +{ + "pricing_category_id": "....", + "target_type": "sector", + "target_ref": "vip", + "amount": "2500.00", + "currency": "RUB" +} + +#### Ограничения v1 +- `target_type` только: + - `sector` + - `group` + - `seat` +- `currency` только `RUB` +- `amount` хранится как `NUMERIC(12,2)` + +### PUT `/api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}` +Обновить rule. + +### DELETE `/api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}` +Удалить rule. + +### GET `/api/v1/schemes/{scheme_id}/pricing` +Вернуть все pricing categories и rules схемы. + +--- + +## 7. Effective pricing + +### GET `/api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price` +Рассчитать effective price для места. + +#### Приоритет поиска rules +1. `seat` +2. `group` +3. `sector` + +#### Ответ +Возвращает: +- `seat_id` +- `sector_id` +- `group_id` +- `matched_rule_level` +- `matched_target_ref` +- `pricing_category_id` +- `amount` +- `currency` + +Если правило не найдено: +- `404 No pricing rule matched current seat` + +Если место не найдено: +- `404 Seat not found in current scheme version` + +--- + +## 8. Test mode + +### GET `/api/v1/schemes/{scheme_id}/test/seats/{seat_id}` +Preview для frontend test mode. + +Возвращает: +- seat metadata +- `selectable` +- `has_price` +- pricing info, если найдено effective rule + +Логика: +- если price найден -> `selectable = true` +- если price не найден -> `selectable = false` + +--- + +## 9. Audit + +### GET `/api/v1/schemes/{scheme_id}/audit` +История событий схемы. + +Сейчас в audit пишутся: +- `scheme.created_from_upload` +- `scheme.published` +- `scheme.unpublished` +- `scheme.rolled_back` +- `scheme.version.created` +- `pricing.category.created` +- `pricing.category.updated` +- `pricing.category.deleted` +- `pricing.rule.created` +- `pricing.rule.updated` +- `pricing.rule.deleted` + +--- + +## Пример базового сценария + +### 1. Загрузить SVG + +curl -s -H 'X-API-Key: admin-local-dev-key' \ + -F 'file=@sample-contract.svg;type=image/svg+xml' \ + http://127.0.0.1:9020/api/v1/schemes/upload + +### 2. Получить список схем + +curl -s -H 'X-API-Key: admin-local-dev-key' \ + http://127.0.0.1:9020/api/v1/schemes + +### 3. Создать pricing category + +curl -s -X POST -H 'Content-Type: application/json' -H 'X-API-Key: admin-local-dev-key' \ + -d '{"name":"VIP","code":"VIP"}' \ + http://127.0.0.1:9020/api/v1/schemes//pricing/categories + +### 4. Создать pricing rule + +curl -s -X POST -H 'Content-Type: application/json' -H 'X-API-Key: admin-local-dev-key' \ + -d '{"pricing_category_id":"","target_type":"sector","target_ref":"vip","amount":"2500.00","currency":"RUB"}' \ + http://127.0.0.1:9020/api/v1/schemes//pricing/rules + +### 5. Проверить цену места + +curl -s -H 'X-API-Key: admin-local-dev-key' \ + http://127.0.0.1:9020/api/v1/schemes//current/seats/seat-a1/price + +### 6. Проверить test mode preview + +curl -s -H 'X-API-Key: admin-local-dev-key' \ + http://127.0.0.1:9020/api/v1/schemes//test/seats/seat-a1 + +--- + +## Ограничения текущего v1 + +- редактирование seats/groups/sectors через API пока не реализовано +- нет bulk update editor API +- нет optimistic locking +- нет soft delete policy +- нет полноценной multi-user edit coordination +- routes пока не разнесены по отдельным router-модулям +- нет полного integration/e2e test набора +- frontend отсутствует + +--- + +## Что делать дальше + +### Приоритет 1 +Разработать frontend / test UI: +- список схем +- просмотр current seats/sectors/groups +- test mode click preview +- pricing management UI + +### Приоритет 2 +Добавить editor operations: +- update seat/group/sector in draft version +- bulk updates +- draft save flow + +### Приоритет 3 +Усилить backend: +- router decomposition +- service-layer cleanup +- pagination/filters for audit +- integration tests +- concurrency policy +- publish validation rules + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..109b98f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY alembic.ini /app/alembic.ini +COPY alembic /app/alembic +COPY app /app/app + +EXPOSE 9020 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9020"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..d85166e --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . + +sqlalchemy.url = postgresql+asyncpg://svg_service:svg_service_dev_password@postgres:5432/svg_service + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..e5ffe2c --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,76 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.core.config import settings +from app.db.base import Base +from app.models.audit_event import AuditEventRecord +from app.models.price_rule import PriceRuleRecord +from app.models.pricing_category import PricingCategoryRecord +from app.models.scheme import SchemeRecord +from app.models.scheme_group import SchemeGroupRecord +from app.models.scheme_seat import SchemeSeatRecord +from app.models.scheme_sector import SchemeSectorRecord +from app.models.scheme_version import SchemeVersionRecord +from app.models.upload import UploadRecord + +config = context.config + +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def main() -> None: + if context.is_offline_mode(): + run_migrations_offline() + else: + import asyncio + asyncio.run(run_migrations_online()) + + +main() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..7669753 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = None +depends_on = None + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/20260316_01_create_uploads_table.py b/backend/alembic/versions/20260316_01_create_uploads_table.py new file mode 100644 index 0000000..9ce1c77 --- /dev/null +++ b/backend/alembic/versions/20260316_01_create_uploads_table.py @@ -0,0 +1,44 @@ +"""create uploads table + +Revision ID: 20260316_01 +Revises: None +Create Date: 2026-03-16 14:50:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_01" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "uploads", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("upload_id", sa.String(length=32), nullable=False), + sa.Column("original_filename", sa.String(length=512), nullable=False), + sa.Column("content_type", sa.String(length=255), nullable=False), + sa.Column("size_bytes", sa.BigInteger(), nullable=False), + sa.Column("element_count", sa.Integer(), nullable=False), + sa.Column("removed_elements_count", sa.Integer(), nullable=False), + sa.Column("removed_attributes_count", sa.Integer(), nullable=False), + sa.Column("normalized_elements_count", sa.Integer(), nullable=False), + sa.Column("normalized_seats_count", sa.Integer(), nullable=False), + sa.Column("normalized_groups_count", sa.Integer(), nullable=False), + sa.Column("normalized_sectors_count", sa.Integer(), nullable=False), + sa.Column("original_storage_path", sa.Text(), nullable=False), + sa.Column("sanitized_storage_path", sa.Text(), nullable=False), + sa.Column("normalized_storage_path", sa.Text(), nullable=False), + sa.Column("processing_status", sa.String(length=32), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_uploads_upload_id", "uploads", ["upload_id"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_uploads_upload_id", table_name="uploads") + op.drop_table("uploads") diff --git a/backend/alembic/versions/20260316_02_create_schemes_table.py b/backend/alembic/versions/20260316_02_create_schemes_table.py new file mode 100644 index 0000000..04a5134 --- /dev/null +++ b/backend/alembic/versions/20260316_02_create_schemes_table.py @@ -0,0 +1,40 @@ +"""create schemes table + +Revision ID: 20260316_02 +Revises: 20260316_01 +Create Date: 2026-03-16 15:15:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_02" +down_revision = "20260316_01" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "schemes", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("source_upload_id", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=512), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("normalized_elements_count", sa.Integer(), nullable=False), + sa.Column("normalized_seats_count", sa.Integer(), nullable=False), + sa.Column("normalized_groups_count", sa.Integer(), nullable=False), + sa.Column("normalized_sectors_count", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["source_upload_id"], ["uploads.upload_id"], ondelete="RESTRICT"), + ) + op.create_index("ix_schemes_scheme_id", "schemes", ["scheme_id"], unique=True) + op.create_index("ix_schemes_source_upload_id", "schemes", ["source_upload_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_schemes_source_upload_id", table_name="schemes") + op.drop_index("ix_schemes_scheme_id", table_name="schemes") + op.drop_table("schemes") diff --git a/backend/alembic/versions/20260316_03_create_scheme_versions_table.py b/backend/alembic/versions/20260316_03_create_scheme_versions_table.py new file mode 100644 index 0000000..b28bfd8 --- /dev/null +++ b/backend/alembic/versions/20260316_03_create_scheme_versions_table.py @@ -0,0 +1,35 @@ +"""create scheme_versions table + +Revision ID: 20260316_03 +Revises: 20260316_02 +Create Date: 2026-03-16 15:25:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_03" +down_revision = "20260316_02" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "scheme_versions", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("scheme_version_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("version_number", sa.Integer(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("normalized_storage_path", sa.Text(), nullable=False), + sa.Column("normalized_elements_count", sa.Integer(), nullable=False), + sa.Column("normalized_seats_count", sa.Integer(), nullable=False), + sa.Column("normalized_groups_count", sa.Integer(), nullable=False), + sa.Column("normalized_sectors_count", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["scheme_id"], ["schemes.scheme_id"], ondelete="RESTRICT"), + ) + op.create_index("ix_scheme_versions_scheme_version_id", "scheme_versions", ["scheme_version_id"], unique=True) + op.create_index("ix_scheme_versions_scheme_id", "scheme_versions", ["scheme_id"], unique=False) diff --git a/backend/alembic/versions/20260316_04_add_current_version_to_schemes.py b/backend/alembic/versions/20260316_04_add_current_version_to_schemes.py new file mode 100644 index 0000000..a814acb --- /dev/null +++ b/backend/alembic/versions/20260316_04_add_current_version_to_schemes.py @@ -0,0 +1,27 @@ +"""add current_version_number to schemes + +Revision ID: 20260316_04 +Revises: 20260316_03 +Create Date: 2026-03-16 15:30:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_04" +down_revision = "20260316_03" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "schemes", + sa.Column("current_version_number", sa.Integer(), nullable=False, server_default="1"), + ) + op.alter_column("schemes", "current_version_number", server_default=None) + + +def downgrade() -> None: + op.drop_column("schemes", "current_version_number") diff --git a/backend/alembic/versions/20260316_05_add_published_at_to_schemes.py b/backend/alembic/versions/20260316_05_add_published_at_to_schemes.py new file mode 100644 index 0000000..1b9eaa8 --- /dev/null +++ b/backend/alembic/versions/20260316_05_add_published_at_to_schemes.py @@ -0,0 +1,26 @@ +"""add published_at to schemes + +Revision ID: 20260316_05 +Revises: 20260316_04 +Create Date: 2026-03-16 15:40:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_05" +down_revision = "20260316_04" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "schemes", + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("schemes", "published_at") diff --git a/backend/alembic/versions/20260316_06_create_scheme_sectors_table.py b/backend/alembic/versions/20260316_06_create_scheme_sectors_table.py new file mode 100644 index 0000000..081e77f --- /dev/null +++ b/backend/alembic/versions/20260316_06_create_scheme_sectors_table.py @@ -0,0 +1,42 @@ +"""create scheme_sectors table + +Revision ID: 20260316_06 +Revises: 20260316_05 +Create Date: 2026-03-16 15:55:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_06" +down_revision = "20260316_05" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "scheme_sectors", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("sector_record_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("scheme_version_id", sa.String(length=32), nullable=False), + sa.Column("element_id", sa.String(length=512), nullable=True), + sa.Column("sector_id", sa.String(length=255), nullable=True), + sa.Column("name", sa.String(length=512), nullable=False), + sa.Column("classes_raw", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["scheme_id"], ["schemes.scheme_id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["scheme_version_id"], ["scheme_versions.scheme_version_id"], ondelete="RESTRICT"), + ) + op.create_index("ix_scheme_sectors_sector_record_id", "scheme_sectors", ["sector_record_id"], unique=True) + op.create_index("ix_scheme_sectors_scheme_id", "scheme_sectors", ["scheme_id"], unique=False) + op.create_index("ix_scheme_sectors_scheme_version_id", "scheme_sectors", ["scheme_version_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_scheme_sectors_scheme_version_id", table_name="scheme_sectors") + op.drop_index("ix_scheme_sectors_scheme_id", table_name="scheme_sectors") + op.drop_index("ix_scheme_sectors_sector_record_id", table_name="scheme_sectors") + op.drop_table("scheme_sectors") diff --git a/backend/alembic/versions/20260316_07_create_scheme_groups_table.py b/backend/alembic/versions/20260316_07_create_scheme_groups_table.py new file mode 100644 index 0000000..94cfb35 --- /dev/null +++ b/backend/alembic/versions/20260316_07_create_scheme_groups_table.py @@ -0,0 +1,42 @@ +"""create scheme_groups table + +Revision ID: 20260316_07 +Revises: 20260316_06 +Create Date: 2026-03-16 16:05:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_07" +down_revision = "20260316_06" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "scheme_groups", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("group_record_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("scheme_version_id", sa.String(length=32), nullable=False), + sa.Column("element_id", sa.String(length=512), nullable=True), + sa.Column("group_id", sa.String(length=255), nullable=True), + sa.Column("name", sa.String(length=512), nullable=False), + sa.Column("classes_raw", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["scheme_id"], ["schemes.scheme_id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["scheme_version_id"], ["scheme_versions.scheme_version_id"], ondelete="RESTRICT"), + ) + op.create_index("ix_scheme_groups_group_record_id", "scheme_groups", ["group_record_id"], unique=True) + op.create_index("ix_scheme_groups_scheme_id", "scheme_groups", ["scheme_id"], unique=False) + op.create_index("ix_scheme_groups_scheme_version_id", "scheme_groups", ["scheme_version_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_scheme_groups_scheme_version_id", table_name="scheme_groups") + op.drop_index("ix_scheme_groups_scheme_id", table_name="scheme_groups") + op.drop_index("ix_scheme_groups_group_record_id", table_name="scheme_groups") + op.drop_table("scheme_groups") diff --git a/backend/alembic/versions/20260316_08_create_scheme_seats_table.py b/backend/alembic/versions/20260316_08_create_scheme_seats_table.py new file mode 100644 index 0000000..38a5b1e --- /dev/null +++ b/backend/alembic/versions/20260316_08_create_scheme_seats_table.py @@ -0,0 +1,52 @@ +"""create scheme_seats table + +Revision ID: 20260316_08 +Revises: 20260316_07 +Create Date: 2026-03-16 16:20:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_08" +down_revision = "20260316_07" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "scheme_seats", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("seat_record_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("scheme_version_id", sa.String(length=32), nullable=False), + sa.Column("element_id", sa.String(length=512), nullable=True), + sa.Column("seat_id", sa.String(length=255), nullable=True), + sa.Column("sector_id", sa.String(length=255), nullable=True), + sa.Column("group_id", sa.String(length=255), nullable=True), + sa.Column("row_label", sa.String(length=255), nullable=True), + sa.Column("seat_number", sa.String(length=255), nullable=True), + sa.Column("tag", sa.String(length=64), nullable=True), + sa.Column("classes_raw", sa.Text(), nullable=True), + sa.Column("x", sa.Float(), nullable=True), + sa.Column("y", sa.Float(), nullable=True), + sa.Column("cx", sa.Float(), nullable=True), + sa.Column("cy", sa.Float(), nullable=True), + sa.Column("width", sa.Float(), nullable=True), + sa.Column("height", sa.Float(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["scheme_id"], ["schemes.scheme_id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["scheme_version_id"], ["scheme_versions.scheme_version_id"], ondelete="RESTRICT"), + ) + op.create_index("ix_scheme_seats_seat_record_id", "scheme_seats", ["seat_record_id"], unique=True) + op.create_index("ix_scheme_seats_scheme_id", "scheme_seats", ["scheme_id"], unique=False) + op.create_index("ix_scheme_seats_scheme_version_id", "scheme_seats", ["scheme_version_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_scheme_seats_scheme_version_id", table_name="scheme_seats") + op.drop_index("ix_scheme_seats_scheme_id", table_name="scheme_seats") + op.drop_index("ix_scheme_seats_seat_record_id", table_name="scheme_seats") + op.drop_table("scheme_seats") diff --git a/backend/alembic/versions/20260316_09_create_pricing_tables.py b/backend/alembic/versions/20260316_09_create_pricing_tables.py new file mode 100644 index 0000000..a48302f --- /dev/null +++ b/backend/alembic/versions/20260316_09_create_pricing_tables.py @@ -0,0 +1,59 @@ +"""create pricing tables + +Revision ID: 20260316_09 +Revises: 20260316_08 +Create Date: 2026-03-16 16:40:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_09" +down_revision = "20260316_08" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "pricing_categories", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("pricing_category_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("code", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["scheme_id"], ["schemes.scheme_id"], ondelete="RESTRICT"), + ) + op.create_index("ix_pricing_categories_pricing_category_id", "pricing_categories", ["pricing_category_id"], unique=True) + op.create_index("ix_pricing_categories_scheme_id", "pricing_categories", ["scheme_id"], unique=False) + + op.create_table( + "price_rules", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("price_rule_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("pricing_category_id", sa.String(length=32), nullable=True), + sa.Column("target_type", sa.String(length=32), nullable=False), + sa.Column("target_ref", sa.String(length=255), nullable=False), + sa.Column("amount", sa.Numeric(12, 2), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["scheme_id"], ["schemes.scheme_id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["pricing_category_id"], ["pricing_categories.pricing_category_id"], ondelete="SET NULL"), + ) + op.create_index("ix_price_rules_price_rule_id", "price_rules", ["price_rule_id"], unique=True) + op.create_index("ix_price_rules_scheme_id", "price_rules", ["scheme_id"], unique=False) + op.create_index("ix_price_rules_pricing_category_id", "price_rules", ["pricing_category_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_price_rules_pricing_category_id", table_name="price_rules") + op.drop_index("ix_price_rules_scheme_id", table_name="price_rules") + op.drop_index("ix_price_rules_price_rule_id", table_name="price_rules") + op.drop_table("price_rules") + + op.drop_index("ix_pricing_categories_scheme_id", table_name="pricing_categories") + op.drop_index("ix_pricing_categories_pricing_category_id", table_name="pricing_categories") + op.drop_table("pricing_categories") diff --git a/backend/alembic/versions/20260316_10_create_audit_events_table.py b/backend/alembic/versions/20260316_10_create_audit_events_table.py new file mode 100644 index 0000000..7e4ebad --- /dev/null +++ b/backend/alembic/versions/20260316_10_create_audit_events_table.py @@ -0,0 +1,38 @@ +"""create audit_events table + +Revision ID: 20260316_10 +Revises: 20260316_09 +Create Date: 2026-03-16 17:00:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260316_10" +down_revision = "20260316_09" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "audit_events", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("audit_event_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("object_type", sa.String(length=64), nullable=False), + sa.Column("object_ref", sa.String(length=255), nullable=True), + sa.Column("details_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["scheme_id"], ["schemes.scheme_id"], ondelete="RESTRICT"), + ) + op.create_index("ix_audit_events_audit_event_id", "audit_events", ["audit_event_id"], unique=True) + op.create_index("ix_audit_events_scheme_id", "audit_events", ["scheme_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_audit_events_scheme_id", table_name="audit_events") + op.drop_index("ix_audit_events_audit_event_id", table_name="audit_events") + op.drop_table("audit_events") diff --git a/backend/alembic/versions/20260318_11_add_display_svg_fields_to_scheme_versions.py b/backend/alembic/versions/20260318_11_add_display_svg_fields_to_scheme_versions.py new file mode 100644 index 0000000..d9762b6 --- /dev/null +++ b/backend/alembic/versions/20260318_11_add_display_svg_fields_to_scheme_versions.py @@ -0,0 +1,27 @@ +"""add display svg fields to scheme_versions + +Revision ID: 20260318_11 +Revises: 20260316_10 +Create Date: 2026-03-18 12:30:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260318_11" +down_revision = "20260316_10" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("scheme_versions", sa.Column("display_svg_storage_path", sa.Text(), nullable=True)) + op.add_column("scheme_versions", sa.Column("display_svg_status", sa.String(length=32), nullable=False, server_default="pending")) + op.add_column("scheme_versions", sa.Column("display_svg_generated_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("scheme_versions", "display_svg_generated_at") + op.drop_column("scheme_versions", "display_svg_status") + op.drop_column("scheme_versions", "display_svg_storage_path") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py new file mode 100644 index 0000000..bb68f59 --- /dev/null +++ b/backend/app/api/routes.py @@ -0,0 +1,1081 @@ +import json +import logging +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi.responses import FileResponse + +from app.core.config import settings +from app.db.session import db_ping +from app.repositories.audit import create_audit_event, list_audit_events +from app.repositories.pricing import ( + create_price_rule, + create_pricing_category, + delete_price_rule, + delete_pricing_category, + find_effective_price_rule, + list_price_rules, + list_pricing_categories, + update_price_rule, + update_pricing_category, +) +from app.repositories.scheme_groups import ( + clone_scheme_version_groups, + list_scheme_version_groups, + replace_scheme_version_groups, +) +from app.repositories.scheme_seats import ( + clone_scheme_version_seats, + get_scheme_version_seat_by_seat_id, + list_scheme_version_seats, + replace_scheme_version_seats, +) +from app.repositories.scheme_sectors import ( + clone_scheme_version_sectors, + list_scheme_version_sectors, + replace_scheme_version_sectors, +) +from app.repositories.scheme_versions import ( + count_scheme_versions, + create_initial_scheme_version, + create_next_scheme_version_from_current, + get_current_scheme_version, + list_scheme_versions, +) +from app.repositories.schemes import ( + count_scheme_records, + create_scheme_from_upload, + get_scheme_record_by_scheme_id, + list_scheme_records, + publish_scheme, + rollback_scheme_to_version, + unpublish_scheme, +) +from app.repositories.uploads import ( + count_upload_records, + create_upload_record, + get_upload_record_by_upload_id, + list_upload_records, +) +from app.schemas.audit import AuditEventItem, SchemeAuditResponse +from app.schemas.manifest import ServiceManifestResponse +from app.schemas.pricing import ( + DeleteResponse, + EffectiveSeatPriceResponse, + PriceRuleCreateRequest, + PriceRuleCreateResponse, + PriceRuleItem, + PriceRuleUpdateRequest, + PriceRuleUpdateResponse, + PricingCategoryCreateRequest, + PricingCategoryCreateResponse, + PricingCategoryItem, + PricingCategoryUpdateRequest, + PricingCategoryUpdateResponse, + SchemePricingResponse, +) +from app.schemas.scheme_groups import SchemeGroupItem, SchemeGroupListResponse +from app.schemas.scheme_registry import ( + SchemeCurrentResponse, + SchemeDetailResponse, + SchemeListItem, + SchemeListResponse, + SchemePublishResponse, + SchemeRollbackRequest, + SchemeRollbackResponse, +) +from app.schemas.scheme_seats import SchemeSeatItem, SchemeSeatListResponse +from app.schemas.scheme_sectors import SchemeSectorItem, SchemeSectorListResponse +from app.schemas.scheme_versions import ( + SchemeVersionCreateResponse, + SchemeVersionListItem, + SchemeVersionListResponse, +) +from app.schemas.test_mode import TestSeatPreviewResponse +from app.schemas.upload import UploadResponse +from app.schemas.upload_registry import UploadDetailResponse, UploadListItem, UploadListResponse +from app.security.auth import require_api_key +from app.services.normalized_reader import read_normalized_payload_from_path +from app.services.storage import ( + load_normalized_json, + save_normalized_json, + save_original_svg, + save_sanitized_svg, +) +from app.services.svg_inspector import inspect_svg_bytes +from app.services.svg_normalizer import normalize_svg_bytes_to_json +from app.services.svg_sanitizer import sanitize_svg_bytes + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/healthz") +async def healthz(): + return {"status": "ok"} + + +@router.get(f"{settings.api_v1_prefix}/db/ping") +async def ping_db(role: str = Depends(require_api_key)): + result = await db_ping() + return { + "database": "ok", + "result": result, + "host": settings.postgres_host, + "port": settings.postgres_port, + "db": settings.postgres_db, + } + + +@router.get(f"{settings.api_v1_prefix}/ping") +async def ping(role: str = Depends(require_api_key)): + return { + "message": "pong", + "prefix": settings.api_v1_prefix, + "role": role, + } + + +@router.get(f"{settings.api_v1_prefix}/auth/me") +async def auth_me(role: str = Depends(require_api_key)): + return { + "role": role, + "auth_header": settings.auth_header_name, + } + + +@router.get(f"{settings.api_v1_prefix}/manifest", response_model=ServiceManifestResponse) +async def get_manifest(role: str = Depends(require_api_key)): + return ServiceManifestResponse( + service=settings.app_name, + api_prefix=settings.api_v1_prefix, + auth_header_name=settings.auth_header_name, + svg_limits={ + "max_file_size_bytes": settings.svg_max_file_size_bytes, + "max_elements": settings.svg_max_elements, + }, + sanitization={ + "allow_internal_use_references_only": settings.svg_allow_internal_use_references_only, + "forbid_foreign_object_v1": settings.svg_forbid_foreign_object_v1, + "forbid_style_v1": settings.svg_forbid_style_v1, + "forbid_image_v1": settings.svg_forbid_image_v1, + "allowed_data_attributes": [ + "data-seat-id", + "data-sector-id", + "data-group-id", + "data-row", + "data-seat-number", + ], + }, + extraction_contract={ + "seat_fields": ["seat_id", "sector_id", "group_id", "row", "seat_number"], + "priority": [ + "data-* attributes", + "inherited parent sector/group", + "fallback to element id", + ], + }, + ) + + +@router.get(f"{settings.api_v1_prefix}/uploads", response_model=UploadListResponse) +async def get_uploads( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + role: str = Depends(require_api_key), +): + rows = await list_upload_records(limit=limit, offset=offset) + total = await count_upload_records() + + items = [ + UploadListItem( + upload_id=row.upload_id, + original_filename=row.original_filename, + content_type=row.content_type, + size_bytes=row.size_bytes, + element_count=row.element_count, + removed_elements_count=row.removed_elements_count, + removed_attributes_count=row.removed_attributes_count, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + original_storage_path=row.original_storage_path, + sanitized_storage_path=row.sanitized_storage_path, + normalized_storage_path=row.normalized_storage_path, + processing_status=row.processing_status, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + + return UploadListResponse(items=items, total=total) + + +@router.get(f"{settings.api_v1_prefix}/uploads/{{upload_id}}", response_model=UploadDetailResponse) +async def get_upload(upload_id: str, role: str = Depends(require_api_key)): + row = await get_upload_record_by_upload_id(upload_id) + + return UploadDetailResponse( + upload_id=row.upload_id, + original_filename=row.original_filename, + content_type=row.content_type, + size_bytes=row.size_bytes, + element_count=row.element_count, + removed_elements_count=row.removed_elements_count, + removed_attributes_count=row.removed_attributes_count, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + original_storage_path=row.original_storage_path, + sanitized_storage_path=row.sanitized_storage_path, + normalized_storage_path=row.normalized_storage_path, + processing_status=row.processing_status, + created_at=row.created_at.isoformat(), + ) + + +@router.get(f"{settings.api_v1_prefix}/uploads/{{upload_id}}/normalized") +async def get_normalized(upload_id: str, role: str = Depends(require_api_key)): + payload = load_normalized_json(upload_id) + return json.loads(payload) + + +@router.get(f"{settings.api_v1_prefix}/schemes", response_model=SchemeListResponse) +async def get_schemes( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + role: str = Depends(require_api_key), +): + rows = await list_scheme_records(limit=limit, offset=offset) + total = await count_scheme_records() + + items = [ + SchemeListItem( + scheme_id=row.scheme_id, + source_upload_id=row.source_upload_id, + name=row.name, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + + return SchemeListResponse(items=items, total=total) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}", response_model=SchemeDetailResponse) +async def get_scheme(scheme_id: str, role: str = Depends(require_api_key)): + row = await get_scheme_record_by_scheme_id(scheme_id) + + return SchemeDetailResponse( + scheme_id=row.scheme_id, + source_upload_id=row.source_upload_id, + name=row.name, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + created_at=row.created_at.isoformat(), + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/svg") +async def get_scheme_current_svg(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + upload = await get_upload_record_by_upload_id(scheme.source_upload_id) + + svg_path = Path(upload.sanitized_storage_path) + if not svg_path.exists() or not svg_path.is_file(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Current sanitized SVG not found", + ) + + filename = f"{scheme.name or scheme.scheme_id}.svg" + return FileResponse( + path=svg_path, + media_type="image/svg+xml", + filename=filename, + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/audit", response_model=SchemeAuditResponse) +async def get_scheme_audit(scheme_id: str, role: str = Depends(require_api_key)): + await get_scheme_record_by_scheme_id(scheme_id) + rows = await list_audit_events(scheme_id) + + return SchemeAuditResponse( + items=[ + AuditEventItem( + audit_event_id=row.audit_event_id, + scheme_id=row.scheme_id, + event_type=row.event_type, + object_type=row.object_type, + object_ref=row.object_ref, + details_json=row.details_json, + created_at=row.created_at.isoformat(), + ) + for row in rows + ], + total=len(rows), + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish", response_model=SchemePublishResponse) +async def publish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)): + row = await publish_scheme(scheme_id) + + await create_audit_event( + scheme_id=row.scheme_id, + event_type="scheme.published", + object_type="scheme", + object_ref=row.scheme_id, + details={ + "current_version_number": row.current_version_number, + "status": row.status, + }, + ) + + return SchemePublishResponse( + scheme_id=row.scheme_id, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse) +async def unpublish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)): + row = await unpublish_scheme(scheme_id) + + await create_audit_event( + scheme_id=row.scheme_id, + event_type="scheme.unpublished", + object_type="scheme", + object_ref=row.scheme_id, + details={ + "current_version_number": row.current_version_number, + "status": row.status, + }, + ) + + return SchemePublishResponse( + scheme_id=row.scheme_id, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/rollback", response_model=SchemeRollbackResponse) +async def rollback_scheme_endpoint( + scheme_id: str, + payload: SchemeRollbackRequest, + role: str = Depends(require_api_key), +): + row = await rollback_scheme_to_version( + scheme_id=scheme_id, + target_version_number=payload.target_version_number, + ) + + await create_audit_event( + scheme_id=row.scheme_id, + event_type="scheme.rolled_back", + object_type="scheme_version", + object_ref=str(payload.target_version_number), + details={ + "current_version_number": row.current_version_number, + "status": row.status, + }, + ) + + return SchemeRollbackResponse( + scheme_id=row.scheme_id, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionCreateResponse) +async def create_next_scheme_version_endpoint( + scheme_id: str, + role: str = Depends(require_api_key), +): + current_scheme = await get_scheme_record_by_scheme_id(scheme_id) + current_version = await get_current_scheme_version( + scheme_id=current_scheme.scheme_id, + current_version_number=current_scheme.current_version_number, + ) + + new_version = await create_next_scheme_version_from_current(scheme_id) + + await clone_scheme_version_sectors( + source_scheme_version_id=current_version.scheme_version_id, + target_scheme_version_id=new_version.scheme_version_id, + ) + await clone_scheme_version_groups( + source_scheme_version_id=current_version.scheme_version_id, + target_scheme_version_id=new_version.scheme_version_id, + ) + await clone_scheme_version_seats( + source_scheme_version_id=current_version.scheme_version_id, + target_scheme_version_id=new_version.scheme_version_id, + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="scheme.version.created", + object_type="scheme_version", + object_ref=new_version.scheme_version_id, + details={ + "source_scheme_version_id": current_version.scheme_version_id, + "version_number": new_version.version_number, + "normalized_storage_path": new_version.normalized_storage_path, + }, + ) + + return SchemeVersionCreateResponse( + scheme_id=new_version.scheme_id, + scheme_version_id=new_version.scheme_version_id, + version_number=new_version.version_number, + status=new_version.status, + normalized_storage_path=new_version.normalized_storage_path, + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current", response_model=SchemeCurrentResponse) +async def get_scheme_current(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + return SchemeCurrentResponse( + scheme_id=version.scheme_id, + scheme_version_id=version.scheme_version_id, + version_number=version.version_number, + status=version.status, + normalized_storage_path=version.normalized_storage_path, + normalized_elements_count=version.normalized_elements_count, + normalized_seats_count=version.normalized_seats_count, + normalized_groups_count=version.normalized_groups_count, + normalized_sectors_count=version.normalized_sectors_count, + created_at=version.created_at.isoformat(), + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionListResponse) +async def get_scheme_versions( + scheme_id: str, + limit: int = Query(default=100, ge=1, le=200), + offset: int = Query(default=0, ge=0), + role: str = Depends(require_api_key), +): + rows = await list_scheme_versions(scheme_id=scheme_id, limit=limit, offset=offset) + total = await count_scheme_versions(scheme_id=scheme_id) + + items = [ + SchemeVersionListItem( + scheme_version_id=row.scheme_version_id, + scheme_id=row.scheme_id, + version_number=row.version_number, + status=row.status, + normalized_storage_path=row.normalized_storage_path, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + + return SchemeVersionListResponse(items=items, total=total) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/sectors", response_model=SchemeSectorListResponse) +async def get_scheme_current_sectors(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + rows = await list_scheme_version_sectors(version.scheme_version_id) + + items = [ + SchemeSectorItem( + sector_record_id=row.sector_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + sector_id=row.sector_id, + name=row.name, + classes_raw=row.classes_raw, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + + return SchemeSectorListResponse(items=items, total=len(items)) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/groups", response_model=SchemeGroupListResponse) +async def get_scheme_current_groups(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + rows = await list_scheme_version_groups(version.scheme_version_id) + + items = [ + SchemeGroupItem( + group_record_id=row.group_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + group_id=row.group_id, + name=row.name, + classes_raw=row.classes_raw, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + + return SchemeGroupListResponse(items=items, total=len(items)) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/seats", response_model=SchemeSeatListResponse) +async def get_scheme_current_seats(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + rows = await list_scheme_version_seats(version.scheme_version_id) + + items = [ + SchemeSeatItem( + seat_record_id=row.seat_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + seat_id=row.seat_id, + sector_id=row.sector_id, + group_id=row.group_id, + row_label=row.row_label, + seat_number=row.seat_number, + tag=row.tag, + classes_raw=row.classes_raw, + x=row.x, + y=row.y, + cx=row.cx, + cy=row.cy, + width=row.width, + height=row.height, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + + return SchemeSeatListResponse(items=items, total=len(items)) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/seats/{{seat_id}}/price", response_model=EffectiveSeatPriceResponse) +async def get_effective_seat_price( + scheme_id: str, + seat_id: str, + role: str = Depends(require_api_key), +): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + seat = await get_scheme_version_seat_by_seat_id( + scheme_version_id=version.scheme_version_id, + seat_id=seat_id, + ) + + if not seat.seat_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Невозможно рассчитать цену: у места отсутствует seat_id", + ) + + matched_rule_level, rule = await find_effective_price_rule( + scheme_id=scheme.scheme_id, + seat_id=seat.seat_id, + group_id=seat.group_id, + sector_id=seat.sector_id, + ) + + return EffectiveSeatPriceResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + seat_id=seat.seat_id, + sector_id=seat.sector_id, + group_id=seat.group_id, + matched_rule_level=matched_rule_level, + matched_target_ref=rule["target_ref"], + pricing_category_id=rule["pricing_category_id"], + amount=rule["amount"], + currency=rule["currency"], + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/test/seats/{{seat_id}}", response_model=TestSeatPreviewResponse) +async def preview_test_seat( + scheme_id: str, + seat_id: str, + role: str = Depends(require_api_key), +): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + seat = await get_scheme_version_seat_by_seat_id( + scheme_version_id=version.scheme_version_id, + seat_id=seat_id, + ) + + if not seat.seat_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Невозможно построить preview: у места отсутствует seat_id", + ) + + matched_rule_level = None + matched_target_ref = None + pricing_category_id = None + amount = None + currency = None + has_price = False + + try: + matched_rule_level, rule = await find_effective_price_rule( + scheme_id=scheme.scheme_id, + seat_id=seat.seat_id, + group_id=seat.group_id, + sector_id=seat.sector_id, + ) + matched_target_ref = rule["target_ref"] + pricing_category_id = rule["pricing_category_id"] + amount = rule["amount"] + currency = rule["currency"] + has_price = True + except HTTPException as exc: + if exc.status_code != status.HTTP_404_NOT_FOUND: + raise + except Exception as exc: + logger.exception( + "preview_test_seat failed for scheme_id=%s seat_id=%s", + scheme_id, + seat_id, + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Не удалось построить preview: {exc.__class__.__name__}: {exc}", + ) + + return TestSeatPreviewResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + seat_id=seat.seat_id, + element_id=seat.element_id, + sector_id=seat.sector_id, + group_id=seat.group_id, + row_label=seat.row_label, + seat_number=seat.seat_number, + selectable=has_price, + has_price=has_price, + matched_rule_level=matched_rule_level, + matched_target_ref=matched_target_ref, + pricing_category_id=pricing_category_id, + amount=amount, + currency=currency, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories", response_model=PricingCategoryCreateResponse) +async def create_pricing_category_endpoint( + scheme_id: str, + payload: PricingCategoryCreateRequest, + role: str = Depends(require_api_key), +): + await get_scheme_record_by_scheme_id(scheme_id) + + pricing_category_id = await create_pricing_category( + scheme_id=scheme_id, + name=payload.name, + code=payload.code, + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.category.created", + object_type="pricing_category", + object_ref=pricing_category_id, + details={ + "name": payload.name, + "code": payload.code, + }, + ) + + return PricingCategoryCreateResponse( + pricing_category_id=pricing_category_id, + scheme_id=scheme_id, + name=payload.name, + code=payload.code, + ) + + +@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=PricingCategoryUpdateResponse) +async def update_pricing_category_endpoint( + scheme_id: str, + pricing_category_id: str, + payload: PricingCategoryUpdateRequest, + role: str = Depends(require_api_key), +): + row = await update_pricing_category( + scheme_id=scheme_id, + pricing_category_id=pricing_category_id, + name=payload.name, + code=payload.code, + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.category.updated", + object_type="pricing_category", + object_ref=pricing_category_id, + details={ + "name": payload.name, + "code": payload.code, + }, + ) + + return PricingCategoryUpdateResponse( + pricing_category_id=row.pricing_category_id, + scheme_id=row.scheme_id, + name=row.name, + code=row.code, + ) + + +@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=DeleteResponse) +async def delete_pricing_category_endpoint( + scheme_id: str, + pricing_category_id: str, + role: str = Depends(require_api_key), +): + await delete_pricing_category( + scheme_id=scheme_id, + pricing_category_id=pricing_category_id, + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.category.deleted", + object_type="pricing_category", + object_ref=pricing_category_id, + details=None, + ) + + return DeleteResponse(status="deleted") + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules", response_model=PriceRuleCreateResponse) +async def create_price_rule_endpoint( + scheme_id: str, + payload: PriceRuleCreateRequest, + role: str = Depends(require_api_key), +): + await get_scheme_record_by_scheme_id(scheme_id) + + price_rule_id = await create_price_rule( + scheme_id=scheme_id, + pricing_category_id=payload.pricing_category_id, + target_type=payload.target_type, + target_ref=payload.target_ref, + amount=payload.amount, + currency=payload.currency, + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.rule.created", + object_type="price_rule", + object_ref=price_rule_id, + details={ + "pricing_category_id": payload.pricing_category_id, + "target_type": payload.target_type, + "target_ref": payload.target_ref, + "amount": str(payload.amount), + "currency": payload.currency, + }, + ) + + return PriceRuleCreateResponse( + price_rule_id=price_rule_id, + scheme_id=scheme_id, + pricing_category_id=payload.pricing_category_id, + target_type=payload.target_type, + target_ref=payload.target_ref, + amount=payload.amount, + currency=payload.currency, + ) + + +@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=PriceRuleUpdateResponse) +async def update_price_rule_endpoint( + scheme_id: str, + price_rule_id: str, + payload: PriceRuleUpdateRequest, + role: str = Depends(require_api_key), +): + row = await update_price_rule( + scheme_id=scheme_id, + price_rule_id=price_rule_id, + pricing_category_id=payload.pricing_category_id, + target_type=payload.target_type, + target_ref=payload.target_ref, + amount=payload.amount, + currency=payload.currency, + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.rule.updated", + object_type="price_rule", + object_ref=price_rule_id, + details={ + "pricing_category_id": payload.pricing_category_id, + "target_type": payload.target_type, + "target_ref": payload.target_ref, + "amount": str(payload.amount), + "currency": payload.currency, + }, + ) + + return PriceRuleUpdateResponse( + price_rule_id=row.price_rule_id, + scheme_id=row.scheme_id, + pricing_category_id=row.pricing_category_id, + target_type=row.target_type, + target_ref=row.target_ref, + amount=row.amount, + currency=row.currency, + ) + + +@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=DeleteResponse) +async def delete_price_rule_endpoint( + scheme_id: str, + price_rule_id: str, + role: str = Depends(require_api_key), +): + await delete_price_rule( + scheme_id=scheme_id, + price_rule_id=price_rule_id, + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.rule.deleted", + object_type="price_rule", + object_ref=price_rule_id, + details=None, + ) + + return DeleteResponse(status="deleted") + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=SchemePricingResponse) +async def get_scheme_pricing( + scheme_id: str, + role: str = Depends(require_api_key), +): + await get_scheme_record_by_scheme_id(scheme_id) + + categories = await list_pricing_categories(scheme_id) + rules = await list_price_rules(scheme_id) + + return SchemePricingResponse( + categories=[ + PricingCategoryItem( + pricing_category_id=row.pricing_category_id, + scheme_id=row.scheme_id, + name=row.name, + code=row.code, + created_at=row.created_at.isoformat(), + ) + for row in categories + ], + rules=[ + PriceRuleItem( + price_rule_id=row.price_rule_id, + scheme_id=row.scheme_id, + pricing_category_id=row.pricing_category_id, + target_type=row.target_type, + target_ref=row.target_ref, + amount=row.amount, + currency=row.currency, + created_at=row.created_at.isoformat(), + ) + for row in rules + ], + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/upload", response_model=UploadResponse) +async def upload_scheme_svg( + file: UploadFile = File(...), + role: str = Depends(require_api_key), +): + filename = file.filename or "" + suffix = Path(filename).suffix.lower() + content_type = (file.content_type or "").lower() + + if suffix != ".svg" and content_type != "image/svg+xml": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only SVG files are allowed", + ) + + content = await file.read() + size_bytes = len(content) + + if size_bytes == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Uploaded file is empty", + ) + + if size_bytes > settings.svg_max_file_size_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="SVG file exceeds configured size limit", + ) + + element_count = inspect_svg_bytes(content) + sanitized_content, removed_elements_count, removed_attributes_count = sanitize_svg_bytes(content) + normalized_json, normalized_payload = normalize_svg_bytes_to_json(sanitized_content) + + upload_id, original_storage_path = save_original_svg(filename=filename, content=content) + sanitized_storage_path = save_sanitized_svg( + upload_id=upload_id, + filename=filename, + content=sanitized_content, + ) + normalized_storage_path = save_normalized_json( + upload_id=upload_id, + filename=filename, + content=normalized_json, + ) + + summary = normalized_payload["summary"] + + await create_upload_record( + upload_id=upload_id, + original_filename=filename, + content_type=content_type, + size_bytes=size_bytes, + element_count=element_count, + removed_elements_count=removed_elements_count, + removed_attributes_count=removed_attributes_count, + normalized_elements_count=summary["elements_count"], + normalized_seats_count=summary["seats_count"], + normalized_groups_count=summary["groups_count"], + normalized_sectors_count=summary["sectors_count"], + original_storage_path=original_storage_path, + sanitized_storage_path=sanitized_storage_path, + normalized_storage_path=normalized_storage_path, + processing_status="completed", + ) + + scheme_id = await create_scheme_from_upload( + source_upload_id=upload_id, + name=Path(filename).stem or filename, + normalized_elements_count=summary["elements_count"], + normalized_seats_count=summary["seats_count"], + normalized_groups_count=summary["groups_count"], + normalized_sectors_count=summary["sectors_count"], + ) + + scheme_version_id = await create_initial_scheme_version( + scheme_id=scheme_id, + normalized_storage_path=normalized_storage_path, + normalized_elements_count=summary["elements_count"], + normalized_seats_count=summary["seats_count"], + normalized_groups_count=summary["groups_count"], + normalized_sectors_count=summary["sectors_count"], + ) + + normalized_payload_from_file = read_normalized_payload_from_path(normalized_storage_path) + + await replace_scheme_version_sectors( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + sectors=normalized_payload_from_file.get("sectors", []), + ) + + await replace_scheme_version_groups( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + groups=normalized_payload_from_file.get("groups", []), + ) + + await replace_scheme_version_seats( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + seats=normalized_payload_from_file.get("seats", []), + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="scheme.created_from_upload", + object_type="upload", + object_ref=upload_id, + details={ + "scheme_version_id": scheme_version_id, + "filename": filename, + "normalized_elements_count": summary["elements_count"], + "normalized_seats_count": summary["seats_count"], + "normalized_groups_count": summary["groups_count"], + "normalized_sectors_count": summary["sectors_count"], + }, + ) + + return UploadResponse( + upload_id=upload_id, + filename=filename, + content_type=content_type, + size_bytes=size_bytes, + element_count=element_count, + removed_elements_count=removed_elements_count, + removed_attributes_count=removed_attributes_count, + normalized_elements_count=summary["elements_count"], + normalized_seats_count=summary["seats_count"], + normalized_groups_count=summary["groups_count"], + normalized_sectors_count=summary["sectors_count"], + svg_max_file_size_bytes=settings.svg_max_file_size_bytes, + svg_max_elements=settings.svg_max_elements, + original_storage_path=original_storage_path, + sanitized_storage_path=sanitized_storage_path, + normalized_storage_path=normalized_storage_path, + accepted=True, + ) diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..510b283 --- /dev/null +++ b/backend/app/api/routes/__init__.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from app.api.routes.audit import router as audit_router +from app.api.routes.pricing import router as pricing_router +from app.api.routes.schemes import router as schemes_router +from app.api.routes.structure import router as structure_router +from app.api.routes.system import router as system_router +from app.api.routes.test_mode import router as test_mode_router +from app.api.routes.uploads import router as uploads_router + +router = APIRouter() +router.include_router(system_router) +router.include_router(uploads_router) +router.include_router(schemes_router) +router.include_router(structure_router) +router.include_router(pricing_router) +router.include_router(test_mode_router) +router.include_router(audit_router) diff --git a/backend/app/api/routes/audit.py b/backend/app/api/routes/audit.py new file mode 100644 index 0000000..dc17c55 --- /dev/null +++ b/backend/app/api/routes/audit.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends + +from app.core.config import settings +from app.repositories.audit import list_audit_events +from app.repositories.schemes import get_scheme_record_by_scheme_id +from app.schemas.audit import AuditEventItem, SchemeAuditResponse +from app.security.auth import require_api_key + +router = APIRouter() + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/audit", response_model=SchemeAuditResponse) +async def get_scheme_audit(scheme_id: str, role: str = Depends(require_api_key)): + await get_scheme_record_by_scheme_id(scheme_id) + rows = await list_audit_events(scheme_id) + + return SchemeAuditResponse( + items=[ + AuditEventItem( + audit_event_id=row.audit_event_id, + scheme_id=row.scheme_id, + event_type=row.event_type, + object_type=row.object_type, + object_ref=row.object_ref, + details_json=row.details_json, + created_at=row.created_at.isoformat(), + ) + for row in rows + ], + total=len(rows), + ) diff --git a/backend/app/api/routes/pricing.py b/backend/app/api/routes/pricing.py new file mode 100644 index 0000000..321865f --- /dev/null +++ b/backend/app/api/routes/pricing.py @@ -0,0 +1,233 @@ +from fastapi import APIRouter, Depends + +from app.core.config import settings +from app.repositories.audit import create_audit_event +from app.repositories.pricing import ( + create_price_rule, + create_pricing_category, + delete_price_rule, + delete_pricing_category, + list_price_rules, + list_pricing_categories, + update_price_rule, + update_pricing_category, +) +from app.repositories.schemes import get_scheme_record_by_scheme_id +from app.schemas.pricing import ( + DeleteResponse, + PriceRuleCreateRequest, + PriceRuleCreateResponse, + PriceRuleItem, + PriceRuleUpdateRequest, + PriceRuleUpdateResponse, + PricingCategoryCreateRequest, + PricingCategoryCreateResponse, + PricingCategoryItem, + PricingCategoryUpdateRequest, + PricingCategoryUpdateResponse, + SchemePricingResponse, +) +from app.security.auth import require_api_key + +router = APIRouter() + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=SchemePricingResponse) +async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key)): + await get_scheme_record_by_scheme_id(scheme_id) + categories = await list_pricing_categories(scheme_id) + rules = await list_price_rules(scheme_id) + + return SchemePricingResponse( + categories=[ + PricingCategoryItem( + pricing_category_id=row.pricing_category_id, + scheme_id=row.scheme_id, + name=row.name, + code=row.code, + created_at=row.created_at.isoformat(), + ) + for row in categories + ], + rules=[ + PriceRuleItem( + price_rule_id=row.price_rule_id, + scheme_id=row.scheme_id, + pricing_category_id=row.pricing_category_id, + target_type=row.target_type, + target_ref=row.target_ref, + amount=row.amount, + currency=row.currency, + created_at=row.created_at.isoformat(), + ) + for row in rules + ], + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories", response_model=PricingCategoryCreateResponse) +async def create_pricing_category_endpoint( + scheme_id: str, + payload: PricingCategoryCreateRequest, + role: str = Depends(require_api_key), +): + await get_scheme_record_by_scheme_id(scheme_id) + pricing_category_id = await create_pricing_category( + scheme_id=scheme_id, + name=payload.name, + code=payload.code, + ) + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.category.created", + object_type="pricing_category", + object_ref=pricing_category_id, + details={"name": payload.name, "code": payload.code}, + ) + return PricingCategoryCreateResponse( + pricing_category_id=pricing_category_id, + scheme_id=scheme_id, + name=payload.name, + code=payload.code, + ) + + +@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=PricingCategoryUpdateResponse) +async def update_pricing_category_endpoint( + scheme_id: str, + pricing_category_id: str, + payload: PricingCategoryUpdateRequest, + role: str = Depends(require_api_key), +): + row = await update_pricing_category( + scheme_id=scheme_id, + pricing_category_id=pricing_category_id, + name=payload.name, + code=payload.code, + ) + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.category.updated", + object_type="pricing_category", + object_ref=pricing_category_id, + details={"name": payload.name, "code": payload.code}, + ) + return PricingCategoryUpdateResponse( + pricing_category_id=row.pricing_category_id, + scheme_id=row.scheme_id, + name=row.name, + code=row.code, + ) + + +@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=DeleteResponse) +async def delete_pricing_category_endpoint( + scheme_id: str, + pricing_category_id: str, + role: str = Depends(require_api_key), +): + await delete_pricing_category(scheme_id=scheme_id, pricing_category_id=pricing_category_id) + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.category.deleted", + object_type="pricing_category", + object_ref=pricing_category_id, + details=None, + ) + return DeleteResponse(status="deleted") + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules", response_model=PriceRuleCreateResponse) +async def create_price_rule_endpoint( + scheme_id: str, + payload: PriceRuleCreateRequest, + role: str = Depends(require_api_key), +): + await get_scheme_record_by_scheme_id(scheme_id) + price_rule_id = await create_price_rule( + scheme_id=scheme_id, + pricing_category_id=payload.pricing_category_id, + target_type=payload.target_type, + target_ref=payload.target_ref, + amount=payload.amount, + currency=payload.currency, + ) + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.rule.created", + object_type="price_rule", + object_ref=price_rule_id, + details={ + "pricing_category_id": payload.pricing_category_id, + "target_type": payload.target_type, + "target_ref": payload.target_ref, + "amount": str(payload.amount), + "currency": payload.currency, + }, + ) + return PriceRuleCreateResponse( + price_rule_id=price_rule_id, + scheme_id=scheme_id, + pricing_category_id=payload.pricing_category_id, + target_type=payload.target_type, + target_ref=payload.target_ref, + amount=payload.amount, + currency=payload.currency, + ) + + +@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=PriceRuleUpdateResponse) +async def update_price_rule_endpoint( + scheme_id: str, + price_rule_id: str, + payload: PriceRuleUpdateRequest, + role: str = Depends(require_api_key), +): + row = await update_price_rule( + scheme_id=scheme_id, + price_rule_id=price_rule_id, + pricing_category_id=payload.pricing_category_id, + target_type=payload.target_type, + target_ref=payload.target_ref, + amount=payload.amount, + currency=payload.currency, + ) + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.rule.updated", + object_type="price_rule", + object_ref=price_rule_id, + details={ + "pricing_category_id": payload.pricing_category_id, + "target_type": payload.target_type, + "target_ref": payload.target_ref, + "amount": str(payload.amount), + "currency": payload.currency, + }, + ) + return PriceRuleUpdateResponse( + price_rule_id=row.price_rule_id, + scheme_id=row.scheme_id, + pricing_category_id=row.pricing_category_id, + target_type=row.target_type, + target_ref=row.target_ref, + amount=row.amount, + currency=row.currency, + ) + + +@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=DeleteResponse) +async def delete_price_rule_endpoint( + scheme_id: str, + price_rule_id: str, + role: str = Depends(require_api_key), +): + await delete_price_rule(scheme_id=scheme_id, price_rule_id=price_rule_id) + await create_audit_event( + scheme_id=scheme_id, + event_type="pricing.rule.deleted", + object_type="price_rule", + object_ref=price_rule_id, + details=None, + ) + return DeleteResponse(status="deleted") diff --git a/backend/app/api/routes/schemes.py b/backend/app/api/routes/schemes.py new file mode 100644 index 0000000..cb44ec2 --- /dev/null +++ b/backend/app/api/routes/schemes.py @@ -0,0 +1,241 @@ +from fastapi import APIRouter, Depends, Query + +from app.core.config import settings +from app.repositories.audit import create_audit_event +from app.repositories.scheme_groups import clone_scheme_version_groups +from app.repositories.scheme_seats import clone_scheme_version_seats +from app.repositories.scheme_sectors import clone_scheme_version_sectors +from app.repositories.scheme_versions import ( + count_scheme_versions, + create_next_scheme_version_from_current, + get_current_scheme_version, + list_scheme_versions, +) +from app.repositories.schemes import ( + count_scheme_records, + get_scheme_record_by_scheme_id, + list_scheme_records, + publish_scheme, + rollback_scheme_to_version, + unpublish_scheme, +) +from app.schemas.scheme_registry import ( + SchemeCurrentResponse, + SchemeDetailResponse, + SchemeListItem, + SchemeListResponse, + SchemePublishResponse, + SchemeRollbackRequest, + SchemeRollbackResponse, +) +from app.schemas.scheme_versions import ( + SchemeVersionCreateResponse, + SchemeVersionListItem, + SchemeVersionListResponse, +) +from app.security.auth import require_api_key + +router = APIRouter() + + +@router.get(f"{settings.api_v1_prefix}/schemes", response_model=SchemeListResponse) +async def get_schemes( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + role: str = Depends(require_api_key), +): + rows = await list_scheme_records(limit=limit, offset=offset) + total = await count_scheme_records() + + items = [ + SchemeListItem( + scheme_id=row.scheme_id, + source_upload_id=row.source_upload_id, + name=row.name, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + return SchemeListResponse(items=items, total=total) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}", response_model=SchemeDetailResponse) +async def get_scheme(scheme_id: str, role: str = Depends(require_api_key)): + row = await get_scheme_record_by_scheme_id(scheme_id) + return SchemeDetailResponse( + scheme_id=row.scheme_id, + source_upload_id=row.source_upload_id, + name=row.name, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + created_at=row.created_at.isoformat(), + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current", response_model=SchemeCurrentResponse) +async def get_scheme_current(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + return SchemeCurrentResponse( + scheme_id=version.scheme_id, + scheme_version_id=version.scheme_version_id, + version_number=version.version_number, + status=version.status, + normalized_storage_path=version.normalized_storage_path, + normalized_elements_count=version.normalized_elements_count, + normalized_seats_count=version.normalized_seats_count, + normalized_groups_count=version.normalized_groups_count, + normalized_sectors_count=version.normalized_sectors_count, + created_at=version.created_at.isoformat(), + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionListResponse) +async def get_scheme_versions( + scheme_id: str, + limit: int = Query(default=100, ge=1, le=200), + offset: int = Query(default=0, ge=0), + role: str = Depends(require_api_key), +): + rows = await list_scheme_versions(scheme_id=scheme_id, limit=limit, offset=offset) + total = await count_scheme_versions(scheme_id=scheme_id) + + items = [ + SchemeVersionListItem( + scheme_version_id=row.scheme_version_id, + scheme_id=row.scheme_id, + version_number=row.version_number, + status=row.status, + normalized_storage_path=row.normalized_storage_path, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + return SchemeVersionListResponse(items=items, total=total) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionCreateResponse) +async def create_next_scheme_version_endpoint( + scheme_id: str, + role: str = Depends(require_api_key), +): + current_scheme = await get_scheme_record_by_scheme_id(scheme_id) + current_version = await get_current_scheme_version( + scheme_id=current_scheme.scheme_id, + current_version_number=current_scheme.current_version_number, + ) + + new_version = await create_next_scheme_version_from_current(scheme_id) + + await clone_scheme_version_sectors( + source_scheme_version_id=current_version.scheme_version_id, + target_scheme_version_id=new_version.scheme_version_id, + ) + await clone_scheme_version_groups( + source_scheme_version_id=current_version.scheme_version_id, + target_scheme_version_id=new_version.scheme_version_id, + ) + await clone_scheme_version_seats( + source_scheme_version_id=current_version.scheme_version_id, + target_scheme_version_id=new_version.scheme_version_id, + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="scheme.version.created", + object_type="scheme_version", + object_ref=new_version.scheme_version_id, + details={ + "source_scheme_version_id": current_version.scheme_version_id, + "version_number": new_version.version_number, + "normalized_storage_path": new_version.normalized_storage_path, + }, + ) + + return SchemeVersionCreateResponse( + scheme_id=new_version.scheme_id, + scheme_version_id=new_version.scheme_version_id, + version_number=new_version.version_number, + status=new_version.status, + normalized_storage_path=new_version.normalized_storage_path, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish", response_model=SchemePublishResponse) +async def publish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)): + row = await publish_scheme(scheme_id) + await create_audit_event( + scheme_id=row.scheme_id, + event_type="scheme.published", + object_type="scheme", + object_ref=row.scheme_id, + details={"current_version_number": row.current_version_number, "status": row.status}, + ) + return SchemePublishResponse( + scheme_id=row.scheme_id, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse) +async def unpublish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)): + row = await unpublish_scheme(scheme_id) + await create_audit_event( + scheme_id=row.scheme_id, + event_type="scheme.unpublished", + object_type="scheme", + object_ref=row.scheme_id, + details={"current_version_number": row.current_version_number, "status": row.status}, + ) + return SchemePublishResponse( + scheme_id=row.scheme_id, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/rollback", response_model=SchemeRollbackResponse) +async def rollback_scheme_endpoint( + scheme_id: str, + payload: SchemeRollbackRequest, + role: str = Depends(require_api_key), +): + row = await rollback_scheme_to_version( + scheme_id=scheme_id, + target_version_number=payload.target_version_number, + ) + await create_audit_event( + scheme_id=row.scheme_id, + event_type="scheme.rolled_back", + object_type="scheme_version", + object_ref=str(payload.target_version_number), + details={"current_version_number": row.current_version_number, "status": row.status}, + ) + return SchemeRollbackResponse( + scheme_id=row.scheme_id, + status=row.status, + current_version_number=row.current_version_number, + published_at=row.published_at.isoformat() if row.published_at else None, + ) diff --git a/backend/app/api/routes/structure.py b/backend/app/api/routes/structure.py new file mode 100644 index 0000000..8c6d230 --- /dev/null +++ b/backend/app/api/routes/structure.py @@ -0,0 +1,331 @@ +import logging +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi.responses import FileResponse +from lxml import etree + +from app.core.config import settings +from app.repositories.pricing import find_effective_price_rule +from app.repositories.scheme_groups import list_scheme_version_groups +from app.repositories.scheme_seats import get_scheme_version_seat_by_seat_id, list_scheme_version_seats +from app.repositories.scheme_sectors import list_scheme_version_sectors +from app.repositories.scheme_versions import ( + get_current_scheme_version, + update_scheme_version_display_artifact, +) +from app.repositories.schemes import get_scheme_record_by_scheme_id +from app.repositories.uploads import get_upload_record_by_upload_id +from app.schemas.pricing import EffectiveSeatPriceResponse +from app.schemas.scheme_groups import SchemeGroupItem, SchemeGroupListResponse +from app.schemas.scheme_seats import SchemeSeatItem, SchemeSeatListResponse +from app.schemas.scheme_sectors import SchemeSectorItem, SchemeSectorListResponse +from app.security.auth import require_api_key +from app.services.storage import save_display_svg +from app.services.svg_display_processor import ALLOWED_MODES, generate_display_svg + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _parse_svg_meta_from_bytes(content: bytes) -> dict: + parser = etree.XMLParser( + resolve_entities=False, + remove_blank_text=False, + remove_comments=False, + no_network=True, + recover=False, + huge_tree=True, + ) + root = etree.fromstring(content, parser=parser) + return { + "view_box": root.attrib.get("viewBox"), + "width": root.attrib.get("width"), + "height": root.attrib.get("height"), + } + + +def _resolve_mode(mode: str | None) -> str: + resolved = mode or settings.svg_display_mode + if resolved not in ALLOWED_MODES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported display mode: {resolved}", + ) + return resolved + + +async def _load_current_context(scheme_id: str): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + upload = await get_upload_record_by_upload_id(scheme.source_upload_id) + return scheme, version, upload + + +async def _generate_default_display_artifact_if_needed(scheme, version, upload) -> tuple[bytes, Path]: + if version.display_svg_status == "ready" and version.display_svg_storage_path: + path = Path(version.display_svg_storage_path) + if path.exists() and path.is_file(): + return path.read_bytes(), path + + sanitized_path = Path(upload.sanitized_storage_path) + if not sanitized_path.exists() or not sanitized_path.is_file(): + if version.display_svg_status == "pending": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Current scheme version is not ready for display rendering", + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Display SVG not found for current scheme version", + ) + + sanitized_bytes = sanitized_path.read_bytes() + try: + display_bytes, meta = generate_display_svg(sanitized_bytes, settings.svg_display_mode) + except Exception: + logger.exception( + "display_svg.lazy_generate failed scheme_id=%s scheme_version_id=%s mode=%s", + scheme.scheme_id, + version.scheme_version_id, + settings.svg_display_mode, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Current scheme version is not ready for display rendering", + ) + + display_path_str = save_display_svg( + upload_id=upload.upload_id, + filename=upload.original_filename, + content=display_bytes, + ) + display_path = Path(display_path_str) + + await update_scheme_version_display_artifact( + scheme_version_id=version.scheme_version_id, + display_svg_storage_path=display_path_str, + display_svg_status="ready", + display_svg_generated_at=datetime.now(timezone.utc), + ) + + logger.info( + "display_svg.lazy_generate scheme_id=%s scheme_version_id=%s mode=%s view_box=%s", + scheme.scheme_id, + version.scheme_version_id, + settings.svg_display_mode, + meta.get("view_box"), + ) + + return display_bytes, display_path + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/sectors", response_model=SchemeSectorListResponse) +async def get_scheme_current_sectors(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version(scheme_id=scheme.scheme_id, current_version_number=scheme.current_version_number) + rows = await list_scheme_version_sectors(version.scheme_version_id) + + items = [ + SchemeSectorItem( + sector_record_id=row.sector_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + sector_id=row.sector_id, + name=row.name, + classes_raw=row.classes_raw, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + return SchemeSectorListResponse(items=items, total=len(items)) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/groups", response_model=SchemeGroupListResponse) +async def get_scheme_current_groups(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version(scheme_id=scheme.scheme_id, current_version_number=scheme.current_version_number) + rows = await list_scheme_version_groups(version.scheme_version_id) + + items = [ + SchemeGroupItem( + group_record_id=row.group_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + group_id=row.group_id, + name=row.name, + classes_raw=row.classes_raw, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + return SchemeGroupListResponse(items=items, total=len(items)) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/seats", response_model=SchemeSeatListResponse) +async def get_scheme_current_seats(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version(scheme_id=scheme.scheme_id, current_version_number=scheme.current_version_number) + rows = await list_scheme_version_seats(version.scheme_version_id) + + items = [ + SchemeSeatItem( + seat_record_id=row.seat_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + seat_id=row.seat_id, + sector_id=row.sector_id, + group_id=row.group_id, + row_label=row.row_label, + seat_number=row.seat_number, + tag=row.tag, + classes_raw=row.classes_raw, + x=row.x, + y=row.y, + cx=row.cx, + cy=row.cy, + width=row.width, + height=row.height, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + return SchemeSeatListResponse(items=items, total=len(items)) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/seats/{{seat_id}}/price", response_model=EffectiveSeatPriceResponse) +async def get_effective_seat_price(scheme_id: str, seat_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version(scheme_id=scheme.scheme_id, current_version_number=scheme.current_version_number) + seat = await get_scheme_version_seat_by_seat_id(scheme_version_id=version.scheme_version_id, seat_id=seat_id) + + if not seat.seat_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Невозможно рассчитать цену: у места отсутствует seat_id") + + matched_rule_level, rule = await find_effective_price_rule( + scheme_id=scheme.scheme_id, + seat_id=seat.seat_id, + group_id=seat.group_id, + sector_id=seat.sector_id, + ) + + return EffectiveSeatPriceResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + seat_id=seat.seat_id, + sector_id=seat.sector_id, + group_id=seat.group_id, + matched_rule_level=matched_rule_level, + matched_target_ref=rule["target_ref"], + pricing_category_id=rule["pricing_category_id"], + amount=rule["amount"], + currency=rule["currency"], + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/svg") +async def get_scheme_current_svg(scheme_id: str, role: str = Depends(require_api_key)): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + upload = await get_upload_record_by_upload_id(scheme.source_upload_id) + + svg_path = Path(upload.sanitized_storage_path) + if not svg_path.exists() or not svg_path.is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Current sanitized SVG not found") + + filename = f"{scheme.name or scheme.scheme_id}.svg" + return FileResponse(path=svg_path, media_type="image/svg+xml", filename=filename) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/svg/display") +async def get_scheme_current_display_svg( + scheme_id: str, + mode: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + resolved_mode = _resolve_mode(mode) + scheme, version, upload = await _load_current_context(scheme_id) + + if resolved_mode == settings.svg_display_mode: + display_bytes, display_path = await _generate_default_display_artifact_if_needed(scheme, version, upload) + filename = f"{scheme.name or scheme.scheme_id}.{resolved_mode}.svg" + return FileResponse(path=display_path, media_type="image/svg+xml", filename=filename) + + sanitized_path = Path(upload.sanitized_storage_path) + if not sanitized_path.exists() or not sanitized_path.is_file(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Display SVG not found for current scheme version", + ) + + sanitized_bytes = sanitized_path.read_bytes() + try: + display_bytes, _meta = generate_display_svg(sanitized_bytes, resolved_mode) + except Exception: + logger.exception( + "display_svg.on_demand failed scheme_id=%s scheme_version_id=%s mode=%s", + scheme.scheme_id, + version.scheme_version_id, + resolved_mode, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Current scheme version is not ready for display rendering", + ) + + return Response(content=display_bytes, media_type="image/svg+xml") + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/svg/display/meta") +async def get_scheme_current_display_svg_meta( + scheme_id: str, + mode: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + resolved_mode = _resolve_mode(mode) + scheme, version, upload = await _load_current_context(scheme_id) + + if resolved_mode == settings.svg_display_mode: + display_bytes, _display_path = await _generate_default_display_artifact_if_needed(scheme, version, upload) + meta = _parse_svg_meta_from_bytes(display_bytes) + generated_at = version.display_svg_generated_at + else: + sanitized_path = Path(upload.sanitized_storage_path) + if not sanitized_path.exists() or not sanitized_path.is_file(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Display SVG not found for current scheme version", + ) + + sanitized_bytes = sanitized_path.read_bytes() + try: + display_bytes, meta = generate_display_svg(sanitized_bytes, resolved_mode) + except Exception: + logger.exception( + "display_svg.meta_on_demand failed scheme_id=%s scheme_version_id=%s mode=%s", + scheme.scheme_id, + version.scheme_version_id, + resolved_mode, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Current scheme version is not ready for display rendering", + ) + meta = _parse_svg_meta_from_bytes(display_bytes) + generated_at = datetime.now(timezone.utc) + + return { + "scheme_id": scheme.scheme_id, + "scheme_version_id": version.scheme_version_id, + "display_svg_available": True, + "view_box": meta["view_box"], + "width": meta["width"], + "height": meta["height"], + "generated_at": generated_at.isoformat() if generated_at else None, + } diff --git a/backend/app/api/routes/system.py b/backend/app/api/routes/system.py new file mode 100644 index 0000000..0ca63b6 --- /dev/null +++ b/backend/app/api/routes/system.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends + +from app.core.config import settings +from app.db.session import db_ping +from app.schemas.manifest import ServiceManifestResponse +from app.security.auth import require_api_key + +router = APIRouter() + + +@router.get("/healthz") +async def healthz(): + return {"status": "ok"} + + +@router.get(f"{settings.api_v1_prefix}/db/ping") +async def ping_db(role: str = Depends(require_api_key)): + result = await db_ping() + return { + "database": "ok", + "result": result, + "host": settings.postgres_host, + "port": settings.postgres_port, + "db": settings.postgres_db, + } + + +@router.get(f"{settings.api_v1_prefix}/ping") +async def ping(role: str = Depends(require_api_key)): + return { + "message": "pong", + "prefix": settings.api_v1_prefix, + "role": role, + } + + +@router.get(f"{settings.api_v1_prefix}/auth/me") +async def auth_me(role: str = Depends(require_api_key)): + return { + "role": role, + "auth_header": settings.auth_header_name, + } + + +@router.get(f"{settings.api_v1_prefix}/manifest", response_model=ServiceManifestResponse) +async def get_manifest(role: str = Depends(require_api_key)): + return ServiceManifestResponse( + service=settings.app_name, + api_prefix=settings.api_v1_prefix, + auth_header_name=settings.auth_header_name, + svg_limits={ + "max_file_size_bytes": settings.svg_max_file_size_bytes, + "max_elements": settings.svg_max_elements, + }, + sanitization={ + "allow_internal_use_references_only": settings.svg_allow_internal_use_references_only, + "forbid_foreign_object_v1": settings.svg_forbid_foreign_object_v1, + "forbid_style_v1": settings.svg_forbid_style_v1, + "forbid_image_v1": settings.svg_forbid_image_v1, + "allowed_data_attributes": [ + "data-seat-id", + "data-sector-id", + "data-group-id", + "data-row", + "data-seat-number", + ], + }, + extraction_contract={ + "seat_fields": ["seat_id", "sector_id", "group_id", "row", "seat_number"], + "priority": [ + "data-* attributes", + "inherited parent sector/group", + "fallback to element id", + ], + }, + ) diff --git a/backend/app/api/routes/test_mode.py b/backend/app/api/routes/test_mode.py new file mode 100644 index 0000000..af46bd9 --- /dev/null +++ b/backend/app/api/routes/test_mode.py @@ -0,0 +1,88 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.core.config import settings +from app.repositories.pricing import find_effective_price_rule +from app.repositories.scheme_seats import get_scheme_version_seat_by_seat_id +from app.repositories.scheme_versions import get_current_scheme_version +from app.repositories.schemes import get_scheme_record_by_scheme_id +from app.schemas.test_mode import TestSeatPreviewResponse +from app.security.auth import require_api_key + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/test/seats/{{seat_id}}", response_model=TestSeatPreviewResponse) +async def preview_test_seat( + scheme_id: str, + seat_id: str, + role: str = Depends(require_api_key), +): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + seat = await get_scheme_version_seat_by_seat_id( + scheme_version_id=version.scheme_version_id, + seat_id=seat_id, + ) + + if not seat.seat_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Невозможно построить preview: у места отсутствует seat_id", + ) + + matched_rule_level = None + matched_target_ref = None + pricing_category_id = None + amount = None + currency = None + has_price = False + + try: + matched_rule_level, rule = await find_effective_price_rule( + scheme_id=scheme.scheme_id, + seat_id=seat.seat_id, + group_id=seat.group_id, + sector_id=seat.sector_id, + ) + matched_target_ref = rule["target_ref"] + pricing_category_id = rule["pricing_category_id"] + amount = rule["amount"] + currency = rule["currency"] + has_price = True + except HTTPException as exc: + if exc.status_code != status.HTTP_404_NOT_FOUND: + raise + except Exception as exc: + logger.exception( + "preview_test_seat failed for scheme_id=%s seat_id=%s", + scheme_id, + seat_id, + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Не удалось построить preview: {exc.__class__.__name__}: {exc}", + ) + + return TestSeatPreviewResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + seat_id=seat.seat_id, + element_id=seat.element_id, + sector_id=seat.sector_id, + group_id=seat.group_id, + row_label=seat.row_label, + seat_number=seat.seat_number, + selectable=has_price, + has_price=has_price, + matched_rule_level=matched_rule_level, + matched_target_ref=matched_target_ref, + pricing_category_id=pricing_category_id, + amount=amount, + currency=currency, + ) diff --git a/backend/app/api/routes/uploads.py b/backend/app/api/routes/uploads.py new file mode 100644 index 0000000..bf1f409 --- /dev/null +++ b/backend/app/api/routes/uploads.py @@ -0,0 +1,261 @@ +import json +import logging +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status + +from app.core.config import settings +from app.repositories.scheme_groups import replace_scheme_version_groups +from app.repositories.scheme_seats import replace_scheme_version_seats +from app.repositories.scheme_sectors import replace_scheme_version_sectors +from app.repositories.scheme_versions import create_initial_scheme_version +from app.repositories.schemes import create_scheme_from_upload +from app.repositories.uploads import ( + count_upload_records, + create_upload_record, + get_upload_record_by_upload_id, + list_upload_records, +) +from app.schemas.upload import UploadResponse +from app.schemas.upload_registry import UploadDetailResponse, UploadListItem, UploadListResponse +from app.security.auth import require_api_key +from app.services.normalized_reader import read_normalized_payload_from_path +from app.services.storage import ( + load_normalized_json, + save_display_svg, + save_normalized_json, + save_original_svg, + save_sanitized_svg, +) +from app.services.svg_display_processor import generate_display_svg +from app.services.svg_inspector import inspect_svg_bytes +from app.services.svg_normalizer import normalize_svg_bytes_to_json +from app.services.svg_sanitizer import sanitize_svg_bytes + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get(f"{settings.api_v1_prefix}/uploads", response_model=UploadListResponse) +async def get_uploads( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + role: str = Depends(require_api_key), +): + rows = await list_upload_records(limit=limit, offset=offset) + total = await count_upload_records() + + items = [ + UploadListItem( + upload_id=row.upload_id, + original_filename=row.original_filename, + content_type=row.content_type, + size_bytes=row.size_bytes, + element_count=row.element_count, + removed_elements_count=row.removed_elements_count, + removed_attributes_count=row.removed_attributes_count, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + original_storage_path=row.original_storage_path, + sanitized_storage_path=row.sanitized_storage_path, + normalized_storage_path=row.normalized_storage_path, + processing_status=row.processing_status, + created_at=row.created_at.isoformat(), + ) + for row in rows + ] + + return UploadListResponse(items=items, total=total) + + +@router.get(f"{settings.api_v1_prefix}/uploads/{{upload_id}}", response_model=UploadDetailResponse) +async def get_upload(upload_id: str, role: str = Depends(require_api_key)): + row = await get_upload_record_by_upload_id(upload_id) + + return UploadDetailResponse( + upload_id=row.upload_id, + original_filename=row.original_filename, + content_type=row.content_type, + size_bytes=row.size_bytes, + element_count=row.element_count, + removed_elements_count=row.removed_elements_count, + removed_attributes_count=row.removed_attributes_count, + normalized_elements_count=row.normalized_elements_count, + normalized_seats_count=row.normalized_seats_count, + normalized_groups_count=row.normalized_groups_count, + normalized_sectors_count=row.normalized_sectors_count, + original_storage_path=row.original_storage_path, + sanitized_storage_path=row.sanitized_storage_path, + normalized_storage_path=row.normalized_storage_path, + processing_status=row.processing_status, + created_at=row.created_at.isoformat(), + ) + + +@router.get(f"{settings.api_v1_prefix}/uploads/{{upload_id}}/normalized") +async def get_normalized(upload_id: str, role: str = Depends(require_api_key)): + payload = load_normalized_json(upload_id) + return json.loads(payload) + + +@router.post(f"{settings.api_v1_prefix}/schemes/upload", response_model=UploadResponse) +async def upload_scheme_svg( + file: UploadFile = File(...), + role: str = Depends(require_api_key), +): + filename = file.filename or "" + suffix = Path(filename).suffix.lower() + content_type = (file.content_type or "").lower() + + if suffix != ".svg" and content_type != "image/svg+xml": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only SVG files are allowed", + ) + + content = await file.read() + size_bytes = len(content) + + if size_bytes == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Uploaded file is empty", + ) + + if size_bytes > settings.svg_max_file_size_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="SVG file exceeds configured size limit", + ) + + element_count = inspect_svg_bytes(content) + sanitized_content, removed_elements_count, removed_attributes_count = sanitize_svg_bytes(content) + normalized_json, normalized_payload = normalize_svg_bytes_to_json(sanitized_content) + + display_svg_storage_path = None + display_svg_status = "pending" + display_svg_generated_at = None + + if settings.svg_display_enabled: + try: + display_content, display_meta = generate_display_svg( + sanitized_content, + settings.svg_display_mode, + ) + logger.info( + "display_svg.upload generated mode=%s view_box=%s width=%s height=%s", + settings.svg_display_mode, + display_meta.get("view_box"), + display_meta.get("width"), + display_meta.get("height"), + ) + display_svg_status = "ready" + except Exception: + logger.exception("display_svg.upload failed filename=%s", filename) + display_content = None + display_svg_status = "failed" + else: + display_content = None + display_svg_status = "failed" + + upload_id, original_storage_path = save_original_svg(filename=filename, content=content) + sanitized_storage_path = save_sanitized_svg( + upload_id=upload_id, + filename=filename, + content=sanitized_content, + ) + normalized_storage_path = save_normalized_json( + upload_id=upload_id, + filename=filename, + content=normalized_json, + ) + + if display_content is not None: + display_svg_storage_path = save_display_svg( + upload_id=upload_id, + filename=filename, + content=display_content, + ) + display_svg_generated_at = datetime.now(timezone.utc) + + summary = normalized_payload["summary"] + + await create_upload_record( + upload_id=upload_id, + original_filename=filename, + content_type=content_type, + size_bytes=size_bytes, + element_count=element_count, + removed_elements_count=removed_elements_count, + removed_attributes_count=removed_attributes_count, + normalized_elements_count=summary["elements_count"], + normalized_seats_count=summary["seats_count"], + normalized_groups_count=summary["groups_count"], + normalized_sectors_count=summary["sectors_count"], + original_storage_path=original_storage_path, + sanitized_storage_path=sanitized_storage_path, + normalized_storage_path=normalized_storage_path, + processing_status="completed", + ) + + scheme_id = await create_scheme_from_upload( + source_upload_id=upload_id, + name=Path(filename).stem or filename, + normalized_elements_count=summary["elements_count"], + normalized_seats_count=summary["seats_count"], + normalized_groups_count=summary["groups_count"], + normalized_sectors_count=summary["sectors_count"], + ) + + scheme_version_id = await create_initial_scheme_version( + scheme_id=scheme_id, + normalized_storage_path=normalized_storage_path, + normalized_elements_count=summary["elements_count"], + normalized_seats_count=summary["seats_count"], + normalized_groups_count=summary["groups_count"], + normalized_sectors_count=summary["sectors_count"], + display_svg_storage_path=display_svg_storage_path, + display_svg_status=display_svg_status, + display_svg_generated_at=display_svg_generated_at, + ) + + normalized_payload_from_file = read_normalized_payload_from_path(normalized_storage_path) + + await replace_scheme_version_sectors( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + sectors=normalized_payload_from_file.get("sectors", []), + ) + await replace_scheme_version_groups( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + groups=normalized_payload_from_file.get("groups", []), + ) + await replace_scheme_version_seats( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + seats=normalized_payload_from_file.get("seats", []), + ) + + return UploadResponse( + upload_id=upload_id, + filename=filename, + content_type=content_type, + size_bytes=size_bytes, + element_count=element_count, + removed_elements_count=removed_elements_count, + removed_attributes_count=removed_attributes_count, + normalized_elements_count=summary["elements_count"], + normalized_seats_count=summary["seats_count"], + normalized_groups_count=summary["groups_count"], + normalized_sectors_count=summary["sectors_count"], + svg_max_file_size_bytes=settings.svg_max_file_size_bytes, + svg_max_elements=settings.svg_max_elements, + original_storage_path=original_storage_path, + sanitized_storage_path=sanitized_storage_path, + normalized_storage_path=normalized_storage_path, + accepted=True, + ) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..79efde4 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,78 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "svg-service" + app_env: str = "development" + app_port: int = 9020 + api_v1_prefix: str = "/api/v1" + + auth_header_name: str = "X-API-Key" + admin_api_key: str = "admin-local-dev-key" + viewer_api_key: str = "viewer-local-dev-key" + + postgres_host: str = "postgres" + postgres_port: int = 5432 + postgres_db: str = "svg_service" + postgres_user: str = "svg_service" + postgres_password: str = "svg_service_dev_password" + + svg_max_file_size_bytes: int = 10 * 1024 * 1024 + svg_max_elements: int = 25000 + + svg_allow_internal_use_references_only: bool = True + svg_forbid_foreign_object_v1: bool = True + svg_forbid_style_v1: bool = False + svg_forbid_image_v1: bool = True + + svg_display_enabled: bool = True + svg_display_mode: str = "passthrough" + svg_display_hide_small_text: bool = False + svg_display_min_text_font_size: float = 8.0 + svg_display_hide_technical_text: bool = False + svg_display_remove_hidden_elements: bool = True + svg_display_force_viewbox: bool = True + svg_display_technical_text_patterns: str = "debug,tech,helper,tmp,service" + + storage_root_dir: str = "/data" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + @property + def admin_keys(self) -> set[str]: + return {item.strip() for item in self.admin_api_key.split(",") if item.strip()} + + @property + def viewer_keys(self) -> set[str]: + return {item.strip() for item in self.viewer_api_key.split(",") if item.strip()} + + @property + def database_url(self) -> str: + return ( + f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}" + f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}" + ) + + @property + def storage_original_dir(self) -> str: + return f"{self.storage_root_dir}/original" + + @property + def storage_sanitized_dir(self) -> str: + return f"{self.storage_root_dir}/sanitized" + + @property + def storage_normalized_dir(self) -> str: + return f"{self.storage_root_dir}/normalized" + + @property + def storage_display_dir(self) -> str: + return f"{self.storage_root_dir}/display" + + +settings = Settings() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..0caefaa --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,22 @@ +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import settings + +engine = create_async_engine( + settings.database_url, + pool_pre_ping=True, +) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def db_ping() -> dict: + async with AsyncSessionLocal() as session: + result = await session.execute(text("select 1 as ok")) + row = result.mappings().first() + return {"ok": row["ok"] if row else None} diff --git a/backend/app/domain/__init__.py b/backend/app/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/domain/roles.py b/backend/app/domain/roles.py new file mode 100644 index 0000000..33603ba --- /dev/null +++ b/backend/app/domain/roles.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class UserRole(str, Enum): + ADMIN = "admin" + OPERATOR = "operator" + VIEWER = "viewer" diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..76f821f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from app.api.routes import router +from app.core.config import settings + + +app = FastAPI( + title=settings.app_name, + version="1.0.0", +) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + errors: list[dict[str, str]] = [] + + for item in exc.errors(): + loc = [str(part) for part in item.get("loc", []) if part not in ("body", "query", "path")] + field = ".".join(loc) if loc else "request" + message = item.get("msg", "Ошибка валидации") + message = message.replace("Value error, ", "") + message = message.replace("Value error,", "") + errors.append({"field": field, "message": message}) + + detail = errors[0]["message"] if errors else "Ошибка валидации запроса" + + return JSONResponse( + status_code=422, + content={ + "detail": detail, + "errors": errors, + }, + ) + + +@app.get("/") +async def root(): + return { + "service": settings.app_name, + "env": settings.app_env, + "status": "ok", + "port": settings.app_port, + "api_prefix": settings.api_v1_prefix, + } + + +app.include_router(router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/audit_event.py b/backend/app/models/audit_event.py new file mode 100644 index 0000000..6fde1ff --- /dev/null +++ b/backend/app/models/audit_event.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AuditEventRecord(Base): + __tablename__ = "audit_events" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + audit_event_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + + scheme_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("schemes.scheme_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + object_type: Mapped[str] = mapped_column(String(64), nullable=False) + object_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + details_json: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/models/price_rule.py b/backend/app/models/price_rule.py new file mode 100644 index 0000000..4da7c8c --- /dev/null +++ b/backend/app/models/price_rule.py @@ -0,0 +1,40 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Numeric, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PriceRuleRecord(Base): + __tablename__ = "price_rules" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + price_rule_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + + scheme_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("schemes.scheme_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + pricing_category_id: Mapped[str | None] = mapped_column( + String(32), + ForeignKey("pricing_categories.pricing_category_id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + target_type: Mapped[str] = mapped_column(String(32), nullable=False) + target_ref: Mapped[str] = mapped_column(String(255), nullable=False) + + amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), nullable=False, default="RUB") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/models/pricing_category.py b/backend/app/models/pricing_category.py new file mode 100644 index 0000000..4f5a238 --- /dev/null +++ b/backend/app/models/pricing_category.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PricingCategoryRecord(Base): + __tablename__ = "pricing_categories" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + pricing_category_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + + scheme_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("schemes.scheme_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + name: Mapped[str] = mapped_column(String(255), nullable=False) + code: Mapped[str | None] = mapped_column(String(128), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/models/scheme.py b/backend/app/models/scheme.py new file mode 100644 index 0000000..1651257 --- /dev/null +++ b/backend/app/models/scheme.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SchemeRecord(Base): + __tablename__ = "schemes" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + scheme_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + + source_upload_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("uploads.upload_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + name: Mapped[str] = mapped_column(String(512), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") + current_version_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + normalized_elements_count: Mapped[int] = mapped_column(Integer, nullable=False) + normalized_seats_count: Mapped[int] = mapped_column(Integer, nullable=False) + normalized_groups_count: Mapped[int] = mapped_column(Integer, nullable=False) + normalized_sectors_count: Mapped[int] = mapped_column(Integer, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/models/scheme_group.py b/backend/app/models/scheme_group.py new file mode 100644 index 0000000..63cab0e --- /dev/null +++ b/backend/app/models/scheme_group.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SchemeGroupRecord(Base): + __tablename__ = "scheme_groups" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + group_record_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + + scheme_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("schemes.scheme_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + scheme_version_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("scheme_versions.scheme_version_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + element_id: Mapped[str | None] = mapped_column(String(512), nullable=True) + group_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + name: Mapped[str] = mapped_column(String(512), nullable=False) + classes_raw: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/models/scheme_seat.py b/backend/app/models/scheme_seat.py new file mode 100644 index 0000000..826abba --- /dev/null +++ b/backend/app/models/scheme_seat.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Float, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SchemeSeatRecord(Base): + __tablename__ = "scheme_seats" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + seat_record_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + + scheme_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("schemes.scheme_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + scheme_version_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("scheme_versions.scheme_version_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + element_id: Mapped[str | None] = mapped_column(String(512), nullable=True) + seat_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + sector_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + group_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + row_label: Mapped[str | None] = mapped_column(String(255), nullable=True) + seat_number: Mapped[str | None] = mapped_column(String(255), nullable=True) + tag: Mapped[str | None] = mapped_column(String(64), nullable=True) + classes_raw: Mapped[str | None] = mapped_column(Text, nullable=True) + + x: Mapped[float | None] = mapped_column(Float, nullable=True) + y: Mapped[float | None] = mapped_column(Float, nullable=True) + cx: Mapped[float | None] = mapped_column(Float, nullable=True) + cy: Mapped[float | None] = mapped_column(Float, nullable=True) + width: Mapped[float | None] = mapped_column(Float, nullable=True) + height: Mapped[float | None] = mapped_column(Float, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/models/scheme_sector.py b/backend/app/models/scheme_sector.py new file mode 100644 index 0000000..662dee2 --- /dev/null +++ b/backend/app/models/scheme_sector.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SchemeSectorRecord(Base): + __tablename__ = "scheme_sectors" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + sector_record_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + + scheme_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("schemes.scheme_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + scheme_version_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("scheme_versions.scheme_version_id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + element_id: Mapped[str | None] = mapped_column(String(512), nullable=True) + sector_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + name: Mapped[str] = mapped_column(String(512), nullable=False) + classes_raw: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/models/scheme_version.py b/backend/app/models/scheme_version.py new file mode 100644 index 0000000..56bbbe1 --- /dev/null +++ b/backend/app/models/scheme_version.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SchemeVersionRecord(Base): + __tablename__ = "scheme_versions" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + scheme_version_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + scheme_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("schemes.scheme_id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") + + normalized_storage_path: Mapped[str] = mapped_column(Text, nullable=False) + normalized_elements_count: Mapped[int] = mapped_column(Integer, nullable=False) + normalized_seats_count: Mapped[int] = mapped_column(Integer, nullable=False) + normalized_groups_count: Mapped[int] = mapped_column(Integer, nullable=False) + normalized_sectors_count: Mapped[int] = mapped_column(Integer, nullable=False) + + display_svg_storage_path: Mapped[str | None] = mapped_column(Text, nullable=True) + display_svg_status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") + display_svg_generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/models/upload.py b/backend/app/models/upload.py new file mode 100644 index 0000000..80da0e4 --- /dev/null +++ b/backend/app/models/upload.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class UploadRecord(Base): + __tablename__ = "uploads" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + upload_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + + original_filename: Mapped[str] = mapped_column(String(512), nullable=False) + content_type: Mapped[str] = mapped_column(String(255), nullable=False) + size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + + element_count: Mapped[int] = mapped_column(Integer, nullable=False) + removed_elements_count: Mapped[int] = mapped_column(Integer, nullable=False) + removed_attributes_count: Mapped[int] = mapped_column(Integer, nullable=False) + + normalized_elements_count: Mapped[int] = mapped_column(Integer, nullable=False) + normalized_seats_count: Mapped[int] = mapped_column(Integer, nullable=False) + normalized_groups_count: Mapped[int] = mapped_column(Integer, nullable=False) + normalized_sectors_count: Mapped[int] = mapped_column(Integer, nullable=False) + + original_storage_path: Mapped[str] = mapped_column(Text, nullable=False) + sanitized_storage_path: Mapped[str] = mapped_column(Text, nullable=False) + normalized_storage_path: Mapped[str] = mapped_column(Text, nullable=False) + + processing_status: Mapped[str] = mapped_column(String(32), nullable=False, default="completed") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repositories/audit.py b/backend/app/repositories/audit.py new file mode 100644 index 0000000..eb7f1dd --- /dev/null +++ b/backend/app/repositories/audit.py @@ -0,0 +1,42 @@ +import json +from uuid import uuid4 + +from sqlalchemy import asc, select + +from app.db.session import AsyncSessionLocal +from app.models.audit_event import AuditEventRecord + + +async def create_audit_event( + *, + scheme_id: str, + event_type: str, + object_type: str, + object_ref: str | None = None, + details: dict | None = None, +) -> str: + audit_event_id = uuid4().hex + + async with AsyncSessionLocal() as session: + row = AuditEventRecord( + audit_event_id=audit_event_id, + scheme_id=scheme_id, + event_type=event_type, + object_type=object_type, + object_ref=object_ref, + details_json=json.dumps(details, ensure_ascii=False) if details is not None else None, + ) + session.add(row) + await session.commit() + + return audit_event_id + + +async def list_audit_events(scheme_id: str) -> list[AuditEventRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(AuditEventRecord) + .where(AuditEventRecord.scheme_id == scheme_id) + .order_by(asc(AuditEventRecord.created_at), asc(AuditEventRecord.id)) + ) + return list(result.scalars().all()) diff --git a/backend/app/repositories/pricing.py b/backend/app/repositories/pricing.py new file mode 100644 index 0000000..0f21554 --- /dev/null +++ b/backend/app/repositories/pricing.py @@ -0,0 +1,237 @@ +from decimal import Decimal +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import asc, desc, select + +from app.db.session import AsyncSessionLocal +from app.models.price_rule import PriceRuleRecord +from app.models.pricing_category import PricingCategoryRecord + + +async def create_pricing_category( + *, + scheme_id: str, + name: str, + code: str | None, +) -> str: + pricing_category_id = uuid4().hex + + async with AsyncSessionLocal() as session: + row = PricingCategoryRecord( + pricing_category_id=pricing_category_id, + scheme_id=scheme_id, + name=name, + code=code, + ) + session.add(row) + await session.commit() + + return pricing_category_id + + +async def update_pricing_category( + *, + scheme_id: str, + pricing_category_id: str, + name: str, + code: str | None, +) -> PricingCategoryRecord: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(PricingCategoryRecord).where( + PricingCategoryRecord.scheme_id == scheme_id, + PricingCategoryRecord.pricing_category_id == pricing_category_id, + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Pricing category not found", + ) + + row.name = name + row.code = code + + await session.commit() + await session.refresh(row) + return row + + +async def delete_pricing_category( + *, + scheme_id: str, + pricing_category_id: str, +) -> None: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(PricingCategoryRecord).where( + PricingCategoryRecord.scheme_id == scheme_id, + PricingCategoryRecord.pricing_category_id == pricing_category_id, + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Pricing category not found", + ) + + await session.delete(row) + await session.commit() + + +async def create_price_rule( + *, + scheme_id: str, + pricing_category_id: str | None, + target_type: str, + target_ref: str, + amount: Decimal, + currency: str, +) -> str: + price_rule_id = uuid4().hex + + async with AsyncSessionLocal() as session: + row = PriceRuleRecord( + price_rule_id=price_rule_id, + scheme_id=scheme_id, + pricing_category_id=pricing_category_id, + target_type=target_type, + target_ref=target_ref, + amount=amount, + currency=currency, + ) + session.add(row) + await session.commit() + + return price_rule_id + + +async def update_price_rule( + *, + scheme_id: str, + price_rule_id: str, + pricing_category_id: str | None, + target_type: str, + target_ref: str, + amount: Decimal, + currency: str, +) -> PriceRuleRecord: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(PriceRuleRecord).where( + PriceRuleRecord.scheme_id == scheme_id, + PriceRuleRecord.price_rule_id == price_rule_id, + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Price rule not found", + ) + + row.pricing_category_id = pricing_category_id + row.target_type = target_type + row.target_ref = target_ref + row.amount = amount + row.currency = currency + + await session.commit() + await session.refresh(row) + return row + + +async def delete_price_rule( + *, + scheme_id: str, + price_rule_id: str, +) -> None: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(PriceRuleRecord).where( + PriceRuleRecord.scheme_id == scheme_id, + PriceRuleRecord.price_rule_id == price_rule_id, + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Price rule not found", + ) + + await session.delete(row) + await session.commit() + + +async def list_pricing_categories(scheme_id: str) -> list[PricingCategoryRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(PricingCategoryRecord) + .where(PricingCategoryRecord.scheme_id == scheme_id) + .order_by(asc(PricingCategoryRecord.created_at), asc(PricingCategoryRecord.id)) + ) + return list(result.scalars().all()) + + +async def list_price_rules(scheme_id: str) -> list[PriceRuleRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(PriceRuleRecord) + .where(PriceRuleRecord.scheme_id == scheme_id) + .order_by(asc(PriceRuleRecord.created_at), asc(PriceRuleRecord.id)) + ) + return list(result.scalars().all()) + + +async def find_effective_price_rule( + *, + scheme_id: str, + seat_id: str | None, + group_id: str | None, + sector_id: str | None, +) -> tuple[str, dict]: + async with AsyncSessionLocal() as session: + checks = [ + ("seat", seat_id), + ("group", group_id), + ("sector", sector_id), + ] + + for level, ref in checks: + if not ref: + continue + + result = await session.execute( + select(PriceRuleRecord) + .where( + PriceRuleRecord.scheme_id == scheme_id, + PriceRuleRecord.target_type == level, + PriceRuleRecord.target_ref == ref, + ) + .order_by(desc(PriceRuleRecord.created_at), desc(PriceRuleRecord.id)) + .limit(1) + ) + row = result.scalar_one_or_none() + if row is not None: + return level, { + "price_rule_id": row.price_rule_id, + "scheme_id": row.scheme_id, + "pricing_category_id": row.pricing_category_id, + "target_type": row.target_type, + "target_ref": row.target_ref, + "amount": row.amount, + "currency": row.currency, + } + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No pricing rule matched current seat", + ) diff --git a/backend/app/repositories/scheme_groups.py b/backend/app/repositories/scheme_groups.py new file mode 100644 index 0000000..1b51afa --- /dev/null +++ b/backend/app/repositories/scheme_groups.py @@ -0,0 +1,74 @@ +import json +from uuid import uuid4 + +from sqlalchemy import asc, delete, select + +from app.db.session import AsyncSessionLocal +from app.models.scheme_group import SchemeGroupRecord + + +async def replace_scheme_version_groups( + *, + scheme_id: str, + scheme_version_id: str, + groups: list[dict], +) -> None: + async with AsyncSessionLocal() as session: + await session.execute( + delete(SchemeGroupRecord).where( + SchemeGroupRecord.scheme_version_id == scheme_version_id + ) + ) + + for item in groups: + row = SchemeGroupRecord( + group_record_id=uuid4().hex, + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + element_id=item.get("id"), + group_id=item.get("group_id"), + name=item.get("group_id") or item.get("id") or "unnamed-group", + classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False), + ) + session.add(row) + + await session.commit() + + +async def list_scheme_version_groups(scheme_version_id: str) -> list[SchemeGroupRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeGroupRecord) + .where(SchemeGroupRecord.scheme_version_id == scheme_version_id) + .order_by(asc(SchemeGroupRecord.id)) + ) + return list(result.scalars().all()) + + +async def clone_scheme_version_groups( + *, + source_scheme_version_id: str, + target_scheme_version_id: str, +) -> None: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeGroupRecord).where( + SchemeGroupRecord.scheme_version_id == source_scheme_version_id + ) + ) + rows = list(result.scalars().all()) + + for row in rows: + session.add( + SchemeGroupRecord( + group_record_id=uuid4().hex, + scheme_id=row.scheme_id, + scheme_version_id=target_scheme_version_id, + element_id=row.element_id, + group_id=row.group_id, + name=row.name, + classes_raw=row.classes_raw, + ) + ) + + await session.commit() diff --git a/backend/app/repositories/scheme_seats.py b/backend/app/repositories/scheme_seats.py new file mode 100644 index 0000000..f2051da --- /dev/null +++ b/backend/app/repositories/scheme_seats.py @@ -0,0 +1,118 @@ +import json +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import asc, delete, select + +from app.db.session import AsyncSessionLocal +from app.models.scheme_seat import SchemeSeatRecord + + +async def replace_scheme_version_seats( + *, + scheme_id: str, + scheme_version_id: str, + seats: list[dict], +) -> None: + async with AsyncSessionLocal() as session: + await session.execute( + delete(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id + ) + ) + + for item in seats: + row = SchemeSeatRecord( + seat_record_id=uuid4().hex, + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + element_id=item.get("id"), + seat_id=item.get("seat_id"), + sector_id=item.get("sector_id"), + group_id=item.get("group_id"), + row_label=item.get("row"), + seat_number=item.get("seat_number"), + tag=item.get("tag"), + classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False), + x=item.get("x"), + y=item.get("y"), + cx=item.get("cx"), + cy=item.get("cy"), + width=item.get("width"), + height=item.get("height"), + ) + session.add(row) + + await session.commit() + + +async def list_scheme_version_seats(scheme_version_id: str) -> list[SchemeSeatRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSeatRecord) + .where(SchemeSeatRecord.scheme_version_id == scheme_version_id) + .order_by(asc(SchemeSeatRecord.id)) + ) + return list(result.scalars().all()) + + +async def get_scheme_version_seat_by_seat_id( + *, + scheme_version_id: str, + seat_id: str, +) -> SchemeSeatRecord: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id, + SchemeSeatRecord.seat_id == seat_id, + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Seat not found in current scheme version", + ) + + return row + + +async def clone_scheme_version_seats( + *, + source_scheme_version_id: str, + target_scheme_version_id: str, +) -> None: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == source_scheme_version_id + ) + ) + rows = list(result.scalars().all()) + + for row in rows: + session.add( + SchemeSeatRecord( + seat_record_id=uuid4().hex, + scheme_id=row.scheme_id, + scheme_version_id=target_scheme_version_id, + element_id=row.element_id, + seat_id=row.seat_id, + sector_id=row.sector_id, + group_id=row.group_id, + row_label=row.row_label, + seat_number=row.seat_number, + tag=row.tag, + classes_raw=row.classes_raw, + x=row.x, + y=row.y, + cx=row.cx, + cy=row.cy, + width=row.width, + height=row.height, + ) + ) + + await session.commit() diff --git a/backend/app/repositories/scheme_sectors.py b/backend/app/repositories/scheme_sectors.py new file mode 100644 index 0000000..5c8517a --- /dev/null +++ b/backend/app/repositories/scheme_sectors.py @@ -0,0 +1,74 @@ +import json +from uuid import uuid4 + +from sqlalchemy import asc, delete, select + +from app.db.session import AsyncSessionLocal +from app.models.scheme_sector import SchemeSectorRecord + + +async def replace_scheme_version_sectors( + *, + scheme_id: str, + scheme_version_id: str, + sectors: list[dict], +) -> None: + async with AsyncSessionLocal() as session: + await session.execute( + delete(SchemeSectorRecord).where( + SchemeSectorRecord.scheme_version_id == scheme_version_id + ) + ) + + for item in sectors: + row = SchemeSectorRecord( + sector_record_id=uuid4().hex, + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + element_id=item.get("id"), + sector_id=item.get("sector_id"), + name=item.get("sector_id") or item.get("id") or "unnamed-sector", + classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False), + ) + session.add(row) + + await session.commit() + + +async def list_scheme_version_sectors(scheme_version_id: str) -> list[SchemeSectorRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSectorRecord) + .where(SchemeSectorRecord.scheme_version_id == scheme_version_id) + .order_by(asc(SchemeSectorRecord.id)) + ) + return list(result.scalars().all()) + + +async def clone_scheme_version_sectors( + *, + source_scheme_version_id: str, + target_scheme_version_id: str, +) -> None: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSectorRecord).where( + SchemeSectorRecord.scheme_version_id == source_scheme_version_id + ) + ) + rows = list(result.scalars().all()) + + for row in rows: + session.add( + SchemeSectorRecord( + sector_record_id=uuid4().hex, + scheme_id=row.scheme_id, + scheme_version_id=target_scheme_version_id, + element_id=row.element_id, + sector_id=row.sector_id, + name=row.name, + classes_raw=row.classes_raw, + ) + ) + + await session.commit() diff --git a/backend/app/repositories/scheme_versions.py b/backend/app/repositories/scheme_versions.py new file mode 100644 index 0000000..2334c0c --- /dev/null +++ b/backend/app/repositories/scheme_versions.py @@ -0,0 +1,169 @@ +from datetime import datetime +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import asc, desc, func, select + +from app.db.session import AsyncSessionLocal +from app.models.scheme import SchemeRecord +from app.models.scheme_version import SchemeVersionRecord + + +async def create_initial_scheme_version( + *, + scheme_id: str, + normalized_storage_path: str, + normalized_elements_count: int, + normalized_seats_count: int, + normalized_groups_count: int, + normalized_sectors_count: int, + display_svg_storage_path: str | None = None, + display_svg_status: str = "pending", + display_svg_generated_at: datetime | None = None, +) -> str: + scheme_version_id = uuid4().hex + + async with AsyncSessionLocal() as session: + row = SchemeVersionRecord( + scheme_version_id=scheme_version_id, + scheme_id=scheme_id, + version_number=1, + status="draft", + normalized_storage_path=normalized_storage_path, + normalized_elements_count=normalized_elements_count, + normalized_seats_count=normalized_seats_count, + normalized_groups_count=normalized_groups_count, + normalized_sectors_count=normalized_sectors_count, + display_svg_storage_path=display_svg_storage_path, + display_svg_status=display_svg_status, + display_svg_generated_at=display_svg_generated_at, + ) + session.add(row) + await session.commit() + + return scheme_version_id + + +async def list_scheme_versions(scheme_id: str, limit: int = 100, offset: int = 0) -> list[SchemeVersionRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeVersionRecord) + .where(SchemeVersionRecord.scheme_id == scheme_id) + .order_by(asc(SchemeVersionRecord.version_number), desc(SchemeVersionRecord.id)) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()) + + +async def count_scheme_versions(scheme_id: str) -> int: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(func.count()).select_from(SchemeVersionRecord).where(SchemeVersionRecord.scheme_id == scheme_id) + ) + return int(result.scalar_one()) + + +async def get_current_scheme_version(scheme_id: str, current_version_number: int) -> SchemeVersionRecord: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeVersionRecord).where( + SchemeVersionRecord.scheme_id == scheme_id, + SchemeVersionRecord.version_number == current_version_number, + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Current scheme version not found", + ) + + return row + + +async def update_scheme_version_display_artifact( + *, + scheme_version_id: str, + display_svg_storage_path: str, + display_svg_status: str, + display_svg_generated_at: datetime, +) -> None: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeVersionRecord).where( + SchemeVersionRecord.scheme_version_id == scheme_version_id + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Scheme version not found", + ) + + row.display_svg_storage_path = display_svg_storage_path + row.display_svg_status = display_svg_status + row.display_svg_generated_at = display_svg_generated_at + + await session.commit() + + +async def create_next_scheme_version_from_current(scheme_id: str) -> SchemeVersionRecord: + async with AsyncSessionLocal() as session: + scheme_result = await session.execute( + select(SchemeRecord).where(SchemeRecord.scheme_id == scheme_id) + ) + scheme = scheme_result.scalar_one_or_none() + + if scheme is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Scheme not found", + ) + + current_result = await session.execute( + select(SchemeVersionRecord).where( + SchemeVersionRecord.scheme_id == scheme.scheme_id, + SchemeVersionRecord.version_number == scheme.current_version_number, + ) + ) + current_version = current_result.scalar_one_or_none() + + if current_version is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Current scheme version not found", + ) + + next_version_number = current_version.version_number + 1 + new_version = SchemeVersionRecord( + scheme_version_id=uuid4().hex, + scheme_id=scheme.scheme_id, + version_number=next_version_number, + status="draft", + normalized_storage_path=current_version.normalized_storage_path, + normalized_elements_count=current_version.normalized_elements_count, + normalized_seats_count=current_version.normalized_seats_count, + normalized_groups_count=current_version.normalized_groups_count, + normalized_sectors_count=current_version.normalized_sectors_count, + display_svg_storage_path=current_version.display_svg_storage_path, + display_svg_status=current_version.display_svg_status, + display_svg_generated_at=current_version.display_svg_generated_at, + ) + session.add(new_version) + + scheme.current_version_number = next_version_number + scheme.status = "draft" + scheme.published_at = None + scheme.normalized_elements_count = current_version.normalized_elements_count + scheme.normalized_seats_count = current_version.normalized_seats_count + scheme.normalized_groups_count = current_version.normalized_groups_count + scheme.normalized_sectors_count = current_version.normalized_sectors_count + + await session.commit() + await session.refresh(new_version) + + return new_version diff --git a/backend/app/repositories/schemes.py b/backend/app/repositories/schemes.py new file mode 100644 index 0000000..8543487 --- /dev/null +++ b/backend/app/repositories/schemes.py @@ -0,0 +1,198 @@ +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import desc, func, select + +from app.db.session import AsyncSessionLocal +from app.models.scheme import SchemeRecord +from app.models.scheme_version import SchemeVersionRecord + + +async def create_scheme_from_upload( + *, + source_upload_id: str, + name: str, + normalized_elements_count: int, + normalized_seats_count: int, + normalized_groups_count: int, + normalized_sectors_count: int, +) -> str: + scheme_id = uuid4().hex + + async with AsyncSessionLocal() as session: + row = SchemeRecord( + scheme_id=scheme_id, + source_upload_id=source_upload_id, + name=name, + status="draft", + current_version_number=1, + normalized_elements_count=normalized_elements_count, + normalized_seats_count=normalized_seats_count, + normalized_groups_count=normalized_groups_count, + normalized_sectors_count=normalized_sectors_count, + ) + session.add(row) + await session.commit() + + return scheme_id + + +async def list_scheme_records(limit: int = 50, offset: int = 0) -> list[SchemeRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeRecord) + .order_by(desc(SchemeRecord.created_at), desc(SchemeRecord.id)) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()) + + +async def count_scheme_records() -> int: + async with AsyncSessionLocal() as session: + result = await session.execute(select(func.count()).select_from(SchemeRecord)) + return int(result.scalar_one()) + + +async def get_scheme_record_by_scheme_id(scheme_id: str) -> SchemeRecord: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeRecord).where(SchemeRecord.scheme_id == scheme_id) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Scheme not found", + ) + + return row + + +async def publish_scheme(scheme_id: str) -> SchemeRecord: + async with AsyncSessionLocal() as session: + scheme_result = await session.execute( + select(SchemeRecord).where(SchemeRecord.scheme_id == scheme_id) + ) + scheme = scheme_result.scalar_one_or_none() + + if scheme is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Scheme not found", + ) + + version_result = await session.execute( + select(SchemeVersionRecord).where( + SchemeVersionRecord.scheme_id == scheme.scheme_id, + SchemeVersionRecord.version_number == scheme.current_version_number, + ) + ) + version = version_result.scalar_one_or_none() + + if version is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Current scheme version not found", + ) + + scheme.status = "published" + scheme.published_at = func.now() + version.status = "published" + + await session.commit() + await session.refresh(scheme) + + return scheme + + +async def unpublish_scheme(scheme_id: str) -> SchemeRecord: + async with AsyncSessionLocal() as session: + scheme_result = await session.execute( + select(SchemeRecord).where(SchemeRecord.scheme_id == scheme_id) + ) + scheme = scheme_result.scalar_one_or_none() + + if scheme is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Scheme not found", + ) + + version_result = await session.execute( + select(SchemeVersionRecord).where( + SchemeVersionRecord.scheme_id == scheme.scheme_id, + SchemeVersionRecord.version_number == scheme.current_version_number, + ) + ) + version = version_result.scalar_one_or_none() + + if version is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Current scheme version not found", + ) + + scheme.status = "draft" + scheme.published_at = None + version.status = "draft" + + await session.commit() + await session.refresh(scheme) + + return scheme + + +async def rollback_scheme_to_version(scheme_id: str, target_version_number: int) -> SchemeRecord: + async with AsyncSessionLocal() as session: + scheme_result = await session.execute( + select(SchemeRecord).where(SchemeRecord.scheme_id == scheme_id) + ) + scheme = scheme_result.scalar_one_or_none() + + if scheme is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Scheme not found", + ) + + target_result = await session.execute( + select(SchemeVersionRecord).where( + SchemeVersionRecord.scheme_id == scheme.scheme_id, + SchemeVersionRecord.version_number == target_version_number, + ) + ) + target_version = target_result.scalar_one_or_none() + + if target_version is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Target scheme version not found", + ) + + current_result = await session.execute( + select(SchemeVersionRecord).where( + SchemeVersionRecord.scheme_id == scheme.scheme_id, + SchemeVersionRecord.version_number == scheme.current_version_number, + ) + ) + current_version = current_result.scalar_one_or_none() + + if current_version is not None: + current_version.status = "draft" + + target_version.status = "draft" + scheme.current_version_number = target_version.version_number + scheme.status = "draft" + scheme.published_at = None + + scheme.normalized_elements_count = target_version.normalized_elements_count + scheme.normalized_seats_count = target_version.normalized_seats_count + scheme.normalized_groups_count = target_version.normalized_groups_count + scheme.normalized_sectors_count = target_version.normalized_sectors_count + + await session.commit() + await session.refresh(scheme) + + return scheme diff --git a/backend/app/repositories/uploads.py b/backend/app/repositories/uploads.py new file mode 100644 index 0000000..113d3bb --- /dev/null +++ b/backend/app/repositories/uploads.py @@ -0,0 +1,79 @@ +from fastapi import HTTPException, status +from sqlalchemy import desc, func, select + +from app.db.session import AsyncSessionLocal +from app.models.upload import UploadRecord + + +async def create_upload_record( + *, + upload_id: str, + original_filename: str, + content_type: str, + size_bytes: int, + element_count: int, + removed_elements_count: int, + removed_attributes_count: int, + normalized_elements_count: int, + normalized_seats_count: int, + normalized_groups_count: int, + normalized_sectors_count: int, + original_storage_path: str, + sanitized_storage_path: str, + normalized_storage_path: str, + processing_status: str = "completed", +) -> None: + async with AsyncSessionLocal() as session: + row = UploadRecord( + upload_id=upload_id, + original_filename=original_filename, + content_type=content_type, + size_bytes=size_bytes, + element_count=element_count, + removed_elements_count=removed_elements_count, + removed_attributes_count=removed_attributes_count, + normalized_elements_count=normalized_elements_count, + normalized_seats_count=normalized_seats_count, + normalized_groups_count=normalized_groups_count, + normalized_sectors_count=normalized_sectors_count, + original_storage_path=original_storage_path, + sanitized_storage_path=sanitized_storage_path, + normalized_storage_path=normalized_storage_path, + processing_status=processing_status, + ) + session.add(row) + await session.commit() + + +async def list_upload_records(limit: int = 50, offset: int = 0) -> list[UploadRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(UploadRecord) + .order_by(desc(UploadRecord.created_at), desc(UploadRecord.id)) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()) + + +async def count_upload_records() -> int: + async with AsyncSessionLocal() as session: + result = await session.execute(select(func.count()).select_from(UploadRecord)) + value = result.scalar_one() + return int(value) + + +async def get_upload_record_by_upload_id(upload_id: str) -> UploadRecord: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(UploadRecord).where(UploadRecord.upload_id == upload_id) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Upload not found", + ) + + return row diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py new file mode 100644 index 0000000..b9a60db --- /dev/null +++ b/backend/app/schemas/audit.py @@ -0,0 +1,18 @@ +from typing import List + +from pydantic import BaseModel + + +class AuditEventItem(BaseModel): + audit_event_id: str + scheme_id: str + event_type: str + object_type: str + object_ref: str | None + details_json: str | None + created_at: str + + +class SchemeAuditResponse(BaseModel): + items: List[AuditEventItem] + total: int diff --git a/backend/app/schemas/manifest.py b/backend/app/schemas/manifest.py new file mode 100644 index 0000000..e1d39ab --- /dev/null +++ b/backend/app/schemas/manifest.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel + + +class SvgLimitsManifest(BaseModel): + max_file_size_bytes: int + max_elements: int + + +class SanitizationManifest(BaseModel): + allow_internal_use_references_only: bool + forbid_foreign_object_v1: bool + forbid_style_v1: bool + forbid_image_v1: bool + allowed_data_attributes: list[str] + + +class ExtractionContractManifest(BaseModel): + seat_fields: list[str] + priority: list[str] + + +class ServiceManifestResponse(BaseModel): + service: str + api_prefix: str + auth_header_name: str + svg_limits: SvgLimitsManifest + sanitization: SanitizationManifest + extraction_contract: ExtractionContractManifest diff --git a/backend/app/schemas/pricing.py b/backend/app/schemas/pricing.py new file mode 100644 index 0000000..bd56558 --- /dev/null +++ b/backend/app/schemas/pricing.py @@ -0,0 +1,179 @@ +from decimal import Decimal, InvalidOperation +from typing import List + +from pydantic import BaseModel, field_validator + + +class PricingCategoryCreateRequest(BaseModel): + name: str + code: str | None = None + + +class PricingCategoryUpdateRequest(BaseModel): + name: str + code: str | None = None + + +class PricingCategoryCreateResponse(BaseModel): + pricing_category_id: str + scheme_id: str + name: str + code: str | None + + +class PricingCategoryUpdateResponse(BaseModel): + pricing_category_id: str + scheme_id: str + name: str + code: str | None + + +class DeleteResponse(BaseModel): + status: str + + +class PriceRuleCreateRequest(BaseModel): + pricing_category_id: str | None = None + target_type: str + target_ref: str + amount: Decimal + currency: str = "RUB" + + @field_validator("target_type") + @classmethod + def validate_target_type(cls, value: str) -> str: + allowed = {"sector", "group", "seat"} + if value not in allowed: + raise ValueError("Поле target_type должно быть одним из: sector, group, seat") + return value + + @field_validator("currency") + @classmethod + def validate_currency(cls, value: str) -> str: + if value != "RUB": + raise ValueError("В v1 поддерживается только валюта RUB") + return value + + @field_validator("amount", mode="before") + @classmethod + def parse_amount(cls, value): + if value is None: + raise ValueError("Поле amount обязательно") + text = str(value).strip() + if text == "": + raise ValueError("Поле amount обязательно") + try: + return Decimal(text) + except (InvalidOperation, ValueError): + raise ValueError("Некорректная сумма. Используйте формат 2500.00") + + @field_validator("amount") + @classmethod + def validate_amount(cls, value: Decimal) -> Decimal: + if value < Decimal("0.00"): + raise ValueError("Сумма не может быть отрицательной") + if value.quantize(Decimal("0.01")) != value: + raise ValueError("Сумма должна быть с точностью до 2 знаков после запятой") + return value + + +class PriceRuleUpdateRequest(BaseModel): + pricing_category_id: str | None = None + target_type: str + target_ref: str + amount: Decimal + currency: str = "RUB" + + @field_validator("target_type") + @classmethod + def validate_target_type(cls, value: str) -> str: + allowed = {"sector", "group", "seat"} + if value not in allowed: + raise ValueError("Поле target_type должно быть одним из: sector, group, seat") + return value + + @field_validator("currency") + @classmethod + def validate_currency(cls, value: str) -> str: + if value != "RUB": + raise ValueError("В v1 поддерживается только валюта RUB") + return value + + @field_validator("amount", mode="before") + @classmethod + def parse_amount(cls, value): + if value is None: + raise ValueError("Поле amount обязательно") + text = str(value).strip() + if text == "": + raise ValueError("Поле amount обязательно") + try: + return Decimal(text) + except (InvalidOperation, ValueError): + raise ValueError("Некорректная сумма. Используйте формат 2500.00") + + @field_validator("amount") + @classmethod + def validate_amount(cls, value: Decimal) -> Decimal: + if value < Decimal("0.00"): + raise ValueError("Сумма не может быть отрицательной") + if value.quantize(Decimal("0.01")) != value: + raise ValueError("Сумма должна быть с точностью до 2 знаков после запятой") + return value + + +class PriceRuleCreateResponse(BaseModel): + price_rule_id: str + scheme_id: str + pricing_category_id: str | None + target_type: str + target_ref: str + amount: Decimal + currency: str + + +class PriceRuleUpdateResponse(BaseModel): + price_rule_id: str + scheme_id: str + pricing_category_id: str | None + target_type: str + target_ref: str + amount: Decimal + currency: str + + +class PricingCategoryItem(BaseModel): + pricing_category_id: str + scheme_id: str + name: str + code: str | None + created_at: str + + +class PriceRuleItem(BaseModel): + price_rule_id: str + scheme_id: str + pricing_category_id: str | None + target_type: str + target_ref: str + amount: Decimal + currency: str + created_at: str + + +class SchemePricingResponse(BaseModel): + categories: List[PricingCategoryItem] + rules: List[PriceRuleItem] + + +class EffectiveSeatPriceResponse(BaseModel): + scheme_id: str + scheme_version_id: str + seat_id: str + sector_id: str | None + group_id: str | None + matched_rule_level: str + matched_target_ref: str + pricing_category_id: str | None + amount: Decimal + currency: str diff --git a/backend/app/schemas/scheme_groups.py b/backend/app/schemas/scheme_groups.py new file mode 100644 index 0000000..05acc2b --- /dev/null +++ b/backend/app/schemas/scheme_groups.py @@ -0,0 +1,19 @@ +from typing import List + +from pydantic import BaseModel + + +class SchemeGroupItem(BaseModel): + group_record_id: str + scheme_id: str + scheme_version_id: str + element_id: str | None + group_id: str | None + name: str + classes_raw: str | None + created_at: str + + +class SchemeGroupListResponse(BaseModel): + items: List[SchemeGroupItem] + total: int diff --git a/backend/app/schemas/scheme_registry.py b/backend/app/schemas/scheme_registry.py new file mode 100644 index 0000000..93b5c41 --- /dev/null +++ b/backend/app/schemas/scheme_registry.py @@ -0,0 +1,67 @@ +from typing import List + +from pydantic import BaseModel + + +class SchemeListItem(BaseModel): + scheme_id: str + source_upload_id: str + name: str + status: str + current_version_number: int + published_at: str | None + normalized_elements_count: int + normalized_seats_count: int + normalized_groups_count: int + normalized_sectors_count: int + created_at: str + + +class SchemeListResponse(BaseModel): + items: List[SchemeListItem] + total: int + + +class SchemeDetailResponse(BaseModel): + scheme_id: str + source_upload_id: str + name: str + status: str + current_version_number: int + published_at: str | None + normalized_elements_count: int + normalized_seats_count: int + normalized_groups_count: int + normalized_sectors_count: int + created_at: str + + +class SchemeCurrentResponse(BaseModel): + scheme_id: str + scheme_version_id: str + version_number: int + status: str + normalized_storage_path: str + normalized_elements_count: int + normalized_seats_count: int + normalized_groups_count: int + normalized_sectors_count: int + created_at: str + + +class SchemePublishResponse(BaseModel): + scheme_id: str + status: str + current_version_number: int + published_at: str | None + + +class SchemeRollbackRequest(BaseModel): + target_version_number: int + + +class SchemeRollbackResponse(BaseModel): + scheme_id: str + status: str + current_version_number: int + published_at: str | None diff --git a/backend/app/schemas/scheme_seats.py b/backend/app/schemas/scheme_seats.py new file mode 100644 index 0000000..4ffbab6 --- /dev/null +++ b/backend/app/schemas/scheme_seats.py @@ -0,0 +1,29 @@ +from typing import List + +from pydantic import BaseModel + + +class SchemeSeatItem(BaseModel): + seat_record_id: str + scheme_id: str + scheme_version_id: str + element_id: str | None + seat_id: str | None + sector_id: str | None + group_id: str | None + row_label: str | None + seat_number: str | None + tag: str | None + classes_raw: str | None + x: float | None + y: float | None + cx: float | None + cy: float | None + width: float | None + height: float | None + created_at: str + + +class SchemeSeatListResponse(BaseModel): + items: List[SchemeSeatItem] + total: int diff --git a/backend/app/schemas/scheme_sectors.py b/backend/app/schemas/scheme_sectors.py new file mode 100644 index 0000000..6e158dc --- /dev/null +++ b/backend/app/schemas/scheme_sectors.py @@ -0,0 +1,19 @@ +from typing import List + +from pydantic import BaseModel + + +class SchemeSectorItem(BaseModel): + sector_record_id: str + scheme_id: str + scheme_version_id: str + element_id: str | None + sector_id: str | None + name: str + classes_raw: str | None + created_at: str + + +class SchemeSectorListResponse(BaseModel): + items: List[SchemeSectorItem] + total: int diff --git a/backend/app/schemas/scheme_versions.py b/backend/app/schemas/scheme_versions.py new file mode 100644 index 0000000..ae15559 --- /dev/null +++ b/backend/app/schemas/scheme_versions.py @@ -0,0 +1,29 @@ +from typing import List + +from pydantic import BaseModel + + +class SchemeVersionListItem(BaseModel): + scheme_version_id: str + scheme_id: str + version_number: int + status: str + normalized_storage_path: str + normalized_elements_count: int + normalized_seats_count: int + normalized_groups_count: int + normalized_sectors_count: int + created_at: str + + +class SchemeVersionListResponse(BaseModel): + items: List[SchemeVersionListItem] + total: int + + +class SchemeVersionCreateResponse(BaseModel): + scheme_id: str + scheme_version_id: str + version_number: int + status: str + normalized_storage_path: str diff --git a/backend/app/schemas/test_mode.py b/backend/app/schemas/test_mode.py new file mode 100644 index 0000000..61335da --- /dev/null +++ b/backend/app/schemas/test_mode.py @@ -0,0 +1,21 @@ +from decimal import Decimal + +from pydantic import BaseModel + + +class TestSeatPreviewResponse(BaseModel): + scheme_id: str + scheme_version_id: str + seat_id: str + element_id: str | None + sector_id: str | None + group_id: str | None + row_label: str | None + seat_number: str | None + selectable: bool + has_price: bool + matched_rule_level: str | None + matched_target_ref: str | None + pricing_category_id: str | None + amount: Decimal | None + currency: str | None diff --git a/backend/app/schemas/upload.py b/backend/app/schemas/upload.py new file mode 100644 index 0000000..6b3caac --- /dev/null +++ b/backend/app/schemas/upload.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel + + +class UploadResponse(BaseModel): + upload_id: str + filename: str + content_type: str + size_bytes: int + element_count: int + removed_elements_count: int + removed_attributes_count: int + normalized_elements_count: int + normalized_seats_count: int + normalized_groups_count: int + normalized_sectors_count: int + svg_max_file_size_bytes: int + svg_max_elements: int + original_storage_path: str + sanitized_storage_path: str + normalized_storage_path: str + accepted: bool diff --git a/backend/app/schemas/upload_registry.py b/backend/app/schemas/upload_registry.py new file mode 100644 index 0000000..95bf715 --- /dev/null +++ b/backend/app/schemas/upload_registry.py @@ -0,0 +1,46 @@ +from typing import List + +from pydantic import BaseModel + + +class UploadListItem(BaseModel): + upload_id: str + original_filename: str + content_type: str + size_bytes: int + element_count: int + removed_elements_count: int + removed_attributes_count: int + normalized_elements_count: int + normalized_seats_count: int + normalized_groups_count: int + normalized_sectors_count: int + original_storage_path: str + sanitized_storage_path: str + normalized_storage_path: str + processing_status: str + created_at: str + + +class UploadListResponse(BaseModel): + items: List[UploadListItem] + total: int + + +class UploadDetailResponse(BaseModel): + upload_id: str + original_filename: str + content_type: str + size_bytes: int + element_count: int + removed_elements_count: int + removed_attributes_count: int + normalized_elements_count: int + normalized_seats_count: int + normalized_groups_count: int + normalized_sectors_count: int + original_storage_path: str + sanitized_storage_path: str + normalized_storage_path: str + processing_status: str + created_at: str diff --git a/backend/app/security/__init__.py b/backend/app/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/security/auth.py b/backend/app/security/auth.py new file mode 100644 index 0000000..9c472bc --- /dev/null +++ b/backend/app/security/auth.py @@ -0,0 +1,31 @@ +from fastapi import Header, HTTPException, status + +from app.core.config import settings +from app.domain.roles import UserRole + + +def resolve_role(api_key: str) -> str | None: + if api_key in settings.admin_keys: + return UserRole.ADMIN.value + if api_key in settings.operator_keys: + return UserRole.OPERATOR.value + if api_key in settings.viewer_keys: + return UserRole.VIEWER.value + return None + + +async def require_api_key(x_api_key: str | None = Header(default=None, alias="X-API-Key")) -> str: + if not x_api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing API key", + ) + + role = resolve_role(x_api_key) + if role is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid API key", + ) + + return role diff --git a/backend/app/services/normalized_reader.py b/backend/app/services/normalized_reader.py new file mode 100644 index 0000000..e473905 --- /dev/null +++ b/backend/app/services/normalized_reader.py @@ -0,0 +1,16 @@ +import json +from pathlib import Path + +from fastapi import HTTPException, status + + +def read_normalized_payload_from_path(normalized_storage_path: str) -> dict: + path = Path(normalized_storage_path) + + if not path.exists() or not path.is_file(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Normalized snapshot file not found", + ) + + return json.loads(path.read_text(encoding="utf-8")) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..a111218 --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +from app.core.config import settings + + +def _ensure_dir(path: str) -> Path: + dir_path = Path(path) + dir_path.mkdir(parents=True, exist_ok=True) + return dir_path + + +def save_original_svg(*, filename: str, content: bytes) -> tuple[str, str]: + upload_id = uuid4().hex + target_dir = _ensure_dir(f"{settings.storage_original_dir}/{upload_id}") + target_path = target_dir / filename + target_path.write_bytes(content) + return upload_id, str(target_path) + + +def save_sanitized_svg(*, upload_id: str, filename: str, content: bytes) -> str: + target_dir = _ensure_dir(f"{settings.storage_sanitized_dir}/{upload_id}") + target_path = target_dir / filename + target_path.write_bytes(content) + return str(target_path) + + +def save_normalized_json(*, upload_id: str, filename: str, content: str) -> str: + target_dir = _ensure_dir(f"{settings.storage_normalized_dir}/{upload_id}") + target_path = target_dir / f"{Path(filename).stem}.normalized.json" + target_path.write_text(content, encoding="utf-8") + return str(target_path) + + +def save_display_svg(*, upload_id: str, filename: str, content: bytes) -> str: + target_dir = _ensure_dir(f"{settings.storage_display_dir}/{upload_id}") + target_path = target_dir / f"{Path(filename).stem}.display.svg" + target_path.write_bytes(content) + return str(target_path) + + +def load_normalized_json(upload_id: str) -> str: + target_dir = Path(f"{settings.storage_normalized_dir}/{upload_id}") + files = sorted(target_dir.glob("*.normalized.json")) + if not files: + raise FileNotFoundError(f"Normalized payload not found for upload_id={upload_id}") + return files[-1].read_text(encoding="utf-8") diff --git a/backend/app/services/svg_display_processor.py b/backend/app/services/svg_display_processor.py new file mode 100644 index 0000000..4b26d2e --- /dev/null +++ b/backend/app/services/svg_display_processor.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import logging +import re +from typing import Any + +from lxml import etree + +from app.core.config import settings + + +logger = logging.getLogger(__name__) + +ALLOWED_MODES = {"passthrough", "optimized"} + + +def _parse_length(value: str | None) -> float | None: + if not value: + return None + cleaned = value.strip().replace("px", "") + try: + return float(cleaned) + except ValueError: + return None + + +def _local_name(tag: str) -> str: + if "}" in tag: + return tag.split("}", 1)[1] + return tag + + +def _is_hidden(node: etree._Element) -> bool: + display = (node.attrib.get("display") or "").strip().lower() + visibility = (node.attrib.get("visibility") or "").strip().lower() + style = (node.attrib.get("style") or "").replace(" ", "").lower() + return ( + display == "none" + or visibility == "hidden" + or "display:none" in style + or "visibility:hidden" in style + ) + + +def _is_seat_related(node: etree._Element) -> bool: + probe = " ".join( + [ + node.attrib.get("id", ""), + node.attrib.get("class", ""), + node.attrib.get("data-seat-id", ""), + node.attrib.get("data-sector-id", ""), + node.attrib.get("data-group-id", ""), + ] + ).lower() + return any(token in probe for token in ["seat", "sector", "group", "place"]) + + +def _font_size(node: etree._Element) -> float | None: + direct = _parse_length(node.attrib.get("font-size")) + if direct is not None: + return direct + + style = node.attrib.get("style") or "" + match = re.search(r"font-size\s*:\s*([0-9.]+)", style, flags=re.IGNORECASE) + if match: + return _parse_length(match.group(1)) + return None + + +def _is_technical_text(node: etree._Element) -> bool: + patterns = [ + item.strip().lower() + for item in settings.svg_display_technical_text_patterns.split(",") + if item.strip() + ] + haystack = " ".join( + [ + node.attrib.get("id", ""), + node.attrib.get("class", ""), + "".join(node.itertext()), + ] + ).lower() + return any(pattern in haystack for pattern in patterns) + + +def _force_viewbox(root: etree._Element) -> None: + if not settings.svg_display_force_viewbox: + return + if root.attrib.get("viewBox"): + return + + width = _parse_length(root.attrib.get("width")) + height = _parse_length(root.attrib.get("height")) + if width and height: + w = int(width) if width.is_integer() else width + h = int(height) if height.is_integer() else height + root.attrib["viewBox"] = f"0 0 {w} {h}" + + +def _extract_meta(root: etree._Element) -> dict[str, Any]: + return { + "view_box": root.attrib.get("viewBox"), + "width": root.attrib.get("width"), + "height": root.attrib.get("height"), + } + + +def generate_display_svg(content: bytes, mode: str) -> tuple[bytes, dict[str, Any]]: + if mode not in ALLOWED_MODES: + raise ValueError(f"Unsupported display mode: {mode}") + + parser = etree.XMLParser( + resolve_entities=False, + remove_blank_text=False, + remove_comments=False, + no_network=True, + recover=False, + huge_tree=True, + ) + root = etree.fromstring(content, parser=parser) + + defs_count = len(root.xpath("//*[local-name()='defs']")) + use_count = len(root.xpath("//*[local-name()='use']")) + style_count = len(root.xpath("//*[local-name()='style']")) + clip_count = len(root.xpath("//*[local-name()='clipPath']")) + + logger.info( + "display_svg.generate mode=%s size_bytes=%s has_style=%s defs=%s use=%s clipPath=%s", + mode, + len(content), + bool(style_count), + defs_count, + use_count, + clip_count, + ) + + removed_hidden_count = 0 + removed_small_text_count = 0 + removed_technical_text_count = 0 + + if mode == "optimized": + for node in list(root.iter()): + tag_name = _local_name(node.tag) + + if settings.svg_display_remove_hidden_elements and not _is_seat_related(node) and _is_hidden(node): + parent = node.getparent() + if parent is not None: + parent.remove(node) + removed_hidden_count += 1 + continue + + if tag_name in {"text", "tspan"}: + if settings.svg_display_hide_small_text and not _is_seat_related(node): + size = _font_size(node) + if size is not None and size < settings.svg_display_min_text_font_size: + parent = node.getparent() + if parent is not None: + parent.remove(node) + removed_small_text_count += 1 + continue + + if settings.svg_display_hide_technical_text and not _is_seat_related(node) and _is_technical_text(node): + parent = node.getparent() + if parent is not None: + parent.remove(node) + removed_technical_text_count += 1 + continue + + _force_viewbox(root) + + output = etree.tostring( + root, + encoding="utf-8", + xml_declaration=True, + pretty_print=False, + ) + + meta = _extract_meta(root) + meta.update( + { + "mode": mode, + "removed_hidden_count": removed_hidden_count, + "removed_small_text_count": removed_small_text_count, + "removed_technical_text_count": removed_technical_text_count, + } + ) + return output, meta diff --git a/backend/app/services/svg_inspector.py b/backend/app/services/svg_inspector.py new file mode 100644 index 0000000..0528899 --- /dev/null +++ b/backend/app/services/svg_inspector.py @@ -0,0 +1,31 @@ +from defusedxml import ElementTree as DefusedET +from fastapi import HTTPException, status + +from app.core.config import settings + + +def inspect_svg_bytes(content: bytes) -> int: + try: + root = DefusedET.fromstring(content) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid SVG XML: {exc.__class__.__name__}", + ) from exc + + tag = root.tag or "" + if not tag.endswith("svg"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Root element is not ", + ) + + element_count = sum(1 for _ in root.iter()) + + if element_count > settings.svg_max_elements: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="SVG element count exceeds configured limit", + ) + + return element_count diff --git a/backend/app/services/svg_normalizer.py b/backend/app/services/svg_normalizer.py new file mode 100644 index 0000000..683295a --- /dev/null +++ b/backend/app/services/svg_normalizer.py @@ -0,0 +1,200 @@ +import json +import re +from typing import Any +from xml.etree import ElementTree as StdET + + +SHAPE_TAGS = {"rect", "circle", "ellipse", "path", "polygon", "polyline", "line"} +CONTAINER_TAGS = {"g"} +TEXT_TAGS = {"text", "tspan"} + + +def _local_name(tag: str) -> str: + if "}" in tag: + return tag.split("}", 1)[1] + return tag + + +def _parse_classes(value: str | None) -> list[str]: + if not value: + return [] + return [item for item in value.strip().split() if item] + + +def _to_float(value: str | None) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except ValueError: + return None + + +def _infer_kind(element_id: str | None, classes: list[str], tag: str) -> str: + haystack = " ".join([element_id or ""] + classes).lower() + + if "seat" in haystack or "place" in haystack: + return "seat" + if "sector" in haystack or "zone" in haystack: + return "sector" + if "group" in haystack: + return "group" + if tag in SHAPE_TAGS: + return "shape" + if tag in CONTAINER_TAGS: + return "container" + if tag in TEXT_TAGS: + return "text" + return "other" + + +def _extract_prefixed_id(value: str | None, prefix: str) -> str | None: + if not value: + return None + low = value.lower() + pref = f"{prefix}-" + if low.startswith(pref): + return value[len(pref):] + return None + + +def _extract_seat_parts_from_id(value: str | None) -> tuple[str | None, str | None]: + if not value: + return None, None + + patterns = [ + r"^seat[-_]?([a-zA-Z]+)[-_]?(\d+)$", + r"^place[-_]?([a-zA-Z]+)[-_]?(\d+)$", + r"^([a-zA-Z]+)[-_]?(\d+)$", + ] + + for pattern in patterns: + match = re.match(pattern, value) + if match: + return match.group(1), match.group(2) + + return None, None + + +def _build_parent_map(root) -> dict[int, dict[str, str | None]]: + parent_map: dict[int, dict[str, str | None]] = {} + + def walk(node, current_sector_id: str | None, current_group_id: str | None): + node_id = node.attrib.get("id") + classes = _parse_classes(node.attrib.get("class")) + kind = _infer_kind(node_id, classes, _local_name(node.tag)) + + sector_id = current_sector_id + group_id = current_group_id + + explicit_sector = node.attrib.get("data-sector-id") or _extract_prefixed_id(node_id, "sector") + explicit_group = node.attrib.get("data-group-id") or _extract_prefixed_id(node_id, "group") + + if kind == "sector": + sector_id = explicit_sector or node_id or sector_id + + if kind == "group": + group_id = explicit_group or node_id or group_id + + parent_map[id(node)] = { + "sector_id": sector_id, + "group_id": group_id, + } + + for child in list(node): + walk(child, sector_id, group_id) + + walk(root, None, None) + return parent_map + + +def normalize_svg_bytes(content: bytes) -> dict[str, Any]: + root = StdET.fromstring(content) + parent_map = _build_parent_map(root) + + elements: list[dict[str, Any]] = [] + seats: list[dict[str, Any]] = [] + groups: list[dict[str, Any]] = [] + sectors: list[dict[str, Any]] = [] + + for node in root.iter(): + tag = _local_name(node.tag) + + if tag == "svg": + continue + + element_id = node.attrib.get("id") + classes = _parse_classes(node.attrib.get("class")) + kind = _infer_kind(element_id=element_id, classes=classes, tag=tag) + + inherited = parent_map.get(id(node), {}) + inherited_sector_id = inherited.get("sector_id") + inherited_group_id = inherited.get("group_id") + + explicit_sector_id = node.attrib.get("data-sector-id") + explicit_group_id = node.attrib.get("data-group-id") + explicit_seat_id = node.attrib.get("data-seat-id") + explicit_row = node.attrib.get("data-row") + explicit_seat_number = node.attrib.get("data-seat-number") + + row_from_id, seat_number_from_id = _extract_seat_parts_from_id(element_id) + + seat_id = explicit_seat_id or (element_id if kind == "seat" else None) + sector_id = explicit_sector_id or inherited_sector_id + group_id = explicit_group_id or inherited_group_id + row = explicit_row or row_from_id + seat_number = explicit_seat_number or seat_number_from_id + + item = { + "id": element_id, + "tag": tag, + "kind": kind, + "classes": classes, + "x": _to_float(node.attrib.get("x")), + "y": _to_float(node.attrib.get("y")), + "cx": _to_float(node.attrib.get("cx")), + "cy": _to_float(node.attrib.get("cy")), + "width": _to_float(node.attrib.get("width")), + "height": _to_float(node.attrib.get("height")), + "href": node.attrib.get("href") or node.attrib.get("{http://www.w3.org/1999/xlink}href"), + "seat_id": seat_id, + "sector_id": sector_id, + "group_id": group_id, + "row": row, + "seat_number": seat_number, + } + + elements.append(item) + + if kind == "seat": + seats.append(item) + elif kind == "group": + groups.append(item) + elif kind == "sector": + sectors.append(item) + + return { + "summary": { + "elements_count": len(elements), + "seats_count": len(seats), + "groups_count": len(groups), + "sectors_count": len(sectors), + }, + "contract": { + "seat_fields": ["seat_id", "sector_id", "group_id", "row", "seat_number"], + "priority": [ + "data-* attributes", + "inherited parent sector/group", + "fallback to element id", + ], + }, + "elements": elements, + "seats": seats, + "groups": groups, + "sectors": sectors, + } + + +def normalize_svg_bytes_to_json(content: bytes) -> tuple[str, dict[str, Any]]: + payload = normalize_svg_bytes(content) + return json.dumps(payload, ensure_ascii=False, indent=2), payload diff --git a/backend/app/services/svg_sanitizer.py b/backend/app/services/svg_sanitizer.py new file mode 100644 index 0000000..4c5e46a --- /dev/null +++ b/backend/app/services/svg_sanitizer.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from lxml import etree + +from app.core.config import settings + + +DANGEROUS_TAGS = {"script"} +SVG_NS = "http://www.w3.org/2000/svg" +XLINK_NS = "http://www.w3.org/1999/xlink" +XLINK_HREF = f"{{{XLINK_NS}}}href" + + +def _local_name(tag: str) -> str: + if "}" in tag: + return tag.split("}", 1)[1] + return tag + + +def _is_external_ref(value: str) -> bool: + low = value.strip().lower() + return ( + low.startswith("http://") + or low.startswith("https://") + or low.startswith("file:") + or low.startswith("javascript:") + or low.startswith("data:") + or low.startswith("//") + ) + + +def sanitize_svg_bytes(content: bytes) -> tuple[bytes, int, int]: + parser = etree.XMLParser( + resolve_entities=False, + remove_blank_text=False, + remove_comments=False, + no_network=True, + recover=False, + huge_tree=True, + ) + root = etree.fromstring(content, parser=parser) + + removed_elements_count = 0 + removed_attributes_count = 0 + + for node in list(root.iter()): + tag_name = _local_name(node.tag) + + if tag_name in DANGEROUS_TAGS: + parent = node.getparent() + if parent is not None: + parent.remove(node) + removed_elements_count += 1 + continue + + if settings.svg_forbid_foreign_object_v1 and tag_name == "foreignObject": + parent = node.getparent() + if parent is not None: + parent.remove(node) + removed_elements_count += 1 + continue + + if settings.svg_forbid_image_v1 and tag_name == "image": + href = node.attrib.get("href") or node.attrib.get(XLINK_HREF) + if href and _is_external_ref(href): + parent = node.getparent() + if parent is not None: + parent.remove(node) + removed_elements_count += 1 + continue + + for attr_name in list(node.attrib.keys()): + local_attr = _local_name(attr_name).lower() + value = node.attrib.get(attr_name) or "" + + if local_attr.startswith("on"): + del node.attrib[attr_name] + removed_attributes_count += 1 + continue + + if local_attr in {"href"}: + if value and not value.startswith("#") and _is_external_ref(value): + del node.attrib[attr_name] + removed_attributes_count += 1 + continue + + if attr_name == XLINK_HREF: + if value and not value.startswith("#") and _is_external_ref(value): + del node.attrib[attr_name] + removed_attributes_count += 1 + continue + + sanitized = etree.tostring( + root, + encoding="utf-8", + xml_declaration=True, + pretty_print=False, + ) + return sanitized, removed_elements_count, removed_attributes_count diff --git a/backend/docs/api-map.md b/backend/docs/api-map.md new file mode 100644 index 0000000..acb640f --- /dev/null +++ b/backend/docs/api-map.md @@ -0,0 +1,49 @@ +# API map + +## app/api/routes/system.py +- GET / +- GET /healthz +- GET /api/v1/ping +- GET /api/v1/auth/me +- GET /api/v1/db/ping +- GET /api/v1/manifest + +## app/api/routes/uploads.py +- POST /api/v1/schemes/upload +- GET /api/v1/uploads +- GET /api/v1/uploads/{upload_id} +- GET /api/v1/uploads/{upload_id}/normalized + +## app/api/routes/schemes.py +- GET /api/v1/schemes +- GET /api/v1/schemes/{scheme_id} +- GET /api/v1/schemes/{scheme_id}/current +- GET /api/v1/schemes/{scheme_id}/versions +- POST /api/v1/schemes/{scheme_id}/versions +- POST /api/v1/schemes/{scheme_id}/publish +- POST /api/v1/schemes/{scheme_id}/unpublish +- POST /api/v1/schemes/{scheme_id}/rollback + +## app/api/routes/structure.py +- GET /api/v1/schemes/{scheme_id}/current/sectors +- GET /api/v1/schemes/{scheme_id}/current/groups +- GET /api/v1/schemes/{scheme_id}/current/seats +- GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price +- GET /api/v1/schemes/{scheme_id}/current/svg +- GET /api/v1/schemes/{scheme_id}/current/svg/display +- GET /api/v1/schemes/{scheme_id}/current/svg/display/meta + +## app/api/routes/pricing.py +- GET /api/v1/schemes/{scheme_id}/pricing +- POST /api/v1/schemes/{scheme_id}/pricing/categories +- PUT /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id} +- DELETE /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id} +- POST /api/v1/schemes/{scheme_id}/pricing/rules +- PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id} +- DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id} + +## app/api/routes/test_mode.py +- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} + +## app/api/routes/audit.py +- GET /api/v1/schemes/{scheme_id}/audit diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..72bd9d2 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +pydantic==2.11.7 +pydantic-settings==2.10.1 +python-multipart==0.0.20 +defusedxml==0.7.1 +sqlalchemy==2.0.43 +asyncpg==0.30.0 +alembic==1.16.4 + +lxml==5.3.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3036988 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + postgres: + image: postgres:16 + container_name: svg-service-postgres + env_file: + - ./.env + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "5434:5432" + volumes: + - ./postgres-data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 20 + + svg-service: + build: + context: ./backend + container_name: svg-service + env_file: + - ./.env + ports: + - "9020:9020" + volumes: + - ./storage:/data + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped