First commit of claude's rework in django + vanillajs fronted
This commit is contained in:
0
apps/sessions/__init__.py
Normal file
0
apps/sessions/__init__.py
Normal file
36
apps/sessions/admin.py
Normal file
36
apps/sessions/admin.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import FreePracticeSession, PRSSession, PRSStage, SpeedShootingSession
|
||||
|
||||
|
||||
class PRSStageInline(admin.TabularInline):
|
||||
model = PRSStage
|
||||
extra = 0
|
||||
fields = [
|
||||
'order', 'position', 'distance_m',
|
||||
'target_width_cm', 'target_height_cm', 'max_time_s', 'shots_count',
|
||||
'actual_elevation', 'actual_windage',
|
||||
'hits', 'score', 'time_taken_s',
|
||||
]
|
||||
|
||||
|
||||
@admin.register(PRSSession)
|
||||
class PRSSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['__str__', 'user', 'date', 'location', 'competition_name', 'category']
|
||||
list_filter = ['date']
|
||||
search_fields = ['user__email', 'competition_name', 'location']
|
||||
inlines = [PRSStageInline]
|
||||
|
||||
|
||||
@admin.register(FreePracticeSession)
|
||||
class FreePracticeSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['__str__', 'user', 'date', 'location', 'distance_m', 'rounds_fired']
|
||||
list_filter = ['date']
|
||||
search_fields = ['user__email', 'name', 'location']
|
||||
|
||||
|
||||
@admin.register(SpeedShootingSession)
|
||||
class SpeedShootingSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['__str__', 'user', 'date', 'format', 'rounds_fired']
|
||||
list_filter = ['date']
|
||||
search_fields = ['user__email', 'name', 'format']
|
||||
7
apps/sessions/apps.py
Normal file
7
apps/sessions/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SessionsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.sessions'
|
||||
label = 'shooting_sessions'
|
||||
32
apps/sessions/ballistics.py
Normal file
32
apps/sessions/ballistics.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Ballistic correction engine.
|
||||
|
||||
Computes scope elevation and windage corrections given a rig, ammo,
|
||||
target distance, and weather conditions.
|
||||
|
||||
Currently a stub — returns None until the trajectory integration is built.
|
||||
"""
|
||||
|
||||
|
||||
def compute_corrections(session, stage) -> dict:
|
||||
"""
|
||||
Return scope corrections for a given PRS stage.
|
||||
|
||||
Args:
|
||||
session: PRSSession instance (provides rig, ammo/reloaded_batch, weather)
|
||||
stage: PRSStage instance (provides distance_m)
|
||||
|
||||
Returns:
|
||||
dict with keys: elevation, windage, unit, message
|
||||
"""
|
||||
# TODO: implement point-mass trajectory integration using:
|
||||
# - session.rig.zero_distance_m, session.rig.scope_height_mm
|
||||
# - ammo BC (Bullet.bc_g7 / bc_g1) and muzzle velocity
|
||||
# - session weather fields (temperature_c, pressure_hpa, humidity_pct)
|
||||
# - stage.distance_m and session wind fields
|
||||
return {
|
||||
'elevation': None,
|
||||
'windage': None,
|
||||
'unit': None,
|
||||
'message': 'Ballistic engine not yet implemented.',
|
||||
}
|
||||
144
apps/sessions/migrations/0001_initial.py
Normal file
144
apps/sessions/migrations/0001_initial.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# Generated by Django 4.2.16 on 2026-03-30 09:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('gears', '0011_rig_ballistic_fields'),
|
||||
('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PRSSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=255, verbose_name='name')),
|
||||
('date', models.DateField(verbose_name='date')),
|
||||
('location', models.CharField(blank=True, max_length=255, verbose_name='location')),
|
||||
('temperature_c', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='temperature (°C)')),
|
||||
('wind_speed_ms', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='wind speed (m/s)')),
|
||||
('wind_direction_deg', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='wind direction (°)')),
|
||||
('humidity_pct', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='humidity (%)')),
|
||||
('pressure_hpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='pressure (hPa)')),
|
||||
('weather_notes', models.TextField(blank=True, verbose_name='weather notes')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('competition_name', models.CharField(blank=True, max_length=255, verbose_name='competition name')),
|
||||
('category', models.CharField(blank=True, max_length=100, verbose_name='category')),
|
||||
('ammo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.ammo', verbose_name='factory ammo')),
|
||||
('reloaded_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.reloadedammobatch', verbose_name='reloaded batch')),
|
||||
('rig', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.rig', verbose_name='rig')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'PRS session',
|
||||
'verbose_name_plural': 'PRS sessions',
|
||||
'ordering': ['-date', '-created_at'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SpeedShootingSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=255, verbose_name='name')),
|
||||
('date', models.DateField(verbose_name='date')),
|
||||
('location', models.CharField(blank=True, max_length=255, verbose_name='location')),
|
||||
('temperature_c', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='temperature (°C)')),
|
||||
('wind_speed_ms', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='wind speed (m/s)')),
|
||||
('wind_direction_deg', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='wind direction (°)')),
|
||||
('humidity_pct', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='humidity (%)')),
|
||||
('pressure_hpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='pressure (hPa)')),
|
||||
('weather_notes', models.TextField(blank=True, verbose_name='weather notes')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('format', models.CharField(blank=True, max_length=100, verbose_name='format')),
|
||||
('rounds_fired', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='rounds fired')),
|
||||
('ammo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.ammo', verbose_name='factory ammo')),
|
||||
('reloaded_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.reloadedammobatch', verbose_name='reloaded batch')),
|
||||
('rig', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.rig', verbose_name='rig')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'speed shooting session',
|
||||
'verbose_name_plural': 'speed shooting sessions',
|
||||
'ordering': ['-date', '-created_at'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PRSStage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.PositiveSmallIntegerField(verbose_name='order')),
|
||||
('position', models.CharField(choices=[('PRONE', 'Prone'), ('STANDING', 'Standing'), ('SITTING', 'Sitting'), ('KNEELING', 'Kneeling'), ('BARRICADE', 'Barricade'), ('UNSUPPORTED', 'Unsupported'), ('OTHER', 'Other')], default='PRONE', max_length=20, verbose_name='shooting position')),
|
||||
('distance_m', models.PositiveSmallIntegerField(verbose_name='distance (m)')),
|
||||
('target_width_cm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='target width (cm)')),
|
||||
('target_height_cm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='target height (cm)')),
|
||||
('max_time_s', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='max time (s)')),
|
||||
('shots_count', models.PositiveSmallIntegerField(default=1, verbose_name='shots count')),
|
||||
('notes_prep', models.TextField(blank=True, verbose_name='prep notes')),
|
||||
('computed_elevation', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='computed elevation')),
|
||||
('computed_windage', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='computed windage')),
|
||||
('correction_unit', models.CharField(blank=True, choices=[('MOA', 'MOA'), ('MRAD', 'MRAD'), ('CLICK', 'Clicks')], max_length=10, verbose_name='correction unit')),
|
||||
('actual_elevation', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='actual elevation')),
|
||||
('actual_windage', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='actual windage')),
|
||||
('hits', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='hits')),
|
||||
('score', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score')),
|
||||
('time_taken_s', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='time taken (s)')),
|
||||
('notes_post', models.TextField(blank=True, verbose_name='post notes')),
|
||||
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='shooting_sessions.prssession', verbose_name='session')),
|
||||
('shot_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prs_stages', to='tools.shotgroup', verbose_name='shot group')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'PRS stage',
|
||||
'verbose_name_plural': 'PRS stages',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FreePracticeSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=255, verbose_name='name')),
|
||||
('date', models.DateField(verbose_name='date')),
|
||||
('location', models.CharField(blank=True, max_length=255, verbose_name='location')),
|
||||
('temperature_c', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='temperature (°C)')),
|
||||
('wind_speed_ms', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='wind speed (m/s)')),
|
||||
('wind_direction_deg', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='wind direction (°)')),
|
||||
('humidity_pct', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='humidity (%)')),
|
||||
('pressure_hpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='pressure (hPa)')),
|
||||
('weather_notes', models.TextField(blank=True, verbose_name='weather notes')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
||||
('distance_m', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='distance (m)')),
|
||||
('target_description', models.CharField(blank=True, max_length=255, verbose_name='target description')),
|
||||
('rounds_fired', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='rounds fired')),
|
||||
('ammo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.ammo', verbose_name='factory ammo')),
|
||||
('reloaded_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.reloadedammobatch', verbose_name='reloaded batch')),
|
||||
('rig', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.rig', verbose_name='rig')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'free practice session',
|
||||
'verbose_name_plural': 'free practice sessions',
|
||||
'ordering': ['-date', '-created_at'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='prsstage',
|
||||
constraint=models.UniqueConstraint(fields=('session', 'order'), name='unique_prs_stage_order'),
|
||||
),
|
||||
]
|
||||
46
apps/sessions/migrations/0002_add_analysis_fk.py
Normal file
46
apps/sessions/migrations/0002_add_analysis_fk.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shooting_sessions', '0001_initial'),
|
||||
('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='prssession',
|
||||
name='analysis',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='tools.chronographanalysis',
|
||||
verbose_name='chronograph analysis',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='freepracticesession',
|
||||
name='analysis',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='tools.chronographanalysis',
|
||||
verbose_name='chronograph analysis',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='speedshootingsession',
|
||||
name='analysis',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='tools.chronographanalysis',
|
||||
verbose_name='chronograph analysis',
|
||||
),
|
||||
),
|
||||
]
|
||||
26
apps/sessions/migrations/0003_session_is_public.py
Normal file
26
apps/sessions/migrations/0003_session_is_public.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shooting_sessions', '0002_add_analysis_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='prssession',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='freepracticesession',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='speedshootingsession',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='public'),
|
||||
),
|
||||
]
|
||||
0
apps/sessions/migrations/__init__.py
Normal file
0
apps/sessions/migrations/__init__.py
Normal file
246
apps/sessions/models.py
Normal file
246
apps/sessions/models.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# ── Choices ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class ShootingPosition(models.TextChoices):
|
||||
PRONE = 'PRONE', _('Prone')
|
||||
STANDING = 'STANDING', _('Standing')
|
||||
SITTING = 'SITTING', _('Sitting')
|
||||
KNEELING = 'KNEELING', _('Kneeling')
|
||||
BARRICADE = 'BARRICADE', _('Barricade')
|
||||
UNSUPPORTED = 'UNSUPPORTED', _('Unsupported')
|
||||
OTHER = 'OTHER', _('Other')
|
||||
|
||||
|
||||
class CorrectionUnit(models.TextChoices):
|
||||
MOA = 'MOA', _('MOA')
|
||||
MRAD = 'MRAD', _('MRAD')
|
||||
CLICK = 'CLICK', _('Clicks')
|
||||
|
||||
|
||||
# ── Abstract base ─────────────────────────────────────────────────────────────
|
||||
|
||||
class AbstractSession(models.Model):
|
||||
"""
|
||||
Shared fields inherited by all concrete session types.
|
||||
Each subclass gets its own DB table (no cross-table joins).
|
||||
"""
|
||||
# Use '+' to suppress reverse accessors — query via concrete model managers
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
verbose_name=_('user'),
|
||||
)
|
||||
name = models.CharField(_('name'), max_length=255, blank=True)
|
||||
date = models.DateField(_('date'))
|
||||
location = models.CharField(_('location'), max_length=255, blank=True)
|
||||
|
||||
# Intentional cross-app FKs (string refs avoid circular imports)
|
||||
analysis = models.ForeignKey(
|
||||
'tools.ChronographAnalysis',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
verbose_name=_('chronograph analysis'),
|
||||
)
|
||||
rig = models.ForeignKey(
|
||||
'gears.Rig',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
verbose_name=_('rig'),
|
||||
)
|
||||
ammo = models.ForeignKey(
|
||||
'gears.Ammo',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
verbose_name=_('factory ammo'),
|
||||
)
|
||||
reloaded_batch = models.ForeignKey(
|
||||
'gears.ReloadedAmmoBatch',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
verbose_name=_('reloaded batch'),
|
||||
)
|
||||
|
||||
# Weather
|
||||
temperature_c = models.DecimalField(_('temperature (°C)'), max_digits=5, decimal_places=1, null=True, blank=True)
|
||||
wind_speed_ms = models.DecimalField(_('wind speed (m/s)'), max_digits=5, decimal_places=1, null=True, blank=True)
|
||||
wind_direction_deg = models.PositiveSmallIntegerField(_('wind direction (°)'), null=True, blank=True)
|
||||
humidity_pct = models.PositiveSmallIntegerField(_('humidity (%)'), null=True, blank=True)
|
||||
pressure_hpa = models.DecimalField(_('pressure (hPa)'), max_digits=6, decimal_places=1, null=True, blank=True)
|
||||
weather_notes = models.TextField(_('weather notes'), blank=True)
|
||||
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
is_public = models.BooleanField(_('public'), default=False)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ['-date', '-created_at']
|
||||
|
||||
def clean(self):
|
||||
if self.ammo_id and self.reloaded_batch_id:
|
||||
raise ValidationError(
|
||||
_('A session may use factory ammo or a reloaded batch, not both.')
|
||||
)
|
||||
if self.rig_id:
|
||||
if self.rig.user_id != self.user_id:
|
||||
raise ValidationError({'rig': _('This rig does not belong to you.')})
|
||||
if self.wind_direction_deg is not None and not (0 <= self.wind_direction_deg <= 359):
|
||||
raise ValidationError(
|
||||
{'wind_direction_deg': _('Wind direction must be between 0 and 359 degrees.')}
|
||||
)
|
||||
if self.humidity_pct is not None and not (0 <= self.humidity_pct <= 100):
|
||||
raise ValidationError(
|
||||
{'humidity_pct': _('Humidity must be between 0 and 100.')}
|
||||
)
|
||||
|
||||
|
||||
# ── PRS session ───────────────────────────────────────────────────────────────
|
||||
|
||||
class PRSSession(AbstractSession):
|
||||
"""
|
||||
A Precision Rifle Series session.
|
||||
Two-phase workflow: preparation (stages defined upfront) →
|
||||
execution (weather entered, corrections computed, results recorded).
|
||||
"""
|
||||
competition_name = models.CharField(_('competition name'), max_length=255, blank=True)
|
||||
category = models.CharField(_('category'), max_length=100, blank=True)
|
||||
|
||||
class Meta(AbstractSession.Meta):
|
||||
verbose_name = _('PRS session')
|
||||
verbose_name_plural = _('PRS sessions')
|
||||
|
||||
def __str__(self):
|
||||
label = self.competition_name or self.name or _('PRS session')
|
||||
return f"{label} — {self.date}"
|
||||
|
||||
|
||||
class PRSStage(models.Model):
|
||||
"""
|
||||
One stage within a PRSSession.
|
||||
Fields are grouped by lifecycle phase: prep, execution, results.
|
||||
"""
|
||||
session = models.ForeignKey(
|
||||
PRSSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='stages',
|
||||
verbose_name=_('session'),
|
||||
)
|
||||
|
||||
# ── Prep phase ────────────────────────────────────────────────────────────
|
||||
order = models.PositiveSmallIntegerField(_('order'))
|
||||
position = models.CharField(
|
||||
_('shooting position'), max_length=20,
|
||||
choices=ShootingPosition.choices, default=ShootingPosition.PRONE,
|
||||
)
|
||||
distance_m = models.PositiveSmallIntegerField(_('distance (m)'))
|
||||
target_width_cm = models.DecimalField(
|
||||
_('target width (cm)'), max_digits=6, decimal_places=1, null=True, blank=True,
|
||||
)
|
||||
target_height_cm = models.DecimalField(
|
||||
_('target height (cm)'), max_digits=6, decimal_places=1, null=True, blank=True,
|
||||
)
|
||||
max_time_s = models.PositiveSmallIntegerField(_('max time (s)'), null=True, blank=True)
|
||||
shots_count = models.PositiveSmallIntegerField(_('shots count'), default=1)
|
||||
notes_prep = models.TextField(_('prep notes'), blank=True)
|
||||
|
||||
# ── Execution phase ───────────────────────────────────────────────────────
|
||||
# computed_* are set by the ballistic engine (read-only for clients)
|
||||
computed_elevation = models.DecimalField(
|
||||
_('computed elevation'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
computed_windage = models.DecimalField(
|
||||
_('computed windage'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
correction_unit = models.CharField(
|
||||
_('correction unit'), max_length=10,
|
||||
choices=CorrectionUnit.choices, blank=True,
|
||||
)
|
||||
# actual_* are editable by the shooter
|
||||
actual_elevation = models.DecimalField(
|
||||
_('actual elevation'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
actual_windage = models.DecimalField(
|
||||
_('actual windage'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
|
||||
# ── Results phase ─────────────────────────────────────────────────────────
|
||||
hits = models.PositiveSmallIntegerField(_('hits'), null=True, blank=True)
|
||||
score = models.PositiveSmallIntegerField(_('score'), null=True, blank=True)
|
||||
time_taken_s = models.DecimalField(
|
||||
_('time taken (s)'), max_digits=6, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
# Optional link to chronograph/shot data
|
||||
shot_group = models.ForeignKey(
|
||||
'tools.ShotGroup',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='prs_stages',
|
||||
verbose_name=_('shot group'),
|
||||
)
|
||||
notes_post = models.TextField(_('post notes'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('PRS stage')
|
||||
verbose_name_plural = _('PRS stages')
|
||||
ordering = ['order']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['session', 'order'], name='unique_prs_stage_order')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Stage {self.order} — {self.distance_m}m {self.get_position_display()}"
|
||||
|
||||
def clean(self):
|
||||
if self.hits is not None and self.hits > self.shots_count:
|
||||
raise ValidationError(
|
||||
{'hits': _('Hits cannot exceed the number of shots for this stage.')}
|
||||
)
|
||||
if self.score is not None and self.hits is not None and self.score > self.hits:
|
||||
raise ValidationError(
|
||||
{'score': _('Score cannot exceed the number of hits.')}
|
||||
)
|
||||
|
||||
|
||||
# ── Free Practice session ─────────────────────────────────────────────────────
|
||||
|
||||
class FreePracticeSession(AbstractSession):
|
||||
"""A free-form practice session at a fixed distance."""
|
||||
distance_m = models.PositiveSmallIntegerField(_('distance (m)'), null=True, blank=True)
|
||||
target_description = models.CharField(_('target description'), max_length=255, blank=True)
|
||||
rounds_fired = models.PositiveSmallIntegerField(_('rounds fired'), null=True, blank=True)
|
||||
|
||||
class Meta(AbstractSession.Meta):
|
||||
verbose_name = _('free practice session')
|
||||
verbose_name_plural = _('free practice sessions')
|
||||
|
||||
def __str__(self):
|
||||
label = self.name or _('Free practice')
|
||||
dist = f' — {self.distance_m}m' if self.distance_m else ''
|
||||
return f"{label}{dist} ({self.date})"
|
||||
|
||||
|
||||
# ── Speed Shooting session ────────────────────────────────────────────────────
|
||||
|
||||
class SpeedShootingSession(AbstractSession):
|
||||
"""A speed shooting session (IPSC, IDPA, Steel Challenge, …). Minimal placeholder."""
|
||||
format = models.CharField(_('format'), max_length=100, blank=True)
|
||||
rounds_fired = models.PositiveSmallIntegerField(_('rounds fired'), null=True, blank=True)
|
||||
|
||||
class Meta(AbstractSession.Meta):
|
||||
verbose_name = _('speed shooting session')
|
||||
verbose_name_plural = _('speed shooting sessions')
|
||||
|
||||
def __str__(self):
|
||||
label = self.name or self.format or _('Speed shooting')
|
||||
return f"{label} ({self.date})"
|
||||
301
apps/sessions/serializers.py
Normal file
301
apps/sessions/serializers.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from django.db.models import Q
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.common.serializer_helpers import ammo_detail, batch_detail
|
||||
from apps.gears.models import Ammo, GearStatus, ReloadedAmmoBatch, Rig
|
||||
from apps.tools.models import ChronographAnalysis, ShotGroup
|
||||
|
||||
from .models import (
|
||||
CorrectionUnit,
|
||||
FreePracticeSession,
|
||||
PRSSession,
|
||||
PRSStage,
|
||||
SpeedShootingSession,
|
||||
)
|
||||
|
||||
|
||||
# ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _rig_detail(rig):
|
||||
if rig is None:
|
||||
return None
|
||||
return {'id': rig.id, 'name': rig.name}
|
||||
|
||||
|
||||
# ── Abstract write mixin ──────────────────────────────────────────────────────
|
||||
|
||||
def _analysis_detail(analysis):
|
||||
if analysis is None:
|
||||
return None
|
||||
return {'id': analysis.id, 'name': analysis.name, 'date': str(analysis.date) if analysis.date else None}
|
||||
|
||||
|
||||
class AbstractSessionWriteMixin:
|
||||
"""
|
||||
Shared __init__ for all session write serializers:
|
||||
narrows FK querysets to the current user.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
self.fields['rig'].queryset = Rig.objects.filter(user=request.user)
|
||||
self.fields['reloaded_batch'].queryset = ReloadedAmmoBatch.objects.filter(
|
||||
recipe__user=request.user
|
||||
)
|
||||
self.fields['analysis'].queryset = ChronographAnalysis.objects.filter(
|
||||
Q(user=request.user) | Q(user__isnull=True)
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
user = self.context['request'].user
|
||||
instance = self.Meta.model(user=user, **attrs)
|
||||
instance.clean()
|
||||
return attrs
|
||||
|
||||
|
||||
# ── PRS session ───────────────────────────────────────────────────────────────
|
||||
|
||||
class PRSStageSerializer(serializers.ModelSerializer):
|
||||
shot_group = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ShotGroup.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
self.fields['shot_group'].queryset = ShotGroup.objects.filter(
|
||||
analysis__user=request.user
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PRSStage
|
||||
fields = [
|
||||
'id', 'order', 'position',
|
||||
'distance_m', 'target_width_cm', 'target_height_cm',
|
||||
'max_time_s', 'shots_count', 'notes_prep',
|
||||
'computed_elevation', 'computed_windage', 'correction_unit',
|
||||
'actual_elevation', 'actual_windage',
|
||||
'hits', 'score', 'time_taken_s',
|
||||
'shot_group', 'notes_post',
|
||||
]
|
||||
read_only_fields = ['computed_elevation', 'computed_windage', 'correction_unit']
|
||||
|
||||
|
||||
class PRSSessionListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PRSSession
|
||||
fields = [
|
||||
'id', 'name', 'competition_name', 'category',
|
||||
'date', 'location', 'rig', 'is_public', 'created_at',
|
||||
]
|
||||
|
||||
|
||||
class PRSSessionWriteSerializer(AbstractSessionWriteMixin, serializers.ModelSerializer):
|
||||
rig = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Rig.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
ammo = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Ammo.objects.filter(status=GearStatus.VERIFIED),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
reloaded_batch = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ReloadedAmmoBatch.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
analysis = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChronographAnalysis.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PRSSession
|
||||
fields = [
|
||||
'id', 'name', 'competition_name', 'category',
|
||||
'date', 'location',
|
||||
'rig', 'ammo', 'reloaded_batch', 'analysis',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public',
|
||||
]
|
||||
|
||||
|
||||
class PRSSessionDetailSerializer(serializers.ModelSerializer):
|
||||
rig_detail = serializers.SerializerMethodField()
|
||||
ammo_detail = serializers.SerializerMethodField()
|
||||
reloaded_batch_detail = serializers.SerializerMethodField()
|
||||
analysis_detail = serializers.SerializerMethodField()
|
||||
stages = PRSStageSerializer(many=True, read_only=True)
|
||||
|
||||
def get_rig_detail(self, obj):
|
||||
return _rig_detail(obj.rig)
|
||||
|
||||
def get_ammo_detail(self, obj):
|
||||
return ammo_detail(obj.ammo)
|
||||
|
||||
def get_reloaded_batch_detail(self, obj):
|
||||
return batch_detail(obj.reloaded_batch)
|
||||
|
||||
def get_analysis_detail(self, obj):
|
||||
return _analysis_detail(obj.analysis)
|
||||
|
||||
class Meta:
|
||||
model = PRSSession
|
||||
fields = [
|
||||
'id', 'name', 'competition_name', 'category',
|
||||
'date', 'location',
|
||||
'rig', 'rig_detail',
|
||||
'ammo', 'ammo_detail',
|
||||
'reloaded_batch', 'reloaded_batch_detail',
|
||||
'analysis', 'analysis_detail',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public', 'stages',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
|
||||
|
||||
# ── Free Practice session ─────────────────────────────────────────────────────
|
||||
|
||||
class FreePracticeSessionListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FreePracticeSession
|
||||
fields = ['id', 'name', 'date', 'location', 'distance_m', 'rounds_fired', 'rig', 'is_public', 'created_at']
|
||||
|
||||
|
||||
class FreePracticeSessionWriteSerializer(AbstractSessionWriteMixin, serializers.ModelSerializer):
|
||||
rig = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Rig.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
ammo = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Ammo.objects.filter(status=GearStatus.VERIFIED),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
reloaded_batch = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ReloadedAmmoBatch.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
analysis = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChronographAnalysis.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FreePracticeSession
|
||||
fields = [
|
||||
'id', 'name', 'date', 'location',
|
||||
'rig', 'ammo', 'reloaded_batch', 'analysis',
|
||||
'distance_m', 'target_description', 'rounds_fired',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public',
|
||||
]
|
||||
|
||||
|
||||
class FreePracticeSessionDetailSerializer(serializers.ModelSerializer):
|
||||
rig_detail = serializers.SerializerMethodField()
|
||||
ammo_detail = serializers.SerializerMethodField()
|
||||
reloaded_batch_detail = serializers.SerializerMethodField()
|
||||
analysis_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_rig_detail(self, obj):
|
||||
return _rig_detail(obj.rig)
|
||||
|
||||
def get_ammo_detail(self, obj):
|
||||
return ammo_detail(obj.ammo)
|
||||
|
||||
def get_reloaded_batch_detail(self, obj):
|
||||
return batch_detail(obj.reloaded_batch)
|
||||
|
||||
def get_analysis_detail(self, obj):
|
||||
return _analysis_detail(obj.analysis)
|
||||
|
||||
class Meta:
|
||||
model = FreePracticeSession
|
||||
fields = [
|
||||
'id', 'name', 'date', 'location',
|
||||
'rig', 'rig_detail',
|
||||
'ammo', 'ammo_detail',
|
||||
'reloaded_batch', 'reloaded_batch_detail',
|
||||
'analysis', 'analysis_detail',
|
||||
'distance_m', 'target_description', 'rounds_fired',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public', 'created_at', 'updated_at',
|
||||
]
|
||||
|
||||
|
||||
# ── Speed Shooting session ────────────────────────────────────────────────────
|
||||
|
||||
class SpeedShootingSessionListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SpeedShootingSession
|
||||
fields = ['id', 'name', 'format', 'date', 'location', 'rounds_fired', 'rig', 'is_public', 'created_at']
|
||||
|
||||
|
||||
class SpeedShootingSessionWriteSerializer(AbstractSessionWriteMixin, serializers.ModelSerializer):
|
||||
rig = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Rig.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
ammo = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Ammo.objects.filter(status=GearStatus.VERIFIED),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
reloaded_batch = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ReloadedAmmoBatch.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
analysis = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChronographAnalysis.objects.none(),
|
||||
required=False, allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SpeedShootingSession
|
||||
fields = [
|
||||
'id', 'name', 'format', 'date', 'location',
|
||||
'rig', 'ammo', 'reloaded_batch', 'analysis',
|
||||
'rounds_fired',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public',
|
||||
]
|
||||
|
||||
|
||||
class SpeedShootingSessionDetailSerializer(serializers.ModelSerializer):
|
||||
rig_detail = serializers.SerializerMethodField()
|
||||
ammo_detail = serializers.SerializerMethodField()
|
||||
reloaded_batch_detail = serializers.SerializerMethodField()
|
||||
analysis_detail = serializers.SerializerMethodField()
|
||||
|
||||
def get_rig_detail(self, obj):
|
||||
return _rig_detail(obj.rig)
|
||||
|
||||
def get_ammo_detail(self, obj):
|
||||
return ammo_detail(obj.ammo)
|
||||
|
||||
def get_reloaded_batch_detail(self, obj):
|
||||
return batch_detail(obj.reloaded_batch)
|
||||
|
||||
def get_analysis_detail(self, obj):
|
||||
return _analysis_detail(obj.analysis)
|
||||
|
||||
class Meta:
|
||||
model = SpeedShootingSession
|
||||
fields = [
|
||||
'id', 'name', 'format', 'date', 'location',
|
||||
'rig', 'rig_detail',
|
||||
'ammo', 'ammo_detail',
|
||||
'reloaded_batch', 'reloaded_batch_detail',
|
||||
'analysis', 'analysis_detail',
|
||||
'rounds_fired',
|
||||
'temperature_c', 'wind_speed_ms', 'wind_direction_deg',
|
||||
'humidity_pct', 'pressure_hpa', 'weather_notes',
|
||||
'notes', 'is_public', 'created_at', 'updated_at',
|
||||
]
|
||||
13
apps/sessions/urls.py
Normal file
13
apps/sessions/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import FreePracticeSessionViewSet, PRSSessionViewSet, SpeedShootingSessionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'sessions/prs', PRSSessionViewSet, basename='prs-session')
|
||||
router.register(r'sessions/free-practice', FreePracticeSessionViewSet, basename='free-practice-session')
|
||||
router.register(r'sessions/speed-shooting', SpeedShootingSessionViewSet, basename='speed-shooting-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
143
apps/sessions/views.py
Normal file
143
apps/sessions/views.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .ballistics import compute_corrections
|
||||
from .models import FreePracticeSession, PRSSession, PRSStage, SpeedShootingSession
|
||||
from .serializers import (
|
||||
FreePracticeSessionDetailSerializer,
|
||||
FreePracticeSessionListSerializer,
|
||||
FreePracticeSessionWriteSerializer,
|
||||
PRSSessionDetailSerializer,
|
||||
PRSSessionListSerializer,
|
||||
PRSSessionWriteSerializer,
|
||||
PRSStageSerializer,
|
||||
SpeedShootingSessionDetailSerializer,
|
||||
SpeedShootingSessionListSerializer,
|
||||
SpeedShootingSessionWriteSerializer,
|
||||
)
|
||||
|
||||
|
||||
# ── PRS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PRSSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['date', 'is_public']
|
||||
search_fields = ['name', 'location', 'notes', 'competition_name']
|
||||
ordering_fields = ['date', 'created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
qs = (
|
||||
PRSSession.objects
|
||||
.filter(user=self.request.user)
|
||||
.select_related('rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis')
|
||||
)
|
||||
if self.action != 'list':
|
||||
qs = qs.prefetch_related('stages')
|
||||
return qs
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return PRSSessionListSerializer
|
||||
if self.action in ('create', 'update', 'partial_update'):
|
||||
return PRSSessionWriteSerializer
|
||||
return PRSSessionDetailSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
# ── Nested stage actions ──────────────────────────────────────────────────
|
||||
|
||||
@action(detail=True, methods=['get', 'post'], url_path='stages')
|
||||
def stages(self, request, pk=None):
|
||||
session = self.get_object()
|
||||
if request.method == 'GET':
|
||||
serializer = PRSStageSerializer(
|
||||
session.stages.all(), many=True, context={'request': request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
serializer = PRSStageSerializer(
|
||||
data=request.data,
|
||||
context={'request': request, 'session': session},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save(session=session)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get', 'patch', 'delete'],
|
||||
url_path=r'stages/(?P<stage_pk>[^/.]+)')
|
||||
def stage_detail(self, request, pk=None, stage_pk=None):
|
||||
session = self.get_object()
|
||||
stage = get_object_or_404(PRSStage, pk=stage_pk, session=session)
|
||||
if request.method == 'GET':
|
||||
return Response(PRSStageSerializer(stage, context={'request': request}).data)
|
||||
if request.method == 'PATCH':
|
||||
serializer = PRSStageSerializer(
|
||||
stage, data=request.data, partial=True, context={'request': request}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
stage.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=['post'],
|
||||
url_path=r'stages/(?P<stage_pk>[^/.]+)/compute-corrections')
|
||||
def compute_corrections_action(self, request, pk=None, stage_pk=None):
|
||||
session = self.get_object()
|
||||
stage = get_object_or_404(PRSStage, pk=stage_pk, session=session)
|
||||
return Response(compute_corrections(session, stage))
|
||||
|
||||
|
||||
# ── Free Practice ─────────────────────────────────────────────────────────────
|
||||
|
||||
class FreePracticeSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['date', 'is_public']
|
||||
search_fields = ['name', 'location', 'notes']
|
||||
ordering_fields = ['date', 'created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
FreePracticeSession.objects
|
||||
.filter(user=self.request.user)
|
||||
.select_related('rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis')
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return FreePracticeSessionListSerializer
|
||||
if self.action in ('create', 'update', 'partial_update'):
|
||||
return FreePracticeSessionWriteSerializer
|
||||
return FreePracticeSessionDetailSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
|
||||
# ── Speed Shooting ────────────────────────────────────────────────────────────
|
||||
|
||||
class SpeedShootingSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['date', 'is_public']
|
||||
search_fields = ['name', 'location', 'notes', 'format']
|
||||
ordering_fields = ['date', 'created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
SpeedShootingSession.objects
|
||||
.filter(user=self.request.user)
|
||||
.select_related('rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis')
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return SpeedShootingSessionListSerializer
|
||||
if self.action in ('create', 'update', 'partial_update'):
|
||||
return SpeedShootingSessionWriteSerializer
|
||||
return SpeedShootingSessionDetailSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
Reference in New Issue
Block a user