From 5b18fadb6029dc4030c332d3762d7e92582ddb55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Colangelo?= Date: Tue, 17 Mar 2026 17:20:54 +0100 Subject: [PATCH] Vibe coded a bit more ... now we have session, attached picture and analysis, MOA group computation --- .env.example | 20 + Dockerfile | 6 +- app.py | 193 ++++-- blueprints/__init__.py | 0 blueprints/analyses.py | 104 +++ blueprints/api/__init__.py | 14 + blueprints/api/analyses.py | 178 +++++ blueprints/api/auth.py | 83 +++ blueprints/api/equipment.py | 222 +++++++ blueprints/api/feed.py | 37 ++ blueprints/api/sessions.py | 327 +++++++++ blueprints/api/utils.py | 79 +++ blueprints/auth.py | 382 +++++++++++ blueprints/dashboard.py | 20 + blueprints/equipment.py | 191 ++++++ blueprints/sessions.py | 343 ++++++++++ config.py | 26 + docker-compose.yaml | 30 + entrypoint.sh | 8 + extensions.py | 14 + migrations/README | 1 + migrations/alembic.ini | 50 ++ migrations/env.py | 113 ++++ migrations/script.py.mako | 24 + migrations/versions/03057ef71b9c_user_bio.py | 32 + .../1bc445c89261_drop_session_title.py | 32 + .../versions/1ec8afb14573_initial_schema.py | 101 +++ .../2b8adad5972b_local_auth_fields.py | 38 ++ ...52a38793e62e_user_show_equipment_public.py | 32 + .../versions/875675ed7b5a_scope_fields.py | 36 + .../versions/a403e38c1c2e_user_avatar_path.py | 32 + .../versions/b94b21ec5fa9_session_photos.py | 36 + .../d46dc696b3c3_session_photo_annotations.py | 32 + .../eb04fe02f528_session_is_public.py | 32 + models.py | 176 +++++ requirements.txt | 10 + storage.py | 156 +++++ templates/analyses/detail.html | 93 +++ templates/auth/confirm_pending.html | 22 + templates/auth/login.html | 62 ++ templates/auth/profile.html | 107 +++ templates/auth/public_profile.html | 80 +++ templates/auth/register.html | 33 + templates/base.html | 345 +++++++++- templates/dashboard/index.html | 67 ++ templates/equipment/detail.html | 72 ++ templates/equipment/form.html | 130 ++++ templates/equipment/list.html | 62 ++ templates/index.html | 157 +++++ templates/results.html | 8 +- templates/sessions/annotate_photo.html | 619 ++++++++++++++++++ templates/sessions/detail.html | 218 ++++++ templates/sessions/form.html | 143 ++++ templates/sessions/list.html | 48 ++ templates/upload.html | 2 +- 55 files changed, 5419 insertions(+), 59 deletions(-) create mode 100644 .env.example create mode 100644 blueprints/__init__.py create mode 100644 blueprints/analyses.py create mode 100644 blueprints/api/__init__.py create mode 100644 blueprints/api/analyses.py create mode 100644 blueprints/api/auth.py create mode 100644 blueprints/api/equipment.py create mode 100644 blueprints/api/feed.py create mode 100644 blueprints/api/sessions.py create mode 100644 blueprints/api/utils.py create mode 100644 blueprints/auth.py create mode 100644 blueprints/dashboard.py create mode 100644 blueprints/equipment.py create mode 100644 blueprints/sessions.py create mode 100644 config.py create mode 100755 entrypoint.sh create mode 100644 extensions.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/03057ef71b9c_user_bio.py create mode 100644 migrations/versions/1bc445c89261_drop_session_title.py create mode 100644 migrations/versions/1ec8afb14573_initial_schema.py create mode 100644 migrations/versions/2b8adad5972b_local_auth_fields.py create mode 100644 migrations/versions/52a38793e62e_user_show_equipment_public.py create mode 100644 migrations/versions/875675ed7b5a_scope_fields.py create mode 100644 migrations/versions/a403e38c1c2e_user_avatar_path.py create mode 100644 migrations/versions/b94b21ec5fa9_session_photos.py create mode 100644 migrations/versions/d46dc696b3c3_session_photo_annotations.py create mode 100644 migrations/versions/eb04fe02f528_session_is_public.py create mode 100644 models.py create mode 100644 storage.py create mode 100644 templates/analyses/detail.html create mode 100644 templates/auth/confirm_pending.html create mode 100644 templates/auth/login.html create mode 100644 templates/auth/profile.html create mode 100644 templates/auth/public_profile.html create mode 100644 templates/auth/register.html create mode 100644 templates/dashboard/index.html create mode 100644 templates/equipment/detail.html create mode 100644 templates/equipment/form.html create mode 100644 templates/equipment/list.html create mode 100644 templates/index.html create mode 100644 templates/sessions/annotate_photo.html create mode 100644 templates/sessions/detail.html create mode 100644 templates/sessions/form.html create mode 100644 templates/sessions/list.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c6509f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Copy this file to .env and fill in real values. +# .env is gitignored — never commit secrets. + +SECRET_KEY=change-me-to-a-long-random-string + +DB_PASSWORD=change-me-db-password + +# Google OAuth — https://console.developers.google.com/ +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# GitHub OAuth — https://github.com/settings/developers +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Email confirmation for local accounts. +# Set to "true" to require users to click a confirmation link before logging in. +# When "false" (default), accounts are activated immediately. +# The confirmation URL is always printed to Docker logs regardless of this setting. +EMAIL_CONFIRMATION_REQUIRED=false diff --git a/Dockerfile b/Dockerfile index 563d941..1725c2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,10 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +RUN chmod +x entrypoint.sh + +ENV FLASK_APP=app + EXPOSE 5000 -CMD ["python", "-m", "gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"] +ENTRYPOINT ["./entrypoint.sh"] diff --git a/app.py b/app.py index 343b472..9e6017a 100644 --- a/app.py +++ b/app.py @@ -1,55 +1,166 @@ import base64 +import io from flask import Flask, request, render_template +from flask_login import current_user +from sqlalchemy import select -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 - -app = Flask(__name__) -app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 +from config import Config +from extensions import db, jwt, login_manager, migrate, oauth -@app.route("/") -def index(): - return render_template("upload.html") +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + jwt.init_app(app) -@app.route("/analyze", methods=["POST"]) -def analyze(): - if "csv_file" not in request.files or request.files["csv_file"].filename == "": - return render_template("upload.html", error="No file selected.") + @jwt.unauthorized_loader + def unauthorized_callback(reason): + from flask import jsonify + return jsonify({"error": {"code": "UNAUTHORIZED", "message": reason}}), 401 - file = request.files["csv_file"] + @jwt.expired_token_loader + def expired_callback(jwt_header, jwt_payload): + from flask import jsonify + return jsonify({"error": {"code": "TOKEN_EXPIRED", "message": "Token has expired"}}), 401 - try: - df = parse_csv(file.stream) - 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) - except ValueError as e: - return render_template("upload.html", error=str(e)) + @jwt.invalid_token_loader + def invalid_callback(reason): + from flask import jsonify + return jsonify({"error": {"code": "INVALID_TOKEN", "message": reason}}), 422 - pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart) - pdf_b64 = base64.b64encode(pdf_bytes).decode("utf-8") - - groups_display = list(zip(group_stats, charts)) - return render_template( - "results.html", - overall=overall, - groups_display=groups_display, - overview_chart=overview_chart, - pdf_b64=pdf_b64, + oauth.init_app(app) + oauth.register( + name="google", + client_id=app.config["GOOGLE_CLIENT_ID"], + client_secret=app.config["GOOGLE_CLIENT_SECRET"], + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, + ) + oauth.register( + name="github", + client_id=app.config["GITHUB_CLIENT_ID"], + client_secret=app.config["GITHUB_CLIENT_SECRET"], + access_token_url="https://github.com/login/oauth/access_token", + authorize_url="https://github.com/login/oauth/authorize", + api_base_url="https://api.github.com/", + client_kwargs={"scope": "user:email"}, ) + # Must import models after db is initialised so Alembic can detect them + from models import User # noqa: F401 -if __name__ == "__main__": - app.run(debug=True) + @login_manager.user_loader + def load_user(user_id): + return db.session.get(User, int(user_id)) + + from blueprints.auth import auth_bp + from blueprints.dashboard import dashboard_bp + from blueprints.equipment import equipment_bp + from blueprints.sessions import sessions_bp + from blueprints.analyses import analyses_bp + app.register_blueprint(auth_bp) + app.register_blueprint(dashboard_bp) + app.register_blueprint(equipment_bp) + app.register_blueprint(sessions_bp) + app.register_blueprint(analyses_bp) + + from blueprints.api import api as api_bp + app.register_blueprint(api_bp) + + @app.route("/u/") + def public_profile(user_id: int): + from models import User, ShootingSession, EquipmentItem + from flask import abort, render_template + user = db.session.get(User, user_id) + if user is None: + abort(404) + public_sessions = db.session.scalars( + db.select(ShootingSession) + .filter_by(user_id=user.id, is_public=True) + .order_by(ShootingSession.session_date.desc()) + ).all() + equipment = None + if user.show_equipment_public: + equipment = db.session.scalars( + db.select(EquipmentItem) + .filter_by(user_id=user.id) + .order_by(EquipmentItem.category, EquipmentItem.name) + ).all() + return render_template("auth/public_profile.html", + profile_user=user, + public_sessions=public_sessions, + equipment=equipment) + + @app.route("/") + def index(): + from models import ShootingSession + public_sessions = db.session.scalars( + select(ShootingSession) + .where(ShootingSession.is_public == True) # noqa: E712 + .order_by(ShootingSession.session_date.desc(), ShootingSession.created_at.desc()) + .limit(12) + ).all() + return render_template("index.html", public_sessions=public_sessions) + + @app.route("/analyze", methods=["GET", "POST"]) + def analyze(): + 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 + + if request.method == "GET": + return render_template("upload.html") + + if "csv_file" not in request.files or request.files["csv_file"].filename == "": + return render_template("upload.html", error="No file selected.") + + file = request.files["csv_file"] + + 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) + except ValueError as e: + return render_template("upload.html", error=str(e)) + + pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart) + pdf_b64 = base64.b64encode(pdf_bytes).decode("utf-8") + + saved_analysis_id = None + if current_user.is_authenticated: + from storage import save_analysis + saved_analysis_id = save_analysis( + user=current_user, + csv_bytes=csv_bytes, + pdf_bytes=pdf_bytes, + overall=overall, + group_stats=group_stats, + filename=file.filename or "upload.csv", + ) + + groups_display = list(zip(group_stats, charts)) + return render_template( + "results.html", + overall=overall, + groups_display=groups_display, + overview_chart=overview_chart, + pdf_b64=pdf_b64, + saved_analysis_id=saved_analysis_id, + ) + + return app diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blueprints/analyses.py b/blueprints/analyses.py new file mode 100644 index 0000000..9c0d89b --- /dev/null +++ b/blueprints/analyses.py @@ -0,0 +1,104 @@ +import io +from pathlib import Path + +from flask import ( + Blueprint, abort, current_app, flash, redirect, + render_template, send_from_directory, url_for, +) +from flask_login import current_user, login_required + +from extensions import db +from models import Analysis + +analyses_bp = Blueprint("analyses", __name__, url_prefix="/analyses") + + +def _can_view(analysis: Analysis) -> bool: + if analysis.is_public: + return True + return current_user.is_authenticated and analysis.user_id == current_user.id + + +@analyses_bp.route("/") +def detail(analysis_id: int): + a = db.session.get(Analysis, analysis_id) + if a is None: + abort(404) + if not _can_view(a): + abort(403) + + # Re-generate charts from the stored CSV + storage_root = current_app.config["STORAGE_ROOT"] + csv_path = Path(storage_root) / a.csv_path + if not csv_path.exists(): + abort(410) # CSV was deleted + + from analyzer.parser import parse_csv + from analyzer.grouper import detect_groups + from analyzer.stats import compute_group_stats + from analyzer.charts import render_group_charts, render_overview_chart + + csv_bytes = csv_path.read_bytes() + df = parse_csv(io.BytesIO(csv_bytes)) + groups = detect_groups(df) + group_stats = compute_group_stats(groups) + charts = render_group_charts( + groups, + y_min=a.overall_stats["min_speed"], + y_max=a.overall_stats["max_speed"], + ) + overview_chart = render_overview_chart(group_stats) + + groups_display = list(zip(group_stats, charts)) + return render_template( + "analyses/detail.html", + analysis=a, + overall=a.overall_stats, + groups_display=groups_display, + overview_chart=overview_chart, + has_pdf=bool(a.pdf_path and (Path(storage_root) / a.pdf_path).exists()), + ) + + +@analyses_bp.route("//delete", methods=["POST"]) +@login_required +def delete(analysis_id: int): + a = db.session.get(Analysis, analysis_id) + if a is None: + abort(404) + if a.user_id != current_user.id: + abort(403) + + back = url_for("sessions.detail", session_id=a.session_id) if a.session_id \ + else url_for("dashboard.index") + + storage_root = current_app.config["STORAGE_ROOT"] + for path_attr in ("csv_path", "pdf_path"): + rel = getattr(a, path_attr, None) + if rel: + try: + (Path(storage_root) / rel).unlink(missing_ok=True) + except Exception: + pass + + db.session.delete(a) + db.session.commit() + flash("Analysis deleted.", "success") + return redirect(back) + + +@analyses_bp.route("//pdf") +def download_pdf(analysis_id: int): + a = db.session.get(Analysis, analysis_id) + if a is None: + abort(404) + if not _can_view(a): + abort(403) + if not a.pdf_path: + abort(404) + + storage_root = current_app.config["STORAGE_ROOT"] + pdf_dir = Path(storage_root) / Path(a.pdf_path).parent + filename = Path(a.pdf_path).name + return send_from_directory(pdf_dir, filename, as_attachment=True, + download_name=f"{a.title}.pdf") diff --git a/blueprints/api/__init__.py b/blueprints/api/__init__.py new file mode 100644 index 0000000..dfde527 --- /dev/null +++ b/blueprints/api/__init__.py @@ -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) diff --git a/blueprints/api/analyses.py b/blueprints/api/analyses.py new file mode 100644 index 0000000..742b940 --- /dev/null +++ b/blueprints/api/analyses.py @@ -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("/") +@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("/") +@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("//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)) diff --git a/blueprints/api/auth.py b/blueprints/api/auth.py new file mode 100644 index 0000000..97b110c --- /dev/null +++ b/blueprints/api/auth.py @@ -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)) diff --git a/blueprints/api/equipment.py b/blueprints/api/equipment.py new file mode 100644 index 0000000..30ec9a6 --- /dev/null +++ b/blueprints/api/equipment.py @@ -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("/") +@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("/") +@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("/") +@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() diff --git a/blueprints/api/feed.py b/blueprints/api/feed.py new file mode 100644 index 0000000..225bf17 --- /dev/null +++ b/blueprints/api/feed.py @@ -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, + }) diff --git a/blueprints/api/sessions.py b/blueprints/api/sessions.py new file mode 100644 index 0000000..900ed1c --- /dev/null +++ b/blueprints/api/sessions.py @@ -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("/") +@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("/") +@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("/") +@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("//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("//photos/") +@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("//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)) diff --git a/blueprints/api/utils.py b/blueprints/api/utils.py new file mode 100644 index 0000000..1945a58 --- /dev/null +++ b/blueprints/api/utils.py @@ -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()} diff --git a/blueprints/auth.py b/blueprints/auth.py new file mode 100644 index 0000000..927ac55 --- /dev/null +++ b/blueprints/auth.py @@ -0,0 +1,382 @@ +import secrets +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlparse + +from flask import ( + Blueprint, + abort, + current_app, + flash, + redirect, + render_template, + request, + send_from_directory, + url_for, +) +from flask_login import current_user, login_required, login_user, logout_user +from sqlalchemy.exc import IntegrityError + +from extensions import db, oauth +from models import User + +auth_bp = Blueprint("auth", __name__, url_prefix="/auth") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _safe_next() -> str: + target = request.args.get("next") or "" + if target and urlparse(target).netloc == "": + return target + return url_for("dashboard.index") + + +def _upsert_oauth_user(*, provider: str, provider_id: str, email: str, + display_name: str | None, avatar_url: str | None) -> User | None: + """Find-or-create a user for an OAuth login. Returns None on email conflict.""" + user = db.session.scalar( + db.select(User).filter_by(provider=provider, provider_id=provider_id) + ) + now = datetime.now(timezone.utc) + if user is None: + user = User( + email=email, + provider=provider, + provider_id=provider_id, + display_name=display_name, + avatar_url=avatar_url, + email_confirmed=True, + created_at=now, + ) + db.session.add(user) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + return None # email already taken by a different provider/local account + else: + user.display_name = display_name or user.display_name + user.avatar_url = avatar_url or user.avatar_url + user.last_login_at = now + db.session.commit() + return user + + +def _dispatch_confirmation(user: User) -> None: + """ + Log the confirmation URL to container logs. + Replace the body of this function with a real mail call when ready. + """ + confirm_url = url_for("auth.confirm_email", token=user.email_confirm_token, _external=True) + current_app.logger.warning( + "EMAIL CONFIRMATION — %s — open this URL to confirm: %s", + user.email, + confirm_url, + ) + + +# --------------------------------------------------------------------------- +# Local login / register +# --------------------------------------------------------------------------- + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("dashboard.index")) + + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + + user = db.session.scalar( + db.select(User).filter_by(email=email, provider="local") + ) + + if user is None or not user.check_password(password): + flash("Invalid email or password.", "error") + return render_template("auth/login.html", prefill_email=email) + + if current_app.config["EMAIL_CONFIRMATION_REQUIRED"] and not user.email_confirmed: + flash("Please confirm your email address before logging in.", "error") + return render_template("auth/login.html", prefill_email=email, + show_resend=True, resend_email=email) + + user.last_login_at = datetime.now(timezone.utc) + db.session.commit() + login_user(user) + return redirect(_safe_next()) + + return render_template("auth/login.html") + + +@auth_bp.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("dashboard.index")) + + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + confirm = request.form.get("confirm_password", "") + + # Validate + error = None + if not email or "@" not in email or "." not in email.split("@")[-1]: + error = "Please enter a valid email address." + elif len(password) < 8: + error = "Password must be at least 8 characters." + elif password != confirm: + error = "Passwords do not match." + else: + existing = db.session.scalar(db.select(User).filter_by(email=email)) + if existing: + if existing.provider == "local": + error = "An account with this email already exists." + else: + error = ( + f"This email is linked to a {existing.provider.title()} account. " + f"Please log in with {existing.provider.title()}." + ) + + if error: + flash(error, "error") + return render_template("auth/register.html", prefill_email=email) + + needs_confirmation = current_app.config["EMAIL_CONFIRMATION_REQUIRED"] + now = datetime.now(timezone.utc) + user = User( + email=email, + provider="local", + provider_id=email, + display_name=email.split("@")[0], + email_confirmed=not needs_confirmation, + email_confirm_token=secrets.token_urlsafe(32) if needs_confirmation else None, + created_at=now, + ) + user.set_password(password) + db.session.add(user) + db.session.commit() + + if needs_confirmation: + _dispatch_confirmation(user) + return render_template("auth/confirm_pending.html", email=email) + + login_user(user) + flash("Account created! Welcome.", "success") + return redirect(url_for("dashboard.index")) + + return render_template("auth/register.html") + + +@auth_bp.route("/confirm/") +def confirm_email(token: str): + user = db.session.scalar( + db.select(User).filter_by(email_confirm_token=token) + ) + if user is None: + flash("Invalid or expired confirmation link.", "error") + return redirect(url_for("auth.login")) + + user.email_confirmed = True + user.email_confirm_token = None + db.session.commit() + login_user(user) + flash("Email confirmed! Welcome.", "success") + return redirect(url_for("dashboard.index")) + + +@auth_bp.route("/resend-confirmation", methods=["POST"]) +def resend_confirmation(): + email = request.form.get("email", "").strip().lower() + user = db.session.scalar( + db.select(User).filter_by(email=email, provider="local") + ) + if user and not user.email_confirmed: + if not user.email_confirm_token: + user.email_confirm_token = secrets.token_urlsafe(32) + db.session.commit() + _dispatch_confirmation(user) + # Vague message to prevent email enumeration + flash("If that account exists and is unconfirmed, a new link has been sent.", "message") + return redirect(url_for("auth.login")) + + +# --------------------------------------------------------------------------- +# OAuth — Google +# --------------------------------------------------------------------------- + +@auth_bp.route("/login/google") +def login_google(): + return oauth.google.authorize_redirect( + url_for("auth.callback_google", _external=True) + ) + + +@auth_bp.route("/callback/google") +def callback_google(): + try: + token = oauth.google.authorize_access_token() + except Exception: + flash("Google login failed. Please try again.", "error") + return redirect(url_for("auth.login")) + + info = token.get("userinfo") or {} + email = info.get("email") + if not email: + flash("Could not retrieve your email from Google.", "error") + return redirect(url_for("auth.login")) + + user = _upsert_oauth_user( + provider="google", + provider_id=info["sub"], + email=email, + display_name=info.get("name"), + avatar_url=info.get("picture"), + ) + if user is None: + flash("This email is already registered with a different login method.", "error") + return redirect(url_for("auth.login")) + + login_user(user) + return redirect(_safe_next()) + + +# --------------------------------------------------------------------------- +# OAuth — GitHub +# --------------------------------------------------------------------------- + +@auth_bp.route("/login/github") +def login_github(): + return oauth.github.authorize_redirect( + url_for("auth.callback_github", _external=True) + ) + + +@auth_bp.route("/callback/github") +def callback_github(): + try: + token = oauth.github.authorize_access_token() + except Exception: + flash("GitHub login failed. Please try again.", "error") + return redirect(url_for("auth.login")) + + resp = oauth.github.get("user", token=token) + info = resp.json() + + email = info.get("email") + if not email: + emails_resp = oauth.github.get("user/emails", token=token) + emails = emails_resp.json() if emails_resp.status_code == 200 else [] + email = next( + (e["email"] for e in emails if e.get("primary") and e.get("verified")), + None, + ) + + if not email: + flash("Could not retrieve a verified email from GitHub.", "error") + return redirect(url_for("auth.login")) + + user = _upsert_oauth_user( + provider="github", + provider_id=str(info["id"]), + email=email, + display_name=info.get("name") or info.get("login"), + avatar_url=info.get("avatar_url"), + ) + if user is None: + flash("This email is already registered with a different login method.", "error") + return redirect(url_for("auth.login")) + + login_user(user) + return redirect(_safe_next()) + + +# --------------------------------------------------------------------------- +# Profile +# --------------------------------------------------------------------------- + +@auth_bp.route("/profile", methods=["GET", "POST"]) +@login_required +def profile(): + if request.method == "POST": + action = request.form.get("action") + + if action == "update_profile": + display_name = request.form.get("display_name", "").strip() + if not display_name: + flash("Display name cannot be empty.", "error") + else: + current_user.display_name = display_name + current_user.bio = request.form.get("bio", "").strip() or None + current_user.show_equipment_public = bool(request.form.get("show_equipment_public")) + avatar_file = request.files.get("avatar") + if avatar_file and avatar_file.filename: + from storage import save_avatar + try: + old_path = current_user.avatar_path + current_user.avatar_path = save_avatar(current_user.id, avatar_file) + if old_path: + _remove_avatar_file(old_path) + except ValueError as e: + flash(str(e), "error") + db.session.rollback() + return render_template("auth/profile.html") + db.session.commit() + flash("Profile updated.", "success") + + elif action == "change_password": + if current_user.provider != "local": + flash("Password change is only available for local accounts.", "error") + else: + current_pw = request.form.get("current_password", "") + new_pw = request.form.get("new_password", "") + confirm_pw = request.form.get("confirm_password", "") + if not current_user.check_password(current_pw): + flash("Current password is incorrect.", "error") + elif len(new_pw) < 8: + flash("New password must be at least 8 characters.", "error") + elif new_pw != confirm_pw: + flash("Passwords do not match.", "error") + else: + current_user.set_password(new_pw) + db.session.commit() + flash("Password changed.", "success") + + return redirect(url_for("auth.profile")) + + return render_template("auth/profile.html") + + +@auth_bp.route("/avatar/") +def avatar(user_id: int): + user = db.session.get(User, user_id) + if not user or not user.avatar_path: + abort(404) + storage_root = current_app.config["STORAGE_ROOT"] + rel = user.avatar_path.removeprefix("avatars/") + return send_from_directory(Path(storage_root) / "avatars", rel) + + +def _remove_avatar_file(avatar_path: str) -> None: + try: + storage_root = current_app.config["STORAGE_ROOT"] + (Path(storage_root) / avatar_path).unlink(missing_ok=True) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Public profile +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Logout +# --------------------------------------------------------------------------- + +@auth_bp.route("/logout", methods=["POST"]) +def logout(): + logout_user() + return redirect(url_for("index")) diff --git a/blueprints/dashboard.py b/blueprints/dashboard.py new file mode 100644 index 0000000..381394c --- /dev/null +++ b/blueprints/dashboard.py @@ -0,0 +1,20 @@ +from flask import Blueprint, render_template +from flask_login import current_user, login_required +from sqlalchemy import select + +from extensions import db +from models import Analysis + +dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/dashboard") + + +@dashboard_bp.route("/") +@login_required +def index(): + analyses = db.session.scalars( + select(Analysis) + .where(Analysis.user_id == current_user.id) + .order_by(Analysis.created_at.desc()) + .limit(50) + ).all() + return render_template("dashboard/index.html", analyses=analyses) diff --git a/blueprints/equipment.py b/blueprints/equipment.py new file mode 100644 index 0000000..b942041 --- /dev/null +++ b/blueprints/equipment.py @@ -0,0 +1,191 @@ +from pathlib import Path + +from flask import ( + abort, current_app, flash, redirect, render_template, + request, send_from_directory, url_for, +) +from flask import Blueprint +from flask_login import current_user, login_required +from sqlalchemy import select + +from extensions import db +from models import EquipmentItem +from storage import rotate_photo, save_equipment_photo + +equipment_bp = Blueprint("equipment", __name__, url_prefix="/equipment") + +CATEGORIES = [ + ("rifle", "Rifle"), + ("handgun", "Handgun"), + ("scope", "Scope"), + ("other", "Other"), +] +CATEGORY_KEYS = [k for k, _ in CATEGORIES] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _own_item(item_id: int) -> EquipmentItem: + item = db.session.get(EquipmentItem, item_id) + if item is None: + abort(404) + if item.user_id != current_user.id: + abort(403) + return item + + +def _apply_form(item: EquipmentItem) -> str | None: + """Write request.form fields onto item. Returns an error string or None.""" + name = request.form.get("name", "").strip() + category = request.form.get("category", "").strip() + if not name: + return "Name is required." + if category not in CATEGORY_KEYS: + return "Invalid category." + item.name = name + item.category = category + item.brand = request.form.get("brand", "").strip() or None + item.model = request.form.get("model", "").strip() or None + item.serial_number = request.form.get("serial_number", "").strip() or None + item.notes = request.form.get("notes", "").strip() or None + if category == "scope": + item.magnification = request.form.get("magnification", "").strip() or None + item.reticle = request.form.get("reticle", "").strip() or None + item.unit = request.form.get("unit", "").strip() or None + item.caliber = None + else: + item.caliber = request.form.get("caliber", "").strip() or None + item.magnification = None + item.reticle = None + item.unit = None + return None + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@equipment_bp.route("/") +@login_required +def index(): + items = db.session.scalars( + select(EquipmentItem) + .where(EquipmentItem.user_id == current_user.id) + .order_by(EquipmentItem.category, EquipmentItem.name) + ).all() + return render_template("equipment/list.html", items=items, categories=CATEGORIES) + + +@equipment_bp.route("/new", methods=["GET", "POST"]) +@login_required +def new(): + if request.method == "POST": + item = EquipmentItem(user_id=current_user.id) + db.session.add(item) + error = _apply_form(item) + if error: + db.session.expunge(item) + flash(error, "error") + return render_template("equipment/form.html", item=None, + categories=CATEGORIES, prefill=request.form) + db.session.flush() + _handle_photo(item, is_new=True) + db.session.commit() + flash(f"'{item.name}' added.", "success") + return redirect(url_for("equipment.detail", item_id=item.id)) + return render_template("equipment/form.html", item=None, categories=CATEGORIES) + + +@equipment_bp.route("/") +@login_required +def detail(item_id: int): + item = _own_item(item_id) + return render_template("equipment/detail.html", item=item, categories=dict(CATEGORIES)) + + +@equipment_bp.route("//edit", methods=["GET", "POST"]) +@login_required +def edit(item_id: int): + item = _own_item(item_id) + if request.method == "POST": + error = _apply_form(item) + if error: + flash(error, "error") + return render_template("equipment/form.html", item=item, + categories=CATEGORIES, prefill=request.form) + _handle_photo(item, is_new=False) + db.session.commit() + flash(f"'{item.name}' updated.", "success") + return redirect(url_for("equipment.detail", item_id=item.id)) + return render_template("equipment/form.html", item=item, categories=CATEGORIES) + + +@equipment_bp.route("//delete", methods=["POST"]) +@login_required +def delete(item_id: int): + item = _own_item(item_id) + name = item.name + if item.photo_path: + _remove_photo_file(item.photo_path) + db.session.delete(item) + db.session.commit() + flash(f"'{name}' deleted.", "success") + return redirect(url_for("equipment.index")) + + +@equipment_bp.route("//photo/rotate", methods=["POST"]) +@login_required +def rotate_photo_view(item_id: int): + item = _own_item(item_id) + if not item.photo_path: + flash("No photo to rotate.", "error") + return redirect(url_for("equipment.detail", item_id=item_id)) + try: + degrees = int(request.form.get("degrees", 0)) + except ValueError: + abort(400) + if degrees not in (-90, 90, 180): + abort(400) + rotate_photo(item.photo_path, degrees) + return redirect(url_for("equipment.detail", item_id=item_id)) + + +@equipment_bp.route("/photos/") +@login_required +def photo(filepath: str): + """Serve equipment photo. Only the owning user may access it.""" + try: + owner_id = int(filepath.split("/")[0]) + except (ValueError, IndexError): + abort(404) + if owner_id != current_user.id: + abort(403) + storage_root = current_app.config["STORAGE_ROOT"] + return send_from_directory(Path(storage_root) / "equipment_photos", filepath) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _handle_photo(item: EquipmentItem, *, is_new: bool) -> None: + photo_file = request.files.get("photo") + if not (photo_file and photo_file.filename): + return + try: + old_path = item.photo_path + item.photo_path = save_equipment_photo(current_user.id, item.id, photo_file) + if old_path: + _remove_photo_file(old_path) + except ValueError as e: + flash(str(e), "error") + + +def _remove_photo_file(photo_path: str) -> None: + try: + storage_root = current_app.config["STORAGE_ROOT"] + (Path(storage_root) / photo_path).unlink(missing_ok=True) + except Exception: + pass diff --git a/blueprints/sessions.py b/blueprints/sessions.py new file mode 100644 index 0000000..80a90b7 --- /dev/null +++ b/blueprints/sessions.py @@ -0,0 +1,343 @@ +import io +from datetime import date +from pathlib import Path + +from flask import ( + Blueprint, abort, current_app, flash, jsonify, redirect, + render_template, request, send_from_directory, url_for, +) +from flask_login import current_user, login_required +from sqlalchemy import select + +from extensions import db +from models import Analysis, EquipmentItem, SessionPhoto, ShootingSession +from storage import rotate_photo, save_session_photo + +sessions_bp = Blueprint("sessions", __name__, url_prefix="/sessions") + +WEATHER_CONDITIONS = [ + ("", "— select —"), + ("sunny", "Sunny"), + ("partly_cloudy", "Partly cloudy"), + ("overcast", "Overcast"), + ("rain", "Rain"), + ("wind", "Wind"), + ("snow", "Snow"), + ("fog", "Fog"), +] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _own_session(session_id: int) -> ShootingSession: + s = db.session.get(ShootingSession, session_id) + if s is None: + abort(404) + if s.user_id != current_user.id: + abort(403) + return s + + +def _user_rifles(): + return db.session.scalars( + select(EquipmentItem) + .where(EquipmentItem.user_id == current_user.id, + EquipmentItem.category.in_(["rifle", "handgun"])) + .order_by(EquipmentItem.name) + ).all() + + +def _user_scopes(): + return db.session.scalars( + select(EquipmentItem) + .where(EquipmentItem.user_id == current_user.id, + EquipmentItem.category == "scope") + .order_by(EquipmentItem.name) + ).all() + + +def _apply_form(s: ShootingSession) -> str | None: + """Write request.form fields onto session. Returns error string or None.""" + date_str = request.form.get("session_date", "").strip() + if not date_str: + return "Date is required." + try: + s.session_date = date.fromisoformat(date_str) + except ValueError: + return "Invalid date." + + s.is_public = bool(request.form.get("is_public")) + s.location_name = request.form.get("location_name", "").strip() or None + s.distance_m = _int_or_none(request.form.get("distance_m")) + s.weather_cond = request.form.get("weather_cond") or None + s.weather_temp_c = _float_or_none(request.form.get("weather_temp_c")) + s.weather_wind_kph = _float_or_none(request.form.get("weather_wind_kph")) + s.rifle_id = _int_or_none(request.form.get("rifle_id")) + s.scope_id = _int_or_none(request.form.get("scope_id")) + s.ammo_brand = request.form.get("ammo_brand", "").strip() or None + s.ammo_weight_gr = _float_or_none(request.form.get("ammo_weight_gr")) + s.ammo_lot = request.form.get("ammo_lot", "").strip() or None + s.notes = request.form.get("notes", "").strip() or None + return None + + +def _int_or_none(val): + try: + v = int(val) + return v if v > 0 else None + except (TypeError, ValueError): + return None + + +def _float_or_none(val): + try: + return float(val) if val and str(val).strip() else None + except (TypeError, ValueError): + return None + + +def _remove_file(rel_path: str) -> None: + try: + storage_root = current_app.config["STORAGE_ROOT"] + (Path(storage_root) / rel_path).unlink(missing_ok=True) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@sessions_bp.route("/") +@login_required +def index(): + sessions = db.session.scalars( + select(ShootingSession) + .where(ShootingSession.user_id == current_user.id) + .order_by(ShootingSession.session_date.desc()) + ).all() + return render_template("sessions/list.html", sessions=sessions) + + +@sessions_bp.route("/new", methods=["GET", "POST"]) +@login_required +def new(): + if request.method == "POST": + s = ShootingSession(user_id=current_user.id) + db.session.add(s) + error = _apply_form(s) + if error: + db.session.expunge(s) + flash(error, "error") + return render_template("sessions/form.html", session=None, + rifles=_user_rifles(), scopes=_user_scopes(), + weather_conditions=WEATHER_CONDITIONS, + prefill=request.form) + db.session.commit() + flash("Session created.", "success") + return redirect(url_for("sessions.detail", session_id=s.id)) + return render_template("sessions/form.html", session=None, + rifles=_user_rifles(), scopes=_user_scopes(), + weather_conditions=WEATHER_CONDITIONS, + today=date.today().isoformat()) + + +@sessions_bp.route("/") +def detail(session_id: int): + s = db.session.get(ShootingSession, session_id) + if s is None: + abort(404) + is_owner = current_user.is_authenticated and s.user_id == current_user.id + if not s.is_public and not is_owner: + abort(403) + analyses = db.session.scalars( + select(Analysis) + .where(Analysis.session_id == session_id) + .order_by(Analysis.created_at) + ).all() + return render_template("sessions/detail.html", session=s, + analyses=analyses, is_owner=is_owner) + + +@sessions_bp.route("//edit", methods=["GET", "POST"]) +@login_required +def edit(session_id: int): + s = _own_session(session_id) + if request.method == "POST": + error = _apply_form(s) + if error: + flash(error, "error") + return render_template("sessions/form.html", session=s, + rifles=_user_rifles(), scopes=_user_scopes(), + weather_conditions=WEATHER_CONDITIONS, + prefill=request.form) + for analysis in s.analyses: + analysis.is_public = s.is_public + db.session.commit() + flash("Session updated.", "success") + return redirect(url_for("sessions.detail", session_id=s.id)) + return render_template("sessions/form.html", session=s, + rifles=_user_rifles(), scopes=_user_scopes(), + weather_conditions=WEATHER_CONDITIONS) + + +@sessions_bp.route("//delete", methods=["POST"]) +@login_required +def delete(session_id: int): + s = _own_session(session_id) + for photo in s.photos: + _remove_file(photo.photo_path) + db.session.delete(s) + db.session.commit() + flash("Session deleted.", "success") + return redirect(url_for("sessions.index")) + + +# --------------------------------------------------------------------------- +# CSV upload +# --------------------------------------------------------------------------- + +@sessions_bp.route("//upload-csv", methods=["POST"]) +@login_required +def upload_csv(session_id: int): + _own_session(session_id) + + csv_file = request.files.get("csv_file") + if not csv_file or not csv_file.filename: + flash("No CSV file selected.", "error") + return redirect(url_for("sessions.detail", session_id=session_id)) + + 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: + flash(str(e), "error") + return redirect(url_for("sessions.detail", session_id=session_id)) + + s = db.session.get(ShootingSession, session_id) + save_analysis( + user=current_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 if s else False, + ) + flash("CSV analysed and linked to this session.", "success") + return redirect(url_for("sessions.detail", session_id=session_id)) + + +# --------------------------------------------------------------------------- +# Photo upload / delete / serve +# --------------------------------------------------------------------------- + +@sessions_bp.route("//upload-photo", methods=["POST"]) +@login_required +def upload_photo(session_id: int): + _own_session(session_id) + + photo_file = request.files.get("photo") + if not photo_file or not photo_file.filename: + flash("No photo selected.", "error") + return redirect(url_for("sessions.detail", session_id=session_id)) + + try: + photo_path = save_session_photo(current_user.id, session_id, photo_file) + except ValueError as e: + flash(str(e), "error") + return redirect(url_for("sessions.detail", session_id=session_id)) + + caption = request.form.get("caption", "").strip() or None + photo = SessionPhoto(session_id=session_id, photo_path=photo_path, caption=caption) + db.session.add(photo) + db.session.commit() + flash("Photo added.", "success") + return redirect(url_for("sessions.detail", session_id=session_id)) + + +@sessions_bp.route("//photos//delete", methods=["POST"]) +@login_required +def delete_photo(session_id: int, photo_id: int): + _own_session(session_id) + photo = db.session.get(SessionPhoto, photo_id) + if photo is None or photo.session_id != session_id: + abort(404) + _remove_file(photo.photo_path) + db.session.delete(photo) + db.session.commit() + flash("Photo deleted.", "success") + return redirect(url_for("sessions.detail", session_id=session_id)) + + +@sessions_bp.route("//photos//rotate", methods=["POST"]) +@login_required +def rotate_photo_view(session_id: int, photo_id: int): + _own_session(session_id) + photo = db.session.get(SessionPhoto, photo_id) + if photo is None or photo.session_id != session_id: + abort(404) + try: + degrees = int(request.form.get("degrees", 0)) + except ValueError: + abort(400) + if degrees not in (-90, 90, 180): + abort(400) + rotate_photo(photo.photo_path, degrees) + return redirect(url_for("sessions.detail", session_id=session_id)) + + +@sessions_bp.route("//photos//annotate", methods=["GET", "POST"]) +@login_required +def annotate_photo(session_id: int, photo_id: int): + _own_session(session_id) + photo = db.session.get(SessionPhoto, photo_id) + if photo is None or photo.session_id != session_id: + abort(404) + + if request.method == "POST": + data = request.get_json(force=True) + photo.annotations = data + db.session.commit() + return jsonify({"ok": True}) + + s = db.session.get(ShootingSession, session_id) + return render_template("sessions/annotate_photo.html", session=s, photo=photo) + + +@sessions_bp.route("/photos/") +def serve_photo(filepath: str): + """Serve a session photo. Private session photos are owner-only.""" + try: + user_id = int(filepath.split("/")[0]) + except (ValueError, IndexError): + abort(404) + + is_owner = current_user.is_authenticated and current_user.id == user_id + if not is_owner: + photo = db.session.scalars( + select(SessionPhoto).where( + SessionPhoto.photo_path == f"session_photos/{filepath}" + ) + ).first() + if photo is None or not photo.session.is_public: + abort(403) + + storage_root = current_app.config["STORAGE_ROOT"] + return send_from_directory(Path(storage_root) / "session_photos", filepath) diff --git a/config.py b/config.py new file mode 100644 index 0000000..8526f29 --- /dev/null +++ b/config.py @@ -0,0 +1,26 @@ +import os +from datetime import timedelta + + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-production") + JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", os.environ.get("SECRET_KEY", "dev-secret")) + JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=24) + SQLALCHEMY_DATABASE_URI = os.environ.get( + "DATABASE_URL", "sqlite:///dev.db" + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 + STORAGE_ROOT = os.environ.get("STORAGE_ROOT", "/app/storage") + + GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") + GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") + GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "") + GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "") + + # Set to "true" in .env to require users to confirm their email before logging in. + # When disabled (default), local accounts are confirmed immediately on registration. + # Confirmation URL is always logged to the container logs for debugging. + EMAIL_CONFIRMATION_REQUIRED: bool = ( + os.environ.get("EMAIL_CONFIRMATION_REQUIRED", "false").lower() == "true" + ) diff --git a/docker-compose.yaml b/docker-compose.yaml index 64d77bf..190a513 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,36 @@ services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ballistic + POSTGRES_USER: ballistic + POSTGRES_PASSWORD: "${DB_PASSWORD}" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ballistic"] + interval: 10s + timeout: 5s + retries: 5 + web: build: . ports: - "5000:5000" restart: unless-stopped + depends_on: + db: + condition: service_healthy + env_file: + - .env + environment: + DATABASE_URL: "postgresql+psycopg://ballistic:${DB_PASSWORD}@db:5432/ballistic" + STORAGE_ROOT: "/app/storage" + volumes: + - app_storage:/app/storage + - .:/app # bind-mount source so code changes are live without a rebuild + +volumes: + postgres_data: + app_storage: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..1d1f2ba --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +mkdir -p /app/storage/csvs /app/storage/pdfs /app/storage/equipment_photos + +flask db upgrade + +exec python -m gunicorn --bind 0.0.0.0:5000 --workers 2 "app:create_app()" diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..b7ce766 --- /dev/null +++ b/extensions.py @@ -0,0 +1,14 @@ +from authlib.integrations.flask_client import OAuth +from flask_jwt_extended import JWTManager +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() +login_manager = LoginManager() +migrate = Migrate() +oauth = OAuth() +jwt = JWTManager() + +login_manager.login_view = "auth.login" +login_manager.login_message = "Please log in to access this page." diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/03057ef71b9c_user_bio.py b/migrations/versions/03057ef71b9c_user_bio.py new file mode 100644 index 0000000..043029d --- /dev/null +++ b/migrations/versions/03057ef71b9c_user_bio.py @@ -0,0 +1,32 @@ +"""user bio + +Revision ID: 03057ef71b9c +Revises: 52a38793e62e +Create Date: 2026-03-17 14:57:43.452741 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '03057ef71b9c' +down_revision = '52a38793e62e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('bio', sa.Text(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('bio') + + # ### end Alembic commands ### diff --git a/migrations/versions/1bc445c89261_drop_session_title.py b/migrations/versions/1bc445c89261_drop_session_title.py new file mode 100644 index 0000000..fa50631 --- /dev/null +++ b/migrations/versions/1bc445c89261_drop_session_title.py @@ -0,0 +1,32 @@ +"""drop session title + +Revision ID: 1bc445c89261 +Revises: a403e38c1c2e +Create Date: 2026-03-17 13:32:53.010390 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1bc445c89261' +down_revision = 'a403e38c1c2e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: + batch_op.drop_column('title') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: + batch_op.add_column(sa.Column('title', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) + + # ### end Alembic commands ### diff --git a/migrations/versions/1ec8afb14573_initial_schema.py b/migrations/versions/1ec8afb14573_initial_schema.py new file mode 100644 index 0000000..5503e1e --- /dev/null +++ b/migrations/versions/1ec8afb14573_initial_schema.py @@ -0,0 +1,101 @@ +"""initial schema + +Revision ID: 1ec8afb14573 +Revises: +Create Date: 2026-03-17 09:30:15.508359 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1ec8afb14573' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('display_name', sa.String(length=120), nullable=True), + sa.Column('avatar_url', sa.Text(), nullable=True), + sa.Column('provider', sa.String(length=20), nullable=False), + sa.Column('provider_id', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('provider', 'provider_id') + ) + op.create_table('equipment_items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('category', sa.String(length=30), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('brand', sa.String(length=120), nullable=True), + sa.Column('model', sa.String(length=120), nullable=True), + sa.Column('serial_number', sa.String(length=120), nullable=True), + sa.Column('caliber', sa.String(length=60), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('photo_path', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('shooting_sessions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('session_date', sa.Date(), nullable=False), + sa.Column('location_name', sa.String(length=255), nullable=True), + sa.Column('location_lat', sa.Double(), nullable=True), + sa.Column('location_lon', sa.Double(), nullable=True), + sa.Column('distance_m', sa.Integer(), nullable=True), + sa.Column('weather_temp_c', sa.Numeric(precision=5, scale=1), nullable=True), + sa.Column('weather_wind_kph', sa.Numeric(precision=5, scale=1), nullable=True), + sa.Column('weather_cond', sa.String(length=80), nullable=True), + sa.Column('rifle_id', sa.Integer(), nullable=True), + sa.Column('scope_id', sa.Integer(), nullable=True), + sa.Column('ammo_brand', sa.String(length=120), nullable=True), + sa.Column('ammo_weight_gr', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('ammo_lot', sa.String(length=80), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['rifle_id'], ['equipment_items.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['scope_id'], ['equipment_items.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('analyses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=True), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('is_public', sa.Boolean(), nullable=False), + sa.Column('csv_path', sa.Text(), nullable=False), + sa.Column('pdf_path', sa.Text(), nullable=True), + sa.Column('overall_stats', sa.JSON(), nullable=False), + sa.Column('group_stats', sa.JSON(), nullable=False), + sa.Column('shot_count', sa.Integer(), nullable=False), + sa.Column('group_count', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['shooting_sessions.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('analyses') + op.drop_table('shooting_sessions') + op.drop_table('equipment_items') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/migrations/versions/2b8adad5972b_local_auth_fields.py b/migrations/versions/2b8adad5972b_local_auth_fields.py new file mode 100644 index 0000000..080f952 --- /dev/null +++ b/migrations/versions/2b8adad5972b_local_auth_fields.py @@ -0,0 +1,38 @@ +"""local auth fields + +Revision ID: 2b8adad5972b +Revises: 1ec8afb14573 +Create Date: 2026-03-17 09:46:47.843894 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2b8adad5972b' +down_revision = '1ec8afb14573' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('password_hash', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('email_confirmed', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('email_confirm_token', sa.String(length=128), nullable=True)) + batch_op.create_index('ix_users_email_confirm_token', ['email_confirm_token'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index('ix_users_email_confirm_token') + batch_op.drop_column('email_confirm_token') + batch_op.drop_column('email_confirmed') + batch_op.drop_column('password_hash') + + # ### end Alembic commands ### diff --git a/migrations/versions/52a38793e62e_user_show_equipment_public.py b/migrations/versions/52a38793e62e_user_show_equipment_public.py new file mode 100644 index 0000000..4effc4e --- /dev/null +++ b/migrations/versions/52a38793e62e_user_show_equipment_public.py @@ -0,0 +1,32 @@ +"""user show equipment public + +Revision ID: 52a38793e62e +Revises: 1bc445c89261 +Create Date: 2026-03-17 14:47:03.751535 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '52a38793e62e' +down_revision = '1bc445c89261' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('show_equipment_public', sa.Boolean(), nullable=False, server_default=sa.false())) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('show_equipment_public') + + # ### end Alembic commands ### diff --git a/migrations/versions/875675ed7b5a_scope_fields.py b/migrations/versions/875675ed7b5a_scope_fields.py new file mode 100644 index 0000000..50e4923 --- /dev/null +++ b/migrations/versions/875675ed7b5a_scope_fields.py @@ -0,0 +1,36 @@ +"""scope fields + +Revision ID: 875675ed7b5a +Revises: eb04fe02f528 +Create Date: 2026-03-17 11:17:08.772131 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '875675ed7b5a' +down_revision = 'eb04fe02f528' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('equipment_items', schema=None) as batch_op: + batch_op.add_column(sa.Column('magnification', sa.String(length=50), nullable=True)) + batch_op.add_column(sa.Column('reticle', sa.String(length=10), nullable=True)) + batch_op.add_column(sa.Column('unit', sa.String(length=10), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('equipment_items', schema=None) as batch_op: + batch_op.drop_column('unit') + batch_op.drop_column('reticle') + batch_op.drop_column('magnification') + + # ### end Alembic commands ### diff --git a/migrations/versions/a403e38c1c2e_user_avatar_path.py b/migrations/versions/a403e38c1c2e_user_avatar_path.py new file mode 100644 index 0000000..ba193c8 --- /dev/null +++ b/migrations/versions/a403e38c1c2e_user_avatar_path.py @@ -0,0 +1,32 @@ +"""user avatar path + +Revision ID: a403e38c1c2e +Revises: b94b21ec5fa9 +Create Date: 2026-03-17 12:50:50.122814 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a403e38c1c2e' +down_revision = 'b94b21ec5fa9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('avatar_path', sa.Text(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('avatar_path') + + # ### end Alembic commands ### diff --git a/migrations/versions/b94b21ec5fa9_session_photos.py b/migrations/versions/b94b21ec5fa9_session_photos.py new file mode 100644 index 0000000..5d0261f --- /dev/null +++ b/migrations/versions/b94b21ec5fa9_session_photos.py @@ -0,0 +1,36 @@ +"""session photos + +Revision ID: b94b21ec5fa9 +Revises: 875675ed7b5a +Create Date: 2026-03-17 11:41:05.860460 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b94b21ec5fa9' +down_revision = '875675ed7b5a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('session_photos', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('photo_path', sa.Text(), nullable=False), + sa.Column('caption', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['shooting_sessions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('session_photos') + # ### end Alembic commands ### diff --git a/migrations/versions/d46dc696b3c3_session_photo_annotations.py b/migrations/versions/d46dc696b3c3_session_photo_annotations.py new file mode 100644 index 0000000..95d90a4 --- /dev/null +++ b/migrations/versions/d46dc696b3c3_session_photo_annotations.py @@ -0,0 +1,32 @@ +"""session_photo annotations + +Revision ID: d46dc696b3c3 +Revises: 03057ef71b9c +Create Date: 2026-03-17 15:35:22.180323 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd46dc696b3c3' +down_revision = '03057ef71b9c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('session_photos', schema=None) as batch_op: + batch_op.add_column(sa.Column('annotations', sa.JSON(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('session_photos', schema=None) as batch_op: + batch_op.drop_column('annotations') + + # ### end Alembic commands ### diff --git a/migrations/versions/eb04fe02f528_session_is_public.py b/migrations/versions/eb04fe02f528_session_is_public.py new file mode 100644 index 0000000..6c29233 --- /dev/null +++ b/migrations/versions/eb04fe02f528_session_is_public.py @@ -0,0 +1,32 @@ +"""session is_public + +Revision ID: eb04fe02f528 +Revises: 2b8adad5972b +Create Date: 2026-03-17 10:13:53.102589 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'eb04fe02f528' +down_revision = '2b8adad5972b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_public', sa.Boolean(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: + batch_op.drop_column('is_public') + + # ### end Alembic commands ### diff --git a/models.py b/models.py new file mode 100644 index 0000000..da5ecd1 --- /dev/null +++ b/models.py @@ -0,0 +1,176 @@ +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}" diff --git a/requirements.txt b/requirements.txt index 659dcff..90458f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,14 @@ Flask>=3.0 +python-dotenv>=1.0 +Flask-SQLAlchemy>=3.1 +Flask-Migrate>=4.0 +Flask-Login>=0.6 +Flask-JWT-Extended>=4.6 +Authlib>=1.3 +httpx>=0.27 +requests>=2.31 +psycopg[binary]>=3.1 +Pillow>=10.0 pandas>=1.5 matplotlib>=3.6 numpy>=1.24 diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..82a85b5 --- /dev/null +++ b/storage.py @@ -0,0 +1,156 @@ +""" +File storage helpers. + +All paths are relative to Config.STORAGE_ROOT (a Docker volume at /app/storage). +Layout: + csvs/{user_id}/{analysis_id}_{filename} + pdfs/{user_id}/{analysis_id}_report.pdf + equipment_photos/{user_id}/{item_id}_{uuid}.jpg + session_photos/{user_id}/{session_id}_{uuid}.jpg +""" + +import io +import uuid +from pathlib import Path + +from flask import current_app +from PIL import Image + + +def _root() -> Path: + return Path(current_app.config["STORAGE_ROOT"]) + + +def _ensure(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + return path + + +# --------------------------------------------------------------------------- +# Analysis files +# --------------------------------------------------------------------------- + +def _to_python(obj): + """Recursively convert numpy scalars/arrays to plain Python types for JSON storage.""" + if isinstance(obj, dict): + return {k: _to_python(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_to_python(v) for v in obj] + if hasattr(obj, "item"): # numpy scalar → int/float + return obj.item() + if hasattr(obj, "tolist"): # numpy array → list + return obj.tolist() + return obj + + +def save_analysis(*, user, csv_bytes: bytes, pdf_bytes: bytes, overall: dict, + group_stats: list, filename: str, session_id: int | None = None, + is_public: bool = False) -> int: + """Persist a completed analysis for a logged-in user. Returns the new Analysis.id.""" + from extensions import db + from models import Analysis + + overall = _to_python(overall) + group_stats = _to_python(group_stats) + shot_count = int(overall.get("count", 0)) + group_count = len(group_stats) + + analysis = Analysis( + user_id=user.id, + session_id=session_id, + is_public=is_public, + title=_default_title(filename), + csv_path="", # filled in below + pdf_path="", + overall_stats=overall, + group_stats=group_stats, + shot_count=shot_count, + group_count=group_count, + ) + db.session.add(analysis) + db.session.flush() # assigns analysis.id without committing + + csv_dir = _ensure(_root() / "csvs" / str(user.id)) + pdf_dir = _ensure(_root() / "pdfs" / str(user.id)) + + safe_name = Path(filename).name.replace(" ", "_") + csv_rel = f"csvs/{user.id}/{analysis.id}_{safe_name}" + pdf_rel = f"pdfs/{user.id}/{analysis.id}_report.pdf" + + (csv_dir / f"{analysis.id}_{safe_name}").write_bytes(csv_bytes) + (pdf_dir / f"{analysis.id}_report.pdf").write_bytes(pdf_bytes) + + analysis.csv_path = csv_rel + analysis.pdf_path = pdf_rel + db.session.commit() + + return analysis.id + + +def _default_title(filename: str) -> str: + stem = Path(filename).stem.replace("_", " ").replace("-", " ") + return stem[:255] if stem else "Analysis" + + +# --------------------------------------------------------------------------- +# Equipment photos +# --------------------------------------------------------------------------- + +MAX_PHOTO_DIM = 1200 +PHOTO_QUALITY = 85 + + +def _save_photo(file_storage, dest_dir: Path, prefix: str) -> str: + """ + Validate, resize, re-encode as JPEG, and save an uploaded photo. + Returns the path relative to STORAGE_ROOT. + Raises ValueError on invalid image data. + """ + try: + img = Image.open(file_storage) + img.verify() + file_storage.seek(0) + img = Image.open(file_storage) + img = img.convert("RGB") + except Exception as exc: + raise ValueError(f"Invalid image file: {exc}") from exc + + img.thumbnail((MAX_PHOTO_DIM, MAX_PHOTO_DIM), Image.LANCZOS) + + _ensure(dest_dir) + unique = uuid.uuid4().hex + filename = f"{prefix}_{unique}.jpg" + + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=PHOTO_QUALITY, optimize=True) + (dest_dir / filename).write_bytes(buf.getvalue()) + + return str(dest_dir.relative_to(_root()) / filename) + + +def save_equipment_photo(user_id: int, item_id: int, file_storage) -> str: + dest = _root() / "equipment_photos" / str(user_id) + return _save_photo(file_storage, dest, str(item_id)) + + +def save_session_photo(user_id: int, session_id: int, file_storage) -> str: + dest = _root() / "session_photos" / str(user_id) + return _save_photo(file_storage, dest, str(session_id)) + + +def save_avatar(user_id: int, file_storage) -> str: + dest = _root() / "avatars" / str(user_id) + return _save_photo(file_storage, dest, "avatar") + + +def rotate_photo(rel_path: str, degrees: int) -> None: + """Rotate a stored JPEG in-place. degrees is clockwise (90, -90, 180).""" + path = _root() / rel_path + if not path.exists(): + return + img = Image.open(path).convert("RGB") + # Pillow rotates counter-clockwise; negate to get clockwise behaviour + rotated = img.rotate(-degrees, expand=True) + buf = io.BytesIO() + rotated.save(buf, format="JPEG", quality=PHOTO_QUALITY, optimize=True) + path.write_bytes(buf.getvalue()) diff --git a/templates/analyses/detail.html b/templates/analyses/detail.html new file mode 100644 index 0000000..d7c7c48 --- /dev/null +++ b/templates/analyses/detail.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} +{% block title %}{{ analysis.title }} — The Shooter's Network{% endblock %} +{% block content %} + +
+
+
+ {% if analysis.session_id %} + Session › + {% else %} + Dashboard › + {% endif %} + Analysis +
+

