collapse epic migrations 0007–0022 → 0007_finalize_earthman_deck; add reset_staging_db
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

- 16 incremental Earthman tweak migrations folded into one end-state finalize
  migration (rename mechanisms→energies / articulations→operations /
  reversal→reversal_qualifier; +italic_word; suit court reversals; Schizo
  energies+operations; card 49 polarity reversal titles; Castanedan Virtues
  trumps 6–9 + 19–21; trump 8 U+2011 hyphen; trump 9 U+00A0 nbsp; pips → MINOR)
- 22 epic migrations → 7; 748 ITs green
- new mgmt cmd `reset_staging_db` — drops schema (Postgres) / tables (sqlite)
  & re-runs migrate; refuses on prod hosts; needs `--i-mean-it` when DEBUG=False;
  interactive host-name confirmation locally; calls ensure_superuser after

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-01 02:22:43 -04:00
parent 3410f073f0
commit 8b0ad545c9
20 changed files with 310 additions and 773 deletions

View File

View File

@@ -0,0 +1,110 @@
"""Wipe the configured database and re-run all migrations from scratch.
Intended for ephemeral environments (staging) where losing every user, room,
billpost, token, etc. is acceptable. Refuses to run when DEBUG=False unless
the operator explicitly confirms with --i-mean-it, and always prints the
DB host before doing anything destructive.
Typical staging usage from the deploy host:
docker exec -it gamearray python manage.py reset_staging_db --i-mean-it
Locally (sqlite, DEBUG=True) the safety prompt is skipped:
python src/manage.py reset_staging_db
"""
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
from django.db import connection
PROD_HOST_FRAGMENTS = ("earthmanrpg.me",)
class Command(BaseCommand):
help = "Drop every table in the default DB and re-run migrations. Destructive."
def add_arguments(self, parser):
parser.add_argument(
"--i-mean-it",
action="store_true",
help="Required when DEBUG=False. Bypasses the interactive confirmation.",
)
parser.add_argument(
"--no-superuser",
action="store_true",
help="Skip the post-migrate ensure_superuser call.",
)
def handle(self, *args, **opts):
db_settings = settings.DATABASES["default"]
engine = db_settings.get("ENGINE", "")
host = db_settings.get("HOST") or db_settings.get("NAME") or "(unknown)"
# Refuse outright if the host name suggests production
if any(frag in str(host) for frag in PROD_HOST_FRAGMENTS) and not host.startswith("staging"):
if "staging" not in str(host):
raise CommandError(
f"Refusing to reset DB at host={host!r} — looks like production. "
"Edit PROD_HOST_FRAGMENTS in this command if you really mean it."
)
self.stdout.write(self.style.WARNING(
f"\nAbout to wipe DB:\n ENGINE: {engine}\n HOST/NAME: {host}\n"
))
if not settings.DEBUG and not opts["i_mean_it"]:
raise CommandError(
"DEBUG=False — pass --i-mean-it to confirm. "
"(This is the staging-safety check; it does not bypass the prod-host refusal above.)"
)
if settings.DEBUG and not opts["i_mean_it"]:
answer = input("Type the DB host/name to confirm: ").strip()
if answer != str(host):
raise CommandError(f"Got {answer!r}, expected {str(host)!r}. Aborting.")
# Drop schema. Postgres + sqlite both honor `flush --no-input`'s
# truncate-tables-in-place model, but for a *fresh* migration run we
# need the migration history wiped too. For Postgres the cleanest
# route is `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` —
# for sqlite, deleting the file is simpler but Django's connection
# has it open. So: introspect the connection and drop all tables.
self.stdout.write("Dropping all tables…")
self._drop_all_tables()
self.stdout.write("Running migrate from scratch…")
call_command("migrate", verbosity=1, interactive=False)
if not opts["no_superuser"]:
try:
call_command("ensure_superuser", verbosity=1)
except Exception as exc: # pragma: no cover - depends on env vars
self.stdout.write(self.style.WARNING(
f"ensure_superuser skipped/failed: {exc}"
))
self.stdout.write(self.style.SUCCESS("\nDB reset complete."))
def _drop_all_tables(self):
vendor = connection.vendor
with connection.cursor() as cursor:
if vendor == "postgresql":
cursor.execute("DROP SCHEMA public CASCADE;")
cursor.execute("CREATE SCHEMA public;")
cursor.execute("GRANT ALL ON SCHEMA public TO public;")
elif vendor == "sqlite":
cursor.execute("PRAGMA foreign_keys = OFF;")
cursor.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)
tables = [row[0] for row in cursor.fetchall()]
for table in tables:
cursor.execute(f'DROP TABLE IF EXISTS "{table}"')
cursor.execute("PRAGMA foreign_keys = ON;")
else:
raise CommandError(
f"reset_staging_db only knows postgresql + sqlite, got {vendor!r}"
)

