Compare commits
109 Commits
e90f10fe47
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d328171b | ||
|
|
5447a26827 | ||
|
|
0cd16861cd | ||
|
|
4b3dc91e7f | ||
|
|
e78ba730e3 | ||
|
|
65689295a7 | ||
|
|
516b917420 | ||
|
|
6fd515bc6d | ||
|
|
1e70ffabd6 | ||
|
|
86a349b64e | ||
|
|
d8377b57bc | ||
|
|
7e39740f9c | ||
|
|
571d5a84ae | ||
|
|
668105aeeb | ||
|
|
de4dcd7979 | ||
|
|
b7d871388e | ||
|
|
7e876557aa | ||
|
|
9678d187b4 | ||
|
|
877e0f544a | ||
|
|
0693a422d2 | ||
|
|
32836704b7 | ||
|
|
01ee8dc1fb | ||
|
|
a85f5b6f44 | ||
|
|
260c1c1325 | ||
|
|
c3594d27ed | ||
|
|
02d2d565a3 | ||
|
|
c4e738ad16 | ||
|
|
2cbc1bf292 | ||
|
|
cb7ca4b5f3 | ||
|
|
92d46b3dce | ||
|
|
f0b9f02c7c | ||
|
|
da97c623c9 | ||
|
|
6799749ede | ||
|
|
b021d8017c | ||
|
|
7bd8e3256a | ||
|
|
1ac380dfc5 | ||
|
|
af8452f22d | ||
|
|
3bf35ad539 | ||
|
|
f5ee83be0a | ||
|
|
d87f26003b | ||
|
|
b563e96f82 | ||
|
|
1e1a0a5ab8 | ||
|
|
6cc11924e3 | ||
|
|
c41cf7ed36 | ||
|
|
68239ac5d4 | ||
|
|
c9a61e5614 | ||
|
|
41217d5438 | ||
|
|
d0c39b51b6 | ||
|
|
fb8563eed2 | ||
|
|
1c799d35ca | ||
|
|
c30b63cd5d | ||
|
|
a39053d3f6 | ||
|
|
6fbeed78d8 | ||
|
|
3ae85b962b | ||
|
|
5cade51d03 | ||
|
|
ca960d1d43 | ||
|
|
894d65fd6b | ||
|
|
6809681e5a | ||
|
|
03feaee9f2 | ||
|
|
3ca986fb45 | ||
|
|
3ad372bc36 | ||
|
|
c84b3ba9f3 | ||
|
|
4ddc0f810c | ||
|
|
c0f4711589 | ||
|
|
c745d2453f | ||
|
|
bf79963fec | ||
|
|
f44a282007 | ||
|
|
7f6c0c2883 | ||
|
|
de9c97a2f8 | ||
|
|
a133a9c1c3 | ||
|
|
652cef09c0 | ||
|
|
955bdc7f67 | ||
|
|
9cdd2cda68 | ||
|
|
c4bbac0938 | ||
|
|
d10ef94161 | ||
|
|
b308115fcf | ||
|
|
1e2041ed9f | ||
|
|
a03d0b0cac | ||
|
|
9d33cda139 | ||
|
|
4554c71aed | ||
|
|
b1c6833956 | ||
|
|
1839a375fe | ||
|
|
efcef15487 | ||
|
|
2ec23ea2c0 | ||
|
|
a9ad422b35 | ||
|
|
5a1acbd9ca | ||
|
|
711b609e0c | ||
|
|
7c6ab39635 | ||
|
|
26cdf0d38b | ||
|
|
15025b4188 | ||
|
|
dd99364b78 | ||
|
|
bdf6a251f4 | ||
|
|
1963ad4c71 | ||
|
|
82813e9fc1 | ||
|
|
750fef890e | ||
|
|
d26c45bf77 | ||
|
|
b9bb73db69 | ||
|
|
436a710478 | ||
|
|
50a12bccab | ||
|
|
5e78e6b832 | ||
|
|
91df482dd8 | ||
|
|
a4ac25605d | ||
|
|
f107522b20 | ||
|
|
0add163f5b | ||
|
|
8a56ebff2c | ||
|
|
92df686d80 | ||
|
|
53cd7afeb4 | ||
|
|
1452de1a76 | ||
|
|
f6093136f1 |
@@ -54,9 +54,18 @@ steps:
|
||||
# Also collectstatic'd here; output sits in the shared workspace so
|
||||
# the downstream FT steps don't have to repeat it.
|
||||
- python manage.py collectstatic --noinput
|
||||
- python manage.py test functional_tests --tag=two-browser
|
||||
- python manage.py test functional_tests --tag=sequential
|
||||
- python manage.py test functional_tests --tag=channels
|
||||
# All three tag-stages run through `_retry_failed.sh` so a single
|
||||
# browsing-context-discarded / NoSuchWindow flake on a multi-browser
|
||||
# channels FT (typically the LAST test in the suite, when Firefox
|
||||
# has accumulated memory pressure from 21 prior browser launches)
|
||||
# costs ~30s on retry instead of failing the whole step. Matches
|
||||
# the retry posture of test-FTs-room + test-FTs-non-room. First-
|
||||
# run-green still exits 0 immediately — no overhead in the happy
|
||||
# path. First-run-crash w. no parseable labels propagates the
|
||||
# original exit (genuine infra problems aren't masked).
|
||||
- bash ../.woodpecker/_retry_failed.sh functional_tests --tag=two-browser
|
||||
- bash ../.woodpecker/_retry_failed.sh functional_tests --tag=sequential
|
||||
- bash ../.woodpecker/_retry_failed.sh functional_tests --tag=channels
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
|
||||
81
infra/coturn-playbook.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
# Provision the dedicated coturn (TURN/STUN) droplet for WebRTC mesh voice —
|
||||
# Phase C of the my-sea invite/voice sprint. Mirrors the PySwiss split: its own
|
||||
# DigitalOcean droplet, NOT the app box. CI needs none of this (signaling tests
|
||||
# use the in-memory channel layer; the TURN endpoint is unit-tested w. a fake
|
||||
# secret) — this runs only when you actually stand voice up on staging/prod.
|
||||
#
|
||||
# Prereqs (manual, one-time):
|
||||
# 1. Create a DO droplet + a reserved/static public IP; point
|
||||
# turn.earthmanrpg.me at it.
|
||||
# 2. Add it to inventory.ini under [coturn] with host_vars:
|
||||
# coturn_secret, coturn_realm, coturn_public_ip[, coturn_private_ip,
|
||||
# coturn_tls_cert, coturn_tls_key]
|
||||
# 3. Put the SAME coturn_secret into the APP droplet's env as
|
||||
# COTURN_SHARED_SECRET (+ COTURN_TURN_HOST=turn.earthmanrpg.me,
|
||||
# COTURN_REALM) so the /api/voice/turn-credentials/ HMAC matches.
|
||||
#
|
||||
# Run: ansible-playbook -i inventory.ini coturn-playbook.yaml
|
||||
#
|
||||
# nginx already proxy-upgrades WebSocket on the APP droplet (nginx.conf.j2), so
|
||||
# ws/voice/ rides the existing proxy — no nginx change here.
|
||||
- hosts: coturn
|
||||
become: true
|
||||
|
||||
tasks:
|
||||
- name: Install coturn
|
||||
ansible.builtin.apt:
|
||||
name: coturn
|
||||
state: latest
|
||||
update_cache: true
|
||||
|
||||
- name: Enable the coturn daemon
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/default/coturn
|
||||
regexp: '^#?TURNSERVER_ENABLED='
|
||||
line: 'TURNSERVER_ENABLED=1'
|
||||
|
||||
- name: Ensure turn log dir exists
|
||||
ansible.builtin.file:
|
||||
path: /var/log/turnserver
|
||||
state: directory
|
||||
owner: turnserver
|
||||
group: turnserver
|
||||
mode: '0755'
|
||||
|
||||
- name: Deploy turnserver.conf
|
||||
ansible.builtin.template:
|
||||
src: coturn.conf.j2
|
||||
dest: /etc/turnserver.conf
|
||||
mode: '0640'
|
||||
notify: Restart coturn
|
||||
|
||||
- name: Open STUN/TURN signaling ports (3478 udp+tcp)
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: '3478'
|
||||
proto: "{{ item }}"
|
||||
loop: [udp, tcp]
|
||||
|
||||
- name: Open TURN-over-TLS port (5349 tcp)
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: '5349'
|
||||
proto: tcp
|
||||
|
||||
- name: Open the relay UDP port range (49152-65535)
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: '49152:65535'
|
||||
proto: udp
|
||||
|
||||
- name: Enable + start coturn
|
||||
ansible.builtin.systemd:
|
||||
name: coturn
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
handlers:
|
||||
- name: Restart coturn
|
||||
ansible.builtin.systemd:
|
||||
name: coturn
|
||||
state: restarted
|
||||
64
infra/coturn.conf.j2
Normal file
@@ -0,0 +1,64 @@
|
||||
# coturn (TURN/STUN) config for the EarthmanRPG WebRTC mesh voice feature —
|
||||
# Phase C of the my-sea invite/voice sprint. Rendered by coturn-playbook.yaml
|
||||
# onto a DEDICATED droplet (PySwiss-style split), NOT the app droplet.
|
||||
#
|
||||
# The app's /api/voice/turn-credentials/ endpoint signs ephemeral credentials
|
||||
# with HMAC-SHA1(<expiry>:<user_id>, secret); `use-auth-secret` +
|
||||
# `static-auth-secret` here must use the SAME secret (COTURN_SHARED_SECRET in
|
||||
# the app env).
|
||||
|
||||
listening-port=3478
|
||||
tls-listening-port=5349
|
||||
|
||||
fingerprint
|
||||
lt-cred-mech
|
||||
use-auth-secret
|
||||
static-auth-secret={{ coturn_secret }}
|
||||
realm={{ coturn_realm }}
|
||||
|
||||
# ── THE #1 FOOTGUN ──────────────────────────────────────────────────────────
|
||||
# Without external-ip, coturn hands out its PRIVATE address as the relay
|
||||
# candidate and every relayed connection silently fails. On a DigitalOcean
|
||||
# droplet with a single public IP set it to that IP; if the droplet also has a
|
||||
# private/anchor IP, use PUBLIC/PRIVATE so coturn maps between them.
|
||||
external-ip={{ coturn_public_ip }}{% if coturn_private_ip is defined and coturn_private_ip %}/{{ coturn_private_ip }}{% endif %}
|
||||
{% if coturn_public_ip6 is defined and coturn_public_ip6 %}
|
||||
# Dual-stack: advertise IPv6 relay candidates too. coturn auto-binds all
|
||||
# interfaces (incl. v6) since no listening-ip is pinned; this maps the public
|
||||
# v6 explicitly. Set coturn_public_ip6 in inventory to enable — leave it unset
|
||||
# for a pure-IPv4 server (the v6 peer-lockdown below is gated on the same var).
|
||||
external-ip={{ coturn_public_ip6 }}
|
||||
{% endif %}
|
||||
|
||||
# Relay port range — open this exact UDP range in the firewall (playbook does).
|
||||
min-port=49152
|
||||
max-port=65535
|
||||
|
||||
# ── TLS (turns: on 5349) — prod hardening ──────────────────────────────────
|
||||
{% if coturn_tls_cert is defined and coturn_tls_cert %}
|
||||
cert={{ coturn_tls_cert }}
|
||||
pkey={{ coturn_tls_key }}
|
||||
{% endif %}
|
||||
no-tlsv1
|
||||
no-tlsv1_1
|
||||
|
||||
# ── Lockdown: relay only, no SSRF via the TURN server ───────────────────────
|
||||
no-multicast-peers
|
||||
no-cli
|
||||
no-software-attribute
|
||||
# Block relaying to private ranges so the box can't be used to probe internals.
|
||||
denied-peer-ip=10.0.0.0-10.255.255.255
|
||||
denied-peer-ip=172.16.0.0-172.31.255.255
|
||||
denied-peer-ip=192.168.0.0-192.168.255.255
|
||||
denied-peer-ip=127.0.0.0-127.255.255.255
|
||||
{% if coturn_public_ip6 is defined and coturn_public_ip6 %}
|
||||
# IPv6 lockdown parity (only emitted when serving v6): loopback, link-local
|
||||
# (fe80::/10), and unique-local (fc00::/7). coturn takes start-end ranges, not
|
||||
# CIDR. Keeps a dual-stack relay from being pointed at internal v6 addresses.
|
||||
denied-peer-ip=::1
|
||||
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||
{% endif %}
|
||||
|
||||
log-file=/var/log/turnserver/turn.log
|
||||
simple-log
|
||||
@@ -10,4 +10,11 @@ STRIPE_SECRET_KEY={{ stripe_secret_key }}
|
||||
CELERY_BROKER_URL=redis://gamearray_redis:6379/0
|
||||
REDIS_URL=redis://gamearray_redis:6379/1
|
||||
PYSWISS_URL=https://charts.earthmanrpg.me
|
||||
# coturn / WebRTC voice — only COTURN_SHARED_SECRET is sensitive (it signs the
|
||||
# TURN HMAC creds + must equal the coturn droplet's static-auth-secret). Host +
|
||||
# realm are public. coturn_secret comes from the vault (share it across the app
|
||||
# + coturn host groups, e.g. group_vars/all/vault.yaml, so both plays match).
|
||||
COTURN_SHARED_SECRET={{ coturn_secret }}
|
||||
COTURN_TURN_HOST=turn.earthmanrpg.me
|
||||
COTURN_REALM=earthmanrpg.me
|
||||
|
||||
|
||||
10
infra/group_vars/all/vault.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
62633637333430623762333637306466646161323861663564373533353565366661616433376465
|
||||
6138653163616138396163363764353464616133303731370a656166623332656234356564373330
|
||||
34656230353138653939313337376365343866623461616466343131313236303439613664616333
|
||||
6665333231353436650a616663653630613465613931353232383437623434383930313862626164
|
||||
39653231326663626562323832666264366331306365333061613535396532303937343065616261
|
||||
62663638386235373566336634616331396434643134303731646435396563343333333034303063
|
||||
66313030396437666461303137613233666366376430356164386561626337643930383433653130
|
||||
39663237303737333834366530303435666366336664363666646632396630626434373535303937
|
||||
3739
|
||||
@@ -8,3 +8,15 @@ dashboard.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.s
|
||||
|
||||
[cicd]
|
||||
gitea.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
|
||||
|
||||
# Dedicated coturn (TURN/STUN) droplet for WebRTC mesh voice — provisioned by
|
||||
# coturn-playbook.yaml. UNCOMMENT + fill once the droplet + static IP exist
|
||||
# (see the playbook header). coturn_secret is NOT set here — it comes from the
|
||||
# shared vault (group_vars/all/vault.yaml) so it matches the app's
|
||||
# COTURN_SHARED_SECRET. (Inventory host_vars OVERRIDE group_vars, so never put
|
||||
# coturn_secret on this line or it would clobber the vault value.)
|
||||
# coturn_private_ip / coturn_tls_* are optional. coturn_public_ip6 (optional):
|
||||
# set the droplet's public IPv6 to serve dual-stack TURN (adds a v6 external-ip
|
||||
# + matching v6 peer-denial lockdown); leave unset for a pure-IPv4 relay.
|
||||
[coturn]
|
||||
turn.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd coturn_realm=earthmanrpg.me coturn_public_ip=167.172.236.157 coturn_public_ip6=2604:a880:800:14:0:3:384:6000
|
||||
|
||||
@@ -5,6 +5,7 @@ omit =
|
||||
*/tests/*
|
||||
*/routing.py
|
||||
*/reset_staging_db.py
|
||||
*/delete_stale_my_sea_draws.py
|
||||
|
||||
[report]
|
||||
show_missing = true
|
||||
55
src/apps/api/tests/integrated/test_turn_credentials.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""ITs for the WebRTC mesh TURN-credentials endpoint — Phase C of
|
||||
[[my-sea-invite-voice-blueprint]]. Verifies the coturn `use-auth-secret`
|
||||
REST scheme (HMAC-SHA1 username/credential) + auth gating.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
@override_settings(
|
||||
COTURN_SHARED_SECRET="testsecret",
|
||||
COTURN_TURN_HOST="turn.test",
|
||||
COTURN_TTL=86400,
|
||||
)
|
||||
class TURNCredentialsTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="turn@test.io", username="turner")
|
||||
self.url = reverse("api_turn_credentials")
|
||||
|
||||
def test_requires_authentication(self):
|
||||
resp = self.client.get(self.url)
|
||||
self.assertIn(resp.status_code, (401, 403))
|
||||
|
||||
def test_returns_ice_servers_and_ttl(self):
|
||||
self.client.force_login(self.user)
|
||||
data = self.client.get(self.url).json()
|
||||
self.assertEqual(data["ttl"], 86400)
|
||||
urls = [s for srv in data["iceServers"] for s in srv["urls"]]
|
||||
self.assertTrue(any(u.startswith("stun:turn.test") for u in urls))
|
||||
self.assertTrue(any("turn:turn.test" in u and "transport=udp" in u for u in urls))
|
||||
self.assertTrue(any("turn:turn.test" in u and "transport=tcp" in u for u in urls))
|
||||
|
||||
def test_username_is_expiry_colon_user_id(self):
|
||||
self.client.force_login(self.user)
|
||||
data = self.client.get(self.url).json()
|
||||
expiry_str, _, uid = data["username"].partition(":")
|
||||
self.assertTrue(expiry_str.isdigit())
|
||||
self.assertEqual(uid, str(self.user.id))
|
||||
|
||||
def test_credential_is_hmac_sha1_of_username(self):
|
||||
self.client.force_login(self.user)
|
||||
data = self.client.get(self.url).json()
|
||||
expected = base64.b64encode(
|
||||
hmac.new(b"testsecret", data["username"].encode(), hashlib.sha1).digest()
|
||||
).decode()
|
||||
self.assertEqual(data["credential"], expected)
|
||||
# The TURN iceServer entry carries the same credential.
|
||||
turn = [s for s in data["iceServers"] if "credential" in s][0]
|
||||
self.assertEqual(turn["credential"], expected)
|
||||
@@ -8,4 +8,6 @@ urlpatterns = [
|
||||
path('posts/<uuid:post_id>/', views.PostDetailAPI.as_view(), name='api_post_detail'),
|
||||
path('posts/<uuid:post_id>/lines/', views.PostLinesAPI.as_view(), name='api_post_lines'),
|
||||
path('users/', views.UserSearchAPI.as_view(), name='api_users'),
|
||||
path('voice/turn-credentials/', views.VoiceTURNCredentialsAPI.as_view(),
|
||||
name='api_turn_credentials'),
|
||||
]
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -44,3 +51,44 @@ class UserSearchAPI(APIView):
|
||||
)
|
||||
serializer = UserSerializer(users, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class VoiceTURNCredentialsAPI(APIView):
|
||||
"""Time-limited TURN/STUN credentials for the WebRTC mesh voice client
|
||||
(Phase C of [[my-sea-invite-voice-blueprint]]). Implements the coturn
|
||||
`use-auth-secret` REST scheme: username = `<expiry>:<user_id>`, credential
|
||||
= base64(HMAC-SHA1(username, COTURN_SHARED_SECRET)) — the coturn droplet
|
||||
validates it with the same shared secret, so no per-user state is stored
|
||||
on either side. Authenticated-only."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
host = settings.COTURN_TURN_HOST
|
||||
ttl = settings.COTURN_TTL
|
||||
expiry = int(time.time()) + ttl
|
||||
username = f"{expiry}:{request.user.id}"
|
||||
digest = hmac.new(
|
||||
settings.COTURN_SHARED_SECRET.encode(),
|
||||
username.encode(),
|
||||
hashlib.sha1,
|
||||
).digest()
|
||||
credential = base64.b64encode(digest).decode()
|
||||
|
||||
ice_servers = []
|
||||
if host:
|
||||
ice_servers.append({"urls": [f"stun:{host}:3478"]})
|
||||
ice_servers.append({
|
||||
"urls": [
|
||||
f"turn:{host}:3478?transport=udp",
|
||||
f"turn:{host}:3478?transport=tcp",
|
||||
],
|
||||
"username": username,
|
||||
"credential": credential,
|
||||
})
|
||||
return Response({
|
||||
"iceServers": ice_servers,
|
||||
"username": username,
|
||||
"credential": credential,
|
||||
"ttl": ttl,
|
||||
})
|
||||
|
||||
99
src/apps/billboard/mail.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""@mailman-authored "Acceptances & rejections" invite log — Phase A of
|
||||
[[my-sea-invite-voice-blueprint]].
|
||||
|
||||
`log_sea_invite(sea_invite)` appends one interactive Line + spawns one Brief
|
||||
on the invitee's single MAIL_ACCEPTANCE Post when an owner invites a bud to
|
||||
their my-sea table. Models on `apps.billboard.tax.log_tax_debit` (the @taxman
|
||||
ledger); the one genuinely new wrinkle is that the Line is *stateful* — its
|
||||
OK/BYE buttons render from the linked `SeaInvite.status` (see post.html, A5),
|
||||
so the single line transforms in place rather than appending accept/decline
|
||||
lines.
|
||||
|
||||
Unlike the tax ledger, the prose carries no timestamp prefix (one invite =
|
||||
one line; the A6 view dedups duplicate PENDING/ACCEPTED invites before
|
||||
calling here, so the `Line.unique_together = (post, text)` invariant isn't
|
||||
stressed by repeat identical prose). `Line.display_text` therefore needs no
|
||||
MAIL_ACCEPTANCE branch.
|
||||
|
||||
The post_save guard in `billboard.models` nukes any Line on a MAIL_ACCEPTANCE
|
||||
Post lacking admin_solicited=True, so this helper sets it True.
|
||||
"""
|
||||
|
||||
from apps.billboard.models import (
|
||||
Brief,
|
||||
Line,
|
||||
MAIL_ACCEPTANCE_POST_TITLE,
|
||||
Post,
|
||||
)
|
||||
from apps.lyric.models import get_or_create_mailman, resolve_pronouns
|
||||
from apps.lyric.templatetags.lyric_extras import at_handle
|
||||
|
||||
|
||||
# Invite prose shown to the invitee. `{handle}` is wrapped in an
|
||||
# `<a class="post-attribution">` whose href routes to the owner's per-bud
|
||||
# landing page — bud landing page sprint 2026-05-27 replaced the in-Line
|
||||
# OK/BYE form-button block w. this navigational anchor. post.html's
|
||||
# `safe`-filter branch is gated on `line.author.username == 'mailman'`
|
||||
# (alongside 'adman'/'taxman') so the anchor renders as HTML.
|
||||
#
|
||||
# `{poss}` = the owner's possessive pronoun ("their"/"his"/"her"/…), so the
|
||||
# table reads as the owner's. Em dash matches the @taxman "Look!—" house style.
|
||||
INVITE_TEMPLATE = (
|
||||
'Listen!—<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>'
|
||||
" invites you to {poss} drawing table. "
|
||||
"This invite will expire 24h from the time it was extended."
|
||||
)
|
||||
|
||||
|
||||
def log_sea_invite(sea_invite):
|
||||
"""Append a Line to the invitee's "Acceptances & rejections" Post (creating
|
||||
the Post on first invite) + spawn a Brief that the invitee's next page-load
|
||||
surfaces as a slide-down banner. Links the new Line back onto the SeaInvite
|
||||
so its OK/BYE buttons render from `sea_invite.status`.
|
||||
|
||||
Returns ``(post, line, brief)``. For an unregistered invitee (``invitee``
|
||||
FK still None) there is no per-user log surface yet — returns
|
||||
``(None, None, None)`` (linking on registration is deferred, mirroring the
|
||||
long-standing RoomInvite-on-registration gap)."""
|
||||
invitee = sea_invite.invitee
|
||||
if invitee is None:
|
||||
return None, None, None
|
||||
|
||||
owner = sea_invite.owner
|
||||
|
||||
post, _ = Post.objects.get_or_create(
|
||||
owner=invitee,
|
||||
kind=Post.KIND_MAIL_ACCEPTANCE,
|
||||
defaults={"title": MAIL_ACCEPTANCE_POST_TITLE},
|
||||
)
|
||||
# Heal a title-less pre-feature Post once on next invite (mirrors the
|
||||
# tax-ledger / Note.grant_if_new title heal).
|
||||
if post.title != MAIL_ACCEPTANCE_POST_TITLE:
|
||||
post.title = MAIL_ACCEPTANCE_POST_TITLE
|
||||
post.save(update_fields=["title"])
|
||||
|
||||
text = INVITE_TEMPLATE.format(
|
||||
owner_id=owner.id,
|
||||
handle=at_handle(owner),
|
||||
poss=resolve_pronouns(owner.pronouns)[2],
|
||||
)
|
||||
|
||||
line = Line.objects.create(
|
||||
post=post,
|
||||
text=text,
|
||||
author=get_or_create_mailman(),
|
||||
admin_solicited=True,
|
||||
)
|
||||
# Link the Line onto the invite so post.html resolves OK/BYE state via the
|
||||
# `line.sea_invite` OneToOne reverse accessor.
|
||||
sea_invite.line = line
|
||||
sea_invite.save(update_fields=["line"])
|
||||
|
||||
brief = Brief.objects.create(
|
||||
owner=invitee,
|
||||
post=post,
|
||||
line=line,
|
||||
kind=Brief.KIND_MAIL_ACCEPTANCE,
|
||||
title=MAIL_ACCEPTANCE_POST_TITLE,
|
||||
)
|
||||
return post, line, brief
|
||||
23
src/apps/billboard/migrations/0008_tax_ledger_kind.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0 on 2026-05-26 19:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0007_brief_room_alter_brief_kind_alter_brief_post'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='brief',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite'), ('tax_ledger', 'Tax ledger')], default='user_post', max_length=32),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites'), ('tax_ledger', 'Debits & credits')], default='user_post', max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0 on 2026-05-27 16:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0008_tax_ledger_kind'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='brief',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite'), ('tax_ledger', 'Tax ledger'), ('mail_acceptance', 'Mail acceptance')], default='user_post', max_length=32),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites'), ('tax_ledger', 'Debits & credits'), ('mail_acceptance', 'Acceptances & rejections')], default='user_post', max_length=32),
|
||||
),
|
||||
]
|
||||
30
src/apps/billboard/migrations/0010_budshipnote.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0 on 2026-05-28 15:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0009_alter_brief_kind_alter_post_kind'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BudshipNote',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('shoptalk', models.CharField(default='', max_length=160)),
|
||||
('edited_at', models.DateTimeField(auto_now=True)),
|
||||
('bud', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_about', to=settings.AUTH_USER_MODEL)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_written', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-edited_at',),
|
||||
'unique_together': {('user', 'bud')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -7,14 +7,32 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
NOTE_UNLOCK_POST_TITLE_HINT = "Notes & recognitions" # see drama.NOTE_UNLOCK_POST_TITLE; copy lives there
|
||||
TAX_LEDGER_POST_TITLE = "Debits & credits"
|
||||
MAIL_ACCEPTANCE_POST_TITLE = "Acceptances & rejections"
|
||||
|
||||
|
||||
class Post(models.Model):
|
||||
KIND_NOTE_UNLOCK = "note_unlock"
|
||||
KIND_USER_POST = "user_post"
|
||||
KIND_SHARE_INVITE = "share_invite"
|
||||
# Per-user @taxman-authored ledger (user-spec 2026-05-26). Each FREE/PAID
|
||||
# DRAW spend at /gameboard/my-sea/ appends one Line via
|
||||
# `apps.billboard.tax.log_tax_debit`. Mirrors the NOTE_UNLOCK Post pattern:
|
||||
# one Post per user, system-authored, readonly textarea in post.html.
|
||||
KIND_TAX_LEDGER = "tax_ledger"
|
||||
# Per-user @mailman-authored "Acceptances & rejections" log (my-sea bud-
|
||||
# invite flow, see [[my-sea-invite-voice-blueprint]]). Each invite appends
|
||||
# one interactive Line (OK/BYE buttons) via `apps.billboard.mail.
|
||||
# log_sea_invite`. Mirrors the TAX_LEDGER Post pattern: one Post per
|
||||
# invitee, system-authored, with admin_solicited Lines.
|
||||
KIND_MAIL_ACCEPTANCE = "mail_acceptance"
|
||||
KIND_CHOICES = [
|
||||
(KIND_NOTE_UNLOCK, "Note unlocks"),
|
||||
(KIND_USER_POST, "User post"),
|
||||
(KIND_SHARE_INVITE, "Share invites"),
|
||||
(KIND_TAX_LEDGER, "Debits & credits"),
|
||||
(KIND_MAIL_ACCEPTANCE, "Acceptances & rejections"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
@@ -77,6 +95,23 @@ class Line(models.Model):
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
@property
|
||||
def display_text(self):
|
||||
"""User-facing line text. For TAX_LEDGER lines, strips the leading
|
||||
`[<ISO timestamp>] ` prefix that the `apps.billboard.tax.log_tax_
|
||||
debit` helper bakes in to satisfy `unique_together = (post, text)`
|
||||
on repeat-slug spends — the Brief carries `created_at` and the
|
||||
Post line carries `created_at` independently, so embedding a third
|
||||
timestamp in the prose is noise.
|
||||
|
||||
For non-tax lines this is identity (returns `text` unchanged) —
|
||||
Note-unlock + user-typed Lines have no prefix to strip."""
|
||||
if self.post.kind == Post.KIND_TAX_LEDGER and self.text.startswith("["):
|
||||
close = self.text.find("] ")
|
||||
if close != -1:
|
||||
return self.text[close + 2:]
|
||||
return self.text
|
||||
|
||||
|
||||
class Brief(models.Model):
|
||||
"""A slide-down notification record. Owner = whose attention; post = where
|
||||
@@ -95,11 +130,21 @@ class Brief(models.Model):
|
||||
KIND_USER_POST = "user_post"
|
||||
KIND_SHARE_INVITE = "share_invite"
|
||||
KIND_GAME_INVITE = "game_invite"
|
||||
# Tax-ledger Briefs (FREE/PAID DRAW spend, user-spec 2026-05-26). FYI
|
||||
# navigates to the user's TAX_LEDGER Post. NVM POSTs to the dismiss-
|
||||
# brief endpoint so the dismissal persists per-cycle (see
|
||||
# `dismiss_url` in `to_banner_dict`).
|
||||
KIND_TAX_LEDGER = "tax_ledger"
|
||||
# Surfaces the invitee's slide-down notification when an owner invites them
|
||||
# to a my-sea table; FYI navigates to their "Acceptances & rejections" Post.
|
||||
KIND_MAIL_ACCEPTANCE = "mail_acceptance"
|
||||
KIND_CHOICES = [
|
||||
(KIND_NOTE_UNLOCK, "Note unlock"),
|
||||
(KIND_USER_POST, "User post"),
|
||||
(KIND_SHARE_INVITE, "Share invite"),
|
||||
(KIND_GAME_INVITE, "Game invite"),
|
||||
(KIND_TAX_LEDGER, "Tax ledger"),
|
||||
(KIND_MAIL_ACCEPTANCE, "Mail acceptance"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
@@ -160,8 +205,14 @@ class Brief(models.Model):
|
||||
carries a square_url pointing at /billboard/my-notes/ so the
|
||||
thumbnail-square inside the banner jumps direct to the user's Note
|
||||
collection. GAME_INVITE kind has no Post — the FYI link navigates
|
||||
to the gatekeeper page for the brief's Room instead."""
|
||||
to the gatekeeper page for the brief's Room instead.
|
||||
|
||||
`dismiss_url` (TAX_LEDGER only — user-spec 2026-05-26): the POST
|
||||
endpoint the banner's NVM btn fires to so the dismissal persists
|
||||
per-cycle (FREE DRAW until next FREE DRAW spend, PAID DRAW until
|
||||
next PAID DRAW commit). Empty for kinds with no persistence."""
|
||||
square_url = ""
|
||||
dismiss_url = ""
|
||||
if self.kind == self.KIND_NOTE_UNLOCK:
|
||||
square_url = reverse("billboard:my_notes")
|
||||
if self.post_id:
|
||||
@@ -174,23 +225,68 @@ class Brief(models.Model):
|
||||
"id": str(self.id),
|
||||
"kind": self.kind,
|
||||
"title": self.title,
|
||||
"line_text": self.line.text if self.line else "",
|
||||
# `display_text` strips the `[<iso timestamp>] ` prefix on
|
||||
# TAX_LEDGER lines (the prefix exists only to satisfy Line's
|
||||
# `unique_together = (post, text)` invariant on repeat-slug
|
||||
# spends — the Brief's own `created_at` slot below covers the
|
||||
# user-facing timestamp). Identity for all other line kinds.
|
||||
"line_text": self.line.display_text if self.line else "",
|
||||
"post_url": post_url,
|
||||
"square_url": square_url,
|
||||
"dismiss_url": dismiss_url,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Listener: nuke unsolicited Lines on NOTE_UNLOCK Posts ─────────────────
|
||||
# ── Listener: nuke unsolicited Lines on system-author Posts ──────────────
|
||||
# Defense-in-depth alongside view_post's POST guard. A Line saved on a
|
||||
# NOTE_UNLOCK Post that lacks admin_solicited=True (e.g. a stray ORM-level
|
||||
# write or an API path that bypasses the view) gets deleted right after
|
||||
# the save. Note.grant_if_new sets admin_solicited=True on its Lines so
|
||||
# legitimate system prose survives.
|
||||
# NOTE_UNLOCK / TAX_LEDGER / MAIL_ACCEPTANCE Post that lacks
|
||||
# admin_solicited=True (e.g. a stray ORM-level write or an API path that
|
||||
# bypasses the view) gets deleted right after the save. `Note.grant_if_new`,
|
||||
# `apps.billboard.tax.log_tax_debit`, and `apps.billboard.mail.log_sea_invite`
|
||||
# all set admin_solicited=True on their Lines so legitimate system prose
|
||||
# survives.
|
||||
|
||||
_SYSTEM_AUTHOR_POST_KINDS = (
|
||||
Post.KIND_NOTE_UNLOCK,
|
||||
Post.KIND_TAX_LEDGER,
|
||||
Post.KIND_MAIL_ACCEPTANCE,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Line)
|
||||
def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
if instance.post.kind == Post.KIND_NOTE_UNLOCK and not instance.admin_solicited:
|
||||
if instance.post.kind in _SYSTEM_AUTHOR_POST_KINDS and not instance.admin_solicited:
|
||||
instance.delete()
|
||||
|
||||
|
||||
class BudshipNote(models.Model):
|
||||
"""Per-relation personal note about a bud — bud landing page sprint
|
||||
2026-05-27 ([[project-bud-landing-page-sprint]]). One row per
|
||||
(user, bud) pair: the user's own shoptalk about that bud, NEVER
|
||||
visible to the bud. Lazy-created on first shoptalk save so the
|
||||
absence of a row reads as 'never edited' (drives the `.tt-milestone`
|
||||
slot on the My Buds tooltip portal — present when ≥1 edit, absent
|
||||
otherwise)."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
"lyric.User",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="budship_notes_written",
|
||||
)
|
||||
bud = models.ForeignKey(
|
||||
"lyric.User",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="budship_notes_about",
|
||||
)
|
||||
shoptalk = models.CharField(max_length=160, default="")
|
||||
edited_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "bud")
|
||||
ordering = ("-edited_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"BudshipNote({self.user_id} → {self.bud_id})"
|
||||
|
||||
139
src/apps/billboard/static/apps/billboard/my-buds-tooltip.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// Row-click → row-lock + tooltip-portal for the My Buds list.
|
||||
//
|
||||
// Bud landing page sprint 2026-05-27 ([[project-bud-landing-page-sprint]]).
|
||||
// Mirrors apps/applets/row-lock.js's lock/unlock state machine, but binds
|
||||
// to `.bud-entry` rows (which are NOT `.row-3col`) AND populates the
|
||||
// shared `#id_tooltip_portal` from the row's data-tt-* attrs.
|
||||
//
|
||||
// Click target semantics:
|
||||
// • Click on the inner `<a>` (the `@<handle>` anchor) — let it navigate
|
||||
// to the bud's landing page. NO lock fires.
|
||||
// • Click anywhere else inside `.bud-entry` — lock the row + open the
|
||||
// tooltip portal w. its data-tt-* fields populated.
|
||||
// • Click outside any `.bud-entry` (and outside the portal) — clear.
|
||||
//
|
||||
// `.tt-milestone` is REMOVED from the DOM (vs. emptied) when the row's
|
||||
// `data-tt-milestone` attr is absent so the FT can distinguish "never
|
||||
// edited" (slot absent) from "cleared after edit" (slot empty).
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _lockedRow = null;
|
||||
var _portal = null;
|
||||
var _milestoneTemplate = null;
|
||||
|
||||
function _clearLock() {
|
||||
if (_lockedRow) {
|
||||
_lockedRow.classList.remove('row-locked');
|
||||
_lockedRow = null;
|
||||
}
|
||||
if (_portal) {
|
||||
_portal.classList.remove('active');
|
||||
// Reset positional props so the next show measures fresh.
|
||||
_portal.style.top = '';
|
||||
_portal.style.bottom = '';
|
||||
_portal.style.left = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp the position:fixed portal to the viewport — same 1rem-inset
|
||||
// shape as game-kit.js / sky-wheel.js / wallet.js. Called AFTER .active
|
||||
// makes the portal display:block so offsetWidth/Height are real: clamp
|
||||
// the left edge into [rem, viewport-ttW-rem], then prefer ABOVE the row
|
||||
// (flip BELOW when the tooltip is too tall to fit above).
|
||||
function _positionPortal(row) {
|
||||
if (!_portal) return;
|
||||
var rect = row.getBoundingClientRect();
|
||||
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
var ttW = _portal.offsetWidth;
|
||||
var ttH = _portal.offsetHeight;
|
||||
|
||||
var minLeft = rem;
|
||||
var maxLeft = window.innerWidth - ttW - rem;
|
||||
var clampedLeft = Math.max(minLeft, Math.min(rect.left, maxLeft));
|
||||
_portal.style.left = clampedLeft + 'px';
|
||||
|
||||
var spaceAbove = rect.top - rem;
|
||||
if (ttH <= spaceAbove) {
|
||||
_portal.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||
_portal.style.top = '';
|
||||
} else {
|
||||
_portal.style.top = (rect.bottom + 8) + 'px';
|
||||
_portal.style.bottom = '';
|
||||
}
|
||||
}
|
||||
|
||||
function _findSlot(name) {
|
||||
if (!_portal) return null;
|
||||
return _portal.querySelector('.tt-' + name);
|
||||
}
|
||||
|
||||
function _populatePortal(row) {
|
||||
if (!_portal) return;
|
||||
var fields = ['title', 'description', 'email', 'shoptalk'];
|
||||
fields.forEach(function (f) {
|
||||
var slot = _findSlot(f);
|
||||
if (slot) slot.textContent = row.dataset['tt' + f.charAt(0).toUpperCase() + f.slice(1)] || '';
|
||||
});
|
||||
// .tt-milestone — absent when never-edited; present (+ populated)
|
||||
// when the row carries data-tt-milestone.
|
||||
var ms = row.dataset.ttMilestone;
|
||||
var existing = _findSlot('milestone');
|
||||
if (ms) {
|
||||
if (!existing) {
|
||||
var span = _milestoneTemplate.cloneNode(true);
|
||||
span.textContent = ms;
|
||||
_portal.appendChild(span);
|
||||
} else {
|
||||
existing.textContent = ms;
|
||||
}
|
||||
} else {
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function _onClick(e) {
|
||||
// Anchor click — let navigation proceed; no lock/portal.
|
||||
if (e.target.closest('.bud-entry .bud-name a')) {
|
||||
_clearLock();
|
||||
return;
|
||||
}
|
||||
var row = e.target.closest('.bud-entry');
|
||||
if (row) {
|
||||
if (row === _lockedRow) {
|
||||
_clearLock();
|
||||
} else {
|
||||
_clearLock();
|
||||
row.classList.add('row-locked');
|
||||
_lockedRow = row;
|
||||
_populatePortal(row);
|
||||
if (_portal) {
|
||||
_portal.classList.add('active');
|
||||
_positionPortal(row);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Click inside the portal itself — preserve lock.
|
||||
if (_portal && _portal.contains(e.target)) return;
|
||||
_clearLock();
|
||||
}
|
||||
|
||||
function _init() {
|
||||
_portal = document.getElementById('id_tooltip_portal');
|
||||
if (_portal) {
|
||||
// Snapshot the milestone element shape so we can restore it
|
||||
// after a never-edited row removes it.
|
||||
var ms = _portal.querySelector('.tt-milestone');
|
||||
if (ms) _milestoneTemplate = ms.cloneNode(false);
|
||||
}
|
||||
document.addEventListener('click', _onClick);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
}());
|
||||
100
src/apps/billboard/tax.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""@taxman-authored "Debits & credits" ledger — user-spec 2026-05-26.
|
||||
|
||||
`log_tax_debit(user, slug)` appends one Line + spawns one Brief on the user's
|
||||
single TAX_LEDGER Post for each FREE/PAID DRAW spend at /gameboard/my-sea/.
|
||||
Parallels `apps.drama.models.Note.grant_if_new` for Note unlocks; the same
|
||||
post_save guard in `billboard.models` nukes any Line saved on a TAX_LEDGER
|
||||
Post w.o. admin_solicited=True.
|
||||
|
||||
Two debit slugs today:
|
||||
free_draw_locked → my_sea_lock first-card-of-cycle
|
||||
paid_draw_locked → my_sea_paid_draw commit
|
||||
|
||||
The Brief that spawns here is rendered via the existing slide-down banner
|
||||
(note.js `Brief.showBanner`); its FYI .btn-info navigates to the user's
|
||||
ledger Post; its NVM stamps the matching `User.{free,paid}_draw_brief_
|
||||
dismissed_at` field via the dismiss-brief gameboard endpoints.
|
||||
|
||||
Line text is timestamp-prefixed so a second identical-slug spend doesn't
|
||||
collide w. `Line.Meta.unique_together = ("post", "text")`."""
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billboard.models import (
|
||||
Brief,
|
||||
Line,
|
||||
Post,
|
||||
TAX_LEDGER_POST_TITLE,
|
||||
)
|
||||
from apps.lyric.models import get_or_create_taxman
|
||||
|
||||
|
||||
# Canonical Line text per slug. Replaces the prior `_showFreeDrawLockedBrief`
|
||||
# helper's wording in my_sea.html — the ledger Line IS the source of truth
|
||||
# for both the persistent log surface (Debits & credits Post) AND the slide-
|
||||
# down Brief banner (via Brief.line.text in to_banner_dict).
|
||||
#
|
||||
# `.btn-pri-name` spans wrap canonical .btn-primary button labels referenced
|
||||
# inline (FREE DRAW, PAID DRAW, GATE VIEW per user-spec 2026-05-26). SCSS
|
||||
# (`_billboard.scss` under `.post-line--system .post-line-text`) styles the
|
||||
# spans so the user sees the same `--quaUser` colour + 700-weight on the
|
||||
# token in prose as they would on the actual button. Rendered as HTML via
|
||||
# `Line.display_text|safe` in post.html (the system-author branch).
|
||||
TAX_DEBIT_TEMPLATES = {
|
||||
"free_draw_locked": (
|
||||
'Look!—My Sea\'s <span class="btn-pri-name">FREE DRAW</span> '
|
||||
'is locked. Next free draw available 24h from the production of this log.'
|
||||
),
|
||||
"paid_draw_locked": (
|
||||
'Look!—My Sea\'s <span class="btn-pri-name">PAID DRAW</span> '
|
||||
'is locked. Another may be unlocked by depositing a Token in '
|
||||
'<span class="btn-pri-name">GATE VIEW</span>.'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def log_tax_debit(user, slug):
|
||||
"""Append a Line to the user's "Debits & credits" Post (creating the Post
|
||||
on first call) + spawn a Brief that the next page-load surfaces as a
|
||||
slide-down banner.
|
||||
|
||||
Returns ``(post, line, brief)``. Raises ``KeyError`` for unknown slugs.
|
||||
|
||||
Line text is prefixed with `[<ISO timestamp>] ` so successive spends of
|
||||
the same slug produce distinct rows (each one survives `Line.Meta.
|
||||
unique_together = ("post", "text")`)."""
|
||||
if slug not in TAX_DEBIT_TEMPLATES:
|
||||
raise KeyError(f"Unknown tax debit slug: {slug!r}")
|
||||
|
||||
post, _ = Post.objects.get_or_create(
|
||||
owner=user,
|
||||
kind=Post.KIND_TAX_LEDGER,
|
||||
defaults={"title": TAX_LEDGER_POST_TITLE},
|
||||
)
|
||||
# Existing TAX_LEDGER Posts (pre-feature migration) might lack a title;
|
||||
# heal once on next debit. Mirrors the Note.grant_if_new title heal.
|
||||
if post.title != TAX_LEDGER_POST_TITLE:
|
||||
post.title = TAX_LEDGER_POST_TITLE
|
||||
post.save(update_fields=["title"])
|
||||
|
||||
body = TAX_DEBIT_TEMPLATES[slug]
|
||||
# Sub-second timestamp prefix — keeps text unique even when two debits
|
||||
# land in the same wallclock second (defensive vs auto-draw paths that
|
||||
# could conceivably commit free + paid in rapid succession).
|
||||
stamp = timezone.now().isoformat(timespec="microseconds")
|
||||
text = f"[{stamp}] {body}"
|
||||
|
||||
line = Line.objects.create(
|
||||
post=post,
|
||||
text=text,
|
||||
author=get_or_create_taxman(),
|
||||
admin_solicited=True,
|
||||
)
|
||||
brief = Brief.objects.create(
|
||||
owner=user,
|
||||
post=post,
|
||||
line=line,
|
||||
kind=Brief.KIND_TAX_LEDGER,
|
||||
title=TAX_LEDGER_POST_TITLE,
|
||||
)
|
||||
return post, line, brief
|
||||
@@ -142,6 +142,19 @@ class AddBudViewTest(TestCase):
|
||||
response = self.client.get(reverse("billboard:add_bud"))
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_add_returns_at_handle_and_title_for_tooltip_row(self):
|
||||
"""The async-appended My Buds row mirrors _my_buds_item.html, so the
|
||||
payload must carry the bud's at_handle + active_title_display to fill
|
||||
the data-tt-* attrs — without them the new row's tooltip renders empty
|
||||
(the entries above it, server-rendered, have them). Regression
|
||||
2026-05-29."""
|
||||
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
body = self.client.post(
|
||||
reverse("billboard:add_bud"), data={"recipient": "alice"},
|
||||
).json()
|
||||
self.assertEqual(body["bud"]["at_handle"], "@alice")
|
||||
self.assertEqual(body["bud"]["title"], alice.active_title_display)
|
||||
|
||||
def test_add_resolves_username_too_not_just_email(self):
|
||||
"""Phase 2: recipient field accepts usernames as well as emails."""
|
||||
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
|
||||
198
src/apps/billboard/tests/integrated/test_mail.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""ITs for the @mailman "Acceptances & rejections" log — Phase A of
|
||||
[[my-sea-invite-voice-blueprint]].
|
||||
|
||||
Mirrors `apps.billboard.tests.integrated.test_tax` (the @taxman ledger): a
|
||||
reserved system-author user (`mailman`) authors the interactive invite log
|
||||
Lines via `apps.billboard.mail.log_sea_invite`.
|
||||
|
||||
This file grows in A4 with the `log_sea_invite` Post/Line/Brief tests; A2
|
||||
lands only the reserved-username + idempotency contract for `mailman`,
|
||||
mirroring `test_tax.TaxmanReservedUsernameTest`.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.billboard.mail import INVITE_TEMPLATE, log_sea_invite
|
||||
from apps.billboard.models import (
|
||||
Brief,
|
||||
Line,
|
||||
MAIL_ACCEPTANCE_POST_TITLE,
|
||||
Post,
|
||||
)
|
||||
from apps.gameboard.models import SeaInvite
|
||||
from apps.lyric.models import (
|
||||
User,
|
||||
get_or_create_mailman,
|
||||
is_reserved_username,
|
||||
)
|
||||
|
||||
|
||||
class LogSeaInviteTest(TestCase):
|
||||
"""`log_sea_invite` appends one interactive Line + spawns a Brief on the
|
||||
invitee's single "Acceptances & rejections" Post, and links the Line back
|
||||
to the SeaInvite (powering the OK/BYE render in A5)."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(
|
||||
email="owner@test.io", username="discoman",
|
||||
)
|
||||
self.invitee = User.objects.create(
|
||||
email="bud@test.io", username="budster",
|
||||
)
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner,
|
||||
invitee=self.invitee,
|
||||
invitee_email=self.invitee.email,
|
||||
)
|
||||
|
||||
def test_creates_post_line_brief_on_invitee(self):
|
||||
post, line, brief = log_sea_invite(self.invite)
|
||||
# Post owned by the INVITEE (it's their notification surface)
|
||||
self.assertEqual(post.owner, self.invitee)
|
||||
self.assertEqual(post.kind, Post.KIND_MAIL_ACCEPTANCE)
|
||||
self.assertEqual(post.title, MAIL_ACCEPTANCE_POST_TITLE)
|
||||
# Line authored by @mailman + admin_solicited (survives the guard)
|
||||
self.assertEqual(line.post, post)
|
||||
self.assertEqual(line.author, get_or_create_mailman())
|
||||
self.assertTrue(line.admin_solicited)
|
||||
# Brief points at the Post + Line w. correct kind
|
||||
self.assertEqual(brief.owner, self.invitee)
|
||||
self.assertEqual(brief.post, post)
|
||||
self.assertEqual(brief.line, line)
|
||||
self.assertEqual(brief.kind, Brief.KIND_MAIL_ACCEPTANCE)
|
||||
self.assertEqual(brief.title, MAIL_ACCEPTANCE_POST_TITLE)
|
||||
|
||||
def test_links_line_to_invite_both_directions(self):
|
||||
_, line, _ = log_sea_invite(self.invite)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.line, line)
|
||||
# OneToOne reverse accessor used by post.html (A5)
|
||||
self.assertEqual(line.sea_invite, self.invite)
|
||||
|
||||
def test_prose_interpolates_owner_handle_and_default_possessive(self):
|
||||
_, line, _ = log_sea_invite(self.invite)
|
||||
self.assertIn("@discoman", line.text)
|
||||
# pluralism (default) possessive = "their"
|
||||
self.assertIn("their drawing table", line.text)
|
||||
self.assertIn("expire 24h", line.text)
|
||||
|
||||
def test_possessive_follows_owner_pronouns(self):
|
||||
self.owner.pronouns = "misogyny" # he/him/his
|
||||
self.owner.save()
|
||||
_, line, _ = log_sea_invite(self.invite)
|
||||
self.assertIn("his drawing table", line.text)
|
||||
|
||||
def test_line_survives_post_save_guard(self):
|
||||
_, line, _ = log_sea_invite(self.invite)
|
||||
self.assertTrue(Line.objects.filter(pk=line.pk).exists())
|
||||
|
||||
def test_two_inviters_share_invitees_one_post(self):
|
||||
other_owner = User.objects.create(
|
||||
email="other@test.io", username="amigo",
|
||||
)
|
||||
other_invite = SeaInvite.objects.create(
|
||||
owner=other_owner, invitee=self.invitee,
|
||||
invitee_email=self.invitee.email,
|
||||
)
|
||||
log_sea_invite(self.invite)
|
||||
log_sea_invite(other_invite)
|
||||
posts = Post.objects.filter(
|
||||
owner=self.invitee, kind=Post.KIND_MAIL_ACCEPTANCE,
|
||||
)
|
||||
self.assertEqual(posts.count(), 1)
|
||||
self.assertEqual(posts.first().lines.count(), 2)
|
||||
|
||||
def test_unregistered_invitee_creates_no_log(self):
|
||||
# An unregistered recipient has no per-user Post surface yet — linking
|
||||
# on registration is deferred. log_sea_invite no-ops to (None,None,None).
|
||||
invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=None,
|
||||
invitee_email="stranger@nowhere.io",
|
||||
)
|
||||
self.assertEqual(log_sea_invite(invite), (None, None, None))
|
||||
|
||||
def test_template_uses_listen_hook(self):
|
||||
self.assertTrue(INVITE_TEMPLATE.startswith("Listen!"))
|
||||
|
||||
def test_line_wraps_owner_handle_in_post_attribution_anchor(self):
|
||||
"""Bud landing page sprint 2026-05-27 — the inline OK/BYE block
|
||||
migrates onto bud.html; the @mailman Line now carries an
|
||||
`<a class="post-attribution">` around the owner's handle whose
|
||||
href routes to the owner's per-bud landing page."""
|
||||
_, line, _ = log_sea_invite(self.invite)
|
||||
self.assertIn('class="post-attribution"', line.text)
|
||||
self.assertIn(
|
||||
f'href="/billboard/buds/{self.owner.id}/"', line.text,
|
||||
)
|
||||
# The anchor wraps ONLY the at_handle (not the surrounding prose)
|
||||
self.assertIn(">@discoman</a>", line.text)
|
||||
|
||||
|
||||
class MailAcceptanceKindTest(TestCase):
|
||||
"""The new MAIL_ACCEPTANCE kind is registered on both Post + Brief."""
|
||||
|
||||
def test_post_kind_registered(self):
|
||||
self.assertEqual(Post.KIND_MAIL_ACCEPTANCE, "mail_acceptance")
|
||||
kinds = dict(Post.KIND_CHOICES)
|
||||
self.assertIn(Post.KIND_MAIL_ACCEPTANCE, kinds)
|
||||
|
||||
def test_brief_kind_registered(self):
|
||||
self.assertEqual(Brief.KIND_MAIL_ACCEPTANCE, "mail_acceptance")
|
||||
kinds = dict(Brief.KIND_CHOICES)
|
||||
self.assertIn(Brief.KIND_MAIL_ACCEPTANCE, kinds)
|
||||
|
||||
def test_post_title_constant(self):
|
||||
self.assertEqual(MAIL_ACCEPTANCE_POST_TITLE, "Acceptances & rejections")
|
||||
|
||||
|
||||
class MailAcceptanceGuardTest(TestCase):
|
||||
"""post_save guard nukes any Line saved on a MAIL_ACCEPTANCE Post w.o.
|
||||
admin_solicited=True — same defense-in-depth as NOTE_UNLOCK / TAX_LEDGER.
|
||||
`log_sea_invite` sets admin_solicited=True so legitimate invite Lines
|
||||
survive."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="guard_mail@test.io")
|
||||
self.mailman = get_or_create_mailman()
|
||||
self.post = Post.objects.create(
|
||||
owner=self.user,
|
||||
kind=Post.KIND_MAIL_ACCEPTANCE,
|
||||
title=MAIL_ACCEPTANCE_POST_TITLE,
|
||||
)
|
||||
|
||||
def test_unsolicited_line_on_mail_acceptance_gets_deleted(self):
|
||||
Line.objects.create(
|
||||
post=self.post, text="impostor", author=self.mailman,
|
||||
admin_solicited=False,
|
||||
)
|
||||
self.assertEqual(self.post.lines.count(), 0)
|
||||
|
||||
def test_solicited_line_on_mail_acceptance_survives(self):
|
||||
Line.objects.create(
|
||||
post=self.post, text="legit", author=self.mailman,
|
||||
admin_solicited=True,
|
||||
)
|
||||
self.assertEqual(self.post.lines.count(), 1)
|
||||
|
||||
|
||||
class MailmanReservedUsernameTest(TestCase):
|
||||
"""`mailman` joins `adman` + `taxman` as a reserved system-author handle."""
|
||||
|
||||
def test_mailman_is_reserved(self):
|
||||
self.assertTrue(is_reserved_username("mailman"))
|
||||
self.assertTrue(is_reserved_username("MAILMAN")) # case-insensitive
|
||||
|
||||
def test_get_or_create_mailman_is_idempotent(self):
|
||||
a = get_or_create_mailman()
|
||||
b = get_or_create_mailman()
|
||||
self.assertEqual(a.pk, b.pk)
|
||||
self.assertEqual(a.email, "mailman@earthmanrpg.local")
|
||||
|
||||
def test_mailman_is_not_searchable(self):
|
||||
# System users never surface in bud / recipient autocomplete.
|
||||
self.assertFalse(get_or_create_mailman().searchable)
|
||||
|
||||
def test_existing_username_owner_is_not_blocked(self):
|
||||
# A user who already holds a name isn't blocked from re-saving it.
|
||||
u = User.objects.create(email="m@test.io", username="mailman_fan")
|
||||
self.assertFalse(is_reserved_username("mailman_fan", current_user=u))
|
||||
121
src/apps/billboard/tests/integrated/test_tax.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""ITs for `apps.billboard.tax.log_tax_debit` — the @taxman-authored
|
||||
"Debits & credits" ledger (user-spec 2026-05-26).
|
||||
|
||||
Mirrors the shape of `apps.drama.tests.integrated.test_note_brief` for
|
||||
`Note.grant_if_new` — each spend appends a Line + spawns a Brief on the
|
||||
user's single TAX_LEDGER Post."""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.billboard.models import Brief, Line, Post, TAX_LEDGER_POST_TITLE
|
||||
from apps.billboard.tax import (
|
||||
TAX_DEBIT_TEMPLATES,
|
||||
log_tax_debit,
|
||||
)
|
||||
from apps.lyric.models import User, get_or_create_taxman
|
||||
|
||||
|
||||
class LogTaxDebitTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="tax@test.io")
|
||||
|
||||
def test_free_draw_locked_creates_post_line_brief(self):
|
||||
post, line, brief = log_tax_debit(self.user, "free_draw_locked")
|
||||
# Post created with the canonical title + correct kind
|
||||
self.assertEqual(post.owner, self.user)
|
||||
self.assertEqual(post.kind, Post.KIND_TAX_LEDGER)
|
||||
self.assertEqual(post.title, TAX_LEDGER_POST_TITLE)
|
||||
# Line authored by @taxman + admin_solicited
|
||||
self.assertEqual(line.post, post)
|
||||
self.assertEqual(line.author, get_or_create_taxman())
|
||||
self.assertTrue(line.admin_solicited)
|
||||
# Brief points at the Post + Line w. correct kind
|
||||
self.assertEqual(brief.owner, self.user)
|
||||
self.assertEqual(brief.post, post)
|
||||
self.assertEqual(brief.line, line)
|
||||
self.assertEqual(brief.kind, Brief.KIND_TAX_LEDGER)
|
||||
self.assertEqual(brief.title, TAX_LEDGER_POST_TITLE)
|
||||
|
||||
def test_paid_draw_locked_uses_paid_template_text(self):
|
||||
_, line, _ = log_tax_debit(self.user, "paid_draw_locked")
|
||||
self.assertIn("PAID DRAW", line.text)
|
||||
# `GATE VIEW` is wrapped in a `.btn-pri-name` span for inline styling
|
||||
# parity w. the actual button label (user-spec 2026-05-26), so the
|
||||
# raw text has HTML between "depositing a Token in " + "GATE VIEW".
|
||||
self.assertIn("depositing a Token in", line.text)
|
||||
self.assertIn("GATE VIEW", line.text)
|
||||
|
||||
def test_free_draw_locked_uses_free_template_text(self):
|
||||
_, line, _ = log_tax_debit(self.user, "free_draw_locked")
|
||||
self.assertIn("FREE DRAW", line.text)
|
||||
self.assertIn("24h from the production of this log", line.text)
|
||||
|
||||
def test_two_spends_share_one_post_with_two_lines(self):
|
||||
"""Like Note unlocks: one Post per user, growing thread of Lines."""
|
||||
log_tax_debit(self.user, "free_draw_locked")
|
||||
log_tax_debit(self.user, "paid_draw_locked")
|
||||
posts = Post.objects.filter(owner=self.user, kind=Post.KIND_TAX_LEDGER)
|
||||
self.assertEqual(posts.count(), 1)
|
||||
self.assertEqual(posts.first().lines.count(), 2)
|
||||
|
||||
def test_two_free_draws_produce_distinct_lines(self):
|
||||
"""Each spend produces a UNIQUE Line — text is timestamp-prefixed so
|
||||
a second identical-slug spend doesn't collide with `unique_together
|
||||
= (post, text)`."""
|
||||
log_tax_debit(self.user, "free_draw_locked")
|
||||
log_tax_debit(self.user, "free_draw_locked")
|
||||
post = Post.objects.get(owner=self.user, kind=Post.KIND_TAX_LEDGER)
|
||||
line_texts = list(post.lines.values_list("text", flat=True))
|
||||
self.assertEqual(len(line_texts), 2)
|
||||
self.assertNotEqual(line_texts[0], line_texts[1])
|
||||
|
||||
def test_unknown_slug_raises(self):
|
||||
with self.assertRaises(KeyError):
|
||||
log_tax_debit(self.user, "no_such_slug")
|
||||
|
||||
def test_template_keys_cover_both_known_slugs(self):
|
||||
self.assertIn("free_draw_locked", TAX_DEBIT_TEMPLATES)
|
||||
self.assertIn("paid_draw_locked", TAX_DEBIT_TEMPLATES)
|
||||
|
||||
|
||||
class UnsolicitedLineGuardTest(TestCase):
|
||||
"""post_save guard nukes any Line saved on a TAX_LEDGER Post w.o.
|
||||
admin_solicited=True — mirrors the NOTE_UNLOCK defense-in-depth."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="guard@test.io")
|
||||
self.taxman = get_or_create_taxman()
|
||||
self.post = Post.objects.create(
|
||||
owner=self.user,
|
||||
kind=Post.KIND_TAX_LEDGER,
|
||||
title=TAX_LEDGER_POST_TITLE,
|
||||
)
|
||||
|
||||
def test_unsolicited_line_on_tax_ledger_gets_deleted(self):
|
||||
Line.objects.create(
|
||||
post=self.post, text="impostor", author=self.taxman,
|
||||
admin_solicited=False,
|
||||
)
|
||||
self.assertEqual(self.post.lines.count(), 0)
|
||||
|
||||
def test_solicited_line_on_tax_ledger_survives(self):
|
||||
Line.objects.create(
|
||||
post=self.post, text="legit", author=self.taxman,
|
||||
admin_solicited=True,
|
||||
)
|
||||
self.assertEqual(self.post.lines.count(), 1)
|
||||
|
||||
|
||||
class TaxmanReservedUsernameTest(TestCase):
|
||||
"""`taxman` joins `adman` as a reserved system-author handle."""
|
||||
|
||||
def test_taxman_is_reserved(self):
|
||||
from apps.lyric.models import is_reserved_username
|
||||
self.assertTrue(is_reserved_username("taxman"))
|
||||
self.assertTrue(is_reserved_username("TAXMAN")) # case-insensitive
|
||||
|
||||
def test_get_or_create_taxman_is_idempotent(self):
|
||||
a = get_or_create_taxman()
|
||||
b = get_or_create_taxman()
|
||||
self.assertEqual(a.pk, b.pk)
|
||||
self.assertEqual(a.email, "taxman@earthmanrpg.local")
|
||||
@@ -860,9 +860,72 @@ class MySignViewTest(TestCase):
|
||||
{"card_id": 999999, "reversed": "0"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_page_carries_data_deck_polarized_attr(self):
|
||||
"""Sprint A.5-polish — the my_sign page wrapper exposes the equipped
|
||||
deck's `is_polarized` state via `data-deck-polarized` so the FLIP-btn
|
||||
JS can branch: polarized decks cycle polarity (existing behavior);
|
||||
non-polarized decks flip to the deck card-back (new)."""
|
||||
import lxml.html
|
||||
# Default Earthman = is_polarized=True per A.0 migration.
|
||||
response = self.client.get(reverse("billboard:my_sign"))
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
[page] = parsed.cssselect(".my-sign-page")
|
||||
self.assertEqual(page.get("data-deck-polarized"), "true")
|
||||
|
||||
def test_image_deck_renders_back_img_in_stage_scaffold(self):
|
||||
"""Image-equipped non-polarized decks (Minchiate) render a hidden
|
||||
<img.sig-stage-card-back-img> inside the stage card; toggled visible
|
||||
by the FLIP-btn JS handler via the .is-flipped-to-back class."""
|
||||
from apps.epic.models import DeckVariant
|
||||
import lxml.html
|
||||
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
||||
self.user.unlocked_decks.add(minchiate)
|
||||
self.user.equipped_deck = minchiate
|
||||
self.user.save(update_fields=["equipped_deck"])
|
||||
response = self.client.get(reverse("billboard:my_sign"))
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
[page] = parsed.cssselect(".my-sign-page")
|
||||
self.assertEqual(page.get("data-deck-polarized"), "false")
|
||||
[back_img] = parsed.cssselect(".sig-stage-card .sig-stage-card-back-img")
|
||||
self.assertIn(
|
||||
"minchiate-fiorentine-1860-1890-back.png",
|
||||
back_img.get("src", ""),
|
||||
)
|
||||
|
||||
def test_polarized_deck_omits_back_img(self):
|
||||
"""Earthman (polarized) keeps the existing polarity-cycle FLIP — no
|
||||
back-image element needed in the scaffold."""
|
||||
import lxml.html
|
||||
response = self.client.get(reverse("billboard:my_sign"))
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
self.assertEqual(
|
||||
len(parsed.cssselect(".sig-stage-card .sig-stage-card-back-img")), 0,
|
||||
"Polarized deck must not render the back-image element",
|
||||
)
|
||||
self.user.refresh_from_db()
|
||||
self.assertIsNone(self.user.significator_id)
|
||||
|
||||
def test_stat_block_renders_rank_suit_chip_per_face(self):
|
||||
"""Sprint A.7.5 — `.stat-face-header` wraps the new top-left rank+suit
|
||||
chip inline w. the EMANATION/REVERSAL label per [[project-image-based-
|
||||
deck-face-rendering]]'s A.3 Q3 spec. Empty by default (JS-populated by
|
||||
stage-card.js populateStatExtras on focus); both upright + reversed
|
||||
faces carry their own chip slot so post-SPIN the chip stays visible."""
|
||||
import lxml.html
|
||||
response = self.client.get(reverse("billboard:my_sign"))
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
for face_cls in ("stat-face--upright", "stat-face--reversed"):
|
||||
face = parsed.cssselect(f".sig-stat-block .{face_cls}")
|
||||
self.assertEqual(len(face), 1, f"expected one {face_cls}")
|
||||
[header] = face[0].cssselect(".stat-face-header")
|
||||
# Polish-4 — header is a 2-row vertical stack: rank on row 1
|
||||
# (direct child), icon+label inside `.stat-chip-tag` on row 2.
|
||||
[_rank] = header.cssselect(".stat-chip-rank")
|
||||
[_tag] = header.cssselect(".stat-chip-tag")
|
||||
[_icon] = _tag.cssselect("i.stat-chip-icon")
|
||||
[_label] = _tag.cssselect(".stat-face-label")
|
||||
|
||||
def test_save_sign_get_redirects_back_to_picker(self):
|
||||
response = self.client.get(reverse("billboard:save_sign"))
|
||||
self.assertRedirects(response, reverse("billboard:my_sign"))
|
||||
@@ -966,5 +1029,503 @@ class BillboardAppletMySignTest(TestCase):
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertContains(response, "my-sign-applet-card")
|
||||
self.assertContains(response, f'data-card-id="{target.id}"')
|
||||
# significator_reversed = True → card carries stage-card--reversed class
|
||||
self.assertContains(response, "stage-card--reversed")
|
||||
# significator_reversed = True ↔ polarity=levity (per convention).
|
||||
# Saved sigs are POLARITY-only — the orientation (SPIN) axis is not
|
||||
# persisted, so the applet card renders upright with the levity
|
||||
# polarity class, NOT rotated via `stage-card--reversed`.
|
||||
self.assertContains(response, "my-sign-applet-card--levity")
|
||||
self.assertNotContains(response, "stage-card--reversed")
|
||||
# Polarity qualifier renders alongside the title (middle court →
|
||||
# "Elevated" for levity, "Graven" for gravity).
|
||||
self.assertContains(response, "fan-card-qualifier")
|
||||
if target.levity_qualifier:
|
||||
self.assertContains(response, target.levity_qualifier)
|
||||
# Always the emanation face — keywords_upright + "Emanation" label.
|
||||
self.assertContains(response, "Emanation")
|
||||
self.assertNotContains(response, ">Reversal<")
|
||||
|
||||
def test_my_sign_applet_renders_gravity_qualifier_when_not_reversed(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = target
|
||||
self.user.significator_reversed = False
|
||||
self.user.save(update_fields=["significator", "significator_reversed"])
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertContains(response, "my-sign-applet-card--gravity")
|
||||
if target.gravity_qualifier:
|
||||
self.assertContains(response, target.gravity_qualifier)
|
||||
|
||||
def test_my_sign_applet_renders_image_when_deck_has_card_images(self):
|
||||
"""Sprint A.6 — applet card carries `.my-sign-applet-card--image` +
|
||||
an <img.sig-stage-card-img> child when the user's equipped deck is
|
||||
image-equipped (Minchiate today). Shares the contour-stroke + depth
|
||||
shadow SCSS w. my_sign.html's stage-card-image via comma-list selector.
|
||||
Text scaffold (fan-card-corner / fan-card-face) is NOT rendered in
|
||||
image mode — server-side template `{% if/else %}` branch."""
|
||||
from apps.epic.models import DeckVariant, TarotCard
|
||||
import lxml.html
|
||||
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
from apps.drama.models import Note
|
||||
Note.grant_if_new(self.user, "super-nomad")
|
||||
Note.grant_if_new(self.user, "super-schizo")
|
||||
self.user.unlocked_decks.add(minchiate)
|
||||
self.user.equipped_deck = minchiate
|
||||
il_matto = TarotCard.objects.get(deck_variant=minchiate, slug="il-matto")
|
||||
self.user.significator = il_matto
|
||||
self.user.save(update_fields=["equipped_deck", "significator"])
|
||||
|
||||
response = self.client.get("/billboard/")
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
[card_el] = parsed.cssselect(".my-sign-applet-card")
|
||||
self.assertIn("my-sign-applet-card--image", card_el.get("class", ""))
|
||||
self.assertEqual(card_el.get("data-arcana-key"), "MAJOR")
|
||||
[img] = card_el.cssselect("img.sig-stage-card-img")
|
||||
self.assertIn(
|
||||
"minchiate-fiorentine-1860-1890-trumps-00-il-matto.png",
|
||||
img.get("src", ""),
|
||||
)
|
||||
# Text scaffold absent in image mode (the server-side {% if %} branch
|
||||
# skips the fan-card-corner + fan-card-face children entirely).
|
||||
self.assertEqual(
|
||||
len(card_el.cssselect(".fan-card-corner")), 0,
|
||||
"Text scaffold must not render in image mode",
|
||||
)
|
||||
|
||||
def test_my_sign_applet_keeps_text_render_for_non_image_deck(self):
|
||||
"""Earthman (has_card_images=False) keeps the existing fan-card-corner
|
||||
text scaffold + lacks the --image modifier class."""
|
||||
from apps.epic.models import personal_sig_cards
|
||||
target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = target
|
||||
self.user.save(update_fields=["significator"])
|
||||
import lxml.html
|
||||
response = self.client.get("/billboard/")
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
[card_el] = parsed.cssselect(".my-sign-applet-card")
|
||||
self.assertNotIn("my-sign-applet-card--image", card_el.get("class", ""))
|
||||
self.assertEqual(
|
||||
len(card_el.cssselect("img.sig-stage-card-img")), 0,
|
||||
"Non-image deck must not render the <img>",
|
||||
)
|
||||
self.assertGreater(
|
||||
len(card_el.cssselect(".fan-card-corner")), 0,
|
||||
"Non-image deck keeps the text scaffold",
|
||||
)
|
||||
|
||||
def test_applet_stat_block_renders_server_side_chip(self):
|
||||
"""Sprint A.7.5 — applet is read-only so the rank+suit chip is server-
|
||||
rendered (not JS-populated as on stage / sea_stage / fan stage). Chip
|
||||
carries the card's corner_rank + suit_icon FA class inline w. the
|
||||
EMANATION label inside `.stat-face-header`."""
|
||||
from apps.epic.models import personal_sig_cards
|
||||
target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = target
|
||||
self.user.save(update_fields=["significator"])
|
||||
import lxml.html
|
||||
response = self.client.get("/billboard/")
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
[block] = parsed.cssselect(".my-sign-applet-stat-block")
|
||||
[header] = block.cssselect(".stat-face-header")
|
||||
# Polish-4 — rank is a direct child of header (own row); icon lives
|
||||
# inside `.stat-chip-tag` (row-2 inline w. the EMANATION label).
|
||||
[rank] = header.cssselect(".stat-chip-rank")
|
||||
# Court middle cards have single-letter corner ranks (M/J/Q/K) per
|
||||
# TarotCard.corner_rank — pin presence, not the exact value (which
|
||||
# depends on which middle court personal_sig_cards returns first).
|
||||
self.assertTrue(rank.text and rank.text.strip())
|
||||
[tag] = header.cssselect(".stat-chip-tag")
|
||||
[icon] = tag.cssselect("i.stat-chip-icon")
|
||||
# Middle court has a suit, so the suit-icon `<i>` is present + carries
|
||||
# the canonical FA class for the suit (fa-wand-sparkles for BRANDS etc).
|
||||
self.assertTrue(any(cls.startswith("fa-") for cls in (icon.get("class") or "").split()))
|
||||
|
||||
|
||||
# ── Per-bud Landing Page ─────────────────────────────────────────────────
|
||||
# /billboard/buds/<uuid:bud_id>/ + the my_buds row enrichment that surfaces
|
||||
# the new tooltip-portal data — bud landing page sprint 2026-05-27 (see
|
||||
# [[project-bud-landing-page-sprint]]). Replaces the @mailman invite Line's
|
||||
# inline OK/BYE block w. a dedicated page; the My Buds list rows now wrap
|
||||
# the `@<handle>` in an anchor to the bud's page + carry data-tt-* attrs
|
||||
# the JS portal reads on row-lock click.
|
||||
|
||||
|
||||
class BudPageRenderTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@buds.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@buds.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_returns_200(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_uses_bud_template(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertTemplateUsed(response, "apps/billboard/bud.html")
|
||||
|
||||
def test_passes_bud_in_context(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.context["bud"], self.alice)
|
||||
|
||||
def test_passes_empty_shoptalk_when_no_note(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.context["shoptalk_text"], "")
|
||||
self.assertIsNone(response.context["milestone_dt"])
|
||||
|
||||
def test_header_renders_at_handle_the_title_and_email(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
body = response.content.decode()
|
||||
self.assertIn("@alice", body)
|
||||
self.assertIn("the Earthman", body)
|
||||
self.assertIn("alice@buds.io", body)
|
||||
|
||||
def test_shoptalk_textarea_carries_160_char_maxlength(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
body = response.content.decode()
|
||||
self.assertRegex(
|
||||
body, r'<textarea[^>]+id="id_shoptalk"[^>]*maxlength="160"',
|
||||
)
|
||||
|
||||
def test_existing_shoptalk_renders_in_textarea(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
BudshipNote.objects.create(
|
||||
user=self.user, bud=self.alice, shoptalk="loves chess",
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.context["shoptalk_text"], "loves chess")
|
||||
self.assertIsNotNone(response.context["milestone_dt"])
|
||||
self.assertContains(response, "loves chess")
|
||||
|
||||
|
||||
class BudPageAutoAddOnFirstVisitTest(TestCase):
|
||||
"""Visiting bud.html for a non-bud auto-adds them to the user's buds —
|
||||
mirrors share_post's implicit-add posture so the @mailman post-
|
||||
attribution anchor lands the inviter on the user's buds graph."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@auto.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@auto.io", username="alice")
|
||||
# alice is NOT in user.buds — auto-add is the contract
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_visit_adds_bud_to_m2m(self):
|
||||
self.assertNotIn(self.alice, list(self.user.buds.all()))
|
||||
self.client.get(reverse("billboard:bud_page", args=[self.alice.id]))
|
||||
self.assertIn(self.alice, list(self.user.buds.all()))
|
||||
|
||||
def test_self_visit_does_not_self_add(self):
|
||||
# Pathological case: navigating to your own bud page must not seed
|
||||
# the user as their own bud (M2M is asymmetric self-FK).
|
||||
self.client.get(reverse("billboard:bud_page", args=[self.user.id]))
|
||||
self.assertNotIn(self.user, list(self.user.buds.all()))
|
||||
|
||||
def test_already_bud_visit_is_idempotent(self):
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.get(reverse("billboard:bud_page", args=[self.alice.id]))
|
||||
# M2M dedup'd; still one row
|
||||
self.assertEqual(self.user.buds.filter(pk=self.alice.pk).count(), 1)
|
||||
|
||||
|
||||
class BudPagePendingInviteCascadeTest(TestCase):
|
||||
"""`sea_btn_active` + `sea_first_draw_pending` fire iff a *live* SeaInvite
|
||||
exists from this bud (owner) to the viewer (invitee) — non-terminal
|
||||
(PENDING or ACCEPTED) AND inside its 24h-from-proffer window OR within 24h
|
||||
of the viewer's last gate token deposit (user-spec 2026-05-29, via
|
||||
`SeaInvite.invitee_access_open`). Reuses the same template flags
|
||||
`_burger.html` already reads on my_sea + room — no new template plumbing
|
||||
on bud.html."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.gameboard.models import SeaInvite
|
||||
self.SeaInvite = SeaInvite
|
||||
self.user = User.objects.create(email="me@inv.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@inv.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_no_invite_no_cascade(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNone(response.context["pending_invite"])
|
||||
self.assertFalse(response.context["sea_btn_active"])
|
||||
self.assertFalse(response.context["sea_first_draw_pending"])
|
||||
|
||||
def test_pending_invite_lights_cascade(self):
|
||||
self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=self.user,
|
||||
invitee_email=self.user.email,
|
||||
status=self.SeaInvite.PENDING,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNotNone(response.context["pending_invite"])
|
||||
self.assertTrue(response.context["sea_btn_active"])
|
||||
self.assertTrue(response.context["sea_first_draw_pending"])
|
||||
|
||||
def test_accepted_invite_within_window_lights_cascade(self):
|
||||
# New spec: an ACCEPTED invite still inside its 24h window keeps the
|
||||
# cascade lit (the old design went dark the instant it accepted, so
|
||||
# the user could never reach the bud's sea from here post-accept).
|
||||
self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=self.user,
|
||||
invitee_email=self.user.email,
|
||||
status=self.SeaInvite.ACCEPTED,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNotNone(response.context["pending_invite"])
|
||||
self.assertTrue(response.context["sea_btn_active"])
|
||||
self.assertTrue(response.context["sea_first_draw_pending"])
|
||||
|
||||
def test_expired_pending_invite_does_not_cascade(self):
|
||||
inv = self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=self.user,
|
||||
invitee_email=self.user.email,
|
||||
status=self.SeaInvite.PENDING,
|
||||
)
|
||||
self.SeaInvite.objects.filter(pk=inv.pk).update(
|
||||
created_at=timezone.now() - timezone.timedelta(hours=48),
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNone(response.context["pending_invite"])
|
||||
self.assertFalse(response.context["sea_btn_active"])
|
||||
|
||||
def test_stale_accepted_without_deposit_does_not_cascade(self):
|
||||
inv = self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=self.user,
|
||||
invitee_email=self.user.email,
|
||||
status=self.SeaInvite.ACCEPTED,
|
||||
)
|
||||
self.SeaInvite.objects.filter(pk=inv.pk).update(
|
||||
created_at=timezone.now() - timezone.timedelta(hours=48),
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNone(response.context["pending_invite"])
|
||||
self.assertFalse(response.context["sea_btn_active"])
|
||||
|
||||
def test_recent_deposit_relights_cascade_past_invite_window(self):
|
||||
# Invite proffered 3 days ago but a gate token deposit 5h ago re-arms
|
||||
# the 24h window — the "OR 24h since last token deposit" clause.
|
||||
inv = self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=self.user,
|
||||
invitee_email=self.user.email,
|
||||
status=self.SeaInvite.ACCEPTED,
|
||||
token_deposited_at=timezone.now() - timezone.timedelta(hours=5),
|
||||
)
|
||||
self.SeaInvite.objects.filter(pk=inv.pk).update(
|
||||
created_at=timezone.now() - timezone.timedelta(hours=72),
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNotNone(response.context["pending_invite"])
|
||||
self.assertTrue(response.context["sea_btn_active"])
|
||||
|
||||
def test_invite_for_other_invitee_ignored(self):
|
||||
# Pending invite from alice → some other user is irrelevant to ME.
|
||||
other = User.objects.create(email="other@inv.io", username="other")
|
||||
self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=other,
|
||||
invitee_email=other.email,
|
||||
status=self.SeaInvite.PENDING,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNone(response.context["pending_invite"])
|
||||
|
||||
|
||||
class SaveBudShoptalkViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@sav.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@sav.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_post_creates_budship_note(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
self.client.post(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
||||
{"shoptalk": "first thoughts"},
|
||||
)
|
||||
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
|
||||
self.assertEqual(bn.shoptalk, "first thoughts")
|
||||
|
||||
def test_post_updates_existing_budship_note(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
BudshipNote.objects.create(user=self.user, bud=self.alice, shoptalk="old")
|
||||
self.client.post(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
||||
{"shoptalk": "new"},
|
||||
)
|
||||
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
|
||||
self.assertEqual(bn.shoptalk, "new")
|
||||
|
||||
def test_post_caps_at_160_chars(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
self.client.post(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
||||
{"shoptalk": "a" * 300},
|
||||
)
|
||||
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
|
||||
self.assertLessEqual(len(bn.shoptalk), 160)
|
||||
|
||||
def test_get_returns_405(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
||||
{"shoptalk": "anon"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class DeleteBudViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@del.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@del.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_post_removes_bud_from_m2m(self):
|
||||
self.client.post(
|
||||
reverse("billboard:delete_bud", args=[self.alice.id])
|
||||
)
|
||||
self.assertNotIn(self.alice, list(self.user.buds.all()))
|
||||
|
||||
def test_post_redirects_to_my_buds(self):
|
||||
response = self.client.post(
|
||||
reverse("billboard:delete_bud", args=[self.alice.id])
|
||||
)
|
||||
self.assertRedirects(response, reverse("billboard:my_buds"))
|
||||
|
||||
def test_get_does_not_remove(self):
|
||||
self.client.get(reverse("billboard:delete_bud", args=[self.alice.id]))
|
||||
self.assertIn(self.alice, list(self.user.buds.all()))
|
||||
|
||||
|
||||
class MyBudsRowEnrichmentTest(TestCase):
|
||||
"""The my_buds page row now carries the data-tt-* attrs the tooltip
|
||||
portal reads on row-lock click, plus an anchor wrapping the handle
|
||||
that routes to the bud's landing page."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@row.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@row.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_row_carries_data_bud_id(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertContains(response, f'data-bud-id="{self.alice.id}"')
|
||||
|
||||
def test_row_carries_tt_title_description_email_attrs(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertContains(response, 'data-tt-title="@alice"')
|
||||
self.assertContains(response, 'data-tt-description="Earthman"')
|
||||
self.assertContains(response, 'data-tt-email="alice@row.io"')
|
||||
|
||||
def test_row_renders_at_handle_the_title(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
body = response.content.decode()
|
||||
self.assertIn("@alice", body)
|
||||
self.assertIn("the Earthman", body)
|
||||
|
||||
def test_username_wrapped_in_anchor_to_bud_page(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
body = response.content.decode()
|
||||
bud_page_url = reverse("billboard:bud_page", args=[self.alice.id])
|
||||
self.assertRegex(
|
||||
body,
|
||||
rf'<span class="bud-name"><a[^>]*href="{bud_page_url}"',
|
||||
)
|
||||
|
||||
def test_row_carries_shoptalk_when_set(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
BudshipNote.objects.create(
|
||||
user=self.user, bud=self.alice, shoptalk="dragonkin",
|
||||
)
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertContains(response, 'data-tt-shoptalk="dragonkin"')
|
||||
self.assertContains(response, "data-tt-milestone=")
|
||||
|
||||
def test_row_carries_empty_shoptalk_attr_when_never_edited(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertContains(response, 'data-tt-shoptalk=""')
|
||||
|
||||
def test_row_omits_milestone_when_no_note(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
body = response.content.decode()
|
||||
self.assertNotIn("data-tt-milestone=", body)
|
||||
|
||||
|
||||
class BudshipNoteModelTest(TestCase):
|
||||
"""`BudshipNote(user, bud, shoptalk, edited_at)` — per-relation note."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@m.io", username="me")
|
||||
self.bud = User.objects.create(email="b@m.io", username="b")
|
||||
|
||||
def test_unique_per_user_bud_pair(self):
|
||||
from django.db import IntegrityError
|
||||
from apps.billboard.models import BudshipNote
|
||||
BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="x")
|
||||
with self.assertRaises(IntegrityError):
|
||||
BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="y")
|
||||
|
||||
def test_edited_at_updates_on_save(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
bn = BudshipNote.objects.create(
|
||||
user=self.user, bud=self.bud, shoptalk="first",
|
||||
)
|
||||
first_ts = bn.edited_at
|
||||
bn.shoptalk = "second"
|
||||
bn.save()
|
||||
self.assertGreaterEqual(bn.edited_at, first_ts)
|
||||
|
||||
def test_shoptalk_max_length_160(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
f = BudshipNote._meta.get_field("shoptalk")
|
||||
self.assertEqual(f.max_length, 160)
|
||||
|
||||
@@ -23,6 +23,9 @@ urlpatterns = [
|
||||
path("my-buds/", views.my_buds, name="my_buds"),
|
||||
path("buds/add", views.add_bud, name="add_bud"),
|
||||
path("buds/search", views.search_buds, name="search_buds"),
|
||||
path("buds/<uuid:bud_id>/", views.bud_page, name="bud_page"),
|
||||
path("buds/<uuid:bud_id>/shoptalk", views.save_bud_shoptalk, name="save_bud_shoptalk"),
|
||||
path("buds/<uuid:bud_id>/delete", views.delete_bud, name="delete_bud"),
|
||||
path("my-sign/", views.my_sign, name="my_sign"),
|
||||
path("my-sign/save", views.save_sign, name="save_sign"),
|
||||
path("my-sign/clear", views.clear_sign, name="clear_sign"),
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.shortcuts import redirect, render
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
|
||||
from apps.billboard.forms import ExistingPostLineForm, LineForm
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
from apps.billboard.models import Brief, BudshipNote, Line, Post
|
||||
from apps.dashboard.views import _PALETTE_DEFS
|
||||
from apps.drama.models import GameEvent, Note, ScrollPosition
|
||||
from apps.epic.models import Room
|
||||
@@ -292,6 +292,7 @@ def my_sign(request):
|
||||
def save_sign(request):
|
||||
"""Persist the user's sign choice — POST { card_id, reversed }."""
|
||||
from apps.epic.models import TarotCard
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
if request.method != "POST":
|
||||
return redirect("billboard:my_sign")
|
||||
card_id = request.POST.get("card_id")
|
||||
@@ -300,9 +301,24 @@ def save_sign(request):
|
||||
card = TarotCard.objects.get(pk=card_id)
|
||||
except (TarotCard.DoesNotExist, ValueError, TypeError):
|
||||
return HttpResponseForbidden("invalid card_id")
|
||||
sig_changed = request.user.significator_id != card.pk
|
||||
request.user.significator = card
|
||||
request.user.significator_reversed = reversed_flag
|
||||
request.user.save(update_fields=["significator", "significator_reversed"])
|
||||
# Sig change RESETS (but does NOT delete) any active MySeaDraw so the
|
||||
# next /my-sea/ visit reads the NEW sig snapshot — while PRESERVING the
|
||||
# cooldown anchor (row's `created_at` gates `in_cooldown` per views.py
|
||||
# line 266) + paid-state fields (`deposit_token_id`, `paid_through_at`).
|
||||
# Deleting the row would re-open the FREE DRAW gate (loophole — user
|
||||
# could circumvent the 24h cooldown by re-picking a sig) AND forfeit
|
||||
# any paid-draw credit the user already committed. Reset clears just
|
||||
# the hand + sig snapshot, leaving cooldown + paid revenue intact.
|
||||
if sig_changed:
|
||||
MySeaDraw.objects.filter(user=request.user).update(
|
||||
hand=[],
|
||||
significator_id=card.pk,
|
||||
significator_reversed=reversed_flag,
|
||||
)
|
||||
return redirect("billboard:my_sign")
|
||||
|
||||
|
||||
@@ -318,6 +334,14 @@ def clear_sign(request):
|
||||
request.user.significator = None
|
||||
request.user.significator_reversed = False
|
||||
request.user.save(update_fields=["significator", "significator_reversed"])
|
||||
# MySeaDraw is INTENTIONALLY left alone on sig-clear. Without a sig the
|
||||
# user can't draw anyway (`my_sea_lock` returns 400 `no_significator`),
|
||||
# and /my-sea/ routes to its sign-gate Brief via `user_has_sig`. The
|
||||
# row's cooldown anchor + paid-state fields must survive a sig clear
|
||||
# so the user can't re-open the FREE DRAW gate or forfeit paid credit
|
||||
# by toggling sig-clear → sig-pick (user-reported loophole 2026-05-26).
|
||||
# When the user re-picks via save_sign, that view's reset path updates
|
||||
# the row's sig snapshot + clears the hand cleanly.
|
||||
return redirect("billboard:my_sign")
|
||||
|
||||
|
||||
@@ -372,11 +396,12 @@ def view_post(request, post_id):
|
||||
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
|
||||
return HttpResponseForbidden()
|
||||
|
||||
# Admin-Post (note-unlock thread) hard write-rejection — the per-Line
|
||||
# signal in billboard.models nukes any Line that bypasses this guard,
|
||||
# but at the view level we want a clean 403 so the FT/IT contract is
|
||||
# explicit and the client never sees a silent line vanish.
|
||||
if our_post.kind == Post.KIND_NOTE_UNLOCK and request.method == "POST":
|
||||
# System-author Post hard write-rejection (note unlock + tax ledger
|
||||
# threads) — the per-Line signal in billboard.models nukes any Line
|
||||
# that bypasses this guard, but at the view level we want a clean 403
|
||||
# so the FT/IT contract is explicit and the client never sees a silent
|
||||
# line vanish.
|
||||
if our_post.kind in (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER) and request.method == "POST":
|
||||
return HttpResponseForbidden()
|
||||
|
||||
form = ExistingPostLineForm(for_post=our_post)
|
||||
@@ -441,7 +466,7 @@ def my_posts(request, user_id):
|
||||
def delete_post(request, post_id):
|
||||
if request.method == "POST":
|
||||
post = Post.objects.get(id=post_id)
|
||||
if request.user == post.owner and post.kind != Post.KIND_NOTE_UNLOCK:
|
||||
if request.user == post.owner and post.kind not in (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER):
|
||||
post.delete()
|
||||
return redirect("billboard:my_posts", user_id=request.user.id)
|
||||
|
||||
@@ -450,7 +475,7 @@ def delete_post(request, post_id):
|
||||
def abandon_post(request, post_id):
|
||||
if request.method == "POST":
|
||||
post = Post.objects.get(id=post_id)
|
||||
if post.kind != Post.KIND_NOTE_UNLOCK:
|
||||
if post.kind not in (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER):
|
||||
post.shared_with.remove(request.user)
|
||||
return redirect("billboard:my_posts", user_id=request.user.id)
|
||||
|
||||
@@ -542,12 +567,104 @@ def share_post(request, post_id):
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_buds(request):
|
||||
"""My Buds page — enriched per-row w. shoptalk + milestone for the
|
||||
tooltip portal (bud landing page sprint 2026-05-27). Attaches
|
||||
`.shoptalk_text` + `.milestone_dt` to each bud User so the row
|
||||
template can render data-tt-* attrs without an extra template tag."""
|
||||
notes_by_bud = {
|
||||
bn.bud_id: bn
|
||||
for bn in BudshipNote.objects.filter(user=request.user)
|
||||
}
|
||||
buds = list(request.user.buds.all().select_related("active_title"))
|
||||
for bud in buds:
|
||||
bn = notes_by_bud.get(bud.id)
|
||||
bud.shoptalk_text = bn.shoptalk if bn else ""
|
||||
bud.milestone_dt = bn.edited_at if bn else None
|
||||
return render(request, "apps/billboard/my_buds.html", {
|
||||
"buds": request.user.buds.all(),
|
||||
"buds": buds,
|
||||
"page_class": "page-billbuds",
|
||||
})
|
||||
|
||||
|
||||
# ── Per-bud landing page ───────────────────────────────────────────────────
|
||||
# /billboard/buds/<bud_id>/ + shoptalk save + bud delete — see
|
||||
# [[project-bud-landing-page-sprint]]. Replaces the @mailman invite Line's
|
||||
# inline OK/BYE block w. a dedicated surface; bud.html is also the click
|
||||
# target of the My Buds row's `@<handle>` anchor.
|
||||
|
||||
@login_required(login_url="/")
|
||||
def bud_page(request, bud_id):
|
||||
"""Render the per-bud landing page. Auto-adds the bud on first visit
|
||||
(mirrors share_post's implicit-add posture) so following the @mailman
|
||||
post-attribution anchor from an invite Brief grows the buds graph
|
||||
without an explicit add step. Self-visits are no-op for the auto-add
|
||||
branch — users don't accumulate themselves as a bud.
|
||||
|
||||
Cascade context (`sea_btn_active` + `sea_first_draw_pending`) reuses
|
||||
the same template variables `_burger.html` already reads on my_sea +
|
||||
room — server-side conditional renders `glow-handoff` on the burger
|
||||
+ `.active` on the sea sub-btn. The flags fire iff a *live* SeaInvite
|
||||
exists from this bud to the viewer — non-terminal (PENDING or ACCEPTED)
|
||||
AND inside its 24h-from-proffer window OR within 24h of the viewer's
|
||||
last gate token deposit (user-spec 2026-05-29, `invitee_access_open`).
|
||||
Accepting the invite no longer darkens the btn; the cascade now stays
|
||||
lit across the whole window so the user can reach the bud's sea
|
||||
(`my_sea_visit` accepts a still-pending invite on GET)."""
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.gameboard.models import SeaInvite
|
||||
bud = get_object_or_404(User, id=bud_id)
|
||||
if bud != request.user and not request.user.buds.filter(id=bud.id).exists():
|
||||
request.user.buds.add(bud)
|
||||
bn = BudshipNote.objects.filter(user=request.user, bud=bud).first()
|
||||
live = (
|
||||
SeaInvite.objects
|
||||
.filter(
|
||||
owner=bud, invitee=request.user,
|
||||
status__in=[SeaInvite.PENDING, SeaInvite.ACCEPTED],
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if live is not None and not live.invitee_access_open:
|
||||
live = None
|
||||
return render(request, "apps/billboard/bud.html", {
|
||||
"bud": bud,
|
||||
"shoptalk_text": bn.shoptalk if bn else "",
|
||||
"milestone_dt": bn.edited_at if bn else None,
|
||||
"pending_invite": live,
|
||||
"sea_btn_active": live is not None,
|
||||
"sea_first_draw_pending": live is not None,
|
||||
"page_class": "page-billbud",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def save_bud_shoptalk(request, bud_id):
|
||||
"""POST-only — upsert a BudshipNote w. up to 160 chars of shoptalk."""
|
||||
from django.http import HttpResponseNotAllowed
|
||||
from django.shortcuts import get_object_or_404
|
||||
if request.method != "POST":
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
bud = get_object_or_404(User, id=bud_id)
|
||||
text = (request.POST.get("shoptalk") or "")[:160]
|
||||
BudshipNote.objects.update_or_create(
|
||||
user=request.user, bud=bud,
|
||||
defaults={"shoptalk": text},
|
||||
)
|
||||
return JsonResponse({"ok": True, "shoptalk": text})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def delete_bud(request, bud_id):
|
||||
"""POST-only — remove the bud from the user's M2M; redirect to my_buds.
|
||||
GET is a silent no-op redirect (no membership change)."""
|
||||
from django.shortcuts import get_object_or_404
|
||||
if request.method == "POST":
|
||||
bud = get_object_or_404(User, id=bud_id)
|
||||
request.user.buds.remove(bud)
|
||||
return redirect("billboard:my_buds")
|
||||
|
||||
|
||||
def _resolve_recipient(raw):
|
||||
"""Resolve a free-form recipient (email OR username) to a User, or None.
|
||||
Email match takes precedence — if the input contains '@' we don't even
|
||||
@@ -583,11 +700,16 @@ def add_bud(request):
|
||||
already_present = candidate in request.user.buds.all()
|
||||
if not already_present:
|
||||
request.user.buds.add(candidate)
|
||||
from apps.lyric.templatetags.lyric_extras import at_handle
|
||||
display = candidate.username or candidate.email
|
||||
bud = {
|
||||
"id": str(candidate.id),
|
||||
"username": display,
|
||||
"email": candidate.email,
|
||||
# at_handle + title feed the async row's data-tt-* attrs so its
|
||||
# tooltip matches the server-rendered rows (regression 2026-05-29).
|
||||
"at_handle": at_handle(candidate),
|
||||
"title": candidate.active_title_display,
|
||||
}
|
||||
recipient_display = display
|
||||
recipient_user_id = str(candidate.id)
|
||||
|
||||
@@ -64,13 +64,42 @@
|
||||
if (!tooltip) return;
|
||||
var rect = el.getBoundingClientRect();
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||
tooltip.style.left = rect.left + 'px';
|
||||
// Show first so offsetWidth/offsetHeight measure real layout,
|
||||
// then clamp both axes so the tooltip never bleeds past the
|
||||
// viewport. Same shape as sky-wheel.js + wallet.js: 1rem inset
|
||||
// margin on every edge.
|
||||
tooltip.style.display = 'block';
|
||||
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
var ttW = tooltip.offsetWidth;
|
||||
var ttH = tooltip.offsetHeight;
|
||||
|
||||
// Horizontal clamp — left edge stays within [rem, viewport-ttW-rem].
|
||||
var minLeft = rem;
|
||||
var maxLeft = window.innerWidth - ttW - rem;
|
||||
var clampedLeft = Math.max(minLeft, Math.min(rect.left, maxLeft));
|
||||
tooltip.style.left = clampedLeft + 'px';
|
||||
|
||||
// Vertical: prefer ABOVE the element; flip BELOW when the
|
||||
// tooltip is too tall to fit above (e.g. in landscape where
|
||||
// the kit bag dialog runs along the top of the right sidebar
|
||||
// + tokens row anchors near the top of the viewport).
|
||||
var spaceAbove = rect.top - rem;
|
||||
if (ttH <= spaceAbove) {
|
||||
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||
tooltip.style.top = '';
|
||||
} else {
|
||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||
tooltip.style.bottom = '';
|
||||
}
|
||||
});
|
||||
el.addEventListener('mouseleave', function () {
|
||||
var tooltip = el.querySelector('.tt');
|
||||
if (tooltip) tooltip.style.display = '';
|
||||
if (tooltip) {
|
||||
tooltip.style.display = '';
|
||||
// Reset positional props so the next show measures fresh.
|
||||
tooltip.style.top = '';
|
||||
tooltip.style.bottom = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,22 @@ const Brief = (() => {
|
||||
'<a href="' + _esc(brief.post_url) + '" class="btn btn-info note-banner__fyi">FYI</a>';
|
||||
|
||||
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
|
||||
// Persistent-NVM kinds (TAX_LEDGER FREE/PAID DRAW per user-spec
|
||||
// 2026-05-26) carry a `dismiss_url` — POST it so the dismissal
|
||||
// anchor is stamped server-side + the Brief stays suppressed on
|
||||
// future page loads until the cycle resets. Fire-and-forget; the
|
||||
// banner removal is unconditional so the user gets immediate
|
||||
// feedback regardless of network state.
|
||||
if (brief.dismiss_url) {
|
||||
var csrfMatch = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
|
||||
fetch(brief.dismiss_url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfMatch ? decodeURIComponent(csrfMatch[1]) : '',
|
||||
},
|
||||
});
|
||||
}
|
||||
banner.remove();
|
||||
});
|
||||
|
||||
|
||||
@@ -76,6 +76,38 @@ const WalletShop = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
async function _doClaimFree(slug) {
|
||||
// Free decks (RWS / Fiorentine) — $0, no Stripe, no guard portal.
|
||||
// One click POSTs the claim; the server adds the DeckVariant to
|
||||
// `unlocked_decks` (→ Game Kit on /gameboard/).
|
||||
const res = await fetch('/dashboard/wallet/shop/claim', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': _getCsrf(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'deck_slug=' + encodeURIComponent(slug),
|
||||
});
|
||||
if (!res.ok) {
|
||||
alert('Could not claim deck (' + res.status + ').');
|
||||
return;
|
||||
}
|
||||
// Reload so the tile re-renders w. the 'Already owned' pill — server
|
||||
// is the source of truth (same posture as the BUY flow's reload).
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function _onFreeClick(e) {
|
||||
const btn = e.target.closest('.tt-free-btn');
|
||||
if (!btn) return;
|
||||
if (btn.classList.contains('btn-disabled')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const slug = btn.dataset.deckSlug;
|
||||
if (!slug) return;
|
||||
_doClaimFree(slug);
|
||||
}
|
||||
|
||||
function _onBuyClick(e) {
|
||||
const btn = e.target.closest('.tt-buy-btn');
|
||||
if (!btn) return;
|
||||
@@ -139,11 +171,21 @@ const WalletShop = (function () {
|
||||
// (c) cloned mini portal (`#id_mini_tooltip_portal`) — production
|
||||
// path post-microtooltip refactor; the BUY btn lives in
|
||||
// `.tt-micro` which clones into the mini portal on hover.
|
||||
// Free-deck claim (`.tt-free-btn`) rides the SAME delegated roots as
|
||||
// the BUY flow — the FREE ITEM btn lives in `.tt-micro` (clones into
|
||||
// the mini portal on hover, same as BUY ITEM).
|
||||
shopRoot.addEventListener('click', _onBuyClick);
|
||||
shopRoot.addEventListener('click', _onFreeClick);
|
||||
const portal = document.getElementById('id_tooltip_portal');
|
||||
if (portal) portal.addEventListener('click', _onBuyClick);
|
||||
if (portal) {
|
||||
portal.addEventListener('click', _onBuyClick);
|
||||
portal.addEventListener('click', _onFreeClick);
|
||||
}
|
||||
const miniPortal = document.getElementById('id_mini_tooltip_portal');
|
||||
if (miniPortal) miniPortal.addEventListener('click', _onBuyClick);
|
||||
if (miniPortal) {
|
||||
miniPortal.addEventListener('click', _onBuyClick);
|
||||
miniPortal.addEventListener('click', _onFreeClick);
|
||||
}
|
||||
shopRoot.dataset.shopWired = '1';
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,143 @@ from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from apps.epic.models import DeckVariant
|
||||
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User
|
||||
|
||||
|
||||
def _seed_free_decks():
|
||||
"""Two free-in-shop decks (RWS + Fiorentine) + one paid deck (Earthman,
|
||||
free_in_shop=False) so the claim endpoint's `free_in_shop` guard is
|
||||
exercised. Mirrors the seed-migration row shapes (TestCase rolls back
|
||||
the data migrations)."""
|
||||
rws, _ = DeckVariant.objects.update_or_create(
|
||||
slug="tarot-rider-waite-smith",
|
||||
defaults={
|
||||
"name": "Tarot (Rider-Waite-Smith)", "card_count": 78,
|
||||
"family": "english", "has_card_images": False,
|
||||
"is_polarized": False, "free_in_shop": True,
|
||||
},
|
||||
)
|
||||
fiorentine, _ = DeckVariant.objects.update_or_create(
|
||||
slug="minchiate-fiorentine-1860-1890",
|
||||
defaults={
|
||||
"name": "Minchiate Fiorentine (1860–1890)", "card_count": 97,
|
||||
"family": "italian", "has_card_images": True,
|
||||
"is_polarized": False, "free_in_shop": True,
|
||||
"description": "97-card Minchiate Fiorentine deck.",
|
||||
},
|
||||
)
|
||||
earthman, _ = DeckVariant.objects.update_or_create(
|
||||
slug="earthman",
|
||||
defaults={
|
||||
"name": "Earthman", "card_count": 106, "is_default": True,
|
||||
"is_polarized": True, "has_card_images": False,
|
||||
"free_in_shop": False,
|
||||
},
|
||||
)
|
||||
return rws, fiorentine, earthman
|
||||
|
||||
|
||||
class ShopClaimFreeViewTest(TestCase):
|
||||
"""`POST /dashboard/wallet/shop/claim` (`shop_claim_free`) — adds a free-
|
||||
in-shop DeckVariant to the user's `unlocked_decks` so it appears in the
|
||||
Game Kit applet. No Stripe, no Purchase row, idempotent."""
|
||||
|
||||
def setUp(self):
|
||||
self.rws, self.fiorentine, self.earthman = _seed_free_decks()
|
||||
self.user = User.objects.create(email="decker@test.io")
|
||||
# Start from a known unlock set — strip the signal's auto-grant so
|
||||
# the claim is the only thing that adds a deck.
|
||||
self.user.unlocked_decks.clear()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/claim",
|
||||
{"deck_slug": "tarot-rider-waite-smith"},
|
||||
)
|
||||
self.assertRedirects(
|
||||
response, "/?next=/dashboard/wallet/shop/claim",
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
def test_claim_adds_deck_to_unlocked(self):
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/claim",
|
||||
{"deck_slug": "tarot-rider-waite-smith"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(
|
||||
self.user.unlocked_decks.filter(pk=self.rws.pk).exists()
|
||||
)
|
||||
|
||||
def test_claim_returns_owned_json(self):
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/claim",
|
||||
{"deck_slug": "tarot-rider-waite-smith"},
|
||||
)
|
||||
body = response.json()
|
||||
self.assertTrue(body["owned"])
|
||||
self.assertEqual(body["deck_name"], "Tarot (Rider-Waite-Smith)")
|
||||
|
||||
def test_claim_is_idempotent(self):
|
||||
for _ in range(2):
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/claim",
|
||||
{"deck_slug": "tarot-rider-waite-smith"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
self.user.unlocked_decks.filter(pk=self.rws.pk).count(), 1
|
||||
)
|
||||
|
||||
def test_unknown_slug_returns_404(self):
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/claim", {"deck_slug": "no-such-deck"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_non_free_deck_cannot_be_claimed(self):
|
||||
"""Guard: a deck with free_in_shop=False (eg Earthman, or any paid
|
||||
deck) is NOT claimable via this $0 endpoint → 404, no unlock."""
|
||||
response = self.client.post(
|
||||
"/dashboard/wallet/shop/claim", {"deck_slug": "earthman"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertFalse(
|
||||
self.user.unlocked_decks.filter(pk=self.earthman.pk).exists()
|
||||
)
|
||||
|
||||
|
||||
class WalletFreeDecksContextTest(TestCase):
|
||||
"""The wallet page exposes `free_decks` — the free-in-shop DeckVariants
|
||||
decorated w. a per-user `.owned` flag the Shop applet uses to render
|
||||
FREE ITEM vs 'Already owned'."""
|
||||
|
||||
def setUp(self):
|
||||
self.rws, self.fiorentine, self.earthman = _seed_free_decks()
|
||||
self.user = User.objects.create(email="ctx@test.io")
|
||||
self.user.unlocked_decks.clear()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_free_decks_in_context_unowned_initially(self):
|
||||
response = self.client.get("/dashboard/wallet/")
|
||||
free = {d.slug: d for d in response.context["free_decks"]}
|
||||
# Both free decks present; the paid Earthman is NOT listed here.
|
||||
self.assertIn("tarot-rider-waite-smith", free)
|
||||
self.assertIn("minchiate-fiorentine-1860-1890", free)
|
||||
self.assertNotIn("earthman", free)
|
||||
self.assertFalse(free["tarot-rider-waite-smith"].owned)
|
||||
|
||||
def test_free_deck_marked_owned_after_claim(self):
|
||||
self.user.unlocked_decks.add(self.rws)
|
||||
response = self.client.get("/dashboard/wallet/")
|
||||
free = {d.slug: d for d in response.context["free_decks"]}
|
||||
self.assertTrue(free["tarot-rider-waite-smith"].owned)
|
||||
self.assertFalse(free["minchiate-fiorentine-1860-1890"].owned)
|
||||
|
||||
|
||||
def _seed_starting_items():
|
||||
"""Mirror the seed-migration row shape so each TestCase starts w. a
|
||||
known catalog (TestCase rolls back the data migration, so the rows
|
||||
|
||||
@@ -502,6 +502,66 @@ class KitBagViewTest(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/?next=", response["Location"])
|
||||
|
||||
def test_deck_section_renders_card_stack_svg_not_fa_id_badge(self):
|
||||
"""Sprint A.4 follow-up — kit-bag Deck section uses the new card-deck
|
||||
SVG icon partial, replacing the prior `<i class="fa-regular fa-id-badge">`
|
||||
for both the equipped-deck branch and the placeholder branch."""
|
||||
import lxml.html
|
||||
response = self.client.get(self.url)
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
# Auto-equipped Earthman appears in the Deck section.
|
||||
deck = parsed.cssselect(".kit-bag-deck")[0]
|
||||
[_] = deck.cssselect("svg.deck-stack-icon")
|
||||
self.assertEqual(
|
||||
len(deck.cssselect("i.fa-id-badge")), 0,
|
||||
"fa-regular fa-id-badge must be gone from kit-bag-deck",
|
||||
)
|
||||
|
||||
def test_no_deck_equipped_renders_placeholder_card_stack_icon(self):
|
||||
"""Sprint A.4 follow-up — when the user has no equipped_deck, the
|
||||
kit-bag Deck section renders the same .deck-stack-icon SVG inside a
|
||||
.kit-bag-placeholder wrapper (no fan-out trigger; CSS dims it to
|
||||
--quaUser at 0.3 alpha to match the empty dice slot)."""
|
||||
import lxml.html
|
||||
# Clear the auto-equipped Earthman to land in the placeholder branch.
|
||||
self.user.equipped_deck = None
|
||||
self.user.save(update_fields=["equipped_deck"])
|
||||
response = self.client.get(self.url)
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
# equipped-deck branch is skipped → placeholder branch fires.
|
||||
self.assertEqual(
|
||||
len(parsed.cssselect(".kit-bag-deck")), 0,
|
||||
"No equipped deck → no .kit-bag-deck wrapper",
|
||||
)
|
||||
placeholders = parsed.cssselect(".kit-bag-section .kit-bag-placeholder")
|
||||
# First placeholder is the Deck slot (Dice + Trinket slots also use
|
||||
# .kit-bag-placeholder when empty); assert at least one carries the
|
||||
# card-stack SVG.
|
||||
deck_placeholder = placeholders[0]
|
||||
[_] = deck_placeholder.cssselect("svg.deck-stack-icon")
|
||||
# Old fa-id-badge must be gone.
|
||||
self.assertEqual(
|
||||
len(deck_placeholder.cssselect("i.fa-id-badge")), 0,
|
||||
"fa-regular fa-id-badge must be gone from kit-bag-placeholder",
|
||||
)
|
||||
|
||||
def test_polarized_equipped_deck_tooltip_has_x2_decoration(self):
|
||||
"""Same (x2) decoration as the gameboard applet — polarized decks
|
||||
signal segment-doubling in the tooltip card-count line via --terUser
|
||||
`.tt-x2` span. Element-presence assertion (the literal `×2` content
|
||||
is exercised by the parent template; this test only locks the conditional
|
||||
render on `is_polarized`)."""
|
||||
import lxml.html
|
||||
response = self.client.get(self.url)
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
deck = parsed.cssselect(".kit-bag-deck")[0]
|
||||
# Earthman is auto-equipped + is_polarized=True per A.0 migration.
|
||||
self.assertEqual(
|
||||
len(deck.cssselect(".tt-x2")), 1,
|
||||
"Polarized equipped deck must render .tt-x2 decoration in kit-bag tooltip",
|
||||
)
|
||||
|
||||
|
||||
class ToggleDashAppletsViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="disco@test.io")
|
||||
|
||||
@@ -12,6 +12,7 @@ urlpatterns = [
|
||||
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
|
||||
path('wallet/shop/buy', views.shop_buy, name='shop_buy'),
|
||||
path('wallet/shop/confirm', views.shop_confirm, name='shop_confirm'),
|
||||
path('wallet/shop/claim', views.shop_claim_free, name='shop_claim_free'),
|
||||
path('kit-bag/', views.kit_bag, name='kit_bag'),
|
||||
path('sky/', views.sky_view, name='sky'),
|
||||
path('sky/preview', views.sky_preview, name='sky_preview'),
|
||||
|
||||
@@ -17,6 +17,7 @@ from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from apps.drama.models import Note
|
||||
from apps.epic.models import DeckVariant
|
||||
from apps.epic.utils import _compute_distinctions
|
||||
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User, Wallet, is_reserved_username
|
||||
|
||||
@@ -93,6 +94,27 @@ def home_page(request):
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
context["applets"] = applet_context(request.user, "dashboard")
|
||||
# My Wallet applet — show all trinkets (PASS/BAND/CARTE/COIN) + stacked
|
||||
# Free + Tithe tokens (badge count) + Writs placeholder. Trinkets COPY
|
||||
# the Game Kit applet's tooltip + DON/DOFF wiring so the user can equip
|
||||
# from either surface; Free + Tithe tokens MOVED off the Game Kit applet
|
||||
# since they aren't equippable (user spec 2026-05-25 PM).
|
||||
user = request.user
|
||||
free_tokens = list(user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).order_by("expires_at"))
|
||||
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
|
||||
context.update({
|
||||
"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,
|
||||
"band": user.tokens.filter(token_type=Token.BAND).first(),
|
||||
"carte": user.tokens.filter(token_type=Token.CARTE).first(),
|
||||
"free_tokens": free_tokens,
|
||||
"tithe_tokens": tithe_tokens,
|
||||
"free_count": len(free_tokens),
|
||||
"tithe_count": len(tithe_tokens),
|
||||
"equipped_trinket_id": user.equipped_trinket_id,
|
||||
})
|
||||
return render(request, "apps/dashboard/home.html", context)
|
||||
|
||||
|
||||
@@ -167,6 +189,18 @@ def _shop_items_for(user):
|
||||
return items
|
||||
|
||||
|
||||
def _free_decks_for(user):
|
||||
"""Decorate the free-in-shop DeckVariant catalog (RWS + Fiorentine) w. a
|
||||
per-user `.owned` flag so the Shop applet renders a FREE ITEM btn for
|
||||
decks the user hasn't claimed + an 'Already owned' pill for ones already
|
||||
in `unlocked_decks`. Ordered by name for a stable render."""
|
||||
unlocked_ids = set(user.unlocked_decks.values_list("pk", flat=True))
|
||||
decks = list(DeckVariant.objects.filter(free_in_shop=True).order_by("name"))
|
||||
for deck in decks:
|
||||
deck.owned = deck.pk in unlocked_ids
|
||||
return decks
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@ensure_csrf_cookie
|
||||
def wallet(request):
|
||||
@@ -183,6 +217,7 @@ def wallet(request):
|
||||
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
||||
"shop_items": shop_items,
|
||||
"free_decks": _free_decks_for(request.user),
|
||||
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
|
||||
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
|
||||
"free_tokens": free_tokens,
|
||||
@@ -225,6 +260,7 @@ def toggle_wallet_applets(request):
|
||||
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
||||
"shop_items": _shop_items_for(request.user),
|
||||
"free_decks": _free_decks_for(request.user),
|
||||
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
|
||||
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
|
||||
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
||||
@@ -361,6 +397,26 @@ def shop_confirm(request):
|
||||
return JsonResponse({"status": purchase.status})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def shop_claim_free(request):
|
||||
"""Claim a free ($0) deck from the Shop applet — adds the DeckVariant to
|
||||
the user's `unlocked_decks` so it renders in the Game Kit applet. No
|
||||
Stripe, no Purchase row (free decks aren't paid goods).
|
||||
|
||||
Body: `deck_slug` (form-encoded).
|
||||
Returns: 200 `{owned: true, deck_name}` on claim or re-claim (M2M add is
|
||||
idempotent); 404 if the slug isn't a `free_in_shop` deck — the
|
||||
`free_in_shop` filter is the guard that stops this $0 endpoint
|
||||
from unlocking paid/auto-granted decks (eg Earthman).
|
||||
"""
|
||||
slug = request.POST.get("deck_slug", "")
|
||||
deck = DeckVariant.objects.filter(slug=slug, free_in_shop=True).first()
|
||||
if deck is None:
|
||||
return HttpResponse(status=404)
|
||||
request.user.unlocked_decks.add(deck)
|
||||
return JsonResponse({"owned": True, "deck_name": deck.name})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def stripe_webhook(request):
|
||||
"""Stripe webhook listener. Verifies signature against
|
||||
|
||||
@@ -71,10 +71,23 @@ class GameEvent(models.Model):
|
||||
return f"deposits a {token} for slot {slot} (expires in {days} days)."
|
||||
if self.verb == self.SLOT_RESERVED:
|
||||
return "reserves a seat"
|
||||
if self.verb == self.SLOT_RETURNED:
|
||||
return "withdraws from the gate"
|
||||
if self.verb == self.SLOT_RELEASED:
|
||||
return f"releases slot {d.get('slot_number', '?')}"
|
||||
if self.verb in (self.SLOT_RETURNED, self.SLOT_RELEASED):
|
||||
# Symmetric counterpart to SLOT_FILLED's "deposits a {token} for
|
||||
# slot {#} …" — same shape so the redact-pair (strikethrough on
|
||||
# the prior deposit, new withdraw entry below it) reads as a
|
||||
# mirror image in the room scroll. User-spec 2026-05-26 sprint
|
||||
# A.8. SLOT_RETURNED + SLOT_RELEASED both render w. this prose;
|
||||
# the verb distinction stays in the data layer (different paths
|
||||
# trigger them — full token return vs. per-slot CARTE release).
|
||||
_token_names = {
|
||||
"coin": "Coin-on-a-String", "Free": "Free Token",
|
||||
"tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche",
|
||||
}
|
||||
code = d.get("token_type", "token")
|
||||
token = d.get("token_display") or _token_names.get(code, code)
|
||||
slot = d.get("slot_number", "?")
|
||||
_, _, poss = _actor_pronouns(self.actor)
|
||||
return f"withdraws {poss} {token} from slot {slot}."
|
||||
if self.verb == self.ROOM_CREATED:
|
||||
# First scroll log on a fresh room — system-authored greeting
|
||||
# (actor=None upstream). Format intentionally drops the actor
|
||||
|
||||
@@ -181,13 +181,33 @@ class GameEventModelTest(TestCase):
|
||||
event = record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
|
||||
self.assertEqual(event.to_prose(), "reserves a seat")
|
||||
|
||||
def test_slot_returned_prose(self):
|
||||
event = record(self.room, GameEvent.SLOT_RETURNED, actor=self.user)
|
||||
self.assertEqual(event.to_prose(), "withdraws from the gate")
|
||||
def test_slot_returned_prose_includes_token_and_slot(self):
|
||||
# Sprint A.8 (2026-05-26): SLOT_RETURNED now mirrors SLOT_FILLED's
|
||||
# shape — symmetric redact-pair on the scroll. Data fields match
|
||||
# the deposit event so the prose reads as a clean mirror.
|
||||
event = record(
|
||||
self.room, GameEvent.SLOT_RETURNED, actor=self.user,
|
||||
slot_number=2, token_type="coin",
|
||||
token_display="Coin-on-a-String",
|
||||
)
|
||||
prose = event.to_prose()
|
||||
self.assertIn("withdraws", prose)
|
||||
self.assertIn("Coin-on-a-String", prose)
|
||||
self.assertIn("slot 2", prose)
|
||||
|
||||
def test_slot_released_prose_includes_slot_number(self):
|
||||
event = record(self.room, GameEvent.SLOT_RELEASED, actor=self.user, slot_number=3)
|
||||
self.assertIn("slot 3", event.to_prose())
|
||||
def test_slot_released_prose_uses_unified_withdraw_shape(self):
|
||||
# SLOT_RELEASED (per-slot CARTE release) shares the unified
|
||||
# withdraw prose w. SLOT_RETURNED — same visual mirror of the
|
||||
# deposit. The verb distinction stays in the data layer.
|
||||
event = record(
|
||||
self.room, GameEvent.SLOT_RELEASED, actor=self.user,
|
||||
slot_number=3, token_type="carte",
|
||||
token_display="Carte Blanche",
|
||||
)
|
||||
prose = event.to_prose()
|
||||
self.assertIn("withdraws", prose)
|
||||
self.assertIn("Carte Blanche", prose)
|
||||
self.assertIn("slot 3", prose)
|
||||
|
||||
def test_invite_sent_prose(self):
|
||||
event = record(self.room, GameEvent.INVITE_SENT, actor=self.user)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Auto-BYE gamers whose room seat token cost lapsed past the renewal grace.
|
||||
|
||||
The lazy `_expire_lapsed_seats` inside the room view / gatekeeper /
|
||||
gate-view already frees lapsed seats on every access; this command is the
|
||||
cron backstop for rooms nobody reopens — a mid-game table left idle past
|
||||
the grace window (filled_at + 2*renewal_period) would otherwise keep its
|
||||
stuck seats forever. Mirrors `delete_stale_my_sea_draws`. No flags;
|
||||
idempotent.
|
||||
|
||||
Usage:
|
||||
python manage.py expire_lapsed_room_seats
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.epic.models import GateSlot, Room
|
||||
from apps.epic.views import _expire_lapsed_seats
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Free room seats whose token cost lapsed past the renewal grace."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
rooms = Room.objects.filter(
|
||||
gate_slots__status=GateSlot.FILLED,
|
||||
gate_slots__filled_at__isnull=False,
|
||||
).distinct()
|
||||
freed = 0
|
||||
for room in rooms:
|
||||
before = room.gate_slots.filter(status=GateSlot.FILLED).count()
|
||||
_expire_lapsed_seats(room)
|
||||
if room.gate_slots.filter(status=GateSlot.FILLED).count() < before:
|
||||
freed += 1
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Freed lapsed seats in {freed} room{'s' if freed != 1 else ''}."
|
||||
))
|
||||
18
src/apps/epic/migrations/0009_reversal_drops_qualifier.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-05-23 18:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0008_blades_reversal_fickle'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tarotcard',
|
||||
name='reversal_drops_qualifier',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Cards 16-18 (Realms — Disco Inferno / Torre Terrestre / Fantasia Celestia)
|
||||
have a reversal NAME swap (`reversal_qualifier` field carrying "Shame" /
|
||||
"Guilt" / "Anxiety") but per user-spec 2026-05-23 the reversal face renders
|
||||
the name ALONE, with NO polarity qualifier appended. Set
|
||||
`reversal_drops_qualifier=True` so `TarotCard.applet_face()` knows to drop
|
||||
the polarity qualifier on the reversal face. See [[feedback-reversal-
|
||||
qualifier-dual-role]] for the broader Pattern B vs Pattern B' distinction.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
REVERSAL_DROPS_QUALIFIER_NUMBERS = [16, 17, 18]
|
||||
|
||||
|
||||
def set_flag(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
TarotCard.objects.filter(
|
||||
arcana="MAJOR", number__in=REVERSAL_DROPS_QUALIFIER_NUMBERS,
|
||||
).update(reversal_drops_qualifier=True)
|
||||
|
||||
|
||||
def clear_flag(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
TarotCard.objects.filter(
|
||||
arcana="MAJOR", number__in=REVERSAL_DROPS_QUALIFIER_NUMBERS,
|
||||
).update(reversal_drops_qualifier=False)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0009_reversal_drops_qualifier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_flag, reverse_code=clear_flag),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0 on 2026-05-25 03:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0010_set_reversal_drops_qualifier_realms'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='deckvariant',
|
||||
name='family',
|
||||
field=models.CharField(choices=[('earthman', 'Earthman'), ('italian', 'Italian / Minchiate'), ('english', 'English Tarot'), ('playing', 'Playing card')], default='earthman', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='deckvariant',
|
||||
name='has_card_images',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='deckvariant',
|
||||
name='is_polarized',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='suit',
|
||||
field=models.CharField(blank=True, choices=[('BRANDS', 'Brands'), ('CROWNS', 'Crowns'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
70
src/apps/epic/migrations/0012_rws_rename_and_suit_revocab.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Sprint A.0 data migration.
|
||||
|
||||
The existing `fiorentine-minchiate` DeckVariant is actually 78-card Rider-Waite-Smith
|
||||
Tarot (22 majors numbered 0-21 with RWS names + 56 minors in WANDS/CUPS/SWORDS/PENTACLES).
|
||||
Rename to its true identity, set the new schema fields (family / has_card_images /
|
||||
is_polarized), and revocab card suits to the canonical Earthman vocabulary that
|
||||
SUIT_CHOICES now requires. Earthman gets its new-field values set too. FKs remain
|
||||
intact since slug-only changes don't break referential integrity.
|
||||
|
||||
The actual Minchiate Fiorentine deck (97 cards, 1860-1890 publication, image set
|
||||
already imported per [[reference-card-image-naming-convention]]) is seeded in
|
||||
Sprint A.1's follow-up migration.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
SUIT_REVOCAB = {
|
||||
"WANDS": "BRANDS",
|
||||
"CUPS": "GRAILS",
|
||||
"SWORDS": "BLADES",
|
||||
"PENTACLES": "CROWNS",
|
||||
}
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
|
||||
# Earthman: set new-field values explicitly (default=True for has_card_images
|
||||
# would have left it True after the schema migration — wrong for Earthman).
|
||||
DeckVariant.objects.filter(slug="earthman").update(
|
||||
family="earthman", has_card_images=False, is_polarized=True,
|
||||
)
|
||||
|
||||
# fiorentine-minchiate → RWS Tarot rename + new-field values.
|
||||
rws = DeckVariant.objects.filter(slug="fiorentine-minchiate").first()
|
||||
if rws:
|
||||
rws.slug = "tarot-rider-waite-smith"
|
||||
rws.name = "Tarot (Rider-Waite-Smith)"
|
||||
rws.family = "english"
|
||||
rws.has_card_images = False
|
||||
rws.is_polarized = False
|
||||
rws.save()
|
||||
# Revocab the 56 minor cards' suits to canonical Earthman vocab.
|
||||
for old_suit, new_suit in SUIT_REVOCAB.items():
|
||||
TarotCard.objects.filter(deck_variant=rws, suit=old_suit).update(
|
||||
suit=new_suit,
|
||||
)
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
|
||||
rws = DeckVariant.objects.filter(slug="tarot-rider-waite-smith").first()
|
||||
if rws:
|
||||
for new_suit, old_suit in {v: k for k, v in SUIT_REVOCAB.items()}.items():
|
||||
TarotCard.objects.filter(deck_variant=rws, suit=new_suit).update(
|
||||
suit=old_suit,
|
||||
)
|
||||
rws.slug = "fiorentine-minchiate"
|
||||
rws.name = "Fiorentine Minchiate"
|
||||
rws.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epic", "0011_deckvariant_family_deckvariant_has_card_images_and_more"),
|
||||
]
|
||||
operations = [migrations.RunPython(forward, backward)]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Sprint A.1 — seed the Minchiate Fiorentine (1860-1890) deck.
|
||||
|
||||
97 cards: 40 numbered trumps + Il Matto (rank 0, unnumbered Fool) + 56 minors
|
||||
(4 suits × 14 cards: pip 1-10, page 11, knight 12, queen 13, king 14).
|
||||
|
||||
Names are stored in Italian-display form (Italian for trumps; English-rank +
|
||||
Italian-suit hybrid for minors — "Page of Batons", "King of Coins"). The
|
||||
canonical SUIT enum stays Earthman vocab (BRANDS/GRAILS/BLADES/CROWNS) per the
|
||||
2026-05-25 lock; Sprint A.2's `display_suit_name` property handles the
|
||||
canonical→display translation.
|
||||
|
||||
Correspondences cite the RWS Tarot equivalent where one exists (16 trumps map
|
||||
cleanly; the 5 popes, 4 theological+cardinal virtues, 4 elements, and 12
|
||||
zodiac trumps don't have RWS parallels — left blank).
|
||||
|
||||
Keywords intentionally left empty for now; admin form (Sprint B) will enrich.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
TRUMPS = [
|
||||
# (number, name, slug, correspondence)
|
||||
(0, "Il Matto", "il-matto", "The Fool"),
|
||||
(1, "Papa Uno", "papa-uno", ""),
|
||||
(2, "Papa Due", "papa-due", ""),
|
||||
(3, "Papa Tre", "papa-tre", ""),
|
||||
(4, "Papa Quattro", "papa-quattro", ""),
|
||||
(5, "Papa Cinque", "papa-cinque", ""),
|
||||
(6, "La Temperanza", "la-temperanza", "Temperance"),
|
||||
(7, "La Forza", "la-forza", "Strength"),
|
||||
(8, "La Giustizia", "la-giustizia", "Justice"),
|
||||
(9, "La Ruota della Fortuna", "la-ruota-della-fortuna", "Wheel of Fortune"),
|
||||
(10, "Il Carro", "il-carro", "The Chariot"),
|
||||
(11, "Il Gobbo", "il-gobbo", "The Hermit"),
|
||||
(12, "L'Impiccato", "l-impiccato", "The Hanged Man"),
|
||||
(13, "La Morte", "la-morte", "Death"),
|
||||
(14, "Il Diavolo", "il-diavolo", "The Devil"),
|
||||
(15, "La Casa del Diavolo", "la-casa-del-diavolo", "The Tower"),
|
||||
(16, "La Speranza", "la-speranza", ""), # Hope — theological virtue
|
||||
(17, "La Prudenza", "la-prudenza", ""), # Prudence — cardinal virtue
|
||||
(18, "La Fede", "la-fede", ""), # Faith
|
||||
(19, "La Carita", "la-carita", ""), # Charity
|
||||
(20, "Il Fuoco", "il-fuoco", ""), # Fire — element
|
||||
(21, "L'Acqua", "l-acqua", ""), # Water
|
||||
(22, "La Terra", "la-terra", ""), # Earth
|
||||
(23, "L'Aria", "l-aria", ""), # Air
|
||||
(24, "La Bilancia", "la-bilancia", ""), # Libra — zodiac
|
||||
(25, "La Vergine", "la-vergine", ""), # Virgo
|
||||
(26, "Il Scorpione", "il-scorpione", ""), # Scorpio
|
||||
(27, "L'Ariete", "l-ariete", ""), # Aries
|
||||
(28, "Il Capricorno", "il-capricorno", ""), # Capricorn
|
||||
(29, "Il Sagittario", "il-sagittario", ""), # Sagittarius
|
||||
(30, "Il Cancro", "il-cancro", ""), # Cancer
|
||||
(31, "I Pesci", "i-pesci", ""), # Pisces
|
||||
(32, "L'Acquario", "l-acquario", ""), # Aquarius
|
||||
(33, "Il Leone", "il-leone", ""), # Leo
|
||||
(34, "Il Toro", "il-toro", ""), # Taurus
|
||||
(35, "I Gemelli", "i-gemelli", ""), # Gemini
|
||||
(36, "La Stella", "la-stella", "The Star"),
|
||||
(37, "La Luna", "la-luna", "The Moon"),
|
||||
(38, "Il Sole", "il-sole", "The Sun"),
|
||||
(39, "Il Mondo", "il-mondo", "The World"),
|
||||
(40, "Le Trombe", "le-trombe", "Judgement"),
|
||||
]
|
||||
|
||||
# Canonical Earthman suit enum → Italian-family display name (also used as slug stem).
|
||||
SUIT_DISPLAY = {
|
||||
"BRANDS": "Batons",
|
||||
"GRAILS": "Cups",
|
||||
"BLADES": "Swords",
|
||||
"CROWNS": "Coins",
|
||||
}
|
||||
PIP_NAMES = {1: "Ace", 2: "Two", 3: "Three", 4: "Four", 5: "Five",
|
||||
6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten"}
|
||||
COURT_NAMES = {11: "Page", 12: "Knight", 13: "Queen", 14: "King"}
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
|
||||
deck = DeckVariant.objects.create(
|
||||
name="Minchiate Fiorentine (1860–1890)",
|
||||
slug="minchiate-fiorentine-1860-1890",
|
||||
card_count=97,
|
||||
is_default=False,
|
||||
family="italian",
|
||||
has_card_images=True,
|
||||
is_polarized=False,
|
||||
description=(
|
||||
"97-card Minchiate Fiorentine deck from the Baragioli-era 1860-1890 "
|
||||
"Florence lithograph series. Five popes, four theological/cardinal "
|
||||
"virtues, four elements, twelve zodiac signs, plus the standard "
|
||||
"trump iconography and Il Matto."
|
||||
),
|
||||
)
|
||||
|
||||
# 41 trumps (incl. Il Matto at rank 0).
|
||||
for number, name, slug, correspondence in TRUMPS:
|
||||
TarotCard.objects.create(
|
||||
deck_variant=deck,
|
||||
arcana="MAJOR",
|
||||
suit=None,
|
||||
number=number,
|
||||
name=name,
|
||||
slug=slug,
|
||||
correspondence=correspondence,
|
||||
)
|
||||
|
||||
# 56 minors: 4 suits × 14 cards.
|
||||
for canonical_suit, display in SUIT_DISPLAY.items():
|
||||
for n in range(1, 15):
|
||||
rank_word = PIP_NAMES.get(n) or COURT_NAMES[n]
|
||||
TarotCard.objects.create(
|
||||
deck_variant=deck,
|
||||
arcana="MINOR",
|
||||
suit=canonical_suit,
|
||||
number=n,
|
||||
name=f"{rank_word} of {display}",
|
||||
slug=f"{rank_word.lower()}-of-{display.lower()}",
|
||||
)
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
deck = DeckVariant.objects.filter(slug="minchiate-fiorentine-1860-1890").first()
|
||||
if deck:
|
||||
TarotCard.objects.filter(deck_variant=deck).delete()
|
||||
deck.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epic", "0012_rws_rename_and_suit_revocab"),
|
||||
]
|
||||
operations = [migrations.RunPython(forward, backward)]
|
||||
35
src/apps/epic/migrations/0014_rws_has_card_images_true.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Flip RWS `has_card_images` to True now that the 79 RWS card-face PNGs are
|
||||
installed + resized + pngquant'd at `cards-faces/english/rider-waite-smith/`.
|
||||
Light up the existing image-mode rendering branches (already shipped for
|
||||
Minchiate) for the RWS deck too — the card face becomes the image; the text
|
||||
metadata moves to the adjacent stat block; the deck-stack icon uses the
|
||||
RWS card-back PNG instead of the generic SCSS placeholder rect-fill.
|
||||
|
||||
`is_polarized` stays False (set by 0012). RWS was always a monodeck — flip-to-
|
||||
back FLIP renders the card-back image instead of cycling gravity/levity halves.
|
||||
|
||||
The is_default flag stays False (Earthman remains the default-equipped deck for
|
||||
new users); explicit equip via Game Kit is required to put RWS on the table.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
DeckVariant.objects.filter(slug="tarot-rider-waite-smith").update(
|
||||
has_card_images=True,
|
||||
)
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
DeckVariant.objects.filter(slug="tarot-rider-waite-smith").update(
|
||||
has_card_images=False,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epic", "0013_seed_minchiate_fiorentine_1860_1890"),
|
||||
]
|
||||
operations = [migrations.RunPython(forward, backward)]
|
||||
18
src/apps/epic/migrations/0015_deckvariant_free_in_shop.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-05-30 18:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0014_rws_has_card_images_true'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='deckvariant',
|
||||
name='free_in_shop',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
31
src/apps/epic/migrations/0016_seed_free_in_shop_decks.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Mark RWS + Minchiate Fiorentine as free-in-shop ($0) decks.
|
||||
|
||||
These two stock decks are offered free in the wallet Shop applet — a one-
|
||||
click claim adds them to a user's `unlocked_decks` (no Stripe). Earthman is
|
||||
deliberately left False: it's auto-granted at signup, not shopped.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
FREE_SLUGS = ("tarot-rider-waite-smith", "minchiate-fiorentine-1860-1890")
|
||||
|
||||
|
||||
def set_free_in_shop(apps, schema_editor):
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
DeckVariant.objects.filter(slug__in=FREE_SLUGS).update(free_in_shop=True)
|
||||
|
||||
|
||||
def unset_free_in_shop(apps, schema_editor):
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
DeckVariant.objects.filter(slug__in=FREE_SLUGS).update(free_in_shop=False)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0015_deckvariant_free_in_shop"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_free_in_shop, unset_free_in_shop),
|
||||
]
|
||||
@@ -85,6 +85,52 @@ class GateSlot(models.Model):
|
||||
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
|
||||
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# ── Seat-occupancy / renewal clock (sprint 2026-05-31) ────────────────
|
||||
# A filled seat's token cost is "current" for one renewal span after
|
||||
# `filled_at`, then sits in a renewal-grace span of equal length before
|
||||
# auto-BYE. Uniform across token types (no exceptions) — keyed on
|
||||
# `filled_at` only; the per-token `debit_token` rules are untouched. A
|
||||
# NULL `filled_at` (ORM fixtures / RESERVED slots) reads current /
|
||||
# never-expired so nothing built without a fill timestamp gets evicted.
|
||||
@property
|
||||
def renewal_span(self):
|
||||
return self.room.renewal_period or timedelta(days=7)
|
||||
|
||||
@property
|
||||
def cost_current_until(self):
|
||||
"""End of the cost-current window [A, A+S). None if not filled."""
|
||||
if self.filled_at is None:
|
||||
return None
|
||||
return self.filled_at + self.renewal_span
|
||||
|
||||
@property
|
||||
def grace_expires_at(self):
|
||||
"""End of the renewal-grace window [A+S, A+2S) — the auto-BYE
|
||||
threshold. None if not filled."""
|
||||
if self.filled_at is None:
|
||||
return None
|
||||
return self.filled_at + 2 * self.renewal_span
|
||||
|
||||
@property
|
||||
def cost_current(self):
|
||||
"""True in [A, A+S). NULL filled_at → True (never-filled / fixtures)."""
|
||||
until = self.cost_current_until
|
||||
return until is None or timezone.now() < until
|
||||
|
||||
@property
|
||||
def in_renewal_grace(self):
|
||||
"""True in [A+S, A+2S) — cost lapsed but the seat is still held for
|
||||
renewal. False before the span and after grace expires."""
|
||||
if self.filled_at is None:
|
||||
return False
|
||||
return self.cost_current_until <= timezone.now() < self.grace_expires_at
|
||||
|
||||
@property
|
||||
def grace_expired(self):
|
||||
"""True at/after A+2S — past renewal grace, eligible for auto-BYE."""
|
||||
exp = self.grace_expires_at
|
||||
return exp is not None and timezone.now() >= exp
|
||||
|
||||
|
||||
class RoomInvite(models.Model):
|
||||
PENDING = "PENDING"
|
||||
@@ -221,13 +267,98 @@ class TableSeat(models.Model):
|
||||
|
||||
|
||||
class DeckVariant(models.Model):
|
||||
"""A named deck variant, e.g. Earthman (108 cards) or Fiorentine Minchiate (78 cards)."""
|
||||
"""A named deck variant, e.g. Earthman or Tarot (Rider-Waite-Smith)."""
|
||||
|
||||
EARTHMAN = "earthman"
|
||||
ITALIAN = "italian"
|
||||
ENGLISH = "english"
|
||||
PLAYING = "playing"
|
||||
FAMILY_CHOICES = [
|
||||
(EARTHMAN, "Earthman"),
|
||||
(ITALIAN, "Italian / Minchiate"),
|
||||
(ENGLISH, "English Tarot"),
|
||||
(PLAYING, "Playing card"),
|
||||
]
|
||||
|
||||
# Per-family translation tables: canonical SUIT enum (Earthman vocab) →
|
||||
# family-authentic display slug used in image filenames + UI labels.
|
||||
# See [[reference-card-image-naming-convention]] v2.
|
||||
_SUIT_SLUG_BY_FAMILY = {
|
||||
EARTHMAN: {"BRANDS": "brands", "CROWNS": "crowns", "GRAILS": "grails", "BLADES": "blades"},
|
||||
ITALIAN: {"BRANDS": "batons", "CROWNS": "coins", "GRAILS": "cups", "BLADES": "swords"},
|
||||
ENGLISH: {"BRANDS": "wands", "CROWNS": "pentacles", "GRAILS": "cups", "BLADES": "swords"},
|
||||
PLAYING: {"BRANDS": "clubs", "CROWNS": "diamonds", "GRAILS": "hearts", "BLADES": "spades"},
|
||||
}
|
||||
_TRUMP_CATEGORY_BY_FAMILY = {
|
||||
EARTHMAN: "trumps",
|
||||
ITALIAN: "trumps",
|
||||
ENGLISH: "majors",
|
||||
PLAYING: None, # 52-card decks: no trump category (jokers handled separately)
|
||||
}
|
||||
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
card_count = models.IntegerField()
|
||||
description = models.TextField(blank=True)
|
||||
is_default = models.BooleanField(default=False)
|
||||
family = models.CharField(max_length=10, choices=FAMILY_CHOICES, default=EARTHMAN)
|
||||
has_card_images = models.BooleanField(default=True)
|
||||
is_polarized = models.BooleanField(default=False)
|
||||
# When True, this deck is offered FREE ($0) in the wallet Shop applet —
|
||||
# a one-click claim adds it to the user's `unlocked_decks` (no Stripe).
|
||||
# Seeded True for RWS + Minchiate Fiorentine; Earthman stays False (it's
|
||||
# auto-granted at signup, not shopped). See migration 0016.
|
||||
free_in_shop = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def variant_dir_slug(self):
|
||||
"""Subdirectory under `cards-faces/<family>/` for this deck's images.
|
||||
Strips family-implied prefixes from `slug` (e.g., RWS slug is
|
||||
`tarot-rider-waite-smith` but lives at `english/rider-waite-smith/` —
|
||||
the "tarot-" is redundant under family=english). Earthman is special-
|
||||
cased to "default" per user-locked spec 2026-05-26: even though it's
|
||||
currently a single canonical deck, we lock in the variant tier now
|
||||
so future Earthman editions slot in alongside as `earthman/<variant>/`
|
||||
w.o. a path migration.
|
||||
|
||||
Mapping today:
|
||||
earthman / earthman → earthman/default
|
||||
italian / minchiate-... → italian/minchiate-fiorentine-1860-1890
|
||||
english / tarot-rws → english/rider-waite-smith (strip "tarot-")
|
||||
"""
|
||||
if self.family == self.EARTHMAN:
|
||||
return "default"
|
||||
if self.slug.startswith("tarot-"):
|
||||
return self.slug[len("tarot-"):]
|
||||
return self.slug
|
||||
|
||||
@property
|
||||
def back_image_url(self):
|
||||
"""Full static-asset URL for this deck's card-back image, or empty
|
||||
string if the deck has no images (legacy text-only mode). Sprint A.4
|
||||
— consumed by the card-stack icon SVG to render the actual deck back
|
||||
as the visible card-stack rect-fills instead of the placeholder
|
||||
`--priUser` solid color."""
|
||||
if not self.has_card_images:
|
||||
return ""
|
||||
from django.templatetags.static import static
|
||||
return static(
|
||||
f"apps/epic/images/cards-faces/{self.family}/{self.variant_dir_slug}/{self.slug}-back.png"
|
||||
)
|
||||
|
||||
def suit_slug(self, canonical_suit):
|
||||
"""Map canonical SUIT enum → family-authentic filename slug.
|
||||
e.g. ('italian', 'BRANDS') → 'batons'."""
|
||||
return self._SUIT_SLUG_BY_FAMILY[self.family][canonical_suit]
|
||||
|
||||
def suit_display(self, canonical_suit):
|
||||
"""User-facing capitalized suit label, e.g. ('italian', 'BRANDS') → 'Batons'."""
|
||||
return self.suit_slug(canonical_suit).capitalize()
|
||||
|
||||
@property
|
||||
def trump_category(self):
|
||||
"""Filename-slug category for trump cards in this family."""
|
||||
return self._TRUMP_CATEGORY_BY_FAMILY[self.family]
|
||||
|
||||
@property
|
||||
def short_key(self):
|
||||
@@ -248,21 +379,16 @@ class TarotCard(models.Model):
|
||||
(MIDDLE, "Middle Arcana"),
|
||||
]
|
||||
|
||||
WANDS = "WANDS"
|
||||
CUPS = "CUPS"
|
||||
SWORDS = "SWORDS"
|
||||
PENTACLES = "PENTACLES" # Fiorentine 4th suit
|
||||
CROWNS = "CROWNS" # Earthman 4th suit
|
||||
BRANDS = "BRANDS" # Earthman Wands
|
||||
GRAILS = "GRAILS" # Earthman Cups
|
||||
BLADES = "BLADES" # Earthman Swords
|
||||
# Canonical SUIT_CHOICES = Earthman vocabulary (2026-05-25 lock).
|
||||
# Per-family display + filename slug mapping lives in image_filename /
|
||||
# display_suit_name properties driven by DeckVariant.family.
|
||||
BRANDS = "BRANDS"
|
||||
CROWNS = "CROWNS"
|
||||
GRAILS = "GRAILS"
|
||||
BLADES = "BLADES"
|
||||
SUIT_CHOICES = [
|
||||
(WANDS, "Wands"),
|
||||
(CUPS, "Cups"),
|
||||
(SWORDS, "Swords"),
|
||||
(PENTACLES, "Pentacles"),
|
||||
(CROWNS, "Crowns"),
|
||||
(BRANDS, "Brands"),
|
||||
(CROWNS, "Crowns"),
|
||||
(GRAILS, "Grails"),
|
||||
(BLADES, "Blades"),
|
||||
]
|
||||
@@ -279,7 +405,8 @@ class TarotCard(models.Model):
|
||||
slug = models.SlugField(max_length=120)
|
||||
correspondence = models.CharField(max_length=200, blank=True) # Tarot / Minchiate equivalent
|
||||
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
|
||||
reversal_qualifier = models.CharField(max_length=200, blank=True, default='') # reversal-axis qualifier (e.g. "Nervous"); polarity-shared; blank = falls back to current polarity's qualifier
|
||||
reversal_qualifier = models.CharField(max_length=200, blank=True, default='') # polysemous (cf [[feedback-reversal-qualifier-dual-role]]): on non-Majors w. no polarity qualifier it's the reversal-face qualifier (e.g. "Vacant"); on Majors w. polarity qualifiers it's the NAME-SWAP for the reversal face (e.g. "Patrilineage" for card 34). `applet_face()` routes on `arcana`.
|
||||
reversal_drops_qualifier = models.BooleanField(default=False) # Pattern B' cards (16-18): reversal face shows the name swap ALONE, no qualifier. Pattern B (default False): polarity qualifier persists on the reversal face.
|
||||
levity_qualifier = models.CharField(max_length=100, blank=True, default='')
|
||||
gravity_qualifier = models.CharField(max_length=100, blank=True, default='')
|
||||
levity_emanation = models.CharField(max_length=200, blank=True, default='') # polarity-split upright (cards 48-49)
|
||||
@@ -297,10 +424,27 @@ class TarotCard(models.Model):
|
||||
ordering = ["deck_variant", "arcana", "suit", "number"]
|
||||
unique_together = [("deck_variant", "slug")]
|
||||
|
||||
# Per-trump overrides for Fiorentine Minchiate fidelity — the historical
|
||||
# deck art uses additive numerals at these specific ranks only (NOT every
|
||||
# 4/9 ending; e.g. trump 9 = IX, trump 14 = XIV stay subtractive per the
|
||||
# actual printed cards). Earthman's 0-49 trumps inherit the same mapping
|
||||
# for visual consistency w. the Fiorentine deck. Other ranks fall through
|
||||
# to the standard subtractive `_to_roman` algorithm.
|
||||
_FIORENTINE_ADDITIVE_NUMERALS = {
|
||||
4: 'IIII',
|
||||
19: 'XVIIII',
|
||||
24: 'XXIIII',
|
||||
29: 'XXVIIII',
|
||||
34: 'XXXIIII',
|
||||
39: 'XXXVIIII',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _to_roman(n):
|
||||
if n == 0:
|
||||
return '0'
|
||||
if n in TarotCard._FIORENTINE_ADDITIVE_NUMERALS:
|
||||
return TarotCard._FIORENTINE_ADDITIVE_NUMERALS[n]
|
||||
val = [50, 40, 10, 9, 5, 4, 1]
|
||||
syms = ['L','XL','X','IX','V','IV','I']
|
||||
result = ''
|
||||
@@ -338,6 +482,75 @@ class TarotCard(models.Model):
|
||||
return self.gravity_reversal
|
||||
return self.reversal_qualifier or self.emanation_for(polarity)
|
||||
|
||||
def applet_face(self, polarity='gravity', reversed=False):
|
||||
"""Return the rendering payload for a card face in the My Sign /
|
||||
My Sea applets — mirrors `populateCard` in `stage-card.js`. Four
|
||||
patterns:
|
||||
|
||||
- **Polarity-split FULL title** (cards 19-21, 48-49): single-line
|
||||
title from `emanation_for` / `reversal_for`; qualifier blank.
|
||||
- **Pattern B — Major w. polarity qualifier + reversal name-swap**
|
||||
(cards 2-5, 10-15, 22-35, 41): `reversal_qualifier` carries the
|
||||
REVERSAL-face NAME (e.g. "Patrilineage" for card 34). Polarity
|
||||
qualifier persists across both faces. Renders: `<reversal_qual>,`
|
||||
/ `<polarity_qualifier>` on the reversal face.
|
||||
- **Pattern B' — Major w. name-swap that DROPS qualifier on
|
||||
reversal** (cards 16-18 — Realms): same as Pattern B but the
|
||||
reversal face renders only the name (e.g. "Shame"), no
|
||||
qualifier. Marked via `reversal_drops_qualifier=True`.
|
||||
- **Non-Major (middle / minor)**: qualifier ABOVE title; reversal
|
||||
face uses `reversal_qualifier` as the QUALIFIER (NOT a name
|
||||
swap) — e.g. "Queen of Crowns" stays as the title, "Vacant"
|
||||
renders as the reversal qualifier.
|
||||
|
||||
Returns a 3-key dict:
|
||||
{
|
||||
"title": str, # title (w. trailing comma for Major+qual)
|
||||
"qualifier": str, # qualifier text (may be blank)
|
||||
"qualifier_first": bool, # True ⇒ qualifier above title; False ⇒ below
|
||||
}
|
||||
"""
|
||||
is_major = (self.arcana == self.MAJOR)
|
||||
if reversed:
|
||||
override = (self.levity_reversal if polarity == 'levity'
|
||||
else self.gravity_reversal)
|
||||
if override:
|
||||
return {"title": override, "qualifier": "", "qualifier_first": False}
|
||||
polarity_qualifier = (
|
||||
self.levity_qualifier if polarity == 'levity'
|
||||
else self.gravity_qualifier
|
||||
)
|
||||
# Pattern B / B' — Major w. both polarity qualifier + reversal
|
||||
# name-swap. `reversal_qualifier` is the SWAPPED NAME (not a
|
||||
# qualifier) for these Majors. See `reversal_qualifier` field
|
||||
# docstring + [[feedback-reversal-qualifier-dual-role]].
|
||||
if is_major and self.reversal_qualifier and polarity_qualifier:
|
||||
if self.reversal_drops_qualifier:
|
||||
# Pattern B' (16-18): single-line reversal name.
|
||||
return {"title": self.reversal_qualifier,
|
||||
"qualifier": "", "qualifier_first": False}
|
||||
# Pattern B (2-5, 10-15, 22-35, 41): swapped name + polarity
|
||||
# qualifier carried across both faces.
|
||||
return {"title": self.reversal_qualifier + ",",
|
||||
"qualifier": polarity_qualifier,
|
||||
"qualifier_first": False}
|
||||
# Non-Major OR Major-without-polarity-qualifier: reversal_
|
||||
# qualifier is the qualifier (Pattern A / fallback).
|
||||
qualifier = self.reversal_qualifier or polarity_qualifier
|
||||
else:
|
||||
override = (self.levity_emanation if polarity == 'levity'
|
||||
else self.gravity_emanation)
|
||||
if override:
|
||||
return {"title": override, "qualifier": "", "qualifier_first": False}
|
||||
qualifier = (self.levity_qualifier if polarity == 'levity'
|
||||
else self.gravity_qualifier)
|
||||
|
||||
title = self.name_title
|
||||
if is_major and qualifier:
|
||||
return {"title": title + ",", "qualifier": qualifier,
|
||||
"qualifier_first": False}
|
||||
return {"title": title, "qualifier": qualifier, "qualifier_first": True}
|
||||
|
||||
@property
|
||||
def name_group(self):
|
||||
"""Returns 'Group N:' prefix if the name contains ': ', else ''."""
|
||||
@@ -362,21 +575,83 @@ class TarotCard(models.Model):
|
||||
|
||||
@property
|
||||
def suit_icon(self):
|
||||
if self.arcana == self.MAJOR:
|
||||
# Trump 0 (Fool / Nomad / Matto) + trump 1 (Magician / Schizo /
|
||||
# Bagatto) carry universal symbol overrides — cowboy-hat-side for
|
||||
# the wanderer/fool archetype, wizard-hat for the magus archetype.
|
||||
# Pinned BEFORE the `self.icon` branch so even a deck seed that
|
||||
# supplies a different icon for these two ranks gets normalized
|
||||
# to the convention (Earthman's seed already aligns; Minchiate's
|
||||
# empty icon field used to fall through to fa-hand-dots).
|
||||
if self.number == 0:
|
||||
return 'fa-hat-cowboy-side'
|
||||
if self.number == 1:
|
||||
return 'fa-hat-wizard'
|
||||
if self.icon:
|
||||
return self.icon
|
||||
if self.arcana == self.MAJOR:
|
||||
return ''
|
||||
# Sprint A.7.5 — trumps default to fa-hand-dots so the chip (and
|
||||
# any text-mode corner) always has a symbol below the rank. Per-
|
||||
# card overrides still win via the `self.icon` branch above (the
|
||||
# Earthman seed sets `icon="fa-hand-dots"` explicitly for trumps
|
||||
# 2+, which was the only place this fallback used to live; trumps
|
||||
# 2+ Minchiate trumps still pick it up for free here).
|
||||
return 'fa-hand-dots'
|
||||
return {
|
||||
self.WANDS: 'fa-wand-sparkles',
|
||||
self.CUPS: 'fa-trophy',
|
||||
self.SWORDS: 'fa-gun',
|
||||
self.PENTACLES: 'fa-star',
|
||||
self.CROWNS: 'fa-crown',
|
||||
self.BRANDS: 'fa-wand-sparkles',
|
||||
self.CROWNS: 'fa-crown',
|
||||
self.GRAILS: 'fa-trophy',
|
||||
self.BLADES: 'fa-gun',
|
||||
}.get(self.suit, '')
|
||||
|
||||
# Tarot-family courts: rank 11=page, 12=knight, 13=queen, 14=king. Playing
|
||||
# family (3 courts: jack/queen/king at ranks 11-13) handled separately when
|
||||
# a playing deck is seeded — Sprint A.2 covers tarot families only.
|
||||
_COURT_NAME_BY_RANK = {11: "page", 12: "knight", 13: "queen", 14: "king"}
|
||||
|
||||
@property
|
||||
def image_filename(self):
|
||||
"""v2-convention filename per [[reference-card-image-naming-convention]].
|
||||
Always derives a path; the template decides whether to actually render
|
||||
an <img> based on `deck_variant.has_card_images`."""
|
||||
deck = self.deck_variant
|
||||
if self.arcana == self.MAJOR:
|
||||
return f"{deck.slug}-{deck.trump_category}-{self.number:02d}-{self.slug}.png"
|
||||
# MINOR or MIDDLE: <deck-slug>-<suit-slug>-<NN>[-<court>].png
|
||||
suit_slug = deck.suit_slug(self.suit)
|
||||
rank = f"{self.number:02d}"
|
||||
court = self._COURT_NAME_BY_RANK.get(self.number)
|
||||
if court:
|
||||
return f"{deck.slug}-{suit_slug}-{rank}-{court}.png"
|
||||
return f"{deck.slug}-{suit_slug}-{rank}.png"
|
||||
|
||||
@property
|
||||
def display_suit_name(self):
|
||||
"""Family-authentic capitalized suit label (e.g. 'Batons' for italian
|
||||
BRANDS, 'Pentacles' for english CROWNS). Empty for major arcana."""
|
||||
if not self.suit:
|
||||
return ""
|
||||
return self.deck_variant.suit_display(self.suit)
|
||||
|
||||
@property
|
||||
def image_url(self):
|
||||
"""Full static-asset URL for the card image, or empty string if the
|
||||
deck has no images (legacy text-only mode). Constructed via Django's
|
||||
`static` helper so STATIC_URL prefix + manifest-versioning (when
|
||||
WhiteNoise compressed manifest is active) flow through.
|
||||
|
||||
Path structure: `cards-faces/<family>/<variant_dir_slug>/<filename>`
|
||||
per the family-grouped tree convention (user spec 2026-05-26). See
|
||||
`DeckVariant.variant_dir_slug` for the variant subdir mapping.
|
||||
"""
|
||||
if not self.deck_variant.has_card_images:
|
||||
return ""
|
||||
from django.templatetags.static import static
|
||||
deck = self.deck_variant
|
||||
return static(
|
||||
f"apps/epic/images/cards-faces/{deck.family}/{deck.variant_dir_slug}/{self.image_filename}"
|
||||
)
|
||||
|
||||
@property
|
||||
def cautions_json(self):
|
||||
import json
|
||||
@@ -488,52 +763,37 @@ def _room_deck_variant(room):
|
||||
def sig_deck_cards(room):
|
||||
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
||||
|
||||
PC/BC pair → BRANDS/WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique
|
||||
SC/AC pair → BLADES/SWORDS + GRAILS/CUPS Middle Arcana court cards (11–14): 8 unique
|
||||
PC/BC pair → BRANDS + CROWNS Middle Arcana court cards (11–14): 8 unique
|
||||
SC/AC pair → BLADES + GRAILS Middle Arcana court cards (11–14): 8 unique
|
||||
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
|
||||
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
|
||||
"""
|
||||
deck_variant = _room_deck_variant(room)
|
||||
if deck_variant is None:
|
||||
return []
|
||||
wands_crowns = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
swords_cups = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
major = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MAJOR,
|
||||
number__in=[0, 1],
|
||||
))
|
||||
unique_cards = wands_crowns + swords_cups + major # 18 unique
|
||||
unique_cards = _sig_unique_cards_for_deck(_room_deck_variant(room))
|
||||
return unique_cards + unique_cards # × 2 = 36
|
||||
|
||||
|
||||
def _sig_unique_cards_for_deck(deck_variant):
|
||||
"""Return the 18 unique TarotCards forming one sig pile for the given
|
||||
deck variant. Shared between room sig-select (called via _sig_unique_cards
|
||||
after room → deck_variant lookup) and the solo My Sig picker (called
|
||||
via personal_sig_cards from User.equipped_deck)."""
|
||||
after room → deck_variant lookup) and the solo My Sign picker (called
|
||||
via personal_sig_cards from User.equipped_deck).
|
||||
|
||||
"Court cards" are recognized by rank (11=Page, 12=Knight, 13=Queen,
|
||||
14=King) regardless of arcana classification: Earthman classifies its
|
||||
courts as MIDDLE arcana, but other tarot families (Minchiate Fiorentine,
|
||||
RWS) classify them as MINOR. Including both classifications gives every
|
||||
deck the symmetric 18-card pile (16 courts × 4 suits + 2 majors at
|
||||
numbers 0/1) instead of letting non-Earthman decks fall to 2 cards just
|
||||
because they don't use the MIDDLE classification. Cross-deck eligibility
|
||||
is NOT segment-limited — all 4 suits' courts qualify per user spec
|
||||
2026-05-25.
|
||||
"""
|
||||
if deck_variant is None:
|
||||
return []
|
||||
wands_crowns = list(TarotCard.objects.filter(
|
||||
courts = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
swords_cups = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||
arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR],
|
||||
suit__in=[TarotCard.BRANDS, TarotCard.CROWNS, TarotCard.BLADES, TarotCard.GRAILS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
major = list(TarotCard.objects.filter(
|
||||
@@ -541,7 +801,7 @@ def _sig_unique_cards_for_deck(deck_variant):
|
||||
arcana=TarotCard.MAJOR,
|
||||
number__in=[0, 1],
|
||||
))
|
||||
return wands_crowns + swords_cups + major
|
||||
return courts + major
|
||||
|
||||
|
||||
def _sig_unique_cards(room):
|
||||
|
||||
303
src/apps/epic/static/apps/epic/burger-btn.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// Burger btn + 5-fan menu on room.html. Pure scaffolding for now —
|
||||
// sub-btns have no click handlers in this sprint. Behaviour owned here:
|
||||
// • Toggle #id_burger_btn.active on click.
|
||||
// • Closing burger via re-click / Escape / click-outside.
|
||||
// • Opening burger auto-closes the kit dialog (#id_kit_bag_dialog) and
|
||||
// the bud slide-out panel (html.bud-open) if either is open —
|
||||
// dispatched by clicking the owning btn, which routes through that
|
||||
// btn's own toggle/close path (no fetch on close).
|
||||
//
|
||||
// bindBurger() returns an AbortController so test code (and any future
|
||||
// re-bind callers) can detach all listeners cleanly via ac.abort().
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function bindBurger() {
|
||||
var btn = document.getElementById('id_burger_btn');
|
||||
var fan = document.getElementById('id_burger_fan');
|
||||
if (!btn || !fan) return null;
|
||||
|
||||
var ac = new AbortController();
|
||||
var sig = ac.signal;
|
||||
|
||||
function _isOpen() {
|
||||
return btn.classList.contains('active');
|
||||
}
|
||||
|
||||
function _closeKit() {
|
||||
var dialog = document.getElementById('id_kit_bag_dialog');
|
||||
var kitBtn = document.getElementById('id_kit_btn');
|
||||
if (dialog && dialog.hasAttribute('open') && kitBtn) {
|
||||
kitBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
function _closeBud() {
|
||||
var budBtn = document.getElementById('id_bud_btn');
|
||||
if (document.documentElement.classList.contains('bud-open') && budBtn) {
|
||||
budBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
function _open() {
|
||||
_closeKit();
|
||||
_closeBud();
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
fan.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function _close() {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
fan.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
// 2 pulses, ~180ms ON / 120ms OFF — tighter cadence than
|
||||
// sig-select's countdown glow (600ms), but same shape.
|
||||
function _flashInactive(subBtn) {
|
||||
var pulses = 2;
|
||||
var onMs = 180;
|
||||
var offMs = 120;
|
||||
function pulse(remaining) {
|
||||
if (remaining <= 0) return;
|
||||
subBtn.classList.add('flash-inactive');
|
||||
setTimeout(function () {
|
||||
subBtn.classList.remove('flash-inactive');
|
||||
setTimeout(function () {
|
||||
pulse(remaining - 1);
|
||||
}, offMs);
|
||||
}, onMs);
|
||||
}
|
||||
pulse(pulses);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (_isOpen()) _close();
|
||||
else _open();
|
||||
}, { signal: sig });
|
||||
|
||||
// Delegated click on the fan:
|
||||
// • INACTIVE sub-btn → flash the --priRd glow twice (no real action
|
||||
// bound yet for that surface).
|
||||
// • ACTIVE sub-btn → close the burger fan so the surface's own
|
||||
// handler (page-level direct listener on the sub-btn) takes over
|
||||
// w. a clean visual. The per-page handler fires BEFORE this
|
||||
// delegated one (target-phase before bubble-up), so the action
|
||||
// already kicked off by the time we close.
|
||||
fan.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var subBtn = e.target.closest('.burger-fan-btn');
|
||||
if (!subBtn) return;
|
||||
if (subBtn.classList.contains('active')) {
|
||||
_close();
|
||||
} else {
|
||||
_flashInactive(subBtn);
|
||||
}
|
||||
}, { signal: sig });
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && _isOpen()) _close();
|
||||
}, { signal: sig });
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!_isOpen()) return;
|
||||
if (btn.contains(e.target)) return;
|
||||
if (fan.contains(e.target)) return;
|
||||
_close();
|
||||
}, { signal: sig });
|
||||
|
||||
return ac;
|
||||
}
|
||||
|
||||
window.bindBurger = bindBurger;
|
||||
|
||||
// Voice sub-btn (Phase C of the my-sea invite/voice sprint). Direct
|
||||
// listener on #id_voice_btn: an ACTIVE click lazy-loads voice-mesh.js
|
||||
// then joins the mesh (first click) or toggles mute (subsequent). An
|
||||
// INACTIVE click is left to the delegated fan handler's 2-pulse flash.
|
||||
// No stopPropagation on active — the delegated handler then closes the
|
||||
// fan (its existing .active behaviour).
|
||||
// Voice persistence across my_sea reloads (2026-05-29): we remember the
|
||||
// active room in sessionStorage so the next my_sea page silently re-joins
|
||||
// the mesh (mic permission persists for the session → no prompt). True
|
||||
// no-reload nav would need an SPA refactor of the draw IIFEs; this gets the
|
||||
// same user-visible result (a brief reconnect, not seamless) with no risk
|
||||
// to those flows. The flag is cleared only on an EXPLICIT leave (BYE /
|
||||
// NVM-disconnect guard) or a failed join, never on an in-my_sea reload.
|
||||
var VOICE_ROOM_KEY = 'mysea-voice-room';
|
||||
function _rememberVoiceRoom(id) {
|
||||
try { sessionStorage.setItem(VOICE_ROOM_KEY, id); } catch (e) {}
|
||||
}
|
||||
function _forgetVoiceRoom() {
|
||||
try { sessionStorage.removeItem(VOICE_ROOM_KEY); } catch (e) {}
|
||||
}
|
||||
function _rememberedVoiceRoom() {
|
||||
try { return sessionStorage.getItem(VOICE_ROOM_KEY); } catch (e) { return null; }
|
||||
}
|
||||
// BYE + the NVM-disconnect guard call this on an explicit leave.
|
||||
window.mySeaVoiceForget = _forgetVoiceRoom;
|
||||
|
||||
// ── Mute persistence + 3-min auto-disconnect (user-spec 2026-05-30) ─────
|
||||
// The mute is stored server-side (User.voice_muted_at) so it SURVIVES in-sea
|
||||
// nav/refresh — the voice auto-rejoin re-applies it. A mute held for
|
||||
// MUTE_MAX_MS auto-disconnects the user from voice (+ clears the field). The
|
||||
// window anchors to the persisted timestamp, so it spans navigations instead
|
||||
// of resetting on each page.
|
||||
var MUTE_MAX_MS = 180000; // 3 minutes
|
||||
var _muteTimer = null;
|
||||
|
||||
function _voiceCsrf() {
|
||||
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
// Persist (or clear) the caller's mute server-side. Best-effort — voice
|
||||
// still works locally if the POST fails; only cross-nav persistence is lost.
|
||||
function _persistMute(muted) {
|
||||
try {
|
||||
fetch('/voice/mute', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': _voiceCsrf() },
|
||||
body: JSON.stringify({ muted: !!muted }),
|
||||
}).catch(function () {});
|
||||
} catch (e) {}
|
||||
}
|
||||
// ms remaining before the 3-min auto-disconnect, given the mute's start (ms)
|
||||
// + now (ms). <= 0 means already elapsed. Pure — exposed for unit testing.
|
||||
function muteRemainingMs(mutedAtMs, nowMs) {
|
||||
return MUTE_MAX_MS - (nowMs - mutedAtMs);
|
||||
}
|
||||
window._muteRemainingMs = muteRemainingMs;
|
||||
|
||||
function _clearMuteTimer() {
|
||||
if (_muteTimer) { clearTimeout(_muteTimer); _muteTimer = null; }
|
||||
}
|
||||
// Arm the auto-disconnect for the mute that began at `mutedAtMs` (ms). Fires
|
||||
// `onFire` after the remaining window (next tick if already elapsed).
|
||||
function _armMuteTimer(mutedAtMs, onFire) {
|
||||
_clearMuteTimer();
|
||||
var remaining = muteRemainingMs(mutedAtMs, Date.now());
|
||||
_muteTimer = setTimeout(onFire, remaining > 0 ? remaining : 0);
|
||||
}
|
||||
|
||||
// Surface a join failure to the user instead of failing silently — most
|
||||
// often the secure-context block (INSECURE_CONTEXT) when the dev server is
|
||||
// reached over plain HTTP from a phone. Prefers the Brief banner; falls
|
||||
// back to console.
|
||||
function _voiceJoinFailed(vbtn, e) {
|
||||
vbtn.classList.remove('in-call');
|
||||
delete vbtn.dataset.inCall; // let the next click retry the join
|
||||
_forgetVoiceRoom(); // don't auto-rejoin a context that can't talk
|
||||
var msg = (e && e.code === 'INSECURE_CONTEXT')
|
||||
? 'Voice needs HTTPS (or localhost) — your browser blocked the mic here.'
|
||||
: 'Couldn’t start voice — mic unavailable or permission denied.';
|
||||
if (window.Brief && typeof window.Brief.showBanner === 'function') {
|
||||
window.Brief.showBanner({
|
||||
title: 'Voice', line_text: msg, kind: 'NUDGE',
|
||||
post_url: '', created_at: '',
|
||||
});
|
||||
} else if (window.console && console.warn) {
|
||||
console.warn('[voice] ' + msg, e || '');
|
||||
}
|
||||
}
|
||||
|
||||
function bindVoiceBtn() {
|
||||
var vbtn = document.getElementById('id_voice_btn');
|
||||
if (!vbtn) return;
|
||||
var roomId = vbtn.getAttribute('data-room-id');
|
||||
// Persisted mute timestamp (server-rendered) — present iff the user was
|
||||
// muted when this page loaded. Drives the auto-rejoin's mute + window.
|
||||
var mutedAtAttr = vbtn.getAttribute('data-voice-muted-at');
|
||||
var persistedMutedAtMs = mutedAtAttr ? Date.parse(mutedAtAttr) : NaN;
|
||||
var hasPersistedMute = !isNaN(persistedMutedAtMs);
|
||||
|
||||
// VoiceRoom is lazy-loaded on first use (mesh injected on demand).
|
||||
function withVoiceRoom(cb) {
|
||||
if (window.VoiceRoom) { cb(); return; }
|
||||
var s = document.createElement('script');
|
||||
s.src = '/static/apps/voice/voice-mesh.js';
|
||||
s.onload = cb;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// 3-min muted timeout fired: leave voice, forget the room (no rejoin),
|
||||
// clear the muted UI + the server field.
|
||||
function _muteAutoDisconnect() {
|
||||
_clearMuteTimer();
|
||||
if (window.VoiceRoom && window.VoiceRoom.leave) window.VoiceRoom.leave();
|
||||
_forgetVoiceRoom();
|
||||
_persistMute(false);
|
||||
vbtn.classList.remove('muted', 'in-call');
|
||||
delete vbtn.dataset.inCall;
|
||||
}
|
||||
|
||||
function startCall() {
|
||||
if (!window.VoiceRoom || vbtn.dataset.inCall) return;
|
||||
vbtn.dataset.inCall = '1';
|
||||
vbtn.classList.add('in-call');
|
||||
_rememberVoiceRoom(roomId);
|
||||
var p = window.VoiceRoom.join(roomId);
|
||||
if (p && typeof p.catch === 'function') {
|
||||
p.catch(function (e) { _voiceJoinFailed(vbtn, e); });
|
||||
}
|
||||
}
|
||||
|
||||
vbtn.addEventListener('click', function () {
|
||||
if (!vbtn.classList.contains('active')) return; // → delegated flash
|
||||
if (!roomId) return;
|
||||
withVoiceRoom(function () {
|
||||
if (!window.VoiceRoom) return;
|
||||
if (!vbtn.dataset.inCall) {
|
||||
// Fresh MANUAL join → start unmuted; clear any stale persisted
|
||||
// mute (e.g. muted, then closed the tab, last session).
|
||||
_clearMuteTimer();
|
||||
_persistMute(false);
|
||||
startCall();
|
||||
} else {
|
||||
var muted = window.VoiceRoom.toggleMute();
|
||||
vbtn.classList.toggle('muted', muted);
|
||||
_persistMute(muted);
|
||||
if (muted) _armMuteTimer(Date.now(), _muteAutoDisconnect);
|
||||
else _clearMuteTimer();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-rejoin: were we in THIS room before the navigation, and is voice
|
||||
// still available on this page? Silently re-join so the call survives an
|
||||
// in-sea reload (GATE VIEW / NVM / draw nav) — CARRYING the mute state.
|
||||
if (roomId && vbtn.classList.contains('active')
|
||||
&& _rememberedVoiceRoom() === roomId) {
|
||||
if (hasPersistedMute && muteRemainingMs(persistedMutedAtMs, Date.now()) <= 0) {
|
||||
// The 3-min mute window already elapsed (muted across a long
|
||||
// idle / nav) → honour the auto-disconnect: don't rejoin, clear.
|
||||
_forgetVoiceRoom();
|
||||
_persistMute(false);
|
||||
vbtn.classList.remove('muted');
|
||||
} else {
|
||||
withVoiceRoom(function () {
|
||||
if (hasPersistedMute && window.VoiceRoom.setMuted) {
|
||||
window.VoiceRoom.setMuted(true); // honoured post-getUserMedia
|
||||
vbtn.classList.add('muted');
|
||||
_armMuteTimer(persistedMutedAtMs, _muteAutoDisconnect);
|
||||
}
|
||||
startCall();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
window.bindVoiceBtn = bindVoiceBtn;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
bindBurger();
|
||||
bindVoiceBtn();
|
||||
});
|
||||
} else {
|
||||
bindBurger();
|
||||
bindVoiceBtn();
|
||||
}
|
||||
}());
|
||||
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 207 KiB |