{{ analysis.title }}

+
+ {{ analysis.created_at.strftime('%d %b %Y') }} +  ·  {{ analysis.shot_count }} shot(s) +  ·  {{ analysis.group_count }} group(s) +
+
+
+ {% if has_pdf %} + + ⇓ Download PDF report + + {% endif %} + {% if current_user.is_authenticated and current_user.id == analysis.user_id %} +
+ +
+ {% endif %} + ← New analysis +
+
+ +

Overall Statistics

+ + + + + + + + + + + + + + +
MetricValue
Total shots{{ overall.count }}
Min speed{{ "%.4f"|format(overall.min_speed) }}
Max speed{{ "%.4f"|format(overall.max_speed) }}
Mean speed{{ "%.4f"|format(overall.mean_speed) }}
Std dev (speed) + {% if overall.std_speed is not none %}{{ "%.4f"|format(overall.std_speed) }}{% else %}–{% endif %} +
+ +Avg speed and std dev per group + +

Groups — {{ groups_display|length }} group(s) detected

+ +{% for stat, chart_b64 in groups_display %} +
+

Group {{ stat.group_index }}

+
+ {{ stat.time_start }} – {{ stat.time_end }}  |  {{ stat.count }} shot(s) +
+ + + + + + + + + + + + + +
MetricValue
Min speed{{ "%.4f"|format(stat.min_speed) }}
Max speed{{ "%.4f"|format(stat.max_speed) }}
Mean speed{{ "%.4f"|format(stat.mean_speed) }}
Std dev (speed) + {% if stat.std_speed is not none %}{{ "%.4f"|format(stat.std_speed) }}{% else %}–{% endif %} +
+ Speed chart for group {{ stat.group_index }} +
+{% endfor %} + +{% endblock %} diff --git a/templates/auth/confirm_pending.html b/templates/auth/confirm_pending.html new file mode 100644 index 0000000..cc3104a --- /dev/null +++ b/templates/auth/confirm_pending.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Confirm your email — Ballistic Analyzer{% endblock %} +{% block content %} +