View File

@@ -0,0 +1,200 @@
"""Collapse of old migrations 00070022 into a single end-state finalize.
Schema:
- mechanisms → energies (was 0008)
- articulations → operations (was 0008)
- reversal → reversal_qualifier (was 0014)
- +italic_word (was 0018)
Data (operates on the Earthman deck seeded in 0004; idempotent against the
current schema, which is the post-rename state thanks to the operations
above running first):
- Middle court reversal_qualifier per suit (was 0007)
- Schizo energies + operations w/ .card-ref (was 0008 + 0009)
- fa-hand-dots fallback for empty MAJOR icons (was 0010; only card 41
in fresh-seed state)
- Card 49 polarity-split reversal titles (was 0015 + 0016)
- Castanedan Virtues: trumps 6-9 + 19-21 (was 0017)
- italic_word for trumps 19-21 (was 0019)
- Trump 8 rename + non-breaking hyphen (was 0020 + 0021)
- Trump 9 non-breaking space (was 0021)
- Pip cards (number 1-10) MIDDLE → MINOR arcana (was 0022)
Skipped (all no-ops against fresh 0004 seed):
- 0011 (nomad/schizo icons already correct in 0004)
- 0012 (no PENTACLES seeded for Earthman in 0004)
- 0013 (nomad icon already fa-hat-cowboy-side in 0004)
"""
from django.db import migrations, models
# ── Schizo energies + operations ─────────────────────────────────────────────
CR = '<span class="card-ref">{}</span>'
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": f'When encountering territorial Libido, may convert Emanation into {CR.format("1. The Priest")}.'},
{"type": "NUMEN", "effect": f'When encountering despotic Numen, may convert Emanation into {CR.format("1. The Powerful")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering axiomatic Voluptas, may convert Emanation into {CR.format("1. The Normal")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering annihilating Voluptas, may convert Emanation into {CR.format("1. The Surrendered")}.'},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": f'When covering {CR.format("2. The Occultist")} she may choose, by converting her own Reversal into {CR.format("2. Pestilence")}, to convert this Reversal into {CR.format("1. The Pervert")}.'},
{"type": "CROWN", "effect": f'When crowning {CR.format("3. The Despot")} she may choose, by converting her own Reversal into {CR.format("3. War")}, to convert this Reversal into {CR.format("1. The Paranoiac")}.'},
{"type": "BEHIND", "effect": f'When behind {CR.format("4. The Capitalist")} he may choose, by converting his own Reversal into {CR.format("4. Famine")}, to convert this Reversal into {CR.format("1. The Neurotic")}.'},
{"type": "BEFORE", "effect": f'When before {CR.format("5. The Fascist")} he may choose, by converting his own Reversal into {CR.format("5. Death")}, to convert this Reversal into {CR.format("1. The Suicidal")}.'},
]
# ── Middle court suit reversals ──────────────────────────────────────────────
SUIT_REVERSAL_QUALIFIER = {
"BRANDS": "Seething",
"GRAILS": "Gloomy",
"BLADES": "Nervous",
"CROWNS": "Vacant",
}
COURT_NUMBERS = [11, 12, 13, 14]
# ── Castanedan Virtues ───────────────────────────────────────────────────────
IMPLICIT_VIRTUES = [
# (number, levity_qualifier, gravity_qualifier, reversal_title)
(6, "Sublimating", "Sedimentary", "Indulged Folly"),
(7, "Sublimating", "Sedimentary", "Indulgent Doing"),
(8, "Sublimating", "Sedimentary", "Self-Indulgence"),
(9, "Sublimating", "Sedimentary", "Indulging Personal History"),
]
EXPLICIT_VIRTUES = [
# (number, levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word)
(19, "The Hunter's Stalking", "The Hunter's Stalking", "The Sleeper's Stalking", "The Quarry's Stalking", "Stalking"),
(20, "The Dreamer's Dreaming", "The Dreamer's Dreaming", "The Sleeper's Dreaming", "The Dreamed's Dreaming", "Dreaming"),
(21, "The Warrior's Intent", "The Warrior's Intent", "The Sleeper's Intent", "The Predator's Intent", "Intent"),
]
def finalize(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Middle court suit reversals
for suit, qualifier in SUIT_REVERSAL_QUALIFIER.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", suit=suit,
number__in=COURT_NUMBERS,
).update(reversal_qualifier=qualifier)
# Schizo: clear stray reversal_qualifier (0004 seeds 'Territoriality') +
# populate energies/operations
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(
reversal_qualifier="",
energies=SCHIZO_ENERGIES,
operations=SCHIZO_OPERATIONS,
)
# fa-hand-dots fallback for empty MAJOR icons (number ≥ 2)
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number__gte=2, icon="",
).update(icon="fa-hand-dots")
# Card 49 polarity reversal titles
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(
levity_reversal="The Vibrational Mould of Man",
gravity_reversal="The Bestowing Eagle",
)
# Castanedan Virtues — implicit (trumps 6-9): trump 7 name canonicalize +
# qualifiers + reversal titles
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not-Doing")
for number, lvty, grav, rev in IMPLICIT_VIRTUES:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_qualifier=lvty,
gravity_qualifier=grav,
levity_reversal=rev,
gravity_reversal=rev,
)
# Castanedan Virtues — explicit (trumps 19-21): polarity-split titles +
# italic_word for the agency stem
for number, le, ge, lr, gr, word in EXPLICIT_VIRTUES:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_emanation=le,
gravity_emanation=ge,
levity_reversal=lr,
gravity_reversal=gr,
italic_word=word,
)
# Trump 8: "Losing Self-Importance" → "SelfUnimportance" w/ U+2011
# non-breaking hyphen (keeps title on one line above qualifier)
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name="SelfUnimportance", slug="self-unimportance")
# Trump 9: insert U+00A0 between "Personal" and "History" so they wrap as
# a single unit ("Erasing / Personal History, / Sublimating")
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=9,
).update(name="Erasing Personal History")
# Pip cards (number 1-10) → MINOR arcana; courts (11-14) stay MIDDLE
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", number__lte=10,
).update(arcana="MINOR")
def revert(apps, schema_editor):
"""Reverse just enough to restore 0006 schema state. Data reverts to the
raw 0004 seed shape (without any of the post-seed tweaks)."""
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Pip arcana
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", number__lte=10,
).update(arcana="MIDDLE")
# Trump 8 + 9 names back
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name="Losing Self-Importance", slug="losing-self-importance")
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=9,
).update(name="Erasing Personal History")
# Trump 7 name back
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not Doing")
class Migration(migrations.Migration):
dependencies = [
("epic", "0006_add_deck_variant_to_tableseat"),
]
operations = [
migrations.RenameField("TarotCard", "mechanisms", "energies"),
migrations.RenameField("TarotCard", "articulations", "operations"),
migrations.RenameField("TarotCard", "reversal", "reversal_qualifier"),
migrations.AddField(
model_name="tarotcard",
name="italic_word",
field=models.CharField(blank=True, default="", max_length=50),
),
migrations.RunPython(finalize, reverse_code=revert),
]

