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

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

20
.env.example Normal file
View File

@@ -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

View File

@@ -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"]

137
app.py
View File

@@ -1,31 +1,131 @@
import base64
import io
from flask import Flask, request, render_template
from flask_login import current_user
from sqlalchemy import select
from config import Config
from extensions import db, jwt, login_manager, migrate, oauth
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)
@jwt.unauthorized_loader
def unauthorized_callback(reason):
from flask import jsonify
return jsonify({"error": {"code": "UNAUTHORIZED", "message": reason}}), 401
@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
@jwt.invalid_token_loader
def invalid_callback(reason):
from flask import jsonify
return jsonify({"error": {"code": "INVALID_TOKEN", "message": reason}}), 422
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
@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/<int:user_id>")
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
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024
@app.route("/")
def index():
if request.method == "GET":
return render_template("upload.html")
@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.")
file = request.files["csv_file"]
try:
df = parse_csv(file.stream)
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)
@@ -41,6 +141,18 @@ def analyze():
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",
@@ -48,8 +160,7 @@ def analyze():
groups_display=groups_display,
overview_chart=overview_chart,
pdf_b64=pdf_b64,
saved_analysis_id=saved_analysis_id,
)
if __name__ == "__main__":
app.run(debug=True)
return app

0
blueprints/__init__.py Normal file
View File

104
blueprints/analyses.py Normal file
View File

@@ -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("/<int:analysis_id>")
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("/<int:analysis_id>/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("/<int:analysis_id>/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")

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

382
blueprints/auth.py Normal file
View File

@@ -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/<token>")
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/<int:user_id>")
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"))

20
blueprints/dashboard.py Normal file
View File

@@ -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)

191
blueprints/equipment.py Normal file
View File

@@ -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("/<int:item_id>")
@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("/<int:item_id>/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("/<int:item_id>/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("/<int:item_id>/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/<path:filepath>")
@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

343
blueprints/sessions.py Normal file
View File

@@ -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("/<int:session_id>")
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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/photos/<int:photo_id>/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("/<int:session_id>/photos/<int:photo_id>/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("/<int:session_id>/photos/<int:photo_id>/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/<path:filepath>")
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)

26
config.py Normal file
View File

@@ -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"
)

View File

@@ -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:

8
entrypoint.sh Executable file
View File

@@ -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()"

14
extensions.py Normal file
View File

@@ -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."

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -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

113
migrations/env.py Normal file
View File

@@ -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()

24
migrations/script.py.mako Normal file
View File

@@ -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"}

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

176
models.py Normal file
View File

@@ -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}"

View File

@@ -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

156
storage.py Normal file
View File

@@ -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())

View File

