Skip to content

Commit 4e7429a

Browse files
authored
Ingest 2024 Spells, Monsters and Items (open5e#748)
* add markdown file source * split markdown by object type * Add spells, plus class stubs to satisfy pk links * Add monsters draft * add weapons and armor, expose property type value * fix weapon property filtering issue * add mundane items and itemsets * split items into logical sections for easier parsing * update importer process to handle circular dependencies and provide better erros * convert items data into new md and json formats * fix filter issue & item rarities * fix tests * Add animals * add pytest to default test command * fix item set references for 2024 * tidy up gitignore file * add mundane adventuring gear * remove services from items * rename to srd-2024 * fix references to old key * cleanup empty test file * update services with additional values * update tests with document structure changes * typo in feats md * initial draft of spellcasting options * Update tests * really remove artificer I swear this time
1 parent f75aab1 commit 4e7429a

File tree

70 files changed

+156558
-84
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+156558
-84
lines changed

.gitignore

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
1+
# Environment and configuration files
12
local.env
3+
*.local
4+
.python-version
5+
6+
# SSL certificates
27
selfsigned.*
38

9+
# OS-specific files
410
**/.DS_Store
11+
12+
# Python
513
__pycache__
6-
staticfiles/
14+
15+
# Database
716
db\.sqlite3
17+
db.sqlite3-journal
818

19+
# Django static files
920
staticfiles/
1021

22+
# Search index
1123
server/whoosh_index/
1224

13-
*.local
14-
25+
# IDE and editor configurations
1526
.vscode/
16-
17-
# pycharm config
1827
.idea/
1928

20-
# pyenv env file
21-
.python-version
22-
29+
# Test files
2330
api/tests/approved_files/*.recieved.*
31+
32+
# Generated files
2433
openapi-schema.yml
34+

api/management/commands/quicksetup.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from django.core.management import call_command
99
from django.core.management.base import BaseCommand
10+
from django.db import IntegrityError
1011

1112

1213
class Command(BaseCommand):
@@ -38,7 +39,6 @@ def handle(self, *args, **options):
3839
else:
3940
self.stdout.write('Directory is clean')
4041

41-
"""Main logic."""
4242
self.stdout.write('Migrating the database...')
4343
migrate_db()
4444

@@ -47,7 +47,16 @@ def handle(self, *args, **options):
4747

4848
if settings.INCLUDE_V1_DATA:
4949
self.stdout.write('Populating the v1 database...')
50-
import_v1()
50+
try:
51+
import_v1()
52+
except IntegrityError as e:
53+
self.stdout.write(self.style.ERROR(
54+
f'V1 data import failed: Foreign key constraint error: {e}'
55+
))
56+
self.stdout.write(self.style.ERROR(
57+
'QUICKSETUP FAILED - Fix foreign key constraint violations before proceeding.'
58+
))
59+
return # Exit without showing "API setup complete"
5160

5261
if not options['noindex']:
5362
if settings.BUILD_V1_INDEX:
@@ -59,14 +68,23 @@ def handle(self, *args, **options):
5968

6069
if settings.INCLUDE_V2_DATA:
6170
self.stdout.write('Populating the v2 database...')
62-
import_v2()
71+
try:
72+
import_v2()
73+
except IntegrityError as e:
74+
self.stdout.write(self.style.ERROR(
75+
f'V2 data import failed: Foreign key constraint error: {e}'
76+
))
77+
self.stdout.write(self.style.ERROR(
78+
'QUICKSETUP FAILED - Fix foreign key constraint violations before proceeding.'
79+
))
80+
return # Exit without showing "API setup complete"
6381

64-
if not options['noindex']:
65-
if settings.BUILD_V2_INDEX:
66-
self.stdout.write('Building the v2 index with both v1 and v2 data.')
67-
build_v1v2_searchindex()
68-
else:
69-
self.stdout.write('Skipping v2 index build because of --noindex.')
82+
if not options['noindex']:
83+
if settings.BUILD_V2_INDEX:
84+
self.stdout.write('Building the v2 index with both v1 and v2 data.')
85+
build_v1v2_searchindex()
86+
else:
87+
self.stdout.write('Skipping v2 index build because of --noindex.')
7088

7189
self.stdout.write(self.style.SUCCESS('API setup complete.'))
7290

api/management/commands/test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.core.management.base import BaseCommand
2+
import sys
3+
import pytest
4+
5+
class Command(BaseCommand):
6+
help = 'Runs tests using pytest instead of Django\'s default test runner'
7+
8+
def add_arguments(self, parser):
9+
parser.add_argument('args', nargs='*')
10+
11+
def handle(self, *args, **options):
12+
# Convert Django's test command arguments to pytest arguments
13+
pytest_args = list(args)
14+
15+
if not pytest_args:
16+
# If no specific test paths are provided, run all tests
17+
pytest_args = ['api/tests', 'api_v2/tests']
18+
19+
# Add -v for more verbose output by default
20+
if '-v' not in pytest_args:
21+
pytest_args.insert(0, '-v')
22+
23+
# Run pytest with the collected arguments
24+
sys.exit(pytest.main(pytest_args))

api_v2/management/commands/import.py

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
2-
31
import os
42
import json
53
import glob
64

75
from django.core.management import call_command
86
from django.core.management.base import BaseCommand
7+
from django.db import IntegrityError, connection
8+
from django.core import serializers
99

1010
class Command(BaseCommand):
1111
"""Implementation for the `manage.py `dumpbyorg` subcommand."""
@@ -17,6 +17,9 @@ def add_arguments(self, parser):
1717
"--dir",
1818
type=str,
1919
help="Directory to write files to.")
20+
parser.add_argument("--debug",
21+
action="store_true",
22+
help="Load files one by one to identify problematic data.")
2023

2124
def handle(self, *args, **options) -> None:
2225
self.stdout.write('Checking if directory exists.')
@@ -28,5 +31,151 @@ def handle(self, *args, **options) -> None:
2831
exit(0)
2932

3033
fixture_filepaths = glob.glob(options['dir'] + '/**/*.json', recursive=True)
34+
35+
self.stdout.write(f'Found {len(fixture_filepaths)} fixture files to load.')
36+
37+
if options['debug']:
38+
self._load_files_individually(fixture_filepaths)
39+
else:
40+
try:
41+
# Disable foreign key checks during loading
42+
self._disable_foreign_key_checks()
43+
self.stdout.write('Disabled foreign key constraints for loading.')
44+
45+
call_command('loaddata', fixture_filepaths)
46+
47+
# Re-enable foreign key checks and validate
48+
self._enable_foreign_key_checks()
49+
self.stdout.write('Re-enabled foreign key constraints.')
50+
51+
# Check constraints after all data is loaded
52+
self._check_constraints()
53+
self.stdout.write('All foreign key constraints validated successfully.')
54+
55+
except IntegrityError as e:
56+
# Re-enable foreign key checks even if there was an error
57+
self._enable_foreign_key_checks()
58+
59+
self.stdout.write(self.style.ERROR(
60+
f'Data import completed but foreign key constraint validation failed.'
61+
))
62+
self.stdout.write(self.style.ERROR(f'Error details: {e}'))
63+
64+
# Extract and format the violation details for a clear worklist
65+
if "Foreign key constraint violations found:" in str(e):
66+
violations = str(e).split("Foreign key constraint violations found:\n")[1]
67+
self.stdout.write(self.style.ERROR('\nFOREIGN KEY CONSTRAINT VIOLATIONS - WORKLIST:'))
68+
for violation in violations.split('\n'):
69+
if violation.strip():
70+
self.stdout.write(self.style.ERROR(f' • {violation}'))
71+
72+
self.stdout.write(self.style.ERROR(
73+
'\nData import FAILED due to foreign key constraint violations.'
74+
))
75+
self.stdout.write(self.style.WARNING(
76+
'Fix the above violations and re-run the import.'
77+
))
78+
raise
79+
80+
def _disable_foreign_key_checks(self):
81+
"""Disable foreign key constraint checking."""
82+
with connection.cursor() as cursor:
83+
if connection.vendor == 'sqlite':
84+
cursor.execute('PRAGMA foreign_keys = OFF;')
85+
elif connection.vendor == 'mysql':
86+
cursor.execute('SET foreign_key_checks = 0;')
87+
elif connection.vendor == 'postgresql':
88+
cursor.execute('SET session_replication_role = replica;')
89+
90+
def _enable_foreign_key_checks(self):
91+
"""Re-enable foreign key constraint checking."""
92+
with connection.cursor() as cursor:
93+
if connection.vendor == 'sqlite':
94+
cursor.execute('PRAGMA foreign_keys = ON;')
95+
elif connection.vendor == 'mysql':
96+
cursor.execute('SET foreign_key_checks = 1;')
97+
elif connection.vendor == 'postgresql':
98+
cursor.execute('SET session_replication_role = DEFAULT;')
99+
100+
def _check_constraints(self):
101+
"""Check all foreign key constraints after loading."""
102+
try:
103+
with connection.cursor() as cursor:
104+
if connection.vendor == 'sqlite':
105+
cursor.execute('PRAGMA foreign_key_check;')
106+
violations = cursor.fetchall()
107+
if violations:
108+
violation_details = []
109+
for violation in violations:
110+
violation_details.append(f"Table: {violation[0]}, Row: {violation[1]}, Parent: {violation[2]}, FK: {violation[3]}")
111+
raise IntegrityError(f"Foreign key constraint violations found:\n" + "\n".join(violation_details))
112+
pass
113+
except Exception as e:
114+
raise IntegrityError(f"Foreign key constraint validation failed: {e}")
115+
116+
def _load_files_individually(self, fixture_filepaths):
117+
"""Load fixture files one by one to identify which one causes the foreign key error."""
118+
loaded_count = 0
119+
120+
for filepath in sorted(fixture_filepaths):
121+
try:
122+
self.stdout.write(f'Loading: {filepath}')
123+
call_command('loaddata', filepath)
124+
loaded_count += 1
125+
except IntegrityError as e:
126+
self.stdout.write(self.style.ERROR(
127+
f'FOREIGN KEY CONSTRAINT FAILED in file: {filepath}'
128+
))
129+
self.stdout.write(self.style.ERROR(f'Error: {e}'))
130+
131+
# Try to identify the specific problematic object
132+
self._analyze_problematic_file(filepath)
133+
134+
self.stdout.write(f'Successfully loaded {loaded_count} files before failure.')
135+
raise
136+
except Exception as e:
137+
self.stdout.write(self.style.ERROR(
138+
f'Other error in file {filepath}: {e}'
139+
))
140+
raise
31141

32-
call_command('loaddata', fixture_filepaths)
142+
def _analyze_problematic_file(self, filepath):
143+
"""Analyze the problematic file to identify specific objects causing issues."""
144+
try:
145+
with open(filepath, 'r') as f:
146+
data = json.load(f)
147+
148+
self.stdout.write(f'Analyzing {len(data)} objects in {filepath}:')
149+
150+
for i, obj in enumerate(data):
151+
try:
152+
# Try to deserialize just this one object
153+
for deserialized_obj in serializers.deserialize('json', [obj]):
154+
# Try to save it
155+
deserialized_obj.save()
156+
except IntegrityError as e:
157+
self.stdout.write(self.style.ERROR(
158+
f' Object {i+1}: {obj.get("model", "unknown")} '
159+
f'pk="{obj.get("pk", "unknown")}" FAILED'
160+
))
161+
self.stdout.write(self.style.ERROR(f' Error: {e}'))
162+
163+
# Show the object's foreign key fields
164+
fields = obj.get('fields', {})
165+
fk_fields = {}
166+
for field_name, field_value in fields.items():
167+
if field_value and (isinstance(field_value, str) or isinstance(field_value, list)):
168+
# Likely a foreign key reference
169+
fk_fields[field_name] = field_value
170+
171+
if fk_fields:
172+
self.stdout.write(f' Foreign key fields: {fk_fields}')
173+
174+
return # Stop at first problematic object
175+
except Exception as e:
176+
self.stdout.write(self.style.WARNING(
177+
f' Object {i+1}: Other error - {e}'
178+
))
179+
180+
except Exception as e:
181+
self.stdout.write(self.style.ERROR(f'Could not analyze file: {e}'))
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.1 on 2025-06-08 03:25
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('api_v2', '0049_add_document_display_name'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='spellcastingoption',
15+
name='desc',
16+
field=models.TextField(blank=True, help_text='Description of complex casting option effects that cannot be captured in other fields.', null=True),
17+
),
18+
]

api_v2/models/spell.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,8 @@ class SpellCastingOption(models.Model):
189189

190190
shape_size = distance_field()
191191
# Null values mean this value is unchanged from the default casting option.
192+
193+
desc = models.TextField(
194+
null=True,
195+
blank=True,
196+
help_text='Description of complex casting option effects that cannot be captured in other fields.')

api_v2/tests/responses/TestObjects.test_spell_cantrip_example.approved.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
{
55
"concentration": null,
66
"damage_roll": null,
7+
"desc": null,
78
"duration": null,
89
"range": null,
910
"shape_size": null,

0 commit comments

Comments
 (0)