First commit of claude's rework in django + vanillajs fronted

This commit is contained in:
Gérald Colangelo
2026-04-02 11:24:30 +02:00
parent 7710a876df
commit fde92f92db
163 changed files with 84852 additions and 15 deletions

0
apps/gears/__init__.py Normal file
View File

171
apps/gears/admin.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class GearsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.gears'

View File

View 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.'
))

View 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'],
},
),
]

View 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'),
),
]

View File

@@ -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'),
),
]

File diff suppressed because it is too large Load Diff

View 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'),
),
]

View 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',
),
),
]

View 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),
]

View File

@@ -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'),
),
]

View 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'),
),
]

View 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)'),
),
]

View 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',
),
),
]

View 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',
),
]

View 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'),
),
]

View File

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