Check your inbox

+ +

+ A confirmation link has been sent to {{ email }}. + Click the link in that email to activate your account. +

+ +

+ Didn't receive it? Check your spam folder, or request a new link below. +

+ +
+ + +
+{% endblock %} diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..0ab8556 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}Login — Ballistic Analyzer{% endblock %} +{% block content %} +

Sign in

+ +
+
+ + +
+
+ + +
+ +
+ +{% if show_resend %} +
+ + +
+{% endif %} + +

+ Don't have an account? Create one +

+ +
+
+ or continue with +
+
+ + +{% endblock %} diff --git a/templates/auth/profile.html b/templates/auth/profile.html new file mode 100644 index 0000000..70f2e55 --- /dev/null +++ b/templates/auth/profile.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} +{% block title %}Profile — The Shooter's Network{% endblock %} +{% block content %} +

Profile

+ +{# ---- Avatar + display name ---- #} +

Account

+
+ + +
+ {% set av = current_user.effective_avatar_url %} + {% if av %} + Avatar + {% else %} +
+ 👤 +
+ {% endif %} +
+ + +
JPEG/PNG, max 1200 px, auto-resized.
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ Logged in via {{ current_user.provider.title() }} +
+
+ +
+ +
+ +
+ + + View my public profile → + +
+
+ +{# ---- Change password (local accounts only) ---- #} +{% if current_user.provider == 'local' %} +

Change password

+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+{% endif %} + +{% endblock %} diff --git a/templates/auth/public_profile.html b/templates/auth/public_profile.html new file mode 100644 index 0000000..882d5e8 --- /dev/null +++ b/templates/auth/public_profile.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}{{ profile_user.display_name or profile_user.email.split('@')[0] }} — The Shooter's Network{% endblock %} +{% block content %} + +
+ {% set av = profile_user.effective_avatar_url %} + {% if av %} + Avatar + {% else %} +
+ 👤 +
+ {% endif %} +
+

{{ profile_user.display_name or profile_user.email.split('@')[0] }}

+
+ Member since {{ profile_user.created_at.strftime('%B %Y') }} +
+ {% if profile_user.bio %} +

{{ profile_user.bio }}

+ {% endif %} +
+
+ +{# ---- Public Sessions ---- #} +

Sessions{% if public_sessions %} ({{ public_sessions|length }}){% endif %}

+ +{% if public_sessions %} + + + + + + {% for s in public_sessions %} + + + + + + {% endfor %} + +
SessionLocationDistance
+ + {{ s.session_date.strftime('%d %b %Y') }} + + {{ s.location_name or '—' }}{% if s.distance_m %}{{ s.distance_m }} m{% else %}—{% endif %}
+{% else %} +

No public sessions yet.

+{% endif %} + +{# ---- Equipment (optional) ---- #} +{% if equipment is not none %} +

Equipment

+{% if equipment %} + + + + + + {% for item in equipment %} + + + + + + + {% endfor %} + +
NameCategoryBrand / ModelCaliber
{{ item.name }}{{ item.category.title() }} + {% if item.brand or item.model %} + {{ item.brand or '' }}{% if item.brand and item.model %} {% endif %}{{ item.model or '' }} + {% else %}—{% endif %} + {{ item.caliber or '—' }}
+{% else %} +

No equipment listed.

+{% endif %} +{% endif %} + +{% endblock %} diff --git a/templates/auth/register.html b/templates/auth/register.html new file mode 100644 index 0000000..59847c4 --- /dev/null +++ b/templates/auth/register.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Create account — Ballistic Analyzer{% endblock %} +{% block content %} +

Create account

+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +

+ Already have an account? Sign in +

+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 8a648f6..2250165 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,7 +3,7 @@ - Ballistic Analyzer + {% block title %}The Shooter's Network{% endblock %} -
- {% block content %}{% endblock %} + + {% block body %} + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+ {% block content %}{% endblock %} +
+ {% endblock %} + + {# ── Lightbox ── always present, activated by any img[data-gallery] #} + + + + diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html new file mode 100644 index 0000000..2cf185b --- /dev/null +++ b/templates/dashboard/index.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}Dashboard — The Shooter's Network{% endblock %} +{% block content %} +

Dashboard

+

+ Welcome back, {{ current_user.display_name or current_user.email }}. +

+ + + +

Recent Analyses

+ +{% if analyses %} + + + + + + + + + + + + {% for a in analyses %} + + + + + + + + {% endfor %} + +
TitleDateShotsGroupsVisibility
{{ a.title }}{{ a.created_at.strftime('%d %b %Y') }}{{ a.shot_count }}{{ a.group_count }} + {{ 'Public' if a.is_public else 'Private' }} +
+{% else %} +

+ No analyses yet. Upload a CSV file to get started — it will be saved here automatically. +

+{% endif %} + + +{% endblock %} diff --git a/templates/equipment/detail.html b/templates/equipment/detail.html new file mode 100644 index 0000000..ba9e5ba --- /dev/null +++ b/templates/equipment/detail.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}{{ item.name }} — The Shooter's Network{% endblock %} +{% block content %} +
+
+
+ Equipment › + {{ categories.get(item.category, item.category).title() }} +
+

{{ item.name }}

+
+
+ + Edit + +
+ +
+
+
+ +{% if item.photo_url %} +
+ {{ item.name }} +
+ {% for label, deg in [('↺ Left', -90), ('↻ Right', 90), ('180°', 180)] %} +
+ + +
+ {% endfor %} +
+
+{% endif %} + + + + + {% if item.brand %}{% endif %} + {% if item.model %}{% endif %} + {% if item.category == 'scope' %} + {% if item.magnification %}{% endif %} + {% if item.reticle %}{% endif %} + {% if item.unit %}{% endif %} + {% else %} + {% if item.caliber %}{% endif %} + {% endif %} + {% if item.serial_number %}{% endif %} + + +
Category{{ categories.get(item.category, item.category).title() }}
Brand{{ item.brand }}
Model{{ item.model }}
Magnification{{ item.magnification }}
Reticle{{ item.reticle }}
Unit{{ item.unit }}
Caliber{{ item.caliber }}
Serial{{ item.serial_number }}
Added{{ item.created_at.strftime('%d %b %Y') }}
+ +{% if item.notes %} +
+

Notes

+

{{ item.notes }}

+
+{% endif %} +{% endblock %} diff --git a/templates/equipment/form.html b/templates/equipment/form.html new file mode 100644 index 0000000..b9e279c --- /dev/null +++ b/templates/equipment/form.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} +{% set editing = item is not none %} +{% block title %}{{ 'Edit' if editing else 'Add' }} Equipment — The Shooter's Network{% endblock %} +{% block content %} +

{{ 'Edit' if editing else 'Add equipment' }}

+ +{% set f = prefill or item %} + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+ + + +
+ + +
+ +
+ + +
+ +
+ + {% if editing and item.photo_url %} +
+ Current photo + Upload a new one to replace it. +
+ {% endif %} + +
+ +
+ + Cancel +
+
+ + + + +{% endblock %} diff --git a/templates/equipment/list.html b/templates/equipment/list.html new file mode 100644 index 0000000..99011d5 --- /dev/null +++ b/templates/equipment/list.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}Equipment — The Shooter's Network{% endblock %} +{% block content %} +
+

My Equipment

+ + + Add item + +
+ +{% if items %} + {% set cat_labels = dict(categories) %} + {% for cat_key, cat_label in categories %} + {% set group = items | selectattr('category', 'equalto', cat_key) | list %} + {% if group %} +

{{ cat_label }}s

+
+ {% for item in group %} +
+ {% if item.photo_url %} + {{ item.name }} + {% else %} +
+ {% if item.category == 'rifle' or item.category == 'handgun' %}🔫 + {% elif item.category == 'scope' %}🔭 + {% else %}🔩{% endif %} +
+ {% endif %} +
+
{{ item.name }}
+ {% if item.brand or item.model %} +
+ {{ [item.brand, item.model] | select | join(' · ') }} +
+ {% endif %} + {% if item.caliber %} +
{{ item.caliber }}
+ {% endif %} +
+ View + Edit +
+ +
+
+
+
+ {% endfor %} +
+ {% endif %} + {% endfor %} +{% else %} +
+
🔫
+

No equipment yet.

+ Add your first item +
+{% endif %} +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b57f265 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} +{% block title %}The Shooter's Network — Track, analyze, share{% endblock %} + +{% block body %} + + + + +
+

