177 lines
8.0 KiB
Python
177 lines
8.0 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)
|
|
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)
|
|
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)
|
|
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")
|
|
|
|
|
|
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}"
|