Vibe coded a bit more ... now we have session, attached picture and analysis, MOA group computation

This commit is contained in:
Gérald Colangelo
2026-03-17 17:20:54 +01:00
parent 120dc70cf5
commit 5b18fadb60
55 changed files with 5419 additions and 59 deletions

View File

@@ -0,0 +1,14 @@
from flask import Blueprint
from .auth import auth_bp
from .equipment import equipment_bp
from .sessions import sessions_bp
from .analyses import analyses_bp
from .feed import feed_bp
api = Blueprint("api", __name__, url_prefix="/api/v1")
api.register_blueprint(auth_bp)
api.register_blueprint(equipment_bp)
api.register_blueprint(sessions_bp)
api.register_blueprint(analyses_bp)
api.register_blueprint(feed_bp)

178
blueprints/api/analyses.py Normal file
View File

@@ -0,0 +1,178 @@
import base64
import io
from pathlib import Path
from flask import Blueprint, current_app, request
from flask_jwt_extended import jwt_required
from sqlalchemy import func, select
from extensions import db
from models import Analysis
from .utils import (
created, err, no_content, ok,
current_api_user, serialize_analysis,
)
analyses_bp = Blueprint("api_analyses", __name__, url_prefix="/analyses")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _remove_analysis_files(analysis: Analysis, storage_root: str) -> None:
root = Path(storage_root)
for path_attr in ("csv_path", "pdf_path"):
rel = getattr(analysis, path_attr, None)
if rel:
try:
root.joinpath(rel).unlink(missing_ok=True)
except Exception:
pass
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@analyses_bp.post("/upload")
@jwt_required(optional=True)
def upload():
from analyzer.parser import parse_csv
from analyzer.grouper import detect_groups
from analyzer.stats import compute_overall_stats, compute_group_stats
from analyzer.charts import render_group_charts, render_overview_chart
from analyzer.pdf_report import generate_pdf
file = request.files.get("csv_file")
if not file or not file.filename:
return err("No csv_file provided.", 400)
try:
csv_bytes = file.read()
df = parse_csv(io.BytesIO(csv_bytes))
groups = detect_groups(df)
overall = compute_overall_stats(df)
group_stats = compute_group_stats(groups)
charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"])
overview_chart = render_overview_chart(group_stats)
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
except ValueError as e:
return err(str(e), 422)
pdf_b64 = base64.b64encode(pdf_bytes).decode()
saved_id = None
user = current_api_user()
if user:
from storage import save_analysis
saved_id = save_analysis(
user=user,
csv_bytes=csv_bytes,
pdf_bytes=pdf_bytes,
overall=overall,
group_stats=group_stats,
filename=file.filename or "upload.csv",
)
return ok({
"overall_stats": overall,
"group_stats": group_stats,
"charts": charts,
"overview_chart": overview_chart,
"pdf_b64": pdf_b64,
"saved_id": saved_id,
})
@analyses_bp.get("/")
@jwt_required()
def list_analyses():
user = current_api_user()
if not user:
return err("User not found.", 404)
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
except (TypeError, ValueError):
page, per_page = 1, 20
total = db.session.scalar(
select(func.count()).select_from(Analysis)
.where(Analysis.user_id == user.id)
) or 0
analyses = db.session.scalars(
select(Analysis)
.where(Analysis.user_id == user.id)
.order_by(Analysis.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
).all()
return ok({
"data": [serialize_analysis(a) for a in analyses],
"total": total,
"page": page,
"per_page": per_page,
})
@analyses_bp.get("/<int:analysis_id>")
@jwt_required(optional=True)
def get_analysis(analysis_id: int):
a = db.session.get(Analysis, analysis_id)
if not a:
return err("Analysis not found.", 404)
user = current_api_user()
is_owner = user and a.user_id == user.id
if not a.is_public and not is_owner:
return err("Access denied.", 403)
return ok(serialize_analysis(a))
@analyses_bp.delete("/<int:analysis_id>")
@jwt_required()
def delete_analysis(analysis_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
a = db.session.get(Analysis, analysis_id)
if not a:
return err("Analysis not found.", 404)
if a.user_id != user.id:
return err("Access denied.", 403)
storage_root = current_app.config["STORAGE_ROOT"]
_remove_analysis_files(a, storage_root)
db.session.delete(a)
db.session.commit()
return no_content()
@analyses_bp.patch("/<int:analysis_id>/visibility")
@jwt_required()
def toggle_visibility(analysis_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
a = db.session.get(Analysis, analysis_id)
if not a:
return err("Analysis not found.", 404)
if a.user_id != user.id:
return err("Access denied.", 403)
body = request.get_json(silent=True) or {}
if "is_public" not in body:
return err("is_public field is required.", 400)
a.is_public = bool(body["is_public"])
db.session.commit()
return ok(serialize_analysis(a))

83
blueprints/api/auth.py Normal file
View File

@@ -0,0 +1,83 @@
from flask import Blueprint, request
from flask_jwt_extended import create_access_token, jwt_required
from extensions import db
from models import User
from .utils import created, err, ok, current_api_user, serialize_user
auth_bp = Blueprint("api_auth", __name__, url_prefix="/auth")
@auth_bp.post("/register")
def register():
body = request.get_json(silent=True) or {}
email = (body.get("email") or "").strip().lower()
password = body.get("password") or ""
display_name = (body.get("display_name") or "").strip() or None
# Validation
if not email or "@" not in email or "." not in email.split("@")[-1]:
return err("A valid email address is required.", 400)
if len(password) < 8:
return err("Password must be at least 8 characters.", 400)
# Uniqueness check
existing = db.session.scalar(
db.select(User).where(User.email == email)
)
if existing:
return err("Email already registered.", 409)
u = User(
email=email,
display_name=display_name,
provider="local",
provider_id=email,
email_confirmed=True,
)
u.set_password(password)
db.session.add(u)
db.session.commit()
token = create_access_token(identity=str(u.id))
return created({"user": serialize_user(u), "access_token": token})
@auth_bp.post("/login")
def login():
body = request.get_json(silent=True) or {}
email = (body.get("email") or "").strip().lower()
password = body.get("password") or ""
u = db.session.scalar(
db.select(User).where(User.email == email, User.provider == "local")
)
if not u or not u.check_password(password):
return err("Invalid email or password.", 401)
token = create_access_token(identity=str(u.id))
return ok({"user": serialize_user(u), "access_token": token})
@auth_bp.get("/me")
@jwt_required()
def me():
u = current_api_user()
if not u:
return err("User not found.", 404)
return ok(serialize_user(u))
@auth_bp.patch("/me")
@jwt_required()
def update_me():
u = current_api_user()
if not u:
return err("User not found.", 404)
body = request.get_json(silent=True) or {}
if "display_name" in body:
u.display_name = (body["display_name"] or "").strip() or None
db.session.commit()
return ok(serialize_user(u))

222
blueprints/api/equipment.py Normal file
View File

@@ -0,0 +1,222 @@
from pathlib import Path
from flask import Blueprint, current_app, request
from flask_jwt_extended import jwt_required
from sqlalchemy import select
from extensions import db
from models import EquipmentItem
from storage import save_equipment_photo
from .utils import (
created, err, no_content, ok,
current_api_user, serialize_equipment,
)
equipment_bp = Blueprint("api_equipment", __name__, url_prefix="/equipment")
CATEGORY_KEYS = ["rifle", "handgun", "scope", "other"]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read_fields(category: str) -> dict:
"""Read fields from JSON body or multipart form data."""
if request.is_json:
body = request.get_json(silent=True) or {}
get = lambda key, default="": body.get(key, default)
else:
get = lambda key, default="": request.form.get(key, default)
fields: dict = {}
for key in ("name", "brand", "model", "serial_number", "notes"):
val = (get(key) or "").strip()
fields[key] = val or None
# name is required — caller checks non-None
fields["name"] = (get("name") or "").strip()
if category == "scope":
fields["magnification"] = (get("magnification") or "").strip() or None
fields["reticle"] = (get("reticle") or "").strip() or None
fields["unit"] = (get("unit") or "").strip() or None
else:
fields["caliber"] = (get("caliber") or "").strip() or None
return fields
def _apply_fields(item: EquipmentItem, fields: dict) -> None:
for key, val in fields.items():
setattr(item, key, val)
def _remove_photo(photo_path: str, storage_root: str) -> None:
try:
Path(storage_root).joinpath(photo_path).unlink(missing_ok=True)
except Exception:
pass
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@equipment_bp.get("/")
@jwt_required()
def list_equipment():
user = current_api_user()
if not user:
return err("User not found.", 404)
items = db.session.scalars(
select(EquipmentItem)
.where(EquipmentItem.user_id == user.id)
.order_by(EquipmentItem.category, EquipmentItem.name)
).all()
return ok([serialize_equipment(i) for i in items])
@equipment_bp.post("/")
@jwt_required()
def create_equipment():
user = current_api_user()
if not user:
return err("User not found.", 404)
# Category can come from JSON or form
if request.is_json:
body = request.get_json(silent=True) or {}
category = (body.get("category") or "").strip()
else:
category = (request.form.get("category") or "").strip()
if not category:
return err("category is required.", 400)
if category not in CATEGORY_KEYS:
return err(f"category must be one of: {', '.join(CATEGORY_KEYS)}.", 400)
fields = _read_fields(category)
if not fields.get("name"):
return err("name is required.", 400)
item = EquipmentItem(user_id=user.id, category=category)
_apply_fields(item, fields)
db.session.add(item)
db.session.flush() # get item.id before photo upload
photo = request.files.get("photo")
if photo and photo.filename:
try:
item.photo_path = save_equipment_photo(user.id, item.id, photo)
except ValueError as e:
db.session.rollback()
return err(str(e), 422)
db.session.commit()
return created(serialize_equipment(item))
@equipment_bp.get("/<int:item_id>")
@jwt_required()
def get_equipment(item_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
item = db.session.get(EquipmentItem, item_id)
if not item:
return err("Equipment item not found.", 404)
if item.user_id != user.id:
return err("Access denied.", 403)
return ok(serialize_equipment(item))
@equipment_bp.patch("/<int:item_id>")
@jwt_required()
def update_equipment(item_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
item = db.session.get(EquipmentItem, item_id)
if not item:
return err("Equipment item not found.", 404)
if item.user_id != user.id:
return err("Access denied.", 403)
# Determine category (may be updated or use existing)
if request.is_json:
body = request.get_json(silent=True) or {}
new_category = (body.get("category") or "").strip() or None
else:
new_category = (request.form.get("category") or "").strip() or None
if new_category:
if new_category not in CATEGORY_KEYS:
return err(f"category must be one of: {', '.join(CATEGORY_KEYS)}.", 400)
item.category = new_category
category = item.category
# Only update fields present in the request
if request.is_json:
body = request.get_json(silent=True) or {}
get = lambda key: body.get(key)
has = lambda key: key in body
else:
get = lambda key: request.form.get(key)
has = lambda key: key in request.form
for key in ("name", "brand", "model", "serial_number", "notes"):
if has(key):
val = (get(key) or "").strip() or None
if key == "name" and not val:
return err("name cannot be empty.", 400)
setattr(item, key, val)
if category == "scope":
for key in ("magnification", "reticle", "unit"):
if has(key):
setattr(item, key, (get(key) or "").strip() or None)
else:
if has("caliber"):
item.caliber = (get("caliber") or "").strip() or None
# Handle photo upload
photo = request.files.get("photo")
if photo and photo.filename:
try:
old_path = item.photo_path
item.photo_path = save_equipment_photo(user.id, item.id, photo)
if old_path:
storage_root = current_app.config["STORAGE_ROOT"]
_remove_photo(old_path, storage_root)
except ValueError as e:
return err(str(e), 422)
db.session.commit()
return ok(serialize_equipment(item))
@equipment_bp.delete("/<int:item_id>")
@jwt_required()
def delete_equipment(item_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
item = db.session.get(EquipmentItem, item_id)
if not item:
return err("Equipment item not found.", 404)
if item.user_id != user.id:
return err("Access denied.", 403)
if item.photo_path:
storage_root = current_app.config["STORAGE_ROOT"]
_remove_photo(item.photo_path, storage_root)
db.session.delete(item)
db.session.commit()
return no_content()

37
blueprints/api/feed.py Normal file
View File

@@ -0,0 +1,37 @@
from flask import Blueprint, request
from sqlalchemy import func, select
from extensions import db
from models import ShootingSession
from .utils import ok, serialize_session
feed_bp = Blueprint("api_feed", __name__, url_prefix="/feed")
@feed_bp.get("/")
def feed():
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
except (TypeError, ValueError):
page, per_page = 1, 20
total = db.session.scalar(
select(func.count()).select_from(ShootingSession)
.where(ShootingSession.is_public == True) # noqa: E712
) or 0
sessions = db.session.scalars(
select(ShootingSession)
.where(ShootingSession.is_public == True) # noqa: E712
.order_by(ShootingSession.session_date.desc(), ShootingSession.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
).all()
return ok({
"data": [serialize_session(s, include_user=True) for s in sessions],
"total": total,
"page": page,
"per_page": per_page,
})

327
blueprints/api/sessions.py Normal file
View File

@@ -0,0 +1,327 @@
import io
from datetime import date
from pathlib import Path
from flask import Blueprint, current_app, request
from flask_jwt_extended import jwt_required
from sqlalchemy import func, select
from extensions import db
from models import SessionPhoto, ShootingSession
from .utils import (
created, err, no_content, ok,
current_api_user, serialize_analysis, serialize_session, serialize_session_photo,
)
sessions_bp = Blueprint("api_sessions", __name__, url_prefix="/sessions")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _int_or_none(v):
try:
result = int(v)
return result if result > 0 else None
except (TypeError, ValueError):
return None
def _float_or_none(v):
try:
return float(v) if v is not None and str(v).strip() else None
except (TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@sessions_bp.get("/")
@jwt_required()
def list_sessions():
user = current_api_user()
if not user:
return err("User not found.", 404)
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
except (TypeError, ValueError):
page, per_page = 1, 20
total = db.session.scalar(
select(func.count()).select_from(ShootingSession)
.where(ShootingSession.user_id == user.id)
) or 0
sessions = db.session.scalars(
select(ShootingSession)
.where(ShootingSession.user_id == user.id)
.order_by(ShootingSession.session_date.desc(), ShootingSession.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
).all()
return ok({
"data": [serialize_session(s) for s in sessions],
"total": total,
"page": page,
"per_page": per_page,
})
@sessions_bp.post("/")
@jwt_required()
def create_session():
user = current_api_user()
if not user:
return err("User not found.", 404)
body = request.get_json(silent=True) or {}
date_str = (body.get("session_date") or "").strip()
if not date_str:
return err("session_date is required.", 400)
try:
session_date = date.fromisoformat(date_str)
except ValueError:
return err("session_date must be a valid ISO date string (YYYY-MM-DD).", 400)
s = ShootingSession(user_id=user.id, session_date=session_date)
s.is_public = bool(body.get("is_public", False))
s.location_name = (body.get("location_name") or "").strip() or None
s.location_lat = _float_or_none(body.get("location_lat"))
s.location_lon = _float_or_none(body.get("location_lon"))
s.distance_m = _int_or_none(body.get("distance_m"))
s.weather_cond = (body.get("weather_cond") or "").strip() or None
s.weather_temp_c = _float_or_none(body.get("weather_temp_c"))
s.weather_wind_kph = _float_or_none(body.get("weather_wind_kph"))
s.rifle_id = _int_or_none(body.get("rifle_id"))
s.scope_id = _int_or_none(body.get("scope_id"))
s.ammo_brand = (body.get("ammo_brand") or "").strip() or None
s.ammo_weight_gr = _float_or_none(body.get("ammo_weight_gr"))
s.ammo_lot = (body.get("ammo_lot") or "").strip() or None
s.notes = (body.get("notes") or "").strip() or None
db.session.add(s)
db.session.commit()
return created(serialize_session(s))
@sessions_bp.get("/<int:session_id>")
@jwt_required(optional=True)
def get_session(session_id: int):
s = db.session.get(ShootingSession, session_id)
if not s:
return err("Session not found.", 404)
user = current_api_user()
is_owner = user and s.user_id == user.id
if not s.is_public and not is_owner:
return err("Access denied.", 403)
return ok(serialize_session(s, include_user=True))
@sessions_bp.patch("/<int:session_id>")
@jwt_required()
def update_session(session_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
s = db.session.get(ShootingSession, session_id)
if not s:
return err("Session not found.", 404)
if s.user_id != user.id:
return err("Access denied.", 403)
body = request.get_json(silent=True) or {}
if "session_date" in body:
try:
s.session_date = date.fromisoformat(body["session_date"])
except (ValueError, TypeError):
return err("session_date must be a valid ISO date string (YYYY-MM-DD).", 400)
if "is_public" in body:
s.is_public = bool(body["is_public"])
for analysis in s.analyses:
analysis.is_public = s.is_public
if "location_name" in body:
s.location_name = (body["location_name"] or "").strip() or None
if "location_lat" in body:
s.location_lat = _float_or_none(body["location_lat"])
if "location_lon" in body:
s.location_lon = _float_or_none(body["location_lon"])
if "distance_m" in body:
s.distance_m = _int_or_none(body["distance_m"])
if "weather_cond" in body:
s.weather_cond = (body["weather_cond"] or "").strip() or None
if "weather_temp_c" in body:
s.weather_temp_c = _float_or_none(body["weather_temp_c"])
if "weather_wind_kph" in body:
s.weather_wind_kph = _float_or_none(body["weather_wind_kph"])
if "rifle_id" in body:
s.rifle_id = _int_or_none(body["rifle_id"])
if "scope_id" in body:
s.scope_id = _int_or_none(body["scope_id"])
if "ammo_brand" in body:
s.ammo_brand = (body["ammo_brand"] or "").strip() or None
if "ammo_weight_gr" in body:
s.ammo_weight_gr = _float_or_none(body["ammo_weight_gr"])
if "ammo_lot" in body:
s.ammo_lot = (body["ammo_lot"] or "").strip() or None
if "notes" in body:
s.notes = (body["notes"] or "").strip() or None
db.session.commit()
return ok(serialize_session(s))
@sessions_bp.delete("/<int:session_id>")
@jwt_required()
def delete_session(session_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
s = db.session.get(ShootingSession, session_id)
if not s:
return err("Session not found.", 404)
if s.user_id != user.id:
return err("Access denied.", 403)
storage_root = current_app.config["STORAGE_ROOT"]
for photo in s.photos:
try:
(Path(storage_root) / photo.photo_path).unlink(missing_ok=True)
except Exception:
pass
db.session.delete(s)
db.session.commit()
return no_content()
# ---------------------------------------------------------------------------
# Photos
# ---------------------------------------------------------------------------
@sessions_bp.post("/<int:session_id>/photos")
@jwt_required()
def upload_photo(session_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
s = db.session.get(ShootingSession, session_id)
if not s:
return err("Session not found.", 404)
if s.user_id != user.id:
return err("Access denied.", 403)
photo_file = request.files.get("photo")
if not photo_file or not photo_file.filename:
return err("No photo file provided.", 400)
from storage import save_session_photo
try:
photo_path = save_session_photo(user.id, session_id, photo_file)
except ValueError as e:
return err(str(e), 422)
caption = (request.form.get("caption") or "").strip() or None
photo = SessionPhoto(session_id=session_id, photo_path=photo_path, caption=caption)
db.session.add(photo)
db.session.commit()
return created(serialize_session_photo(photo))
@sessions_bp.delete("/<int:session_id>/photos/<int:photo_id>")
@jwt_required()
def delete_photo(session_id: int, photo_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
s = db.session.get(ShootingSession, session_id)
if not s:
return err("Session not found.", 404)
if s.user_id != user.id:
return err("Access denied.", 403)
photo = db.session.get(SessionPhoto, photo_id)
if not photo or photo.session_id != session_id:
return err("Photo not found.", 404)
storage_root = current_app.config["STORAGE_ROOT"]
try:
(Path(storage_root) / photo.photo_path).unlink(missing_ok=True)
except Exception:
pass
db.session.delete(photo)
db.session.commit()
return no_content()
# ---------------------------------------------------------------------------
# CSV upload
# ---------------------------------------------------------------------------
@sessions_bp.post("/<int:session_id>/csv")
@jwt_required()
def upload_csv(session_id: int):
user = current_api_user()
if not user:
return err("User not found.", 404)
s = db.session.get(ShootingSession, session_id)
if not s:
return err("Session not found.", 404)
if s.user_id != user.id:
return err("Access denied.", 403)
csv_file = request.files.get("csv_file")
if not csv_file or not csv_file.filename:
return err("No csv_file provided.", 400)
from analyzer.parser import parse_csv
from analyzer.grouper import detect_groups
from analyzer.stats import compute_overall_stats, compute_group_stats
from analyzer.charts import render_group_charts, render_overview_chart
from analyzer.pdf_report import generate_pdf
from storage import save_analysis
try:
csv_bytes = csv_file.read()
df = parse_csv(io.BytesIO(csv_bytes))
groups = detect_groups(df)
overall = compute_overall_stats(df)
group_stats = compute_group_stats(groups)
charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"])
overview_chart = render_overview_chart(group_stats)
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
except ValueError as e:
return err(str(e), 422)
analysis_id = save_analysis(
user=user,
csv_bytes=csv_bytes,
pdf_bytes=pdf_bytes,
overall=overall,
group_stats=group_stats,
filename=csv_file.filename or "upload.csv",
session_id=session_id,
is_public=s.is_public,
)
from models import Analysis
analysis = db.session.get(Analysis, analysis_id)
return created(serialize_analysis(analysis))

79
blueprints/api/utils.py Normal file
View File

@@ -0,0 +1,79 @@
from flask import jsonify
from flask_jwt_extended import get_jwt_identity
from extensions import db
from models import User
def ok(data, status=200):
return jsonify({"data": data}), status
def created(data):
return jsonify({"data": data}), 201
def no_content():
return "", 204
def err(message: str, status: int = 400, code: str | None = None):
_codes = {400: "BAD_REQUEST", 401: "UNAUTHORIZED", 403: "FORBIDDEN",
404: "NOT_FOUND", 409: "CONFLICT", 422: "UNPROCESSABLE"}
return jsonify({"error": {"code": code or _codes.get(status, "ERROR"), "message": message}}), status
def current_api_user() -> User | None:
uid = get_jwt_identity()
return db.session.get(User, int(uid)) if uid else None
# ---------------------------------------------------------------------------
# Serializers
# ---------------------------------------------------------------------------
def serialize_user(u) -> dict:
return {"id": u.id, "email": u.email, "display_name": u.display_name,
"avatar_url": u.avatar_url, "provider": u.provider,
"created_at": u.created_at.isoformat()}
def serialize_equipment(item) -> dict:
base = {"id": item.id, "category": item.category, "name": item.name,
"brand": item.brand, "model": item.model, "serial_number": item.serial_number,
"notes": item.notes, "photo_url": item.photo_url,
"created_at": item.created_at.isoformat(), "updated_at": item.updated_at.isoformat()}
if item.category == "scope":
base.update({"magnification": item.magnification, "reticle": item.reticle, "unit": item.unit})
else:
base["caliber"] = item.caliber
return base
def serialize_session_photo(p) -> dict:
return {"id": p.id, "photo_url": p.photo_url, "caption": p.caption,
"created_at": p.created_at.isoformat()}
def serialize_session(s, include_user: bool = False) -> dict:
d = {"id": s.id, "label": s.label, "session_date": s.session_date.isoformat(),
"is_public": s.is_public, "location_name": s.location_name,
"location_lat": s.location_lat, "location_lon": s.location_lon,
"distance_m": s.distance_m, "weather_cond": s.weather_cond,
"weather_temp_c": float(s.weather_temp_c) if s.weather_temp_c is not None else None,
"weather_wind_kph": float(s.weather_wind_kph) if s.weather_wind_kph is not None else None,
"rifle_id": s.rifle_id, "scope_id": s.scope_id,
"ammo_brand": s.ammo_brand,
"ammo_weight_gr": float(s.ammo_weight_gr) if s.ammo_weight_gr is not None else None,
"ammo_lot": s.ammo_lot, "notes": s.notes,
"photos": [serialize_session_photo(p) for p in s.photos],
"created_at": s.created_at.isoformat(), "updated_at": s.updated_at.isoformat()}
if include_user:
d["user"] = serialize_user(s.user)
return d
def serialize_analysis(a) -> dict:
return {"id": a.id, "title": a.title, "is_public": a.is_public,
"shot_count": a.shot_count, "group_count": a.group_count,
"overall_stats": a.overall_stats, "group_stats": a.group_stats,
"session_id": a.session_id, "created_at": a.created_at.isoformat()}