import uuid from datetime import datetime from pgvector.sqlalchemy import Vector from sqlalchemy import ( CheckConstraint, DateTime, Float, ForeignKey, Index, PrimaryKeyConstraint, String, Text, ) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from app.config import get_settings from app.db import Base _dim = get_settings().embedding_dim class LostDog(Base): """A registered missing-dog cluster: name + last-seen + 1..N reference photos. Carries a small state machine on top of the cluster: - ``status`` flips from ``looking`` (default) to ``found`` (owner action) or ``archived`` (system, after 6 months of no owner activity). Archived dogs drop out of /search results. - ``last_owner_visit_at`` is bumped on every owner read/write; drives both the "what's new since last visit" UI and the inactivity job. - ``inactivity_email_sent_at`` records the 5-month "are you still looking?" nudge so we don't re-send. - ``archived_at`` is stamped by the inactivity job when the dog flips to ``archived`` after another 30 days of silence post-nudge. """ __tablename__ = "lost_dogs" __table_args__ = ( CheckConstraint( "status IN ('looking', 'found', 'archived')", name="lost_dogs_status_check", ), Index("lost_dogs_status_visit_idx", "status", "last_owner_visit_at"), ) id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) name: Mapped[str | None] = mapped_column(String(80), nullable=True) last_seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) last_seen_lat: Mapped[float] = mapped_column(Float, nullable=False) last_seen_lng: Mapped[float] = mapped_column(Float, nullable=False) contact_name: Mapped[str | None] = mapped_column(String(80), nullable=True) contact_email: Mapped[str | None] = mapped_column(String(200), nullable=True) contact_phone: Mapped[str | None] = mapped_column(String(40), nullable=True) status: Mapped[str] = mapped_column( String(16), nullable=False, server_default="looking", default="looking" ) last_owner_visit_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) inactivity_email_sent_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) archived_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) photos: Mapped[list["Sighting"]] = relationship( back_populates="lost_dog", cascade="all, delete-orphan", ) class Sighting(Base): __tablename__ = "sightings" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) image_url: Mapped[str] = mapped_column(Text, nullable=False) cropped_url: Mapped[str] = mapped_column(Text, nullable=False) embedding: Mapped[list[float]] = mapped_column(Vector(_dim), nullable=False) latitude: Mapped[float] = mapped_column(Float, nullable=False) longitude: Mapped[float] = mapped_column(Float, nullable=False) sighted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) notes: Mapped[str | None] = mapped_column(Text, nullable=True) source: Mapped[str] = mapped_column(String(16), nullable=False, default="user") identity: Mapped[str | None] = mapped_column(String(64), nullable=True) lost_dog_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("lost_dogs.id", ondelete="CASCADE"), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) lost_dog: Mapped[LostDog | None] = relationship(back_populates="photos") class SightingRejection(Base): """Owner said 'not my dog' on a possible spotting. Composite PK on (lost_dog_id, sighting_id) makes inserts idempotent and keeps the LEFT JOIN anti-join filter on possible-sightings cheap. """ __tablename__ = "sighting_rejections" __table_args__ = ( PrimaryKeyConstraint("lost_dog_id", "sighting_id"), Index("sighting_rejections_sighting_idx", "sighting_id"), ) lost_dog_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("lost_dogs.id", ondelete="CASCADE"), nullable=False, ) sighting_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("sightings.id", ondelete="CASCADE"), nullable=False, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False )