View File

@@ -1,73 +0,0 @@
"""Populate TarotCard.reversal for Earthman Middle Arcana court cards.
Each suit has a fixed reversal qualifier that replaces the polarity qualifier
(Elevated/Graven) when the card is spun to its reversed face:
Brands → Seething Grails → Gloomy Blades → Nervous Crowns → Vacant
Also clears the incorrectly inherited reversal on The Schizo (card 1), which
mistakenly carried 'Territoriality' from The Occultist (card 2).
"""
from django.db import migrations
SUIT_REVERSAL_QUALIFIER = {
"BRANDS": "Seething",
"GRAILS": "Gloomy",
"BLADES": "Nervous",
"CROWNS": "Vacant",
}
RANK_NAMES = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
def populate_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Middle Arcana court cards
for suit, qualifier in SUIT_REVERSAL_QUALIFIER.items():
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MIDDLE",
suit=suit,
number__in=list(RANK_NAMES.keys()),
).update(reversal=qualifier)
# Clear The Schizo's incorrectly inherited reversal (belongs to The Occultist)
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MAJOR",
number=1,
).update(reversal="")
def clear_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE",
suit__in=list(SUIT_REVERSAL_QUALIFIER.keys()),
number__in=list(RANK_NAMES.keys()),
).update(reversal="")
TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR", number=1).update(
reversal="Territoriality"
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0006_add_deck_variant_to_tableseat"),
]
operations = [
migrations.RunPython(populate_reversals, reverse_code=clear_reversals),
]

