- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
- lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
- drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
- billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
- post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
- SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
- _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
- FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
- rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
- 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
407 lines
15 KiB
Python
407 lines
15 KiB
Python
import json
|
|
import stripe
|
|
import zoneinfo
|
|
from datetime import datetime
|
|
|
|
import requests as http_requests
|
|
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.db.models import Max, Q
|
|
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, JsonResponse
|
|
from django.shortcuts import redirect, render
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
|
|
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
|
from apps.drama.models import Note
|
|
from apps.epic.utils import _compute_distinctions
|
|
from apps.lyric.models import PaymentMethod, Token, User, Wallet, is_reserved_username
|
|
|
|
|
|
APPLET_ORDER = ["wallet", "username", "palette"]
|
|
_BASE_UNLOCKED = frozenset([
|
|
"palette-default",
|
|
"palette-cedar",
|
|
"palette-oblivion-light",
|
|
"palette-monochrome-dark",
|
|
])
|
|
_PALETTE_DEFS = [
|
|
{"name": "palette-default", "label": "Earthman", "locked": False},
|
|
{"name": "palette-cedar", "label": "Cedar", "locked": False},
|
|
{"name": "palette-oblivion-light", "label": "Oblivion (Light)","locked": False},
|
|
{"name": "palette-monochrome-dark","label": "Monochrome (Dark)","locked": False},
|
|
{"name": "palette-bardo", "label": "Bardo", "locked": True},
|
|
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
|
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
|
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
|
|
{"name": "palette-celestia", "label": "Celestia", "locked": True},
|
|
]
|
|
_NOTE_TITLES = {
|
|
"stargazer": "Stargazer",
|
|
"schizo": "Schizo",
|
|
"nomad": "Nomad",
|
|
}
|
|
# Keep PALETTES as an alias used by views that don't have a request user.
|
|
PALETTES = _PALETTE_DEFS
|
|
|
|
|
|
def _palettes_for_user(user):
|
|
if not (user and user.is_authenticated):
|
|
return [
|
|
dict(p, description="available by default" if not p["locked"] else "explore to unlock")
|
|
for p in _PALETTE_DEFS
|
|
]
|
|
granted = {
|
|
r.palette: r
|
|
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette="")
|
|
}
|
|
result = []
|
|
for p in _PALETTE_DEFS:
|
|
entry = dict(p)
|
|
r = granted.get(p["name"])
|
|
if r and p["locked"]:
|
|
entry["locked"] = False
|
|
title = _NOTE_TITLES.get(r.slug, r.slug.capitalize())
|
|
entry["description"] = f"recognized via {title}"
|
|
entry["unlocked_date"] = r.earned_at.isoformat()
|
|
elif not p["locked"]:
|
|
entry["description"] = "available by default"
|
|
else:
|
|
entry["description"] = "explore to unlock"
|
|
result.append(entry)
|
|
return result
|
|
|
|
|
|
def _unlocked_palettes_for_user(user):
|
|
base = set(_BASE_UNLOCKED)
|
|
if user and user.is_authenticated:
|
|
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
|
|
base.add(r.palette)
|
|
return base
|
|
|
|
|
|
def home_page(request):
|
|
context = {
|
|
"palettes": _palettes_for_user(request.user),
|
|
"page_class": "page-dashboard",
|
|
}
|
|
if request.user.is_authenticated:
|
|
context["applets"] = applet_context(request.user, "dashboard")
|
|
return render(request, "apps/dashboard/home.html", context)
|
|
|
|
|
|
# Post / Line CRUD lives in apps.billboard.views since the Post + Line models
|
|
# moved to apps.billboard.models.
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def set_palette(request):
|
|
if request.method == "POST":
|
|
palette = request.POST.get("palette", "")
|
|
if palette in _unlocked_palettes_for_user(request.user):
|
|
request.user.palette = palette
|
|
request.user.save(update_fields=["palette"])
|
|
if "application/json" in request.headers.get("Accept", ""):
|
|
return JsonResponse({"palette": request.user.palette})
|
|
return redirect("home")
|
|
|
|
@login_required(login_url="/")
|
|
def set_profile(request):
|
|
if request.method == "POST":
|
|
username = request.POST.get("username", "")
|
|
if is_reserved_username(username, current_user=request.user):
|
|
messages.error(request, "That handle is reserved.")
|
|
return redirect("/")
|
|
request.user.username = username
|
|
request.user.save(update_fields=["username"])
|
|
return redirect("/")
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def set_pronouns(request):
|
|
from django.http import HttpResponseNotAllowed
|
|
from apps.lyric.models import PRONOUN_TABLE
|
|
if request.method != "POST":
|
|
return HttpResponseNotAllowed(["POST"])
|
|
choice = request.POST.get("pronouns", "")
|
|
if choice not in PRONOUN_TABLE:
|
|
return HttpResponse(status=400)
|
|
request.user.pronouns = choice
|
|
request.user.save(update_fields=["pronouns"])
|
|
return HttpResponse(status=204)
|
|
|
|
@login_required(login_url="/")
|
|
def toggle_applets(request):
|
|
checked = request.POST.getlist("applets")
|
|
apply_applet_toggle(request.user, "dashboard", checked)
|
|
if request.headers.get("HX-Request"):
|
|
return render(request, "apps/dashboard/_partials/_applets.html", {
|
|
"applets": applet_context(request.user, "dashboard"),
|
|
"palettes": _palettes_for_user(request.user),
|
|
})
|
|
return redirect("home")
|
|
|
|
@login_required(login_url="/")
|
|
@ensure_csrf_cookie
|
|
def wallet(request):
|
|
free_tokens = list(request.user.tokens.filter(
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
).order_by("expires_at"))
|
|
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
|
|
return render(request, "apps/dashboard/wallet.html", {
|
|
"wallet": request.user.wallet,
|
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
|
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
|
"free_tokens": free_tokens,
|
|
"tithe_tokens": tithe_tokens,
|
|
"free_count": len(free_tokens),
|
|
"tithe_count": len(tithe_tokens),
|
|
"applets": applet_context(request.user, "wallet"),
|
|
"page_class": "page-wallet",
|
|
})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def kit_bag(request):
|
|
tokens = list(request.user.tokens.all())
|
|
free_tokens = sorted(
|
|
[t for t in tokens if t.token_type == Token.FREE and t.expires_at and t.expires_at > timezone.now()],
|
|
key=lambda t: t.expires_at,
|
|
)
|
|
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
|
return render(request, "core/_partials/_kit_bag_panel.html", {
|
|
"equipped_deck": request.user.equipped_deck,
|
|
"equipped_trinket": request.user.equipped_trinket,
|
|
"free_token": free_tokens[0] if free_tokens else None,
|
|
"free_count": len(free_tokens),
|
|
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
|
|
"tithe_count": len(tithe_tokens),
|
|
})
|
|
|
|
@login_required(login_url="/")
|
|
def toggle_wallet_applets(request):
|
|
checked = request.POST.getlist("applets")
|
|
apply_applet_toggle(request.user, "wallet", checked)
|
|
if request.headers.get("HX-Request"):
|
|
return render(request, "apps/wallet/_partials/_applets.html", {
|
|
"applets": applet_context(request.user, "wallet"),
|
|
"wallet": request.user.wallet,
|
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
|
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
|
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
|
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
|
})
|
|
return redirect("wallet")
|
|
|
|
@login_required(login_url="/")
|
|
def setup_intent(request):
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
user = request.user
|
|
if not user.stripe_customer_id:
|
|
customer = stripe.Customer.create(email=user.email)
|
|
user.stripe_customer_id = customer.id
|
|
user.save(update_fields=["stripe_customer_id"])
|
|
intent = stripe.SetupIntent.create(customer=user.stripe_customer_id)
|
|
return JsonResponse({
|
|
"client_secret": intent.client_secret,
|
|
"publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
|
|
})
|
|
|
|
@login_required(login_url="/")
|
|
def save_payment_method(request):
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
pm_id = request.POST.get("payment_method_id")
|
|
pm = stripe.PaymentMethod.retrieve(pm_id)
|
|
stripe.PaymentMethod.attach(pm_id, customer=request.user.stripe_customer_id)
|
|
PaymentMethod.objects.create(
|
|
user=request.user,
|
|
stripe_pm_id=pm_id,
|
|
last4=pm.card.last4,
|
|
brand=pm.card.brand,
|
|
)
|
|
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
|
|
|
|
|
|
# ── My Sky (personal natal chart) ────────────────────────────────────────────
|
|
|
|
def _sky_preview_data(request):
|
|
"""Shared preview logic — proxies to PySwiss, no DB writes."""
|
|
date_str = request.GET.get('date')
|
|
time_str = request.GET.get('time', '12:00')
|
|
tz_str = request.GET.get('tz', '').strip()
|
|
lat_str = request.GET.get('lat')
|
|
lon_str = request.GET.get('lon')
|
|
|
|
if not date_str or lat_str is None or lon_str is None:
|
|
return HttpResponse(status=400)
|
|
|
|
try:
|
|
lat = float(lat_str)
|
|
lon = float(lon_str)
|
|
except ValueError:
|
|
return HttpResponse(status=400)
|
|
|
|
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
|
return HttpResponse(status=400)
|
|
|
|
if not tz_str:
|
|
try:
|
|
tz_resp = http_requests.get(
|
|
settings.PYSWISS_URL + '/api/tz/',
|
|
params={'lat': lat_str, 'lon': lon_str},
|
|
timeout=5,
|
|
)
|
|
tz_resp.raise_for_status()
|
|
tz_str = tz_resp.json().get('timezone') or 'UTC'
|
|
except Exception:
|
|
tz_str = 'UTC'
|
|
|
|
try:
|
|
tz = zoneinfo.ZoneInfo(tz_str)
|
|
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
|
return HttpResponse(status=400)
|
|
|
|
try:
|
|
local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M')
|
|
local_dt = local_dt.replace(tzinfo=tz)
|
|
utc_dt = local_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
|
|
dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
except ValueError:
|
|
return HttpResponse(status=400)
|
|
|
|
try:
|
|
resp = http_requests.get(
|
|
settings.PYSWISS_URL + '/api/chart/',
|
|
params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str},
|
|
timeout=5,
|
|
)
|
|
resp.raise_for_status()
|
|
except Exception:
|
|
return HttpResponse(status=502)
|
|
|
|
data = resp.json()
|
|
if 'elements' in data and 'Earth' in data['elements']:
|
|
data['elements']['Stone'] = data['elements'].pop('Earth')
|
|
data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
|
|
data['timezone'] = tz_str
|
|
return JsonResponse(data)
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def sky_view(request):
|
|
chart_data = request.user.sky_chart_data
|
|
birth_dt = request.user.sky_birth_dt
|
|
saved_birth_date = ''
|
|
saved_birth_time = ''
|
|
if birth_dt:
|
|
if request.user.sky_birth_tz:
|
|
try:
|
|
birth_dt = birth_dt.astimezone(zoneinfo.ZoneInfo(request.user.sky_birth_tz))
|
|
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
|
pass
|
|
saved_birth_date = birth_dt.strftime('%Y-%m-%d')
|
|
saved_birth_time = birth_dt.strftime('%H:%M')
|
|
return render(request, "apps/dashboard/sky.html", {
|
|
"preview_url": request.build_absolute_uri("/dashboard/sky/preview"),
|
|
"save_url": request.build_absolute_uri("/dashboard/sky/save"),
|
|
"saved_sky_json": json.dumps(chart_data) if chart_data else 'null',
|
|
"saved_birth_date": saved_birth_date,
|
|
"saved_birth_time": saved_birth_time,
|
|
"saved_birth_place": request.user.sky_birth_place,
|
|
"saved_birth_lat": request.user.sky_birth_lat,
|
|
"saved_birth_lon": request.user.sky_birth_lon,
|
|
"saved_birth_tz": request.user.sky_birth_tz,
|
|
"page_class": "page-sky" + (" sky-saved" if chart_data else ""),
|
|
})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def sky_preview(request):
|
|
return _sky_preview_data(request)
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def sky_save(request):
|
|
if request.method != 'POST':
|
|
return HttpResponse(status=405)
|
|
|
|
try:
|
|
body = json.loads(request.body)
|
|
except json.JSONDecodeError:
|
|
return HttpResponse(status=400)
|
|
|
|
user = request.user
|
|
birth_tz_str = body.get('birth_tz', '').strip()
|
|
birth_dt_str = body.get('birth_dt', '')
|
|
if birth_dt_str:
|
|
try:
|
|
naive = datetime.fromisoformat(birth_dt_str.replace('Z', '').replace('+00:00', ''))
|
|
if naive.tzinfo is None and birth_tz_str:
|
|
try:
|
|
local_tz = zoneinfo.ZoneInfo(birth_tz_str)
|
|
user.sky_birth_dt = naive.replace(tzinfo=local_tz).astimezone(
|
|
zoneinfo.ZoneInfo('UTC')
|
|
)
|
|
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
|
user.sky_birth_dt = naive.replace(tzinfo=zoneinfo.ZoneInfo('UTC'))
|
|
elif naive.tzinfo is None:
|
|
user.sky_birth_dt = naive.replace(tzinfo=zoneinfo.ZoneInfo('UTC'))
|
|
else:
|
|
user.sky_birth_dt = naive.astimezone(zoneinfo.ZoneInfo('UTC'))
|
|
except ValueError:
|
|
user.sky_birth_dt = None
|
|
user.sky_birth_lat = body.get('birth_lat')
|
|
user.sky_birth_lon = body.get('birth_lon')
|
|
user.sky_birth_place = body.get('birth_place', '')
|
|
user.sky_birth_tz = body.get('birth_tz', '')
|
|
user.sky_house_system = body.get('house_system', 'O')
|
|
user.sky_chart_data = body.get('chart_data')
|
|
|
|
user.save(update_fields=[
|
|
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
|
|
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
|
|
])
|
|
|
|
brief_payload = None
|
|
if user.sky_chart_data:
|
|
note, created, brief = Note.grant_if_new(user, "stargazer")
|
|
if created and brief is not None:
|
|
brief_payload = brief.to_banner_dict()
|
|
|
|
return JsonResponse({"saved": True, "brief": brief_payload})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def sky_delete(request):
|
|
if request.method != 'POST':
|
|
return HttpResponse(status=405)
|
|
user = request.user
|
|
user.sky_birth_dt = None
|
|
user.sky_birth_lat = None
|
|
user.sky_birth_lon = None
|
|
user.sky_birth_place = ''
|
|
user.sky_birth_tz = ''
|
|
user.sky_house_system = User._meta.get_field('sky_house_system').default
|
|
user.sky_chart_data = None
|
|
user.save(update_fields=[
|
|
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
|
|
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
|
|
])
|
|
return HttpResponseRedirect(reverse('sky'))
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def sky_data(request):
|
|
user = request.user
|
|
if not user.sky_chart_data:
|
|
return HttpResponse(status=404)
|
|
|
|
data = dict(user.sky_chart_data)
|
|
data['distinctions'] = _compute_distinctions(
|
|
data.get('planets', {}), data.get('houses', {})
|
|
)
|
|
return JsonResponse(data)
|