Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 50 additions & 15 deletions api_v2/management/commands/import.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json
import glob

from django.apps import apps
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import IntegrityError, connection
Expand Down Expand Up @@ -53,28 +54,38 @@ def handle(self, *args, **options) -> None:

# Check constraints after all data is loaded
self._check_constraints()
self.stdout.write('All foreign key constraints validated successfully.')

self.stdout.write(self.style.SUCCESS('All foreign key constraints validated successfully.'))

# Validate choice fields on all models
self._validate_choices()
self.stdout.write(self.style.SUCCESS('All choice field constraints validated successfully.'))

except IntegrityError as e:
# Re-enable foreign key checks even if there was an error
self._enable_foreign_key_checks()

self.stdout.write(self.style.ERROR(
f'Data import completed but foreign key constraint validation failed.'
))
self.stdout.write(self.style.ERROR(f'Error details: {e}'))

# Extract and format the violation details for a clear worklist
if "Foreign key constraint violations found:" in str(e):
violations = str(e).split("Foreign key constraint violations found:\n")[1]

error_str = str(e)
if "Foreign key constraint violations found:" in error_str:
violations = error_str.split("Foreign key constraint violations found:\n")[1]
self.stdout.write(self.style.ERROR('\nFOREIGN KEY CONSTRAINT VIOLATIONS - WORKLIST:'))
for violation in violations.split('\n'):
if violation.strip():
self.stdout.write(self.style.ERROR(f' • {violation}'))

self.stdout.write(self.style.ERROR(
'\nData import FAILED due to foreign key constraint violations.'
))
self.stdout.write(self.style.ERROR(
'\nData import FAILED due to foreign key constraint violations.'
))
elif "Choice field constraint violations found:" in error_str:
violations = error_str.split("Choice field constraint violations found:\n")[1]
self.stdout.write(self.style.ERROR('\nCHOICE FIELD CONSTRAINT VIOLATIONS - WORKLIST:'))
for violation in violations.split('\n'):
if violation.strip():
self.stdout.write(self.style.ERROR(f' • {violation}'))
self.stdout.write(self.style.ERROR(
'\nData import FAILED due to choice field constraint violations.'
))
else:
self.stdout.write(self.style.ERROR(f'Data import FAILED: {e}'))

self.stdout.write(self.style.WARNING(
'Fix the above violations and re-run the import.'
))
Expand Down Expand Up @@ -116,6 +127,30 @@ def _check_constraints(self):
except Exception as e:
raise IntegrityError(f"Foreign key constraint validation failed: {e}")

def _validate_choices(self):
"""Check that all choice-constrained fields contain only valid values."""
violations = []
for model in apps.get_app_config('api_v2').get_models():
choice_fields = [
f for f in model._meta.get_fields()
if hasattr(f, 'choices') and f.choices
]
if not choice_fields:
continue
valid_values = {f.name: {k for k, _ in f.choices} for f in choice_fields}
for obj in model.objects.all():
for field in choice_fields:
value = getattr(obj, field.name)
if value is not None and value not in valid_values[field.name]:
violations.append(
f"{model.__name__} pk={obj.pk!r}: "
f"{field.name}={value!r} not in choices"
)
if violations:
raise IntegrityError(
"Choice field constraint violations found:\n" + "\n".join(violations)
)

def _load_files_individually(self, fixture_filepaths):
"""Load fixture files one by one to identify which one causes the foreign key error."""
loaded_count = 0
Expand Down
18 changes: 18 additions & 0 deletions api_v2/migrations/0075_alter_spell_target_type_nullable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-19 22:21

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api_v2', '0074_alter_creature_challenge_rating'),
]

operations = [
migrations.AlterField(
model_name='spell',
name='target_type',
field=models.TextField(blank=True, choices=[('creature', 'Creature'), ('object', 'Object'), ('point', 'Point'), ('area', 'Area')], help_text='Spell target type key.', null=True),
),
]
2 changes: 2 additions & 0 deletions api_v2/models/spell.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class Spell(HasName, HasDescription, FromDocument):