View File

@@ -1,57 +0,0 @@
"""Rename mechanisms→energies and articulations→operations on TarotCard;
seed The Schizo (Earthman major arcana card 1) with Energy and Operation entries.
"""
from django.db import migrations
CR = '<span class="card-ref">{}</span>'
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": f'When encountering territorial Libido, may convert Emanation into {CR.format("1. The Priest")}.'},
{"type": "NUMEN", "effect": f'When encountering despotic Numen, may convert Emanation into {CR.format("1. The Powerful")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering axiomatic Voluptas, may convert Emanation into {CR.format("1. The Normal")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering annihilating Voluptas, may convert Emanation into {CR.format("1. The Surrendered")}.'},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": f'When covering {CR.format("2. The Occultist")} she may choose, by converting her own Reversal into {CR.format("2. Pestilence")}, to convert this Reversal into {CR.format("1. The Pervert")}.'},
{"type": "CROWN", "effect": f'When crowning {CR.format("3. The Despot")} she may choose, by converting her own Reversal into {CR.format("3. War")}, to convert this Reversal into {CR.format("1. The Paranoiac")}.'},
{"type": "BEHIND", "effect": f'When behind {CR.format("4. The Capitalist")} he may choose, by converting his own Reversal into {CR.format("4. Famine")}, to convert this Reversal into {CR.format("1. The Neurotic")}.'},
{"type": "BEFORE", "effect": f'When before {CR.format("5. The Fascist")} he may choose, by converting his own Reversal into {CR.format("5. Death")}, to convert this Reversal into {CR.format("1. The Suicidal")}.'},
]
def seed_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=SCHIZO_ENERGIES, operations=SCHIZO_OPERATIONS)
def clear_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=[], operations=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0007_populate_middle_arcana_reversals"),
]
operations = [
migrations.RenameField("TarotCard", "mechanisms", "energies"),
migrations.RenameField("TarotCard", "articulations", "operations"),
migrations.RunPython(seed_schizo, reverse_code=clear_schizo),
]

View File