@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}{{ analysis.title }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
{% if analysis.session_id %}
<a href="{{ url_for('sessions.detail', session_id=analysis.session_id) }}">Session</a> &rsaquo;
{% else %}
<a href="{{ url_for('dashboard.index') }}">Dashboard</a> &rsaquo;
{% endif %}
Analysis
</div>
<h1 style="margin:0;">{{ analysis.title }}</h1>
<div style="font-size:0.85rem;color:#888;margin-top:.3rem;">
{{ analysis.created_at.strftime('%d %b %Y') }}
&nbsp;&middot;&nbsp; {{ analysis.shot_count }} shot(s)
&nbsp;&middot;&nbsp; {{ analysis.group_count }} group(s)
</div>
</div>
<div style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;">
{% if has_pdf %}
<a href="{{ url_for('analyses.download_pdf', analysis_id=analysis.id) }}"
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
&#8659; Download PDF report
</a>
{% endif %}
{% if current_user.is_authenticated and current_user.id == analysis.user_id %}
<form method="post" action="{{ url_for('analyses.delete', analysis_id=analysis.id) }}"
onsubmit="return confirm('Delete this analysis? The CSV and PDF will be permanently removed.');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Delete
</button>
</form>
{% endif %}
<a href="{{ url_for('analyze') }}" style="font-size:0.9rem;color:#666;">&#8592; New analysis</a>
</div>
</div>
<h2>Overall Statistics</h2>
<table style="max-width:480px;">
<thead>
<tr><th>Metric</th><th>Value</th></tr>
</thead>
<tbody>
<tr><td>Total shots</td><td>{{ overall.count }}</td></tr>
<tr><td>Min speed</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
<tr><td>Max speed</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
<tr><td>Mean speed</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
<tr>
<td>Std dev (speed)</td>
<td>
{% if overall.std_speed is not none %}{{ "%.4f"|format(overall.std_speed) }}{% else %}&ndash;{% endif %}
</td>
</tr>
</tbody>
</table>
<img class="chart-img" src="data:image/png;base64,{{ overview_chart }}"
alt="Avg speed and std dev per group" style="max-width:600px;margin:1rem 0 1.5rem;">
<h2>Groups &mdash; {{ groups_display|length }} group(s) detected</h2>
{% for stat, chart_b64 in groups_display %}
<div class="group-section">
<h3>Group {{ stat.group_index }}</h3>
<div class="group-meta">
{{ stat.time_start }} &ndash; {{ stat.time_end }} &nbsp;|&nbsp; {{ stat.count }} shot(s)
</div>
<table style="max-width:480px;">
<thead>
<tr><th>Metric</th><th>Value</th></tr>
</thead>
<tbody>
<tr><td>Min speed</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
<tr><td>Max speed</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
<tr><td>Mean speed</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
<tr>
<td>Std dev (speed)</td>
<td>
{% if stat.std_speed is not none %}{{ "%.4f"|format(stat.std_speed) }}{% else %}&ndash;{% endif %}
</td>
</tr>
</tbody>
</table>
<img class="chart-img" src="data:image/png;base64,{{ chart_b64 }}"
alt="Speed chart for group {{ stat.group_index }}">
</div>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Confirm your email — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>Check your inbox</h1>
<p style="color:#555;margin-bottom:1.25rem;">
A confirmation link has been sent to <strong>{{ email }}</strong>.
Click the link in that email to activate your account.
</p>
<p style="color:#888;font-size:0.9rem;margin-bottom:1.5rem;">
Didn't receive it? Check your spam folder, or request a new link below.
</p>
<form method="post" action="{{ url_for('auth.resend_confirmation') }}">
<input type="hidden" name="email" value="{{ email }}">
<button type="submit"
style="background:#f0f4ff;color:#1f77b4;border:1px solid #c0d4f0;border-radius:4px;padding:0.55rem 1.2rem;font-size:0.92rem;cursor:pointer;">
Resend confirmation email
</button>
</form>
{% endblock %}

62
templates/auth/login.html Normal file
View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Login — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>Sign in</h1>
<form method="post" action="{{ url_for('auth.login') }}" style="max-width:360px;margin-bottom:1.5rem;">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Email</label>
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Password</label>
<input type="password" name="password" required autocomplete="current-password"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<button type="submit"
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
Sign in
</button>
</form>
{% if show_resend %}
<form method="post" action="{{ url_for('auth.resend_confirmation') }}" style="margin-bottom:1.5rem;">
<input type="hidden" name="email" value="{{ resend_email }}">
<button type="submit" class="btn-link" style="color:#1f77b4;font-size:0.88rem;">
Resend confirmation email
</button>
</form>
{% endif %}
<p style="font-size:0.9rem;color:#555;margin-bottom:1.5rem;">
Don't have an account? <a href="{{ url_for('auth.register') }}">Create one</a>
</p>
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem;max-width:360px;">
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
<span style="font-size:0.8rem;color:#999;">or continue with</span>
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
</div>
<div style="display:flex;flex-direction:column;gap:0.75rem;max-width:360px;">
<a href="{{ url_for('auth.login_google') }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1.1rem;border:1px solid #dadce0;border-radius:6px;color:#3c4043;text-decoration:none;font-size:0.92rem;font-weight:500;background:#fff;">
<svg width="17" height="17" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Continue with Google
</a>
<a href="{{ url_for('auth.login_github') }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1.1rem;border:1px solid #d0d7de;border-radius:6px;color:#24292f;text-decoration:none;font-size:0.92rem;font-weight:500;background:#f6f8fa;">
<svg width="17" height="17" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/>
</svg>
Continue with GitHub
</a>
</div>
{% endblock %}

107
templates/auth/profile.html Normal file
View File

@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}Profile — The Shooter's Network{% endblock %}
{% block content %}
<h1>Profile</h1>
{# ---- Avatar + display name ---- #}
<h2>Account</h2>
<form method="post" action="{{ url_for('auth.profile') }}"
enctype="multipart/form-data"
style="max-width:480px;">
<input type="hidden" name="action" value="update_profile">
<div style="display:flex;align-items:center;gap:1.5rem;margin-bottom:1.5rem;">
{% set av = current_user.effective_avatar_url %}
{% if av %}
<img src="{{ av }}" alt="Avatar"
style="width:80px;height:80px;border-radius:50%;object-fit:cover;border:2px solid #e0e0e0;">
{% else %}
<div style="width:80px;height:80px;border-radius:50%;background:#e0e6f0;display:flex;align-items:center;justify-content:center;font-size:2rem;color:#888;">
&#128100;
</div>
{% endif %}
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
Profile picture
</label>
<input type="file" name="avatar" accept="image/*" style="font-size:0.9rem;">
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">JPEG/PNG, max 1200 px, auto-resized.</div>
</div>
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Display name</label>
<input type="text" name="display_name"
value="{{ current_user.display_name or '' }}"
required
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Bio</label>
<textarea name="bio" rows="4" placeholder="Tell others a bit about yourself…"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ current_user.bio or '' }}</textarea>
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Email</label>
<input type="text" value="{{ current_user.email }}" disabled
style="width:100%;padding:.55rem .75rem;border:1px solid #e0e0e0;border-radius:4px;font-size:0.95rem;background:#f5f5f5;color:#888;">
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">
Logged in via <strong>{{ current_user.provider.title() }}</strong>
</div>
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;font-size:.95rem;">
<input type="checkbox" name="show_equipment_public"
{% if current_user.show_equipment_public %}checked{% endif %}
style="width:1rem;height:1rem;">
Show my equipment on my public profile
</label>
</div>
<div style="display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
Save changes
</button>
<a href="{{ url_for('public_profile', user_id=current_user.id) }}"
style="font-size:0.9rem;color:#1f77b4;" target="_blank">
View my public profile &rarr;
</a>
</div>
</form>
{# ---- Change password (local accounts only) ---- #}
{% if current_user.provider == 'local' %}
<h2>Change password</h2>
<form method="post" action="{{ url_for('auth.profile') }}"
style="max-width:480px;">
<input type="hidden" name="action" value="change_password">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Current password</label>
<input type="password" name="current_password" required
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">New password</label>
<input type="password" name="new_password" required minlength="8"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Confirm</label>
<input type="password" name="confirm_password" required minlength="8"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
Change password
</button>
</form>
{% endif %}
{% endblock %}

View File

@@ -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 %}
<div style="display:flex;align-items:center;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap;">
{% set av = profile_user.effective_avatar_url %}
{% if av %}
<img src="{{ av }}" alt="Avatar"
style="width:80px;height:80px;border-radius:50%;object-fit:cover;border:2px solid #e0e0e0;flex-shrink:0;">
{% else %}
<div style="width:80px;height:80px;border-radius:50%;background:#e0e6f0;display:flex;align-items:center;justify-content:center;font-size:2.5rem;color:#888;flex-shrink:0;">
&#128100;
</div>
{% endif %}
<div>
<h1 style="margin:0 0 .25rem;">{{ profile_user.display_name or profile_user.email.split('@')[0] }}</h1>
<div style="font-size:0.85rem;color:#888;">
Member since {{ profile_user.created_at.strftime('%B %Y') }}
</div>
{% if profile_user.bio %}
<p style="margin-top:.75rem;color:#444;white-space:pre-wrap;max-width:600px;">{{ profile_user.bio }}</p>
{% endif %}
</div>
</div>
{# ---- Public Sessions ---- #}
<h2>Sessions{% if public_sessions %} ({{ public_sessions|length }}){% endif %}</h2>
{% if public_sessions %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>Session</th><th>Location</th><th>Distance</th></tr>
</thead>
<tbody>
{% for s in public_sessions %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('sessions.detail', session_id=s.id) }}'">
<td>
<a href="{{ url_for('sessions.detail', session_id=s.id) }}" style="color:inherit;text-decoration:none;">
{{ s.session_date.strftime('%d %b %Y') }}
</a>
</td>
<td style="color:#666;">{{ s.location_name or '—' }}</td>
<td style="color:#666;">{% if s.distance_m %}{{ s.distance_m }} m{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">No public sessions yet.</p>
{% endif %}
{# ---- Equipment (optional) ---- #}
{% if equipment is not none %}
<h2>Equipment</h2>
{% if equipment %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>Name</th><th>Category</th><th>Brand / Model</th><th>Caliber</th></tr>
</thead>
<tbody>
{% for item in equipment %}
<tr>
<td>{{ item.name }}</td>
<td style="color:#666;font-size:0.88rem;">{{ item.category.title() }}</td>
<td style="color:#666;font-size:0.88rem;">
{% if item.brand or item.model %}
{{ item.brand or '' }}{% if item.brand and item.model %} {% endif %}{{ item.model or '' }}
{% else %}—{% endif %}
</td>
<td style="color:#666;font-size:0.88rem;">{{ item.caliber or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">No equipment listed.</p>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Create account — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>Create account</h1>
<form method="post" action="{{ url_for('auth.register') }}" style="max-width:360px;">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Email</label>
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Password
<span style="font-weight:400;color:#888;">(min. 8 characters)</span>
</label>
<input type="password" name="password" required autocomplete="new-password" minlength="8"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1.5rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Confirm password</label>
<input type="password" name="confirm_password" required autocomplete="new-password"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<button type="submit"
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
Create account
</button>
</form>
<p style="font-size:0.9rem;color:#555;margin-top:1.25rem;">
Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a>
</p>
{% endblock %}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ballistic Analyzer</title>
<title>{% block title %}The Shooter's Network{% endblock %}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
@@ -11,8 +11,130 @@
background: #f4f5f7;
color: #222;
min-height: 100vh;
padding: 2rem 1rem;
}
/* ── Nav ── */
.nav {
background: #1a1a2e;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 52px;
gap: 1rem;
}
.nav-brand {
font-weight: 700;
font-size: 1rem;
color: #fff;
text-decoration: none;
letter-spacing: 0.02em;
white-space: nowrap;
}
.nav-links {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.nav-links a {
color: #c8cfe0;
text-decoration: none;
font-size: 0.9rem;
}
.nav-links a:hover { color: #fff; }
.nav-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
vertical-align: middle;
object-fit: cover;
}
.nav-right {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
/* ── User dropdown ── */
.nav-dropdown { position: relative; }
.nav-user-btn {
display: flex;
align-items: center;
gap: 0.4rem;
background: none;
border: 1px solid rgba(255,255,255,.25);
border-radius: 20px;
padding: 0.25rem 0.75rem 0.25rem 0.4rem;
color: #c8cfe0;
cursor: pointer;
font-size: 0.88rem;
font-family: inherit;
white-space: nowrap;
}
.nav-user-btn:hover { border-color: rgba(255,255,255,.55); color: #fff; }
.nav-user-btn .caret { font-size: 0.65rem; opacity: .7; }
.nav-dd-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 6px);
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,.12);
min-width: 160px;
z-index: 100;
overflow: hidden;
}
.nav-dropdown.open .nav-dd-menu { display: block; }
.nav-dd-menu a,
.nav-dd-menu button {
display: block;
width: 100%;
text-align: left;
padding: 0.65rem 1rem;
font-size: 0.9rem;
color: #222;
text-decoration: none;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.nav-dd-menu a:hover,
.nav-dd-menu button:hover { background: #f4f5f7; }
.nav-dd-menu hr { border: none; border-top: 1px solid #e8e8e8; margin: 0; }
.btn-link {
background: none;
border: none;
color: #c8cfe0;
cursor: pointer;
font-size: 0.9rem;
padding: 0;
font-family: inherit;
}
.btn-link:hover { color: #fff; text-decoration: underline; }
/* ── Flash messages ── */
.flashes {
max-width: 960px;
margin: 1rem auto 0;
padding: 0 1rem;
}
.flash {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 0.5rem;
font-size: 0.92rem;
}
.flash.error { background: #fff0f0; border-left: 4px solid #e74c3c; color: #c0392b; }
.flash.success { background: #f0fff4; border-left: 4px solid #27ae60; color: #1e8449; }
.flash.message { background: #f0f4ff; border-left: 4px solid #1f77b4; color: #154360; }
/* ── Page content ── */
.page { padding: 2rem 1rem; }
.container {
max-width: 960px;
margin: 0 auto;
@@ -45,11 +167,7 @@
border-bottom: 1px solid #e8e8e8;
font-size: 0.92rem;
}
th {
background: #f0f4ff;
font-weight: 600;
color: #444;
}
th { background: #f0f4ff; font-weight: 600; color: #444; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafbff; }
.group-section {
@@ -58,11 +176,7 @@
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.group-meta {
font-size: 0.88rem;
color: #666;
margin-bottom: 0.75rem;
}
.group-meta { font-size: 0.88rem; color: #666; margin-bottom: 0.75rem; }
.chart-img {
width: 100%;
max-width: 860px;
@@ -73,8 +187,211 @@
</style>
</head>
<body>
{% block body %}
<nav class="nav">
<a href="/" class="nav-brand">The Shooter's Network</a>
<div class="nav-links">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}">New Analysis</a>
<a href="{{ url_for('equipment.index') }}">Equipment</a>
<a href="{{ url_for('sessions.index') }}">Sessions</a>
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
{% endif %}
</div>
<div class="nav-right">
{% if current_user.is_authenticated %}
<div class="nav-dropdown" id="userDropdown">
<button class="nav-user-btn" onclick="toggleDropdown(event)">
{% set av = current_user.effective_avatar_url %}
{% if av %}
<img src="{{ av }}" class="nav-avatar" alt="">
{% else %}
<span style="font-size:1.1rem;line-height:1;">&#128100;</span>
{% endif %}
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu">
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;Profile</a>
<hr>
<form method="post" action="{{ url_for('auth.logout') }}">
<button type="submit">&#8594;&ensp;Logout</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">Login</a>
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
Join free
</a>
{% endif %}
</div>
</nav>
<script>
function toggleDropdown(e) {
e.stopPropagation();
document.getElementById('userDropdown').classList.toggle('open');
}
document.addEventListener('click', function() {
var d = document.getElementById('userDropdown');
if (d) d.classList.remove('open');
});
</script>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="page">
<div class="container">
{% block content %}{% endblock %}
</div>
</div>
{% endblock %}
{# ── Lightbox ── always present, activated by any img[data-gallery] #}
<div id="lb" role="dialog" aria-modal="true" aria-label="Photo viewer"
style="display:none;position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.88);
align-items:center;justify-content:center;">
<button id="lb-close" aria-label="Close"
style="position:absolute;top:.9rem;right:1.2rem;background:none;border:none;
color:#fff;font-size:2rem;line-height:1;cursor:pointer;opacity:.8;">&#x2715;</button>
<button id="lb-prev" aria-label="Previous"
style="position:absolute;left:.75rem;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;color:#fff;font-size:2.5rem;
line-height:1;padding:.2rem .55rem;border-radius:6px;cursor:pointer;">&#8249;</button>
<button id="lb-next" aria-label="Next"
style="position:absolute;right:.75rem;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;color:#fff;font-size:2.5rem;
line-height:1;padding:.2rem .55rem;border-radius:6px;cursor:pointer;">&#8250;</button>
<div style="max-width:92vw;text-align:center;pointer-events:none;">
<img id="lb-img" src="" alt=""
style="max-width:92vw;max-height:88vh;object-fit:contain;border-radius:4px;
display:block;margin:0 auto;pointer-events:none;">
<div id="lb-caption"
style="color:#ddd;margin-top:.6rem;font-size:.9rem;min-height:1.2em;"></div>
<div id="lb-counter"
style="color:#aaa;font-size:.78rem;margin-top:.2rem;"></div>
</div>
</div>
<script>
(function () {
var lb = document.getElementById('lb');
var lbImg = document.getElementById('lb-img');
var lbCap = document.getElementById('lb-caption');
var lbCnt = document.getElementById('lb-counter');
var lbPrev = document.getElementById('lb-prev');
var lbNext = document.getElementById('lb-next');
var gallery = []; // [{src, caption}]
var current = 0;
function open(items, idx) {
gallery = items;
current = idx;
render();
lb.style.display = 'flex';
document.body.style.overflow = 'hidden';
lb.focus();
}
function close() {
lb.style.display = 'none';
document.body.style.overflow = '';
}
function render() {
var item = gallery[current];
lbImg.src = item.src;
lbImg.alt = item.caption || '';
lbCap.textContent = item.caption || '';
if (gallery.length > 1) {
lbPrev.style.display = '';
lbNext.style.display = '';
lbCnt.textContent = (current + 1) + ' / ' + gallery.length;
} else {
lbPrev.style.display = 'none';
lbNext.style.display = 'none';
lbCnt.textContent = '';
}
}
function move(delta) {
current = (current + delta + gallery.length) % gallery.length;
render();
}
// Click outside the image closes
lb.addEventListener('click', function (e) {
if (e.target === lb) close();
});
document.getElementById('lb-close').addEventListener('click', close);
lbPrev.addEventListener('click', function (e) { e.stopPropagation(); move(-1); });
lbNext.addEventListener('click', function (e) { e.stopPropagation(); move(1); });
document.addEventListener('keydown', function (e) {
if (lb.style.display === 'none') return;
if (e.key === 'Escape') close();
if (e.key === 'ArrowLeft') move(-1);
if (e.key === 'ArrowRight') move(1);
});
// Touch swipe support
var touchStartX = null;
lb.addEventListener('touchstart', function (e) { touchStartX = e.touches[0].clientX; });
lb.addEventListener('touchend', function (e) {
if (touchStartX === null) return;
var dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 40) move(dx < 0 ? 1 : -1);
touchStartX = null;
});
// Wire up all gallery images after DOM is ready
function initGalleries() {
var groups = {};
document.querySelectorAll('img[data-gallery]').forEach(function (img) {
var g = img.getAttribute('data-gallery');
if (!groups[g]) groups[g] = [];
groups[g].push({ src: img.getAttribute('data-src') || img.src,
caption: img.getAttribute('data-caption') || '',
el: img });
});
Object.keys(groups).forEach(function (g) {
var items = groups[g];
items.forEach(function (item, idx) {
item.el.style.cursor = 'zoom-in';
item.el.addEventListener('click', function (e) {
e.preventDefault();
open(items.map(function (x) { return { src: x.src, caption: x.caption }; }), idx);
});
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGalleries);
} else {
initGalleries();
}
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Dashboard — The Shooter's Network{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<p style="color:#555;margin-bottom:2rem;">
Welcome back, <strong>{{ current_user.display_name or current_user.email }}</strong>.
</p>
<div style="display:flex;gap:1.5rem;margin-bottom:2.5rem;flex-wrap:wrap;">
<a href="{{ url_for('sessions.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
+ New session
</a>
<a href="{{ url_for('equipment.new') }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
+ Add equipment
</a>
<a href="{{ url_for('analyze') }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
New analysis
</a>
</div>
<h2>Recent Analyses</h2>
{% if analyses %}
<table>
<thead>
<tr>
<th>Title</th>
<th>Date</th>
<th>Shots</th>
<th>Groups</th>
<th>Visibility</th>
</tr>
</thead>
<tbody>
{% for a in analyses %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('analyses.detail', analysis_id=a.id) }}'">
<td><a href="{{ url_for('analyses.detail', analysis_id=a.id) }}" style="color:inherit;text-decoration:none;">{{ a.title }}</a></td>
<td style="white-space:nowrap;color:#666;font-size:0.88rem;">{{ a.created_at.strftime('%d %b %Y') }}</td>
<td>{{ a.shot_count }}</td>
<td>{{ a.group_count }}</td>
<td style="color:{% if a.is_public %}#27ae60{% else %}#888{% endif %};font-size:0.88rem;">
{{ 'Public' if a.is_public else 'Private' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-top:1rem;">
No analyses yet. <a href="{{ url_for('analyze') }}">Upload a CSV file</a> to get started — it will be saved here automatically.
</p>
{% endif %}
<div style="display:flex;gap:2rem;margin-top:2.5rem;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">Equipment</div>
<a href="{{ url_for('equipment.index') }}">Manage your rifles, scopes &amp; gear &rarr;</a>
</div>
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">Sessions</div>
<a href="{{ url_for('sessions.index') }}">View your shooting sessions &rarr;</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}{{ item.name }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.2rem;">
<a href="{{ url_for('equipment.index') }}">Equipment</a> &rsaquo;
{{ categories.get(item.category, item.category).title() }}
</div>
<h1 style="margin:0;">{{ item.name }}</h1>
</div>
<div style="display:flex;gap:0.75rem;">
<a href="{{ url_for('equipment.edit', item_id=item.id) }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
Edit
</a>
<form method="post" action="{{ url_for('equipment.delete', item_id=item.id) }}"
onsubmit="return confirm('Delete {{ item.name }}?');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Delete
</button>
</form>
</div>
</div>
{% if item.photo_url %}
<div style="margin-bottom:1.5rem;">
<img src="{{ item.photo_url }}"
data-gallery="equipment-{{ item.id }}"
data-src="{{ item.photo_url }}"
data-caption="{{ item.name }}"
alt="{{ item.name }}"
style="max-width:480px;width:100%;border-radius:8px;display:block;margin-bottom:.6rem;">
<div style="display:flex;gap:.5rem;">
{% for label, deg in [('↺ Left', -90), ('↻ Right', 90), ('180°', 180)] %}
<form method="post" action="{{ url_for('equipment.rotate_photo_view', item_id=item.id) }}">
<input type="hidden" name="degrees" value="{{ deg }}">
<button type="submit"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
{{ label }}
</button>
</form>
{% endfor %}
</div>
</div>
{% endif %}
<table style="max-width:480px;">
<tbody>
<tr><td style="color:#888;width:140px;">Category</td><td>{{ categories.get(item.category, item.category).title() }}</td></tr>
{% if item.brand %}<tr><td style="color:#888;">Brand</td><td>{{ item.brand }}</td></tr>{% endif %}
{% if item.model %}<tr><td style="color:#888;">Model</td><td>{{ item.model }}</td></tr>{% endif %}
{% if item.category == 'scope' %}
{% if item.magnification %}<tr><td style="color:#888;">Magnification</td><td>{{ item.magnification }}</td></tr>{% endif %}
{% if item.reticle %}<tr><td style="color:#888;">Reticle</td><td>{{ item.reticle }}</td></tr>{% endif %}
{% if item.unit %}<tr><td style="color:#888;">Unit</td><td>{{ item.unit }}</td></tr>{% endif %}
{% else %}
{% if item.caliber %}<tr><td style="color:#888;">Caliber</td><td>{{ item.caliber }}</td></tr>{% endif %}
{% endif %}
{% if item.serial_number %}<tr><td style="color:#888;">Serial</td><td>{{ item.serial_number }}</td></tr>{% endif %}
<tr><td style="color:#888;">Added</td><td>{{ item.created_at.strftime('%d %b %Y') }}</td></tr>
</tbody>
</table>
{% if item.notes %}
<div style="margin-top:1.5rem;">
<h3>Notes</h3>
<p style="color:#555;white-space:pre-wrap;">{{ item.notes }}</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -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 %}
<h1>{{ 'Edit' if editing else 'Add equipment' }}</h1>
{% set f = prefill or item %}
<form method="post"
action="{{ url_for('equipment.edit', item_id=item.id) if editing else url_for('equipment.new') }}"
enctype="multipart/form-data"
style="max-width:520px;">
<div style="margin-bottom:1rem;">
<label class="field-label">Category *</label>
<select name="category" required style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
{% for key, label in categories %}
<option value="{{ key }}" {% if (f and f.category == key) or (not f and key == 'rifle') %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">Name *</label>
<input type="text" name="name" value="{{ f.name if f else '' }}" required
placeholder="e.g. Tikka T3x, Glock 17"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="field-label">Brand</label>
<input type="text" name="brand" value="{{ f.brand if f else '' }}"
placeholder="e.g. Tikka, Leupold"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="field-label">Model</label>
<input type="text" name="model" value="{{ f.model if f else '' }}"
placeholder="e.g. T3x, VX-3HD"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<div id="rifle-fields" style="margin-bottom:1rem;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div>
<label class="field-label">Caliber</label>
<input type="text" name="caliber" value="{{ f.caliber if f else '' }}"
placeholder="e.g. .308 Win, 6.5 CM"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
</div>
<div id="scope-fields" style="display:none;margin-bottom:1rem;">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;">
<div>
<label class="field-label">Magnification</label>
<input type="text" name="magnification" value="{{ f.magnification if f else '' }}"
placeholder="e.g. 3-15x50"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="field-label">Reticle</label>
<select name="reticle" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
<option value=""></option>
<option value="FFP" {% if f and f.reticle == 'FFP' %}selected{% endif %}>FFP (First Focal Plane)</option>
<option value="SFP" {% if f and f.reticle == 'SFP' %}selected{% endif %}>SFP (Second Focal Plane)</option>
</select>
</div>
<div>
<label class="field-label">Unit</label>
<select name="unit" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
<option value=""></option>
<option value="MOA" {% if f and f.unit == 'MOA' %}selected{% endif %}>MOA</option>
<option value="MRAD" {% if f and f.unit == 'MRAD' %}selected{% endif %}>MRAD</option>
</select>
</div>
</div>
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">Serial number</label>
<input type="text" name="serial_number" value="{{ f.serial_number if f else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">Notes</label>
<textarea name="notes" rows="3"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
</div>
<div style="margin-bottom:1.5rem;">
<label class="field-label">Photo</label>
{% if editing and item.photo_url %}
<div style="margin-bottom:0.5rem;">
<img src="{{ item.photo_url }}" alt="Current photo"
style="height:80px;border-radius:4px;object-fit:cover;">
<span style="font-size:0.82rem;color:#888;margin-left:0.5rem;">Upload a new one to replace it.</span>
</div>
{% endif %}
<input type="file" name="photo" accept="image/*"
style="font-size:0.92rem;">
</div>
<div style="display:flex;gap:1rem;align-items:center;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
{{ 'Save changes' if editing else 'Add equipment' }}
</button>
<a href="{{ url_for('equipment.detail', item_id=item.id) if editing else url_for('equipment.index') }}"
style="font-size:0.9rem;color:#666;">Cancel</a>
</div>
</form>
<style>.field-label { display:block; font-size:.88rem; font-weight:600; color:#444; margin-bottom:.3rem; }</style>
<script>
function toggleCategoryFields() {
var cat = document.querySelector('[name="category"]').value;
var isScope = cat === 'scope';
document.getElementById('scope-fields').style.display = isScope ? '' : 'none';
document.getElementById('rifle-fields').style.display = isScope ? 'none' : '';
}
document.querySelector('[name="category"]').addEventListener('change', toggleCategoryFields);
toggleCategoryFields(); // run on load
</script>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Equipment — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">My Equipment</h1>
<a href="{{ url_for('equipment.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
+ Add item
</a>
</div>
{% 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 %}
<h2>{{ cat_label }}s</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem;margin-bottom:2rem;">
{% for item in group %}
<div style="border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">
{% if item.photo_url %}
<img src="{{ item.photo_url }}" alt="{{ item.name }}"
style="width:100%;height:150px;object-fit:cover;display:block;">
{% else %}
<div style="width:100%;height:80px;background:#f0f4ff;display:flex;align-items:center;justify-content:center;font-size:2rem;color:#c0c8e0;">
{% if item.category == 'rifle' or item.category == 'handgun' %}🔫
{% elif item.category == 'scope' %}🔭
{% else %}🔩{% endif %}
</div>
{% endif %}
<div style="padding:0.9rem 1rem;">
<div style="font-weight:600;color:#1a1a2e;margin-bottom:0.2rem;">{{ item.name }}</div>
{% if item.brand or item.model %}
<div style="font-size:0.85rem;color:#666;margin-bottom:0.3rem;">
{{ [item.brand, item.model] | select | join(' · ') }}
</div>
{% endif %}
{% if item.caliber %}
<div style="font-size:0.82rem;color:#888;margin-bottom:0.6rem;">{{ item.caliber }}</div>
{% endif %}
<div style="display:flex;gap:0.75rem;margin-top:0.5rem;">
<a href="{{ url_for('equipment.detail', item_id=item.id) }}" style="font-size:0.85rem;">View</a>
<a href="{{ url_for('equipment.edit', item_id=item.id) }}" style="font-size:0.85rem;">Edit</a>
<form method="post" action="{{ url_for('equipment.delete', item_id=item.id) }}" style="display:inline;"
onsubmit="return confirm('Delete {{ item.name }}?');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">Delete</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% else %}
<div style="text-align:center;padding:3rem 0;color:#888;">
<div style="font-size:3rem;margin-bottom:1rem;">🔫</div>
<p style="margin-bottom:1rem;">No equipment yet.</p>
<a href="{{ url_for('equipment.new') }}">Add your first item</a>
</div>
{% endif %}
{% endblock %}

157
templates/index.html Normal file
View File

@@ -0,0 +1,157 @@
{% extends "base.html" %}
{% block title %}The Shooter's Network — Track, analyze, share{% endblock %}
{% block body %}
<nav class="nav">
<a href="/" class="nav-brand">The Shooter's Network</a>
<div class="nav-links">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}">New Analysis</a>
<a href="{{ url_for('equipment.index') }}">Equipment</a>
<a href="{{ url_for('sessions.index') }}">Sessions</a>
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
{% endif %}
</div>
<div class="nav-right">
{% if current_user.is_authenticated %}
<div class="nav-dropdown" id="userDropdown">
<button class="nav-user-btn" onclick="toggleDropdown(event)">
{% set av = current_user.effective_avatar_url %}
{% if av %}<img src="{{ av }}" class="nav-avatar" alt="">
{% else %}<span style="font-size:1.1rem;line-height:1;">&#128100;</span>{% endif %}
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu">
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;Profile</a>
<hr>
<form method="post" action="{{ url_for('auth.logout') }}">
<button type="submit">&#8594;&ensp;Logout</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">Login</a>
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
Join free
</a>
{% endif %}
</div>
</nav>
<script>
function toggleDropdown(e) {
e.stopPropagation();
document.getElementById('userDropdown').classList.toggle('open');
}
document.addEventListener('click', function() {
var d = document.getElementById('userDropdown');
if (d) d.classList.remove('open');
});
</script>
<!-- ── Hero ── -->
<section style="background:#1a1a2e;color:#fff;padding:4rem 1.5rem;text-align:center;">
<h1 style="font-size:2.4rem;font-weight:800;color:#fff;margin-bottom:0.75rem;letter-spacing:-0.02em;">
The Shooter's Network
</h1>
<p style="font-size:1.15rem;color:#a0aec0;max-width:560px;margin:0 auto 2rem;">
Analyze your ballistic data, track every session, manage your equipment,
and share your performance with the community.
</p>
<div style="display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}"
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
New Analysis
</a>
<a href="{{ url_for('sessions.new') }}"
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
Log a Session
</a>
{% else %}
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
Get started — free
</a>
<a href="{{ url_for('analyze') }}"
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
Try without account
</a>
{% endif %}
</div>
</section>
<!-- ── Features ── -->
<section style="background:#f8f9fb;padding:2.5rem 1.5rem;">
<div style="max-width:900px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:1.25rem;">
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">📊</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Ballistic Analysis</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports.</p>
</div>
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🎯</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Session Tracking</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place.</p>
</div>
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🤝</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Community Feed</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">Share your public sessions and see what other shooters are achieving on the range.</p>
</div>
</div>
</section>
<!-- ── Flash messages ── -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div style="max-width:960px;margin:1rem auto;padding:0 1.5rem;">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- ── Public sessions feed ── -->
<section style="padding:2.5rem 1.5rem 3rem;">
<div style="max-width:960px;margin:0 auto;">
<h2 style="font-size:1.3rem;color:#1a1a2e;margin-bottom:1.25rem;border-bottom:2px solid #e0e0e0;padding-bottom:.4rem;">
Latest sessions
</h2>
{% if public_sessions %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1.1rem;">
{% for s in public_sessions %}
<a href="{{ url_for('sessions.detail', session_id=s.id) }}"
style="display:block;background:#fff;border:1px solid #e8e8e8;border-radius:8px;padding:1.1rem 1.25rem;text-decoration:none;color:inherit;">
<div style="display:flex;align-items:center;gap:0.55rem;margin-bottom:0.65rem;">
{% if s.user.avatar_url %}
<img src="{{ s.user.avatar_url }}" style="width:26px;height:26px;border-radius:50%;object-fit:cover;" alt="">
{% else %}
<div style="width:26px;height:26px;border-radius:50%;background:#e0e4f0;display:flex;align-items:center;justify-content:center;font-size:0.72rem;color:#666;font-weight:700;">
{{ (s.user.display_name or s.user.email)[0].upper() }}
</div>
{% endif %}
<span style="font-size:0.83rem;color:#666;">{{ s.user.display_name or s.user.email.split('@')[0] }}</span>
</div>
<div style="font-weight:600;color:#1a1a2e;margin-bottom:0.35rem;font-size:0.95rem;">{{ s.label }}</div>
<div style="font-size:0.81rem;color:#888;display:flex;flex-wrap:wrap;gap:.3rem .65rem;">
<span>{{ s.session_date.strftime('%d %b %Y') }}</span>
{% if s.location_name %}<span>📍 {{ s.location_name }}</span>{% endif %}
{% if s.distance_m %}<span>{{ s.distance_m }} m</span>{% endif %}
{% if s.weather_cond %}<span>{{ s.weather_cond.replace('_', ' ').title() }}</span>{% endif %}
{% if s.weather_temp_c is not none %}<span>{{ s.weather_temp_c }}°C</span>{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<p style="color:#aaa;text-align:center;padding:3rem 0;">
No public sessions yet. Be the first to share one!
</p>
{% endif %}
</div>
</section>
{% endblock %}

View File

@@ -2,12 +2,16 @@
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">Analysis Results</h1>
<div style="display:flex;gap:0.75rem;align-items:center;">
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
<a href="/">&larr; Upload another file</a>
{% if saved_analysis_id %}
<a href="{{ url_for('analyses.detail', analysis_id=saved_analysis_id) }}"
style="font-size:0.9rem;color:#1f77b4;">View saved report &rarr;</a>
{% endif %}
<a href="data:application/pdf;base64,{{ pdf_b64 }}"
download="ballistic_report.pdf"
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
Download PDF report
&#8659; Download PDF report
</a>
</div>
</div>

View File

@@ -0,0 +1,619 @@
{% extends "base.html" %}
{% block title %}Annotate photo — {{ session.label }}{% endblock %}
{% block content %}
<div style="margin-bottom:1rem;">
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
<a href="{{ url_for('sessions.index') }}">Sessions</a> &rsaquo;
<a href="{{ url_for('sessions.detail', session_id=session.id) }}">{{ session.label }}</a> &rsaquo;
Annotate
</div>
<h1 style="margin:0;">{{ photo.caption or 'Photo annotation' }}</h1>
</div>
<div style="display:flex;gap:1.5rem;align-items:flex-start;">
{# ── Canvas ── #}
<div style="flex:1;min-width:0;">
<canvas id="ann-canvas"
style="width:100%;border-radius:6px;cursor:crosshair;display:block;
box-shadow:0 2px 8px rgba(0,0,0,.18);background:#111;"></canvas>
</div>
{# ── Control panel ── #}
<div style="width:260px;flex-shrink:0;">
{# Step indicator #}
<div style="margin-bottom:1.25rem;">
<div id="si-0" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Reference line
</div>
<div id="si-1" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Point of Aim
</div>
<div id="si-2" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Points of Impact
</div>
<div id="si-3" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Results
</div>
</div>
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Shooting distance (always visible) #}
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Shooting distance</label>
<div style="display:flex;gap:.4rem;">
<input type="number" id="shoot-dist" min="1" step="1" placeholder="100"
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<select id="shoot-unit"
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<option value="m">m</option>
<option value="yd">yd</option>
</select>
</div>
</div>
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Step 0: Reference line #}
<div id="panel-0" class="step-panel">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click <strong>two points</strong> on the image to draw a reference line — e.g. a known grid square or target diameter.
</p>
<div id="ref-dist-row" style="display:none;margin-bottom:.75rem;">
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Real distance</label>
<div style="display:flex;gap:.4rem;">
<input type="number" id="ref-dist" min="0.1" step="0.1" placeholder="50"
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<select id="ref-unit"
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="in">in</option>
</select>
</div>
</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-next-0" disabled onclick="goStep(1)">Next →</button>
<button class="btn-ghost" onclick="resetRef()">Reset</button>
</div>
</div>
{# Step 1: POA #}
<div id="panel-1" class="step-panel" style="display:none;">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click your <strong>Point of Aim</strong> — the center of the target or wherever you were aiming.
</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-ghost" onclick="goStep(0)">← Back</button>
<button class="btn-ghost" onclick="poa=null;redraw();">Reset POA</button>
</div>
</div>
{# Step 2: POIs #}
<div id="panel-2" class="step-panel" style="display:none;">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click each <strong>bullet hole</strong>. Click an existing point to remove it.
</p>
<p id="poi-count" style="font-size:0.88rem;font-weight:600;color:#1a1a2e;margin-bottom:.75rem;">0 impacts</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-compute" disabled onclick="compute()">Compute →</button>
<button class="btn-ghost" onclick="goStep(1)">← Back</button>
<button class="btn-ghost" onclick="undoPoi()">Undo last</button>
</div>
</div>
{# Step 3: Results #}
<div id="panel-3" class="step-panel" style="display:none;">
<div id="results-box" style="margin-bottom:1rem;"></div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-save" onclick="saveAnnotations()">Save</button>
<button class="btn-ghost" onclick="goStep(2)">← Edit</button>
</div>
<div id="save-status" style="font-size:0.82rem;margin-top:.5rem;"></div>
</div>
</div>{# end control panel #}
</div>
<style>
.btn-primary {
background: #1a1a2e; color: #fff; border: none; border-radius: 4px;
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
}
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
.btn-ghost {
background: #f0f4ff; color: #1a1a2e; border: 1px solid #c8d4f0; border-radius: 4px;
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
}
.stat-row { display: flex; justify-content: space-between; font-size: 0.85rem;
padding: .3rem 0; border-bottom: 1px solid #f0f0f0; }
.stat-label { color: #666; }
.stat-val { font-weight: 600; color: #1a1a2e; }
.stat-section { font-size: 0.78rem; text-transform: uppercase; letter-spacing: .05em;
color: #888; margin: .75rem 0 .35rem; }
</style>
<script>
const PHOTO_URL = {{ photo.photo_url | tojson }};
const SAVE_URL = {{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) | tojson }};
const EXISTING = {{ (photo.annotations or {}) | tojson }};
// ── State ──────────────────────────────────────────────────────────────────
let step = 0;
// Reference: coords in natural image pixels (fractions stored on save)
let refP1 = null, refP2 = null, refMm = null;
let refClickStage = 0; // 0=waiting p1, 1=waiting p2, 2=done
let poa = null; // natural px
let pois = []; // natural px array
let stats = null;
let mousePos = null; // canvas px, for rubber-band
// ── Canvas / image ─────────────────────────────────────────────────────────
const canvas = document.getElementById('ann-canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = PHOTO_URL;
img.onload = () => { resizeCanvas(); loadExisting(); redraw(); };
function resizeCanvas() {
// Canvas internal size = natural image size (so all coords stay in nat px)
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
}
// Convert canvas mouse event → natural image pixels
function evToNat(e) {
const r = canvas.getBoundingClientRect();
const sx = img.naturalWidth / r.width;
const sy = img.naturalHeight / r.height;
return { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
}
// ── Mouse events ───────────────────────────────────────────────────────────
canvas.addEventListener('mousemove', e => {
if (step === 0 && refClickStage === 1) {
const r = canvas.getBoundingClientRect();
const sx = img.naturalWidth / r.width;
const sy = img.naturalHeight / r.height;
mousePos = { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
redraw();
}
});
canvas.addEventListener('click', e => {
const p = evToNat(e);
if (step === 0) handleRefClick(p);
else if (step === 1) handlePoaClick(p);
else if (step === 2) handlePoiClick(p);
});
canvas.addEventListener('mouseleave', () => { mousePos = null; redraw(); });
// ── Step 0: Reference line ─────────────────────────────────────────────────
function handleRefClick(p) {
if (refClickStage === 0) {
refP1 = p; refP2 = null; refClickStage = 1;
canvas.style.cursor = 'crosshair';
redraw();
} else if (refClickStage === 1) {
refP2 = p; refClickStage = 2; mousePos = null;
document.getElementById('ref-dist-row').style.display = '';
redraw();
updateNextBtn0();
}
}
function resetRef() {
refP1 = refP2 = null; refClickStage = 0; refMm = null; mousePos = null;
document.getElementById('ref-dist-row').style.display = 'none';
document.getElementById('ref-dist').value = '';
updateNextBtn0(); redraw();
}
document.getElementById('ref-dist').addEventListener('input', updateNextBtn0);
function updateNextBtn0() {
const v = parseFloat(document.getElementById('ref-dist').value);
document.getElementById('btn-next-0').disabled = !(refP1 && refP2 && v > 0);
}
// ── Step 1: POA ────────────────────────────────────────────────────────────
function handlePoaClick(p) {
poa = p; redraw();
// Auto-advance to step 2
goStep(2);
}
// ── Step 2: POIs ───────────────────────────────────────────────────────────
const HIT_RADIUS = 14; // canvas display px
function handlePoiClick(p) {
// Check if clicking near an existing POI to remove it
const r = canvas.getBoundingClientRect();
const dispScale = r.width / img.naturalWidth; // nat px → display px
for (let i = pois.length - 1; i >= 0; i--) {
const dx = (pois[i].x - p.x) * dispScale;
const dy = (pois[i].y - p.y) * dispScale;
if (Math.sqrt(dx*dx + dy*dy) < HIT_RADIUS) {
pois.splice(i, 1);
updatePoiUI(); redraw(); return;
}
}
pois.push(p);
updatePoiUI(); redraw();
}
function undoPoi() { if (pois.length) { pois.pop(); updatePoiUI(); redraw(); } }
function updatePoiUI() {
document.getElementById('poi-count').textContent = pois.length + ' impact' + (pois.length !== 1 ? 's' : '');
document.getElementById('btn-compute').disabled = pois.length < 1;
}
// ── Step navigation ────────────────────────────────────────────────────────
function goStep(n) {
// Validate before advancing
if (n === 1) {
const distVal = parseFloat(document.getElementById('ref-dist').value);
const unitSel = document.getElementById('ref-unit').value;
if (!(refP1 && refP2 && distVal > 0)) { alert('Please draw the reference line and enter its distance.'); return; }
refMm = toMm(distVal, unitSel);
}
step = n;
updateStepUI(); redraw();
}
function updateStepUI() {
// Panels
for (let i = 0; i <= 3; i++) {
document.getElementById('panel-' + i).style.display = (i === step) ? '' : 'none';
}
// Step indicators
const labels = ['Reference line', 'Point of Aim', 'Points of Impact', 'Results'];
for (let i = 0; i <= 3; i++) {
const el = document.getElementById('si-' + i);
const num = el.querySelector('.si-num');
if (i < step) {
el.style.background = '#e8f5e9'; el.style.color = '#27ae60';
num.style.background = '#27ae60'; num.style.color = '#fff';
num.textContent = '✓';
} else if (i === step) {
el.style.background = '#f0f4ff'; el.style.color = '#1a1a2e';
num.style.background = '#1a1a2e'; num.style.color = '#fff';
num.textContent = i + 1;
} else {
el.style.background = ''; el.style.color = '#aaa';
num.style.background = '#e0e0e0'; num.style.color = '#888';
num.textContent = i + 1;
}
}
// Cursor
canvas.style.cursor = (step <= 2) ? 'crosshair' : 'default';
}
// ── Computation ────────────────────────────────────────────────────────────
function dist2(a, b) { return Math.sqrt((a.x-b.x)**2 + (a.y-b.y)**2); }
function toMm(val, unit) {
if (unit === 'cm') return val * 10;
if (unit === 'in') return val * 25.4;
return val;
}
function toMoa(sizeMm, distM) {
// true angular MOA
return Math.atan(sizeMm / (distM * 1000)) * (180 / Math.PI * 60);
}
function compute() {
const shootDistEl = document.getElementById('shoot-dist');
const shootUnitEl = document.getElementById('shoot-unit');
let distM = parseFloat(shootDistEl.value);
if (isNaN(distM) || distM <= 0) { alert('Enter a valid shooting distance first.'); shootDistEl.focus(); return; }
if (shootUnitEl.value === 'yd') distM *= 0.9144; // yards → metres
// Scale factor: pixels per mm
const refPxDist = dist2(refP1, refP2);
const pxPerMm = refPxDist / refMm;
// Convert POIs to mm relative to POA
const poisMm = pois.map(p => ({
x: (p.x - poa.x) / pxPerMm,
y: (p.y - poa.y) / pxPerMm,
}));
// Group centre
const cx = poisMm.reduce((s, p) => s + p.x, 0) / poisMm.length;
const cy = poisMm.reduce((s, p) => s + p.y, 0) / poisMm.length;
// Extreme Spread: max pairwise distance
let es = 0, esI = 0, esJ = 0;
for (let i = 0; i < poisMm.length; i++) {
for (let j = i + 1; j < poisMm.length; j++) {
const d = dist2(poisMm[i], poisMm[j]);
if (d > es) { es = d; esI = i; esJ = j; }
}
}
// Mean Radius: average distance from group centre
const mr = poisMm.reduce((s, p) => s + dist2(p, {x:cx,y:cy}), 0) / poisMm.length;
// POA → centre
const poaToCenter = dist2({x:0,y:0}, {x:cx,y:cy});
stats = {
shot_count: pois.length,
group_size_mm: es,
group_size_moa: distM > 0 ? toMoa(es, distM) : null,
mean_radius_mm: mr,
mean_radius_moa: distM > 0 ? toMoa(mr, distM) : null,
center_x_mm: cx, // + = right, - = left
center_y_mm: cy, // + = down, - = up
center_dist_mm: poaToCenter,
center_dist_moa: distM > 0 ? toMoa(poaToCenter, distM) : null,
shooting_distance_m: distM,
es_poi_indices: [esI, esJ],
};
renderResults();
goStep(3);
redraw();
}
function renderResults() {
if (!stats) return;
const f1 = v => (v != null ? v.toFixed(1) : '—');
const f2 = v => (v != null ? v.toFixed(2) : '—');
const sign = v => v >= 0 ? '+' : '';
const dir = (mm, axis) => {
if (axis === 'x') return mm > 0 ? 'right' : mm < 0 ? 'left' : 'center';
return mm > 0 ? 'low' : mm < 0 ? 'high' : 'center';
};
document.getElementById('results-box').innerHTML = `
<div class="stat-section">Group size</div>
<div class="stat-row"><span class="stat-label">Extreme Spread</span>
<span class="stat-val">${f2(stats.group_size_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label"></span>
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.group_size_mm)} mm</span></div>
<div class="stat-section">Precision</div>
<div class="stat-row"><span class="stat-label">Mean Radius</span>
<span class="stat-val">${f2(stats.mean_radius_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label"></span>
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.mean_radius_mm)} mm</span></div>
<div class="stat-section">Center vs POA</div>
<div class="stat-row"><span class="stat-label">Distance</span>
<span class="stat-val">${f2(stats.center_dist_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label">Horiz.</span>
<span class="stat-val">${f1(Math.abs(stats.center_x_mm))} mm ${dir(stats.center_x_mm,'x')}</span></div>
<div class="stat-row"><span class="stat-label">Vert.</span>
<span class="stat-val">${f1(Math.abs(stats.center_y_mm))} mm ${dir(stats.center_y_mm,'y')}</span></div>
<div class="stat-section">Info</div>
<div class="stat-row"><span class="stat-label">Shots</span>
<span class="stat-val">${stats.shot_count}</span></div>
<div class="stat-row"><span class="stat-label">@ distance</span>
<span class="stat-val">${stats.shooting_distance_m.toFixed(0)} m</span></div>
`;
}
// ── Drawing ────────────────────────────────────────────────────────────────
const COLORS = {
ref: '#2196f3',
poa: '#e53935',
poi: '#1565c0',
center: '#ff9800',
es: '#9c27b0',
mr: '#00897b',
};
function lineW(px) {
// px in display pixels → natural pixels
const r = canvas.getBoundingClientRect();
return px * (img.naturalWidth / r.width);
}
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const lw = lineW(2);
const dotR = lineW(7);
// Reference line
if (refP1) {
const p2 = refClickStage === 1 && mousePos ? mousePos : refP2;
if (p2) {
ctx.save();
ctx.setLineDash([lineW(8), lineW(5)]);
ctx.strokeStyle = COLORS.ref; ctx.lineWidth = lw;
ctx.beginPath(); ctx.moveTo(refP1.x, refP1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
ctx.setLineDash([]);
// Endpoints
drawDot(refP1, dotR * 0.7, COLORS.ref);
if (refP2) {
drawDot(refP2, dotR * 0.7, COLORS.ref);
// Label
const mid = { x: (refP1.x + refP2.x) / 2, y: (refP1.y + refP2.y) / 2 };
drawLabel(mid, refMm ? refMm.toFixed(0) + ' mm' : '?', COLORS.ref, lineW(12));
}
ctx.restore();
} else {
drawDot(refP1, dotR * 0.7, COLORS.ref);
}
}
// POA
if (poa) {
const r = dotR * 1.3;
ctx.save();
ctx.strokeStyle = COLORS.poa; ctx.lineWidth = lw * 1.5;
// Circle
ctx.beginPath(); ctx.arc(poa.x, poa.y, r, 0, Math.PI * 2); ctx.stroke();
// Crosshair
ctx.beginPath();
ctx.moveTo(poa.x - r * 1.6, poa.y); ctx.lineTo(poa.x - r * 0.4, poa.y);
ctx.moveTo(poa.x + r * 0.4, poa.y); ctx.lineTo(poa.x + r * 1.6, poa.y);
ctx.moveTo(poa.x, poa.y - r * 1.6); ctx.lineTo(poa.x, poa.y - r * 0.4);
ctx.moveTo(poa.x, poa.y + r * 0.4); ctx.lineTo(poa.x, poa.y + r * 1.6);
ctx.stroke();
ctx.restore();
}
// Group overlay (if computed)
if (stats && poa) {
const pxPerMm = dist2(refP1, refP2) / refMm;
const cx = poa.x + stats.center_x_mm * pxPerMm;
const cy = poa.y + stats.center_y_mm * pxPerMm;
// Mean radius circle
const mrPx = stats.mean_radius_mm * pxPerMm;
ctx.save();
ctx.setLineDash([lineW(6), lineW(4)]);
ctx.strokeStyle = COLORS.mr; ctx.lineWidth = lw;
ctx.beginPath(); ctx.arc(cx, cy, mrPx, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// ES line between furthest pair
if (stats.shot_count >= 2) {
const [ei, ej] = stats.es_poi_indices;
ctx.save();
ctx.strokeStyle = COLORS.es; ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(pois[ei].x, pois[ei].y);
ctx.lineTo(pois[ej].x, pois[ej].y);
ctx.stroke();
ctx.restore();
}
// Group centre
drawDot({x:cx,y:cy}, dotR * 0.8, COLORS.center);
// Line POA → centre
if (dist2(poa, {x:cx,y:cy}) > dotR) {
ctx.save();
ctx.strokeStyle = COLORS.center; ctx.lineWidth = lw * 0.7;
ctx.setLineDash([lineW(4), lineW(3)]);
ctx.beginPath(); ctx.moveTo(poa.x, poa.y); ctx.lineTo(cx, cy); ctx.stroke();
ctx.restore();
}
}
// POIs
pois.forEach((p, i) => {
drawDot(p, dotR, COLORS.poi);
drawLabel(p, String(i + 1), '#fff', dotR * 0.85);
});
}
function drawDot(p, r, color) {
ctx.save();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawLabel(p, text, color, size) {
ctx.save();
ctx.fillStyle = color;
ctx.font = `bold ${size}px system-ui,sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, p.x, p.y);
ctx.restore();
}
// ── Save ───────────────────────────────────────────────────────────────────
async function saveAnnotations() {
const btn = document.getElementById('btn-save');
const status = document.getElementById('save-status');
btn.disabled = true;
status.textContent = 'Saving…';
const refDistVal = parseFloat(document.getElementById('ref-dist').value);
const refUnitVal = document.getElementById('ref-unit').value;
// Store coords as fractions of natural image size for portability
function toFrac(p) { return { x: p.x / img.naturalWidth, y: p.y / img.naturalHeight }; }
const payload = {
ref: { p1: toFrac(refP1), p2: toFrac(refP2), dist_value: refDistVal, dist_unit: refUnitVal, dist_mm: refMm },
poa: toFrac(poa),
pois: pois.map(toFrac),
shooting_distance_m: stats.shooting_distance_m,
stats: stats,
};
try {
const resp = await fetch(SAVE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
status.style.color = '#27ae60';
status.textContent = 'Saved!';
} else {
throw new Error('Server error');
}
} catch {
status.style.color = '#e53935';
status.textContent = 'Save failed.';
btn.disabled = false;
}
}
// ── Load existing annotations ──────────────────────────────────────────────
function loadExisting() {
if (!EXISTING || !EXISTING.ref) return;
const W = img.naturalWidth, H = img.naturalHeight;
function fromFrac(f) { return { x: f.x * W, y: f.y * H }; }
refP1 = fromFrac(EXISTING.ref.p1);
refP2 = fromFrac(EXISTING.ref.p2);
refMm = EXISTING.ref.dist_mm;
refClickStage = 2;
document.getElementById('ref-dist').value = EXISTING.ref.dist_value || '';
document.getElementById('ref-unit').value = EXISTING.ref.dist_unit || 'mm';
document.getElementById('ref-dist-row').style.display = '';
updateNextBtn0();
if (EXISTING.poa) poa = fromFrac(EXISTING.poa);
if (EXISTING.pois) pois = EXISTING.pois.map(fromFrac);
if (EXISTING.shooting_distance_m) {
document.getElementById('shoot-dist').value = EXISTING.shooting_distance_m.toFixed(0);
document.getElementById('shoot-unit').value = 'm';
}
if (EXISTING.stats) {
stats = EXISTING.stats;
renderResults();
goStep(3);
} else if (pois.length > 0) {
goStep(2); updatePoiUI();
} else if (poa) {
goStep(2);
} else {
goStep(0);
}
redraw();
}
// ── Init ───────────────────────────────────────────────────────────────────
updateStepUI();
updatePoiUI();
</script>
{% endblock %}

View File

@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}{{ session.label }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.2rem;">
{% if is_owner %}<a href="{{ url_for('sessions.index') }}">Sessions</a> &rsaquo; {% endif %}
{{ session.session_date.strftime('%d %b %Y') }}
{% if session.is_public %}
<span style="background:#e8f5e9;color:#27ae60;font-size:0.75rem;padding:.1rem .45rem;border-radius:3px;margin-left:.4rem;">Public</span>
{% endif %}
</div>
<h1 style="margin:0;">{{ session.label }}</h1>
<div style="font-size:0.88rem;color:#666;margin-top:.4rem;">
by {{ session.user.display_name or session.user.email.split('@')[0] }}
</div>
</div>
{% if is_owner %}
<div style="display:flex;gap:.75rem;">
<a href="{{ url_for('sessions.edit', session_id=session.id) }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
Edit
</a>
<form method="post" action="{{ url_for('sessions.delete', session_id=session.id) }}"
onsubmit="return confirm('Delete this session? This cannot be undone.');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Delete
</button>
</form>
</div>
{% endif %}
</div>
{# ---- Stats cards ---- #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;margin-bottom:2rem;">
{% if session.location_name or session.distance_m %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Location</div>
{% if session.location_name %}<div style="font-weight:600;">{{ session.location_name }}</div>{% endif %}
{% if session.distance_m %}<div style="color:#555;font-size:0.9rem;">{{ session.distance_m }} m</div>{% endif %}
</div>
{% endif %}
{% if session.weather_cond or session.weather_temp_c is not none or session.weather_wind_kph is not none %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Weather</div>
{% if session.weather_cond %}<div style="font-weight:600;">{{ session.weather_cond.replace('_',' ').title() }}</div>{% endif %}
<div style="color:#555;font-size:0.9rem;">
{% if session.weather_temp_c is not none %}{{ session.weather_temp_c }}°C{% endif %}
{% if session.weather_wind_kph is not none %}&nbsp; {{ session.weather_wind_kph }} km/h wind{% endif %}
</div>
</div>
{% endif %}
{% if session.rifle %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Rifle / Handgun</div>
<div style="font-weight:600;">{{ session.rifle.name }}</div>
{% if session.rifle.caliber %}<div style="color:#555;font-size:0.9rem;">{{ session.rifle.caliber }}</div>{% endif %}
</div>
{% endif %}
{% if session.scope %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Scope</div>
<div style="font-weight:600;">{{ session.scope.name }}</div>
</div>
{% endif %}
{% if session.ammo_brand or session.ammo_weight_gr is not none %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Ammo</div>
{% if session.ammo_brand %}<div style="font-weight:600;">{{ session.ammo_brand }}</div>{% endif %}
<div style="color:#555;font-size:0.9rem;">
{% if session.ammo_weight_gr is not none %}{{ session.ammo_weight_gr }} gr{% endif %}
{% if session.ammo_lot %}&nbsp; lot {{ session.ammo_lot }}{% endif %}
</div>
</div>
{% endif %}
</div>
{% if session.notes %}
<h2>Notes</h2>
<p style="color:#555;white-space:pre-wrap;">{{ session.notes }}</p>
{% endif %}
{# ---- Photos ---- #}
{% if session.photos or is_owner %}
<h2>Photos</h2>
{% if session.photos %}
<div style="display:flex;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
{% for photo in session.photos %}
<div>
<div style="position:relative;display:inline-block;">
<img src="{{ photo.photo_url }}"
data-gallery="session-{{ session.id }}"
data-src="{{ photo.photo_url }}"
data-caption="{{ photo.caption or '' }}"
alt="{{ photo.caption or '' }}"
style="height:180px;width:auto;border-radius:6px;object-fit:cover;display:block;">
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.delete_photo', session_id=session.id, photo_id=photo.id) }}"
onsubmit="return confirm('Delete this photo?');"
style="position:absolute;top:4px;right:4px;">
<button type="submit"
style="background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:3px;padding:.2rem .45rem;font-size:0.8rem;cursor:pointer;line-height:1.2;">
&#x2715;
</button>
</form>
{% endif %}
</div>
{% if photo.annotations and photo.annotations.stats %}
{% set s = photo.annotations.stats %}
<div style="font-size:0.78rem;background:#f0f4ff;color:#1a1a2e;padding:.2rem .45rem;border-radius:3px;margin-top:.3rem;font-weight:600;">
{{ s.shot_count }} shots &middot; {{ '%.2f'|format(s.group_size_moa) }} MOA ES
</div>
{% endif %}
{% if photo.caption %}
<div style="font-size:0.78rem;color:#666;margin-top:.25rem;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ photo.caption }}
</div>
{% endif %}
{% if is_owner %}
<div style="display:flex;gap:.35rem;margin-top:.35rem;">
{% for label, deg in [('↺', -90), ('↻', 90), ('180°', 180)] %}
<form method="post" action="{{ url_for('sessions.rotate_photo_view', session_id=session.id, photo_id=photo.id) }}">
<input type="hidden" name="degrees" value="{{ deg }}">
<button type="submit"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.2rem .55rem;font-size:0.8rem;cursor:pointer;">
{{ label }}
</button>
</form>
{% endfor %}
</div>
<a href="{{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) }}"
style="display:inline-block;margin-top:.4rem;padding:.3rem .75rem;border-radius:4px;font-size:0.82rem;text-decoration:none;
{% if photo.annotations and photo.annotations.stats %}
background:#e8f5e9;color:#27ae60;border:1px solid #a5d6a7;
{% else %}
background:#1a1a2e;color:#fff;border:1px solid #1a1a2e;
{% endif %}">
{% if photo.annotations and photo.annotations.stats %}&#10003;{% else %}&#9654;{% endif %}
Measure group
</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.upload_photo', session_id=session.id) }}"
enctype="multipart/form-data"
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Add photo</label>
<input type="file" name="photo" accept="image/*" required style="font-size:0.9rem;">
</div>
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Caption (optional)</label>
<input type="text" name="caption" placeholder="e.g. 300 m target"
style="padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Upload
</button>
</form>
{% endif %}
{% endif %}
{# ---- Analyses ---- #}
<h2>Analyses{% if analyses %} ({{ analyses|length }}){% endif %}</h2>
{% if analyses %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>Title</th><th>Date</th><th>Shots</th><th>Groups</th><th>Mean speed</th></tr>
</thead>
<tbody>
{% for a in analyses %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('analyses.detail', analysis_id=a.id) }}'">
<td><a href="{{ url_for('analyses.detail', analysis_id=a.id) }}" style="color:inherit;text-decoration:none;">{{ a.title }}</a></td>
<td style="color:#666;font-size:0.88rem;">{{ a.created_at.strftime('%d %b %Y') }}</td>
<td>{{ a.shot_count }}</td>
<td>{{ a.group_count }}</td>
<td>{{ "%.2f"|format(a.overall_stats.mean_speed) }} m/s</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">No analyses yet.</p>
{% endif %}
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.upload_csv', session_id=session.id) }}"
enctype="multipart/form-data"
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Upload chronograph CSV</label>
<input type="file" name="csv_file" accept=".csv,text/csv" required style="font-size:0.9rem;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Analyse &amp; link
</button>
</form>
{% endif %}
{% endblock %}

View File

@@ -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 %}
<h1>{{ 'Edit session' if editing else 'Log a session' }}</h1>
{% set f = prefill or session %}
<form method="post"
action="{{ url_for('sessions.edit', session_id=session.id) if editing else url_for('sessions.new') }}"
style="max-width:580px;">
<h2>Basic info</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Date *</label>
<input type="date" name="session_date" required
value="{{ (f.session_date.isoformat() if f.session_date else '') if f else (today or '') }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Distance (m)</label>
<input type="number" name="distance_m" min="1" max="5000"
value="{{ f.distance_m if f and f.distance_m else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<div style="margin-bottom:1rem;">
<label class="fl">Location</label>
<input type="text" name="location_name" value="{{ f.location_name if f else '' }}"
placeholder="e.g. Range name, city"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<h2>Weather</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Condition</label>
<select name="weather_cond"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
{% for val, label in weather_conditions %}
<option value="{{ val }}" {% if f and f.weather_cond == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="fl">Temp (°C)</label>
<input type="number" name="weather_temp_c" step="0.1"
value="{{ f.weather_temp_c if f and f.weather_temp_c is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Wind (km/h)</label>
<input type="number" name="weather_wind_kph" step="0.1" min="0"
value="{{ f.weather_wind_kph if f and f.weather_wind_kph is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<h2>Equipment &amp; Ammo</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Rifle / Handgun</label>
<select name="rifle_id"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
<option value="">— none —</option>
{% for r in rifles %}
<option value="{{ r.id }}" {% if f and f.rifle_id == r.id %}selected{% endif %}>
{{ r.name }}{% if r.caliber %} ({{ r.caliber }}){% endif %}
</option>
{% endfor %}
</select>
{% if not rifles %}
<div style="font-size:0.78rem;color:#aaa;margin-top:.25rem;">
<a href="{{ url_for('equipment.new') }}">Add a rifle first</a>
</div>
{% endif %}
</div>
<div>
<label class="fl">Scope</label>
<select name="scope_id"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
<option value="">— none —</option>
{% for sc in scopes %}
<option value="{{ sc.id }}" {% if f and f.scope_id == sc.id %}selected{% endif %}>{{ sc.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Ammo brand</label>
<input type="text" name="ammo_brand" value="{{ f.ammo_brand if f else '' }}"
placeholder="e.g. Lapua, Federal"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Bullet weight (gr)</label>
<input type="number" name="ammo_weight_gr" step="0.1" min="0"
value="{{ f.ammo_weight_gr if f and f.ammo_weight_gr is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Lot number</label>
<input type="text" name="ammo_lot" value="{{ f.ammo_lot if f else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<h2>Notes &amp; Visibility</h2>
<div style="margin-bottom:1rem;">
<label class="fl">Notes</label>
<textarea name="notes" rows="4"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
</div>
<div style="margin-bottom:1.5rem;">
<label style="display:flex;align-items:center;gap:0.6rem;cursor:pointer;font-size:0.95rem;">
<input type="checkbox" name="is_public" value="1"
{% if f and f.is_public %}checked{% endif %}
style="width:16px;height:16px;">
Make this session public (visible in the community feed)
</label>
</div>
<div style="display:flex;gap:1rem;align-items:center;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
{{ 'Save changes' if editing else 'Log session' }}
</button>
<a href="{{ url_for('sessions.detail', session_id=session.id) if editing else url_for('sessions.index') }}"
style="font-size:0.9rem;color:#666;">Cancel</a>
</div>
</form>
<style>.fl { display:block; font-size:.88rem; font-weight:600; color:#444; margin-bottom:.3rem; }</style>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Sessions — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">My Sessions</h1>
<a href="{{ url_for('sessions.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
+ New session
</a>
</div>
{% if sessions %}
<table>
<thead>
<tr>
<th>Session</th>
<th>Location</th>
<th>Visibility</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td><a href="{{ url_for('sessions.detail', session_id=s.id) }}">{{ s.session_date.strftime('%d %b %Y') }}</a></td>
<td style="color:#666;font-size:0.88rem;">{{ s.location_name or '—' }}</td>
<td style="font-size:0.85rem;color:{% if s.is_public %}#27ae60{% else %}#aaa{% endif %};">
{{ 'Public' if s.is_public else 'Private' }}
</td>
<td style="white-space:nowrap;">
<a href="{{ url_for('sessions.edit', session_id=s.id) }}" style="font-size:0.85rem;margin-right:.75rem;">Edit</a>
<form method="post" action="{{ url_for('sessions.delete', session_id=s.id) }}" style="display:inline;"
onsubmit="return confirm('Delete this session?');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align:center;padding:3rem 0;color:#888;">
<div style="font-size:3rem;margin-bottom:1rem;">🎯</div>
<p style="margin-bottom:1rem;">No sessions recorded yet.</p>
<a href="{{ url_for('sessions.new') }}">Log your first session</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<h1>Ballistic Analyzer</h1>
<h1>New Analysis</h1>
{% if error %}
<div class="error">{{ error }}</div>