Initial commit: svg backend

This commit is contained in:
adminko
2026-03-19 13:39:32 +03:00
commit 85fb2f4bb9
78 changed files with 6161 additions and 0 deletions

30
.env.example Normal file
View File

@@ -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

46
.gitignore vendored Normal file
View File

@@ -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

467
README.md Normal file
View File

@@ -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: <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/<scheme_id>/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":"<category_id>","target_type":"sector","target_ref":"vip","amount":"2500.00","currency":"RUB"}' \
http://127.0.0.1:9020/api/v1/schemes/<scheme_id>/pricing/rules
### 5. Проверить цену места
curl -s -H 'X-API-Key: admin-local-dev-key' \
http://127.0.0.1:9020/api/v1/schemes/<scheme_id>/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/<scheme_id>/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

17
backend/Dockerfile Normal file
View File

@@ -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"]

37
backend/alembic.ini Normal file
View File

@@ -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

76
backend/alembic/env.py Normal file
View File

@@ -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()

View File

@@ -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"}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

0
backend/app/__init__.py Normal file
View File

View File

1081
backend/app/api/routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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),
)

View File

@@ -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")

View File

@@ -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,
)

View File

@@ -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,
}

View File

@@ -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",
],
},
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

View File

@@ -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()

View File

5
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

22
backend/app/db/session.py Normal file
View File

@@ -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}

View File

View File

@@ -0,0 +1,7 @@
from enum import Enum
class UserRole(str, Enum):
ADMIN = "admin"
OPERATOR = "operator"
VIEWER = "viewer"

49
backend/app/main.py Normal file
View File

@@ -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)

View File

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

View File

@@ -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())

View File

@@ -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",
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

View File

@@ -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

View File

@@ -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"))

View File

@@ -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")

View File

@@ -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

View File

@@ -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 <svg>",
)
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

View File

@@ -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

View File

@@ -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

49
backend/docs/api-map.md Normal file
View File

@@ -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

11
backend/requirements.txt Normal file
View File

@@ -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

35
docker-compose.yml Normal file
View File

@@ -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