# Casting target requirements of the spell instance SHOULD BE A LIST
target_type = models.TextField(
null=True,
blank=True,
choices = SPELL_TARGET_TYPE_CHOICES,
help_text='Spell target type key.')

Expand Down
72 changes: 72 additions & 0 deletions api_v2/tests/test_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,42 @@
API_BASE = f"http://localhost:8000"


VALID_CASTING_TIME = {
'reaction', 'bonus-action', 'action', 'turn', 'round',
'1minute', '5minutes', '10minutes',
'1hour', '4hours', '7hours', '8hours', '9hours', '12hours', '24hours',
'1week',
}
VALID_TARGET_TYPE = {'creature', 'object', 'point', 'area'}
VALID_SHAPE_TYPE = {'cone', 'cube', 'cylinder', 'line', 'sphere'}
VALID_DURATION = {
'instantaneous', 'instantaneous or special',
'1 turn', '1 round', 'concentration + 1 round',
'2 rounds', '3 rounds', '4 rounds', '1d4+2 rounds', '5 rounds', '6 rounds', '10 rounds',
'up to 1 minute', '1 minute', '1 minute, or until expended', '1 minute, until expended',
'5 minutes', '10 minutes', '1 minute or 1 hour',
'up to 1 hour', '1 hour', '1 hour or until triggered',
'2 hours', '3 hours', '1d10 hours', '6 hours', '2-12 hours', 'up to 8 hours', '8 hours',
'1 hour/caster level', '10 hours', '12 hours',
'24 hours or until the target attempts a third death saving throw', '24 hours',
'1 day', '3 days', '5 days', '7 days', '10 days', '13 days', '30 days',
'1 year', 'special',
'until dispelled or destroyed', 'until destroyed', 'until dispelled',
'until cured or dispelled', 'until dispelled or triggered',
'permanent until discharged', 'permanent; one generation', 'permanent',
}


def _fetch_all_spells():
spells = []
url = f"{API_BASE}/v2/spells/?limit=500"
while url:
data = requests.get(url, headers={'Accept': 'application/json'}).json()
spells.extend(data['results'])
url = data.get('next')
return spells


class TestSpellCastingOptions:
"""Tests to validate spell casting options data integrity."""

Expand Down Expand Up @@ -42,6 +78,42 @@ def test_no_duplicate_casting_option_types(self):
assert not spells_with_duplicates, \
f"Spells with duplicate casting option types: {spells_with_duplicates}"

def test_all_spell_casting_times_are_valid(self):
"""All spells must have a casting_time value that matches the enumerated choices."""
violations = [
f"{s['key']}: casting_time={s['casting_time']!r}"
for s in _fetch_all_spells()
if s.get('casting_time') not in VALID_CASTING_TIME
]
assert not violations, f"Spells with invalid casting_time: {violations}"

def test_all_spell_target_types_are_valid(self):
"""All spells with a target_type must use an enumerated choice value."""
violations = [
f"{s['key']}: target_type={s['target_type']!r}"
for s in _fetch_all_spells()
if s.get('target_type') is not None and s['target_type'] not in VALID_TARGET_TYPE
]
assert not violations, f"Spells with invalid target_type: {violations}"

def test_all_spell_shape_types_are_valid(self):
"""All spells with a shape_type must use an enumerated choice value."""
violations = [
f"{s['key']}: shape_type={s['shape_type']!r}"
for s in _fetch_all_spells()
if s.get('shape_type') is not None and s['shape_type'] not in VALID_SHAPE_TYPE
]
assert not violations, f"Spells with invalid shape_type: {violations}"

def test_all_spell_durations_are_valid(self):
"""All spells must have a duration value that matches the enumerated choices."""
violations = [
f"{s['key']}: duration={s['duration']!r}"
for s in _fetch_all_spells()
if s.get('duration') not in VALID_DURATION
]
assert not violations, f"Spells with invalid duration: {violations}"


class TestObjects:

Expand Down
Loading