@@ -1,53 +0,0 @@
"""Re-seed The Schizo's energies and operations with .card-ref HTML spans."""
from django.db import migrations
CR = '<span class="card-ref">{}</span>'
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": f'When encountering territorial Libido, may convert Emanation into {CR.format("1. The Priest")}.'},
{"type": "NUMEN", "effect": f'When encountering despotic Numen, may convert Emanation into {CR.format("1. The Powerful")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering axiomatic Voluptas, may convert Emanation into {CR.format("1. The Normal")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering annihilating Voluptas, may convert Emanation into {CR.format("1. The Surrendered")}.'},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": f'When covering {CR.format("2. The Occultist")} she may choose, by converting her own Reversal into {CR.format("2. Pestilence")}, to convert this Reversal into {CR.format("1. The Pervert")}.'},
{"type": "CROWN", "effect": f'When crowning {CR.format("3. The Despot")} she may choose, by converting her own Reversal into {CR.format("3. War")}, to convert this Reversal into {CR.format("1. The Paranoiac")}.'},
{"type": "BEHIND", "effect": f'When behind {CR.format("4. The Capitalist")} he may choose, by converting his own Reversal into {CR.format("4. Famine")}, to convert this Reversal into {CR.format("1. The Neurotic")}.'},
{"type": "BEFORE", "effect": f'When before {CR.format("5. The Fascist")} he may choose, by converting his own Reversal into {CR.format("5. Death")}, to convert this Reversal into {CR.format("1. The Suicidal")}.'},
]
def seed_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=SCHIZO_ENERGIES, operations=SCHIZO_OPERATIONS)
def clear_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=[], operations=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0008_rename_energies_operations_seed_schizo"),
]
operations = [
migrations.RunPython(seed_schizo, reverse_code=clear_schizo),
]

View File

@@ -1,49 +0,0 @@
"""Assign fa-hand-dots icon to all Earthman Major Arcana cards with number >= 2.
Cards 0 (The Nomad) and 1 (The Schizo) keep their existing icon value so they
can receive distinct icons later. All other Major Arcana groups (Popes, Implicit
Virtues, Elements, Realms, Explicit Virtues, Zodiac, Lunars, Planets, Inner Rings,
polarity-split finals) default to fa-hand-dots until per-group icons are assigned.
"""
from django.db import migrations
def assign_hand_dots(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MAJOR",
number__gte=2,
icon="",
).update(icon="fa-hand-dots")
def clear_hand_dots(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MAJOR",
number__gte=2,
icon="fa-hand-dots",
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0009_schizo_card_ref_spans"),
]
operations = [
migrations.RunPython(assign_hand_dots, reverse_code=clear_hand_dots),
]

View File

