Files
rf4-spotter/apps/api/app/models.py
T
ik 0abe1a2bb4
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
feat: lock concurrent moderation decisions
2026-09-13 09:26:18 +07:00

242 lines
11 KiB
Python

from __future__ import annotations
import enum
import uuid
from datetime import datetime, time
from sqlalchemy import JSON, CheckConstraint, DateTime, Enum, ForeignKey, Integer, String, Text, Time, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class SourceType(str, enum.Enum):
official_record = "official_record"
user = "user"
manual_import = "manual_import"
class ModerationStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class BaitKind(str, enum.Enum):
bait = "bait"
lure = "lure"
unknown = "unknown"
class ImportStatus(str, enum.Enum):
running = "running"
success = "success"
partial = "partial"
failed = "failed"
class Fish(Base):
__tablename__ = "fish"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(100), unique=True)
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
trophy_weight_g: Mapped[int | None]
class Waterbody(Base):
__tablename__ = "waterbody"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(100), unique=True)
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
unlock_level: Mapped[int | None]
class Bait(Base):
__tablename__ = "bait"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
class Spot(Base):
__tablename__ = "spot"
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
waterbody_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("waterbody.id"))
x: Mapped[int]
y: Mapped[int]
description: Mapped[str | None] = mapped_column(Text)
waterbody: Mapped[Waterbody] = relationship()
class CatchReport(Base):
__tablename__ = "catch_report"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
fish_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("fish.id"))
spot_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("spot.id"))
waterbody_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("waterbody.id"))
bait_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("bait.id"))
weight_g: Mapped[int]
fishing_method: Mapped[str | None] = mapped_column(String(50))
rig_type: Mapped[str | None] = mapped_column(String(100))
retrieve_method: Mapped[str | None] = mapped_column(String(100))
retrieve_speed: Mapped[int | None]
game_time: Mapped[time | None] = mapped_column(Time)
caught_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
player_name: Mapped[str | None] = mapped_column(String(100))
source_type: Mapped[SourceType] = mapped_column(Enum(SourceType))
source_url: Mapped[str | None] = mapped_column(Text)
source_external_id: Mapped[str | None] = mapped_column(String(64), unique=True)
source_confidence: Mapped[int]
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
screenshot_key: Mapped[str | None] = mapped_column(Text)
screenshot_upload_token_hash: Mapped[str | None] = mapped_column(String(64))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
moderation_version: Mapped[int] = mapped_column(default=0)
fish: Mapped[Fish] = relationship()
spot: Mapped[Spot | None] = relationship()
waterbody: Mapped[Waterbody] = relationship()
bait: Mapped[Bait | None] = relationship()
class OfficialRecordImport(Base):
__tablename__ = "official_record_import"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[ImportStatus] = mapped_column(Enum(ImportStatus))
source_url: Mapped[str] = mapped_column(Text)
rows_seen: Mapped[int] = mapped_column(default=0)
rows_created: Mapped[int] = mapped_column(default=0)
rows_updated: Mapped[int] = mapped_column(default=0)
error_summary: Mapped[str | None] = mapped_column(Text)
response_status: Mapped[int | None]
response_etag: Mapped[str | None] = mapped_column(String(500))
response_last_modified: Mapped[str | None] = mapped_column(String(500))
response_content_type: Mapped[str | None] = mapped_column(String(200))
response_bytes: Mapped[int | None]
not_modified: Mapped[bool] = mapped_column(default=False)
class ModerationEvent(Base):
__tablename__ = "moderation_event"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
previous_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus, name="moderationstatus", create_type=False))
new_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus, name="moderationstatus", create_type=False))
moderator: Mapped[str] = mapped_column(String(100))
reason: Mapped[str | None] = mapped_column(Text)
catch_report: Mapped[CatchReport] = relationship()
class SubmissionAttempt(Base):
__tablename__ = "submission_attempt"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
client_hash: Mapped[str] = mapped_column(String(64), index=True)
idempotency_key: Mapped[str | None] = mapped_column(String(128), unique=True, index=True)
catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), nullable=True)
payload_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
catch_report: Mapped[CatchReport | None] = relationship()
class AdminAuthAttempt(Base):
__tablename__ = "admin_auth_attempt"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
client_hash: Mapped[str] = mapped_column(String(64), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
class DataSource(Base):
__tablename__ = "data_source"
key: Mapped[str] = mapped_column(String(50), primary_key=True)
name: Mapped[str] = mapped_column(String(100))
base_url: Mapped[str] = mapped_column(Text)
default_confidence: Mapped[int]
enabled: Mapped[bool] = mapped_column(default=True)
class CommunityImportRun(Base):
__tablename__ = "community_import_run"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
source_system: Mapped[str] = mapped_column(ForeignKey("data_source.key"), index=True)
source_url: Mapped[str] = mapped_column(Text)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String(30), index=True)
rows_seen: Mapped[int] = mapped_column(default=0)
rows_created: Mapped[int] = mapped_column(default=0)
rows_updated: Mapped[int] = mapped_column(default=0)
error_summary: Mapped[str | None] = mapped_column(Text)
class ExternalObservation(Base):
__tablename__ = "external_observation"
__table_args__ = (UniqueConstraint("source_system", "source_external_id"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
source_system: Mapped[str] = mapped_column(ForeignKey("data_source.key"), index=True)
source_external_id: Mapped[str] = mapped_column(String(200))
source_url: Mapped[str] = mapped_column(Text)
fish_name: Mapped[str] = mapped_column(String(200))
fish_external_id: Mapped[str | None] = mapped_column(String(200))
waterbody_name: Mapped[str] = mapped_column(String(200))
waterbody_external_id: Mapped[str | None] = mapped_column(String(200))
x: Mapped[int | None]
y: Mapped[int | None]
weight_g: Mapped[int | None]
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
status: Mapped[str] = mapped_column(String(30), default="staged", index=True)
payload: Mapped[dict] = mapped_column(JSON)
fish_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("fish.id"))
waterbody_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("waterbody.id"))
catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), unique=True)
review_note: Mapped[str | None] = mapped_column(Text)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
moderation_version: Mapped[int] = mapped_column(default=0)
source: Mapped[DataSource] = relationship()
fish: Mapped[Fish | None] = relationship()
waterbody: Mapped[Waterbody | None] = relationship()
catch_report: Mapped[CatchReport | None] = relationship()
class ExternalEntityAlias(Base):
__tablename__ = "external_entity_alias"
__table_args__ = (
UniqueConstraint("source_system", "entity_type", "external_id"),
CheckConstraint(
"(entity_type = 'fish' AND fish_id IS NOT NULL AND waterbody_id IS NULL) OR "
"(entity_type = 'waterbody' AND waterbody_id IS NOT NULL AND fish_id IS NULL)",
name="ck_external_entity_alias_target",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
source_system: Mapped[str] = mapped_column(ForeignKey("data_source.key"), index=True)
entity_type: Mapped[str] = mapped_column(String(20))
external_id: Mapped[str] = mapped_column(String(200))
external_name: Mapped[str] = mapped_column(String(200))
fish_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("fish.id"))
waterbody_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("waterbody.id"))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
fish: Mapped[Fish | None] = relationship()
waterbody: Mapped[Waterbody | None] = relationship()
class ImportRecordEvent(Base):
"""Track per-record import events for D09 revision history with provenance."""
__tablename__ = "import_record_event"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"), index=True)
import_run_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("official_record_import.id"), index=True)
event_type: Mapped[str] = mapped_column(String(20)) # created/updated/deleted
changes: Mapped[dict | None] = mapped_column(JSON, default=None) # what fields changed
provenance: Mapped[dict | None] = mapped_column(JSON, default=None) # source system, external_id
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
catch_report: Mapped[CatchReport] = relationship()
import_run: Mapped[OfficialRecordImport] = relationship()