Vibe coded a bit more ... now we have session, attached picture and analysis, MOA group computation
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal 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
|
||||
@@ -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
137
app.py
@@ -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
0
blueprints/__init__.py
Normal file
104
blueprints/analyses.py
Normal file
104
blueprints/analyses.py
Normal 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")
|
||||
14
blueprints/api/__init__.py
Normal file
14
blueprints/api/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from flask import Blueprint
|
||||
|
||||
from .auth import auth_bp
|
||||
from .equipment import equipment_bp
|
||||
from .sessions import sessions_bp
|
||||
from .analyses import analyses_bp
|
||||
from .feed import feed_bp
|
||||
|
||||
api = Blueprint("api", __name__, url_prefix="/api/v1")
|
||||
api.register_blueprint(auth_bp)
|
||||
api.register_blueprint(equipment_bp)
|
||||
api.register_blueprint(sessions_bp)
|
||||
api.register_blueprint(analyses_bp)
|
||||
api.register_blueprint(feed_bp)
|
||||
178
blueprints/api/analyses.py
Normal file
178
blueprints/api/analyses.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import base64
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from extensions import db
|
||||
from models import Analysis
|
||||
from .utils import (
|
||||
created, err, no_content, ok,
|
||||
current_api_user, serialize_analysis,
|
||||
)
|
||||
|
||||
analyses_bp = Blueprint("api_analyses", __name__, url_prefix="/analyses")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _remove_analysis_files(analysis: Analysis, storage_root: str) -> None:
|
||||
root = Path(storage_root)
|
||||
for path_attr in ("csv_path", "pdf_path"):
|
||||
rel = getattr(analysis, path_attr, None)
|
||||
if rel:
|
||||
try:
|
||||
root.joinpath(rel).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@analyses_bp.post("/upload")
|
||||
@jwt_required(optional=True)
|
||||
def upload():
|
||||
from analyzer.parser import parse_csv
|
||||
from analyzer.grouper import detect_groups
|
||||
from analyzer.stats import compute_overall_stats, compute_group_stats
|
||||
from analyzer.charts import render_group_charts, render_overview_chart
|
||||
from analyzer.pdf_report import generate_pdf
|
||||
|
||||
file = request.files.get("csv_file")
|
||||
if not file or not file.filename:
|
||||
return err("No csv_file provided.", 400)
|
||||
|
||||
try:
|
||||
csv_bytes = file.read()
|
||||
df = parse_csv(io.BytesIO(csv_bytes))
|
||||
groups = detect_groups(df)
|
||||
overall = compute_overall_stats(df)
|
||||
group_stats = compute_group_stats(groups)
|
||||
charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"])
|
||||
overview_chart = render_overview_chart(group_stats)
|
||||
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
|
||||
except ValueError as e:
|
||||
return err(str(e), 422)
|
||||
|
||||
pdf_b64 = base64.b64encode(pdf_bytes).decode()
|
||||
|
||||
saved_id = None
|
||||
user = current_api_user()
|
||||
if user:
|
||||
from storage import save_analysis
|
||||
saved_id = save_analysis(
|
||||
user=user,
|
||||
csv_bytes=csv_bytes,
|
||||
pdf_bytes=pdf_bytes,
|
||||
overall=overall,
|
||||
group_stats=group_stats,
|
||||
filename=file.filename or "upload.csv",
|
||||
)
|
||||
|
||||
return ok({
|
||||
"overall_stats": overall,
|
||||
"group_stats": group_stats,
|
||||
"charts": charts,
|
||||
"overview_chart": overview_chart,
|
||||
"pdf_b64": pdf_b64,
|
||||
"saved_id": saved_id,
|
||||
})
|
||||
|
||||
|
||||
@analyses_bp.get("/")
|
||||
@jwt_required()
|
||||
def list_analyses():
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
|
||||
except (TypeError, ValueError):
|
||||
page, per_page = 1, 20
|
||||
|
||||
total = db.session.scalar(
|
||||
select(func.count()).select_from(Analysis)
|
||||
.where(Analysis.user_id == user.id)
|
||||
) or 0
|
||||
|
||||
analyses = db.session.scalars(
|
||||
select(Analysis)
|
||||
.where(Analysis.user_id == user.id)
|
||||
.order_by(Analysis.created_at.desc())
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
).all()
|
||||
|
||||
return ok({
|
||||
"data": [serialize_analysis(a) for a in analyses],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
})
|
||||
|
||||
|
||||
@analyses_bp.get("/<int:analysis_id>")
|
||||
@jwt_required(optional=True)
|
||||
def get_analysis(analysis_id: int):
|
||||
a = db.session.get(Analysis, analysis_id)
|
||||
if not a:
|
||||
return err("Analysis not found.", 404)
|
||||
|
||||
user = current_api_user()
|
||||
is_owner = user and a.user_id == user.id
|
||||
|
||||
if not a.is_public and not is_owner:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
return ok(serialize_analysis(a))
|
||||
|
||||
|
||||
@analyses_bp.delete("/<int:analysis_id>")
|
||||
@jwt_required()
|
||||
def delete_analysis(analysis_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
a = db.session.get(Analysis, analysis_id)
|
||||
if not a:
|
||||
return err("Analysis not found.", 404)
|
||||
if a.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
storage_root = current_app.config["STORAGE_ROOT"]
|
||||
_remove_analysis_files(a, storage_root)
|
||||
|
||||
db.session.delete(a)
|
||||
db.session.commit()
|
||||
return no_content()
|
||||
|
||||
|
||||
@analyses_bp.patch("/<int:analysis_id>/visibility")
|
||||
@jwt_required()
|
||||
def toggle_visibility(analysis_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
a = db.session.get(Analysis, analysis_id)
|
||||
if not a:
|
||||
return err("Analysis not found.", 404)
|
||||
if a.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
if "is_public" not in body:
|
||||
return err("is_public field is required.", 400)
|
||||
|
||||
a.is_public = bool(body["is_public"])
|
||||
db.session.commit()
|
||||
return ok(serialize_analysis(a))
|
||||
83
blueprints/api/auth.py
Normal file
83
blueprints/api/auth.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import create_access_token, jwt_required
|
||||
|
||||
from extensions import db
|
||||
from models import User
|
||||
from .utils import created, err, ok, current_api_user, serialize_user
|
||||
|
||||
auth_bp = Blueprint("api_auth", __name__, url_prefix="/auth")
|
||||
|
||||
|
||||
@auth_bp.post("/register")
|
||||
def register():
|
||||
body = request.get_json(silent=True) or {}
|
||||
email = (body.get("email") or "").strip().lower()
|
||||
password = body.get("password") or ""
|
||||
display_name = (body.get("display_name") or "").strip() or None
|
||||
|
||||
# Validation
|
||||
if not email or "@" not in email or "." not in email.split("@")[-1]:
|
||||
return err("A valid email address is required.", 400)
|
||||
if len(password) < 8:
|
||||
return err("Password must be at least 8 characters.", 400)
|
||||
|
||||
# Uniqueness check
|
||||
existing = db.session.scalar(
|
||||
db.select(User).where(User.email == email)
|
||||
)
|
||||
if existing:
|
||||
return err("Email already registered.", 409)
|
||||
|
||||
u = User(
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
provider="local",
|
||||
provider_id=email,
|
||||
email_confirmed=True,
|
||||
)
|
||||
u.set_password(password)
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
|
||||
token = create_access_token(identity=str(u.id))
|
||||
return created({"user": serialize_user(u), "access_token": token})
|
||||
|
||||
|
||||
@auth_bp.post("/login")
|
||||
def login():
|
||||
body = request.get_json(silent=True) or {}
|
||||
email = (body.get("email") or "").strip().lower()
|
||||
password = body.get("password") or ""
|
||||
|
||||
u = db.session.scalar(
|
||||
db.select(User).where(User.email == email, User.provider == "local")
|
||||
)
|
||||
if not u or not u.check_password(password):
|
||||
return err("Invalid email or password.", 401)
|
||||
|
||||
token = create_access_token(identity=str(u.id))
|
||||
return ok({"user": serialize_user(u), "access_token": token})
|
||||
|
||||
|
||||
@auth_bp.get("/me")
|
||||
@jwt_required()
|
||||
def me():
|
||||
u = current_api_user()
|
||||
if not u:
|
||||
return err("User not found.", 404)
|
||||
return ok(serialize_user(u))
|
||||
|
||||
|
||||
@auth_bp.patch("/me")
|
||||
@jwt_required()
|
||||
def update_me():
|
||||
u = current_api_user()
|
||||
if not u:
|
||||
return err("User not found.", 404)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
if "display_name" in body:
|
||||
u.display_name = (body["display_name"] or "").strip() or None
|
||||
|
||||
db.session.commit()
|
||||
return ok(serialize_user(u))
|
||||
222
blueprints/api/equipment.py
Normal file
222
blueprints/api/equipment.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy import select
|
||||
|
||||
from extensions import db
|
||||
from models import EquipmentItem
|
||||
from storage import save_equipment_photo
|
||||
from .utils import (
|
||||
created, err, no_content, ok,
|
||||
current_api_user, serialize_equipment,
|
||||
)
|
||||
|
||||
equipment_bp = Blueprint("api_equipment", __name__, url_prefix="/equipment")
|
||||
|
||||
CATEGORY_KEYS = ["rifle", "handgun", "scope", "other"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_fields(category: str) -> dict:
|
||||
"""Read fields from JSON body or multipart form data."""
|
||||
if request.is_json:
|
||||
body = request.get_json(silent=True) or {}
|
||||
get = lambda key, default="": body.get(key, default)
|
||||
else:
|
||||
get = lambda key, default="": request.form.get(key, default)
|
||||
|
||||
fields: dict = {}
|
||||
for key in ("name", "brand", "model", "serial_number", "notes"):
|
||||
val = (get(key) or "").strip()
|
||||
fields[key] = val or None
|
||||
|
||||
# name is required — caller checks non-None
|
||||
fields["name"] = (get("name") or "").strip()
|
||||
|
||||
if category == "scope":
|
||||
fields["magnification"] = (get("magnification") or "").strip() or None
|
||||
fields["reticle"] = (get("reticle") or "").strip() or None
|
||||
fields["unit"] = (get("unit") or "").strip() or None
|
||||
else:
|
||||
fields["caliber"] = (get("caliber") or "").strip() or None
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def _apply_fields(item: EquipmentItem, fields: dict) -> None:
|
||||
for key, val in fields.items():
|
||||
setattr(item, key, val)
|
||||
|
||||
|
||||
def _remove_photo(photo_path: str, storage_root: str) -> None:
|
||||
try:
|
||||
Path(storage_root).joinpath(photo_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@equipment_bp.get("/")
|
||||
@jwt_required()
|
||||
def list_equipment():
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
items = db.session.scalars(
|
||||
select(EquipmentItem)
|
||||
.where(EquipmentItem.user_id == user.id)
|
||||
.order_by(EquipmentItem.category, EquipmentItem.name)
|
||||
).all()
|
||||
return ok([serialize_equipment(i) for i in items])
|
||||
|
||||
|
||||
@equipment_bp.post("/")
|
||||
@jwt_required()
|
||||
def create_equipment():
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
# Category can come from JSON or form
|
||||
if request.is_json:
|
||||
body = request.get_json(silent=True) or {}
|
||||
category = (body.get("category") or "").strip()
|
||||
else:
|
||||
category = (request.form.get("category") or "").strip()
|
||||
|
||||
if not category:
|
||||
return err("category is required.", 400)
|
||||
if category not in CATEGORY_KEYS:
|
||||
return err(f"category must be one of: {', '.join(CATEGORY_KEYS)}.", 400)
|
||||
|
||||
fields = _read_fields(category)
|
||||
if not fields.get("name"):
|
||||
return err("name is required.", 400)
|
||||
|
||||
item = EquipmentItem(user_id=user.id, category=category)
|
||||
_apply_fields(item, fields)
|
||||
db.session.add(item)
|
||||
db.session.flush() # get item.id before photo upload
|
||||
|
||||
photo = request.files.get("photo")
|
||||
if photo and photo.filename:
|
||||
try:
|
||||
item.photo_path = save_equipment_photo(user.id, item.id, photo)
|
||||
except ValueError as e:
|
||||
db.session.rollback()
|
||||
return err(str(e), 422)
|
||||
|
||||
db.session.commit()
|
||||
return created(serialize_equipment(item))
|
||||
|
||||
|
||||
@equipment_bp.get("/<int:item_id>")
|
||||
@jwt_required()
|
||||
def get_equipment(item_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
item = db.session.get(EquipmentItem, item_id)
|
||||
if not item:
|
||||
return err("Equipment item not found.", 404)
|
||||
if item.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
return ok(serialize_equipment(item))
|
||||
|
||||
|
||||
@equipment_bp.patch("/<int:item_id>")
|
||||
@jwt_required()
|
||||
def update_equipment(item_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
item = db.session.get(EquipmentItem, item_id)
|
||||
if not item:
|
||||
return err("Equipment item not found.", 404)
|
||||
if item.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
# Determine category (may be updated or use existing)
|
||||
if request.is_json:
|
||||
body = request.get_json(silent=True) or {}
|
||||
new_category = (body.get("category") or "").strip() or None
|
||||
else:
|
||||
new_category = (request.form.get("category") or "").strip() or None
|
||||
|
||||
if new_category:
|
||||
if new_category not in CATEGORY_KEYS:
|
||||
return err(f"category must be one of: {', '.join(CATEGORY_KEYS)}.", 400)
|
||||
item.category = new_category
|
||||
|
||||
category = item.category
|
||||
|
||||
# Only update fields present in the request
|
||||
if request.is_json:
|
||||
body = request.get_json(silent=True) or {}
|
||||
get = lambda key: body.get(key)
|
||||
has = lambda key: key in body
|
||||
else:
|
||||
get = lambda key: request.form.get(key)
|
||||
has = lambda key: key in request.form
|
||||
|
||||
for key in ("name", "brand", "model", "serial_number", "notes"):
|
||||
if has(key):
|
||||
val = (get(key) or "").strip() or None
|
||||
if key == "name" and not val:
|
||||
return err("name cannot be empty.", 400)
|
||||
setattr(item, key, val)
|
||||
|
||||
if category == "scope":
|
||||
for key in ("magnification", "reticle", "unit"):
|
||||
if has(key):
|
||||
setattr(item, key, (get(key) or "").strip() or None)
|
||||
else:
|
||||
if has("caliber"):
|
||||
item.caliber = (get("caliber") or "").strip() or None
|
||||
|
||||
# Handle photo upload
|
||||
photo = request.files.get("photo")
|
||||
if photo and photo.filename:
|
||||
try:
|
||||
old_path = item.photo_path
|
||||
item.photo_path = save_equipment_photo(user.id, item.id, photo)
|
||||
if old_path:
|
||||
storage_root = current_app.config["STORAGE_ROOT"]
|
||||
_remove_photo(old_path, storage_root)
|
||||
except ValueError as e:
|
||||
return err(str(e), 422)
|
||||
|
||||
db.session.commit()
|
||||
return ok(serialize_equipment(item))
|
||||
|
||||
|
||||
@equipment_bp.delete("/<int:item_id>")
|
||||
@jwt_required()
|
||||
def delete_equipment(item_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
item = db.session.get(EquipmentItem, item_id)
|
||||
if not item:
|
||||
return err("Equipment item not found.", 404)
|
||||
if item.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
if item.photo_path:
|
||||
storage_root = current_app.config["STORAGE_ROOT"]
|
||||
_remove_photo(item.photo_path, storage_root)
|
||||
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
return no_content()
|
||||
37
blueprints/api/feed.py
Normal file
37
blueprints/api/feed.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from flask import Blueprint, request
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from extensions import db
|
||||
from models import ShootingSession
|
||||
from .utils import ok, serialize_session
|
||||
|
||||
feed_bp = Blueprint("api_feed", __name__, url_prefix="/feed")
|
||||
|
||||
|
||||
@feed_bp.get("/")
|
||||
def feed():
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
|
||||
except (TypeError, ValueError):
|
||||
page, per_page = 1, 20
|
||||
|
||||
total = db.session.scalar(
|
||||
select(func.count()).select_from(ShootingSession)
|
||||
.where(ShootingSession.is_public == True) # noqa: E712
|
||||
) or 0
|
||||
|
||||
sessions = db.session.scalars(
|
||||
select(ShootingSession)
|
||||
.where(ShootingSession.is_public == True) # noqa: E712
|
||||
.order_by(ShootingSession.session_date.desc(), ShootingSession.created_at.desc())
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
).all()
|
||||
|
||||
return ok({
|
||||
"data": [serialize_session(s, include_user=True) for s in sessions],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
})
|
||||
327
blueprints/api/sessions.py
Normal file
327
blueprints/api/sessions.py
Normal file
@@ -0,0 +1,327 @@
|
||||
import io
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from extensions import db
|
||||
from models import SessionPhoto, ShootingSession
|
||||
from .utils import (
|
||||
created, err, no_content, ok,
|
||||
current_api_user, serialize_analysis, serialize_session, serialize_session_photo,
|
||||
)
|
||||
|
||||
sessions_bp = Blueprint("api_sessions", __name__, url_prefix="/sessions")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _int_or_none(v):
|
||||
try:
|
||||
result = int(v)
|
||||
return result if result > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _float_or_none(v):
|
||||
try:
|
||||
return float(v) if v is not None and str(v).strip() else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@sessions_bp.get("/")
|
||||
@jwt_required()
|
||||
def list_sessions():
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
|
||||
except (TypeError, ValueError):
|
||||
page, per_page = 1, 20
|
||||
|
||||
total = db.session.scalar(
|
||||
select(func.count()).select_from(ShootingSession)
|
||||
.where(ShootingSession.user_id == user.id)
|
||||
) or 0
|
||||
|
||||
sessions = db.session.scalars(
|
||||
select(ShootingSession)
|
||||
.where(ShootingSession.user_id == user.id)
|
||||
.order_by(ShootingSession.session_date.desc(), ShootingSession.created_at.desc())
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
).all()
|
||||
|
||||
return ok({
|
||||
"data": [serialize_session(s) for s in sessions],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
})
|
||||
|
||||
|
||||
@sessions_bp.post("/")
|
||||
@jwt_required()
|
||||
def create_session():
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
date_str = (body.get("session_date") or "").strip()
|
||||
if not date_str:
|
||||
return err("session_date is required.", 400)
|
||||
try:
|
||||
session_date = date.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
return err("session_date must be a valid ISO date string (YYYY-MM-DD).", 400)
|
||||
|
||||
s = ShootingSession(user_id=user.id, session_date=session_date)
|
||||
|
||||
s.is_public = bool(body.get("is_public", False))
|
||||
s.location_name = (body.get("location_name") or "").strip() or None
|
||||
s.location_lat = _float_or_none(body.get("location_lat"))
|
||||
s.location_lon = _float_or_none(body.get("location_lon"))
|
||||
s.distance_m = _int_or_none(body.get("distance_m"))
|
||||
s.weather_cond = (body.get("weather_cond") or "").strip() or None
|
||||
s.weather_temp_c = _float_or_none(body.get("weather_temp_c"))
|
||||
s.weather_wind_kph = _float_or_none(body.get("weather_wind_kph"))
|
||||
s.rifle_id = _int_or_none(body.get("rifle_id"))
|
||||
s.scope_id = _int_or_none(body.get("scope_id"))
|
||||
s.ammo_brand = (body.get("ammo_brand") or "").strip() or None
|
||||
s.ammo_weight_gr = _float_or_none(body.get("ammo_weight_gr"))
|
||||
s.ammo_lot = (body.get("ammo_lot") or "").strip() or None
|
||||
s.notes = (body.get("notes") or "").strip() or None
|
||||
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
return created(serialize_session(s))
|
||||
|
||||
|
||||
@sessions_bp.get("/<int:session_id>")
|
||||
@jwt_required(optional=True)
|
||||
def get_session(session_id: int):
|
||||
s = db.session.get(ShootingSession, session_id)
|
||||
if not s:
|
||||
return err("Session not found.", 404)
|
||||
|
||||
user = current_api_user()
|
||||
is_owner = user and s.user_id == user.id
|
||||
|
||||
if not s.is_public and not is_owner:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
return ok(serialize_session(s, include_user=True))
|
||||
|
||||
|
||||
@sessions_bp.patch("/<int:session_id>")
|
||||
@jwt_required()
|
||||
def update_session(session_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
s = db.session.get(ShootingSession, session_id)
|
||||
if not s:
|
||||
return err("Session not found.", 404)
|
||||
if s.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
if "session_date" in body:
|
||||
try:
|
||||
s.session_date = date.fromisoformat(body["session_date"])
|
||||
except (ValueError, TypeError):
|
||||
return err("session_date must be a valid ISO date string (YYYY-MM-DD).", 400)
|
||||
|
||||
if "is_public" in body:
|
||||
s.is_public = bool(body["is_public"])
|
||||
for analysis in s.analyses:
|
||||
analysis.is_public = s.is_public
|
||||
if "location_name" in body:
|
||||
s.location_name = (body["location_name"] or "").strip() or None
|
||||
if "location_lat" in body:
|
||||
s.location_lat = _float_or_none(body["location_lat"])
|
||||
if "location_lon" in body:
|
||||
s.location_lon = _float_or_none(body["location_lon"])
|
||||
if "distance_m" in body:
|
||||
s.distance_m = _int_or_none(body["distance_m"])
|
||||
if "weather_cond" in body:
|
||||
s.weather_cond = (body["weather_cond"] or "").strip() or None
|
||||
if "weather_temp_c" in body:
|
||||
s.weather_temp_c = _float_or_none(body["weather_temp_c"])
|
||||
if "weather_wind_kph" in body:
|
||||
s.weather_wind_kph = _float_or_none(body["weather_wind_kph"])
|
||||
if "rifle_id" in body:
|
||||
s.rifle_id = _int_or_none(body["rifle_id"])
|
||||
if "scope_id" in body:
|
||||
s.scope_id = _int_or_none(body["scope_id"])
|
||||
if "ammo_brand" in body:
|
||||
s.ammo_brand = (body["ammo_brand"] or "").strip() or None
|
||||
if "ammo_weight_gr" in body:
|
||||
s.ammo_weight_gr = _float_or_none(body["ammo_weight_gr"])
|
||||
if "ammo_lot" in body:
|
||||
s.ammo_lot = (body["ammo_lot"] or "").strip() or None
|
||||
if "notes" in body:
|
||||
s.notes = (body["notes"] or "").strip() or None
|
||||
|
||||
db.session.commit()
|
||||
return ok(serialize_session(s))
|
||||
|
||||
|
||||
@sessions_bp.delete("/<int:session_id>")
|
||||
@jwt_required()
|
||||
def delete_session(session_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
s = db.session.get(ShootingSession, session_id)
|
||||
if not s:
|
||||
return err("Session not found.", 404)
|
||||
if s.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
storage_root = current_app.config["STORAGE_ROOT"]
|
||||
for photo in s.photos:
|
||||
try:
|
||||
(Path(storage_root) / photo.photo_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.session.delete(s)
|
||||
db.session.commit()
|
||||
return no_content()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Photos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@sessions_bp.post("/<int:session_id>/photos")
|
||||
@jwt_required()
|
||||
def upload_photo(session_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
s = db.session.get(ShootingSession, session_id)
|
||||
if not s:
|
||||
return err("Session not found.", 404)
|
||||
if s.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
photo_file = request.files.get("photo")
|
||||
if not photo_file or not photo_file.filename:
|
||||
return err("No photo file provided.", 400)
|
||||
|
||||
from storage import save_session_photo
|
||||
try:
|
||||
photo_path = save_session_photo(user.id, session_id, photo_file)
|
||||
except ValueError as e:
|
||||
return err(str(e), 422)
|
||||
|
||||
caption = (request.form.get("caption") or "").strip() or None
|
||||
photo = SessionPhoto(session_id=session_id, photo_path=photo_path, caption=caption)
|
||||
db.session.add(photo)
|
||||
db.session.commit()
|
||||
return created(serialize_session_photo(photo))
|
||||
|
||||
|
||||
@sessions_bp.delete("/<int:session_id>/photos/<int:photo_id>")
|
||||
@jwt_required()
|
||||
def delete_photo(session_id: int, photo_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
s = db.session.get(ShootingSession, session_id)
|
||||
if not s:
|
||||
return err("Session not found.", 404)
|
||||
if s.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
photo = db.session.get(SessionPhoto, photo_id)
|
||||
if not photo or photo.session_id != session_id:
|
||||
return err("Photo not found.", 404)
|
||||
|
||||
storage_root = current_app.config["STORAGE_ROOT"]
|
||||
try:
|
||||
(Path(storage_root) / photo.photo_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.session.delete(photo)
|
||||
db.session.commit()
|
||||
return no_content()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@sessions_bp.post("/<int:session_id>/csv")
|
||||
@jwt_required()
|
||||
def upload_csv(session_id: int):
|
||||
user = current_api_user()
|
||||
if not user:
|
||||
return err("User not found.", 404)
|
||||
|
||||
s = db.session.get(ShootingSession, session_id)
|
||||
if not s:
|
||||
return err("Session not found.", 404)
|
||||
if s.user_id != user.id:
|
||||
return err("Access denied.", 403)
|
||||
|
||||
csv_file = request.files.get("csv_file")
|
||||
if not csv_file or not csv_file.filename:
|
||||
return err("No csv_file provided.", 400)
|
||||
|
||||
from analyzer.parser import parse_csv
|
||||
from analyzer.grouper import detect_groups
|
||||
from analyzer.stats import compute_overall_stats, compute_group_stats
|
||||
from analyzer.charts import render_group_charts, render_overview_chart
|
||||
from analyzer.pdf_report import generate_pdf
|
||||
from storage import save_analysis
|
||||
|
||||
try:
|
||||
csv_bytes = csv_file.read()
|
||||
df = parse_csv(io.BytesIO(csv_bytes))
|
||||
groups = detect_groups(df)
|
||||
overall = compute_overall_stats(df)
|
||||
group_stats = compute_group_stats(groups)
|
||||
charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"])
|
||||
overview_chart = render_overview_chart(group_stats)
|
||||
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
|
||||
except ValueError as e:
|
||||
return err(str(e), 422)
|
||||
|
||||
analysis_id = save_analysis(
|
||||
user=user,
|
||||
csv_bytes=csv_bytes,
|
||||
pdf_bytes=pdf_bytes,
|
||||
overall=overall,
|
||||
group_stats=group_stats,
|
||||
filename=csv_file.filename or "upload.csv",
|
||||
session_id=session_id,
|
||||
is_public=s.is_public,
|
||||
)
|
||||
|
||||
from models import Analysis
|
||||
analysis = db.session.get(Analysis, analysis_id)
|
||||
return created(serialize_analysis(analysis))
|
||||
79
blueprints/api/utils.py
Normal file
79
blueprints/api/utils.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from flask import jsonify
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
from extensions import db
|
||||
from models import User
|
||||
|
||||
|
||||
def ok(data, status=200):
|
||||
return jsonify({"data": data}), status
|
||||
|
||||
|
||||
def created(data):
|
||||
return jsonify({"data": data}), 201
|
||||
|
||||
|
||||
def no_content():
|
||||
return "", 204
|
||||
|
||||
|
||||
def err(message: str, status: int = 400, code: str | None = None):
|
||||
_codes = {400: "BAD_REQUEST", 401: "UNAUTHORIZED", 403: "FORBIDDEN",
|
||||
404: "NOT_FOUND", 409: "CONFLICT", 422: "UNPROCESSABLE"}
|
||||
return jsonify({"error": {"code": code or _codes.get(status, "ERROR"), "message": message}}), status
|
||||
|
||||
|
||||
def current_api_user() -> User | None:
|
||||
uid = get_jwt_identity()
|
||||
return db.session.get(User, int(uid)) if uid else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serializers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def serialize_user(u) -> dict:
|
||||
return {"id": u.id, "email": u.email, "display_name": u.display_name,
|
||||
"avatar_url": u.avatar_url, "provider": u.provider,
|
||||
"created_at": u.created_at.isoformat()}
|
||||
|
||||
|
||||
def serialize_equipment(item) -> dict:
|
||||
base = {"id": item.id, "category": item.category, "name": item.name,
|
||||
"brand": item.brand, "model": item.model, "serial_number": item.serial_number,
|
||||
"notes": item.notes, "photo_url": item.photo_url,
|
||||
"created_at": item.created_at.isoformat(), "updated_at": item.updated_at.isoformat()}
|
||||
if item.category == "scope":
|
||||
base.update({"magnification": item.magnification, "reticle": item.reticle, "unit": item.unit})
|
||||
else:
|
||||
base["caliber"] = item.caliber
|
||||
return base
|
||||
|
||||
|
||||
def serialize_session_photo(p) -> dict:
|
||||
return {"id": p.id, "photo_url": p.photo_url, "caption": p.caption,
|
||||
"created_at": p.created_at.isoformat()}
|
||||
|
||||
|
||||
def serialize_session(s, include_user: bool = False) -> dict:
|
||||
d = {"id": s.id, "label": s.label, "session_date": s.session_date.isoformat(),
|
||||
"is_public": s.is_public, "location_name": s.location_name,
|
||||
"location_lat": s.location_lat, "location_lon": s.location_lon,
|
||||
"distance_m": s.distance_m, "weather_cond": s.weather_cond,
|
||||
"weather_temp_c": float(s.weather_temp_c) if s.weather_temp_c is not None else None,
|
||||
"weather_wind_kph": float(s.weather_wind_kph) if s.weather_wind_kph is not None else None,
|
||||
"rifle_id": s.rifle_id, "scope_id": s.scope_id,
|
||||
"ammo_brand": s.ammo_brand,
|
||||
"ammo_weight_gr": float(s.ammo_weight_gr) if s.ammo_weight_gr is not None else None,
|
||||
"ammo_lot": s.ammo_lot, "notes": s.notes,
|
||||
"photos": [serialize_session_photo(p) for p in s.photos],
|
||||
"created_at": s.created_at.isoformat(), "updated_at": s.updated_at.isoformat()}
|
||||
if include_user:
|
||||
d["user"] = serialize_user(s.user)
|
||||
return d
|
||||
|
||||
|
||||
def serialize_analysis(a) -> dict:
|
||||
return {"id": a.id, "title": a.title, "is_public": a.is_public,
|
||||
"shot_count": a.shot_count, "group_count": a.group_count,
|
||||
"overall_stats": a.overall_stats, "group_stats": a.group_stats,
|
||||
"session_id": a.session_id, "created_at": a.created_at.isoformat()}
|
||||
382
blueprints/auth.py
Normal file
382
blueprints/auth.py
Normal 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
20
blueprints/dashboard.py
Normal 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
191
blueprints/equipment.py
Normal 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
343
blueprints/sessions.py
Normal 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
26
config.py
Normal 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"
|
||||
)
|
||||
@@ -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
8
entrypoint.sh
Executable 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
14
extensions.py
Normal 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
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal 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
113
migrations/env.py
Normal 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
24
migrations/script.py.mako
Normal 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"}
|
||||
32
migrations/versions/03057ef71b9c_user_bio.py
Normal file
32
migrations/versions/03057ef71b9c_user_bio.py
Normal 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 ###
|
||||
32
migrations/versions/1bc445c89261_drop_session_title.py
Normal file
32
migrations/versions/1bc445c89261_drop_session_title.py
Normal 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 ###
|
||||
101
migrations/versions/1ec8afb14573_initial_schema.py
Normal file
101
migrations/versions/1ec8afb14573_initial_schema.py
Normal 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 ###
|
||||
38
migrations/versions/2b8adad5972b_local_auth_fields.py
Normal file
38
migrations/versions/2b8adad5972b_local_auth_fields.py
Normal 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 ###
|
||||
@@ -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 ###
|
||||
36
migrations/versions/875675ed7b5a_scope_fields.py
Normal file
36
migrations/versions/875675ed7b5a_scope_fields.py
Normal 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 ###
|
||||
32
migrations/versions/a403e38c1c2e_user_avatar_path.py
Normal file
32
migrations/versions/a403e38c1c2e_user_avatar_path.py
Normal 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 ###
|
||||
36
migrations/versions/b94b21ec5fa9_session_photos.py
Normal file
36
migrations/versions/b94b21ec5fa9_session_photos.py
Normal 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 ###
|
||||
@@ -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 ###
|
||||
32
migrations/versions/eb04fe02f528_session_is_public.py
Normal file
32
migrations/versions/eb04fe02f528_session_is_public.py
Normal 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
176
models.py
Normal 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}"
|
||||
@@ -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
156
storage.py
Normal 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())
|
||||
93
templates/analyses/detail.html
Normal file
93
templates/analyses/detail.html
Normal 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> ›
|
||||
{% else %}
|
||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a> ›
|
||||
{% 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') }}
|
||||
· {{ analysis.shot_count }} shot(s)
|
||||
· {{ 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;">
|
||||
⇓ 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;">← 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 %}–{% 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 — {{ 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 }} – {{ stat.time_end }} | {{ 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 %}–{% 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 %}
|
||||
22
templates/auth/confirm_pending.html
Normal file
22
templates/auth/confirm_pending.html
Normal 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
62
templates/auth/login.html
Normal 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
107
templates/auth/profile.html
Normal 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;">
|
||||
👤
|
||||
</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 →
|
||||
</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 %}
|
||||
80
templates/auth/public_profile.html
Normal file
80
templates/auth/public_profile.html
Normal 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;">
|
||||
👤
|
||||
</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 %}
|
||||
33
templates/auth/register.html
Normal file
33
templates/auth/register.html
Normal 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 %}
|
||||
@@ -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;">👤</span>
|
||||
{% endif %}
|
||||
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
|
||||
<span class="caret">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu">
|
||||
<a href="{{ url_for('auth.profile') }}">👤 Profile</a>
|
||||
<hr>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button type="submit">→ 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;">✕</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;">‹</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;">›</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>
|
||||
|
||||
67
templates/dashboard/index.html
Normal file
67
templates/dashboard/index.html
Normal 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 & gear →</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 →</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
72
templates/equipment/detail.html
Normal file
72
templates/equipment/detail.html
Normal 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> ›
|
||||
{{ 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 %}
|
||||
130
templates/equipment/form.html
Normal file
130
templates/equipment/form.html
Normal 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 %}
|
||||
62
templates/equipment/list.html
Normal file
62
templates/equipment/list.html
Normal 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
157
templates/index.html
Normal 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;">👤</span>{% endif %}
|
||||
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
|
||||
<span class="caret">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu">
|
||||
<a href="{{ url_for('auth.profile') }}">👤 Profile</a>
|
||||
<hr>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button type="submit">→ 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 %}
|
||||
@@ -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="/">← 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 →</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
|
||||
⇓ Download PDF report
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
619
templates/sessions/annotate_photo.html
Normal file
619
templates/sessions/annotate_photo.html
Normal 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> ›
|
||||
<a href="{{ url_for('sessions.detail', session_id=session.id) }}">{{ session.label }}</a> ›
|
||||
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 %}
|
||||
218
templates/sessions/detail.html
Normal file
218
templates/sessions/detail.html
Normal 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> › {% 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 %} {{ 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 %} 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;">
|
||||
✕
|
||||
</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 · {{ '%.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 %}✓{% else %}▶{% 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 & link
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
143
templates/sessions/form.html
Normal file
143
templates/sessions/form.html
Normal 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 & 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 & 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 %}
|
||||
48
templates/sessions/list.html
Normal file
48
templates/sessions/list.html
Normal 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 %}
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Ballistic Analyzer</h1>
|
||||
<h1>New Analysis</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
|
||||
Reference in New Issue
Block a user