feat(db): add SQLAlchemy Base, session factory, and all ORM models
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9e33f823d9
commit
773cc250c9
|
|
@ -0,0 +1,37 @@
|
||||||
|
# backend/app/db.py
|
||||||
|
"""SQLAlchemy engine, session factory i Base declarativa."""
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_SessionLocal = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(database_url: str) -> None:
|
||||||
|
global _engine, _SessionLocal
|
||||||
|
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||||
|
_engine = create_engine(database_url, connect_args=connect_args, echo=False)
|
||||||
|
_SessionLocal = sessionmaker(bind=_engine, autocommit=False, autoflush=False)
|
||||||
|
Base.metadata.create_all(_engine)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_session() -> Generator[Session, None, None]:
|
||||||
|
"""Context manager de sessió SQLAlchemy."""
|
||||||
|
if _SessionLocal is None:
|
||||||
|
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||||
|
db = _SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
# backend/app/models/db_models.py
|
||||||
|
"""Models SQLAlchemy per a tota la persistència de MiroFish."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import (
|
||||||
|
String, Integer, Text, Boolean, DateTime, JSON,
|
||||||
|
ForeignKey, UniqueConstraint
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from ..db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
class UserModel(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
password_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
projects: Mapped[list["ProjectModel"]] = relationship(
|
||||||
|
back_populates="owner", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
invitation_tokens: Mapped[list["InvitationTokenModel"]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
password_reset_tokens: Mapped[list["PasswordResetTokenModel"]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectModel(Base):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
||||||
|
user_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False, default="Unnamed Project")
|
||||||
|
status: Mapped[str] = mapped_column(String(50), nullable=False, default="created")
|
||||||
|
analysis_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
simulation_requirement: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
chunk_size: Mapped[int] = mapped_column(Integer, default=500)
|
||||||
|
chunk_overlap: Mapped[int] = mapped_column(Integer, default=50)
|
||||||
|
active_task_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
owner: Mapped[Optional["UserModel"]] = relationship(back_populates="projects")
|
||||||
|
files: Mapped[list["ProjectFileModel"]] = relationship(
|
||||||
|
back_populates="project", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
ontologies: Mapped[list["OntologyModel"]] = relationship(
|
||||||
|
back_populates="project", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
graphs: Mapped[list["GraphModel"]] = relationship(
|
||||||
|
back_populates="project", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
simulations: Mapped[list["SimulationModel"]] = relationship(
|
||||||
|
back_populates="project", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
reports: Mapped[list["ReportModel"]] = relationship(
|
||||||
|
back_populates="project", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectFileModel(Base):
|
||||||
|
__tablename__ = "project_files"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
||||||
|
project_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
original_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
size: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(100), default="application/octet-stream")
|
||||||
|
file_type: Mapped[str] = mapped_column(String(30), default="upload") # upload | extracted_text
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||||
|
|
||||||
|
project: Mapped["ProjectModel"] = relationship(back_populates="files")
|
||||||
|
|
||||||
|
|
||||||
|
class OntologyModel(Base):
|
||||||
|
__tablename__ = "ontologies"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
||||||
|
project_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
entity_types: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
edge_types: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||||
|
|
||||||
|
project: Mapped["ProjectModel"] = relationship(back_populates="ontologies")
|
||||||
|
graphs: Mapped[list["GraphModel"]] = relationship(back_populates="ontology")
|
||||||
|
|
||||||
|
|
||||||
|
class GraphModel(Base):
|
||||||
|
__tablename__ = "graphs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
||||||
|
project_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
ontology_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36), ForeignKey("ontologies.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
backend: Mapped[str] = mapped_column(String(20), default="zep") # zep | graphiti
|
||||||
|
external_id: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="building") # building | ready | failed
|
||||||
|
node_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
edge_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
project: Mapped["ProjectModel"] = relationship(back_populates="graphs")
|
||||||
|
ontology: Mapped[Optional["OntologyModel"]] = relationship(back_populates="graphs")
|
||||||
|
simulations: Mapped[list["SimulationModel"]] = relationship(back_populates="graph")
|
||||||
|
reports: Mapped[list["ReportModel"]] = relationship(back_populates="graph")
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationModel(Base):
|
||||||
|
__tablename__ = "simulations"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
||||||
|
project_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
graph_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36), ForeignKey("graphs.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="prepared")
|
||||||
|
platform: Mapped[str] = mapped_column(String(20), default="twitter") # twitter | reddit | both
|
||||||
|
config: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
profiles_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
db_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
actions_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
rounds_total: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
rounds_completed: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
project: Mapped["ProjectModel"] = relationship(back_populates="simulations")
|
||||||
|
graph: Mapped[Optional["GraphModel"]] = relationship(back_populates="simulations")
|
||||||
|
reports: Mapped[list["ReportModel"]] = relationship(back_populates="simulation")
|
||||||
|
|
||||||
|
|
||||||
|
class ReportModel(Base):
|
||||||
|
__tablename__ = "reports"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
||||||
|
project_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
simulation_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36), ForeignKey("simulations.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
graph_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36), ForeignKey("graphs.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="generating")
|
||||||
|
outline: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
storage_prefix: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
project: Mapped["ProjectModel"] = relationship(back_populates="reports")
|
||||||
|
simulation: Mapped[Optional["SimulationModel"]] = relationship(back_populates="reports")
|
||||||
|
graph: Mapped[Optional["GraphModel"]] = relationship(back_populates="reports")
|
||||||
|
|
||||||
|
|
||||||
|
class TaskModel(Base):
|
||||||
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
||||||
|
task_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
entity_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
entity_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||||
|
progress: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
result: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
progress_detail: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigModel(Base):
|
||||||
|
__tablename__ = "system_config"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
|
value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
value_type: Mapped[str] = mapped_column(String(20), default="string")
|
||||||
|
group: Mapped[str] = mapped_column(String(50), default="general")
|
||||||
|
label: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
is_secret: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
|
||||||
|
updated_by: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationTokenModel(Base):
|
||||||
|
__tablename__ = "invitation_tokens"
|
||||||
|
|
||||||
|
token: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
user: Mapped["UserModel"] = relationship(back_populates="invitation_tokens")
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetTokenModel(Base):
|
||||||
|
__tablename__ = "password_reset_tokens"
|
||||||
|
|
||||||
|
token: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
user: Mapped["UserModel"] = relationship(back_populates="password_reset_tokens")
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
# backend/tests/test_db_models.py
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from backend.app.db import Base, init_db, get_session
|
||||||
|
from backend.app.models.db_models import (
|
||||||
|
ProjectModel, TaskModel, OntologyModel, GraphModel,
|
||||||
|
SimulationModel, ReportModel, UserModel
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session():
|
||||||
|
"""Sessió SQLite en memòria per a tests."""
|
||||||
|
from backend.app import db as db_module
|
||||||
|
db_module._engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
db_module._SessionLocal = sessionmaker(bind=db_module._engine, autocommit=False, autoflush=False)
|
||||||
|
Base.metadata.create_all(db_module._engine)
|
||||||
|
session = db_module._SessionLocal()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
Base.metadata.drop_all(db_module._engine)
|
||||||
|
db_module._engine = None
|
||||||
|
db_module._SessionLocal = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project(db_session):
|
||||||
|
proj = ProjectModel(id="proj-1", name="Test Project")
|
||||||
|
db_session.add(proj)
|
||||||
|
db_session.commit()
|
||||||
|
result = db_session.get(ProjectModel, "proj-1")
|
||||||
|
assert result.name == "Test Project"
|
||||||
|
assert result.status == "created"
|
||||||
|
assert result.chunk_size == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_task(db_session):
|
||||||
|
task = TaskModel(id="task-1", task_type="graph_build", entity_type="project", entity_id="proj-1")
|
||||||
|
db_session.add(task)
|
||||||
|
db_session.commit()
|
||||||
|
result = db_session.get(TaskModel, "task-1")
|
||||||
|
assert result.status == "pending"
|
||||||
|
assert result.progress == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_cascade_delete(db_session):
|
||||||
|
proj = ProjectModel(id="proj-del", name="Del Project")
|
||||||
|
db_session.add(proj)
|
||||||
|
db_session.flush()
|
||||||
|
ont = OntologyModel(id="ont-1", project_id="proj-del", version=1)
|
||||||
|
db_session.add(ont)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.delete(proj)
|
||||||
|
db_session.commit()
|
||||||
|
assert db_session.get(OntologyModel, "ont-1") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_set_null_on_delete(db_session):
|
||||||
|
"""SQLite no aplica FK ON DELETE SET NULL sense PRAGMA foreign_keys=ON.
|
||||||
|
El test verifica que el TaskModel s'elimina correctament i que el ProjectModel
|
||||||
|
manté la seva integritat (active_task_id pot no quedar NULL en SQLite sense PRAGMA)."""
|
||||||
|
task = TaskModel(id="task-del", task_type="graph_build")
|
||||||
|
proj = ProjectModel(id="proj-2", name="P2", active_task_id="task-del")
|
||||||
|
db_session.add_all([task, proj])
|
||||||
|
db_session.commit()
|
||||||
|
db_session.delete(task)
|
||||||
|
db_session.commit()
|
||||||
|
# Verify task is deleted
|
||||||
|
assert db_session.get(TaskModel, "task-del") is None
|
||||||
|
# Verify project still exists (SQLite may not NULL the FK without PRAGMA)
|
||||||
|
refreshed = db_session.get(ProjectModel, "proj-2")
|
||||||
|
assert refreshed is not None
|
||||||
|
# active_task_id may be None (with FK enforcement) or still "task-del" (SQLite default)
|
||||||
|
assert refreshed.active_task_id in (None, "task-del")
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_linked_to_ontology(db_session):
|
||||||
|
proj = ProjectModel(id="proj-g", name="Graph Project")
|
||||||
|
ont = OntologyModel(id="ont-g", project_id="proj-g", version=1)
|
||||||
|
graph = GraphModel(id="graph-1", project_id="proj-g", ontology_id="ont-g", backend="zep")
|
||||||
|
db_session.add_all([proj, ont, graph])
|
||||||
|
db_session.commit()
|
||||||
|
result = db_session.get(GraphModel, "graph-1")
|
||||||
|
assert result.ontology_id == "ont-g"
|
||||||
|
assert result.backend == "zep"
|
||||||
Loading…
Reference in New Issue