Files
ShooterHub/models.py
Gérald Colangelo 85de9781d7 wip: claude
2026-03-23 18:50:18 +01:00

208 lines
9.5 KiB
Python

from datetime import date, datetime, timezone
from flask_login import UserMixin
from sqlalchemy import (
Boolean,
Date,
DateTime,
Double,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from werkzeug.security import check_password_hash, generate_password_hash
from extensions import db
def _now() -> datetime:
return datetime.now(timezone.utc)
class User(UserMixin, db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
display_name: Mapped[str | None] = mapped_column(String(120))
avatar_url: Mapped[str | None] = mapped_column(Text) # OAuth-provided URL
avatar_path: Mapped[str | None] = mapped_column(Text) # locally uploaded file
provider: Mapped[str] = mapped_column(String(20), nullable=False) # 'google' | 'github' | 'local'
provider_id: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str | None] = mapped_column(Text)
email_confirmed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
email_confirm_token: Mapped[str | None] = mapped_column(String(128))
show_equipment_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
bio: Mapped[str | None] = mapped_column(Text)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
language: Mapped[str | None] = mapped_column(String(10))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
db.UniqueConstraint("provider", "provider_id"),
Index("ix_users_email_confirm_token", "email_confirm_token"),
)
analyses: Mapped[list["Analysis"]] = relationship(
"Analysis", back_populates="user", cascade="all, delete-orphan"
)
equipment: Mapped[list["EquipmentItem"]] = relationship(
"EquipmentItem", back_populates="user", cascade="all, delete-orphan"
)
sessions: Mapped[list["ShootingSession"]] = relationship(
"ShootingSession", back_populates="user", cascade="all, delete-orphan"
)
@property
def effective_avatar_url(self) -> str | None:
if self.avatar_path:
return f"/auth/avatar/{self.id}"
return self.avatar_url
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return bool(self.password_hash and check_password_hash(self.password_hash, password))
class EquipmentItem(db.Model):
__tablename__ = "equipment_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
# 'rifle' | 'handgun' | 'scope' | 'other'
category: Mapped[str] = mapped_column(String(30), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
brand: Mapped[str | None] = mapped_column(String(120))
model: Mapped[str | None] = mapped_column(String(120))
serial_number: Mapped[str | None] = mapped_column(String(120))
caliber: Mapped[str | None] = mapped_column(String(60))
magnification: Mapped[str | None] = mapped_column(String(50))
reticle: Mapped[str | None] = mapped_column(String(10))
unit: Mapped[str | None] = mapped_column(String(10))
notes: Mapped[str | None] = mapped_column(Text)
photo_path: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
user: Mapped["User"] = relationship("User", back_populates="equipment")
@property
def photo_url(self) -> str | None:
if not self.photo_path:
return None
rel = self.photo_path.removeprefix("equipment_photos/")
return f"/equipment/photos/{rel}"
class ShootingSession(db.Model):
__tablename__ = "shooting_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
session_date: Mapped[date] = mapped_column(Date, nullable=False)
location_name: Mapped[str | None] = mapped_column(String(255))
location_lat: Mapped[float | None] = mapped_column(Double)
location_lon: Mapped[float | None] = mapped_column(Double)
distance_m: Mapped[int | None] = mapped_column(Integer)
weather_temp_c: Mapped[float | None] = mapped_column(Numeric(5, 1))
weather_wind_kph: Mapped[float | None] = mapped_column(Numeric(5, 1))
weather_cond: Mapped[str | None] = mapped_column(String(80))
rifle_id: Mapped[int | None] = mapped_column(ForeignKey("equipment_items.id", ondelete="SET NULL"))
scope_id: Mapped[int | None] = mapped_column(ForeignKey("equipment_items.id", ondelete="SET NULL"))
ammo_brand: Mapped[str | None] = mapped_column(String(120))
ammo_weight_gr: Mapped[float | None] = mapped_column(Numeric(7, 2))
ammo_lot: Mapped[str | None] = mapped_column(String(80))
notes: Mapped[str | None] = mapped_column(Text)
session_type: Mapped[str | None] = mapped_column(String(50))
shooting_position: Mapped[str | None] = mapped_column(String(100))
prs_stages: Mapped[list | None] = mapped_column(db.JSON)
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@property
def label(self) -> str:
date_str = self.session_date.strftime("%d %b %Y")
return f"{date_str}{self.location_name}" if self.location_name else date_str
user: Mapped["User"] = relationship("User", back_populates="sessions")
rifle: Mapped["EquipmentItem | None"] = relationship("EquipmentItem", foreign_keys=[rifle_id])
scope: Mapped["EquipmentItem | None"] = relationship("EquipmentItem", foreign_keys=[scope_id])
analyses: Mapped[list["Analysis"]] = relationship("Analysis", back_populates="session")
photos: Mapped[list["SessionPhoto"]] = relationship(
"SessionPhoto", back_populates="session", cascade="all, delete-orphan",
order_by="SessionPhoto.created_at"
)
class Analysis(db.Model):
__tablename__ = "analyses"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
session_id: Mapped[int | None] = mapped_column(ForeignKey("shooting_sessions.id", ondelete="SET NULL"))
title: Mapped[str] = mapped_column(String(255), nullable=False)
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
csv_path: Mapped[str] = mapped_column(Text, nullable=False)
pdf_path: Mapped[str | None] = mapped_column(Text)
overall_stats: Mapped[dict] = mapped_column(db.JSON, nullable=False)
group_stats: Mapped[list] = mapped_column(db.JSON, nullable=False)
shot_count: Mapped[int] = mapped_column(Integer, nullable=False)
group_count: Mapped[int] = mapped_column(Integer, nullable=False)
grouping_outlier_factor: Mapped[float | None] = mapped_column(Double)
grouping_manual_splits: Mapped[list | None] = mapped_column(db.JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
user: Mapped["User"] = relationship("User", back_populates="analyses")
session: Mapped["ShootingSession | None"] = relationship("ShootingSession", back_populates="analyses")
group_photos: Mapped[list["AnalysisGroupPhoto"]] = relationship(
"AnalysisGroupPhoto", back_populates="analysis", cascade="all, delete-orphan",
order_by="AnalysisGroupPhoto.group_index, AnalysisGroupPhoto.created_at"
)
class SessionPhoto(db.Model):
__tablename__ = "session_photos"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
session_id: Mapped[int] = mapped_column(ForeignKey("shooting_sessions.id"), nullable=False)
photo_path: Mapped[str] = mapped_column(Text, nullable=False)
caption: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
annotations: Mapped[dict | None] = mapped_column(db.JSON)
session: Mapped["ShootingSession"] = relationship("ShootingSession", back_populates="photos")
@property
def photo_url(self) -> str:
rel = self.photo_path.removeprefix("session_photos/")
return f"/sessions/photos/{rel}"
class AnalysisGroupPhoto(db.Model):
__tablename__ = "analysis_group_photos"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
analysis_id: Mapped[int] = mapped_column(ForeignKey("analyses.id"), nullable=False)
group_index: Mapped[int] = mapped_column(Integer, nullable=False)
photo_path: Mapped[str] = mapped_column(Text, nullable=False)
caption: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
annotations: Mapped[dict | None] = mapped_column(db.JSON)
analysis: Mapped["Analysis"] = relationship("Analysis", back_populates="group_photos")
@property
def photo_url(self) -> str:
rel = self.photo_path.removeprefix("analysis_group_photos/")
return f"/analyses/group-photos/{rel}"