+ The Shooter's Network +

+

+ Analyze your ballistic data, track every session, manage your equipment, + and share your performance with the community. +

+
+ {% if current_user.is_authenticated %} + + New Analysis + + + Log a Session + + {% else %} + + Get started — free + + + Try without account + + {% endif %} +
+
+ + +
+
+
+
📊
+

Ballistic Analysis

+

Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports.

+
+
+
🎯
+

Session Tracking

+

Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place.

+
+
+
🤝
+

Community Feed

+

Share your public sessions and see what other shooters are achieving on the range.

+
+
+
+ + +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} +{% endwith %} + + +
+ +
+{% endblock %} diff --git a/templates/results.html b/templates/results.html index 7d8d890..7b97cb6 100644 --- a/templates/results.html +++ b/templates/results.html @@ -2,12 +2,16 @@ {% block content %}

Analysis Results

- diff --git a/templates/sessions/annotate_photo.html b/templates/sessions/annotate_photo.html new file mode 100644 index 0000000..574ad22 --- /dev/null +++ b/templates/sessions/annotate_photo.html @@ -0,0 +1,619 @@ +{% extends "base.html" %} +{% block title %}Annotate photo — {{ session.label }}{% endblock %} +{% block content %} + +
+
+ Sessions › + {{ session.label }} › + Annotate +
+

