From 5b92553148e76e119e6aa67fcadeaaa1ba0b1557 Mon Sep 17 00:00:00 2001 From: nekenny Date: Fri, 3 Apr 2026 22:03:20 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20database=20models=20and=20Alembic=20mig?= =?UTF-8?q?rations=20=E2=80=94=20User,=20Recording,=20Transcription,=20Pro?= =?UTF-8?q?tocol,=20PromptTemplate,=20ExportFile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/alembic.ini | 36 ++++++++++++++++++++++ backend/alembic/env.py | 44 +++++++++++++++++++++++++++ backend/alembic/versions/.gitkeep | 0 backend/app/__init__.py | 0 backend/app/config.py | 28 +++++++++++++++++ backend/app/database.py | 11 +++++++ backend/app/models/__init__.py | 23 ++++++++++++++ backend/app/models/base.py | 21 +++++++++++++ backend/app/models/export_file.py | 26 ++++++++++++++++ backend/app/models/prompt_template.py | 28 +++++++++++++++++ backend/app/models/protocol.py | 39 ++++++++++++++++++++++++ backend/app/models/recording.py | 40 ++++++++++++++++++++++++ backend/app/models/transcription.py | 23 ++++++++++++++ backend/app/models/user.py | 25 +++++++++++++++ 14 files changed, 344 insertions(+) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/versions/.gitkeep create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/export_file.py create mode 100644 backend/app/models/prompt_template.py create mode 100644 backend/app/models/protocol.py create mode 100644 backend/app/models/recording.py create mode 100644 backend/app/models/transcription.py create mode 100644 backend/app/models/user.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..4770284 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql+asyncpg://meetproto:changeme@db:5432/meetproto + +[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 = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..abfab38 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,44 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from app.config import settings +from app.models import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline(): + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online(): + connectable = create_async_engine(settings.database_url) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e13972f --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,28 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_url: str = "postgresql+asyncpg://meetproto:changeme@db:5432/meetproto" + redis_url: str = "redis://redis:6379/0" + jwt_secret: str = "change-this-to-a-random-secret" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 1440 + whisper_model: str = "large-v3" + whisper_device: str = "cuda" + whisper_compute_type: str = "float16" + litellm_model: str = "openrouter/meta-llama/llama-3.1-70b-instruct" + litellm_api_key: str = "" + litellm_api_base: str = "" + upload_dir: str = "/app/uploads" + export_dir: str = "/app/exports" + retention_days: int = 90 + telegram_bot_token: str = "" + telegram_webhook_url: str = "" + app_host: str = "0.0.0.0" + app_port: int = 8000 + app_url: str = "http://localhost:8000" + + model_config = {"env_file": ".env", "extra": "ignore"} + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..1f048db --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine(settings.database_url, echo=False) +async_session = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_db(): + async with async_session() as session: + yield session diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..a3a4382 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,23 @@ +from app.models.base import Base +from app.models.export_file import ExportFile, ExportFormat +from app.models.prompt_template import PromptTemplate, TemplateType +from app.models.protocol import Protocol, ProtocolStatus +from app.models.recording import Recording, RecordingSource, RecordingStatus +from app.models.transcription import Transcription +from app.models.user import User, UserRole + +__all__ = [ + "Base", + "User", + "UserRole", + "Recording", + "RecordingSource", + "RecordingStatus", + "Transcription", + "Protocol", + "ProtocolStatus", + "PromptTemplate", + "TemplateType", + "ExportFile", + "ExportFormat", +] diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..b256092 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + +class UUIDMixin: + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, default=uuid.uuid4 + ) diff --git a/backend/app/models/export_file.py b/backend/app/models/export_file.py new file mode 100644 index 0000000..e45ec72 --- /dev/null +++ b/backend/app/models/export_file.py @@ -0,0 +1,26 @@ +import enum +import uuid + +from sqlalchemy import BigInteger, Enum, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + + +class ExportFormat(str, enum.Enum): + docx = "docx" + pdf = "pdf" + md = "md" + + +class ExportFile(UUIDMixin, TimestampMixin, Base): + __tablename__ = "export_files" + + protocol_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("protocols.id", ondelete="CASCADE") + ) + format: Mapped[ExportFormat] = mapped_column(Enum(ExportFormat)) + file_path: Mapped[str] = mapped_column(String(1000)) + file_size: Mapped[int] = mapped_column(BigInteger) + + protocol: Mapped["Protocol"] = relationship(back_populates="export_files") diff --git a/backend/app/models/prompt_template.py b/backend/app/models/prompt_template.py new file mode 100644 index 0000000..379feb5 --- /dev/null +++ b/backend/app/models/prompt_template.py @@ -0,0 +1,28 @@ +import enum + +from sqlalchemy import Boolean, Enum, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + + +class TemplateType(str, enum.Enum): + client_survey = "client_survey" + client_intro = "client_intro" + internal = "internal" + custom = "custom" + + +class PromptTemplate(UUIDMixin, TimestampMixin, Base): + __tablename__ = "prompt_templates" + + name: Mapped[str] = mapped_column(String(255)) + description: Mapped[str] = mapped_column(String(1000), default="") + type: Mapped[TemplateType] = mapped_column(Enum(TemplateType)) + system_prompt: Mapped[str] = mapped_column(Text) + user_prompt: Mapped[str] = mapped_column(Text) + output_schema: Mapped[dict] = mapped_column(JSONB, default=dict) + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + + protocols: Mapped[list["Protocol"]] = relationship(back_populates="template") diff --git a/backend/app/models/protocol.py b/backend/app/models/protocol.py new file mode 100644 index 0000000..b702440 --- /dev/null +++ b/backend/app/models/protocol.py @@ -0,0 +1,39 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + + +class ProtocolStatus(str, enum.Enum): + generating = "generating" + done = "done" + error = "error" + + +class Protocol(UUIDMixin, TimestampMixin, Base): + __tablename__ = "protocols" + + transcription_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("transcriptions.id", ondelete="CASCADE") + ) + template_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("prompt_templates.id") + ) + content: Mapped[dict] = mapped_column(JSONB, default=dict) + raw_text: Mapped[str] = mapped_column(Text, default="") + llm_model: Mapped[str] = mapped_column(String(100)) + status: Mapped[ProtocolStatus] = mapped_column( + Enum(ProtocolStatus), default=ProtocolStatus.generating + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + transcription: Mapped["Transcription"] = relationship(back_populates="protocols") + template: Mapped["PromptTemplate"] = relationship(back_populates="protocols") + export_files: Mapped[list["ExportFile"]] = relationship(back_populates="protocol") diff --git a/backend/app/models/recording.py b/backend/app/models/recording.py new file mode 100644 index 0000000..be0c39b --- /dev/null +++ b/backend/app/models/recording.py @@ -0,0 +1,40 @@ +import enum +import uuid + +from sqlalchemy import BigInteger, Enum, ForeignKey, Interval, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + + +class RecordingSource(str, enum.Enum): + web = "web" + telegram = "telegram" + lensa = "lensa" + + +class RecordingStatus(str, enum.Enum): + uploaded = "uploaded" + processing = "processing" + done = "done" + error = "error" + + +class Recording(UUIDMixin, TimestampMixin, Base): + __tablename__ = "recordings" + + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id")) + title: Mapped[str] = mapped_column(String(500)) + file_path: Mapped[str] = mapped_column(String(1000)) + file_size: Mapped[int] = mapped_column(BigInteger) + duration: Mapped[float | None] = mapped_column(nullable=True) + format: Mapped[str] = mapped_column(String(20)) + source: Mapped[RecordingSource] = mapped_column(Enum(RecordingSource)) + status: Mapped[RecordingStatus] = mapped_column( + Enum(RecordingStatus), default=RecordingStatus.uploaded + ) + + user: Mapped["User"] = relationship(back_populates="recordings") + transcription: Mapped["Transcription | None"] = relationship( + back_populates="recording", uselist=False + ) diff --git a/backend/app/models/transcription.py b/backend/app/models/transcription.py new file mode 100644 index 0000000..ac77d6a --- /dev/null +++ b/backend/app/models/transcription.py @@ -0,0 +1,23 @@ +import uuid + +from sqlalchemy import ForeignKey, Interval, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + + +class Transcription(UUIDMixin, TimestampMixin, Base): + __tablename__ = "transcriptions" + + recording_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("recordings.id", ondelete="CASCADE"), unique=True + ) + text: Mapped[str] = mapped_column(Text) + segments: Mapped[dict] = mapped_column(JSONB, default=list) + language: Mapped[str | None] = mapped_column(String(10), nullable=True) + whisper_model: Mapped[str] = mapped_column(String(50)) + processing_time: Mapped[float | None] = mapped_column(nullable=True) + + recording: Mapped["Recording"] = relationship(back_populates="transcription") + protocols: Mapped[list["Protocol"]] = relationship(back_populates="transcription") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..b7dde20 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,25 @@ +import enum + +from sqlalchemy import Boolean, Enum, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + + +class UserRole(str, enum.Enum): + admin = "admin" + user = "user" + + +class User(UUIDMixin, TimestampMixin, Base): + __tablename__ = "users" + + username: Mapped[str] = mapped_column(String(150), unique=True, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + role: Mapped[UserRole] = mapped_column( + Enum(UserRole), default=UserRole.user + ) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + recordings: Mapped[list["Recording"]] = relationship(back_populates="user")