Compare commits
13 Commits
457abdf59a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
990db0f265 | ||
|
|
2a86165c10 | ||
|
|
405ec8a300 | ||
|
|
fde92f92db | ||
|
|
7710a876df | ||
|
|
85de9781d7 | ||
|
|
a4dad2a9f2 | ||
|
|
04e8631a3a | ||
|
|
e455b9b459 | ||
|
|
8e84ea09f1 | ||
|
|
7f7f66b597 | ||
|
|
df7d94933e | ||
|
|
7c7224dc47 |
13
.env
Normal file
13
.env
Normal file
@@ -0,0 +1,13 @@
|
||||
SECRET_KEY=tYmcw4icw9qlVU2wToq3Szpli6TLhKqsHFCh616oY3ZnbmUbKh1ehjZzcZZwtHKqcgE
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,web
|
||||
NETWORK=shooter-hub_default
|
||||
|
||||
DATABASE_URL=postgresql://shooter:shooter_secret@db:5432/shooter_hub
|
||||
|
||||
# External IDP credentials (configure in Django admin or here)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_CLIENT_SECRET=
|
||||
22
.env.example
22
.env.example
@@ -1,20 +1,12 @@
|
||||
# Copy this file to .env and fill in real values.
|
||||
# .env is gitignored — never commit secrets.
|
||||
SECRET_KEY=change-me-in-production
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,web
|
||||
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
DATABASE_URL=postgresql://shooter:shooter_secret@db:5432/shooter_hub
|
||||
|
||||
DB_PASSWORD=change-me-db-password
|
||||
|
||||
# Google OAuth — https://console.developers.google.com/
|
||||
# External IDP credentials (configure in Django admin or here)
|
||||
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
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_CLIENT_SECRET=
|
||||
|
||||
79
CLAUDE.md
Normal file
79
CLAUDE.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Running the Project
|
||||
|
||||
```bash
|
||||
docker compose up # Start all services (db, backend, frontend)
|
||||
docker compose up -d # Start in detached mode
|
||||
docker compose down # Stop all services
|
||||
docker compose build # Rebuild images after Dockerfile/requirements changes
|
||||
```
|
||||
|
||||
Services:
|
||||
- Django API: `http://localhost:8000`
|
||||
- Frontend (Nginx): `http://localhost:5173`
|
||||
|
||||
Default dev admin: `admin@shooterhub.local` / `changeme`
|
||||
|
||||
## Django Commands (inside container or with venv)
|
||||
|
||||
```bash
|
||||
python manage.py migrate
|
||||
python manage.py makemigrations
|
||||
python manage.py compilemessages # Required after editing .po translation files
|
||||
python manage.py shell
|
||||
python manage.py create_default_admin
|
||||
```
|
||||
|
||||
No test suite or linter is configured yet.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
**Backend**: Django 4.2 + Django REST Framework, PostgreSQL 16, JWT auth (simplejwt) + OAuth2 (allauth for Google/Apple).
|
||||
|
||||
**Frontend**: Vanilla JS + Bootstrap 5.3 served by Nginx. No build step — files are served as-is. `frontend/js/api.js` is the central API client handling JWT refresh logic.
|
||||
|
||||
### Django Apps (`apps/`)
|
||||
|
||||
| App | Responsibility |
|
||||
|-----|----------------|
|
||||
| `users` | Custom User model (email-based auth), language preference, avatar |
|
||||
| `gears` | Firearm & equipment catalog + per-user inventory + reloading (recipes, ammo batches) |
|
||||
| `tools` | Chronograph data ingestion, shot group analysis, PDF/chart generation |
|
||||
| `photos` | Binary image storage in PostgreSQL, bullet-hole annotation, ballistic overlays |
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
- **Gear catalog moderation**: Items have PENDING/VERIFIED/REJECTED status. Verified items are shared community-wide; unverified are private. `GearCatalogMixin` in `gears/views.py` enforces this.
|
||||
- **Ownership permissions**: Custom permission classes (`IsOwnerOrUnclaimed`, `IsAdminOrReadOnly`) in each app's `permissions.py`.
|
||||
- **Photo storage**: Images are stored as raw bytes in a PostgreSQL `bytea` field (`Photo.data`) — no filesystem or S3 involved.
|
||||
- **Chronograph analysis pipeline**: `apps/tools/analyzer/` contains independent modules — `parser.py` (CSV→DataFrame), `grouper.py` (shot clustering), `stats.py` (velocity stats), `charts.py` (matplotlib), `pdf_report.py` (fpdf2).
|
||||
- **Anonymous sessions**: `ChronographAnalysis` can be unclaimed (no user FK), later claimed after login.
|
||||
- **Nested routing**: Shot groups and shots are nested under chronograph analyses in the URL structure.
|
||||
- **i18n**: 4 languages (en, fr, de, es). Django backend uses `.po`/`.mo` files; frontend uses `frontend/js/i18n.js`.
|
||||
|
||||
### URL Structure
|
||||
|
||||
All API routes are under `/api/`:
|
||||
|
||||
```
|
||||
/api/auth/token/refresh/
|
||||
/api/auth/social/google/
|
||||
/api/auth/social/apple/
|
||||
/api/users/profile/
|
||||
/api/users/admin/
|
||||
/api/gears/firearms/ /api/gears/scopes/ /api/gears/suppressors/ ...
|
||||
/api/gears/ammo/
|
||||
/api/gears/components/primers|brass|bullets|powders/
|
||||
/api/inventory/
|
||||
/api/rigs/
|
||||
/api/reloading/recipes/ /api/reloading/batches/
|
||||
/api/tools/chronograph/
|
||||
/api/tools/chronograph/{id}/groups/
|
||||
/api/tools/chronograph/{id}/groups/{gid}/shots/
|
||||
/api/photos/upload/
|
||||
/api/photos/{pk}/data/
|
||||
/api/photos/group-photos/
|
||||
```
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,17 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
gettext \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pybabel compile -d translations 2>/dev/null || true
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
ENV FLASK_APP=app
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
BIN
ShooterHub Data Model.png
Normal file
BIN
ShooterHub Data Model.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,143 +0,0 @@
|
||||
"""Generate a printable PRS dope card as PDF (A4 portrait)."""
|
||||
|
||||
from fpdf import FPDF
|
||||
|
||||
|
||||
# Column widths in mm
|
||||
_W = {
|
||||
"num": 10,
|
||||
"name": 28,
|
||||
"dist": 20,
|
||||
"time": 18,
|
||||
"pos": 26,
|
||||
"dope_e": 30,
|
||||
"dope_w": 30,
|
||||
"hits": 24,
|
||||
"notes": 0, # fills remaining width
|
||||
}
|
||||
_ROW_H = 8
|
||||
_HEAD_H = 9
|
||||
_DARK = (26, 26, 46) # #1a1a2e
|
||||
_LIGHT = (240, 244, 255) # #f0f4ff
|
||||
_GRID = (200, 210, 230)
|
||||
|
||||
|
||||
def _notes_w(epw: float) -> float:
|
||||
fixed = sum(v for k, v in _W.items() if k != "notes")
|
||||
return max(0, epw - fixed)
|
||||
|
||||
|
||||
def generate_dope_card(session, stages: list) -> bytes:
|
||||
pdf = FPDF(orientation="P", unit="mm", format="A4")
|
||||
pdf.set_auto_page_break(auto=True, margin=15)
|
||||
pdf.add_page()
|
||||
pdf.set_margins(10, 12, 10)
|
||||
|
||||
epw = pdf.w - pdf.l_margin - pdf.r_margin
|
||||
nw = _notes_w(epw)
|
||||
|
||||
# ── Header ──────────────────────────────────────────────────────────────
|
||||
pdf.set_font("Helvetica", "B", 18)
|
||||
pdf.set_text_color(*_DARK)
|
||||
pdf.cell(0, 10, "FICHE DE TIR — PRS", new_x="LMARGIN", new_y="NEXT", align="C")
|
||||
|
||||
pdf.set_font("Helvetica", "", 9)
|
||||
pdf.set_text_color(80, 80, 80)
|
||||
parts = [session.session_date.strftime("%d/%m/%Y")]
|
||||
if session.location_name:
|
||||
parts.append(session.location_name)
|
||||
if session.rifle:
|
||||
parts.append(session.rifle.name)
|
||||
if session.rifle.caliber:
|
||||
parts.append(session.rifle.caliber)
|
||||
if session.ammo_brand:
|
||||
parts.append(session.ammo_brand)
|
||||
if session.ammo_weight_gr:
|
||||
parts.append(f"{session.ammo_weight_gr} gr")
|
||||
pdf.cell(0, 5, " | ".join(parts), new_x="LMARGIN", new_y="NEXT", align="C")
|
||||
pdf.ln(4)
|
||||
|
||||
# ── Column headers ───────────────────────────────────────────────────────
|
||||
pdf.set_fill_color(*_DARK)
|
||||
pdf.set_text_color(255, 255, 255)
|
||||
pdf.set_font("Helvetica", "B", 8)
|
||||
|
||||
headers = [
|
||||
("N°", _W["num"]),
|
||||
("Nom", _W["name"]),
|
||||
("Dist.(m)", _W["dist"]),
|
||||
("Temps(s)", _W["time"]),
|
||||
("Position", _W["pos"]),
|
||||
("Dope Élév.", _W["dope_e"]),
|
||||
("Dope Dérive", _W["dope_w"]),
|
||||
("Coups/Poss.", _W["hits"]),
|
||||
("Notes", nw),
|
||||
]
|
||||
for label, w in headers:
|
||||
pdf.cell(w, _HEAD_H, label, border=0, fill=True, align="C")
|
||||
pdf.ln()
|
||||
|
||||
# ── Stage rows ───────────────────────────────────────────────────────────
|
||||
pdf.set_text_color(30, 30, 30)
|
||||
pdf.set_font("Helvetica", "", 9)
|
||||
|
||||
for i, st in enumerate(stages):
|
||||
fill = i % 2 == 0
|
||||
pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255)))
|
||||
pdf.set_draw_color(*_GRID)
|
||||
|
||||
hits_str = ""
|
||||
if st.get("hits") is not None:
|
||||
hits_str = str(st["hits"])
|
||||
if st.get("possible"):
|
||||
hits_str += f"/{st['possible']}"
|
||||
elif st.get("possible"):
|
||||
hits_str = f"—/{st['possible']}"
|
||||
|
||||
row = [
|
||||
(str(st.get("num", i + 1)), _W["num"], "C"),
|
||||
(st.get("name") or "", _W["name"], "L"),
|
||||
(str(st.get("distance_m") or ""), _W["dist"], "C"),
|
||||
(str(st.get("time_s") or ""), _W["time"], "C"),
|
||||
(_pos_label(st.get("position", "")), _W["pos"], "L"),
|
||||
(st.get("dope_elevation") or "", _W["dope_e"], "C"),
|
||||
(st.get("dope_windage") or "", _W["dope_w"], "C"),
|
||||
(hits_str, _W["hits"], "C"),
|
||||
(st.get("notes") or "", nw, "L"),
|
||||
]
|
||||
for val, w, align in row:
|
||||
pdf.cell(w, _ROW_H, val, border="B", fill=fill, align=align)
|
||||
pdf.ln()
|
||||
|
||||
# ── Blank rows for hand-written stages ──────────────────────────────────
|
||||
spare = max(0, 10 - len(stages))
|
||||
for i in range(min(spare, 5)):
|
||||
fill = (len(stages) + i) % 2 == 0
|
||||
pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255)))
|
||||
for _, w, _ in row: # reuse last row widths
|
||||
pdf.cell(w, _ROW_H, "", border="B", fill=fill)
|
||||
pdf.ln()
|
||||
|
||||
# ── Footer ───────────────────────────────────────────────────────────────
|
||||
pdf.ln(4)
|
||||
pdf.set_font("Helvetica", "I", 7)
|
||||
pdf.set_text_color(160, 160, 160)
|
||||
pdf.cell(0, 5, "The Shooter's Network — fiche générée automatiquement",
|
||||
new_x="LMARGIN", new_y="NEXT", align="C")
|
||||
|
||||
return bytes(pdf.output())
|
||||
|
||||
|
||||
_POSITION_LABELS = {
|
||||
"prone": "Couché",
|
||||
"standing": "Debout",
|
||||
"kneeling": "Agenouillé",
|
||||
"sitting": "Assis",
|
||||
"barricade": "Barricade",
|
||||
"rooftop": "Toit",
|
||||
"unknown": "Variable",
|
||||
}
|
||||
|
||||
|
||||
def _pos_label(slug: str) -> str:
|
||||
return _POSITION_LABELS.get(slug, slug.replace("_", " ").title() if slug else "")
|
||||
@@ -1,54 +0,0 @@
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
|
||||
OUTLIER_FACTOR = 5
|
||||
|
||||
|
||||
def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR,
|
||||
manual_splits: list | None = None) -> list:
|
||||
"""Split shots into groups.
|
||||
|
||||
Auto-detection: consecutive shots with a time gap > median_gap * outlier_factor
|
||||
start a new group. manual_splits is an optional list of shot indices (0-based
|
||||
positions in df) where a split should be forced, regardless of timing.
|
||||
Both mechanisms are merged and deduplicated.
|
||||
"""
|
||||
if len(df) <= 1:
|
||||
return [df]
|
||||
|
||||
times = df["time"]
|
||||
diffs = times.diff().dropna()
|
||||
|
||||
if diffs.empty:
|
||||
return [df]
|
||||
|
||||
median_gap = diffs.median()
|
||||
|
||||
# Auto-detect splits based on time gaps
|
||||
auto_splits: set[int] = set()
|
||||
if median_gap != timedelta(0):
|
||||
threshold = outlier_factor * median_gap
|
||||
for idx, gap in diffs.items():
|
||||
if gap > threshold:
|
||||
pos = df.index.get_loc(idx)
|
||||
auto_splits.add(pos)
|
||||
|
||||
# Merge with manual splits (filter to valid range)
|
||||
extra = set(manual_splits) if manual_splits else set()
|
||||
all_splits = sorted(auto_splits | extra)
|
||||
|
||||
if not all_splits:
|
||||
return [df]
|
||||
|
||||
groups = []
|
||||
prev = 0
|
||||
for pos in all_splits:
|
||||
group = df.iloc[prev:pos]
|
||||
if len(group) > 0:
|
||||
groups.append(group.reset_index(drop=True))
|
||||
prev = pos
|
||||
last = df.iloc[prev:]
|
||||
if len(last) > 0:
|
||||
groups.append(last.reset_index(drop=True))
|
||||
|
||||
return groups
|
||||
199
app.py
199
app.py
@@ -1,199 +0,0 @@
|
||||
import base64
|
||||
import io
|
||||
|
||||
from flask import Flask, redirect, request, render_template, session as flask_session
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import Config
|
||||
from extensions import babel, db, jwt, login_manager, migrate, oauth
|
||||
|
||||
SUPPORTED_LANGS = ["fr", "en", "de"]
|
||||
|
||||
|
||||
def _select_locale():
|
||||
# 1. Explicit session override (set via flag switcher)
|
||||
lang = flask_session.get("lang")
|
||||
if lang in SUPPORTED_LANGS:
|
||||
return lang
|
||||
# 2. Authenticated user's stored preference
|
||||
if current_user.is_authenticated and current_user.language in SUPPORTED_LANGS:
|
||||
flask_session["lang"] = current_user.language
|
||||
return current_user.language
|
||||
# 3. Browser Accept-Language header
|
||||
return request.accept_languages.best_match(SUPPORTED_LANGS) or "en"
|
||||
|
||||
|
||||
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)
|
||||
babel.init_app(app, locale_selector=_select_locale)
|
||||
|
||||
@app.context_processor
|
||||
def inject_locale():
|
||||
from flask_babel import get_locale
|
||||
return {"current_lang": str(get_locale())}
|
||||
|
||||
|
||||
@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.admin import admin_bp
|
||||
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(admin_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("/set-lang/<lang>")
|
||||
def set_lang(lang: str):
|
||||
if lang in SUPPORTED_LANGS:
|
||||
flask_session["lang"] = lang
|
||||
if current_user.is_authenticated:
|
||||
current_user.language = lang
|
||||
db.session.commit()
|
||||
return redirect(request.referrer or "/")
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
from models import ShootingSession
|
||||
public_sessions = db.session.scalars(
|
||||
select(ShootingSession)
|
||||
.where(ShootingSession.is_public == True) # noqa: E712
|
||||
.order_by(ShootingSession.session_date.desc(), ShootingSession.created_at.desc())
|
||||
.limit(12)
|
||||
).all()
|
||||
return render_template("index.html", public_sessions=public_sessions)
|
||||
|
||||
@app.route("/analyze", methods=["GET", "POST"])
|
||||
def analyze():
|
||||
from analyzer.parser import parse_csv
|
||||
from analyzer.grouper import detect_groups
|
||||
from analyzer.stats import compute_overall_stats, compute_group_stats
|
||||
from analyzer.charts import render_group_charts, render_overview_chart
|
||||
from analyzer.pdf_report import generate_pdf
|
||||
|
||||
if request.method == "GET":
|
||||
return render_template("upload.html")
|
||||
|
||||
if "csv_file" not in request.files or request.files["csv_file"].filename == "":
|
||||
return render_template("upload.html", error="No file selected.")
|
||||
|
||||
file = request.files["csv_file"]
|
||||
|
||||
try:
|
||||
csv_bytes = file.read()
|
||||
df = parse_csv(io.BytesIO(csv_bytes))
|
||||
groups = detect_groups(df)
|
||||
overall = compute_overall_stats(df)
|
||||
group_stats = compute_group_stats(groups)
|
||||
charts = render_group_charts(
|
||||
groups,
|
||||
y_min=overall["min_speed"],
|
||||
y_max=overall["max_speed"],
|
||||
)
|
||||
overview_chart = render_overview_chart(group_stats)
|
||||
except ValueError as e:
|
||||
return render_template("upload.html", error=str(e))
|
||||
|
||||
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
|
||||
pdf_b64 = base64.b64encode(pdf_bytes).decode("utf-8")
|
||||
|
||||
saved_analysis_id = None
|
||||
if current_user.is_authenticated:
|
||||
from storage import save_analysis
|
||||
saved_analysis_id = save_analysis(
|
||||
user=current_user,
|
||||
csv_bytes=csv_bytes,
|
||||
pdf_bytes=pdf_bytes,
|
||||
overall=overall,
|
||||
group_stats=group_stats,
|
||||
filename=file.filename or "upload.csv",
|
||||
)
|
||||
|
||||
groups_display = list(zip(group_stats, charts))
|
||||
return render_template(
|
||||
"results.html",
|
||||
overall=overall,
|
||||
groups_display=groups_display,
|
||||
overview_chart=overview_chart,
|
||||
pdf_b64=pdf_b64,
|
||||
saved_analysis_id=saved_analysis_id,
|
||||
)
|
||||
|
||||
return app
|
||||
11
apps/calibers/admin.py
Normal file
11
apps/calibers/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Caliber
|
||||
|
||||
|
||||
@admin.register(Caliber)
|
||||
class CaliberAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'short_name', 'status', 'submitted_by']
|
||||
list_filter = ['status']
|
||||
search_fields = ['name', 'short_name']
|
||||
readonly_fields = ['submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at']
|
||||
6
apps/calibers/apps.py
Normal file
6
apps/calibers/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CalibersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.calibers'
|
||||
60
apps/calibers/migrations/0001_initial.py
Normal file
60
apps/calibers/migrations/0001_initial.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Caliber',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='name')),
|
||||
('short_name', models.CharField(blank=True, max_length=50, verbose_name='short name')),
|
||||
('case_length_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='case length (mm)')),
|
||||
('overall_length_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='overall length (mm)')),
|
||||
('bullet_diameter_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='bullet diameter (mm)')),
|
||||
('case_head_diameter_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='case head diameter (mm)')),
|
||||
('rim_diameter_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='rim diameter (mm)')),
|
||||
('max_pressure_mpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='max pressure (MPa)')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('status', models.CharField(
|
||||
choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')],
|
||||
default='PENDING',
|
||||
max_length=10,
|
||||
verbose_name='status',
|
||||
)),
|
||||
('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='reviewed at')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('reviewed_by', models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='reviewed_calibers',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='reviewed by',
|
||||
)),
|
||||
('submitted_by', models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='submitted_calibers',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='submitted by',
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'caliber',
|
||||
'verbose_name_plural': 'calibers',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/calibers/migrations/__init__.py
Normal file
0
apps/calibers/migrations/__init__.py
Normal file
76
apps/calibers/models.py
Normal file
76
apps/calibers/models.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class CaliberStatus(models.TextChoices):
|
||||
PENDING = 'PENDING', _('Pending Verification')
|
||||
VERIFIED = 'VERIFIED', _('Verified')
|
||||
REJECTED = 'REJECTED', _('Rejected')
|
||||
|
||||
|
||||
class Caliber(models.Model):
|
||||
name = models.CharField(_('name'), max_length=100, unique=True)
|
||||
short_name = models.CharField(_('short name'), max_length=50, blank=True)
|
||||
|
||||
# CIP standard dimensions
|
||||
case_length_mm = models.DecimalField(
|
||||
_('case length (mm)'), max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
overall_length_mm = models.DecimalField(
|
||||
_('overall length (mm)'), max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
bullet_diameter_mm = models.DecimalField(
|
||||
_('bullet diameter (mm)'), max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
case_head_diameter_mm = models.DecimalField(
|
||||
_('case head diameter (mm)'), max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
rim_diameter_mm = models.DecimalField(
|
||||
_('rim diameter (mm)'), max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
max_pressure_mpa = models.DecimalField(
|
||||
_('max pressure (MPa)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
|
||||
# Moderation
|
||||
status = models.CharField(
|
||||
_('status'), max_length=10, choices=CaliberStatus.choices, default=CaliberStatus.PENDING
|
||||
)
|
||||
submitted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='submitted_calibers',
|
||||
verbose_name=_('submitted by'),
|
||||
)
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='reviewed_calibers',
|
||||
verbose_name=_('reviewed by'),
|
||||
)
|
||||
reviewed_at = models.DateTimeField(_('reviewed at'), null=True, blank=True)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('caliber')
|
||||
verbose_name_plural = _('calibers')
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
if self.short_name:
|
||||
return f"{self.name} ({self.short_name})"
|
||||
return self.name
|
||||
|
||||
def verify(self, reviewed_by):
|
||||
self.status = CaliberStatus.VERIFIED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
|
||||
def reject(self, reviewed_by):
|
||||
self.status = CaliberStatus.REJECTED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
23
apps/calibers/serializers.py
Normal file
23
apps/calibers/serializers.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Caliber
|
||||
|
||||
|
||||
class CaliberSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Caliber
|
||||
fields = [
|
||||
'id', 'name', 'short_name',
|
||||
'case_length_mm', 'overall_length_mm', 'bullet_diameter_mm',
|
||||
'case_head_diameter_mm', 'rim_diameter_mm', 'max_pressure_mpa',
|
||||
'notes',
|
||||
'status', 'submitted_by', 'reviewed_by', 'reviewed_at',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class CaliberListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Caliber
|
||||
fields = ['id', 'name', 'short_name', 'status', 'max_pressure_mpa']
|
||||
8
apps/calibers/urls.py
Normal file
8
apps/calibers/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import CaliberViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'calibers', CaliberViewSet, basename='caliber')
|
||||
|
||||
urlpatterns = router.urls
|
||||
50
apps/calibers/views.py
Normal file
50
apps/calibers/views.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from django.db.models import Q
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Caliber, CaliberStatus
|
||||
from .serializers import CaliberListSerializer, CaliberSerializer
|
||||
|
||||
|
||||
class CaliberViewSet(viewsets.ModelViewSet):
|
||||
queryset = Caliber.objects.select_related('submitted_by', 'reviewed_by')
|
||||
serializer_class = CaliberSerializer
|
||||
filterset_fields = ['status']
|
||||
search_fields = ['name', 'short_name']
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ('list', 'retrieve'):
|
||||
return [AllowAny()]
|
||||
if self.action in ('update', 'partial_update', 'destroy'):
|
||||
return [IsAdminUser()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
return qs.filter(
|
||||
Q(status=CaliberStatus.VERIFIED) |
|
||||
Q(status=CaliberStatus.PENDING, submitted_by=user)
|
||||
)
|
||||
return qs.filter(status=CaliberStatus.VERIFIED)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
status=CaliberStatus.PENDING,
|
||||
submitted_by=self.request.user,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsAdminUser])
|
||||
def verify(self, request, pk=None):
|
||||
caliber = self.get_object()
|
||||
caliber.verify(request.user)
|
||||
return Response(self.get_serializer(caliber).data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsAdminUser])
|
||||
def reject(self, request, pk=None):
|
||||
caliber = self.get_object()
|
||||
caliber.reject(request.user)
|
||||
return Response(self.get_serializer(caliber).data)
|
||||
0
apps/common/__init__.py
Normal file
0
apps/common/__init__.py
Normal file
22
apps/common/serializer_helpers.py
Normal file
22
apps/common/serializer_helpers.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Shared serializer helper functions used across multiple apps."""
|
||||
|
||||
|
||||
def ammo_detail(ammo):
|
||||
if ammo is None:
|
||||
return None
|
||||
cal = ammo.caliber
|
||||
return {
|
||||
'id': ammo.id, 'brand': ammo.brand, 'name': ammo.name,
|
||||
'caliber_detail': {'id': cal.id, 'name': cal.name} if cal else None,
|
||||
}
|
||||
|
||||
|
||||
def batch_detail(batch):
|
||||
if batch is None:
|
||||
return None
|
||||
return {
|
||||
'id': batch.id,
|
||||
'recipe_name': batch.recipe.name,
|
||||
'powder': str(batch.powder),
|
||||
'powder_charge_gr': str(batch.powder_charge_gr),
|
||||
}
|
||||
0
apps/gears/__init__.py
Normal file
0
apps/gears/__init__.py
Normal file
171
apps/gears/admin.py
Normal file
171
apps/gears/admin.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import (
|
||||
Ammo,
|
||||
Bipod,
|
||||
Brass,
|
||||
Bullet,
|
||||
Firearm,
|
||||
Magazine,
|
||||
Powder,
|
||||
Primer,
|
||||
ReloadedAmmoBatch,
|
||||
ReloadRecipe,
|
||||
Rig,
|
||||
RigItem,
|
||||
Scope,
|
||||
Suppressor,
|
||||
UserGear,
|
||||
)
|
||||
|
||||
|
||||
class GearAdminBase(admin.ModelAdmin):
|
||||
list_display = ('brand', 'model_name', 'status', 'submitted_by', 'reviewed_by', 'created_at')
|
||||
list_filter = ('status',)
|
||||
search_fields = ('brand', 'model_name')
|
||||
readonly_fields = ('gear_type', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at')
|
||||
actions = ['verify_gears', 'reject_gears']
|
||||
|
||||
@admin.action(description=_('Mark selected gears as Verified'))
|
||||
def verify_gears(self, request, queryset):
|
||||
for gear in queryset:
|
||||
gear.verify(reviewed_by=request.user)
|
||||
|
||||
@admin.action(description=_('Mark selected gears as Rejected'))
|
||||
def reject_gears(self, request, queryset):
|
||||
for gear in queryset:
|
||||
gear.reject(reviewed_by=request.user)
|
||||
|
||||
|
||||
@admin.register(Firearm)
|
||||
class FirearmAdmin(GearAdminBase):
|
||||
list_display = GearAdminBase.list_display + ('firearm_type', 'caliber')
|
||||
list_filter = GearAdminBase.list_filter + ('firearm_type',)
|
||||
|
||||
|
||||
@admin.register(Scope)
|
||||
class ScopeAdmin(GearAdminBase):
|
||||
list_display = GearAdminBase.list_display + ('magnification_min', 'magnification_max', 'objective_diameter_mm')
|
||||
|
||||
|
||||
@admin.register(Suppressor)
|
||||
class SuppressorAdmin(GearAdminBase):
|
||||
list_display = GearAdminBase.list_display + ('max_caliber', 'thread_pitch')
|
||||
|
||||
|
||||
@admin.register(Bipod)
|
||||
class BipodAdmin(GearAdminBase):
|
||||
list_display = GearAdminBase.list_display + ('attachment_type',)
|
||||
|
||||
|
||||
@admin.register(Magazine)
|
||||
class MagazineAdmin(GearAdminBase):
|
||||
list_display = GearAdminBase.list_display + ('caliber', 'capacity')
|
||||
|
||||
|
||||
@admin.register(UserGear)
|
||||
class UserGearAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'gear', 'nickname', 'serial_number', 'added_at')
|
||||
search_fields = ('user__email', 'gear__brand', 'gear__model_name', 'nickname')
|
||||
raw_id_fields = ('user', 'gear')
|
||||
|
||||
|
||||
class RigItemInline(admin.TabularInline):
|
||||
model = RigItem
|
||||
extra = 0
|
||||
raw_id_fields = ('user_gear',)
|
||||
|
||||
|
||||
@admin.register(Rig)
|
||||
class RigAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user', 'created_at')
|
||||
search_fields = ('name', 'user__email')
|
||||
inlines = [RigItemInline]
|
||||
|
||||
|
||||
@admin.register(RigItem)
|
||||
class RigItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('rig', 'user_gear', 'role')
|
||||
list_filter = ('role',)
|
||||
search_fields = ('rig__name', 'user_gear__gear__brand', 'user_gear__gear__model_name')
|
||||
raw_id_fields = ('rig', 'user_gear')
|
||||
|
||||
|
||||
# ── Ammo catalog ──────────────────────────────────────────────────────────────
|
||||
|
||||
@admin.register(Ammo)
|
||||
class AmmoAdmin(admin.ModelAdmin):
|
||||
list_display = ('brand', 'name', 'caliber', 'bullet_weight_gr', 'bullet_type', 'status', 'submitted_by')
|
||||
list_filter = ('status', 'bullet_type', 'caliber', 'case_material')
|
||||
search_fields = ('brand', 'name', 'caliber')
|
||||
readonly_fields = ('submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at')
|
||||
raw_id_fields = ('submitted_by', 'reviewed_by')
|
||||
actions = ['verify_ammo', 'reject_ammo']
|
||||
|
||||
@admin.action(description=_('Mark selected ammo as Verified'))
|
||||
def verify_ammo(self, request, queryset):
|
||||
for ammo in queryset:
|
||||
ammo.verify(reviewed_by=request.user)
|
||||
|
||||
@admin.action(description=_('Mark selected ammo as Rejected'))
|
||||
def reject_ammo(self, request, queryset):
|
||||
for ammo in queryset:
|
||||
ammo.reject(reviewed_by=request.user)
|
||||
|
||||
|
||||
# ── Reloading components ───────────────────────────────────────────────────────
|
||||
|
||||
@admin.register(Primer)
|
||||
class PrimerAdmin(admin.ModelAdmin):
|
||||
list_display = ('brand', 'name', 'size')
|
||||
list_filter = ('size',)
|
||||
search_fields = ('brand', 'name')
|
||||
|
||||
|
||||
@admin.register(Brass)
|
||||
class BrassAdmin(admin.ModelAdmin):
|
||||
list_display = ('brand', 'caliber', 'primer_pocket', 'trim_length_mm')
|
||||
list_filter = ('caliber',)
|
||||
search_fields = ('brand', 'caliber')
|
||||
|
||||
|
||||
@admin.register(Bullet)
|
||||
class BulletAdmin(admin.ModelAdmin):
|
||||
list_display = ('brand', 'model_name', 'weight_gr', 'bullet_type', 'diameter_mm')
|
||||
list_filter = ('bullet_type',)
|
||||
search_fields = ('brand', 'model_name')
|
||||
|
||||
|
||||
@admin.register(Powder)
|
||||
class PowderAdmin(admin.ModelAdmin):
|
||||
list_display = ('brand', 'name', 'powder_type', 'burn_rate_index')
|
||||
list_filter = ('powder_type',)
|
||||
search_fields = ('brand', 'name')
|
||||
ordering = ('burn_rate_index',)
|
||||
|
||||
|
||||
# ── Reload development ────────────────────────────────────────────────────────
|
||||
|
||||
class ReloadedAmmoBatchInline(admin.TabularInline):
|
||||
model = ReloadedAmmoBatch
|
||||
extra = 0
|
||||
show_change_link = True
|
||||
fields = ('powder', 'powder_charge_gr', 'quantity', 'oal_mm', 'loaded_at')
|
||||
raw_id_fields = ('powder',)
|
||||
|
||||
|
||||
@admin.register(ReloadRecipe)
|
||||
class ReloadRecipeAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user', 'caliber', 'primer', 'brass', 'bullet', 'created_at')
|
||||
search_fields = ('name', 'user__email', 'caliber')
|
||||
raw_id_fields = ('user', 'primer', 'brass', 'bullet')
|
||||
inlines = [ReloadedAmmoBatchInline]
|
||||
|
||||
|
||||
@admin.register(ReloadedAmmoBatch)
|
||||
class ReloadedAmmoBatchAdmin(admin.ModelAdmin):
|
||||
list_display = ('recipe', 'powder', 'powder_charge_gr', 'quantity', 'loaded_at')
|
||||
search_fields = ('recipe__name', 'powder__name')
|
||||
list_filter = ('loaded_at',)
|
||||
raw_id_fields = ('recipe', 'powder')
|
||||
6
apps/gears/apps.py
Normal file
6
apps/gears/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GearsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.gears'
|
||||
0
apps/gears/management/__init__.py
Normal file
0
apps/gears/management/__init__.py
Normal file
0
apps/gears/management/commands/__init__.py
Normal file
0
apps/gears/management/commands/__init__.py
Normal file
216
apps/gears/management/commands/import_weapons_csv.py
Normal file
216
apps/gears/management/commands/import_weapons_csv.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Management command: import_weapons_csv
|
||||
|
||||
Reads weapon.csv (French RGA export, ';'-separated) and:
|
||||
1. Creates Caliber instances for all unique caliber names (status=VERIFIED).
|
||||
2. Creates Firearm instances for each row where the weapon type is
|
||||
mappable to a FirearmType (skips air guns, NL weapons, etc.).
|
||||
|
||||
CSV column layout (1-indexed, matching the header):
|
||||
1 referenceRGA
|
||||
2 famille (EPAULE / POING)
|
||||
3 typeArme (weapon type in French)
|
||||
4 marque (brand)
|
||||
5 modele (model name)
|
||||
6 fabricant
|
||||
7 paysFabricant
|
||||
8 modeFonctionnement
|
||||
9 systemeAlimentation
|
||||
10 longueurArme
|
||||
11 capaciteHorsChambre
|
||||
12 capaciteChambre
|
||||
13 calibreCanonUn ← primary caliber
|
||||
14 modePercussionCanonUn
|
||||
15 typeCanonUn
|
||||
16 longueurCanonUn (barrel length mm)
|
||||
|
||||
Usage:
|
||||
python manage.py import_weapons_csv /path/to/weapon.csv
|
||||
python manage.py import_weapons_csv /path/to/weapon.csv --dry-run
|
||||
"""
|
||||
|
||||
import csv
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
FIREARM_TYPE_MAP = {
|
||||
'CARABINE': 'CARBINE',
|
||||
'CARABINE A BARILLET': 'CARBINE',
|
||||
'FUSIL': 'SHOTGUN',
|
||||
"FUSIL A POMPE": 'SHOTGUN',
|
||||
"FUSIL D'ASSAUT": 'RIFLE',
|
||||
"FUSIL (FAP MODIFIE 1 COUP)": 'SHOTGUN',
|
||||
"FUSIL SEMI-AUTOMATIQUE ET A POMPE": 'SHOTGUN',
|
||||
'PISTOLET': 'PISTOL',
|
||||
'REVOLVER': 'REVOLVER',
|
||||
}
|
||||
|
||||
|
||||
def _clean(val: str) -> str:
|
||||
"""Strip surrounding whitespace and quotation marks."""
|
||||
return val.strip().strip('"').strip("'").strip()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Import calibers and firearms from the French RGA weapon CSV.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('csv_path', type=str, help='Path to weapon.csv')
|
||||
parser.add_argument(
|
||||
'--dry-run', action='store_true',
|
||||
help='Parse and count without writing to the database.',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from apps.calibers.models import Caliber, CaliberStatus
|
||||
from apps.gears.models import Firearm, GearStatus
|
||||
|
||||
csv_path = options['csv_path']
|
||||
dry_run = options['dry_run']
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('DRY RUN — nothing will be saved.'))
|
||||
|
||||
# ── Pass 1: collect unique caliber names ──────────────────────────────
|
||||
caliber_names = set()
|
||||
rows = []
|
||||
|
||||
self.stdout.write('Reading CSV…')
|
||||
with open(csv_path, encoding='utf-8', errors='replace') as fh:
|
||||
reader = csv.reader(fh, delimiter=';')
|
||||
next(reader) # skip header
|
||||
for row in reader:
|
||||
if len(row) < 13:
|
||||
continue
|
||||
cal_raw = _clean(row[12])
|
||||
type_raw = _clean(row[2])
|
||||
if cal_raw and type_raw in FIREARM_TYPE_MAP:
|
||||
caliber_names.add(cal_raw)
|
||||
rows.append(row)
|
||||
|
||||
self.stdout.write(f' {len(rows)} data rows, {len(caliber_names)} unique calibers to import.')
|
||||
|
||||
if dry_run:
|
||||
fw_count = sum(
|
||||
1 for r in rows
|
||||
if len(r) >= 5 and _clean(r[2]) in FIREARM_TYPE_MAP and _clean(r[4])
|
||||
)
|
||||
self.stdout.write(f' ~{fw_count} firearms would be created.')
|
||||
return
|
||||
|
||||
# ── Pass 2: upsert Caliber instances ─────────────────────────────────
|
||||
self.stdout.write('Upserting calibers…')
|
||||
caliber_map = {} # name → Caliber pk
|
||||
|
||||
existing = {c.name: c for c in Caliber.objects.filter(name__in=caliber_names)}
|
||||
caliber_map.update({name: cal.pk for name, cal in existing.items()})
|
||||
|
||||
new_calibers = []
|
||||
now = timezone.now()
|
||||
for name in caliber_names:
|
||||
if name not in existing:
|
||||
new_calibers.append(Caliber(
|
||||
name=name,
|
||||
status=CaliberStatus.VERIFIED,
|
||||
reviewed_at=now,
|
||||
))
|
||||
|
||||
if new_calibers:
|
||||
created = Caliber.objects.bulk_create(new_calibers, batch_size=500)
|
||||
for c in created:
|
||||
caliber_map[c.name] = c.pk
|
||||
self.stdout.write(f' Created {len(created)} new calibers ({len(existing)} already existed).')
|
||||
else:
|
||||
self.stdout.write(f' All {len(existing)} calibers already existed.')
|
||||
|
||||
# ── Pass 3: import firearms ───────────────────────────────────────────
|
||||
self.stdout.write('Importing firearms…')
|
||||
|
||||
# Build set of existing (brand, model_name) pairs to avoid duplicates
|
||||
existing_firearms = set(
|
||||
Firearm.objects.values_list('brand', 'model_name')
|
||||
)
|
||||
|
||||
to_create = []
|
||||
skipped_type = 0
|
||||
skipped_dup = 0
|
||||
skipped_no_model = 0
|
||||
|
||||
for row in rows:
|
||||
if len(row) < 5:
|
||||
continue
|
||||
|
||||
type_raw = _clean(row[2])
|
||||
firearm_type = FIREARM_TYPE_MAP.get(type_raw)
|
||||
if not firearm_type:
|
||||
skipped_type += 1
|
||||
continue
|
||||
|
||||
brand = _clean(row[3])
|
||||
model = _clean(row[4])
|
||||
if not model:
|
||||
skipped_no_model += 1
|
||||
continue
|
||||
if not brand:
|
||||
brand = '(unknown)'
|
||||
|
||||
if (brand, model) in existing_firearms:
|
||||
skipped_dup += 1
|
||||
continue
|
||||
|
||||
cal_raw = _clean(row[12]) if len(row) > 12 else ''
|
||||
cal_pk = caliber_map.get(cal_raw) if cal_raw else None
|
||||
|
||||
barrel_mm = None
|
||||
if len(row) > 15:
|
||||
try:
|
||||
barrel_mm = float(_clean(row[15]).replace(',', '.'))
|
||||
if barrel_mm <= 0:
|
||||
barrel_mm = None
|
||||
except (ValueError, AttributeError):
|
||||
barrel_mm = None
|
||||
|
||||
cap_extra = None
|
||||
if len(row) > 10:
|
||||
try:
|
||||
cap_extra = int(_clean(row[10]))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
to_create.append(Firearm(
|
||||
brand = brand,
|
||||
model_name = model,
|
||||
firearm_type = firearm_type,
|
||||
caliber_id = cal_pk,
|
||||
barrel_length_mm = barrel_mm,
|
||||
magazine_capacity = cap_extra if cap_extra else None,
|
||||
status = GearStatus.VERIFIED,
|
||||
reviewed_at = now,
|
||||
))
|
||||
# Track to prevent within-batch duplicates
|
||||
existing_firearms.add((brand, model))
|
||||
|
||||
self.stdout.write(
|
||||
f' {len(to_create)} firearms to create '
|
||||
f'({skipped_dup} duplicates, {skipped_type} unsupported types, '
|
||||
f'{skipped_no_model} no model name).'
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
# Firearm inherits from Gear (MTI) so bulk_create won't work directly.
|
||||
# Use chunked individual creates instead.
|
||||
chunk = 500
|
||||
for i in range(0, len(to_create), chunk):
|
||||
batch = to_create[i:i + chunk]
|
||||
for fw in batch:
|
||||
fw.save()
|
||||
pct = min(i + chunk, len(to_create))
|
||||
self.stdout.write(f' … {pct}/{len(to_create)}', ending='\r')
|
||||
self.stdout.flush()
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'Done. {len(to_create)} firearms and {len(new_calibers)} calibers imported.'
|
||||
))
|
||||
136
apps/gears/migrations/0001_initial.py
Normal file
136
apps/gears/migrations/0001_initial.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-24 09:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Gear',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('brand', models.CharField(max_length=100)),
|
||||
('model_name', models.CharField(max_length=150)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('gear_type', models.CharField(choices=[('FIREARM', 'Firearm'), ('SCOPE', 'Scope'), ('SUPPRESSOR', 'Suppressor'), ('BIPOD', 'Bipod'), ('MAGAZINE', 'Magazine')], editable=False, max_length=20)),
|
||||
('status', models.CharField(choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], default='PENDING', max_length=10)),
|
||||
('reviewed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['brand', 'model_name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Rig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RigItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('PRIMARY', 'Primary Firearm'), ('OPTIC', 'Optic / Scope'), ('SUPPRESSOR', 'Suppressor'), ('BIPOD', 'Bipod'), ('MAGAZINE', 'Magazine'), ('OTHER', 'Other Accessory')], default='OTHER', max_length=20)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Bipod',
|
||||
fields=[
|
||||
('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')),
|
||||
('min_height_mm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)),
|
||||
('max_height_mm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)),
|
||||
('attachment_type', models.CharField(blank=True, choices=[('PICATINNY', 'Picatinny Rail'), ('SLING_STUD', 'Sling Stud'), ('ARCA_SWISS', 'Arca-Swiss'), ('M_LOK', 'M-LOK'), ('KEYMOD', 'KeyMod')], max_length=20)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Bipod',
|
||||
},
|
||||
bases=('gears.gear',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Firearm',
|
||||
fields=[
|
||||
('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')),
|
||||
('firearm_type', models.CharField(choices=[('RIFLE', 'Rifle'), ('PISTOL', 'Pistol'), ('SHOTGUN', 'Shotgun'), ('REVOLVER', 'Revolver'), ('CARBINE', 'Carbine')], max_length=10)),
|
||||
('caliber', models.CharField(max_length=20)),
|
||||
('action', models.CharField(choices=[('BOLT', 'Bolt Action'), ('SEMI_AUTO', 'Semi-Automatic'), ('PUMP', 'Pump Action'), ('LEVER', 'Lever Action'), ('BREAK', 'Break Action'), ('FULL_AUTO', 'Full Automatic')], max_length=10)),
|
||||
('barrel_length_mm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)),
|
||||
('magazine_capacity', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Firearm',
|
||||
},
|
||||
bases=('gears.gear',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Magazine',
|
||||
fields=[
|
||||
('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')),
|
||||
('caliber', models.CharField(max_length=20)),
|
||||
('capacity', models.PositiveSmallIntegerField()),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Magazine',
|
||||
},
|
||||
bases=('gears.gear',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Scope',
|
||||
fields=[
|
||||
('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')),
|
||||
('magnification_min', models.DecimalField(decimal_places=1, max_digits=5)),
|
||||
('magnification_max', models.DecimalField(decimal_places=1, max_digits=5)),
|
||||
('objective_diameter_mm', models.DecimalField(decimal_places=1, max_digits=5)),
|
||||
('tube_diameter_mm', models.DecimalField(decimal_places=1, default=30, max_digits=5)),
|
||||
('reticle_type', models.CharField(blank=True, choices=[('DUPLEX', 'Duplex'), ('MILDOT', 'Mil-Dot'), ('BDC', 'BDC'), ('ILLUMINATED', 'Illuminated'), ('ETCHED', 'Etched Glass')], max_length=20)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Scope',
|
||||
},
|
||||
bases=('gears.gear',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Suppressor',
|
||||
fields=[
|
||||
('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')),
|
||||
('max_caliber', models.CharField(max_length=20)),
|
||||
('thread_pitch', models.CharField(blank=True, max_length=20)),
|
||||
('length_mm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)),
|
||||
('weight_g', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Suppressor',
|
||||
},
|
||||
bases=('gears.gear',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserGear',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('nickname', models.CharField(blank=True, max_length=100)),
|
||||
('serial_number', models.CharField(blank=True, max_length=100)),
|
||||
('purchase_date', models.DateField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('added_at', models.DateTimeField(auto_now_add=True)),
|
||||
('gear', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_instances', to='gears.gear')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-added_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
65
apps/gears/migrations/0002_initial.py
Normal file
65
apps/gears/migrations/0002_initial.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-24 09:48
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('gears', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='usergear',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rigitem',
|
||||
name='rig',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rig_items', to='gears.rig'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rigitem',
|
||||
name='user_gear',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rig_items', to='gears.usergear'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rig',
|
||||
name='items',
|
||||
field=models.ManyToManyField(related_name='rigs', through='gears.RigItem', to='gears.usergear'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rig',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rigs', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gear',
|
||||
name='reviewed_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_gears', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gear',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_gears', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='rigitem',
|
||||
constraint=models.UniqueConstraint(fields=('rig', 'user_gear'), name='unique_gear_per_rig'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='rig',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'name'), name='unique_rig_per_user'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='gear',
|
||||
constraint=models.UniqueConstraint(fields=('brand', 'model_name'), name='unique_gear_brand_model'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,450 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-24 13:42
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('gears', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Ammo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('brand', models.CharField(max_length=100, verbose_name='brand')),
|
||||
('name', models.CharField(max_length=150, verbose_name='name')),
|
||||
('caliber', models.CharField(max_length=20, verbose_name='caliber')),
|
||||
('bullet_weight_gr', models.DecimalField(decimal_places=1, max_digits=6, verbose_name='bullet weight (gr)')),
|
||||
('bullet_type', models.CharField(choices=[('FMJ', 'Full Metal Jacket'), ('HP', 'Hollow Point'), ('BTHP', 'Boat Tail Hollow Point'), ('SP', 'Soft Point'), ('HPBT', 'Hollow Point Boat Tail'), ('SMK', 'Sierra MatchKing'), ('A_TIP', 'Hornady A-Tip'), ('MONO', 'Monolithic / Solid')], max_length=5, verbose_name='bullet type')),
|
||||
('primer_size', models.CharField(blank=True, choices=[('SP', 'Small Pistol'), ('LP', 'Large Pistol'), ('SR', 'Small Rifle'), ('LR', 'Large Rifle'), ('LRM', 'Large Rifle Magnum')], max_length=3, verbose_name='primer size')),
|
||||
('case_material', models.CharField(choices=[('BRASS', 'Brass'), ('STEEL', 'Steel'), ('ALUMINUM', 'Aluminum'), ('NICKEL', 'Nickel-Plated Brass')], default='BRASS', max_length=10, verbose_name='case material')),
|
||||
('muzzle_velocity_fps', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='muzzle velocity (fps)')),
|
||||
('muzzle_energy_ftlb', models.DecimalField(blank=True, decimal_places=1, max_digits=7, null=True, verbose_name='muzzle energy (ft·lb)')),
|
||||
('box_count', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='box count')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('status', models.CharField(choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], default='PENDING', max_length=10, verbose_name='status')),
|
||||
('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='reviewed at')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ammo',
|
||||
'verbose_name_plural': 'ammo',
|
||||
'ordering': ['brand', 'name', 'caliber'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Brass',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('brand', models.CharField(max_length=100, verbose_name='brand')),
|
||||
('caliber', models.CharField(max_length=20, verbose_name='caliber')),
|
||||
('primer_pocket', models.CharField(blank=True, choices=[('SP', 'Small Pistol'), ('LP', 'Large Pistol'), ('SR', 'Small Rifle'), ('LR', 'Large Rifle'), ('LRM', 'Large Rifle Magnum')], max_length=3, verbose_name='primer pocket')),
|
||||
('trim_length_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='trim-to length (mm)')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'brass',
|
||||
'verbose_name_plural': 'brass',
|
||||
'ordering': ['brand', 'caliber'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Bullet',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('brand', models.CharField(max_length=100, verbose_name='brand')),
|
||||
('model_name', models.CharField(max_length=150, verbose_name='model name')),
|
||||
('weight_gr', models.DecimalField(decimal_places=1, max_digits=6, verbose_name='weight (gr)')),
|
||||
('bullet_type', models.CharField(choices=[('FMJ', 'Full Metal Jacket'), ('HP', 'Hollow Point'), ('BTHP', 'Boat Tail Hollow Point'), ('SP', 'Soft Point'), ('HPBT', 'Hollow Point Boat Tail'), ('SMK', 'Sierra MatchKing'), ('A_TIP', 'Hornady A-Tip'), ('MONO', 'Monolithic / Solid')], max_length=5, verbose_name='bullet type')),
|
||||
('diameter_mm', models.DecimalField(blank=True, decimal_places=3, max_digits=5, null=True, verbose_name='diameter (mm)')),
|
||||
('length_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='length (mm)')),
|
||||
('bc_g1', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, verbose_name='BC (G1)')),
|
||||
('bc_g7', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, verbose_name='BC (G7)')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'bullet',
|
||||
'verbose_name_plural': 'bullets',
|
||||
'ordering': ['brand', 'model_name', 'weight_gr'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Powder',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('brand', models.CharField(max_length=100, verbose_name='brand')),
|
||||
('name', models.CharField(max_length=100, verbose_name='name')),
|
||||
('powder_type', models.CharField(blank=True, choices=[('BALL', 'Ball / Spherical'), ('EXTRUDED', 'Extruded / Stick'), ('FLAKE', 'Flake')], max_length=10, verbose_name='powder type')),
|
||||
('burn_rate_index', models.PositiveSmallIntegerField(blank=True, help_text='Lower = faster burning. Used for relative ordering only.', null=True, verbose_name='burn rate index')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'powder',
|
||||
'verbose_name_plural': 'powders',
|
||||
'ordering': ['burn_rate_index', 'brand', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Primer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('brand', models.CharField(max_length=100, verbose_name='brand')),
|
||||
('name', models.CharField(max_length=100, verbose_name='name')),
|
||||
('size', models.CharField(choices=[('SP', 'Small Pistol'), ('LP', 'Large Pistol'), ('SR', 'Small Rifle'), ('LR', 'Large Rifle'), ('LRM', 'Large Rifle Magnum')], max_length=3, verbose_name='size')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'primer',
|
||||
'verbose_name_plural': 'primers',
|
||||
'ordering': ['brand', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='bipod',
|
||||
options={'verbose_name': 'bipod', 'verbose_name_plural': 'bipods'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='firearm',
|
||||
options={'verbose_name': 'firearm', 'verbose_name_plural': 'firearms'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='gear',
|
||||
options={'ordering': ['brand', 'model_name'], 'verbose_name': 'gear', 'verbose_name_plural': 'gears'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='magazine',
|
||||
options={'verbose_name': 'magazine', 'verbose_name_plural': 'magazines'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='rig',
|
||||
options={'ordering': ['-created_at'], 'verbose_name': 'rig', 'verbose_name_plural': 'rigs'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='rigitem',
|
||||
options={'verbose_name': 'rig item', 'verbose_name_plural': 'rig items'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='scope',
|
||||
options={'verbose_name': 'scope', 'verbose_name_plural': 'scopes'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='suppressor',
|
||||
options={'verbose_name': 'suppressor', 'verbose_name_plural': 'suppressors'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='usergear',
|
||||
options={'ordering': ['-added_at'], 'verbose_name': 'owned gear', 'verbose_name_plural': 'owned gears'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bipod',
|
||||
name='attachment_type',
|
||||
field=models.CharField(blank=True, choices=[('PICATINNY', 'Picatinny Rail'), ('SLING_STUD', 'Sling Stud'), ('ARCA_SWISS', 'Arca-Swiss'), ('M_LOK', 'M-LOK'), ('KEYMOD', 'KeyMod')], max_length=20, verbose_name='attachment type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bipod',
|
||||
name='max_height_mm',
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='max height (mm)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bipod',
|
||||
name='min_height_mm',
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='min height (mm)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='firearm',
|
||||
name='action',
|
||||
field=models.CharField(choices=[('BOLT', 'Bolt Action'), ('SEMI_AUTO', 'Semi-Automatic'), ('PUMP', 'Pump Action'), ('LEVER', 'Lever Action'), ('BREAK', 'Break Action'), ('FULL_AUTO', 'Full Automatic')], max_length=10, verbose_name='action'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='firearm',
|
||||
name='barrel_length_mm',
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='barrel length (mm)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='firearm',
|
||||
name='caliber',
|
||||
field=models.CharField(max_length=20, verbose_name='caliber'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='firearm',
|
||||
name='firearm_type',
|
||||
field=models.CharField(choices=[('RIFLE', 'Rifle'), ('PISTOL', 'Pistol'), ('SHOTGUN', 'Shotgun'), ('REVOLVER', 'Revolver'), ('CARBINE', 'Carbine')], max_length=10, verbose_name='firearm type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='firearm',
|
||||
name='magazine_capacity',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='magazine capacity'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='brand',
|
||||
field=models.CharField(max_length=100, verbose_name='brand'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, verbose_name='description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='gear_type',
|
||||
field=models.CharField(choices=[('FIREARM', 'Firearm'), ('SCOPE', 'Scope'), ('SUPPRESSOR', 'Suppressor'), ('BIPOD', 'Bipod'), ('MAGAZINE', 'Magazine')], editable=False, max_length=20, verbose_name='gear type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='model_name',
|
||||
field=models.CharField(max_length=150, verbose_name='model name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='reviewed_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='reviewed at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='reviewed_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_gears', to=settings.AUTH_USER_MODEL, verbose_name='reviewed by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], default='PENDING', max_length=10, verbose_name='status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_gears', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gear',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='magazine',
|
||||
name='caliber',
|
||||
field=models.CharField(max_length=20, verbose_name='caliber'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='magazine',
|
||||
name='capacity',
|
||||
field=models.PositiveSmallIntegerField(verbose_name='capacity'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rig',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rig',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, verbose_name='description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rig',
|
||||
name='items',
|
||||
field=models.ManyToManyField(related_name='rigs', through='gears.RigItem', to='gears.usergear', verbose_name='items'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rig',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, verbose_name='name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rig',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rig',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rigs', to=settings.AUTH_USER_MODEL, verbose_name='user'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rigitem',
|
||||
name='rig',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rig_items', to='gears.rig', verbose_name='rig'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rigitem',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('PRIMARY', 'Primary Firearm'), ('OPTIC', 'Optic / Scope'), ('SUPPRESSOR', 'Suppressor'), ('BIPOD', 'Bipod'), ('MAGAZINE', 'Magazine'), ('OTHER', 'Other Accessory')], default='OTHER', max_length=20, verbose_name='role'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rigitem',
|
||||
name='user_gear',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rig_items', to='gears.usergear', verbose_name='gear'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scope',
|
||||
name='magnification_max',
|
||||
field=models.DecimalField(decimal_places=1, max_digits=5, verbose_name='max magnification'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scope',
|
||||
name='magnification_min',
|
||||
field=models.DecimalField(decimal_places=1, max_digits=5, verbose_name='min magnification'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scope',
|
||||
name='objective_diameter_mm',
|
||||
field=models.DecimalField(decimal_places=1, max_digits=5, verbose_name='objective diameter (mm)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scope',
|
||||
name='reticle_type',
|
||||
field=models.CharField(blank=True, choices=[('DUPLEX', 'Duplex'), ('MILDOT', 'Mil-Dot'), ('BDC', 'BDC'), ('ILLUMINATED', 'Illuminated'), ('ETCHED', 'Etched Glass')], max_length=20, verbose_name='reticle type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scope',
|
||||
name='tube_diameter_mm',
|
||||
field=models.DecimalField(decimal_places=1, default=30, max_digits=5, verbose_name='tube diameter (mm)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='suppressor',
|
||||
name='length_mm',
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='length (mm)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='suppressor',
|
||||
name='max_caliber',
|
||||
field=models.CharField(max_length=20, verbose_name='max caliber'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='suppressor',
|
||||
name='thread_pitch',
|
||||
field=models.CharField(blank=True, max_length=20, verbose_name='thread pitch'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='suppressor',
|
||||
name='weight_g',
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='weight (g)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usergear',
|
||||
name='added_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='added at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usergear',
|
||||
name='gear',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_instances', to='gears.gear', verbose_name='gear'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usergear',
|
||||
name='nickname',
|
||||
field=models.CharField(blank=True, max_length=100, verbose_name='nickname'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usergear',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, verbose_name='notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usergear',
|
||||
name='purchase_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='purchase date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usergear',
|
||||
name='serial_number',
|
||||
field=models.CharField(blank=True, max_length=100, verbose_name='serial number'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usergear',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory', to=settings.AUTH_USER_MODEL, verbose_name='user'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReloadRecipe',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=150, verbose_name='name')),
|
||||
('caliber', models.CharField(max_length=20, verbose_name='caliber')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('brass', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipes', to='gears.brass', verbose_name='brass')),
|
||||
('bullet', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipes', to='gears.bullet', verbose_name='bullet')),
|
||||
('primer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipes', to='gears.primer', verbose_name='primer')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reload_recipes', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'reload recipe',
|
||||
'verbose_name_plural': 'reload recipes',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReloadedAmmoBatch',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('powder_charge_gr', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='powder charge (gr)')),
|
||||
('quantity', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='quantity loaded')),
|
||||
('oal_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='overall length (mm)')),
|
||||
('coal_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='cartridge overall length to ogive (mm)')),
|
||||
('crimp', models.CharField(choices=[('NONE', 'No Crimp'), ('TAPER', 'Taper Crimp'), ('ROLL', 'Roll Crimp')], default='NONE', max_length=6, verbose_name='crimp')),
|
||||
('case_prep_notes', models.TextField(blank=True, verbose_name='case prep notes')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('loaded_at', models.DateField(blank=True, null=True, verbose_name='loaded at')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('powder', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='batches', to='gears.powder', verbose_name='powder')),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='batches', to='gears.reloadrecipe', verbose_name='recipe')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'reloaded ammo batch',
|
||||
'verbose_name_plural': 'reloaded ammo batches',
|
||||
'ordering': ['recipe', 'powder_charge_gr'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='primer',
|
||||
constraint=models.UniqueConstraint(fields=('brand', 'name'), name='unique_primer_brand_name'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='powder',
|
||||
constraint=models.UniqueConstraint(fields=('brand', 'name'), name='unique_powder_brand_name'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='bullet',
|
||||
constraint=models.UniqueConstraint(fields=('brand', 'model_name', 'weight_gr'), name='unique_bullet_brand_model_weight'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='brass',
|
||||
constraint=models.UniqueConstraint(fields=('brand', 'caliber'), name='unique_brass_brand_caliber'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ammo',
|
||||
name='reviewed_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_ammo', to=settings.AUTH_USER_MODEL, verbose_name='reviewed by'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ammo',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_ammo', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='reloadrecipe',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'name'), name='unique_recipe_name_per_user'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='reloadedammobatch',
|
||||
constraint=models.UniqueConstraint(fields=('recipe', 'powder', 'powder_charge_gr'), name='unique_batch_charge_per_recipe_powder'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='ammo',
|
||||
constraint=models.UniqueConstraint(fields=('brand', 'name', 'caliber'), name='unique_ammo_brand_name_caliber'),
|
||||
),
|
||||
]
|
||||
1035
apps/gears/migrations/0004_catalog_initial_data.py
Normal file
1035
apps/gears/migrations/0004_catalog_initial_data.py
Normal file
File diff suppressed because it is too large
Load Diff
16
apps/gears/migrations/0005_rig_is_public.py
Normal file
16
apps/gears/migrations/0005_rig_is_public.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gears', '0004_catalog_initial_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rig',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
]
|
||||
31
apps/gears/migrations/0007_scope_optics.py
Normal file
31
apps/gears/migrations/0007_scope_optics.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gears', 'gears_0006_component_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scope',
|
||||
name='adjustment_unit',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[('MOA', 'MOA (Minute of Angle)'), ('MRAD', 'MRAD (Milliradian)')],
|
||||
max_length=4,
|
||||
verbose_name='adjustment unit',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scope',
|
||||
name='focal_plane',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[('FFP', 'First Focal Plane (FFP)'), ('SFP', 'Second Focal Plane (SFP)')],
|
||||
max_length=3,
|
||||
verbose_name='focal plane',
|
||||
),
|
||||
),
|
||||
]
|
||||
428
apps/gears/migrations/0008_catalog_enrichment.py
Normal file
428
apps/gears/migrations/0008_catalog_enrichment.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
Data migration: enrich the public catalog with additional firearms (pistols,
|
||||
semi-auto rifles, rimfire), scopes (with adjustment_unit/focal_plane),
|
||||
suppressors, bipods and magazines.
|
||||
|
||||
All items use get_or_create() to be idempotent.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def _get(apps, model_name):
|
||||
return apps.get_model('gears', model_name)
|
||||
|
||||
|
||||
def add_firearms(apps, schema_editor):
|
||||
Firearm = _get(apps, 'Firearm')
|
||||
V = 'VERIFIED'
|
||||
|
||||
items = [
|
||||
# ── Pistols ─────────────────────────────────────────────────────────
|
||||
dict(brand='Glock', model_name='G17 Gen5', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=114, magazine_capacity=17, status=V),
|
||||
dict(brand='Glock', model_name='G19 Gen5', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=102, magazine_capacity=15, status=V),
|
||||
dict(brand='Glock', model_name='G34 Gen5 MOS', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=135, magazine_capacity=17, status=V),
|
||||
dict(brand='Glock', model_name='G19X', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=102, magazine_capacity=17, status=V),
|
||||
dict(brand='Glock', model_name='G45', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=114, magazine_capacity=17, status=V),
|
||||
dict(brand='SIG Sauer', model_name='P320 M17', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=112, magazine_capacity=17, status=V),
|
||||
dict(brand='SIG Sauer', model_name='P320 X5 Legion', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=127, magazine_capacity=21, status=V),
|
||||
dict(brand='SIG Sauer', model_name='P226', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=112, magazine_capacity=15, status=V),
|
||||
dict(brand='SIG Sauer', model_name='P226 Legion', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=112, magazine_capacity=15, status=V),
|
||||
dict(brand='CZ', model_name='Shadow 2', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=120, magazine_capacity=17, status=V),
|
||||
dict(brand='CZ', model_name='SP-01 Tactical', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=119, magazine_capacity=18, status=V),
|
||||
dict(brand='Beretta', model_name='92FS', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=125, magazine_capacity=15, status=V),
|
||||
dict(brand='Beretta', model_name='APX A1', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=108, magazine_capacity=15, status=V),
|
||||
dict(brand='Smith & Wesson', model_name='M&P 2.0 5" Pro', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=127, magazine_capacity=17, status=V),
|
||||
dict(brand='Smith & Wesson', model_name='M&P 2.0 Compact 4"', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=102, magazine_capacity=15, status=V),
|
||||
dict(brand='HK', model_name='VP9', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=108, magazine_capacity=15, status=V),
|
||||
dict(brand='HK', model_name='USP Tactical 9mm', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=123, magazine_capacity=15, status=V),
|
||||
dict(brand='Walther', model_name='Q5 Match Steel Frame', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=127, magazine_capacity=15, status=V),
|
||||
dict(brand='Walther', model_name='PPQ M2 5"', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=127, magazine_capacity=15, status=V),
|
||||
dict(brand='FN', model_name='FN 509 Tactical', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=121, magazine_capacity=17, status=V),
|
||||
dict(brand='Canik', model_name='TP9SFx', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=127, magazine_capacity=20, status=V),
|
||||
dict(brand='Springfield Armory', model_name='XD-M Elite 5.25" OSP', gear_type='FIREARM',
|
||||
firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=133, magazine_capacity=22, status=V),
|
||||
# ── Semi-auto rifles ─────────────────────────────────────────────────
|
||||
dict(brand='HK', model_name='HK416 A5 14.5"', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='5.56x45mm NATO', action='SEMI-AUTO',
|
||||
barrel_length_mm=368, magazine_capacity=30, status=V),
|
||||
dict(brand='HK', model_name='HK417 A2 16"', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='7.62x51mm NATO', action='SEMI-AUTO',
|
||||
barrel_length_mm=406, magazine_capacity=20, status=V),
|
||||
dict(brand='SIG Sauer', model_name='MCX Spear 16"', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.8x51mm', action='SEMI-AUTO',
|
||||
barrel_length_mm=406, magazine_capacity=20, status=V),
|
||||
dict(brand='FN', model_name='SCAR 16S', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='5.56x45mm NATO', action='SEMI-AUTO',
|
||||
barrel_length_mm=406, magazine_capacity=30, status=V),
|
||||
dict(brand='FN', model_name='SCAR 17S', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='7.62x51mm NATO', action='SEMI-AUTO',
|
||||
barrel_length_mm=406, magazine_capacity=20, status=V),
|
||||
dict(brand='Daniel Defense', model_name='DDM4 V7 16"', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='5.56x45mm NATO', action='SEMI-AUTO',
|
||||
barrel_length_mm=406, magazine_capacity=30, status=V),
|
||||
dict(brand='Bravo Company Mfg', model_name='Recce-16 MCMR', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='5.56x45mm NATO', action='SEMI-AUTO',
|
||||
barrel_length_mm=406, magazine_capacity=30, status=V),
|
||||
dict(brand='JP Enterprises', model_name='JP-5 16" PCC', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='9mm Luger', action='SEMI-AUTO',
|
||||
barrel_length_mm=406, magazine_capacity=17, status=V),
|
||||
dict(brand='SIG Sauer', model_name='MCX Rattler 5.5"', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='300 Blackout', action='SEMI-AUTO',
|
||||
barrel_length_mm=140, magazine_capacity=30, status=V),
|
||||
# ── Rimfire ──────────────────────────────────────────────────────────
|
||||
dict(brand='Ruger', model_name='10/22 Carbine', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.22 LR', action='SEMI-AUTO',
|
||||
barrel_length_mm=470, magazine_capacity=10, status=V),
|
||||
dict(brand='Ruger', model_name='American Rimfire .22 LR', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.22 LR', action='BOLT',
|
||||
barrel_length_mm=559, magazine_capacity=10, status=V),
|
||||
dict(brand='CZ', model_name='457 American .22 LR', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.22 LR', action='BOLT',
|
||||
barrel_length_mm=508, magazine_capacity=5, status=V),
|
||||
dict(brand='CZ', model_name='457 Varmint .22 LR', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.22 LR', action='BOLT',
|
||||
barrel_length_mm=610, magazine_capacity=5, status=V),
|
||||
dict(brand='Anschütz', model_name='1710 D HB .22 LR', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.22 LR', action='BOLT',
|
||||
barrel_length_mm=660, magazine_capacity=5, status=V),
|
||||
dict(brand='Anschütz', model_name='2013 Supermatch .22 LR', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.22 LR', action='BOLT',
|
||||
barrel_length_mm=690, magazine_capacity=1, status=V),
|
||||
# ── Additional bolt-action rifles ────────────────────────────────────
|
||||
dict(brand='CZ', model_name='600 Alpha .308', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.308 Win', action='BOLT',
|
||||
barrel_length_mm=560, status=V),
|
||||
dict(brand='CZ', model_name='600 Range .308', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.308 Win', action='BOLT',
|
||||
barrel_length_mm=660, status=V),
|
||||
dict(brand='Christensen Arms', model_name='Modern Precision Rifle 6.5CM', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT',
|
||||
barrel_length_mm=610, status=V),
|
||||
dict(brand='Christensen Arms', model_name='Modern Precision Rifle .308', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.308 Win', action='BOLT',
|
||||
barrel_length_mm=610, status=V),
|
||||
dict(brand='Proof Research', model_name='Glacier Ti 6.5CM', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT',
|
||||
barrel_length_mm=559, status=V),
|
||||
dict(brand='Winchester', model_name='XPR .308', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.308 Win', action='BOLT',
|
||||
barrel_length_mm=610, status=V),
|
||||
dict(brand='Winchester', model_name='XPR 6.5CM', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT',
|
||||
barrel_length_mm=610, status=V),
|
||||
dict(brand='Mossberg', model_name='MVP Precision .308', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.308 Win', action='BOLT',
|
||||
barrel_length_mm=660, status=V),
|
||||
dict(brand='Desert Tech', model_name='SRS A2 .308', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.308 Win', action='BOLT',
|
||||
barrel_length_mm=610, status=V),
|
||||
dict(brand='Desert Tech', model_name='SRS A2 6.5CM', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT',
|
||||
barrel_length_mm=610, status=V),
|
||||
dict(brand='Victrix Armaments', model_name='Scorpio T 6.5CM', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT',
|
||||
barrel_length_mm=660, status=V),
|
||||
dict(brand='Victrix Armaments', model_name='Venus .308', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.308 Win', action='BOLT',
|
||||
barrel_length_mm=660, status=V),
|
||||
dict(brand='Bergara', model_name='B-14 Squared Crest 6.5CM', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT',
|
||||
barrel_length_mm=559, status=V),
|
||||
dict(brand='Bergara', model_name='Premier HMR Pro 6.5PRC', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.5 PRC', action='BOLT',
|
||||
barrel_length_mm=610, status=V),
|
||||
dict(brand='Tikka', model_name='T3x UPR 6.5CM', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT',
|
||||
barrel_length_mm=610, status=V),
|
||||
dict(brand='Sako', model_name='90 Adventure .308', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.308 Win', action='BOLT',
|
||||
barrel_length_mm=572, status=V),
|
||||
dict(brand='Sako', model_name='90 Peak 6.5CM', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT',
|
||||
barrel_length_mm=560, status=V),
|
||||
dict(brand='Accuracy International', model_name='AX308 .308', gear_type='FIREARM',
|
||||
firearm_type='RIFLE', caliber='.308 Win', action='BOLT',
|
||||
barrel_length_mm=660, status=V),
|
||||
]
|
||||
|
||||
for data in items:
|
||||
Firearm.objects.get_or_create(
|
||||
brand=data['brand'],
|
||||
model_name=data['model_name'],
|
||||
defaults=data,
|
||||
)
|
||||
|
||||
|
||||
def add_scopes(apps, schema_editor):
|
||||
Scope = _get(apps, 'Scope')
|
||||
V = 'VERIFIED'
|
||||
|
||||
items = [
|
||||
dict(brand='Athlon', model_name='Argos BTR 6-24x50', gear_type='SCOPE',
|
||||
magnification_min=6, magnification_max=24, objective_diameter_mm=50,
|
||||
tube_diameter_mm=30, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='Athlon', model_name='Cronus BTR 4.5-29x56', gear_type='SCOPE',
|
||||
magnification_min=4, magnification_max=29, objective_diameter_mm=56,
|
||||
tube_diameter_mm=34, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='Athlon', model_name='Ares BTR 4.5-27x50 FFP', gear_type='SCOPE',
|
||||
magnification_min=4, magnification_max=27, objective_diameter_mm=50,
|
||||
tube_diameter_mm=30, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='Primary Arms', model_name='PLx 6-30x56 FFP', gear_type='SCOPE',
|
||||
magnification_min=6, magnification_max=30, objective_diameter_mm=56,
|
||||
tube_diameter_mm=34, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='Primary Arms', model_name='SLx 4-16x44 FFP', gear_type='SCOPE',
|
||||
magnification_min=4, magnification_max=16, objective_diameter_mm=44,
|
||||
tube_diameter_mm=30, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='Hawke', model_name='Sidewinder 30 6-24x50 FFP', gear_type='SCOPE',
|
||||
magnification_min=6, magnification_max=24, objective_diameter_mm=50,
|
||||
tube_diameter_mm=30, reticle_type='MILDOT',
|
||||
adjustment_unit='MOA', focal_plane='FFP', status=V),
|
||||
dict(brand='Delta Optical', model_name='Stryker HD 4.5-30x56 FFP', gear_type='SCOPE',
|
||||
magnification_min=4, magnification_max=30, objective_diameter_mm=56,
|
||||
tube_diameter_mm=34, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='Kahles', model_name='K525i 5-25x56 FFP', gear_type='SCOPE',
|
||||
magnification_min=5, magnification_max=25, objective_diameter_mm=56,
|
||||
tube_diameter_mm=34, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='March', model_name='FX 8-80x56 FFP', gear_type='SCOPE',
|
||||
magnification_min=8, magnification_max=80, objective_diameter_mm=56,
|
||||
tube_diameter_mm=34, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='March', model_name='Genesis 6-60x56 FFP', gear_type='SCOPE',
|
||||
magnification_min=6, magnification_max=60, objective_diameter_mm=56,
|
||||
tube_diameter_mm=34, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='SIG Sauer', model_name='TANGO6T 1-6x24 FFP', gear_type='SCOPE',
|
||||
magnification_min=1, magnification_max=6, objective_diameter_mm=24,
|
||||
tube_diameter_mm=30, reticle_type='ILLUMINATED',
|
||||
adjustment_unit='MOA', focal_plane='FFP', status=V),
|
||||
dict(brand='SIG Sauer', model_name='TANGO4 4-16x44 FFP', gear_type='SCOPE',
|
||||
magnification_min=4, magnification_max=16, objective_diameter_mm=44,
|
||||
tube_diameter_mm=30, reticle_type='MILDOT',
|
||||
adjustment_unit='MOA', focal_plane='FFP', status=V),
|
||||
dict(brand='Leupold', model_name='VX-5HD 3-15x44 CDS-TZL3', gear_type='SCOPE',
|
||||
magnification_min=3, magnification_max=15, objective_diameter_mm=44,
|
||||
tube_diameter_mm=30, reticle_type='DUPLEX',
|
||||
adjustment_unit='MOA', focal_plane='SFP', status=V),
|
||||
dict(brand='Leupold', model_name='Mark 5HD 5-25x56 M5C3', gear_type='SCOPE',
|
||||
magnification_min=5, magnification_max=25, objective_diameter_mm=56,
|
||||
tube_diameter_mm=35, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='Burris', model_name='XTR III 3.3-18x50 FFP', gear_type='SCOPE',
|
||||
magnification_min=3, magnification_max=18, objective_diameter_mm=50,
|
||||
tube_diameter_mm=34, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='US Optics', model_name='B-25 5-25x52 FFP', gear_type='SCOPE',
|
||||
magnification_min=5, magnification_max=25, objective_diameter_mm=52,
|
||||
tube_diameter_mm=34, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='IOR Valdada', model_name='Terminator 3-18x50 FFP', gear_type='SCOPE',
|
||||
magnification_min=3, magnification_max=18, objective_diameter_mm=50,
|
||||
tube_diameter_mm=30, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='Vortex', model_name='Strike Eagle 5-25x56', gear_type='SCOPE',
|
||||
magnification_min=5, magnification_max=25, objective_diameter_mm=56,
|
||||
tube_diameter_mm=34, reticle_type='ILLUMINATED',
|
||||
adjustment_unit='MOA', focal_plane='FFP', status=V),
|
||||
dict(brand='Vortex', model_name='Diamondback Tactical 6-24x50', gear_type='SCOPE',
|
||||
magnification_min=6, magnification_max=24, objective_diameter_mm=50,
|
||||
tube_diameter_mm=30, reticle_type='ETCHED',
|
||||
adjustment_unit='MOA', focal_plane='FFP', status=V),
|
||||
dict(brand='Nightforce', model_name='ATACR 7-35x56 F1', gear_type='SCOPE',
|
||||
magnification_min=7, magnification_max=35, objective_diameter_mm=56,
|
||||
tube_diameter_mm=34, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
dict(brand='Nightforce', model_name='NXS 5.5-22x56 F1', gear_type='SCOPE',
|
||||
magnification_min=5, magnification_max=22, objective_diameter_mm=56,
|
||||
tube_diameter_mm=30, reticle_type='MILDOT',
|
||||
adjustment_unit='MRAD', focal_plane='FFP', status=V),
|
||||
]
|
||||
|
||||
for data in items:
|
||||
Scope.objects.get_or_create(
|
||||
brand=data['brand'],
|
||||
model_name=data['model_name'],
|
||||
defaults=data,
|
||||
)
|
||||
|
||||
|
||||
def add_suppressors(apps, schema_editor):
|
||||
Suppressor = _get(apps, 'Suppressor')
|
||||
V = 'VERIFIED'
|
||||
|
||||
items = [
|
||||
dict(brand='OSS', model_name='HX-QD 556', gear_type='SUPPRESSOR',
|
||||
max_caliber='5.56mm NATO', thread_pitch='1/2-28',
|
||||
length_mm=170, weight_g=340, status=V),
|
||||
dict(brand='Griffin Armament', model_name='Resistance 46M', gear_type='SUPPRESSOR',
|
||||
max_caliber='9mm Luger', thread_pitch='1/2-28',
|
||||
length_mm=190, weight_g=283, status=V),
|
||||
dict(brand='SureFire', model_name='SOCOM556-RC2', gear_type='SUPPRESSOR',
|
||||
max_caliber='5.56mm NATO', thread_pitch='1/2-28',
|
||||
length_mm=152, weight_g=454, status=V),
|
||||
dict(brand='AAC', model_name='SDN-6', gear_type='SUPPRESSOR',
|
||||
max_caliber='7.62mm NATO', thread_pitch='5/8-24',
|
||||
length_mm=185, weight_g=496, status=V),
|
||||
dict(brand='SilencerCo', model_name='Omega 9K', gear_type='SUPPRESSOR',
|
||||
max_caliber='9mm Luger', thread_pitch='1/2-28',
|
||||
length_mm=127, weight_g=340, status=V),
|
||||
dict(brand='Dead Air', model_name='Nomad-L', gear_type='SUPPRESSOR',
|
||||
max_caliber='7.62mm NATO', thread_pitch='5/8-24',
|
||||
length_mm=216, weight_g=510, status=V),
|
||||
dict(brand='Rugged', model_name='Obsidian 9', gear_type='SUPPRESSOR',
|
||||
max_caliber='9mm Luger', thread_pitch='1/2-28',
|
||||
length_mm=203, weight_g=425, status=V),
|
||||
dict(brand='Gemtech', model_name='G5-T 7.62', gear_type='SUPPRESSOR',
|
||||
max_caliber='7.62mm NATO', thread_pitch='5/8-24',
|
||||
length_mm=191, weight_g=510, status=V),
|
||||
dict(brand='B&T', model_name='Rotex-V 7.62', gear_type='SUPPRESSOR',
|
||||
max_caliber='7.62mm NATO', thread_pitch='5/8-24',
|
||||
length_mm=220, weight_g=640, status=V),
|
||||
]
|
||||
|
||||
for data in items:
|
||||
Suppressor.objects.get_or_create(
|
||||
brand=data['brand'],
|
||||
model_name=data['model_name'],
|
||||
defaults=data,
|
||||
)
|
||||
|
||||
|
||||
def add_bipods(apps, schema_editor):
|
||||
Bipod = _get(apps, 'Bipod')
|
||||
V = 'VERIFIED'
|
||||
|
||||
items = [
|
||||
dict(brand='Caldwell', model_name='XLA 9-13"', gear_type='BIPOD',
|
||||
min_height_mm=229, max_height_mm=330,
|
||||
attachment_type='Picatinny', status=V),
|
||||
dict(brand='RRS', model_name='SOAR B2 Arca', gear_type='BIPOD',
|
||||
min_height_mm=177, max_height_mm=330,
|
||||
attachment_type='Arca-Swiss/Picatinny', status=V),
|
||||
dict(brand='Trigger-Tech', model_name='Diamond Bipod', gear_type='BIPOD',
|
||||
min_height_mm=152, max_height_mm=280,
|
||||
attachment_type='Picatinny', status=V),
|
||||
dict(brand='Spartan Precision', model_name='Javelin Lite', gear_type='BIPOD',
|
||||
min_height_mm=178, max_height_mm=330,
|
||||
attachment_type='Sling stud/Picatinny', status=V),
|
||||
dict(brand='Fortmeier', model_name='H-POD', gear_type='BIPOD',
|
||||
min_height_mm=200, max_height_mm=380,
|
||||
attachment_type='Picatinny', status=V),
|
||||
]
|
||||
|
||||
for data in items:
|
||||
Bipod.objects.get_or_create(
|
||||
brand=data['brand'],
|
||||
model_name=data['model_name'],
|
||||
defaults=data,
|
||||
)
|
||||
|
||||
|
||||
def add_magazines(apps, schema_editor):
|
||||
Magazine = _get(apps, 'Magazine')
|
||||
V = 'VERIFIED'
|
||||
|
||||
items = [
|
||||
dict(brand='Glock', model_name='G17 Factory 17rd', gear_type='MAGAZINE',
|
||||
caliber='9mm Luger', capacity=17, status=V),
|
||||
dict(brand='Glock', model_name='G17 Factory 33rd', gear_type='MAGAZINE',
|
||||
caliber='9mm Luger', capacity=33, status=V),
|
||||
dict(brand='Glock', model_name='G19 Factory 15rd', gear_type='MAGAZINE',
|
||||
caliber='9mm Luger', capacity=15, status=V),
|
||||
dict(brand='SIG Sauer', model_name='P320 Factory 17rd', gear_type='MAGAZINE',
|
||||
caliber='9mm Luger', capacity=17, status=V),
|
||||
dict(brand='SIG Sauer', model_name='P320 Factory 21rd', gear_type='MAGAZINE',
|
||||
caliber='9mm Luger', capacity=21, status=V),
|
||||
dict(brand='CZ', model_name='Shadow 2 Factory 17rd', gear_type='MAGAZINE',
|
||||
caliber='9mm Luger', capacity=17, status=V),
|
||||
dict(brand='HK', model_name='VP9 Factory 15rd', gear_type='MAGAZINE',
|
||||
caliber='9mm Luger', capacity=15, status=V),
|
||||
dict(brand='Beretta', model_name='92FS Factory 15rd', gear_type='MAGAZINE',
|
||||
caliber='9mm Luger', capacity=15, status=V),
|
||||
dict(brand='Magpul', model_name='PMAG 10 AR/M4 Gen M3', gear_type='MAGAZINE',
|
||||
caliber='5.56x45mm NATO', capacity=10, status=V),
|
||||
dict(brand='Magpul', model_name='PMAG D-60 AR/M4', gear_type='MAGAZINE',
|
||||
caliber='5.56x45mm NATO', capacity=60, status=V),
|
||||
dict(brand='Ruger', model_name='BX-25 10/22', gear_type='MAGAZINE',
|
||||
caliber='.22 LR', capacity=25, status=V),
|
||||
dict(brand='Ruger', model_name='BX-1 10/22', gear_type='MAGAZINE',
|
||||
caliber='.22 LR', capacity=10, status=V),
|
||||
dict(brand='CZ', model_name='457 Factory 5rd', gear_type='MAGAZINE',
|
||||
caliber='.22 LR', capacity=5, status=V),
|
||||
dict(brand='CZ', model_name='457 Factory 10rd', gear_type='MAGAZINE',
|
||||
caliber='.22 LR', capacity=10, status=V),
|
||||
dict(brand='Magpul', model_name='PMAG 20 GL9 (Glock 17)', gear_type='MAGAZINE',
|
||||
caliber='9mm Luger', capacity=20, status=V),
|
||||
]
|
||||
|
||||
for data in items:
|
||||
Magazine.objects.get_or_create(
|
||||
brand=data['brand'],
|
||||
model_name=data['model_name'],
|
||||
caliber=data.get('caliber', ''),
|
||||
defaults=data,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gears', '0007_scope_optics'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_firearms, migrations.RunPython.noop),
|
||||
migrations.RunPython(add_scopes, migrations.RunPython.noop),
|
||||
migrations.RunPython(add_suppressors, migrations.RunPython.noop),
|
||||
migrations.RunPython(add_bipods, migrations.RunPython.noop),
|
||||
migrations.RunPython(add_magazines, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-25 08:38
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('gears', '0008_catalog_enrichment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='brass',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_%(class)ss', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bullet',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_%(class)ss', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powder',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_%(class)ss', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='primer',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_%(class)ss', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'),
|
||||
),
|
||||
]
|
||||
20
apps/gears/migrations/0010_rig_photo.py
Normal file
20
apps/gears/migrations/0010_rig_photo.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-25 10:15
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('photos', '0001_initial'),
|
||||
('gears', '0009_alter_brass_submitted_by_alter_bullet_submitted_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rig',
|
||||
name='photo',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rig', to='photos.photo', verbose_name='photo'),
|
||||
),
|
||||
]
|
||||
23
apps/gears/migrations/0011_rig_ballistic_fields.py
Normal file
23
apps/gears/migrations/0011_rig_ballistic_fields.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-30 09:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gears', '0010_rig_photo'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rig',
|
||||
name='scope_height_mm',
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='scope height above bore (mm)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rig',
|
||||
name='zero_distance_m',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='zero distance (m)'),
|
||||
),
|
||||
]
|
||||
142
apps/gears/migrations/0012_caliber_fk.py
Normal file
142
apps/gears/migrations/0012_caliber_fk.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gears', '0011_rig_ballistic_fields'),
|
||||
('calibers', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# ── Firearm.caliber ──────────────────────────────────────────────────
|
||||
migrations.RemoveField(
|
||||
model_name='firearm',
|
||||
name='caliber',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='firearm',
|
||||
name='caliber',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='calibers.caliber',
|
||||
verbose_name='caliber',
|
||||
),
|
||||
),
|
||||
|
||||
# ── Suppressor.max_caliber ───────────────────────────────────────────
|
||||
migrations.RemoveField(
|
||||
model_name='suppressor',
|
||||
name='max_caliber',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='suppressor',
|
||||
name='max_caliber',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='calibers.caliber',
|
||||
verbose_name='max caliber',
|
||||
),
|
||||
),
|
||||
|
||||
# ── Magazine.caliber ─────────────────────────────────────────────────
|
||||
migrations.RemoveField(
|
||||
model_name='magazine',
|
||||
name='caliber',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='magazine',
|
||||
name='caliber',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='calibers.caliber',
|
||||
verbose_name='caliber',
|
||||
),
|
||||
),
|
||||
|
||||
# ── Ammo.caliber — remove constraint first ───────────────────────────
|
||||
migrations.RemoveConstraint(
|
||||
model_name='ammo',
|
||||
name='unique_ammo_brand_name_caliber',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ammo',
|
||||
name='caliber',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ammo',
|
||||
name='caliber',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='calibers.caliber',
|
||||
verbose_name='caliber',
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='ammo',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=['brand', 'name', 'caliber'],
|
||||
name='unique_ammo_brand_name_caliber',
|
||||
),
|
||||
),
|
||||
|
||||
# ── Brass.caliber — remove constraint first ───────────────────────────
|
||||
migrations.RemoveConstraint(
|
||||
model_name='brass',
|
||||
name='unique_brass_brand_caliber',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='brass',
|
||||
name='caliber',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='brass',
|
||||
name='caliber',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='calibers.caliber',
|
||||
verbose_name='caliber',
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='brass',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=['brand', 'caliber'],
|
||||
name='unique_brass_brand_caliber',
|
||||
),
|
||||
),
|
||||
|
||||
# ── ReloadRecipe.caliber ─────────────────────────────────────────────
|
||||
migrations.RemoveField(
|
||||
model_name='reloadrecipe',
|
||||
name='caliber',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reloadrecipe',
|
||||
name='caliber',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='calibers.caliber',
|
||||
verbose_name='caliber',
|
||||
),
|
||||
),
|
||||
]
|
||||
25
apps/gears/migrations/0013_remove_firearm_action.py
Normal file
25
apps/gears/migrations/0013_remove_firearm_action.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-31 12:48
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gears', '0012_caliber_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='ammo',
|
||||
options={'ordering': ['brand', 'name', 'caliber__name'], 'verbose_name': 'ammo', 'verbose_name_plural': 'ammo'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='brass',
|
||||
options={'ordering': ['brand', 'caliber__name'], 'verbose_name': 'brass', 'verbose_name_plural': 'brass'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='firearm',
|
||||
name='action',
|
||||
),
|
||||
]
|
||||
16
apps/gears/migrations/0014_reloadrecipe_is_public.py
Normal file
16
apps/gears/migrations/0014_reloadrecipe_is_public.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gears', '0013_remove_firearm_action'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='reloadrecipe',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
]
|
||||
0
apps/gears/migrations/__init__.py
Normal file
0
apps/gears/migrations/__init__.py
Normal file
86
apps/gears/migrations/gears_0006_component_status.py
Normal file
86
apps/gears/migrations/gears_0006_component_status.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gears', '0005_rig_is_public'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Primer
|
||||
migrations.AddField(
|
||||
model_name='primer',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')],
|
||||
default='VERIFIED', max_length=10, verbose_name='status',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='primer',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='submitted_primers', to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='submitted by',
|
||||
),
|
||||
),
|
||||
# Brass
|
||||
migrations.AddField(
|
||||
model_name='brass',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')],
|
||||
default='VERIFIED', max_length=10, verbose_name='status',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='brass',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='submitted_brasss', to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='submitted by',
|
||||
),
|
||||
),
|
||||
# Bullet
|
||||
migrations.AddField(
|
||||
model_name='bullet',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')],
|
||||
default='VERIFIED', max_length=10, verbose_name='status',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='bullet',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='submitted_bullets', to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='submitted by',
|
||||
),
|
||||
),
|
||||
# Powder
|
||||
migrations.AddField(
|
||||
model_name='powder',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')],
|
||||
default='VERIFIED', max_length=10, verbose_name='status',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powder',
|
||||
name='submitted_by',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='submitted_powders', to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='submitted by',
|
||||
),
|
||||
),
|
||||
]
|
||||
727
apps/gears/models.py
Normal file
727
apps/gears/models.py
Normal file
@@ -0,0 +1,727 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# ── Choices ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class GearStatus(models.TextChoices):
|
||||
PENDING = 'PENDING', _('Pending Verification')
|
||||
VERIFIED = 'VERIFIED', _('Verified')
|
||||
REJECTED = 'REJECTED', _('Rejected')
|
||||
|
||||
|
||||
class GearType(models.TextChoices):
|
||||
FIREARM = 'FIREARM', _('Firearm')
|
||||
SCOPE = 'SCOPE', _('Scope')
|
||||
SUPPRESSOR = 'SUPPRESSOR', _('Suppressor')
|
||||
BIPOD = 'BIPOD', _('Bipod')
|
||||
MAGAZINE = 'MAGAZINE', _('Magazine')
|
||||
|
||||
|
||||
class FirearmType(models.TextChoices):
|
||||
RIFLE = 'RIFLE', _('Rifle')
|
||||
PISTOL = 'PISTOL', _('Pistol')
|
||||
SHOTGUN = 'SHOTGUN', _('Shotgun')
|
||||
REVOLVER = 'REVOLVER', _('Revolver')
|
||||
CARBINE = 'CARBINE', _('Carbine')
|
||||
|
||||
|
||||
|
||||
class ReticleType(models.TextChoices):
|
||||
DUPLEX = 'DUPLEX', _('Duplex')
|
||||
MILDOT = 'MILDOT', _('Mil-Dot')
|
||||
BDC = 'BDC', _('BDC')
|
||||
ILLUMINATED = 'ILLUMINATED', _('Illuminated')
|
||||
ETCHED = 'ETCHED', _('Etched Glass')
|
||||
|
||||
|
||||
class AdjustmentUnit(models.TextChoices):
|
||||
MOA = 'MOA', _('MOA (Minute of Angle)')
|
||||
MRAD = 'MRAD', _('MRAD (Milliradian)')
|
||||
|
||||
|
||||
class FocalPlane(models.TextChoices):
|
||||
FFP = 'FFP', _('First Focal Plane (FFP)')
|
||||
SFP = 'SFP', _('Second Focal Plane (SFP)')
|
||||
|
||||
|
||||
class AttachmentType(models.TextChoices):
|
||||
PICATINNY = 'PICATINNY', _('Picatinny Rail')
|
||||
SLING_STUD = 'SLING_STUD', _('Sling Stud')
|
||||
ARCA_SWISS = 'ARCA_SWISS', _('Arca-Swiss')
|
||||
M_LOK = 'M_LOK', _('M-LOK')
|
||||
KEYMOD = 'KEYMOD', _('KeyMod')
|
||||
|
||||
|
||||
class RigRole(models.TextChoices):
|
||||
PRIMARY = 'PRIMARY', _('Primary Firearm')
|
||||
OPTIC = 'OPTIC', _('Optic / Scope')
|
||||
SUPPRESSOR = 'SUPPRESSOR', _('Suppressor')
|
||||
BIPOD = 'BIPOD', _('Bipod')
|
||||
MAGAZINE = 'MAGAZINE', _('Magazine')
|
||||
OTHER = 'OTHER', _('Other Accessory')
|
||||
|
||||
|
||||
class BulletType(models.TextChoices):
|
||||
FMJ = 'FMJ', _('Full Metal Jacket')
|
||||
HP = 'HP', _('Hollow Point')
|
||||
BTHP = 'BTHP', _('Boat Tail Hollow Point')
|
||||
SP = 'SP', _('Soft Point')
|
||||
HPBT = 'HPBT', _('Hollow Point Boat Tail')
|
||||
SMK = 'SMK', _('Sierra MatchKing')
|
||||
A_TIP = 'A_TIP', _('Hornady A-Tip')
|
||||
MONO = 'MONO', _('Monolithic / Solid')
|
||||
|
||||
|
||||
class PrimerSize(models.TextChoices):
|
||||
SMALL_PISTOL = 'SP', _('Small Pistol')
|
||||
LARGE_PISTOL = 'LP', _('Large Pistol')
|
||||
SMALL_RIFLE = 'SR', _('Small Rifle')
|
||||
LARGE_RIFLE = 'LR', _('Large Rifle')
|
||||
LARGE_RIFLE_MAG = 'LRM', _('Large Rifle Magnum')
|
||||
|
||||
|
||||
class CaseMaterial(models.TextChoices):
|
||||
BRASS = 'BRASS', _('Brass')
|
||||
STEEL = 'STEEL', _('Steel')
|
||||
ALUMINUM = 'ALUMINUM', _('Aluminum')
|
||||
NICKEL_PLATED = 'NICKEL', _('Nickel-Plated Brass')
|
||||
|
||||
|
||||
class PowderType(models.TextChoices):
|
||||
BALL = 'BALL', _('Ball / Spherical')
|
||||
EXTRUDED = 'EXTRUDED', _('Extruded / Stick')
|
||||
FLAKE = 'FLAKE', _('Flake')
|
||||
|
||||
|
||||
class CrimpType(models.TextChoices):
|
||||
NONE = 'NONE', _('No Crimp')
|
||||
TAPER = 'TAPER', _('Taper Crimp')
|
||||
ROLL = 'ROLL', _('Roll Crimp')
|
||||
|
||||
|
||||
# ── Gear catalog (MTI) ────────────────────────────────────────────────────────
|
||||
|
||||
class Gear(models.Model):
|
||||
"""
|
||||
Base catalog entry shared by all gear types.
|
||||
Concrete entries live in child tables via multi-table inheritance.
|
||||
gear_type acts as a discriminator and is set automatically by each subclass.
|
||||
"""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
model_name = models.CharField(_('model name'), max_length=150)
|
||||
description = models.TextField(_('description'), blank=True)
|
||||
gear_type = models.CharField(
|
||||
_('gear type'), max_length=20, choices=GearType.choices, editable=False
|
||||
)
|
||||
status = models.CharField(
|
||||
_('status'), max_length=10, choices=GearStatus.choices, default=GearStatus.PENDING
|
||||
)
|
||||
submitted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='submitted_gears',
|
||||
verbose_name=_('submitted by'),
|
||||
)
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='reviewed_gears',
|
||||
verbose_name=_('reviewed by'),
|
||||
)
|
||||
reviewed_at = models.DateTimeField(_('reviewed at'), null=True, blank=True)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('gear')
|
||||
verbose_name_plural = _('gears')
|
||||
ordering = ['brand', 'model_name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'model_name'], name='unique_gear_brand_model'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.brand} {self.model_name}"
|
||||
|
||||
def verify(self, reviewed_by):
|
||||
self.status = GearStatus.VERIFIED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
|
||||
def reject(self, reviewed_by):
|
||||
self.status = GearStatus.REJECTED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
|
||||
|
||||
class Firearm(Gear):
|
||||
firearm_type = models.CharField(_('firearm type'), max_length=10, choices=FirearmType.choices)
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
barrel_length_mm = models.DecimalField(
|
||||
_('barrel length (mm)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
magazine_capacity = models.PositiveSmallIntegerField(
|
||||
_('magazine capacity'), null=True, blank=True
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.FIREARM
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('firearm')
|
||||
verbose_name_plural = _('firearms')
|
||||
|
||||
|
||||
class Scope(Gear):
|
||||
magnification_min = models.DecimalField(_('min magnification'), max_digits=5, decimal_places=1)
|
||||
magnification_max = models.DecimalField(_('max magnification'), max_digits=5, decimal_places=1)
|
||||
objective_diameter_mm = models.DecimalField(
|
||||
_('objective diameter (mm)'), max_digits=5, decimal_places=1
|
||||
)
|
||||
tube_diameter_mm = models.DecimalField(
|
||||
_('tube diameter (mm)'), max_digits=5, decimal_places=1, default=30
|
||||
)
|
||||
reticle_type = models.CharField(
|
||||
_('reticle type'), max_length=20, choices=ReticleType.choices, blank=True
|
||||
)
|
||||
adjustment_unit = models.CharField(
|
||||
_('adjustment unit'), max_length=4, choices=AdjustmentUnit.choices, blank=True
|
||||
)
|
||||
focal_plane = models.CharField(
|
||||
_('focal plane'), max_length=3, choices=FocalPlane.choices, blank=True
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.SCOPE
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('scope')
|
||||
verbose_name_plural = _('scopes')
|
||||
|
||||
|
||||
class Suppressor(Gear):
|
||||
max_caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('max caliber'),
|
||||
)
|
||||
thread_pitch = models.CharField(_('thread pitch'), max_length=20, blank=True)
|
||||
length_mm = models.DecimalField(
|
||||
_('length (mm)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
weight_g = models.DecimalField(
|
||||
_('weight (g)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.SUPPRESSOR
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('suppressor')
|
||||
verbose_name_plural = _('suppressors')
|
||||
|
||||
|
||||
class Bipod(Gear):
|
||||
min_height_mm = models.DecimalField(
|
||||
_('min height (mm)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
max_height_mm = models.DecimalField(
|
||||
_('max height (mm)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
attachment_type = models.CharField(
|
||||
_('attachment type'), max_length=20, choices=AttachmentType.choices, blank=True
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.BIPOD
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('bipod')
|
||||
verbose_name_plural = _('bipods')
|
||||
|
||||
|
||||
class Magazine(Gear):
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
capacity = models.PositiveSmallIntegerField(_('capacity'))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.MAGAZINE
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('magazine')
|
||||
verbose_name_plural = _('magazines')
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_GEAR_TYPE_ATTR = {
|
||||
GearType.FIREARM: 'firearm',
|
||||
GearType.SCOPE: 'scope',
|
||||
GearType.SUPPRESSOR: 'suppressor',
|
||||
GearType.BIPOD: 'bipod',
|
||||
GearType.MAGAZINE: 'magazine',
|
||||
}
|
||||
|
||||
|
||||
def get_concrete_gear(gear):
|
||||
"""Return the concrete MTI subclass instance for a base Gear instance."""
|
||||
attr = _GEAR_TYPE_ATTR.get(gear.gear_type)
|
||||
if attr:
|
||||
try:
|
||||
return getattr(gear, attr)
|
||||
except Exception:
|
||||
pass
|
||||
return gear
|
||||
|
||||
|
||||
# ── User inventory ────────────────────────────────────────────────────────────
|
||||
|
||||
class UserGear(models.Model):
|
||||
"""A user's personal instance of a catalog Gear entry."""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
related_name='inventory', verbose_name=_('user'),
|
||||
)
|
||||
gear = models.ForeignKey(
|
||||
Gear, on_delete=models.CASCADE,
|
||||
related_name='user_instances', verbose_name=_('gear'),
|
||||
)
|
||||
nickname = models.CharField(_('nickname'), max_length=100, blank=True)
|
||||
serial_number = models.CharField(_('serial number'), max_length=100, blank=True)
|
||||
purchase_date = models.DateField(_('purchase date'), null=True, blank=True)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
added_at = models.DateTimeField(_('added at'), auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('owned gear')
|
||||
verbose_name_plural = _('owned gears')
|
||||
ordering = ['-added_at']
|
||||
|
||||
def __str__(self):
|
||||
label = self.nickname or str(self.gear)
|
||||
return f"{self.user.email} — {label}"
|
||||
|
||||
|
||||
# ── Rigs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Rig(models.Model):
|
||||
"""A named loadout: a collection of UserGear items with optional roles."""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
related_name='rigs', verbose_name=_('user'),
|
||||
)
|
||||
name = models.CharField(_('name'), max_length=100)
|
||||
description = models.TextField(_('description'), blank=True)
|
||||
is_public = models.BooleanField(_('public'), default=False)
|
||||
photo = models.ForeignKey(
|
||||
'photos.Photo',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='rig',
|
||||
verbose_name=_('photo'),
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
UserGear, through='RigItem', related_name='rigs',
|
||||
verbose_name=_('items'),
|
||||
)
|
||||
# Ballistic computation inputs
|
||||
zero_distance_m = models.PositiveSmallIntegerField(
|
||||
_('zero distance (m)'), null=True, blank=True,
|
||||
)
|
||||
scope_height_mm = models.DecimalField(
|
||||
_('scope height above bore (mm)'), max_digits=5, decimal_places=1,
|
||||
null=True, blank=True,
|
||||
)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('rig')
|
||||
verbose_name_plural = _('rigs')
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'name'], name='unique_rig_per_user')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} — {self.name}"
|
||||
|
||||
|
||||
class RigItem(models.Model):
|
||||
"""Through table linking a UserGear to a Rig with an optional role label."""
|
||||
rig = models.ForeignKey(
|
||||
Rig, on_delete=models.CASCADE,
|
||||
related_name='rig_items', verbose_name=_('rig'),
|
||||
)
|
||||
user_gear = models.ForeignKey(
|
||||
UserGear, on_delete=models.CASCADE,
|
||||
related_name='rig_items', verbose_name=_('gear'),
|
||||
)
|
||||
role = models.CharField(
|
||||
_('role'), max_length=20, choices=RigRole.choices, default=RigRole.OTHER
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('rig item')
|
||||
verbose_name_plural = _('rig items')
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['rig', 'user_gear'], name='unique_gear_per_rig'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.rig.name} / {self.user_gear} [{self.role}]"
|
||||
|
||||
def clean(self):
|
||||
if self.user_gear.user_id != self.rig.user_id:
|
||||
raise ValidationError(
|
||||
{'user_gear': _('This gear does not belong to the rig owner.')}
|
||||
)
|
||||
|
||||
if self.role == RigRole.PRIMARY:
|
||||
if self.user_gear.gear.gear_type != GearType.FIREARM:
|
||||
raise ValidationError(
|
||||
{'role': _('The PRIMARY slot must contain a Firearm.')}
|
||||
)
|
||||
qs = RigItem.objects.filter(rig=self.rig, role=RigRole.PRIMARY)
|
||||
if self.pk:
|
||||
qs = qs.exclude(pk=self.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError(
|
||||
{'role': _('A rig can only have one primary firearm.')}
|
||||
)
|
||||
|
||||
|
||||
# ── Ammo catalog ──────────────────────────────────────────────────────────────
|
||||
|
||||
class Ammo(models.Model):
|
||||
"""
|
||||
Commercial/factory ammunition catalog entry.
|
||||
Independent of the Gear MTI hierarchy.
|
||||
Same PENDING/VERIFIED/REJECTED moderation workflow as Gear.
|
||||
"""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
name = models.CharField(_('name'), max_length=150)
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
bullet_weight_gr = models.DecimalField(
|
||||
_('bullet weight (gr)'), max_digits=6, decimal_places=1
|
||||
)
|
||||
bullet_type = models.CharField(
|
||||
_('bullet type'), max_length=5, choices=BulletType.choices
|
||||
)
|
||||
primer_size = models.CharField(
|
||||
_('primer size'), max_length=3, choices=PrimerSize.choices, blank=True
|
||||
)
|
||||
case_material = models.CharField(
|
||||
_('case material'), max_length=10, choices=CaseMaterial.choices,
|
||||
default=CaseMaterial.BRASS
|
||||
)
|
||||
muzzle_velocity_fps = models.DecimalField(
|
||||
_('muzzle velocity (fps)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
muzzle_energy_ftlb = models.DecimalField(
|
||||
_('muzzle energy (ft·lb)'), max_digits=7, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
box_count = models.PositiveSmallIntegerField(_('box count'), null=True, blank=True)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
status = models.CharField(
|
||||
_('status'), max_length=10, choices=GearStatus.choices, default=GearStatus.PENDING
|
||||
)
|
||||
submitted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='submitted_ammo',
|
||||
verbose_name=_('submitted by'),
|
||||
)
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='reviewed_ammo',
|
||||
verbose_name=_('reviewed by'),
|
||||
)
|
||||
reviewed_at = models.DateTimeField(_('reviewed at'), null=True, blank=True)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('ammo')
|
||||
verbose_name_plural = _('ammo')
|
||||
ordering = ['brand', 'name', 'caliber__name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'name', 'caliber'],
|
||||
name='unique_ammo_brand_name_caliber'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
caliber_str = self.caliber.name if self.caliber_id else '?'
|
||||
return f"{self.brand} {self.name} ({caliber_str})"
|
||||
|
||||
def verify(self, reviewed_by):
|
||||
self.status = GearStatus.VERIFIED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
|
||||
def reject(self, reviewed_by):
|
||||
self.status = GearStatus.REJECTED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
|
||||
|
||||
# ── Reloading components ───────────────────────────────────────────────────────
|
||||
|
||||
class ComponentMixin(models.Model):
|
||||
"""
|
||||
Shared moderation fields for user-submitted reload components.
|
||||
Existing catalog entries default to VERIFIED for backward compatibility.
|
||||
"""
|
||||
status = models.CharField(
|
||||
_('status'), max_length=10, choices=GearStatus.choices, default=GearStatus.VERIFIED
|
||||
)
|
||||
submitted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='submitted_%(class)ss',
|
||||
verbose_name=_('submitted by'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def verify(self, reviewed_by=None):
|
||||
self.status = GearStatus.VERIFIED
|
||||
self.save(update_fields=['status'])
|
||||
|
||||
def reject(self, reviewed_by=None):
|
||||
self.status = GearStatus.REJECTED
|
||||
self.save(update_fields=['status'])
|
||||
|
||||
|
||||
class Primer(ComponentMixin):
|
||||
"""Primer reference — can be user-submitted (PENDING) or admin-verified."""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
name = models.CharField(_('name'), max_length=100)
|
||||
size = models.CharField(_('size'), max_length=3, choices=PrimerSize.choices)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('primer')
|
||||
verbose_name_plural = _('primers')
|
||||
ordering = ['brand', 'name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['brand', 'name'], name='unique_primer_brand_name')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.brand} {self.name} ({self.get_size_display()})"
|
||||
|
||||
|
||||
class Brass(ComponentMixin):
|
||||
"""Brass/case reference — can be user-submitted (PENDING) or admin-verified."""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
primer_pocket = models.CharField(
|
||||
_('primer pocket'), max_length=3, choices=PrimerSize.choices, blank=True
|
||||
)
|
||||
trim_length_mm = models.DecimalField(
|
||||
_('trim-to length (mm)'), max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('brass')
|
||||
verbose_name_plural = _('brass')
|
||||
ordering = ['brand', 'caliber__name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'caliber'], name='unique_brass_brand_caliber'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
caliber_str = self.caliber.name if self.caliber_id else '?'
|
||||
return f"{self.brand} {caliber_str}"
|
||||
|
||||
|
||||
class Bullet(ComponentMixin):
|
||||
"""Bullet/projectile reference — can be user-submitted (PENDING) or admin-verified."""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
model_name = models.CharField(_('model name'), max_length=150)
|
||||
weight_gr = models.DecimalField(_('weight (gr)'), max_digits=6, decimal_places=1)
|
||||
bullet_type = models.CharField(_('bullet type'), max_length=5, choices=BulletType.choices)
|
||||
diameter_mm = models.DecimalField(
|
||||
_('diameter (mm)'), max_digits=5, decimal_places=3, null=True, blank=True
|
||||
)
|
||||
length_mm = models.DecimalField(
|
||||
_('length (mm)'), max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
bc_g1 = models.DecimalField(
|
||||
_('BC (G1)'), max_digits=5, decimal_places=4, null=True, blank=True
|
||||
)
|
||||
bc_g7 = models.DecimalField(
|
||||
_('BC (G7)'), max_digits=5, decimal_places=4, null=True, blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('bullet')
|
||||
verbose_name_plural = _('bullets')
|
||||
ordering = ['brand', 'model_name', 'weight_gr']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'model_name', 'weight_gr'],
|
||||
name='unique_bullet_brand_model_weight'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.brand} {self.model_name} {self.weight_gr}gr"
|
||||
|
||||
|
||||
class Powder(ComponentMixin):
|
||||
"""Propellant powder reference — can be user-submitted (PENDING) or admin-verified."""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
name = models.CharField(_('name'), max_length=100)
|
||||
powder_type = models.CharField(
|
||||
_('powder type'), max_length=10, choices=PowderType.choices, blank=True
|
||||
)
|
||||
burn_rate_index = models.PositiveSmallIntegerField(
|
||||
_('burn rate index'), null=True, blank=True,
|
||||
help_text=_('Lower = faster burning. Used for relative ordering only.'),
|
||||
)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('powder')
|
||||
verbose_name_plural = _('powders')
|
||||
ordering = ['burn_rate_index', 'brand', 'name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'name'], name='unique_powder_brand_name'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.brand} {self.name}"
|
||||
|
||||
|
||||
# ── Reload development ────────────────────────────────────────────────────────
|
||||
|
||||
class ReloadRecipe(models.Model):
|
||||
"""
|
||||
A reloading recipe: a fixed combination of primer, brass, and bullet
|
||||
owned by one user. Batches (different powder charges) hang off this.
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
related_name='reload_recipes', verbose_name=_('user'),
|
||||
)
|
||||
name = models.CharField(_('name'), max_length=150)
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
primer = models.ForeignKey(
|
||||
Primer, on_delete=models.PROTECT,
|
||||
related_name='recipes', verbose_name=_('primer'),
|
||||
)
|
||||
brass = models.ForeignKey(
|
||||
Brass, on_delete=models.PROTECT,
|
||||
related_name='recipes', verbose_name=_('brass'),
|
||||
)
|
||||
bullet = models.ForeignKey(
|
||||
Bullet, on_delete=models.PROTECT,
|
||||
related_name='recipes', verbose_name=_('bullet'),
|
||||
)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
is_public = models.BooleanField(_('public'), default=False)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('reload recipe')
|
||||
verbose_name_plural = _('reload recipes')
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['user', 'name'], name='unique_recipe_name_per_user'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
caliber_str = self.caliber.name if self.caliber_id else '?'
|
||||
return f"{self.user.email} — {self.name} ({caliber_str})"
|
||||
|
||||
|
||||
class ReloadedAmmoBatch(models.Model):
|
||||
"""
|
||||
A specific powder charge variant within a ReloadRecipe.
|
||||
Multiple batches under one recipe represent the powder charge development workflow.
|
||||
ShotGroups in apps.tools can link to a batch to track performance per charge.
|
||||
"""
|
||||
recipe = models.ForeignKey(
|
||||
ReloadRecipe, on_delete=models.CASCADE,
|
||||
related_name='batches', verbose_name=_('recipe'),
|
||||
)
|
||||
powder = models.ForeignKey(
|
||||
Powder, on_delete=models.PROTECT,
|
||||
related_name='batches', verbose_name=_('powder'),
|
||||
)
|
||||
powder_charge_gr = models.DecimalField(
|
||||
_('powder charge (gr)'), max_digits=5, decimal_places=2
|
||||
)
|
||||
quantity = models.PositiveSmallIntegerField(
|
||||
_('quantity loaded'), null=True, blank=True
|
||||
)
|
||||
oal_mm = models.DecimalField(
|
||||
_('overall length (mm)'), max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
coal_mm = models.DecimalField(
|
||||
_('cartridge overall length to ogive (mm)'), max_digits=6, decimal_places=2,
|
||||
null=True, blank=True
|
||||
)
|
||||
crimp = models.CharField(
|
||||
_('crimp'), max_length=6, choices=CrimpType.choices, default=CrimpType.NONE
|
||||
)
|
||||
case_prep_notes = models.TextField(_('case prep notes'), blank=True)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
loaded_at = models.DateField(_('loaded at'), null=True, blank=True)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('reloaded ammo batch')
|
||||
verbose_name_plural = _('reloaded ammo batches')
|
||||
ordering = ['recipe', 'powder_charge_gr']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['recipe', 'powder', 'powder_charge_gr'],
|
||||
name='unique_batch_charge_per_recipe_powder'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.recipe.name} / {self.powder} {self.powder_charge_gr}gr"
|
||||
16
apps/gears/permissions.py
Normal file
16
apps/gears/permissions.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
|
||||
class IsAdminOrReadOnly(BasePermission):
|
||||
"""
|
||||
Read access for any authenticated user.
|
||||
Write access (create / update / delete) restricted to staff only.
|
||||
Regular users may still POST (to submit a pending gear request) —
|
||||
that special case is handled at the view level, not here.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return request.user.is_staff
|
||||
391
apps/gears/serializers.py
Normal file
391
apps/gears/serializers.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.calibers.models import Caliber
|
||||
|
||||
from .models import (
|
||||
Ammo,
|
||||
Bipod,
|
||||
Brass,
|
||||
Bullet,
|
||||
Firearm,
|
||||
Gear,
|
||||
GearStatus,
|
||||
Magazine,
|
||||
Powder,
|
||||
Primer,
|
||||
ReloadedAmmoBatch,
|
||||
ReloadRecipe,
|
||||
Rig,
|
||||
RigItem,
|
||||
Scope,
|
||||
Suppressor,
|
||||
UserGear,
|
||||
get_concrete_gear,
|
||||
)
|
||||
|
||||
|
||||
# ── Caliber helper serializer ─────────────────────────────────────────────────
|
||||
|
||||
class CaliberMinSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Caliber
|
||||
fields = ['id', 'name', 'short_name']
|
||||
|
||||
|
||||
# ── Gear catalog serializers ──────────────────────────────────────────────────
|
||||
|
||||
class GearBaseSerializer(serializers.ModelSerializer):
|
||||
"""Common read-only fields for every gear type (used in list views)."""
|
||||
|
||||
class Meta:
|
||||
model = Gear
|
||||
fields = [
|
||||
'id', 'brand', 'model_name', 'description',
|
||||
'gear_type', 'status', 'created_at',
|
||||
]
|
||||
read_only_fields = ['gear_type', 'status', 'created_at']
|
||||
|
||||
|
||||
class FirearmSerializer(serializers.ModelSerializer):
|
||||
caliber = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Caliber.objects.filter(status='VERIFIED'),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
caliber_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_caliber_detail(self, obj):
|
||||
if obj.caliber_id:
|
||||
return CaliberMinSerializer(obj.caliber).data
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Firearm
|
||||
fields = [
|
||||
'id', 'brand', 'model_name', 'description',
|
||||
'gear_type', 'status',
|
||||
'firearm_type', 'caliber', 'caliber_detail',
|
||||
'barrel_length_mm', 'magazine_capacity',
|
||||
'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at',
|
||||
]
|
||||
read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'caliber_detail']
|
||||
|
||||
|
||||
class ScopeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Scope
|
||||
fields = [
|
||||
'id', 'brand', 'model_name', 'description',
|
||||
'gear_type', 'status',
|
||||
'magnification_min', 'magnification_max',
|
||||
'objective_diameter_mm', 'tube_diameter_mm', 'reticle_type',
|
||||
'adjustment_unit', 'focal_plane',
|
||||
'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at',
|
||||
]
|
||||
read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at']
|
||||
|
||||
|
||||
class SuppressorSerializer(serializers.ModelSerializer):
|
||||
max_caliber = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Caliber.objects.filter(status='VERIFIED'),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
max_caliber_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_max_caliber_detail(self, obj):
|
||||
if obj.max_caliber_id:
|
||||
return CaliberMinSerializer(obj.max_caliber).data
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Suppressor
|
||||
fields = [
|
||||
'id', 'brand', 'model_name', 'description',
|
||||
'gear_type', 'status',
|
||||
'max_caliber', 'max_caliber_detail', 'thread_pitch', 'length_mm', 'weight_g',
|
||||
'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at',
|
||||
]
|
||||
read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'max_caliber_detail']
|
||||
|
||||
|
||||
class BipodSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bipod
|
||||
fields = [
|
||||
'id', 'brand', 'model_name', 'description',
|
||||
'gear_type', 'status',
|
||||
'min_height_mm', 'max_height_mm', 'attachment_type',
|
||||
'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at',
|
||||
]
|
||||
read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at']
|
||||
|
||||
|
||||
class MagazineSerializer(serializers.ModelSerializer):
|
||||
caliber = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Caliber.objects.filter(status='VERIFIED'),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
caliber_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_caliber_detail(self, obj):
|
||||
if obj.caliber_id:
|
||||
return CaliberMinSerializer(obj.caliber).data
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Magazine
|
||||
fields = [
|
||||
'id', 'brand', 'model_name', 'description',
|
||||
'gear_type', 'status',
|
||||
'caliber', 'caliber_detail', 'capacity',
|
||||
'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at',
|
||||
]
|
||||
read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'caliber_detail']
|
||||
|
||||
|
||||
# Maps gear_type discriminator → serializer class
|
||||
_GEAR_SERIALIZER_MAP = {
|
||||
'FIREARM': FirearmSerializer,
|
||||
'SCOPE': ScopeSerializer,
|
||||
'SUPPRESSOR': SuppressorSerializer,
|
||||
'BIPOD': BipodSerializer,
|
||||
'MAGAZINE': MagazineSerializer,
|
||||
}
|
||||
|
||||
|
||||
class PolymorphicGearSerializer(serializers.BaseSerializer):
|
||||
"""
|
||||
Read-only serializer that dispatches to the correct typed serializer
|
||||
based on gear_type. Used when embedding gear details in nested responses.
|
||||
"""
|
||||
def to_representation(self, instance):
|
||||
concrete = get_concrete_gear(instance)
|
||||
serializer_cls = _GEAR_SERIALIZER_MAP.get(instance.gear_type, GearBaseSerializer)
|
||||
return serializer_cls(concrete, context=self.context).data
|
||||
|
||||
|
||||
# ── User inventory serializers ────────────────────────────────────────────────
|
||||
|
||||
class UserGearSerializer(serializers.ModelSerializer):
|
||||
# Write: accept a gear FK (VERIFIED or user's own PENDING)
|
||||
gear = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Gear.objects.none(), # narrowed in __init__
|
||||
write_only=True,
|
||||
)
|
||||
# Read: return full typed gear details
|
||||
gear_detail = PolymorphicGearSerializer(source='gear', read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
from django.db.models import Q
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
self.fields['gear'].queryset = Gear.objects.filter(
|
||||
Q(status=GearStatus.VERIFIED) |
|
||||
Q(status=GearStatus.PENDING, submitted_by=request.user)
|
||||
)
|
||||
else:
|
||||
self.fields['gear'].queryset = Gear.objects.filter(status=GearStatus.VERIFIED)
|
||||
|
||||
class Meta:
|
||||
model = UserGear
|
||||
fields = [
|
||||
'id',
|
||||
'gear', # write
|
||||
'gear_detail', # read
|
||||
'nickname', 'serial_number', 'purchase_date', 'notes',
|
||||
'added_at',
|
||||
]
|
||||
read_only_fields = ['added_at']
|
||||
|
||||
|
||||
# ── Rig serializers ───────────────────────────────────────────────────────────
|
||||
|
||||
class RigItemReadSerializer(serializers.ModelSerializer):
|
||||
user_gear = UserGearSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RigItem
|
||||
fields = ['id', 'user_gear', 'role']
|
||||
|
||||
|
||||
class RigItemCreateSerializer(serializers.ModelSerializer):
|
||||
"""Used when adding an item to a rig (POST /rigs/{id}/items/)."""
|
||||
user_gear = serializers.PrimaryKeyRelatedField(
|
||||
queryset=UserGear.objects.none() # narrowed to request.user in __init__
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RigItem
|
||||
fields = ['id', 'user_gear', 'role']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
self.fields['user_gear'].queryset = UserGear.objects.filter(
|
||||
user=request.user
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
rig = self.context['rig']
|
||||
# Build a temporary instance for clean() validation
|
||||
instance = RigItem(rig=rig, **attrs)
|
||||
instance.clean()
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
rig = self.context['rig']
|
||||
return RigItem.objects.create(rig=rig, **validated_data)
|
||||
|
||||
|
||||
class RigSerializer(serializers.ModelSerializer):
|
||||
rig_items = RigItemReadSerializer(many=True, read_only=True)
|
||||
primary_caliber = serializers.SerializerMethodField()
|
||||
|
||||
def get_primary_caliber(self, obj):
|
||||
for item in obj.rig_items.all():
|
||||
if item.role == 'PRIMARY':
|
||||
gear = item.user_gear.gear
|
||||
# Firearm caliber lives on the MTI child table
|
||||
try:
|
||||
firearm = gear.firearm
|
||||
if firearm.caliber_id:
|
||||
return {'id': firearm.caliber_id, 'name': firearm.caliber.name}
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Rig
|
||||
fields = ['id', 'name', 'description', 'is_public', 'primary_caliber', 'rig_items', 'created_at', 'updated_at']
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
# ── Ammo catalog serializer ───────────────────────────────────────────────────
|
||||
|
||||
class AmmoSerializer(serializers.ModelSerializer):
|
||||
caliber = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Caliber.objects.filter(status='VERIFIED'),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
caliber_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_caliber_detail(self, obj):
|
||||
if obj.caliber_id:
|
||||
return CaliberMinSerializer(obj.caliber).data
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Ammo
|
||||
fields = [
|
||||
'id', 'brand', 'name', 'caliber', 'caliber_detail',
|
||||
'bullet_weight_gr', 'bullet_type',
|
||||
'primer_size', 'case_material',
|
||||
'muzzle_velocity_fps', 'muzzle_energy_ftlb',
|
||||
'box_count', 'notes', 'status',
|
||||
'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at',
|
||||
]
|
||||
read_only_fields = ['status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'caliber_detail']
|
||||
|
||||
|
||||
# ── Reloading component serializers ──────────────────────────────────────────
|
||||
|
||||
class PrimerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Primer
|
||||
fields = ['id', 'brand', 'name', 'size', 'notes', 'status', 'submitted_by']
|
||||
read_only_fields = ['status', 'submitted_by']
|
||||
|
||||
|
||||
class BrassSerializer(serializers.ModelSerializer):
|
||||
caliber = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Caliber.objects.filter(status='VERIFIED'),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
caliber_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_caliber_detail(self, obj):
|
||||
if obj.caliber_id:
|
||||
return CaliberMinSerializer(obj.caliber).data
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Brass
|
||||
fields = ['id', 'brand', 'caliber', 'caliber_detail', 'primer_pocket', 'trim_length_mm', 'notes', 'status', 'submitted_by']
|
||||
read_only_fields = ['status', 'submitted_by', 'caliber_detail']
|
||||
|
||||
|
||||
class BulletSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bullet
|
||||
fields = [
|
||||
'id', 'brand', 'model_name', 'weight_gr', 'bullet_type',
|
||||
'diameter_mm', 'length_mm', 'bc_g1', 'bc_g7',
|
||||
'status', 'submitted_by',
|
||||
]
|
||||
read_only_fields = ['status', 'submitted_by']
|
||||
|
||||
|
||||
class PowderSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Powder
|
||||
fields = ['id', 'brand', 'name', 'powder_type', 'burn_rate_index', 'notes', 'status', 'submitted_by']
|
||||
read_only_fields = ['status', 'submitted_by']
|
||||
|
||||
|
||||
# ── Reload development serializers ────────────────────────────────────────────
|
||||
|
||||
class ReloadedAmmoBatchSerializer(serializers.ModelSerializer):
|
||||
powder_detail = PowderSerializer(source='powder', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ReloadedAmmoBatch
|
||||
fields = [
|
||||
'id', 'recipe',
|
||||
'powder', # write (PK)
|
||||
'powder_detail', # read (nested)
|
||||
'powder_charge_gr', 'quantity',
|
||||
'oal_mm', 'coal_mm', 'crimp',
|
||||
'case_prep_notes', 'notes', 'loaded_at',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
class ReloadRecipeSerializer(serializers.ModelSerializer):
|
||||
caliber = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Caliber.objects.filter(status='VERIFIED'),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
caliber_detail = serializers.SerializerMethodField()
|
||||
primer_detail = PrimerSerializer(source='primer', read_only=True)
|
||||
brass_detail = BrassSerializer(source='brass', read_only=True)
|
||||
bullet_detail = BulletSerializer(source='bullet', read_only=True)
|
||||
batches = ReloadedAmmoBatchSerializer(many=True, read_only=True)
|
||||
|
||||
def get_caliber_detail(self, obj):
|
||||
if obj.caliber_id:
|
||||
return CaliberMinSerializer(obj.caliber).data
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = ReloadRecipe
|
||||
fields = [
|
||||
'id', 'name', 'caliber', 'caliber_detail',
|
||||
'primer', # write
|
||||
'primer_detail', # read
|
||||
'brass',
|
||||
'brass_detail',
|
||||
'bullet',
|
||||
'bullet_detail',
|
||||
'notes', 'is_public', 'batches',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'caliber_detail']
|
||||
51
apps/gears/urls.py
Normal file
51
apps/gears/urls.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
AmmoViewSet,
|
||||
BipodViewSet,
|
||||
BrassViewSet,
|
||||
BulletViewSet,
|
||||
FirearmViewSet,
|
||||
MagazineViewSet,
|
||||
PowderViewSet,
|
||||
PrimerViewSet,
|
||||
ReloadedAmmoBatchViewSet,
|
||||
ReloadRecipeViewSet,
|
||||
RigViewSet,
|
||||
ScopeViewSet,
|
||||
SuppressorViewSet,
|
||||
UserGearViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
|
||||
# Gear catalog — per type
|
||||
router.register(r'gears/firearms', FirearmViewSet, basename='firearm')
|
||||
router.register(r'gears/scopes', ScopeViewSet, basename='scope')
|
||||
router.register(r'gears/suppressors', SuppressorViewSet, basename='suppressor')
|
||||
router.register(r'gears/bipods', BipodViewSet, basename='bipod')
|
||||
router.register(r'gears/magazines', MagazineViewSet, basename='magazine')
|
||||
|
||||
# Ammo catalog
|
||||
router.register(r'gears/ammo', AmmoViewSet, basename='ammo')
|
||||
|
||||
# Reloading components (admin CRUD, read-only for users)
|
||||
router.register(r'gears/components/primers', PrimerViewSet, basename='primer')
|
||||
router.register(r'gears/components/brass', BrassViewSet, basename='brass')
|
||||
router.register(r'gears/components/bullets', BulletViewSet, basename='bullet')
|
||||
router.register(r'gears/components/powders', PowderViewSet, basename='powder')
|
||||
|
||||
# User inventory
|
||||
router.register(r'inventory', UserGearViewSet, basename='usergear')
|
||||
|
||||
# Rigs
|
||||
router.register(r'rigs', RigViewSet, basename='rig')
|
||||
|
||||
# Reload development (user-owned)
|
||||
router.register(r'reloading/recipes', ReloadRecipeViewSet, basename='reload-recipe')
|
||||
router.register(r'reloading/batches', ReloadedAmmoBatchViewSet, basename='reload-batch')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
387
apps/gears/views.py
Normal file
387
apps/gears/views.py
Normal file
@@ -0,0 +1,387 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import (
|
||||
Ammo,
|
||||
Bipod,
|
||||
Brass,
|
||||
Bullet,
|
||||
Firearm,
|
||||
GearStatus,
|
||||
Magazine,
|
||||
Powder,
|
||||
Primer,
|
||||
ReloadedAmmoBatch,
|
||||
ReloadRecipe,
|
||||
Rig,
|
||||
RigItem,
|
||||
Scope,
|
||||
Suppressor,
|
||||
UserGear,
|
||||
)
|
||||
from .permissions import IsAdminOrReadOnly
|
||||
from .serializers import (
|
||||
AmmoSerializer,
|
||||
BipodSerializer,
|
||||
BrassSerializer,
|
||||
BulletSerializer,
|
||||
FirearmSerializer,
|
||||
MagazineSerializer,
|
||||
PowderSerializer,
|
||||
PrimerSerializer,
|
||||
ReloadedAmmoBatchSerializer,
|
||||
ReloadRecipeSerializer,
|
||||
RigItemCreateSerializer,
|
||||
RigItemReadSerializer,
|
||||
RigSerializer,
|
||||
ScopeSerializer,
|
||||
SuppressorSerializer,
|
||||
UserGearSerializer,
|
||||
)
|
||||
|
||||
|
||||
# ── Gear catalog — shared mixin ───────────────────────────────────────────────
|
||||
|
||||
class GearCatalogMixin:
|
||||
"""
|
||||
Behaviour shared across all per-type gear viewsets.
|
||||
|
||||
- GET list/retrieve: authenticated users see only VERIFIED entries;
|
||||
staff see everything.
|
||||
- POST: any authenticated user may submit a new entry (status=PENDING).
|
||||
Staff submissions are auto-verified.
|
||||
- PUT/PATCH/DELETE: staff only.
|
||||
- POST .../verify/ or .../reject/: staff only.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
from django.db.models import Q
|
||||
if self.request.user.is_staff:
|
||||
return self.queryset.all()
|
||||
return self.queryset.filter(
|
||||
Q(status=GearStatus.VERIFIED) |
|
||||
Q(status=GearStatus.PENDING, submitted_by=self.request.user)
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ('update', 'partial_update', 'destroy'):
|
||||
return [IsAdminUser()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if self.request.user.is_staff:
|
||||
serializer.save(
|
||||
status=GearStatus.VERIFIED,
|
||||
reviewed_by=self.request.user,
|
||||
reviewed_at=timezone.now(),
|
||||
)
|
||||
else:
|
||||
serializer.save(
|
||||
status=GearStatus.PENDING,
|
||||
submitted_by=self.request.user,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsAdminUser])
|
||||
def verify(self, request, pk=None):
|
||||
gear = self.get_object()
|
||||
gear.verify(reviewed_by=request.user)
|
||||
return Response(self.get_serializer(gear).data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsAdminUser])
|
||||
def reject(self, request, pk=None):
|
||||
gear = self.get_object()
|
||||
gear.reject(reviewed_by=request.user)
|
||||
return Response(self.get_serializer(gear).data)
|
||||
|
||||
|
||||
# ── Per-type gear viewsets ────────────────────────────────────────────────────
|
||||
|
||||
class FirearmViewSet(GearCatalogMixin, viewsets.ModelViewSet):
|
||||
queryset = Firearm.objects.select_related('submitted_by', 'reviewed_by', 'caliber')
|
||||
serializer_class = FirearmSerializer
|
||||
search_fields = ['brand', 'model_name', 'caliber__name']
|
||||
ordering_fields = ['brand', 'model_name', 'caliber__name', 'created_at']
|
||||
filterset_fields = ['firearm_type', 'caliber', 'status']
|
||||
|
||||
|
||||
class ScopeViewSet(GearCatalogMixin, viewsets.ModelViewSet):
|
||||
queryset = Scope.objects.select_related('submitted_by', 'reviewed_by')
|
||||
serializer_class = ScopeSerializer
|
||||
search_fields = ['brand', 'model_name', 'reticle_type']
|
||||
ordering_fields = ['brand', 'model_name', 'magnification_max', 'created_at']
|
||||
filterset_fields = ['reticle_type', 'status']
|
||||
|
||||
|
||||
class SuppressorViewSet(GearCatalogMixin, viewsets.ModelViewSet):
|
||||
queryset = Suppressor.objects.select_related('submitted_by', 'reviewed_by', 'max_caliber')
|
||||
serializer_class = SuppressorSerializer
|
||||
search_fields = ['brand', 'model_name', 'max_caliber__name']
|
||||
ordering_fields = ['brand', 'model_name', 'created_at']
|
||||
filterset_fields = ['max_caliber', 'status']
|
||||
|
||||
|
||||
class BipodViewSet(GearCatalogMixin, viewsets.ModelViewSet):
|
||||
queryset = Bipod.objects.select_related('submitted_by', 'reviewed_by')
|
||||
serializer_class = BipodSerializer
|
||||
search_fields = ['brand', 'model_name']
|
||||
ordering_fields = ['brand', 'model_name', 'created_at']
|
||||
filterset_fields = ['attachment_type', 'status']
|
||||
|
||||
|
||||
class MagazineViewSet(GearCatalogMixin, viewsets.ModelViewSet):
|
||||
queryset = Magazine.objects.select_related('submitted_by', 'reviewed_by', 'caliber')
|
||||
serializer_class = MagazineSerializer
|
||||
search_fields = ['brand', 'model_name', 'caliber__name']
|
||||
ordering_fields = ['brand', 'model_name', 'caliber__name', 'created_at']
|
||||
filterset_fields = ['caliber', 'status']
|
||||
|
||||
|
||||
# ── User inventory ────────────────────────────────────────────────────────────
|
||||
|
||||
class UserGearViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
The authenticated user's personal gear inventory.
|
||||
Each item links a catalog Gear to the user with optional personal metadata.
|
||||
"""
|
||||
serializer_class = UserGearSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = None
|
||||
search_fields = ['nickname', 'serial_number', 'gear__brand', 'gear__model_name']
|
||||
ordering_fields = ['added_at', 'nickname']
|
||||
filterset_fields = ['gear__gear_type']
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
UserGear.objects
|
||||
.filter(user=self.request.user)
|
||||
.select_related('gear')
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
|
||||
# ── Rigs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class RigViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
The authenticated user's loadout rigs.
|
||||
|
||||
Items are managed via nested endpoints:
|
||||
POST /rigs/{id}/items/ → add a UserGear to the rig
|
||||
DELETE /rigs/{id}/items/{item_id}/ → remove an item from the rig
|
||||
"""
|
||||
serializer_class = RigSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = None
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name', 'created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Rig.objects
|
||||
.filter(user=self.request.user)
|
||||
.prefetch_related('rig_items__user_gear__gear__firearm')
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='items')
|
||||
def add_item(self, request, pk=None):
|
||||
rig = self.get_object()
|
||||
serializer = RigItemCreateSerializer(
|
||||
data=request.data,
|
||||
context={'request': request, 'rig': rig},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
item = serializer.save()
|
||||
return Response(
|
||||
RigItemReadSerializer(item, context={'request': request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=['delete'],
|
||||
url_path=r'items/(?P<item_pk>[^/.]+)',
|
||||
)
|
||||
def remove_item(self, request, pk=None, item_pk=None):
|
||||
rig = self.get_object()
|
||||
item = get_object_or_404(RigItem, pk=item_pk, rig=rig)
|
||||
item.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ── Ammo catalog ──────────────────────────────────────────────────────────────
|
||||
|
||||
class AmmoViewSet(GearCatalogMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
Commercial/factory ammunition catalog.
|
||||
Same moderation flow as gear types: users submit PENDING, staff verify/reject.
|
||||
"""
|
||||
queryset = Ammo.objects.select_related('submitted_by', 'reviewed_by', 'caliber')
|
||||
serializer_class = AmmoSerializer
|
||||
search_fields = ['brand', 'name', 'caliber__name']
|
||||
ordering_fields = ['brand', 'name', 'caliber__name', 'bullet_weight_gr', 'created_at']
|
||||
filterset_fields = ['bullet_type', 'caliber', 'primer_size', 'case_material', 'status']
|
||||
|
||||
|
||||
# ── Reloading components ──────────────────────────────────────────────────────
|
||||
|
||||
class ComponentViewSetMixin:
|
||||
"""
|
||||
Any authenticated user may submit a component (status=PENDING).
|
||||
Staff submissions are auto-verified. Only staff may update/delete or verify/reject.
|
||||
"""
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
from django.db.models import Q
|
||||
qs = super().get_queryset()
|
||||
if self.request.user.is_staff:
|
||||
return qs
|
||||
return qs.filter(
|
||||
Q(status=GearStatus.VERIFIED) |
|
||||
Q(status=GearStatus.PENDING, submitted_by=self.request.user)
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ('update', 'partial_update', 'destroy'):
|
||||
return [IsAdminUser()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if self.request.user.is_staff:
|
||||
serializer.save(status=GearStatus.VERIFIED, submitted_by=self.request.user)
|
||||
else:
|
||||
serializer.save(status=GearStatus.PENDING, submitted_by=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsAdminUser])
|
||||
def verify(self, request, pk=None):
|
||||
obj = self.get_object()
|
||||
obj.verify()
|
||||
return Response(self.get_serializer(obj).data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsAdminUser])
|
||||
def reject(self, request, pk=None):
|
||||
obj = self.get_object()
|
||||
obj.reject()
|
||||
return Response(self.get_serializer(obj).data)
|
||||
|
||||
|
||||
class PrimerViewSet(ComponentViewSetMixin, viewsets.ModelViewSet):
|
||||
queryset = Primer.objects.all()
|
||||
serializer_class = PrimerSerializer
|
||||
search_fields = ['brand', 'name']
|
||||
ordering_fields = ['brand', 'name']
|
||||
filterset_fields = ['size']
|
||||
|
||||
|
||||
class BrassViewSet(ComponentViewSetMixin, viewsets.ModelViewSet):
|
||||
queryset = Brass.objects.select_related('caliber')
|
||||
serializer_class = BrassSerializer
|
||||
search_fields = ['brand', 'caliber__name']
|
||||
ordering_fields = ['brand', 'caliber__name']
|
||||
filterset_fields = ['caliber', 'primer_pocket']
|
||||
|
||||
|
||||
class BulletViewSet(ComponentViewSetMixin, viewsets.ModelViewSet):
|
||||
queryset = Bullet.objects.all()
|
||||
serializer_class = BulletSerializer
|
||||
search_fields = ['brand', 'model_name']
|
||||
ordering_fields = ['brand', 'model_name', 'weight_gr']
|
||||
filterset_fields = ['bullet_type']
|
||||
|
||||
|
||||
class PowderViewSet(ComponentViewSetMixin, viewsets.ModelViewSet):
|
||||
queryset = Powder.objects.all()
|
||||
serializer_class = PowderSerializer
|
||||
search_fields = ['brand', 'name']
|
||||
ordering_fields = ['brand', 'name', 'burn_rate_index']
|
||||
filterset_fields = ['powder_type']
|
||||
|
||||
|
||||
# ── Reload development ────────────────────────────────────────────────────────
|
||||
|
||||
class ReloadRecipeViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
User's reload recipes (fixed primer + brass + bullet combinations).
|
||||
Batches (different powder charges) are created via /reloading/batches/.
|
||||
"""
|
||||
serializer_class = ReloadRecipeSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = None
|
||||
search_fields = ['name', 'caliber__name']
|
||||
ordering_fields = ['name', 'caliber__name', 'created_at']
|
||||
filterset_fields = ['caliber']
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
ReloadRecipe.objects
|
||||
.filter(user=self.request.user)
|
||||
.select_related('primer', 'brass', 'bullet')
|
||||
.prefetch_related('batches__powder')
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='stats')
|
||||
def stats(self, request, pk=None):
|
||||
"""
|
||||
Per-batch velocity statistics for all batches in this recipe.
|
||||
Useful for comparing powder charges and identifying the optimal load.
|
||||
"""
|
||||
# Local imports to avoid circular module-level dependency tools ↔ gears
|
||||
from apps.tools.models import Shot
|
||||
from apps.tools.serializers import _compute_stats
|
||||
|
||||
recipe = self.get_object()
|
||||
result = []
|
||||
for batch in recipe.batches.select_related('powder').prefetch_related(
|
||||
'shot_groups__shots'
|
||||
):
|
||||
all_shots = Shot.objects.filter(group__ammo_batch=batch)
|
||||
result.append({
|
||||
'batch_id': batch.pk,
|
||||
'powder': str(batch.powder),
|
||||
'powder_charge_gr': str(batch.powder_charge_gr),
|
||||
'stats': _compute_stats(all_shots),
|
||||
})
|
||||
return Response(result)
|
||||
|
||||
|
||||
class ReloadedAmmoBatchViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
Individual powder charge batches under a recipe.
|
||||
Filter by recipe using ?recipe=<id>.
|
||||
"""
|
||||
serializer_class = ReloadedAmmoBatchSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = None
|
||||
search_fields = ['notes', 'powder__name', 'powder__brand']
|
||||
ordering_fields = ['powder_charge_gr', 'loaded_at', 'created_at']
|
||||
filterset_fields = ['recipe', 'powder']
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
ReloadedAmmoBatch.objects
|
||||
.filter(recipe__user=self.request.user)
|
||||
.select_related('recipe', 'powder')
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='stats')
|
||||
def stats(self, request, pk=None):
|
||||
"""Velocity statistics for all ShotGroups linked to this batch."""
|
||||
# Local imports to avoid circular module-level dependency tools ↔ gears
|
||||
from apps.tools.models import Shot
|
||||
from apps.tools.serializers import _compute_stats
|
||||
|
||||
batch = self.get_object()
|
||||
all_shots = Shot.objects.filter(group__ammo_batch=batch)
|
||||
return Response(_compute_stats(all_shots))
|
||||
0
apps/photos/__init__.py
Normal file
0
apps/photos/__init__.py
Normal file
50
apps/photos/admin.py
Normal file
50
apps/photos/admin.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .models import GroupPhoto, GroupPhotoAnalysis, Photo, PointOfImpact
|
||||
|
||||
|
||||
@admin.register(Photo)
|
||||
class PhotoAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'content_type', 'size_kb', 'width', 'height', 'uploaded_by', 'uploaded_at')
|
||||
search_fields = ('uploaded_by__email', 'description', 'content_type')
|
||||
readonly_fields = ('size', 'width', 'height', 'uploaded_at', 'preview')
|
||||
raw_id_fields = ('uploaded_by',)
|
||||
|
||||
def size_kb(self, obj):
|
||||
return f'{obj.size / 1024:.1f} KB'
|
||||
size_kb.short_description = 'Size'
|
||||
|
||||
def preview(self, obj):
|
||||
return format_html(
|
||||
'<img src="/api/photos/{}/data/" style="max-width:300px;max-height:300px;" />',
|
||||
obj.pk,
|
||||
)
|
||||
preview.short_description = 'Preview'
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
fields = ['content_type', 'size', 'width', 'height', 'uploaded_by', 'description', 'uploaded_at']
|
||||
if obj:
|
||||
fields.insert(0, 'preview')
|
||||
return fields
|
||||
|
||||
|
||||
class GroupPhotoAnalysisInline(admin.StackedInline):
|
||||
model = GroupPhotoAnalysis
|
||||
extra = 0
|
||||
max_num = 1
|
||||
|
||||
|
||||
class PointOfImpactInline(admin.TabularInline):
|
||||
model = PointOfImpact
|
||||
extra = 0
|
||||
fields = ('order', 'shot', 'x_px', 'y_px', 'x_mm', 'y_mm', 'radius_mm', 'notes')
|
||||
raw_id_fields = ('shot',)
|
||||
|
||||
|
||||
@admin.register(GroupPhoto)
|
||||
class GroupPhotoAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'shot_group', 'caption', 'order')
|
||||
search_fields = ('caption', 'shot_group__label', 'shot_group__analysis__name')
|
||||
raw_id_fields = ('photo', 'shot_group')
|
||||
inlines = [GroupPhotoAnalysisInline, PointOfImpactInline]
|
||||
64
apps/photos/analysis.py
Normal file
64
apps/photos/analysis.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Group size computation from PointOfImpact real-world coordinates.
|
||||
|
||||
All measurements in millimetres. Origin is point-of-aim:
|
||||
x > 0 = right (windage), y > 0 = up (elevation).
|
||||
"""
|
||||
import math
|
||||
|
||||
|
||||
def compute_group_size(
|
||||
points: list[tuple[float, float]],
|
||||
distance_m: float | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Compute ballistic group metrics from a list of (x_mm, y_mm) coordinates.
|
||||
|
||||
Args:
|
||||
points: list of (x_mm, y_mm) tuples — minimum 2 required.
|
||||
distance_m: shooting distance in metres, used for MOA conversion.
|
||||
Pass None to leave MOA fields as None.
|
||||
|
||||
Returns:
|
||||
dict with keys matching GroupPhotoAnalysis fields.
|
||||
"""
|
||||
if len(points) < 2:
|
||||
raise ValueError("At least 2 points of impact are required.")
|
||||
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
n = len(points)
|
||||
|
||||
# Extreme spread: maximum pairwise distance
|
||||
group_size_mm = 0.0
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
d = math.sqrt((xs[i] - xs[j]) ** 2 + (ys[i] - ys[j]) ** 2)
|
||||
if d > group_size_mm:
|
||||
group_size_mm = d
|
||||
|
||||
# Centroid
|
||||
cx = sum(xs) / n
|
||||
cy = sum(ys) / n
|
||||
|
||||
# Mean radius: average distance from centroid
|
||||
mean_radius_mm = sum(
|
||||
math.sqrt((x - cx) ** 2 + (y - cy) ** 2) for x, y in points
|
||||
) / n
|
||||
|
||||
def to_moa(mm: float) -> float | None:
|
||||
"""Convert mm at distance_m to MOA. 1 MOA ≈ 0.29089 mm/m at that distance."""
|
||||
if distance_m is None or distance_m <= 0:
|
||||
return None
|
||||
return round(mm / (distance_m * 0.29089), 3)
|
||||
|
||||
return {
|
||||
'group_size_mm': round(group_size_mm, 2),
|
||||
'group_size_moa': to_moa(group_size_mm),
|
||||
'mean_radius_mm': round(mean_radius_mm, 2),
|
||||
'mean_radius_moa': to_moa(mean_radius_mm),
|
||||
'windage_offset_mm': round(cx, 2),
|
||||
'windage_offset_moa': to_moa(cx),
|
||||
'elevation_offset_mm': round(cy, 2),
|
||||
'elevation_offset_moa': to_moa(cy),
|
||||
}
|
||||
7
apps/photos/apps.py
Normal file
7
apps/photos/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PhotosConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.photos'
|
||||
verbose_name = 'Photos'
|
||||
100
apps/photos/migrations/0001_initial.py
Normal file
100
apps/photos/migrations/0001_initial.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-25 10:15
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GroupPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('caption', models.CharField(blank=True, max_length=255, verbose_name='caption')),
|
||||
('order', models.PositiveSmallIntegerField(default=0, verbose_name='order')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'group photo',
|
||||
'verbose_name_plural': 'group photos',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PointOfImpact',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.PositiveSmallIntegerField(default=0, help_text='1-based sequence; used when shot FK is absent.', verbose_name='order')),
|
||||
('x_px', models.PositiveSmallIntegerField(verbose_name='x (px)')),
|
||||
('y_px', models.PositiveSmallIntegerField(verbose_name='y (px)')),
|
||||
('x_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='x offset (mm)')),
|
||||
('y_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='y offset (mm)')),
|
||||
('radius_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='bullet hole radius (mm)')),
|
||||
('notes', models.CharField(blank=True, max_length=255, verbose_name='notes')),
|
||||
('group_photo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='points_of_impact', to='photos.groupphoto', verbose_name='group photo')),
|
||||
('shot', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='point_of_impact', to='tools.shot', verbose_name='shot')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'point of impact',
|
||||
'verbose_name_plural': 'points of impact',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Photo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('data', models.BinaryField(verbose_name='data')),
|
||||
('content_type', models.CharField(max_length=50, verbose_name='content type')),
|
||||
('size', models.PositiveIntegerField(verbose_name='size (bytes)')),
|
||||
('width', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='width (px)')),
|
||||
('height', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='height (px)')),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='uploaded at')),
|
||||
('description', models.CharField(blank=True, max_length=255, verbose_name='description')),
|
||||
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='photos', to=settings.AUTH_USER_MODEL, verbose_name='uploaded by')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'photo',
|
||||
'verbose_name_plural': 'photos',
|
||||
'ordering': ['-uploaded_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GroupPhotoAnalysis',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('group_size_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='group size (mm)')),
|
||||
('group_size_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='group size (MOA)')),
|
||||
('elevation_offset_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='elevation offset (mm)')),
|
||||
('elevation_offset_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='elevation offset (MOA)')),
|
||||
('windage_offset_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='windage offset (mm)')),
|
||||
('windage_offset_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='windage offset (MOA)')),
|
||||
('mean_radius_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='mean radius (mm)')),
|
||||
('mean_radius_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='mean radius (MOA)')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('group_photo', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='analysis', to='photos.groupphoto', verbose_name='group photo')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'group photo analysis',
|
||||
'verbose_name_plural': 'group photo analyses',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='groupphoto',
|
||||
name='photo',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='group_photo', to='photos.photo', verbose_name='photo'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='groupphoto',
|
||||
name='shot_group',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_photos', to='tools.shotgroup', verbose_name='shot group'),
|
||||
),
|
||||
]
|
||||
20
apps/photos/migrations/0002_groupphoto_nullable_shotgroup.py
Normal file
20
apps/photos/migrations/0002_groupphoto_nullable_shotgroup.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-30 13:12
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tools', '0004_shotgroup_nullable_analysis_user_ammo'),
|
||||
('photos', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='groupphoto',
|
||||
name='shot_group',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='group_photos', to='tools.shotgroup', verbose_name='shot group'),
|
||||
),
|
||||
]
|
||||
16
apps/photos/migrations/0003_groupphoto_is_public.py
Normal file
16
apps/photos/migrations/0003_groupphoto_is_public.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('photos', '0002_groupphoto_nullable_shotgroup'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='groupphoto',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
]
|
||||
0
apps/photos/migrations/__init__.py
Normal file
0
apps/photos/migrations/__init__.py
Normal file
172
apps/photos/models.py
Normal file
172
apps/photos/models.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class Photo(models.Model):
|
||||
"""
|
||||
Generic DB-backed image. Raw bytes are stored in PostgreSQL (bytea).
|
||||
Served via GET /api/photos/{id}/data/ — no filesystem or S3 required.
|
||||
"""
|
||||
data = models.BinaryField(_('data'))
|
||||
content_type = models.CharField(_('content type'), max_length=50) # e.g. 'image/jpeg'
|
||||
size = models.PositiveIntegerField(_('size (bytes)'))
|
||||
width = models.PositiveSmallIntegerField(_('width (px)'), null=True, blank=True)
|
||||
height = models.PositiveSmallIntegerField(_('height (px)'), null=True, blank=True)
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='photos',
|
||||
verbose_name=_('uploaded by'),
|
||||
)
|
||||
uploaded_at = models.DateTimeField(_('uploaded at'), auto_now_add=True)
|
||||
description = models.CharField(_('description'), max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('photo')
|
||||
verbose_name_plural = _('photos')
|
||||
ordering = ['-uploaded_at']
|
||||
|
||||
def __str__(self):
|
||||
owner = self.uploaded_by.email if self.uploaded_by_id else _('anonymous')
|
||||
return f"Photo #{self.pk} ({self.content_type}, {owner})"
|
||||
|
||||
|
||||
class GroupPhoto(models.Model):
|
||||
"""
|
||||
Links a Photo to a ShotGroup. A single group can have multiple photos
|
||||
(e.g. different distances or targets at the same session).
|
||||
"""
|
||||
photo = models.OneToOneField(
|
||||
Photo,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='group_photo',
|
||||
verbose_name=_('photo'),
|
||||
)
|
||||
shot_group = models.ForeignKey(
|
||||
'tools.ShotGroup',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='group_photos',
|
||||
verbose_name=_('shot group'),
|
||||
)
|
||||
caption = models.CharField(_('caption'), max_length=255, blank=True)
|
||||
order = models.PositiveSmallIntegerField(_('order'), default=0)
|
||||
is_public = models.BooleanField(_('public'), default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('group photo')
|
||||
verbose_name_plural = _('group photos')
|
||||
ordering = ['order', 'id']
|
||||
|
||||
def __str__(self):
|
||||
target = self.shot_group or _('unlinked')
|
||||
return f"GroupPhoto #{self.pk} → {target}"
|
||||
|
||||
|
||||
class GroupPhotoAnalysis(models.Model):
|
||||
"""
|
||||
Ballistic overlay data for a GroupPhoto: group size, point-of-impact
|
||||
offsets, and mean radius — all in millimetres and MOA.
|
||||
"""
|
||||
group_photo = models.OneToOneField(
|
||||
GroupPhoto,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='analysis',
|
||||
verbose_name=_('group photo'),
|
||||
)
|
||||
group_size_mm = models.DecimalField(
|
||||
_('group size (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
group_size_moa = models.DecimalField(
|
||||
_('group size (MOA)'), max_digits=7, decimal_places=3, null=True, blank=True,
|
||||
)
|
||||
elevation_offset_mm = models.DecimalField(
|
||||
_('elevation offset (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
elevation_offset_moa = models.DecimalField(
|
||||
_('elevation offset (MOA)'), max_digits=7, decimal_places=3, null=True, blank=True,
|
||||
)
|
||||
windage_offset_mm = models.DecimalField(
|
||||
_('windage offset (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
windage_offset_moa = models.DecimalField(
|
||||
_('windage offset (MOA)'), max_digits=7, decimal_places=3, null=True, blank=True,
|
||||
)
|
||||
mean_radius_mm = models.DecimalField(
|
||||
_('mean radius (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
mean_radius_moa = models.DecimalField(
|
||||
_('mean radius (MOA)'), max_digits=7, decimal_places=3, null=True, blank=True,
|
||||
)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('group photo analysis')
|
||||
verbose_name_plural = _('group photo analyses')
|
||||
|
||||
def __str__(self):
|
||||
return f"Analysis for {self.group_photo}"
|
||||
|
||||
def clean(self):
|
||||
errors = {}
|
||||
for field in ('group_size_mm', 'group_size_moa', 'mean_radius_mm', 'mean_radius_moa'):
|
||||
value = getattr(self, field)
|
||||
if value is not None and value < 0:
|
||||
errors[field] = _('This measurement cannot be negative.')
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
|
||||
class PointOfImpact(models.Model):
|
||||
"""
|
||||
An individual bullet-hole marker on a GroupPhoto.
|
||||
|
||||
Pixel coordinates (x_px, y_px) allow UI overlays.
|
||||
Real-world coordinates (x_mm, y_mm) use the point-of-aim as origin,
|
||||
with + = right and + = up (standard ballistic convention).
|
||||
Optionally linked to a Shot from the chronograph for combined analysis.
|
||||
"""
|
||||
group_photo = models.ForeignKey(
|
||||
GroupPhoto,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='points_of_impact',
|
||||
verbose_name=_('group photo'),
|
||||
)
|
||||
# Optional link to the matching Shot record from a ChronographAnalysis
|
||||
shot = models.OneToOneField(
|
||||
'tools.Shot',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='point_of_impact',
|
||||
verbose_name=_('shot'),
|
||||
)
|
||||
order = models.PositiveSmallIntegerField(
|
||||
_('order'), default=0,
|
||||
help_text=_('1-based sequence; used when shot FK is absent.'),
|
||||
)
|
||||
# Pixel position on the photo (for overlay rendering)
|
||||
x_px = models.PositiveSmallIntegerField(_('x (px)'))
|
||||
y_px = models.PositiveSmallIntegerField(_('y (px)'))
|
||||
# Real-world offsets from point-of-aim (millimetres)
|
||||
x_mm = models.DecimalField(
|
||||
_('x offset (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
y_mm = models.DecimalField(
|
||||
_('y offset (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
# Radius of the bullet hole (for rendering)
|
||||
radius_mm = models.DecimalField(
|
||||
_('bullet hole radius (mm)'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
notes = models.CharField(_('notes'), max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('point of impact')
|
||||
verbose_name_plural = _('points of impact')
|
||||
ordering = ['order', 'id']
|
||||
|
||||
def __str__(self):
|
||||
return f"POI #{self.order or self.pk} on {self.group_photo}"
|
||||
93
apps/photos/serializers.py
Normal file
93
apps/photos/serializers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
# Cross-app import (photos → tools). String FK in the model avoids DB circular
|
||||
# import; here we need the class directly for the serializer queryset.
|
||||
from apps.tools.models import ShotGroup
|
||||
|
||||
from .models import GroupPhoto, GroupPhotoAnalysis, Photo, PointOfImpact
|
||||
|
||||
|
||||
class PhotoMetaSerializer(serializers.ModelSerializer):
|
||||
"""Photo metadata only — the binary `data` field is never exposed in JSON."""
|
||||
|
||||
class Meta:
|
||||
model = Photo
|
||||
fields = ['id', 'content_type', 'size', 'width', 'height',
|
||||
'uploaded_by', 'uploaded_at', 'description']
|
||||
read_only_fields = ['id', 'size', 'width', 'height', 'uploaded_by', 'uploaded_at']
|
||||
|
||||
|
||||
class GroupPhotoAnalysisSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = GroupPhotoAnalysis
|
||||
fields = [
|
||||
'group_size_mm', 'group_size_moa',
|
||||
'elevation_offset_mm', 'elevation_offset_moa',
|
||||
'windage_offset_mm', 'windage_offset_moa',
|
||||
'mean_radius_mm', 'mean_radius_moa',
|
||||
'notes',
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
instance = GroupPhotoAnalysis(**attrs)
|
||||
instance.clean()
|
||||
return attrs
|
||||
|
||||
|
||||
class PointOfImpactSerializer(serializers.ModelSerializer):
|
||||
# Write: accept Shot PK; Read: compact inline summary
|
||||
shot_detail = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PointOfImpact
|
||||
fields = [
|
||||
'id', 'order',
|
||||
'shot', 'shot_detail',
|
||||
'x_px', 'y_px',
|
||||
'x_mm', 'y_mm', 'radius_mm',
|
||||
'notes',
|
||||
]
|
||||
|
||||
def get_shot_detail(self, obj):
|
||||
if not obj.shot_id:
|
||||
return None
|
||||
return {
|
||||
'id': obj.shot.pk,
|
||||
'shot_number': obj.shot.shot_number,
|
||||
'velocity_fps': str(obj.shot.velocity_fps),
|
||||
}
|
||||
|
||||
|
||||
class GroupPhotoSerializer(serializers.ModelSerializer):
|
||||
photo = PhotoMetaSerializer(read_only=True)
|
||||
photo_id = serializers.PrimaryKeyRelatedField(
|
||||
source='photo',
|
||||
queryset=Photo.objects.all(),
|
||||
write_only=True,
|
||||
)
|
||||
# shot_group is optional — photos can exist independently of any group
|
||||
shot_group = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ShotGroup.objects.all(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
shot_group_detail = serializers.SerializerMethodField()
|
||||
analysis = GroupPhotoAnalysisSerializer(read_only=True)
|
||||
points_of_impact = PointOfImpactSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = GroupPhoto
|
||||
fields = [
|
||||
'id', 'photo_id', 'photo',
|
||||
'shot_group', 'shot_group_detail', 'caption', 'order',
|
||||
'is_public', 'analysis', 'points_of_impact',
|
||||
]
|
||||
|
||||
def get_shot_group_detail(self, obj):
|
||||
if not obj.shot_group_id:
|
||||
return None
|
||||
sg = obj.shot_group
|
||||
return {
|
||||
'id': sg.pk,
|
||||
'label': sg.label,
|
||||
'distance_m': str(sg.distance_m) if sg.distance_m else None,
|
||||
}
|
||||
13
apps/photos/urls.py
Normal file
13
apps/photos/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import GroupPhotoViewSet, PhotoUploadView, photo_data_view
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'group-photos', GroupPhotoViewSet, basename='group-photo')
|
||||
|
||||
urlpatterns = [
|
||||
path('upload/', PhotoUploadView.as_view(), name='photo-upload'),
|
||||
path('<int:pk>/data/', photo_data_view, name='photo-data'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
226
apps/photos/views.py
Normal file
226
apps/photos/views.py
Normal file
@@ -0,0 +1,226 @@
|
||||
from io import BytesIO
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from PIL import Image as PillowImage
|
||||
from rest_framework import parsers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
# Reuse the same permission used by ChronographAnalysis (anonymous-friendly)
|
||||
from apps.tools.permissions import IsOwnerOrUnclaimed
|
||||
|
||||
|
||||
class IsGroupPhotoOwner(BasePermission):
|
||||
"""
|
||||
For GroupPhoto objects, ownership is via photo.uploaded_by.
|
||||
Read is open; mutations require being the uploader (or anonymous upload).
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
return True
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
uploader = obj.photo.uploaded_by if obj.photo_id else None
|
||||
if uploader is None:
|
||||
return True
|
||||
return request.user.is_authenticated and uploader == request.user
|
||||
|
||||
from .analysis import compute_group_size as _compute_group_size
|
||||
from .models import GroupPhoto, GroupPhotoAnalysis, Photo, PointOfImpact
|
||||
from .serializers import (
|
||||
GroupPhotoAnalysisSerializer,
|
||||
GroupPhotoSerializer,
|
||||
PhotoMetaSerializer,
|
||||
PointOfImpactSerializer,
|
||||
)
|
||||
|
||||
# Allowed image MIME types
|
||||
_ALLOWED_CONTENT_TYPES = {'image/jpeg', 'image/png', 'image/webp', 'image/gif'}
|
||||
|
||||
|
||||
class PhotoUploadView(APIView):
|
||||
"""
|
||||
POST multipart/form-data with a 'file' field → create a Photo.
|
||||
|
||||
Optional fields: description (string).
|
||||
Returns PhotoMetaSerializer data (no binary).
|
||||
Anonymous uploads are allowed (same pattern as ChronographAnalysis).
|
||||
"""
|
||||
parser_classes = [parsers.MultiPartParser]
|
||||
permission_classes = [IsOwnerOrUnclaimed]
|
||||
|
||||
def post(self, request):
|
||||
f = request.FILES.get('file')
|
||||
if not f:
|
||||
return Response({'detail': 'No file provided.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if f.content_type not in _ALLOWED_CONTENT_TYPES:
|
||||
return Response(
|
||||
{'detail': f'Unsupported file type: {f.content_type}. '
|
||||
f'Allowed: {", ".join(sorted(_ALLOWED_CONTENT_TYPES))}'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
raw = f.read()
|
||||
try:
|
||||
img = PillowImage.open(BytesIO(raw))
|
||||
img.verify() # check it is a valid image
|
||||
img = PillowImage.open(BytesIO(raw)) # re-open after verify (verify closes the stream)
|
||||
width, height = img.size
|
||||
except Exception:
|
||||
return Response({'detail': 'Invalid or corrupt image file.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
photo = Photo.objects.create(
|
||||
data=raw,
|
||||
content_type=f.content_type,
|
||||
size=len(raw),
|
||||
width=width,
|
||||
height=height,
|
||||
uploaded_by=request.user if request.user.is_authenticated else None,
|
||||
description=request.data.get('description', ''),
|
||||
)
|
||||
return Response(PhotoMetaSerializer(photo).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
def photo_data_view(request, pk):
|
||||
"""
|
||||
GET /api/photos/{pk}/data/ → serve the raw image bytes.
|
||||
Public — no authentication required.
|
||||
"""
|
||||
photo = get_object_or_404(Photo, pk=pk)
|
||||
return HttpResponse(bytes(photo.data), content_type=photo.content_type)
|
||||
|
||||
|
||||
class GroupPhotoViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
CRUD for GroupPhoto objects.
|
||||
|
||||
Filter by ?shot_group=<id> to list photos for a specific shot group.
|
||||
|
||||
Nested sub-resources:
|
||||
GET/PUT/PATCH .../analysis/ upsert GroupPhotoAnalysis
|
||||
GET/POST .../points/ list / add PointOfImpact
|
||||
PATCH/DELETE .../points/{poi_pk}/ update / remove a PointOfImpact
|
||||
"""
|
||||
serializer_class = GroupPhotoSerializer
|
||||
permission_classes = [IsGroupPhotoOwner]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = GroupPhoto.objects.select_related('photo', 'analysis').prefetch_related('points_of_impact')
|
||||
shot_group = self.request.query_params.get('shot_group')
|
||||
if shot_group:
|
||||
qs = qs.filter(shot_group_id=shot_group)
|
||||
return qs
|
||||
|
||||
# ── Analysis ──────────────────────────────────────────────────────────────
|
||||
|
||||
@action(detail=True, methods=['get', 'put', 'patch'], url_path='analysis')
|
||||
def analysis(self, request, pk=None):
|
||||
group_photo = self.get_object()
|
||||
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
serializer = GroupPhotoAnalysisSerializer(group_photo.analysis)
|
||||
return Response(serializer.data)
|
||||
except GroupPhotoAnalysis.DoesNotExist:
|
||||
return Response({}, status=status.HTTP_200_OK)
|
||||
|
||||
# PUT / PATCH — upsert
|
||||
partial = request.method == 'PATCH'
|
||||
try:
|
||||
instance = group_photo.analysis
|
||||
except GroupPhotoAnalysis.DoesNotExist:
|
||||
instance = None
|
||||
|
||||
serializer = GroupPhotoAnalysisSerializer(
|
||||
instance,
|
||||
data=request.data,
|
||||
partial=partial,
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save(group_photo=group_photo)
|
||||
return Response(serializer.data)
|
||||
|
||||
# ── Points of impact ──────────────────────────────────────────────────────
|
||||
|
||||
@action(detail=True, methods=['get', 'post'], url_path='points')
|
||||
def points(self, request, pk=None):
|
||||
group_photo = self.get_object()
|
||||
|
||||
if request.method == 'GET':
|
||||
pois = group_photo.points_of_impact.all()
|
||||
return Response(PointOfImpactSerializer(pois, many=True).data)
|
||||
|
||||
serializer = PointOfImpactSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
poi = serializer.save(group_photo=group_photo)
|
||||
return Response(PointOfImpactSerializer(poi).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=['patch', 'delete'],
|
||||
url_path=r'points/(?P<poi_pk>[^/.]+)',
|
||||
)
|
||||
def point_detail(self, request, pk=None, poi_pk=None):
|
||||
group_photo = self.get_object()
|
||||
poi = get_object_or_404(PointOfImpact, pk=poi_pk, group_photo=group_photo)
|
||||
|
||||
if request.method == 'DELETE':
|
||||
poi.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
serializer = PointOfImpactSerializer(poi, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
# ── Group size computation ─────────────────────────────────────────────────
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='compute-group-size')
|
||||
def compute_group_size(self, request, pk=None):
|
||||
"""
|
||||
Compute GroupPhotoAnalysis metrics from PointOfImpact real-world
|
||||
coordinates (x_mm, y_mm). Requires ≥ 2 POIs with mm coordinates set.
|
||||
Upserts the GroupPhotoAnalysis and returns the updated record.
|
||||
"""
|
||||
group_photo = self.get_object()
|
||||
|
||||
pois = list(
|
||||
group_photo.points_of_impact
|
||||
.filter(x_mm__isnull=False, y_mm__isnull=False)
|
||||
.values_list('x_mm', 'y_mm')
|
||||
)
|
||||
|
||||
if len(pois) < 2:
|
||||
return Response(
|
||||
{'detail': 'At least 2 points of impact with real-world '
|
||||
'coordinates (x_mm, y_mm) are required.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
distance_m = None
|
||||
if group_photo.shot_group_id and group_photo.shot_group.distance_m:
|
||||
distance_m = float(group_photo.shot_group.distance_m)
|
||||
|
||||
metrics = _compute_group_size(
|
||||
[(float(x), float(y)) for x, y in pois],
|
||||
distance_m=distance_m,
|
||||
)
|
||||
|
||||
try:
|
||||
instance = group_photo.analysis
|
||||
except GroupPhotoAnalysis.DoesNotExist:
|
||||
instance = None
|
||||
|
||||
serializer = GroupPhotoAnalysisSerializer(
|
||||
instance,
|
||||
data=metrics,
|
||||
partial=bool(instance),
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save(group_photo=group_photo)
|
||||
return Response(serializer.data)
|
||||
0
apps/sessions/__init__.py
Normal file
0
apps/sessions/__init__.py
Normal file
36
apps/sessions/admin.py
Normal file
36
apps/sessions/admin.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import FreePracticeSession, PRSSession, PRSStage, SpeedShootingSession
|
||||
|
||||
|
||||
class PRSStageInline(admin.TabularInline):
|
||||
model = PRSStage
|
||||
extra = 0
|
||||
fields = [
|
||||
'order', 'position', 'distance_m',
|
||||
'target_width_cm', 'target_height_cm', 'max_time_s', 'shots_count',
|
||||
'actual_elevation', 'actual_windage',
|
||||
'hits', 'score', 'time_taken_s',
|
||||
]
|
||||
|
||||
|
||||
@admin.register(PRSSession)
|
||||
class PRSSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['__str__', 'user', 'date', 'location', 'competition_name', 'category']
|
||||
list_filter = ['date']
|
||||
search_fields = ['user__email', 'competition_name', 'location']
|
||||
inlines = [PRSStageInline]
|
||||
|
||||
|
||||
@admin.register(FreePracticeSession)
|
||||
class FreePracticeSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['__str__', 'user', 'date', 'location', 'distance_m', 'rounds_fired']
|
||||
list_filter = ['date']
|
||||
search_fields = ['user__email', 'name', 'location']
|
||||
|
||||
|
||||
@admin.register(SpeedShootingSession)
|
||||
class SpeedShootingSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['__str__', 'user', 'date', 'format', 'rounds_fired']
|
||||
list_filter = ['date']
|
||||
search_fields = ['user__email', 'name', 'format']
|
||||
7
apps/sessions/apps.py
Normal file
7
apps/sessions/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SessionsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.sessions'
|
||||
label = 'shooting_sessions'
|
||||
32
apps/sessions/ballistics.py
Normal file
32
apps/sessions/ballistics.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Ballistic correction engine.
|
||||
|
||||
Computes scope elevation and windage corrections given a rig, ammo,
|
||||
target distance, and weather conditions.
|
||||
|
||||
Currently a stub — returns None until the trajectory integration is built.
|
||||
"""
|
||||
|
||||
|
||||
def compute_corrections(session, stage) -> dict:
|
||||
"""
|
||||
Return scope corrections for a given PRS stage.
|
||||
|
||||
Args:
|
||||
session: PRSSession instance (provides rig, ammo/reloaded_batch, weather)
|
||||
stage: PRSStage instance (provides distance_m)
|
||||
|
||||
Returns:
|
||||
dict with keys: elevation, windage, unit, message
|
||||
"""
|
||||
# TODO: implement point-mass trajectory integration using:
|
||||
# - session.rig.zero_distance_m, session.rig.scope_height_mm
|
||||
# - ammo BC (Bullet.bc_g7 / bc_g1) and muzzle velocity
|
||||
# - session weather fields (temperature_c, pressure_hpa, humidity_pct)
|
||||
# - stage.distance_m and session wind fields
|
||||
return {
|
||||
'elevation': None,
|
||||
'windage': None,
|
||||
'unit': None,
|
||||
'message': 'Ballistic engine not yet implemented.',
|
||||
}
|
||||
144
apps/sessions/migrations/0001_initial.py
Normal file
144
apps/sessions/migrations/0001_initial.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-30 09:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('gears', '0011_rig_ballistic_fields'),
|
||||
('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PRSSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=255, verbose_name='name')),
|
||||
('date', models.DateField(verbose_name='date')),
|
||||
('location', models.CharField(blank=True, max_length=255, verbose_name='location')),
|
||||
('temperature_c', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='temperature (°C)')),
|
||||
('wind_speed_ms', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='wind speed (m/s)')),
|
||||
('wind_direction_deg', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='wind direction (°)')),
|
||||
('humidity_pct', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='humidity (%)')),
|
||||
('pressure_hpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='pressure (hPa)')),
|
||||
('weather_notes', models.TextField(blank=True, verbose_name='weather notes')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('competition_name', models.CharField(blank=True, max_length=255, verbose_name='competition name')),
|
||||
('category', models.CharField(blank=True, max_length=100, verbose_name='category')),
|
||||
('ammo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.ammo', verbose_name='factory ammo')),
|
||||
('reloaded_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.reloadedammobatch', verbose_name='reloaded batch')),
|
||||
('rig', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.rig', verbose_name='rig')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'PRS session',
|
||||
'verbose_name_plural': 'PRS sessions',
|
||||
'ordering': ['-date', '-created_at'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SpeedShootingSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=255, verbose_name='name')),
|
||||
('date', models.DateField(verbose_name='date')),
|
||||
('location', models.CharField(blank=True, max_length=255, verbose_name='location')),
|
||||
('temperature_c', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='temperature (°C)')),
|
||||
('wind_speed_ms', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='wind speed (m/s)')),
|
||||
('wind_direction_deg', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='wind direction (°)')),
|
||||
('humidity_pct', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='humidity (%)')),
|
||||
('pressure_hpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='pressure (hPa)')),
|
||||
('weather_notes', models.TextField(blank=True, verbose_name='weather notes')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('format', models.CharField(blank=True, max_length=100, verbose_name='format')),
|
||||
('rounds_fired', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='rounds fired')),
|
||||
('ammo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.ammo', verbose_name='factory ammo')),
|
||||
('reloaded_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.reloadedammobatch', verbose_name='reloaded batch')),
|
||||
('rig', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.rig', verbose_name='rig')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'speed shooting session',
|
||||
'verbose_name_plural': 'speed shooting sessions',
|
||||
'ordering': ['-date', '-created_at'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PRSStage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.PositiveSmallIntegerField(verbose_name='order')),
|
||||
('position', models.CharField(choices=[('PRONE', 'Prone'), ('STANDING', 'Standing'), ('SITTING', 'Sitting'), ('KNEELING', 'Kneeling'), ('BARRICADE', 'Barricade'), ('UNSUPPORTED', 'Unsupported'), ('OTHER', 'Other')], default='PRONE', max_length=20, verbose_name='shooting position')),
|
||||
('distance_m', models.PositiveSmallIntegerField(verbose_name='distance (m)')),
|
||||
('target_width_cm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='target width (cm)')),
|
||||
('target_height_cm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='target height (cm)')),
|
||||
('max_time_s', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='max time (s)')),
|
||||
('shots_count', models.PositiveSmallIntegerField(default=1, verbose_name='shots count')),
|
||||
('notes_prep', models.TextField(blank=True, verbose_name='prep notes')),
|
||||
('computed_elevation', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='computed elevation')),
|
||||
('computed_windage', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='computed windage')),
|
||||
('correction_unit', models.CharField(blank=True, choices=[('MOA', 'MOA'), ('MRAD', 'MRAD'), ('CLICK', 'Clicks')], max_length=10, verbose_name='correction unit')),
|
||||
('actual_elevation', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='actual elevation')),
|
||||
('actual_windage', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='actual windage')),
|
||||
('hits', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='hits')),
|
||||
('score', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score')),
|
||||
('time_taken_s', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='time taken (s)')),
|
||||
('notes_post', models.TextField(blank=True, verbose_name='post notes')),
|
||||
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='shooting_sessions.prssession', verbose_name='session')),
|
||||
('shot_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prs_stages', to='tools.shotgroup', verbose_name='shot group')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'PRS stage',
|
||||
'verbose_name_plural': 'PRS stages',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FreePracticeSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=255, verbose_name='name')),
|
||||
('date', models.DateField(verbose_name='date')),
|
||||
('location', models.CharField(blank=True, max_length=255, verbose_name='location')),
|
||||
('temperature_c', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='temperature (°C)')),
|
||||
('wind_speed_ms', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='wind speed (m/s)')),
|
||||
('wind_direction_deg', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='wind direction (°)')),
|
||||
('humidity_pct', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='humidity (%)')),
|
||||
('pressure_hpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='pressure (hPa)')),
|
||||
('weather_notes', models.TextField(blank=True, verbose_name='weather notes')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('distance_m', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='distance (m)')),
|
||||
('target_description', models.CharField(blank=True, max_length=255, verbose_name='target description')),
|
||||
('rounds_fired', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='rounds fired')),
|
||||
('ammo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.ammo', verbose_name='factory ammo')),
|
||||
('reloaded_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.reloadedammobatch', verbose_name='reloaded batch')),
|
||||
('rig', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.rig', verbose_name='rig')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'free practice session',
|
||||
'verbose_name_plural': 'free practice sessions',
|
||||
'ordering': ['-date', '-created_at'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='prsstage',
|
||||
constraint=models.UniqueConstraint(fields=('session', 'order'), name='unique_prs_stage_order'),
|
||||
),
|
||||
]
|
||||
46
apps/sessions/migrations/0002_add_analysis_fk.py
Normal file
46
apps/sessions/migrations/0002_add_analysis_fk.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shooting_sessions', '0001_initial'),
|
||||
('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='prssession',
|
||||
name='analysis',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='tools.chronographanalysis',
|
||||
verbose_name='chronograph analysis',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='freepracticesession',
|
||||
name='analysis',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='tools.chronographanalysis',
|
||||
verbose_name='chronograph analysis',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='speedshootingsession',
|
||||
name='analysis',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='tools.chronographanalysis',
|
||||
verbose_name='chronograph analysis',
|
||||
),
|
||||
),
|
||||
]
|
||||
26
apps/sessions/migrations/0003_session_is_public.py
Normal file
26
apps/sessions/migrations/0003_session_is_public.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shooting_sessions', '0002_add_analysis_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='prssession',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='freepracticesession',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='speedshootingsession',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
]
|
||||
0
apps/sessions/migrations/__init__.py
Normal file
0
apps/sessions/migrations/__init__.py
Normal file
246
apps/sessions/models.py
Normal file
246
apps/sessions/models.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# ── Choices ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class ShootingPosition(models.TextChoices):
|
||||
PRONE = 'PRONE', _('Prone')
|
||||
STANDING = 'STANDING', _('Standing')
|
||||
SITTING = 'SITTING', _('Sitting')
|
||||
KNEELING = 'KNEELING', _('Kneeling')
|
||||
BARRICADE = 'BARRICADE', _('Barricade')
|
||||
UNSUPPORTED = 'UNSUPPORTED', _('Unsupported')
|
||||
OTHER = 'OTHER', _('Other')
|
||||
|
||||
|
||||
class CorrectionUnit(models.TextChoices):
|
||||
MOA = 'MOA', _('MOA')
|
||||
MRAD = 'MRAD', _('MRAD')
|
||||
CLICK = 'CLICK', _('Clicks')
|
||||
|
||||
|
||||
# ── Abstract base ─────────────────────────────────────────────────────────────
|
||||
|
||||
class AbstractSession(models.Model):
|
||||
"""
|
||||
Shared fields inherited by all concrete session types.
|
||||
Each subclass gets its own DB table (no cross-table joins).
|
||||
"""
|
||||
# Use '+' to suppress reverse accessors — query via concrete model managers
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
verbose_name=_('user'),
|
||||
)
|
||||
name = models.CharField(_('name'), max_length=255, blank=True)
|
||||
date = models.DateField(_('date'))
|
||||
location = models.CharField(_('location'), max_length=255, blank=True)
|
||||
|
||||
# Intentional cross-app FKs (string refs avoid circular imports)
|
||||
analysis = models.ForeignKey(
|
||||
'tools.ChronographAnalysis',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
verbose_name=_('chronograph analysis'),
|
||||
)
|
||||
rig = models.ForeignKey(
|
||||
'gears.Rig',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
verbose_name=_('rig'),
|
||||
)
|
||||
ammo = models.ForeignKey(
|
||||
'gears.Ammo',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
verbose_name=_('factory ammo'),
|
||||
)
|
||||
reloaded_batch = models.ForeignKey(
|
||||
'gears.ReloadedAmmoBatch',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
verbose_name=_('reloaded batch'),
|
||||
)
|
||||
|
||||
# Weather
|
||||
temperature_c = models.DecimalField(_('temperature (°C)'), max_digits=5, decimal_places=1, null=True, blank=True)
|
||||
wind_speed_ms = models.DecimalField(_('wind speed (m/s)'), max_digits=5, decimal_places=1, null=True, blank=True)
|
||||
wind_direction_deg = models.PositiveSmallIntegerField(_('wind direction (°)'), null=True, blank=True)
|
||||
humidity_pct = models.PositiveSmallIntegerField(_('humidity (%)'), null=True, blank=True)
|
||||
pressure_hpa = models.DecimalField(_('pressure (hPa)'), max_digits=6, decimal_places=1, null=True, blank=True)
|
||||
weather_notes = models.TextField(_('weather notes'), blank=True)
|
||||
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
is_public = models.BooleanField(_('public'), default=False)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ['-date', '-created_at']
|
||||
|
||||
def clean(self):
|
||||
if self.ammo_id and self.reloaded_batch_id:
|
||||
raise ValidationError(
|
||||
_('A session may use factory ammo or a reloaded batch, not both.')
|
||||
)
|
||||
if self.rig_id:
|
||||
if self.rig.user_id != self.user_id:
|
||||
raise ValidationError({'rig': _('This rig does not belong to you.')})
|
||||
if self.wind_direction_deg is not None and not (0 <= self.wind_direction_deg <= 359):
|
||||
raise ValidationError(
|
||||
{'wind_direction_deg': _('Wind direction must be between 0 and 359 degrees.')}
|
||||
)
|
||||
if self.humidity_pct is not None and not (0 <= self.humidity_pct <= 100):
|
||||
raise ValidationError(
|
||||
{'humidity_pct': _('Humidity must be between 0 and 100.')}
|
||||
)
|
||||
|
||||
|
||||
# ── PRS session ───────────────────────────────────────────────────────────────
|
||||
|
||||
class PRSSession(AbstractSession):
|
||||
"""
|
||||
A Precision Rifle Series session.
|
||||
Two-phase workflow: preparation (stages defined upfront) →
|
||||
execution (weather entered, corrections computed, results recorded).
|
||||
"""
|
||||
competition_name = models.CharField(_('competition name'), max_length=255, blank=True)
|
||||
category = models.CharField(_('category'), max_length=100, blank=True)
|
||||
|
||||
class Meta(AbstractSession.Meta):
|
||||
verbose_name = _('PRS session')
|
||||
verbose_name_plural = _('PRS sessions')
|
||||
|
||||
def __str__(self):
|
||||
label = self.competition_name or self.name or _('PRS session')
|
||||
return f"{label} — {self.date}"
|
||||
|
||||
|
||||
class PRSStage(models.Model):
|
||||
"""
|
||||
One stage within a PRSSession.
|
||||
Fields are grouped by lifecycle phase: prep, execution, results.
|
||||
"""
|
||||
session = models.ForeignKey(
|
||||
PRSSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='stages',
|
||||
verbose_name=_('session'),
|
||||
)
|
||||
|
||||
# ── Prep phase ────────────────────────────────────────────────────────────
|
||||
order = models.PositiveSmallIntegerField(_('order'))
|
||||
position = models.CharField(
|
||||
_('shooting position'), max_length=20,
|
||||
choices=ShootingPosition.choices, default=ShootingPosition.PRONE,
|
||||
)
|
||||
distance_m = models.PositiveSmallIntegerField(_('distance (m)'))
|
||||
target_width_cm = models.DecimalField(
|
||||
_('target width (cm)'), max_digits=6, decimal_places=1, null=True, blank=True,
|
||||
)
|
||||
target_height_cm = models.DecimalField(
|
||||
_('target height (cm)'), max_digits=6, decimal_places=1, null=True, blank=True,
|
||||
)
|
||||
max_time_s = models.PositiveSmallIntegerField(_('max time (s)'), null=True, blank=True)
|
||||
shots_count = models.PositiveSmallIntegerField(_('shots count'), default=1)
|
||||
notes_prep = models.TextField(_('prep notes'), blank=True)
|
||||
|
||||
# ── Execution phase ───────────────────────────────────────────────────────
|
||||
# computed_* are set by the ballistic engine (read-only for clients)
|
||||
computed_elevation = models.DecimalField(
|
||||
_('computed elevation'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
computed_windage = models.DecimalField(
|
||||
_('computed windage'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
correction_unit = models.CharField(
|
||||
_('correction unit'), max_length=10,
|
||||
choices=CorrectionUnit.choices, blank=True,
|
||||
)
|
||||
# actual_* are editable by the shooter
|
||||
actual_elevation = models.DecimalField(
|
||||
_('actual elevation'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
actual_windage = models.DecimalField(
|
||||
_('actual windage'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
|
||||
# ── Results phase ─────────────────────────────────────────────────────────
|
||||
hits = models.PositiveSmallIntegerField(_('hits'), null=True, blank=True)
|
||||
score = models.PositiveSmallIntegerField(_('score'), null=True, blank=True)
|
||||
time_taken_s = models.DecimalField(
|
||||
_('time taken (s)'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
# Optional link to chronograph/shot data
|
||||
shot_group = models.ForeignKey(
|
||||
'tools.ShotGroup',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='prs_stages',
|
||||
verbose_name=_('shot group'),
|
||||
)
|
||||
notes_post = models.TextField(_('post notes'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('PRS stage')
|
||||
verbose_name_plural = _('PRS stages')
|
||||
ordering = ['order']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['session', 'order'], name='unique_prs_stage_order')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Stage {self.order} — {self.distance_m}m {self.get_position_display()}"
|
||||
|
||||
def clean(self):
|
||||
if self.hits is not None and self.hits > self.shots_count:
|
||||
raise ValidationError(
|
||||
{'hits': _('Hits cannot exceed the number of shots for this stage.')}
|
||||
)
|
||||
if self.score is not None and self.hits is not None and self.score > self.hits:
|
||||
raise ValidationError(
|
||||
{'score': _('Score cannot exceed the number of hits.')}
|
||||
)
|
||||
|
||||
|
||||
# ── Free Practice session ─────────────────────────────────────────────────────
|
||||
|
||||
class FreePracticeSession(AbstractSession):
|
||||
"""A free-form practice session at a fixed distance."""
|
||||
distance_m = models.PositiveSmallIntegerField(_('distance (m)'), null=True, blank=True)
|
||||
target_description = models.CharField(_('target description'), max_length=255, blank=True)
|
||||
rounds_fired = models.PositiveSmallIntegerField(_('rounds fired'), null=True, blank=True)
|
||||
|
||||
class Meta(AbstractSession.Meta):
|
||||
verbose_name = _('free practice session')
|
||||
verbose_name_plural = _('free practice sessions')
|
||||
|
||||
def __str__(self):
|
||||
label = self.name or _('Free practice')
|
||||
dist = f' — {self.distance_m}m' if self.distance_m else ''
|
||||
return f"{label}{dist} ({self.date})"
|
||||
|
||||
|
||||
# ── Speed Shooting session ────────────────────────────────────────────────────
|
||||
|
||||
class SpeedShootingSession(AbstractSession):
|
||||
"""A speed shooting session (IPSC, IDPA, Steel Challenge, …). Minimal placeholder."""
|
||||
format = models.CharField(_('format'), max_length=100, blank=True)
|
||||
rounds_fired = models.PositiveSmallIntegerField(_('rounds fired'), null=True, blank=True)
|
||||
|
||||
class Meta(AbstractSession.Meta):
|
||||
verbose_name = _('speed shooting session')
|
||||
verbose_name_plural = _('speed shooting sessions')
|
||||
|
||||
def __str__(self):
|
||||
label = self.name or self.format or _('Speed shooting')
|
||||
return f"{label} ({self.date})"
|
||||
301
apps/sessions/serializers.py
Normal file
301
apps/sessions/serializers.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from django.db.models import Q
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.common.serializer_helpers import ammo_detail, batch_detail
|
||||
from apps.gears.models import Ammo, GearStatus, ReloadedAmmoBatch, Rig
|
||||
from apps.tools.models import ChronographAnalysis, ShotGroup
|
||||
|
||||
from .models import (
|
||||
CorrectionUnit,
|
||||
FreePracticeSession,
|
||||
PRSSession,
|
||||
PRSStage,
|
||||
SpeedShootingSession,
|
||||
)
|
||||
|
||||
|
||||
# ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _rig_detail(rig):
|
||||
if rig is None:
|
||||
return None
|
||||
return {'id': rig.id, 'name': rig.name}
|
||||
|
||||
|
||||
# ── Abstract write mixin ──────────────────────────────────────────────────────
|
||||
|
||||
def _analysis_detail(analysis):
|
||||
if analysis is None:
|
||||
return None
|
||||
return {'id': analysis.id, 'name': analysis.name, 'date': str(analysis.date) if analysis.date else None}
|
||||
|
||||
|
||||
class AbstractSessionWriteMixin:
|
||||
"""
|
||||
Shared __init__ for all session write serializers:
|
||||
narrows FK querysets to the current user.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
self.fields['rig'].queryset = Rig.objects.filter(user=request.user)
|
||||
self.fields['reloaded_batch'].queryset = ReloadedAmmoBatch.objects.filter(
|
||||
recipe__user=request.user
|
||||
)
|
||||
self.fields['analysis'].queryset = ChronographAnalysis.objects.filter(
|
||||
Q(user=request.user) | Q(user__isnull=True)
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
user = self.context['request'].user
|
||||
instance = self.Meta.model(user=user, **attrs)
|
||||
instance.clean()
|
||||
return attrs
|
||||
|
||||
|
||||
# ── PRS session ───────────────────────────────────────────────────────────────
|
||||
|
||||
class PRSStageSerializer(serializers.ModelSerializer):
|
||||
shot_group = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ShotGroup.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
self.fields['shot_group'].queryset = ShotGroup.objects.filter(
|
||||
analysis__user=request.user
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PRSStage
|
||||
fields = [
|
||||
'id', 'order', 'position',
|
||||
'distance_m', 'target_width_cm', 'target_height_cm',
|
||||
'max_time_s', 'shots_count', 'notes_prep',
|
||||
'computed_elevation', 'computed_windage', 'correction_unit',
|
||||
'actual_elevation', 'actual_windage',
|
||||
'hits', 'score', 'time_taken_s',
|
||||
'shot_group', 'notes_post',
|
||||
]
|
||||
read_only_fields = ['computed_elevation', 'computed_windage', 'correction_unit']
|
||||
|
||||
|
||||
class PRSSessionListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PRSSession
|
||||
fields = [
|
||||
'id', 'name', 'competition_name', 'category',
|
||||
'date', 'location', 'rig', 'is_public', 'created_at',
|
||||
]
|
||||
|
||||
|
||||
class PRSSessionWriteSerializer(AbstractSessionWriteMixin, serializers.ModelSerializer):
|
||||
rig = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Rig.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
ammo = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Ammo.objects.filter(status=GearStatus.VERIFIED),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
reloaded_batch = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ReloadedAmmoBatch.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
analysis = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChronographAnalysis.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PRSSession
|
||||
fields = [
|
||||
'id', 'name', 'competition_name', 'category',
|
||||
'date', 'location',
|
||||
'rig', 'ammo', 'reloaded_batch', 'analysis',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public',
|
||||
]
|
||||
|
||||
|
||||
class PRSSessionDetailSerializer(serializers.ModelSerializer):
|
||||
rig_detail = serializers.SerializerMethodField()
|
||||
ammo_detail = serializers.SerializerMethodField()
|
||||
reloaded_batch_detail = serializers.SerializerMethodField()
|
||||
analysis_detail = serializers.SerializerMethodField()
|
||||
stages = PRSStageSerializer(many=True, read_only=True)
|
||||
|
||||
def get_rig_detail(self, obj):
|
||||
return _rig_detail(obj.rig)
|
||||
|
||||
def get_ammo_detail(self, obj):
|
||||
return ammo_detail(obj.ammo)
|
||||
|
||||
def get_reloaded_batch_detail(self, obj):
|
||||
return batch_detail(obj.reloaded_batch)
|
||||
|
||||
def get_analysis_detail(self, obj):
|
||||
return _analysis_detail(obj.analysis)
|
||||
|
||||
class Meta:
|
||||
model = PRSSession
|
||||
fields = [
|
||||
'id', 'name', 'competition_name', 'category',
|
||||
'date', 'location',
|
||||
'rig', 'rig_detail',
|
||||
'ammo', 'ammo_detail',
|
||||
'reloaded_batch', 'reloaded_batch_detail',
|
||||
'analysis', 'analysis_detail',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public', 'stages',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
|
||||
|
||||
# ── Free Practice session ─────────────────────────────────────────────────────
|
||||
|
||||
class FreePracticeSessionListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FreePracticeSession
|
||||
fields = ['id', 'name', 'date', 'location', 'distance_m', 'rounds_fired', 'rig', 'is_public', 'created_at']
|
||||
|
||||
|
||||
class FreePracticeSessionWriteSerializer(AbstractSessionWriteMixin, serializers.ModelSerializer):
|
||||
rig = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Rig.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
ammo = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Ammo.objects.filter(status=GearStatus.VERIFIED),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
reloaded_batch = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ReloadedAmmoBatch.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
analysis = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChronographAnalysis.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FreePracticeSession
|
||||
fields = [
|
||||
'id', 'name', 'date', 'location',
|
||||
'rig', 'ammo', 'reloaded_batch', 'analysis',
|
||||
'distance_m', 'target_description', 'rounds_fired',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public',
|
||||
]
|
||||
|
||||
|
||||
class FreePracticeSessionDetailSerializer(serializers.ModelSerializer):
|
||||
rig_detail = serializers.SerializerMethodField()
|
||||
ammo_detail = serializers.SerializerMethodField()
|
||||
reloaded_batch_detail = serializers.SerializerMethodField()
|
||||
analysis_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_rig_detail(self, obj):
|
||||
return _rig_detail(obj.rig)
|
||||
|
||||
def get_ammo_detail(self, obj):
|
||||
return ammo_detail(obj.ammo)
|
||||
|
||||
def get_reloaded_batch_detail(self, obj):
|
||||
return batch_detail(obj.reloaded_batch)
|
||||
|
||||
def get_analysis_detail(self, obj):
|
||||
return _analysis_detail(obj.analysis)
|
||||
|
||||
class Meta:
|
||||
model = FreePracticeSession
|
||||
fields = [
|
||||
'id', 'name', 'date', 'location',
|
||||
'rig', 'rig_detail',
|
||||
'ammo', 'ammo_detail',
|
||||
'reloaded_batch', 'reloaded_batch_detail',
|
||||
'analysis', 'analysis_detail',
|
||||
'distance_m', 'target_description', 'rounds_fired',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public', 'created_at', 'updated_at',
|
||||
]
|
||||
|
||||
|
||||
# ── Speed Shooting session ────────────────────────────────────────────────────
|
||||
|
||||
class SpeedShootingSessionListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SpeedShootingSession
|
||||
fields = ['id', 'name', 'format', 'date', 'location', 'rounds_fired', 'rig', 'is_public', 'created_at']
|
||||
|
||||
|
||||
class SpeedShootingSessionWriteSerializer(AbstractSessionWriteMixin, serializers.ModelSerializer):
|
||||
rig = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Rig.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
ammo = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Ammo.objects.filter(status=GearStatus.VERIFIED),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
reloaded_batch = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ReloadedAmmoBatch.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
analysis = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChronographAnalysis.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SpeedShootingSession
|
||||
fields = [
|
||||
'id', 'name', 'format', 'date', 'location',
|
||||
'rig', 'ammo', 'reloaded_batch', 'analysis',
|
||||
'rounds_fired',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public',
|
||||
]
|
||||
|
||||
|
||||
class SpeedShootingSessionDetailSerializer(serializers.ModelSerializer):
|
||||
rig_detail = serializers.SerializerMethodField()
|
||||
ammo_detail = serializers.SerializerMethodField()
|
||||
reloaded_batch_detail = serializers.SerializerMethodField()
|
||||
analysis_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_rig_detail(self, obj):
|
||||
return _rig_detail(obj.rig)
|
||||
|
||||
def get_ammo_detail(self, obj):
|
||||
return ammo_detail(obj.ammo)
|
||||
|
||||
def get_reloaded_batch_detail(self, obj):
|
||||
return batch_detail(obj.reloaded_batch)
|
||||
|
||||
def get_analysis_detail(self, obj):
|
||||
return _analysis_detail(obj.analysis)
|
||||
|
||||
class Meta:
|
||||
model = SpeedShootingSession
|
||||
fields = [
|
||||
'id', 'name', 'format', 'date', 'location',
|
||||
'rig', 'rig_detail',
|
||||
'ammo', 'ammo_detail',
|
||||
'reloaded_batch', 'reloaded_batch_detail',
|
||||
'analysis', 'analysis_detail',
|
||||
'rounds_fired',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public', 'created_at', 'updated_at',
|
||||
]
|
||||
13
apps/sessions/urls.py
Normal file
13
apps/sessions/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import FreePracticeSessionViewSet, PRSSessionViewSet, SpeedShootingSessionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'sessions/prs', PRSSessionViewSet, basename='prs-session')
|
||||
router.register(r'sessions/free-practice', FreePracticeSessionViewSet, basename='free-practice-session')
|
||||
router.register(r'sessions/speed-shooting', SpeedShootingSessionViewSet, basename='speed-shooting-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
172
apps/sessions/views.py
Normal file
172
apps/sessions/views.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
class IsOwnerOrReadPublic(BasePermission):
|
||||
"""
|
||||
Read-only access for anonymous users (public sessions only).
|
||||
Authenticated users can only mutate their own sessions.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return obj.is_public or (
|
||||
request.user.is_authenticated and obj.user == request.user
|
||||
)
|
||||
return request.user.is_authenticated and obj.user == request.user
|
||||
|
||||
from .ballistics import compute_corrections
|
||||
from .models import FreePracticeSession, PRSSession, PRSStage, SpeedShootingSession
|
||||
from .serializers import (
|
||||
FreePracticeSessionDetailSerializer,
|
||||
FreePracticeSessionListSerializer,
|
||||
FreePracticeSessionWriteSerializer,
|
||||
PRSSessionDetailSerializer,
|
||||
PRSSessionListSerializer,
|
||||
PRSSessionWriteSerializer,
|
||||
PRSStageSerializer,
|
||||
SpeedShootingSessionDetailSerializer,
|
||||
SpeedShootingSessionListSerializer,
|
||||
SpeedShootingSessionWriteSerializer,
|
||||
)
|
||||
|
||||
|
||||
# ── PRS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PRSSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsOwnerOrReadPublic]
|
||||
filterset_fields = ['date', 'is_public']
|
||||
search_fields = ['name', 'location', 'notes', 'competition_name']
|
||||
ordering_fields = ['date', 'created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if not user.is_authenticated:
|
||||
qs = PRSSession.objects.filter(is_public=True)
|
||||
else:
|
||||
qs = PRSSession.objects.filter(user=user)
|
||||
qs = qs.select_related('rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis')
|
||||
if self.action != 'list':
|
||||
qs = qs.prefetch_related('stages')
|
||||
return qs
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return PRSSessionListSerializer
|
||||
if self.action in ('create', 'update', 'partial_update'):
|
||||
return PRSSessionWriteSerializer
|
||||
return PRSSessionDetailSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
# ── Nested stage actions ──────────────────────────────────────────────────
|
||||
|
||||
@action(detail=True, methods=['get', 'post'], url_path='stages')
|
||||
def stages(self, request, pk=None):
|
||||
session = self.get_object()
|
||||
if request.method == 'GET':
|
||||
serializer = PRSStageSerializer(
|
||||
session.stages.all(), many=True, context={'request': request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
serializer = PRSStageSerializer(
|
||||
data=request.data,
|
||||
context={'request': request, 'session': session},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save(session=session)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get', 'patch', 'delete'],
|
||||
url_path=r'stages/(?P<stage_pk>[^/.]+)')
|
||||
def stage_detail(self, request, pk=None, stage_pk=None):
|
||||
session = self.get_object()
|
||||
stage = get_object_or_404(PRSStage, pk=stage_pk, session=session)
|
||||
if request.method == 'GET':
|
||||
return Response(PRSStageSerializer(stage, context={'request': request}).data)
|
||||
if request.method == 'PATCH':
|
||||
serializer = PRSStageSerializer(
|
||||
stage, data=request.data, partial=True, context={'request': request}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
stage.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=['post'],
|
||||
url_path=r'stages/(?P<stage_pk>[^/.]+)/compute-corrections')
|
||||
def compute_corrections_action(self, request, pk=None, stage_pk=None):
|
||||
session = self.get_object()
|
||||
stage = get_object_or_404(PRSStage, pk=stage_pk, session=session)
|
||||
return Response(compute_corrections(session, stage))
|
||||
|
||||
|
||||
# ── Free Practice ─────────────────────────────────────────────────────────────
|
||||
|
||||
class FreePracticeSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsOwnerOrReadPublic]
|
||||
filterset_fields = ['date', 'is_public']
|
||||
search_fields = ['name', 'location', 'notes']
|
||||
ordering_fields = ['date', 'created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if not user.is_authenticated:
|
||||
return FreePracticeSession.objects.filter(is_public=True).select_related(
|
||||
'rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis'
|
||||
)
|
||||
return (
|
||||
FreePracticeSession.objects
|
||||
.filter(user=user)
|
||||
.select_related('rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis')
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return FreePracticeSessionListSerializer
|
||||
if self.action in ('create', 'update', 'partial_update'):
|
||||
return FreePracticeSessionWriteSerializer
|
||||
return FreePracticeSessionDetailSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
|
||||
# ── Speed Shooting ────────────────────────────────────────────────────────────
|
||||
|
||||
class SpeedShootingSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsOwnerOrReadPublic]
|
||||
filterset_fields = ['date', 'is_public']
|
||||
search_fields = ['name', 'location', 'notes', 'format']
|
||||
ordering_fields = ['date', 'created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if not user.is_authenticated:
|
||||
return SpeedShootingSession.objects.filter(is_public=True).select_related(
|
||||
'rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis'
|
||||
)
|
||||
return (
|
||||
SpeedShootingSession.objects
|
||||
.filter(user=user)
|
||||
.select_related('rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis')
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return SpeedShootingSessionListSerializer
|
||||
if self.action in ('create', 'update', 'partial_update'):
|
||||
return SpeedShootingSessionWriteSerializer
|
||||
return SpeedShootingSessionDetailSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
0
apps/social/__init__.py
Normal file
0
apps/social/__init__.py
Normal file
33
apps/social/admin.py
Normal file
33
apps/social/admin.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import BlogPost, Bug, Friendship, Message
|
||||
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
list_display = ('sender', 'recipient', 'subject', 'sent_at', 'read_at')
|
||||
list_filter = ('sent_at',)
|
||||
search_fields = ('sender__email', 'recipient__email', 'subject')
|
||||
readonly_fields = ('sent_at', 'read_at')
|
||||
|
||||
|
||||
@admin.register(BlogPost)
|
||||
class BlogPostAdmin(admin.ModelAdmin):
|
||||
list_display = ('author', 'title', 'is_public', 'created_at')
|
||||
list_filter = ('is_public',)
|
||||
search_fields = ('author__email', 'title')
|
||||
|
||||
|
||||
@admin.register(Bug)
|
||||
class BugAdmin(admin.ModelAdmin):
|
||||
list_display = ('reporter', 'title', 'severity', 'status', 'created_at')
|
||||
list_filter = ('severity', 'status')
|
||||
search_fields = ('reporter__email', 'title')
|
||||
readonly_fields = ('created_at', 'updated_at', 'resolved_at')
|
||||
|
||||
|
||||
@admin.register(Friendship)
|
||||
class FriendshipAdmin(admin.ModelAdmin):
|
||||
list_display = ('from_user', 'to_user', 'status', 'created_at')
|
||||
list_filter = ('status',)
|
||||
search_fields = ('from_user__email', 'to_user__email')
|
||||
6
apps/social/apps.py
Normal file
6
apps/social/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SocialConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.social'
|
||||
81
apps/social/migrations/0001_initial.py
Normal file
81
apps/social/migrations/0001_initial.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# Generated by Django 4.2.16 on 2026-04-01 19:33
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('subject', models.CharField(max_length=200, verbose_name='subject')),
|
||||
('body', models.TextField(verbose_name='body')),
|
||||
('sent_at', models.DateTimeField(auto_now_add=True)),
|
||||
('read_at', models.DateTimeField(blank=True, null=True)),
|
||||
('deleted_by_sender', models.BooleanField(default=False)),
|
||||
('deleted_by_recipient', models.BooleanField(default=False)),
|
||||
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)),
|
||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-sent_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Bug',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=300, verbose_name='title')),
|
||||
('description', models.TextField(verbose_name='description')),
|
||||
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], default='medium', max_length=10, verbose_name='severity')),
|
||||
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], default='open', max_length=15, verbose_name='status')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('resolved_at', models.DateTimeField(blank=True, null=True)),
|
||||
('reporter', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reported_bugs', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BlogPost',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=300, verbose_name='title')),
|
||||
('body', models.TextField(verbose_name='body')),
|
||||
('is_public', models.BooleanField(default=True, verbose_name='public')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blog_posts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Friendship',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('blocked', 'Blocked')], default='pending', max_length=10, verbose_name='status')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friendships_sent', to=settings.AUTH_USER_MODEL)),
|
||||
('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friendships_received', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'unique_together': {('from_user', 'to_user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/social/migrations/__init__.py
Normal file
0
apps/social/migrations/__init__.py
Normal file
111
apps/social/models.py
Normal file
111
apps/social/models.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# ── Message ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class Message(models.Model):
|
||||
sender = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sent_messages')
|
||||
recipient = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='received_messages')
|
||||
subject = models.CharField(_('subject'), max_length=200)
|
||||
body = models.TextField(_('body'))
|
||||
sent_at = models.DateTimeField(auto_now_add=True)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
deleted_by_sender = models.BooleanField(default=False)
|
||||
deleted_by_recipient = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-sent_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.sender} → {self.recipient}: {self.subject}'
|
||||
|
||||
|
||||
# ── BlogPost ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class BlogPost(models.Model):
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='blog_posts')
|
||||
title = models.CharField(_('title'), max_length=300)
|
||||
body = models.TextField(_('body'))
|
||||
is_public = models.BooleanField(_('public'), default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
# ── Bug ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
class BugSeverity(models.TextChoices):
|
||||
LOW = 'low', _('Low')
|
||||
MEDIUM = 'medium', _('Medium')
|
||||
HIGH = 'high', _('High')
|
||||
CRITICAL = 'critical', _('Critical')
|
||||
|
||||
|
||||
class BugStatus(models.TextChoices):
|
||||
OPEN = 'open', _('Open')
|
||||
IN_PROGRESS = 'in_progress', _('In Progress')
|
||||
RESOLVED = 'resolved', _('Resolved')
|
||||
CLOSED = 'closed', _('Closed')
|
||||
|
||||
|
||||
class Bug(models.Model):
|
||||
reporter = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||||
null=True, related_name='reported_bugs',
|
||||
)
|
||||
title = models.CharField(_('title'), max_length=300)
|
||||
description = models.TextField(_('description'))
|
||||
severity = models.CharField(
|
||||
_('severity'), max_length=10,
|
||||
choices=BugSeverity.choices, default=BugSeverity.MEDIUM,
|
||||
)
|
||||
status = models.CharField(
|
||||
_('status'), max_length=15,
|
||||
choices=BugStatus.choices, default=BugStatus.OPEN,
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'[{self.get_severity_display()}] {self.title}'
|
||||
|
||||
|
||||
# ── Friendship ────────────────────────────────────────────────────────────────
|
||||
|
||||
class FriendshipStatus(models.TextChoices):
|
||||
PENDING = 'pending', _('Pending')
|
||||
ACCEPTED = 'accepted', _('Accepted')
|
||||
BLOCKED = 'blocked', _('Blocked')
|
||||
|
||||
|
||||
class Friendship(models.Model):
|
||||
from_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friendships_sent',
|
||||
)
|
||||
to_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friendships_received',
|
||||
)
|
||||
status = models.CharField(
|
||||
_('status'), max_length=10,
|
||||
choices=FriendshipStatus.choices, default=FriendshipStatus.PENDING,
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [('from_user', 'to_user')]
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.from_user} → {self.to_user} ({self.status})'
|
||||
105
apps/social/serializers.py
Normal file
105
apps/social/serializers.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import BlogPost, Bug, Friendship, Message
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _user_mini(user):
|
||||
if user is None:
|
||||
return None
|
||||
name = (f'{user.first_name} {user.last_name}'.strip()) or user.username
|
||||
return {'id': user.id, 'username': user.username, 'display_name': name}
|
||||
|
||||
|
||||
# ── Message ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class MessageListSerializer(serializers.ModelSerializer):
|
||||
sender_detail = serializers.SerializerMethodField()
|
||||
recipient_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_sender_detail(self, obj): return _user_mini(obj.sender)
|
||||
def get_recipient_detail(self, obj): return _user_mini(obj.recipient)
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = ['id', 'sender_detail', 'recipient_detail', 'subject', 'sent_at', 'read_at']
|
||||
|
||||
|
||||
class MessageDetailSerializer(serializers.ModelSerializer):
|
||||
sender_detail = serializers.SerializerMethodField()
|
||||
recipient_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_sender_detail(self, obj): return _user_mini(obj.sender)
|
||||
def get_recipient_detail(self, obj): return _user_mini(obj.recipient)
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = ['id', 'sender_detail', 'recipient_detail', 'subject', 'body', 'sent_at', 'read_at']
|
||||
|
||||
|
||||
class MessageCreateSerializer(serializers.ModelSerializer):
|
||||
recipient = serializers.PrimaryKeyRelatedField(queryset=User.objects.all())
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = ['recipient', 'subject', 'body']
|
||||
|
||||
def validate_recipient(self, value):
|
||||
if value == self.context['request'].user:
|
||||
raise serializers.ValidationError('You cannot send a message to yourself.')
|
||||
return value
|
||||
|
||||
|
||||
# ── BlogPost ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class BlogPostSerializer(serializers.ModelSerializer):
|
||||
author_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_author_detail(self, obj): return _user_mini(obj.author)
|
||||
|
||||
class Meta:
|
||||
model = BlogPost
|
||||
fields = ['id', 'author_detail', 'title', 'body', 'is_public', 'created_at', 'updated_at']
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
# ── Bug ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
class BugSerializer(serializers.ModelSerializer):
|
||||
reporter_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_reporter_detail(self, obj): return _user_mini(obj.reporter)
|
||||
|
||||
class Meta:
|
||||
model = Bug
|
||||
fields = [
|
||||
'id', 'reporter_detail', 'title', 'description',
|
||||
'severity', 'status', 'created_at', 'updated_at', 'resolved_at',
|
||||
]
|
||||
read_only_fields = ['status', 'resolved_at', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
# ── Friendship ────────────────────────────────────────────────────────────────
|
||||
|
||||
class FriendshipSerializer(serializers.ModelSerializer):
|
||||
from_user_detail = serializers.SerializerMethodField()
|
||||
to_user_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_from_user_detail(self, obj): return _user_mini(obj.from_user)
|
||||
def get_to_user_detail(self, obj): return _user_mini(obj.to_user)
|
||||
|
||||
class Meta:
|
||||
model = Friendship
|
||||
fields = ['id', 'from_user_detail', 'to_user_detail', 'status', 'created_at']
|
||||
read_only_fields = ['status', 'created_at']
|
||||
|
||||
|
||||
class FriendRequestSerializer(serializers.Serializer):
|
||||
to_user = serializers.PrimaryKeyRelatedField(queryset=User.objects.all())
|
||||
|
||||
def validate_to_user(self, value):
|
||||
if value == self.context['request'].user:
|
||||
raise serializers.ValidationError('You cannot send a friend request to yourself.')
|
||||
return value
|
||||
14
apps/social/urls.py
Normal file
14
apps/social/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import BlogPostViewSet, BugViewSet, FriendshipViewSet, MessageViewSet, member_search
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('social/messages', MessageViewSet, basename='message')
|
||||
router.register('social/blog', BlogPostViewSet, basename='blogpost')
|
||||
router.register('social/bugs', BugViewSet, basename='bug')
|
||||
router.register('social/friends', FriendshipViewSet, basename='friendship')
|
||||
|
||||
urlpatterns = [
|
||||
path('social/members/', member_search, name='member-search'),
|
||||
] + router.urls
|
||||
262
apps/social/views.py
Normal file
262
apps/social/views.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import BlogPost, Bug, BugStatus, Friendship, FriendshipStatus, Message
|
||||
from .serializers import (
|
||||
BlogPostSerializer,
|
||||
BugSerializer,
|
||||
FriendRequestSerializer,
|
||||
FriendshipSerializer,
|
||||
MessageCreateSerializer,
|
||||
MessageDetailSerializer,
|
||||
MessageListSerializer,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# ── Member search (for adding friends) ───────────────────────────────────────
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def member_search(request):
|
||||
q = request.query_params.get('q', '').strip()
|
||||
if len(q) < 2:
|
||||
return Response([])
|
||||
users = (
|
||||
User.objects
|
||||
.exclude(pk=request.user.pk)
|
||||
.filter(
|
||||
Q(username__icontains=q) |
|
||||
Q(first_name__icontains=q) |
|
||||
Q(last_name__icontains=q)
|
||||
)[:20]
|
||||
)
|
||||
data = [
|
||||
{
|
||||
'id': u.id,
|
||||
'username': u.username,
|
||||
'display_name': (f'{u.first_name} {u.last_name}'.strip()) or u.username,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class MessageViewSet(viewsets.GenericViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def list(self, request):
|
||||
"""Inbox: messages received by the current user."""
|
||||
qs = (
|
||||
Message.objects
|
||||
.filter(recipient=request.user, deleted_by_recipient=False)
|
||||
.select_related('sender', 'recipient')
|
||||
.order_by('-sent_at')
|
||||
)
|
||||
return Response(MessageListSerializer(qs, many=True).data)
|
||||
|
||||
def create(self, request):
|
||||
ser = MessageCreateSerializer(data=request.data, context={'request': request})
|
||||
ser.is_valid(raise_exception=True)
|
||||
msg = ser.save(sender=request.user)
|
||||
return Response(MessageDetailSerializer(msg).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
user = request.user
|
||||
msg = get_object_or_404(
|
||||
Message,
|
||||
Q(sender=user, deleted_by_sender=False) |
|
||||
Q(recipient=user, deleted_by_recipient=False),
|
||||
pk=pk,
|
||||
)
|
||||
if msg.recipient == user and msg.read_at is None:
|
||||
msg.read_at = timezone.now()
|
||||
msg.save(update_fields=['read_at'])
|
||||
return Response(MessageDetailSerializer(msg).data)
|
||||
|
||||
def destroy(self, request, pk=None):
|
||||
user = request.user
|
||||
msg = get_object_or_404(
|
||||
Message,
|
||||
Q(sender=user) | Q(recipient=user),
|
||||
pk=pk,
|
||||
)
|
||||
if msg.sender == user:
|
||||
msg.deleted_by_sender = True
|
||||
if msg.recipient == user:
|
||||
msg.deleted_by_recipient = True
|
||||
msg.save(update_fields=['deleted_by_sender', 'deleted_by_recipient'])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='sent')
|
||||
def sent(self, request):
|
||||
qs = (
|
||||
Message.objects
|
||||
.filter(sender=request.user, deleted_by_sender=False)
|
||||
.select_related('sender', 'recipient')
|
||||
.order_by('-sent_at')
|
||||
)
|
||||
return Response(MessageListSerializer(qs, many=True).data)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='unread-count')
|
||||
def unread_count(self, request):
|
||||
count = Message.objects.filter(
|
||||
recipient=request.user, read_at__isnull=True, deleted_by_recipient=False
|
||||
).count()
|
||||
return Response({'unread': count})
|
||||
|
||||
|
||||
# ── BlogPost ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class BlogPostViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = BlogPostSerializer
|
||||
search_fields = ['title', 'body']
|
||||
ordering_fields = ['created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return (
|
||||
BlogPost.objects
|
||||
.filter(Q(author=user) | Q(is_public=True))
|
||||
.select_related('author')
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(author=self.request.user)
|
||||
|
||||
def check_write_permission(self, instance):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
if instance.author != self.request.user and not self.request.user.is_staff:
|
||||
raise PermissionDenied
|
||||
|
||||
def perform_update(self, serializer):
|
||||
self.check_write_permission(self.get_object())
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
self.check_write_permission(instance)
|
||||
instance.delete()
|
||||
|
||||
|
||||
# ── Bug ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
class BugViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = BugSerializer
|
||||
filterset_fields = ['severity', 'status']
|
||||
search_fields = ['title', 'description']
|
||||
ordering_fields = ['created_at', 'severity']
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if user.is_staff:
|
||||
return Bug.objects.select_related('reporter').all()
|
||||
return Bug.objects.filter(reporter=user).select_related('reporter')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(reporter=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='resolve')
|
||||
def resolve(self, request, pk=None):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
if not request.user.is_staff:
|
||||
raise PermissionDenied
|
||||
bug = self.get_object()
|
||||
bug.status = BugStatus.RESOLVED
|
||||
bug.resolved_at = timezone.now()
|
||||
bug.save(update_fields=['status', 'resolved_at', 'updated_at'])
|
||||
return Response(BugSerializer(bug).data)
|
||||
|
||||
|
||||
# ── Friendship ────────────────────────────────────────────────────────────────
|
||||
|
||||
class FriendshipViewSet(viewsets.GenericViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = FriendshipSerializer
|
||||
|
||||
def list(self, request):
|
||||
"""Accepted friends."""
|
||||
user = request.user
|
||||
qs = (
|
||||
Friendship.objects
|
||||
.filter(Q(from_user=user) | Q(to_user=user), status=FriendshipStatus.ACCEPTED)
|
||||
.select_related('from_user', 'to_user')
|
||||
)
|
||||
return Response(FriendshipSerializer(qs, many=True).data)
|
||||
|
||||
def create(self, request):
|
||||
ser = FriendRequestSerializer(data=request.data, context={'request': request})
|
||||
ser.is_valid(raise_exception=True)
|
||||
to_user = ser.validated_data['to_user']
|
||||
user = request.user
|
||||
|
||||
existing = Friendship.objects.filter(
|
||||
Q(from_user=user, to_user=to_user) |
|
||||
Q(from_user=to_user, to_user=user)
|
||||
).first()
|
||||
if existing:
|
||||
return Response({'detail': 'A friendship or request already exists.'}, status=status.HTTP_409_CONFLICT)
|
||||
|
||||
friendship = Friendship.objects.create(from_user=user, to_user=to_user)
|
||||
return Response(FriendshipSerializer(friendship).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def destroy(self, request, pk=None):
|
||||
user = request.user
|
||||
friendship = get_object_or_404(
|
||||
Friendship,
|
||||
Q(from_user=user) | Q(to_user=user),
|
||||
pk=pk,
|
||||
)
|
||||
friendship.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='requests')
|
||||
def requests(self, request):
|
||||
"""Incoming pending requests."""
|
||||
qs = (
|
||||
Friendship.objects
|
||||
.filter(to_user=request.user, status=FriendshipStatus.PENDING)
|
||||
.select_related('from_user', 'to_user')
|
||||
)
|
||||
return Response(FriendshipSerializer(qs, many=True).data)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='sent-requests')
|
||||
def sent_requests(self, request):
|
||||
"""Outgoing pending requests."""
|
||||
qs = (
|
||||
Friendship.objects
|
||||
.filter(from_user=request.user, status=FriendshipStatus.PENDING)
|
||||
.select_related('from_user', 'to_user')
|
||||
)
|
||||
return Response(FriendshipSerializer(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='accept')
|
||||
def accept(self, request, pk=None):
|
||||
friendship = get_object_or_404(
|
||||
Friendship, pk=pk, to_user=request.user, status=FriendshipStatus.PENDING
|
||||
)
|
||||
friendship.status = FriendshipStatus.ACCEPTED
|
||||
friendship.save(update_fields=['status', 'updated_at'])
|
||||
return Response(FriendshipSerializer(friendship).data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='decline')
|
||||
def decline(self, request, pk=None):
|
||||
"""Decline an incoming request or cancel an outgoing one."""
|
||||
user = request.user
|
||||
friendship = get_object_or_404(
|
||||
Friendship,
|
||||
Q(from_user=user) | Q(to_user=user),
|
||||
pk=pk, status=FriendshipStatus.PENDING,
|
||||
)
|
||||
friendship.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
0
apps/tools/__init__.py
Normal file
0
apps/tools/__init__.py
Normal file
45
apps/tools/admin.py
Normal file
45
apps/tools/admin.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import ChronographAnalysis, Shot, ShotGroup
|
||||
|
||||
|
||||
class ShotInline(admin.TabularInline):
|
||||
model = Shot
|
||||
extra = 0
|
||||
readonly_fields = ('shot_number',)
|
||||
fields = ('shot_number', 'velocity_fps', 'notes')
|
||||
|
||||
|
||||
class ShotGroupInline(admin.TabularInline):
|
||||
model = ShotGroup
|
||||
extra = 0
|
||||
show_change_link = True
|
||||
fields = ('label', 'distance_m', 'order', 'ammo_batch', 'notes')
|
||||
raw_id_fields = ('ammo_batch',)
|
||||
|
||||
|
||||
@admin.register(ChronographAnalysis)
|
||||
class ChronographAnalysisAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user', 'date', 'created_at')
|
||||
search_fields = ('name', 'user__email', 'notes')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
raw_id_fields = ('user',)
|
||||
inlines = [ShotGroupInline]
|
||||
|
||||
|
||||
@admin.register(ShotGroup)
|
||||
class ShotGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ('label', 'analysis', 'distance_m', 'order', 'ammo_batch')
|
||||
search_fields = ('label', 'analysis__name')
|
||||
raw_id_fields = ('analysis', 'ammo_batch')
|
||||
inlines = [ShotInline]
|
||||
|
||||
|
||||
@admin.register(Shot)
|
||||
class ShotAdmin(admin.ModelAdmin):
|
||||
list_display = ('shot_number', 'group', 'velocity_fps', 'notes')
|
||||
search_fields = ('group__label', 'group__analysis__name')
|
||||
readonly_fields = ('shot_number',)
|
||||
raw_id_fields = ('group',)
|
||||
ordering = ('group', 'shot_number')
|
||||
0
apps/tools/analyzer/__init__.py
Normal file
0
apps/tools/analyzer/__init__.py
Normal file
60
apps/tools/analyzer/grouper.py
Normal file
60
apps/tools/analyzer/grouper.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
|
||||
OUTLIER_FACTOR = 5
|
||||
|
||||
|
||||
def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR,
|
||||
manual_splits: list | None = None,
|
||||
forced_splits: list | None = None) -> list:
|
||||
"""Split shots into groups.
|
||||
|
||||
forced_splits: when provided, ONLY these split positions are used — auto-detection
|
||||
is bypassed entirely. Use this for user-defined groupings from the visual editor.
|
||||
|
||||
manual_splits: added on top of auto-detected splits (when forced_splits is None).
|
||||
Both auto+manual mechanisms are merged and deduplicated.
|
||||
"""
|
||||
if len(df) <= 1:
|
||||
return [df]
|
||||
|
||||
def _build_groups(all_splits):
|
||||
if not all_splits:
|
||||
return [df]
|
||||
groups = []
|
||||
prev = 0
|
||||
for pos in all_splits:
|
||||
group = df.iloc[prev:pos]
|
||||
if len(group) > 0:
|
||||
groups.append(group.reset_index(drop=True))
|
||||
prev = pos
|
||||
last = df.iloc[prev:]
|
||||
if len(last) > 0:
|
||||
groups.append(last.reset_index(drop=True))
|
||||
return groups
|
||||
|
||||
# Forced mode: user controls exact split positions, no auto-detection
|
||||
if forced_splits is not None:
|
||||
valid = sorted(s for s in forced_splits if 0 < s < len(df))
|
||||
return _build_groups(valid)
|
||||
|
||||
times = df["time"]
|
||||
diffs = times.diff().dropna()
|
||||
|
||||
if diffs.empty:
|
||||
return [df]
|
||||
|
||||
median_gap = diffs.median()
|
||||
|
||||
# Auto-detect splits based on time gaps
|
||||
auto_splits: set[int] = set()
|
||||
if median_gap != timedelta(0):
|
||||
threshold = outlier_factor * median_gap
|
||||
for idx, gap in diffs.items():
|
||||
if gap > threshold:
|
||||
pos = df.index.get_loc(idx)
|
||||
auto_splits.add(pos)
|
||||
|
||||
# Merge with manual splits (filter to valid range)
|
||||
extra = set(manual_splits) if manual_splits else set()
|
||||
return _build_groups(sorted(auto_splits | extra))
|
||||
6
apps/tools/apps.py
Normal file
6
apps/tools/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ToolsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.tools'
|
||||
93
apps/tools/migrations/0001_initial.py
Normal file
93
apps/tools/migrations/0001_initial.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-24 12:28
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ChronographAnalysis',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=150, verbose_name='name')),
|
||||
('date', models.DateField(verbose_name='date')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='chronograph_analyses', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'chronograph analysis',
|
||||
'verbose_name_plural': 'chronograph analyses',
|
||||
'ordering': ['-date', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ResultPicture',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='result_pictures/', verbose_name='image')),
|
||||
('description', models.CharField(blank=True, max_length=255, verbose_name='description')),
|
||||
('group_size_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='group size (mm)')),
|
||||
('group_size_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='group size (MOA)')),
|
||||
('elevation_offset_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='elevation offset (mm)')),
|
||||
('elevation_offset_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='elevation offset (MOA)')),
|
||||
('windage_offset_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='windage offset (mm)')),
|
||||
('windage_offset_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='windage offset (MOA)')),
|
||||
('mean_radius_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='mean radius (mm)')),
|
||||
('mean_radius_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='mean radius (MOA)')),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='uploaded at')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='result_pictures', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'result picture',
|
||||
'verbose_name_plural': 'result pictures',
|
||||
'ordering': ['-uploaded_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShotGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('label', models.CharField(max_length=100, verbose_name='label')),
|
||||
('distance_m', models.DecimalField(blank=True, decimal_places=1, max_digits=7, null=True, verbose_name='distance (m)')),
|
||||
('order', models.PositiveSmallIntegerField(default=0, verbose_name='order')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('analysis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shot_groups', to='tools.chronographanalysis', verbose_name='analysis')),
|
||||
('result_picture', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_group', to='tools.resultpicture', verbose_name='result picture')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'shot group',
|
||||
'verbose_name_plural': 'shot groups',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Shot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('shot_number', models.PositiveSmallIntegerField(editable=False, verbose_name='shot number')),
|
||||
('velocity_fps', models.DecimalField(decimal_places=1, max_digits=6, verbose_name='velocity (fps)')),
|
||||
('notes', models.CharField(blank=True, max_length=255, verbose_name='notes')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shots', to='tools.shotgroup', verbose_name='group')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'shot',
|
||||
'verbose_name_plural': 'shots',
|
||||
'ordering': ['shot_number'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='shot',
|
||||
constraint=models.UniqueConstraint(fields=('group', 'shot_number'), name='unique_shot_number_per_group'),
|
||||
),
|
||||
]
|
||||
20
apps/tools/migrations/0002_shotgroup_ammo_batch.py
Normal file
20
apps/tools/migrations/0002_shotgroup_ammo_batch.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-24 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gears', '0003_ammo_brass_bullet_powder_primer_alter_bipod_options_and_more'),
|
||||
('tools', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shotgroup',
|
||||
name='ammo_batch',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_groups', to='gears.reloadedammobatch', verbose_name='reloaded ammo batch'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-25 10:15
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tools', '0002_shotgroup_ammo_batch'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='shotgroup',
|
||||
name='result_picture',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='ResultPicture',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-30 13:12
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('gears', '0011_rig_ballistic_fields'),
|
||||
('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shotgroup',
|
||||
name='ammo',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_groups', to='gears.ammo', verbose_name='factory ammo'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shotgroup',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_groups', to=settings.AUTH_USER_MODEL, verbose_name='user'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shotgroup',
|
||||
name='analysis',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_groups', to='tools.chronographanalysis', verbose_name='analysis'),
|
||||
),
|
||||
]
|
||||
16
apps/tools/migrations/0005_chronographanalysis_is_public.py
Normal file
16
apps/tools/migrations/0005_chronographanalysis_is_public.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tools', '0004_shotgroup_nullable_analysis_user_ammo'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chronographanalysis',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
]
|
||||
0
apps/tools/migrations/__init__.py
Normal file
0
apps/tools/migrations/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user