{{ photo.caption or 'Photo annotation' }}

+
+ +
+ + {# ── Canvas ── #} +
+ +
+ + {# ── Control panel ── #} +
+ + {# Step indicator #} +
+
+ + Reference line +
+
+ + Point of Aim +
+
+ + Points of Impact +
+
+ + Results +
+
+ +
+ + {# Shooting distance (always visible) #} +
+ +
+ + +
+
+ +
+ + {# Step 0: Reference line #} +
+

+ Click two points on the image to draw a reference line — e.g. a known grid square or target diameter. +

+ +
+ + +
+
+ + {# Step 1: POA #} + + + {# Step 2: POIs #} + + + {# Step 3: Results #} + + +
{# end control panel #} +
+ + + + + +{% endblock %} diff --git a/templates/sessions/detail.html b/templates/sessions/detail.html new file mode 100644 index 0000000..8a4d2c7 --- /dev/null +++ b/templates/sessions/detail.html @@ -0,0 +1,218 @@ +{% extends "base.html" %} +{% block title %}{{ session.label }} — The Shooter's Network{% endblock %} +{% block content %} + +
+
+
+ {% if is_owner %}Sessions › {% endif %} + {{ session.session_date.strftime('%d %b %Y') }} + {% if session.is_public %} + Public + {% endif %} +
+

{{ session.label }}

+
+ by {{ session.user.display_name or session.user.email.split('@')[0] }} +
+
+ {% if is_owner %} +
+ + Edit + +
+ +
+
+ {% endif %} +
+ +{# ---- Stats cards ---- #} +
+ + {% if session.location_name or session.distance_m %} +
+
Location
+ {% if session.location_name %}
{{ session.location_name }}
{% endif %} + {% if session.distance_m %}
{{ session.distance_m }} m
{% endif %} +
+ {% endif %} + + {% if session.weather_cond or session.weather_temp_c is not none or session.weather_wind_kph is not none %} +
+
Weather
+ {% if session.weather_cond %}
{{ session.weather_cond.replace('_',' ').title() }}
{% endif %} +
+ {% if session.weather_temp_c is not none %}{{ session.weather_temp_c }}°C{% endif %} + {% if session.weather_wind_kph is not none %}  {{ session.weather_wind_kph }} km/h wind{% endif %} +
+
+ {% endif %} + + {% if session.rifle %} +
+
Rifle / Handgun
+
{{ session.rifle.name }}
+ {% if session.rifle.caliber %}
{{ session.rifle.caliber }}
{% endif %} +
+ {% endif %} + + {% if session.scope %} +
+
Scope
+
{{ session.scope.name }}
+
+ {% endif %} + + {% if session.ammo_brand or session.ammo_weight_gr is not none %} +
+
Ammo
+ {% if session.ammo_brand %}
{{ session.ammo_brand }}
{% endif %} +
+ {% if session.ammo_weight_gr is not none %}{{ session.ammo_weight_gr }} gr{% endif %} + {% if session.ammo_lot %}  lot {{ session.ammo_lot }}{% endif %} +
+
+ {% endif %} + +
+ +{% if session.notes %} +

Notes

+

{{ session.notes }}

+{% endif %} + +{# ---- Photos ---- #} +{% if session.photos or is_owner %} +

Photos

+{% if session.photos %} +
+ {% for photo in session.photos %} +
+
+ {{ photo.caption or '' }} + {% if is_owner %} +
+ +
+ {% endif %} +
+ {% if photo.annotations and photo.annotations.stats %} + {% set s = photo.annotations.stats %} +
+ {{ s.shot_count }} shots · {{ '%.2f'|format(s.group_size_moa) }} MOA ES +
+ {% endif %} + {% if photo.caption %} +
+ {{ photo.caption }} +
+ {% endif %} + {% if is_owner %} +
+ {% for label, deg in [('↺', -90), ('↻', 90), ('180°', 180)] %} +
+ + +
+ {% endfor %} +
+ + {% if photo.annotations and photo.annotations.stats %}✓{% else %}▶{% endif %} + Measure group + + {% endif %} +
+ {% endfor %} +
+{% endif %} + +{% if is_owner %} +
+
+ + +
+
+ + +
+ +
+{% endif %} +{% endif %} + +{# ---- Analyses ---- #} +

Analyses{% if analyses %} ({{ analyses|length }}){% endif %}

+ +{% if analyses %} + + + + + + {% for a in analyses %} + + + + + + + + {% endfor %} + +
TitleDateShotsGroupsMean speed
{{ a.title }}{{ a.created_at.strftime('%d %b %Y') }}{{ a.shot_count }}{{ a.group_count }}{{ "%.2f"|format(a.overall_stats.mean_speed) }} m/s
+{% else %} +

No analyses yet.

+{% endif %} + +{% if is_owner %} +
+
+ + +
+ +
+{% endif %} + +{% endblock %} diff --git a/templates/sessions/form.html b/templates/sessions/form.html new file mode 100644 index 0000000..690d308 --- /dev/null +++ b/templates/sessions/form.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} +{% set editing = session is not none %} +{% block title %}{{ 'Edit session' if editing else 'New session' }} — The Shooter's Network{% endblock %} +{% block content %} +

{{ 'Edit session' if editing else 'Log a session' }}

+ +{% set f = prefill or session %} + +
+ +

Basic info

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +

Weather

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Equipment & Ammo

+ +
+
+ + + {% if not rifles %} + + {% endif %} +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Notes & Visibility

+ +
+ + +
+ +
+ +
+ +
+ + Cancel +
+
+ + +{% endblock %} diff --git a/templates/sessions/list.html b/templates/sessions/list.html new file mode 100644 index 0000000..287b93f --- /dev/null +++ b/templates/sessions/list.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Sessions — The Shooter's Network{% endblock %} +{% block content %} +
+

My Sessions

+ + + New session + +
+ +{% if sessions %} + + + + + + + + + + + {% for s in sessions %} + + + + + + + {% endfor %} + +
SessionLocationVisibility
{{ s.session_date.strftime('%d %b %Y') }}{{ s.location_name or '—' }} + {{ 'Public' if s.is_public else 'Private' }} + + Edit +
+ +
+
+{% else %} +
+
🎯
+

No sessions recorded yet.

+ Log your first session +
+{% endif %} +{% endblock %} diff --git a/templates/upload.html b/templates/upload.html index 5083fe7..dd1e08b 100644 --- a/templates/upload.html +++ b/templates/upload.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block content %} -

Ballistic Analyzer

+

New Analysis

{% if error %}
{{ error }}