Initial commit: svg backend
This commit is contained in:
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal 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
37
backend/alembic.ini
Normal 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
76
backend/alembic/env.py
Normal 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()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal 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"}
|
||||
44
backend/alembic/versions/20260316_01_create_uploads_table.py
Normal file
44
backend/alembic/versions/20260316_01_create_uploads_table.py
Normal 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")
|
||||
40
backend/alembic/versions/20260316_02_create_schemes_table.py
Normal file
40
backend/alembic/versions/20260316_02_create_schemes_table.py
Normal 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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
1081
backend/app/api/routes.py
Normal file
1081
backend/app/api/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
18
backend/app/api/routes/__init__.py
Normal file
18
backend/app/api/routes/__init__.py
Normal 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)
|
||||
31
backend/app/api/routes/audit.py
Normal file
31
backend/app/api/routes/audit.py
Normal 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),
|
||||
)
|
||||
233
backend/app/api/routes/pricing.py
Normal file
233
backend/app/api/routes/pricing.py
Normal 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")
|
||||
241
backend/app/api/routes/schemes.py
Normal file
241
backend/app/api/routes/schemes.py
Normal 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,
|
||||
)
|
||||
331
backend/app/api/routes/structure.py
Normal file
331
backend/app/api/routes/structure.py
Normal 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,
|
||||
}
|
||||
76
backend/app/api/routes/system.py
Normal file
76
backend/app/api/routes/system.py
Normal 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",
|
||||
],
|
||||
},
|
||||
)
|
||||
88
backend/app/api/routes/test_mode.py
Normal file
88
backend/app/api/routes/test_mode.py
Normal 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,
|
||||
)
|
||||
261
backend/app/api/routes/uploads.py
Normal file
261
backend/app/api/routes/uploads.py
Normal 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,
|
||||
)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
78
backend/app/core/config.py
Normal file
78
backend/app/core/config.py
Normal 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()
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
5
backend/app/db/base.py
Normal file
5
backend/app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
22
backend/app/db/session.py
Normal file
22
backend/app/db/session.py
Normal 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}
|
||||
0
backend/app/domain/__init__.py
Normal file
0
backend/app/domain/__init__.py
Normal file
7
backend/app/domain/roles.py
Normal file
7
backend/app/domain/roles.py
Normal 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
49
backend/app/main.py
Normal 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)
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
31
backend/app/models/audit_event.py
Normal file
31
backend/app/models/audit_event.py
Normal 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(),
|
||||
)
|
||||
40
backend/app/models/price_rule.py
Normal file
40
backend/app/models/price_rule.py
Normal 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(),
|
||||
)
|
||||
29
backend/app/models/pricing_category.py
Normal file
29
backend/app/models/pricing_category.py
Normal 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(),
|
||||
)
|
||||
36
backend/app/models/scheme.py
Normal file
36
backend/app/models/scheme.py
Normal 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(),
|
||||
)
|
||||
38
backend/app/models/scheme_group.py
Normal file
38
backend/app/models/scheme_group.py
Normal 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(),
|
||||
)
|
||||
49
backend/app/models/scheme_seat.py
Normal file
49
backend/app/models/scheme_seat.py
Normal 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(),
|
||||
)
|
||||
38
backend/app/models/scheme_sector.py
Normal file
38
backend/app/models/scheme_sector.py
Normal 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(),
|
||||
)
|
||||
37
backend/app/models/scheme_version.py
Normal file
37
backend/app/models/scheme_version.py
Normal 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(),
|
||||
)
|
||||
38
backend/app/models/upload.py
Normal file
38
backend/app/models/upload.py
Normal 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(),
|
||||
)
|
||||
0
backend/app/repositories/__init__.py
Normal file
0
backend/app/repositories/__init__.py
Normal file
42
backend/app/repositories/audit.py
Normal file
42
backend/app/repositories/audit.py
Normal 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())
|
||||
237
backend/app/repositories/pricing.py
Normal file
237
backend/app/repositories/pricing.py
Normal 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",
|
||||
)
|
||||
74
backend/app/repositories/scheme_groups.py
Normal file
74
backend/app/repositories/scheme_groups.py
Normal 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()
|
||||
118
backend/app/repositories/scheme_seats.py
Normal file
118
backend/app/repositories/scheme_seats.py
Normal 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()
|
||||
74
backend/app/repositories/scheme_sectors.py
Normal file
74
backend/app/repositories/scheme_sectors.py
Normal 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()
|
||||
169
backend/app/repositories/scheme_versions.py
Normal file
169
backend/app/repositories/scheme_versions.py
Normal 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
|
||||
198
backend/app/repositories/schemes.py
Normal file
198
backend/app/repositories/schemes.py
Normal 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
|
||||
79
backend/app/repositories/uploads.py
Normal file
79
backend/app/repositories/uploads.py
Normal 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
|
||||
18
backend/app/schemas/audit.py
Normal file
18
backend/app/schemas/audit.py
Normal 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
|
||||
28
backend/app/schemas/manifest.py
Normal file
28
backend/app/schemas/manifest.py
Normal 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
|
||||
179
backend/app/schemas/pricing.py
Normal file
179
backend/app/schemas/pricing.py
Normal 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
|
||||
19
backend/app/schemas/scheme_groups.py
Normal file
19
backend/app/schemas/scheme_groups.py
Normal 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
|
||||
67
backend/app/schemas/scheme_registry.py
Normal file
67
backend/app/schemas/scheme_registry.py
Normal 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
|
||||
29
backend/app/schemas/scheme_seats.py
Normal file
29
backend/app/schemas/scheme_seats.py
Normal 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
|
||||
19
backend/app/schemas/scheme_sectors.py
Normal file
19
backend/app/schemas/scheme_sectors.py
Normal 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
|
||||
29
backend/app/schemas/scheme_versions.py
Normal file
29
backend/app/schemas/scheme_versions.py
Normal 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
|
||||
21
backend/app/schemas/test_mode.py
Normal file
21
backend/app/schemas/test_mode.py
Normal 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
|
||||
21
backend/app/schemas/upload.py
Normal file
21
backend/app/schemas/upload.py
Normal 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
|
||||
46
backend/app/schemas/upload_registry.py
Normal file
46
backend/app/schemas/upload_registry.py
Normal 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
|
||||
0
backend/app/security/__init__.py
Normal file
0
backend/app/security/__init__.py
Normal file
31
backend/app/security/auth.py
Normal file
31
backend/app/security/auth.py
Normal 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
|
||||
16
backend/app/services/normalized_reader.py
Normal file
16
backend/app/services/normalized_reader.py
Normal 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"))
|
||||
49
backend/app/services/storage.py
Normal file
49
backend/app/services/storage.py
Normal 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")
|
||||
187
backend/app/services/svg_display_processor.py
Normal file
187
backend/app/services/svg_display_processor.py
Normal 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
|
||||
31
backend/app/services/svg_inspector.py
Normal file
31
backend/app/services/svg_inspector.py
Normal 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
|
||||
200
backend/app/services/svg_normalizer.py
Normal file
200
backend/app/services/svg_normalizer.py
Normal 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
|
||||
99
backend/app/services/svg_sanitizer.py
Normal file
99
backend/app/services/svg_sanitizer.py
Normal 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
49
backend/docs/api-map.md
Normal 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
11
backend/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user