@@ -1,43 +0,0 @@
"""Assign individual icons to The Nomad (0) and The Schizo (1).
All other Major Arcana already have fa-hand-dots from migration 0010.
"""
from django.db import migrations
ICONS = {0: 'fa-hat-cowboy-side', 1: 'fa-hat-wizard'}
def assign_icons(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
for number, icon in ICONS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(icon=icon)
def clear_icons(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number__in=list(ICONS.keys())
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0010_major_arcana_hand_dots_icon"),
]
operations = [
migrations.RunPython(assign_icons, reverse_code=clear_icons),
]

View File

@@ -1,27 +0,0 @@
"""Delete 4 stray PENTACLES court cards from the Earthman deck.
These survived the migration collapse; the Earthman deck uses
BRANDS/GRAILS/BLADES/CROWNS only.
"""
from django.db import migrations
def delete_pentacles(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES").delete()
class Migration(migrations.Migration):
dependencies = [
("epic", "0011_nomad_schizo_icons"),
]
operations = [
migrations.RunPython(delete_pentacles, reverse_code=migrations.RunPython.noop),
]

View File

@@ -1,25 +0,0 @@
"""Fix The Nomad icon: fa-hat-cowboy → fa-hat-cowboy-side."""
from django.db import migrations
def fix_nomad_icon(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=0, icon="fa-hat-cowboy"
).update(icon="fa-hat-cowboy-side")
class Migration(migrations.Migration):
dependencies = [
("epic", "0012_delete_stray_pentacles"),
]
operations = [
migrations.RunPython(fix_nomad_icon, reverse_code=migrations.RunPython.noop),
]

View File

@@ -1,22 +0,0 @@
"""Rename TarotCard.reversal → TarotCard.reversal_qualifier.
Symmetric naming with levity_qualifier / gravity_qualifier; disambiguates the
qualifier-text field from the reversal *axis* state and the keywords_reversed
list. Pure column rename — no data movement.
"""
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("epic", "0013_fix_nomad_icon"),
]
operations = [
migrations.RenameField(
model_name="tarotcard",
old_name="reversal",
new_name="reversal_qualifier",
),
]

View File

@@ -1,60 +0,0 @@
"""Populate card 49's polarity-split reversal titles.
The Earthman deck's last two cards (4849) carry distinct titles per polarity
(stored in `levity_emanation` / `gravity_emanation` / `levity_reversal` /
`gravity_reversal`) rather than a shared title + qualifier.
Card 48 had its full set seeded in migration 0004:
levity: Father Sky → reversal: The Storm
gravity: Mother Sea → reversal: The Flood
Card 49 had only emanations seeded; this migration fills the reversals:
levity: The Effulgent Mould of Man → reversal: The Vibrational Mould of Man
gravity: The Devouring Eagle → reversal: The All-Bestowing Eagle
The "qualifier" (Effulgent / Vibrational / Devouring / All-Bestowing) is baked
into the title between "The" and the title-proper rather than rendered as a
separate qualifier slot — the per-polarity title strings are stored verbatim.
"""
from django.db import migrations
def populate_card49_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(
levity_reversal="The Vibrational Mould of Man",
gravity_reversal="The All-Bestowing Eagle",
)
def clear_card49_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(
levity_reversal="",
gravity_reversal="",
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0014_rename_reversal_to_reversal_qualifier"),
]
operations = [
migrations.RunPython(populate_card49_reversals, reverse_code=clear_card49_reversals),
]

View File

@@ -1,37 +0,0 @@
"""Tweak card 49 gravity_reversal: 'All-Bestowing Eagle''Bestowing Eagle'."""
from django.db import migrations
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(gravity_reversal="The Bestowing Eagle")
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(gravity_reversal="The All-Bestowing Eagle")
class Migration(migrations.Migration):
dependencies = [
("epic", "0015_card49_polarity_reversal_titles"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -1,114 +0,0 @@
"""Populate the seven Castanedan Virtues — trumps 69 (Implicit) + 1921 (Explicit).
Implicit Virtues (69): emanation qualifier differs by polarity (Sublimating /
Sedimentary), name is shared. Reversal is a single full string shared across
both polarities (the agency word — Controlled / Not / Losing / Erasing —
flips to Indulged / Indulgent / Self-Indulgence / Indulging). We fill the
standard `levity_qualifier` / `gravity_qualifier` slots so the major-arcana
upright renders "Controlled Folly,\nSublimating" via the existing template
branch; we fill BOTH `levity_reversal` + `gravity_reversal` with the same
string so a FLIP'd reversal still picks up the override (an empty side falls
through to the default major-arcana rendering).
Explicit Virtues (1921): emanation is shared across polarities (e.g. "The
Hunter's Stalking" — no qualifier + stem decomposition), reversal differs by
polarity. All four polarity-split title fields filled.
Also canonicalizes trump 7's name from "Not Doing" to "Not-Doing" per the spec
doc (slug "not-doing" already correct).
"""
from django.db import migrations
IMPLICIT = [
# (number, levity_qualifier, gravity_qualifier, reversal_title)
(6, "Sublimating", "Sedimentary", "Indulged Folly"),
(7, "Sublimating", "Sedimentary", "Indulgent Doing"),
(8, "Sublimating", "Sedimentary", "Self-Indulgence"),
(9, "Sublimating", "Sedimentary", "Indulging Personal History"),
]
EXPLICIT = [
# (number, levity_emanation, gravity_emanation, levity_reversal, gravity_reversal)
(19, "The Hunter's Stalking", "The Hunter's Stalking", "The Sleeper's Stalking", "The Quarry's Stalking"),
(20, "The Dreamer's Dreaming", "The Dreamer's Dreaming", "The Sleeper's Dreaming", "The Dreamed's Dreaming"),
(21, "The Warrior's Intent", "The Warrior's Intent", "The Sleeper's Intent", "The Predator's Intent"),
]
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Trump 7 name canonicalization
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not-Doing")
for number, lvty, grav, rev in IMPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_qualifier=lvty,
gravity_qualifier=grav,
levity_reversal=rev,
gravity_reversal=rev,
)
for number, le, ge, lr, gr in EXPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_emanation=le,
gravity_emanation=ge,
levity_reversal=lr,
gravity_reversal=gr,
)
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not Doing")
for number, _lvty, _grav, _rev in IMPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_qualifier="",
gravity_qualifier="",
levity_reversal="",
gravity_reversal="",
)
for number, _le, _ge, _lr, _gr in EXPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_emanation="",
gravity_emanation="",
levity_reversal="",
gravity_reversal="",
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0016_card49_bestowing_eagle"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2026-05-01 03:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0017_castanedan_virtues'),
]
operations = [
migrations.AddField(
model_name='tarotcard',
name='italic_word',
field=models.CharField(blank=True, default='', max_length=50),
),
]

