pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
src/apps/applets/migrations/0005_seed_pronouns_applet.py
Normal file
33
src/apps/applets/migrations/0005_seed_pronouns_applet.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Seed the Pronouns applet on the Game Kit page (3x3, default visible)."""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
SLUG = "pronouns"
|
||||||
|
NAME = "Pronouns"
|
||||||
|
CONTEXT = "game-kit"
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=SLUG,
|
||||||
|
defaults={
|
||||||
|
"name": NAME,
|
||||||
|
"context": CONTEXT,
|
||||||
|
"default_visible": True,
|
||||||
|
"grid_cols": 3,
|
||||||
|
"grid_rows": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def backward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.filter(slug=SLUG).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0004_rename_billboard_applet_slugs"),
|
||||||
|
]
|
||||||
|
operations = [migrations.RunPython(forward, backward)]
|
||||||
@@ -651,3 +651,39 @@ class SkyNatusDataViewTest(TestCase):
|
|||||||
self.client.logout()
|
self.client.logout()
|
||||||
response = self.client.get("/dashboard/sky/data")
|
response = self.client.get("/dashboard/sky/data")
|
||||||
self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False)
|
self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SetPronounsViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="they@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.url = reverse("set_pronouns")
|
||||||
|
|
||||||
|
def test_post_valid_choice_persists_on_user(self):
|
||||||
|
response = self.client.post(self.url, data={"pronouns": "misogyny"})
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.pronouns, "misogyny")
|
||||||
|
|
||||||
|
def test_post_each_valid_choice(self):
|
||||||
|
for key in ("pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"):
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.client.post(self.url, data={"pronouns": key})
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.pronouns, key)
|
||||||
|
|
||||||
|
def test_post_invalid_choice_returns_400_and_does_not_change_user(self):
|
||||||
|
original = self.user.pronouns
|
||||||
|
response = self.client.post(self.url, data={"pronouns": "bogus"})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.pronouns, original)
|
||||||
|
|
||||||
|
def test_get_returns_405(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(self.url, data={"pronouns": "misogyny"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ urlpatterns = [
|
|||||||
path('sky/preview', views.sky_preview, name='sky_preview'),
|
path('sky/preview', views.sky_preview, name='sky_preview'),
|
||||||
path('sky/save', views.sky_save, name='sky_save'),
|
path('sky/save', views.sky_save, name='sky_save'),
|
||||||
path('sky/data', views.sky_natus_data, name='sky_natus_data'),
|
path('sky/data', views.sky_natus_data, name='sky_natus_data'),
|
||||||
|
path('set-pronouns', views.set_pronouns, name='set_pronouns'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -179,6 +179,20 @@ def set_profile(request):
|
|||||||
request.user.save(update_fields=["username"])
|
request.user.save(update_fields=["username"])
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def set_pronouns(request):
|
||||||
|
from django.http import HttpResponseNotAllowed
|
||||||
|
from apps.lyric.models import PRONOUN_TABLE
|
||||||
|
if request.method != "POST":
|
||||||
|
return HttpResponseNotAllowed(["POST"])
|
||||||
|
choice = request.POST.get("pronouns", "")
|
||||||
|
if choice not in PRONOUN_TABLE:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
request.user.pronouns = choice
|
||||||
|
request.user.save(update_fields=["pronouns"])
|
||||||
|
return HttpResponse(status=204)
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def toggle_applets(request):
|
def toggle_applets(request):
|
||||||
checked = request.POST.getlist("applets")
|
checked = request.POST.getlist("applets")
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
# ── Default gender-neutral pronouns (Baltimore original) ──────────────────────
|
from apps.lyric.models import resolve_pronouns
|
||||||
# Later: replace with per-actor lookup when User model gains a pronouns field.
|
|
||||||
PRONOUN_SUBJ = "yo"
|
|
||||||
PRONOUN_OBJ = "yo"
|
def _actor_pronouns(actor):
|
||||||
PRONOUN_POSS = "yos"
|
"""Return (subj, obj, poss) for an event actor; default = pluralism when None."""
|
||||||
|
return resolve_pronouns(getattr(actor, "pronouns", None) if actor else None)
|
||||||
|
|
||||||
|
|
||||||
class GameEvent(models.Model):
|
class GameEvent(models.Model):
|
||||||
@@ -90,7 +91,8 @@ class GameEvent(models.Model):
|
|||||||
ordinal = _ordinals[_chair_order.index(code)]
|
ordinal = _ordinals[_chair_order.index(code)]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ordinal = "?"
|
ordinal = "?"
|
||||||
return f"assumes {ordinal} Chair; yo will start the game as the {role}."
|
subj, _, _ = _actor_pronouns(self.actor)
|
||||||
|
return f"assumes {ordinal} Chair; {subj} will start the game as the {role}."
|
||||||
if self.verb == self.ROLES_REVEALED:
|
if self.verb == self.ROLES_REVEALED:
|
||||||
return "All roles assigned"
|
return "All roles assigned"
|
||||||
if self.verb == self.SIG_READY:
|
if self.verb == self.SIG_READY:
|
||||||
@@ -102,9 +104,16 @@ class GameEvent(models.Model):
|
|||||||
abbrev = f" ({corner_rank}{icon_html})"
|
abbrev = f" ({corner_rank}{icon_html})"
|
||||||
else:
|
else:
|
||||||
abbrev = ""
|
abbrev = ""
|
||||||
return f"embodies as {PRONOUN_POSS} Significator the {card_name}{abbrev}."
|
# Trump cards ("The Schizo", "The Nomad", "The Wanderer") drop
|
||||||
|
# their "The " in this rendering: the prose template already
|
||||||
|
# supplies "the", and a levity/gravity qualifier (e.g. "Engraven"
|
||||||
|
# in "Engraven The Nomad") needs to butt up against the proper name.
|
||||||
|
card_name = card_name.replace("The ", "", 1)
|
||||||
|
_, _, poss = _actor_pronouns(self.actor)
|
||||||
|
return f"embodies as {poss} Significator the {card_name}{abbrev}."
|
||||||
if self.verb == self.SIG_UNREADY:
|
if self.verb == self.SIG_UNREADY:
|
||||||
return f"disembodies {PRONOUN_POSS} Significator."
|
_, _, poss = _actor_pronouns(self.actor)
|
||||||
|
return f"disembodies {poss} Significator."
|
||||||
return self.verb
|
return self.verb
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class GameEventModelTest(TestCase):
|
|||||||
role="PC", role_display="Player")
|
role="PC", role_display="Player")
|
||||||
prose = event.to_prose()
|
prose = event.to_prose()
|
||||||
self.assertIn("Player", prose)
|
self.assertIn("Player", prose)
|
||||||
self.assertIn("yo will start the game", prose)
|
# Default user pronouns = pluralism → "they".
|
||||||
|
self.assertIn("they will start the game", prose)
|
||||||
|
|
||||||
# ── to_prose — SIG_READY ─────────────────────────────────────────────
|
# ── to_prose — SIG_READY ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,7 +66,8 @@ class GameEventModelTest(TestCase):
|
|||||||
card_name="Maid of Brands", corner_rank="M",
|
card_name="Maid of Brands", corner_rank="M",
|
||||||
suit_icon="fa-wand-sparkles")
|
suit_icon="fa-wand-sparkles")
|
||||||
prose = event.to_prose()
|
prose = event.to_prose()
|
||||||
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
# Default user pronouns = pluralism → "their".
|
||||||
|
self.assertIn("embodies as their Significator the Maid of Brands", prose)
|
||||||
self.assertIn("(M", prose)
|
self.assertIn("(M", prose)
|
||||||
self.assertIn("fa-wand-sparkles", prose)
|
self.assertIn("fa-wand-sparkles", prose)
|
||||||
|
|
||||||
@@ -73,15 +75,57 @@ class GameEventModelTest(TestCase):
|
|||||||
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
card_name="The Wanderer", corner_rank="0", suit_icon="")
|
card_name="The Wanderer", corner_rank="0", suit_icon="")
|
||||||
prose = event.to_prose()
|
prose = event.to_prose()
|
||||||
self.assertIn("embodies as yos Significator the The Wanderer (0)", prose)
|
# Trump card: leading "The " is stripped so qualifier (if any) butts up
|
||||||
|
# against the proper name and "the The Wanderer" never reads doubled.
|
||||||
|
self.assertIn("embodies as their Significator the Wanderer (0)", prose)
|
||||||
|
self.assertNotIn("the The Wanderer", prose)
|
||||||
self.assertNotIn("fa-", prose)
|
self.assertNotIn("fa-", prose)
|
||||||
|
|
||||||
|
def test_sig_ready_prose_strips_leading_the_on_qualified_trump(self):
|
||||||
|
# Trump card with a levity/gravity qualifier already pre-pended in
|
||||||
|
# card_name: must read "the Engraven Nomad", not "the Engraven The Nomad".
|
||||||
|
# Default actor pronouns = pluralism → "their".
|
||||||
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
|
card_name="Engraven The Nomad", corner_rank="0", suit_icon="")
|
||||||
|
prose = event.to_prose()
|
||||||
|
self.assertIn("embodies as their Significator the Engraven Nomad (0)", prose)
|
||||||
|
self.assertNotIn("Engraven The Nomad", prose)
|
||||||
|
|
||||||
|
def test_sig_ready_prose_uses_actor_pronouns_at_render_time(self):
|
||||||
|
# Bawlmorese actor → "yos"; default actor → "their"; switching
|
||||||
|
# the actor's pronouns updates ALL their existing prose on next render.
|
||||||
|
self.user.pronouns = "bawlmorese"
|
||||||
|
self.user.save(update_fields=["pronouns"])
|
||||||
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
|
card_name="Maid of Brands", corner_rank="M",
|
||||||
|
suit_icon="fa-wand-sparkles")
|
||||||
|
self.assertIn("embodies as yos Significator the Maid of Brands", event.to_prose())
|
||||||
|
self.user.pronouns = "misogyny"
|
||||||
|
self.user.save(update_fields=["pronouns"])
|
||||||
|
# Re-fetch — the related object cache may hold the stale pronouns.
|
||||||
|
event.refresh_from_db()
|
||||||
|
self.assertIn("embodies as his Significator the Maid of Brands", event.to_prose())
|
||||||
|
|
||||||
|
def test_sig_unready_prose_uses_actor_pronouns(self):
|
||||||
|
self.user.pronouns = "misandry"
|
||||||
|
self.user.save(update_fields=["pronouns"])
|
||||||
|
event = record(self.room, GameEvent.SIG_UNREADY, actor=self.user)
|
||||||
|
self.assertIn("disembodies hers Significator", event.to_prose())
|
||||||
|
|
||||||
|
def test_role_selected_prose_uses_actor_pronouns(self):
|
||||||
|
self.user.pronouns = "misanthropy"
|
||||||
|
self.user.save(update_fields=["pronouns"])
|
||||||
|
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||||
|
role="PC", role_display="Player")
|
||||||
|
self.assertIn("it will start the game", event.to_prose())
|
||||||
|
|
||||||
def test_sig_ready_prose_degrades_without_corner_rank(self):
|
def test_sig_ready_prose_degrades_without_corner_rank(self):
|
||||||
# Old events recorded before this change have no corner_rank key
|
# Old events recorded before this change have no corner_rank key
|
||||||
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
card_name="Maid of Brands")
|
card_name="Maid of Brands")
|
||||||
prose = event.to_prose()
|
prose = event.to_prose()
|
||||||
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
# Default user pronouns = pluralism → "their".
|
||||||
|
self.assertIn("embodies as their Significator the Maid of Brands", prose)
|
||||||
self.assertNotIn("(", prose)
|
self.assertNotIn("(", prose)
|
||||||
|
|
||||||
def test_str_without_actor_shows_system(self):
|
def test_str_without_actor_shows_system(self):
|
||||||
|
|||||||
@@ -420,3 +420,70 @@ var GameKit = (function () {
|
|||||||
_testNavigate: navigate,
|
_testNavigate: navigate,
|
||||||
};
|
};
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
// ── Pronouns applet — guard-portal-confirmed preference flip ─────────────
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function csrfFromCookie() {
|
||||||
|
var name = 'csrftoken';
|
||||||
|
if (!document.cookie) return '';
|
||||||
|
var found = '';
|
||||||
|
document.cookie.split(';').forEach(function (c) {
|
||||||
|
c = c.trim();
|
||||||
|
if (c.indexOf(name + '=') === 0) {
|
||||||
|
found = decodeURIComponent(c.substring(name.length + 1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(card) {
|
||||||
|
var key = card.getAttribute('data-pronoun');
|
||||||
|
var body = new URLSearchParams({ pronouns: key });
|
||||||
|
fetch('/dashboard/set-pronouns', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRFToken': csrfFromCookie(),
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: body.toString(),
|
||||||
|
}).then(function (resp) {
|
||||||
|
if (resp.status === 204) {
|
||||||
|
// Reload so any provenance prose currently on the page renders
|
||||||
|
// with the new pronouns and the .active class moves to the new card.
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindCards() {
|
||||||
|
var cards = document.querySelectorAll('.gk-pronoun-card');
|
||||||
|
if (!cards.length) return;
|
||||||
|
cards.forEach(function (card) {
|
||||||
|
card.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (typeof window.showGuard !== 'function') return;
|
||||||
|
var trio = card.getAttribute('data-trio') || '';
|
||||||
|
var msg = 'Set pronoun preference?'
|
||||||
|
+ '<span class="guard-pronoun-trio">' + escapeHtml(trio) + '</span>';
|
||||||
|
window.showGuard(card, msg, function () {
|
||||||
|
commit(card);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', bindCards);
|
||||||
|
} else {
|
||||||
|
bindCards();
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ class GameKitViewTest(TestCase):
|
|||||||
Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"})
|
Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"})
|
||||||
Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"})
|
Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"})
|
||||||
Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"})
|
Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"})
|
||||||
|
Applet.objects.get_or_create(slug="pronouns", defaults={"name": "Pronouns", "context": "game-kit"})
|
||||||
response = self.client.get("/gameboard/game-kit/")
|
response = self.client.get("/gameboard/game-kit/")
|
||||||
self.parsed = lxml.html.fromstring(response.content)
|
self.parsed = lxml.html.fromstring(response.content)
|
||||||
|
|
||||||
@@ -247,7 +248,27 @@ class GameKitViewTest(TestCase):
|
|||||||
|
|
||||||
def test_all_sections_visible_by_default(self):
|
def test_all_sections_visible_by_default(self):
|
||||||
sections = self.parsed.cssselect("#id_gk_sections_container section")
|
sections = self.parsed.cssselect("#id_gk_sections_container section")
|
||||||
self.assertEqual(len(sections), 4)
|
# Trinkets, Tokens, Card Decks, Dice Sets, Pronouns
|
||||||
|
self.assertEqual(len(sections), 5)
|
||||||
|
|
||||||
|
def test_pronouns_section_renders_five_cards(self):
|
||||||
|
[section] = self.parsed.cssselect("#id_gk_pronouns")
|
||||||
|
cards = section.cssselect(".gk-pronoun-card")
|
||||||
|
self.assertEqual(len(cards), 5)
|
||||||
|
slugs = [c.get("data-pronoun") for c in cards]
|
||||||
|
self.assertEqual(
|
||||||
|
slugs,
|
||||||
|
["pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pronouns_section_marks_current_choice_active(self):
|
||||||
|
# Default user pronouns = "pluralism" — that card should carry .active.
|
||||||
|
[active] = self.parsed.cssselect("#id_gk_pronouns .gk-pronoun-card.active")
|
||||||
|
self.assertEqual(active.get("data-pronoun"), "pluralism")
|
||||||
|
|
||||||
|
def test_game_kit_applet_menu_has_pronouns_checkbox(self):
|
||||||
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='pronouns']")
|
||||||
|
self.assertEqual(inp.get("type"), "checkbox")
|
||||||
|
|
||||||
|
|
||||||
class ToggleGameKitSectionsViewTest(TestCase):
|
class ToggleGameKitSectionsViewTest(TestCase):
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ def unequip_deck(request, deck_id):
|
|||||||
|
|
||||||
|
|
||||||
def _game_kit_context(user):
|
def _game_kit_context(user):
|
||||||
|
from apps.lyric.models import PRONOUN_CHOICES
|
||||||
coin = user.tokens.filter(token_type=Token.COIN).first()
|
coin = user.tokens.filter(token_type=Token.COIN).first()
|
||||||
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
|
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
|
||||||
carte = user.tokens.filter(token_type=Token.CARTE).first()
|
carte = user.tokens.filter(token_type=Token.CARTE).first()
|
||||||
@@ -130,6 +131,10 @@ def _game_kit_context(user):
|
|||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
).order_by("expires_at"))
|
).order_by("expires_at"))
|
||||||
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
|
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
|
||||||
|
pronoun_options = [
|
||||||
|
{"key": k, "label": label, "active": (k == user.pronouns)}
|
||||||
|
for (k, label) in PRONOUN_CHOICES
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
"coin": coin,
|
"coin": coin,
|
||||||
"pass_token": pass_token,
|
"pass_token": pass_token,
|
||||||
@@ -138,6 +143,8 @@ def _game_kit_context(user):
|
|||||||
"tithe_tokens": tithe_tokens,
|
"tithe_tokens": tithe_tokens,
|
||||||
"unlocked_decks": list(user.unlocked_decks.all()),
|
"unlocked_decks": list(user.unlocked_decks.all()),
|
||||||
"applets": applet_context(user, "game-kit"),
|
"applets": applet_context(user, "game-kit"),
|
||||||
|
"pronoun_options": pronoun_options,
|
||||||
|
"current_pronouns": user.pronouns,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
18
src/apps/lyric/migrations/0002_user_pronouns.py
Normal file
18
src/apps/lyric/migrations/0002_user_pronouns.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-05-04 04:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lyric', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='pronouns',
|
||||||
|
field=models.CharField(choices=[('pluralism', 'they/them/their'), ('bawlmorese', 'yo/yo/yos'), ('misogyny', 'he/him/his'), ('misandry', 'she/her/hers'), ('misanthropy', 'it/it/its')], default='pluralism', max_length=16),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -9,6 +9,34 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pronoun preference set ────────────────────────────────────────────────
|
||||||
|
# Drives provenance prose ("embodies as their Significator …") and any other
|
||||||
|
# user-facing referent. Default is pluralism (singular they) so a brand-new
|
||||||
|
# account renders neutrally; bawlmorese (yo/yo/yos) is the original Earthman
|
||||||
|
# default kept available as a Baltimore-flavoured option.
|
||||||
|
|
||||||
|
PRONOUN_CHOICES = [
|
||||||
|
("pluralism", "they/them/their"),
|
||||||
|
("bawlmorese", "yo/yo/yos"),
|
||||||
|
("misogyny", "he/him/his"),
|
||||||
|
("misandry", "she/her/hers"),
|
||||||
|
("misanthropy", "it/it/its"),
|
||||||
|
]
|
||||||
|
PRONOUN_TABLE = {
|
||||||
|
"pluralism": {"subj": "they", "obj": "them", "poss": "their"},
|
||||||
|
"bawlmorese": {"subj": "yo", "obj": "yo", "poss": "yos"},
|
||||||
|
"misogyny": {"subj": "he", "obj": "him", "poss": "his"},
|
||||||
|
"misandry": {"subj": "she", "obj": "her", "poss": "hers"},
|
||||||
|
"misanthropy": {"subj": "it", "obj": "it", "poss": "its"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_pronouns(pronouns_key):
|
||||||
|
"""Return (subj, obj, poss) for a pronouns key, defaulting to pluralism."""
|
||||||
|
row = PRONOUN_TABLE.get(pronouns_key) or PRONOUN_TABLE["pluralism"]
|
||||||
|
return row["subj"], row["obj"], row["poss"]
|
||||||
|
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
def create_user(self, email):
|
def create_user(self, email):
|
||||||
user = self.model(email=email)
|
user = self.model(email=email)
|
||||||
@@ -63,10 +91,26 @@ class User(AbstractBaseUser):
|
|||||||
is_staff = models.BooleanField(default=False)
|
is_staff = models.BooleanField(default=False)
|
||||||
is_superuser = models.BooleanField(default=False)
|
is_superuser = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
pronouns = models.CharField(
|
||||||
|
max_length=16, choices=PRONOUN_CHOICES, default="pluralism",
|
||||||
|
)
|
||||||
|
|
||||||
objects = UserManager()
|
objects = UserManager()
|
||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "email"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pronoun_subj(self):
|
||||||
|
return resolve_pronouns(self.pronouns)[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pronoun_obj(self):
|
||||||
|
return resolve_pronouns(self.pronouns)[1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pronoun_poss(self):
|
||||||
|
return resolve_pronouns(self.pronouns)[2]
|
||||||
|
|
||||||
def ensure_keypair(self):
|
def ensure_keypair(self):
|
||||||
"""Generate and persist an RSA-2048 keypair if not already set."""
|
"""Generate and persist an RSA-2048 keypair if not already set."""
|
||||||
if self.ap_public_key:
|
if self.ap_public_key:
|
||||||
|
|||||||
@@ -79,6 +79,50 @@ class UserPaletteTest(TestCase):
|
|||||||
user = User.objects.create(email="a@b.cde")
|
user = User.objects.create(email="a@b.cde")
|
||||||
self.assertEqual(user.palette, "palette-default")
|
self.assertEqual(user.palette, "palette-default")
|
||||||
|
|
||||||
|
|
||||||
|
class UserPronounsTest(TestCase):
|
||||||
|
def test_pronouns_default_to_pluralism(self):
|
||||||
|
user = User.objects.create(email="they@test.io")
|
||||||
|
self.assertEqual(user.pronouns, "pluralism")
|
||||||
|
|
||||||
|
def test_pronouns_choices_include_all_five_options(self):
|
||||||
|
from apps.lyric.models import PRONOUN_CHOICES
|
||||||
|
keys = [k for (k, _) in PRONOUN_CHOICES]
|
||||||
|
self.assertEqual(
|
||||||
|
keys,
|
||||||
|
["pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pluralism_resolves_to_they_them_their(self):
|
||||||
|
user = User.objects.create(email="plural@test.io")
|
||||||
|
self.assertEqual(user.pronoun_subj, "they")
|
||||||
|
self.assertEqual(user.pronoun_obj, "them")
|
||||||
|
self.assertEqual(user.pronoun_poss, "their")
|
||||||
|
|
||||||
|
def test_bawlmorese_resolves_to_yo_yo_yos(self):
|
||||||
|
user = User.objects.create(email="yo@test.io", pronouns="bawlmorese")
|
||||||
|
self.assertEqual(user.pronoun_subj, "yo")
|
||||||
|
self.assertEqual(user.pronoun_obj, "yo")
|
||||||
|
self.assertEqual(user.pronoun_poss, "yos")
|
||||||
|
|
||||||
|
def test_misogyny_resolves_to_he_him_his(self):
|
||||||
|
user = User.objects.create(email="he@test.io", pronouns="misogyny")
|
||||||
|
self.assertEqual(user.pronoun_subj, "he")
|
||||||
|
self.assertEqual(user.pronoun_obj, "him")
|
||||||
|
self.assertEqual(user.pronoun_poss, "his")
|
||||||
|
|
||||||
|
def test_misandry_resolves_to_she_her_hers(self):
|
||||||
|
user = User.objects.create(email="she@test.io", pronouns="misandry")
|
||||||
|
self.assertEqual(user.pronoun_subj, "she")
|
||||||
|
self.assertEqual(user.pronoun_obj, "her")
|
||||||
|
self.assertEqual(user.pronoun_poss, "hers")
|
||||||
|
|
||||||
|
def test_misanthropy_resolves_to_it_it_its(self):
|
||||||
|
user = User.objects.create(email="it@test.io", pronouns="misanthropy")
|
||||||
|
self.assertEqual(user.pronoun_subj, "it")
|
||||||
|
self.assertEqual(user.pronoun_obj, "it")
|
||||||
|
self.assertEqual(user.pronoun_poss, "its")
|
||||||
|
|
||||||
class WalletCreationTest(TestCase):
|
class WalletCreationTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create(email="capman@test.io")
|
self.user = User.objects.create(email="capman@test.io")
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ body.page-billscroll {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
|
||||||
.scroll-buffer {
|
.scroll-buffer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
|
|
||||||
.gk-deck-card,
|
.gk-deck-card,
|
||||||
.gk-trinket-card,
|
.gk-trinket-card,
|
||||||
.gk-token-card {
|
.gk-token-card,
|
||||||
|
.gk-pronoun-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -208,6 +209,32 @@
|
|||||||
&:hover { border-color: rgba(var(--secUser), 0.8); }
|
&:hover { border-color: rgba(var(--secUser), 0.8); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gk-pronoun-card {
|
||||||
|
// Card shows the ideology slug (pluralism, bawlmorese, …) in italic;
|
||||||
|
// the guard portal previews the actual slash trio above OK|NVM via JS.
|
||||||
|
// Active card is filled with the secondary tint so the user can see at a
|
||||||
|
// glance which preference is currently in effect.
|
||||||
|
.gk-pronoun-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
border-color: rgba(var(--secUser), 1);
|
||||||
|
background: rgba(var(--secUser), 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_guard_portal .guard-pronoun-trio {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
opacity: 0.75;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.gk-placeholder {
|
.gk-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -77,5 +77,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if entry.applet.slug == 'pronouns' and entry.visible %}
|
||||||
|
<section id="id_gk_pronouns" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">
|
||||||
|
<h2>Pronouns</h2>
|
||||||
|
<div class="gk-items">
|
||||||
|
{% for opt in pronoun_options %}
|
||||||
|
<div class="gk-pronoun-card{% if opt.active %} active{% endif %}" data-pronoun="{{ opt.key }}" data-trio="{{ opt.label }}">
|
||||||
|
<span class="gk-pronoun-label">{{ opt.key }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user