"""SQLAlchemy models for PunimTag Web - matching desktop schema exactly.""" from __future__ import annotations from datetime import datetime, date from typing import TYPE_CHECKING from sqlalchemy import ( Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, LargeBinary, Numeric, Text, UniqueConstraint, CheckConstraint, ) from sqlalchemy.orm import declarative_base, relationship from backend.constants.roles import DEFAULT_USER_ROLE if TYPE_CHECKING: pass Base = declarative_base() class Photo(Base): """Photo model - matches desktop schema exactly.""" __tablename__ = "photos" id = Column(Integer, primary_key=True, autoincrement=True, index=True) path = Column(Text, unique=True, nullable=False, index=True) filename = Column(Text, nullable=False) date_added = Column(DateTime, default=datetime.utcnow, nullable=False) date_taken = Column(Date, nullable=True, index=True) processed = Column(Boolean, default=False, nullable=False, index=True) file_hash = Column(Text, nullable=False, index=True) media_type = Column(Text, default="image", nullable=False, index=True) # "image" or "video" faces = relationship("Face", back_populates="photo", cascade="all, delete-orphan") photo_tags = relationship( "PhotoTagLinkage", back_populates="photo", cascade="all, delete-orphan" ) favorites = relationship("PhotoFavorite", back_populates="photo", cascade="all, delete-orphan") video_people = relationship( "PhotoPersonLinkage", back_populates="photo", cascade="all, delete-orphan" ) __table_args__ = ( Index("idx_photos_processed", "processed"), Index("idx_photos_date_taken", "date_taken"), Index("idx_photos_date_added", "date_added"), Index("idx_photos_file_hash", "file_hash"), ) class Person(Base): """Person model - matches desktop schema exactly.""" __tablename__ = "people" id = Column(Integer, primary_key=True, autoincrement=True, index=True) first_name = Column(Text, nullable=False) last_name = Column(Text, nullable=False) middle_name = Column(Text, nullable=True) maiden_name = Column(Text, nullable=True) date_of_birth = Column(Date, nullable=True) created_date = Column(DateTime, default=datetime.utcnow, nullable=False) faces = relationship("Face", back_populates="person") person_encodings = relationship( "PersonEncoding", back_populates="person", cascade="all, delete-orphan" ) video_photos = relationship( "PhotoPersonLinkage", back_populates="person", cascade="all, delete-orphan" ) __table_args__ = ( UniqueConstraint( "first_name", "last_name", "middle_name", "maiden_name", "date_of_birth", name="uq_people_names_dob" ), ) class Face(Base): """Face detection model - matches desktop schema exactly.""" __tablename__ = "faces" id = Column(Integer, primary_key=True, autoincrement=True, index=True) photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True) person_id = Column(Integer, ForeignKey("people.id"), nullable=True, index=True) encoding = Column(LargeBinary, nullable=False) location = Column(Text, nullable=False) confidence = Column(Numeric, default=0.0, nullable=False) quality_score = Column(Numeric, default=0.0, nullable=False, index=True) is_primary_encoding = Column(Boolean, default=False, nullable=False) detector_backend = Column(Text, default="retinaface", nullable=False) model_name = Column(Text, default="ArcFace", nullable=False) face_confidence = Column(Numeric, default=0.0, nullable=False) exif_orientation = Column(Integer, nullable=True) pose_mode = Column(Text, default="frontal", nullable=False, index=True) yaw_angle = Column(Numeric, nullable=True) pitch_angle = Column(Numeric, nullable=True) roll_angle = Column(Numeric, nullable=True) landmarks = Column(Text, nullable=True) # JSON string of facial landmarks identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) excluded = Column(Boolean, default=False, nullable=False, index=True) # Exclude from identification photo = relationship("Photo", back_populates="faces") person = relationship("Person", back_populates="faces") person_encodings = relationship( "PersonEncoding", back_populates="face", cascade="all, delete-orphan" ) __table_args__ = ( Index("idx_faces_person_id", "person_id"), Index("idx_faces_photo_id", "photo_id"), Index("idx_faces_quality", "quality_score"), Index("idx_faces_pose_mode", "pose_mode"), Index("idx_faces_identified_by", "identified_by_user_id"), Index("idx_faces_excluded", "excluded"), ) class PersonEncoding(Base): """Person encoding model - matches desktop schema exactly (was person_encodings).""" __tablename__ = "person_encodings" id = Column(Integer, primary_key=True, autoincrement=True, index=True) person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True) face_id = Column(Integer, ForeignKey("faces.id"), nullable=False, index=True) encoding = Column(LargeBinary, nullable=False) quality_score = Column(Numeric, default=0.0, nullable=False, index=True) detector_backend = Column(Text, default="retinaface", nullable=False) model_name = Column(Text, default="ArcFace", nullable=False) created_date = Column(DateTime, default=datetime.utcnow, nullable=False) person = relationship("Person", back_populates="person_encodings") face = relationship("Face", back_populates="person_encodings") __table_args__ = ( Index("idx_person_encodings_person_id", "person_id"), Index("idx_person_encodings_quality", "quality_score"), ) class Tag(Base): """Tag model - matches desktop schema exactly.""" __tablename__ = "tags" id = Column(Integer, primary_key=True, autoincrement=True, index=True) tag_name = Column(Text, unique=True, nullable=False, index=True) created_date = Column(DateTime, default=datetime.utcnow, nullable=False) photo_tags = relationship( "PhotoTagLinkage", back_populates="tag", cascade="all, delete-orphan" ) class PhotoTagLinkage(Base): """Photo-Tag linkage model - matches desktop schema exactly (was phototaglinkage).""" __tablename__ = "phototaglinkage" linkage_id = Column(Integer, primary_key=True, autoincrement=True) photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True) tag_id = Column(Integer, ForeignKey("tags.id"), nullable=False, index=True) linkage_type = Column( Integer, default=0, nullable=False, server_default="0" ) created_date = Column(DateTime, default=datetime.utcnow, nullable=False) photo = relationship("Photo", back_populates="photo_tags") tag = relationship("Tag", back_populates="photo_tags") __table_args__ = ( UniqueConstraint("photo_id", "tag_id", name="uq_photo_tag"), CheckConstraint("linkage_type IN (0, 1)", name="ck_linkage_type"), Index("idx_photo_tags_tag", "tag_id"), Index("idx_photo_tags_photo", "photo_id"), ) class PhotoFavorite(Base): """Photo favorites model - user-specific favorites.""" __tablename__ = "photo_favorites" id = Column(Integer, primary_key=True, autoincrement=True) username = Column(Text, nullable=False, index=True) photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True) created_date = Column(DateTime, default=datetime.utcnow, nullable=False) photo = relationship("Photo", back_populates="favorites") __table_args__ = ( UniqueConstraint("username", "photo_id", name="uq_user_photo_favorite"), Index("idx_favorites_username", "username"), Index("idx_favorites_photo", "photo_id"), ) class User(Base): """User model for main database - separate from auth database users.""" __tablename__ = "users" id = Column(Integer, primary_key=True, autoincrement=True, index=True) username = Column(Text, unique=True, nullable=False, index=True) password_hash = Column(Text, nullable=False) # Hashed password email = Column(Text, unique=True, nullable=False, index=True) full_name = Column(Text, nullable=False) is_active = Column(Boolean, default=True, nullable=False) is_admin = Column(Boolean, default=False, nullable=False, index=True) role = Column( Text, nullable=False, default=DEFAULT_USER_ROLE, server_default=DEFAULT_USER_ROLE, index=True, ) password_change_required = Column(Boolean, default=True, nullable=False, index=True) created_date = Column(DateTime, default=datetime.utcnow, nullable=False) last_login = Column(DateTime, nullable=True) __table_args__ = ( Index("idx_users_username", "username"), Index("idx_users_email", "email"), Index("idx_users_is_admin", "is_admin"), Index("idx_users_password_change_required", "password_change_required"), Index("idx_users_role", "role"), ) class PhotoPersonLinkage(Base): """Direct linkage between Video (Photo with media_type='video') and Person. This allows identifying people in videos without requiring face detection. Only used for videos, not photos (photos use Face model for identification). """ __tablename__ = "photo_person_linkage" id = Column(Integer, primary_key=True, autoincrement=True) photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True) person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True) identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) created_date = Column(DateTime, default=datetime.utcnow, nullable=False) photo = relationship("Photo", back_populates="video_people") person = relationship("Person", back_populates="video_photos") __table_args__ = ( UniqueConstraint("photo_id", "person_id", name="uq_photo_person"), Index("idx_photo_person_photo", "photo_id"), Index("idx_photo_person_person", "person_id"), Index("idx_photo_person_user", "identified_by_user_id"), ) class RolePermission(Base): """Role-to-feature permission matrix.""" __tablename__ = "role_permissions" id = Column(Integer, primary_key=True, autoincrement=True) role = Column(Text, nullable=False, index=True) feature_key = Column(Text, nullable=False, index=True) allowed = Column(Boolean, nullable=False, default=False, server_default="0") __table_args__ = ( UniqueConstraint("role", "feature_key", name="uq_role_feature"), Index("idx_role_permissions_role_feature", "role", "feature_key"), )