Vibe coded a bit more ... now we have session, attached picture and analysis, MOA group computation
This commit is contained in:
14
blueprints/api/__init__.py
Normal file
14
blueprints/api/__init__.py
Normal 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
178
blueprints/api/analyses.py
Normal 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
83
blueprints/api/auth.py
Normal 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
222
blueprints/api/equipment.py
Normal 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
37
blueprints/api/feed.py
Normal 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
327
blueprints/api/sessions.py
Normal 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
79
blueprints/api/utils.py
Normal 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()}
|
||||
Reference in New Issue
Block a user