View File

@@ -1,51 +0,0 @@
"""Set TarotCard.italic_word for trumps 19-21 (Stalking / Dreaming / Intent).
Each of these three Castanedan virtues has its title key-word italicized
across every emanation/reversal slot ("The Hunter's *Stalking*", "The
Sleeper's *Stalking*", etc.). Storing the word in a single field lets the
renderer wrap it in <em> at display time without HTML in the data.
"""
from django.db import migrations
WORDS = {
19: "Stalking",
20: "Dreaming",
21: "Intent",
}
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
for number, word in WORDS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(italic_word=word)
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number__in=list(WORDS),
).update(italic_word="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0018_add_italic_word"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -1,41 +0,0 @@
"""Trump 8 rename: Losing Self-Importance → Self-Unimportance.
The renamed form fits on one fan-card line above the Sublimating/Sedimentary
qualifier without a scaleX squeeze.
"""
from django.db import migrations
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name="Self-Unimportance", slug="self-unimportance")
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name="Losing Self-Importance", slug="losing-self-importance")
class Migration(migrations.Migration):
dependencies = [
("epic", "0019_explicit_virtues_italic_word"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -1,61 +0,0 @@
"""Long-title wrap fixes for trumps 8 and 9.
Trump 8 "Self-Unimportance" → swap the hyphen for U+2011 (non-breaking
hyphen) so it stays glued and the title sits on one line above
Sublimating / Sedimentary.
Trump 9 "Erasing Personal History" → insert U+00A0 (non-breaking space)
between "Personal" and "History" so the browser keeps them together,
forcing "Erasing" alone on line 1 and "Personal History," on line 2.
"""
from django.db import migrations
# Trump 8
OLD_8 = "Self-Unimportance"
NEW_8 = "SelfUnimportance"
# Trump 9
OLD_9 = "Erasing Personal History"
NEW_9 = "Erasing Personal History"
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name=NEW_8)
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=9,
).update(name=NEW_9)
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name=OLD_8)
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=9,
).update(name=OLD_9)
class Migration(migrations.Migration):
dependencies = [
("epic", "0020_self_unimportance"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -1,42 +0,0 @@
"""Reclassify Earthman pip cards (number 1-10) from MIDDLE to MINOR arcana.
The 0004 reseed initially lumped pips + court cards under MIDDLE; pips
should be MINOR arcana, with MIDDLE reserved for the Earthman court
cards (Maid/Jack/Queen/King at numbers 11-14).
"""
from django.db import migrations
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", number__lte=10,
).update(arcana="MINOR")
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", number__lte=10,
).update(arcana="MIDDLE")
class Migration(migrations.Migration):
dependencies = [
("epic", "0021_trump9_nbsp"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]