hasari-api / services /backend /db_models.py
erdoganpeker's picture
v0.3.0 — multimodal vehicle damage MVP
e327f0d
"""
backend/db_models.py
SQLAlchemy 2.0 ORM modelleri — pilot-production schema.
Tasarim notlari:
- Tum PK'lar UUID (server-side: gen_random_uuid via pgcrypto).
- JSONB tercih edildi (bbox/polygon/metadata/secondary_parts) — Postgres native.
- ENUM tipleri PG native ENUM (alembic migration'da CREATE TYPE).
- Foreign key cascade davranisi: user silinince audit_log + inspection da silinir
(pilot icin acceptable; production'da soft-delete dusunulebilir).
- Tum FK kolonlar indekslenir; sik kullanilan filter+sort'lar icin composite +
partial index'ler eklenmistir.
Import:
from db_models import (
Base, User, UserRole,
Inspection, InspectionStatus, InspectionMode,
InspectionImage, Damage, DamageType, Severity,
Part, ApiKey, AuditLog,
)
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
Enum as SAEnum,
ForeignKey,
Index,
Integer,
Numeric,
SmallInteger,
String,
Text,
func,
text,
)
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
# ---------------- Enums (Postgres native) ----------------
class UserRole(str):
ADMIN = "admin"
USER = "user"
user_role_enum = SAEnum(
"admin",
"user",
name="user_role",
create_type=False, # Alembic migration ENUM'u manuel create eder
validate_strings=True,
)
class InspectionStatus(str):
PENDING = "pending"
PROCESSING = "processing"
DONE = "done"
FAILED = "failed"
inspection_status_enum = SAEnum(
"pending",
"processing",
"done",
"failed",
name="inspection_status",
create_type=False,
validate_strings=True,
)
class InspectionMode(str):
SYNC = "sync"
ASYNC = "async"
inspection_mode_enum = SAEnum(
"sync",
"async",
name="inspection_mode",
create_type=False,
validate_strings=True,
)
class DamageType(str):
DENT = "dent"
SCRATCH = "scratch"
CRACK = "crack"
GLASS_SHATTER = "glass_shatter"
LAMP_BROKEN = "lamp_broken"
TIRE_FLAT = "tire_flat"
damage_type_enum = SAEnum(
"dent",
"scratch",
"crack",
"glass_shatter",
"lamp_broken",
"tire_flat",
name="damage_type",
create_type=False,
validate_strings=True,
)
class Severity(str):
HAFIF = "hafif"
ORTA = "orta"
AGIR = "agir"
severity_enum = SAEnum(
"hafif",
"orta",
"agir",
name="severity_level",
create_type=False,
validate_strings=True,
)
# ---------------- Helpers ----------------
def _uuid_pk() -> Mapped[uuid.UUID]:
return mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
def _now() -> Mapped[datetime]:
return mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
# ---------------- User ----------------
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = _uuid_pk()
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(
user_role_enum, nullable=False, server_default=text("'user'")
)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("true")
)
created_at: Mapped[datetime] = _now()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
last_login_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Relationships
inspections: Mapped[list[Inspection]] = relationship(
back_populates="user", cascade="all, delete-orphan", passive_deletes=True
)
api_keys: Mapped[list[ApiKey]] = relationship(
back_populates="user", cascade="all, delete-orphan", passive_deletes=True
)
audit_logs: Mapped[list[AuditLog]] = relationship(
back_populates="user", passive_deletes=True
)
__table_args__ = (
# email unique constraint zaten unique=True ile gelir; ek B-tree index
# case-insensitive arama icin ileride lower(email) functional index eklenebilir.
Index("idx_users_email", "email"),
Index("idx_users_role", "role"),
)
# ---------------- Inspection ----------------
class Inspection(Base):
__tablename__ = "inspections"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
status: Mapped[str] = mapped_column(
inspection_status_enum, nullable=False, server_default=text("'pending'")
)
mode: Mapped[str] = mapped_column(
inspection_mode_enum, nullable=False, server_default=text("'async'")
)
image_count: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("0")
)
created_at: Mapped[datetime] = _now()
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
processing_duration_ms: Mapped[Optional[int]] = mapped_column(
BigInteger, nullable=True
)
error_msg: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
model_versions: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True
)
# Relationships
user: Mapped[User] = relationship(back_populates="inspections")
images: Mapped[list[InspectionImage]] = relationship(
back_populates="inspection",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="InspectionImage.order_idx",
)
damages: Mapped[list[Damage]] = relationship(
back_populates="inspection",
cascade="all, delete-orphan",
passive_deletes=True,
)
parts: Mapped[list[Part]] = relationship(
back_populates="inspection",
cascade="all, delete-orphan",
passive_deletes=True,
)
__table_args__ = (
# NOT: created_at DESC siralamasi ile pagination (history listesi) icin.
# Migration 0001'de zaten DESC olusturuluyor; modeli hizalamak icin text() kullanildi.
Index("idx_inspections_user_created", "user_id", text("created_at DESC")),
# Aktif inspection'lar icin partial index — kuyruk taramasi O(active).
Index(
"idx_inspections_status_active",
"status",
postgresql_where=text("status IN ('pending', 'processing')"),
),
Index("idx_inspections_status", "status"),
# JSONB sutunlarinda nested sorgu (model_versions ->> 'detector') hizlandirmak icin GIN.
Index(
"idx_inspections_model_versions_gin",
"model_versions",
postgresql_using="gin",
),
# Completed_at gore "son tamamlanan" sorgular icin partial DESC index.
Index(
"idx_inspections_completed_at",
text("completed_at DESC"),
postgresql_where=text("status = 'done'"),
),
)
# ---------------- InspectionImage ----------------
class InspectionImage(Base):
__tablename__ = "inspection_images"
id: Mapped[uuid.UUID] = _uuid_pk()
inspection_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("inspections.id", ondelete="CASCADE"),
nullable=False,
)
order_idx: Mapped[int] = mapped_column(SmallInteger, nullable=False)
s3_key: Mapped[str] = mapped_column(String(512), nullable=False)
original_filename: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
width: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
height: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
created_at: Mapped[datetime] = _now()
inspection: Mapped[Inspection] = relationship(back_populates="images")
damages: Mapped[list[Damage]] = relationship(
back_populates="image", cascade="all, delete-orphan", passive_deletes=True
)
parts: Mapped[list[Part]] = relationship(
back_populates="image", cascade="all, delete-orphan", passive_deletes=True
)
__table_args__ = (
Index(
"idx_inspection_images_inspection_order",
"inspection_id",
"order_idx",
unique=True,
),
CheckConstraint("order_idx >= 0", name="ck_inspection_images_order_idx_nonneg"),
)
# ---------------- Damage ----------------
class Damage(Base):
__tablename__ = "damages"
id: Mapped[uuid.UUID] = _uuid_pk()
inspection_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("inspections.id", ondelete="CASCADE"),
nullable=False,
)
image_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("inspection_images.id", ondelete="CASCADE"),
nullable=False,
)
damage_type: Mapped[str] = mapped_column(damage_type_enum, nullable=False)
primary_part: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
secondary_parts: Mapped[Optional[list[str]]] = mapped_column(JSONB, nullable=True)
bbox: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
polygon: Mapped[Optional[list[list[float]]]] = mapped_column(JSONB, nullable=True)
confidence: Mapped[float] = mapped_column(
Numeric(5, 4), nullable=False
) # 0.0000 - 1.0000
severity: Mapped[Optional[str]] = mapped_column(severity_enum, nullable=True)
severity_confidence: Mapped[Optional[float]] = mapped_column(
Numeric(5, 4), nullable=True
)
cost_min_tl: Mapped[Optional[float]] = mapped_column(Numeric(12, 2), nullable=True)
cost_max_tl: Mapped[Optional[float]] = mapped_column(Numeric(12, 2), nullable=True)
is_multi_part: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("false")
)
is_low_confidence_match: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("false")
)
created_at: Mapped[datetime] = _now()
inspection: Mapped[Inspection] = relationship(back_populates="damages")
image: Mapped[InspectionImage] = relationship(back_populates="damages")
__table_args__ = (
Index("idx_damages_inspection", "inspection_id"),
Index("idx_damages_image", "image_id"),
Index("idx_damages_type", "damage_type"),
Index("idx_damages_severity", "severity"),
# GIN: bbox/polygon/secondary_parts uzerinde @> containment ve ->> aramalari icin.
Index(
"idx_damages_secondary_parts_gin",
"secondary_parts",
postgresql_using="gin",
),
CheckConstraint(
"confidence >= 0 AND confidence <= 1",
name="ck_damages_confidence_range",
),
CheckConstraint(
"severity_confidence IS NULL OR (severity_confidence >= 0 AND severity_confidence <= 1)",
name="ck_damages_severity_confidence_range",
),
CheckConstraint(
"cost_min_tl IS NULL OR cost_max_tl IS NULL OR cost_min_tl <= cost_max_tl",
name="ck_damages_cost_range",
),
)
# ---------------- Part ----------------
class Part(Base):
__tablename__ = "parts"
id: Mapped[uuid.UUID] = _uuid_pk()
inspection_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("inspections.id", ondelete="CASCADE"),
nullable=False,
)
image_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("inspection_images.id", ondelete="CASCADE"),
nullable=False,
)
part_name: Mapped[str] = mapped_column(String(64), nullable=False)
bbox: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
polygon: Mapped[Optional[list[list[float]]]] = mapped_column(JSONB, nullable=True)
confidence: Mapped[float] = mapped_column(Numeric(5, 4), nullable=False)
created_at: Mapped[datetime] = _now()
inspection: Mapped[Inspection] = relationship(back_populates="parts")
image: Mapped[InspectionImage] = relationship(back_populates="parts")
__table_args__ = (
Index("idx_parts_inspection", "inspection_id"),
Index("idx_parts_image", "image_id"),
Index("idx_parts_name", "part_name"),
CheckConstraint(
"confidence >= 0 AND confidence <= 1",
name="ck_parts_confidence_range",
),
)
# ---------------- ApiKey ----------------
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
last_used_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = _now()
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("true")
)
user: Mapped[User] = relationship(back_populates="api_keys")
__table_args__ = (
Index("idx_api_keys_user", "user_id"),
# Aktif anahtarlar uzerinde sorgu icin partial index
Index(
"idx_api_keys_active",
"key_hash",
postgresql_where=text("is_active = true"),
),
)
# ---------------- AuditLog ----------------
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[uuid.UUID] = _uuid_pk()
# Audit log icin SET NULL: user silinse bile log kaydi tutulur (compliance).
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
action: Mapped[str] = mapped_column(String(128), nullable=False)
resource_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
resource_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
# Pydantic v2 / TS uyumu icin alan adi "metadata" — ancak SQLAlchemy
# `MetaData` ile karismamasi icin attribute "extra_metadata" yapildi ve
# kolonu "metadata" olarak adlandirildi.
extra_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column(
"metadata", JSONB, nullable=True
)
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = _now()
user: Mapped[Optional[User]] = relationship(back_populates="audit_logs")
__table_args__ = (
# created_at DESC: audit log her zaman tersten okunur.
Index("idx_audit_log_user_created", "user_id", text("created_at DESC")),
Index("idx_audit_log_action", "action"),
Index("idx_audit_log_resource", "resource_type", "resource_id"),
# JSONB metadata uzerinde sorgu (action filter + payload arama) icin GIN.
# NOT: Index'e string ad verirken SQLAlchemy kolon adina (ORM attribute
# degil) bakar. extra_metadata attribute'unun gercek kolonu "metadata"
# (mapped_column'da rename edildi); GIN bu isim uzerinde tanimlanir.
Index(
"idx_audit_log_metadata_gin",
"metadata",
postgresql_using="gin",
),
)
__all__ = [
"Base",
"User",
"UserRole",
"Inspection",
"InspectionStatus",
"InspectionMode",
"InspectionImage",
"Damage",
"DamageType",
"Severity",
"Part",
"ApiKey",
"AuditLog",
]