Files
python-tdd/src/apps/dashboard/views.py
Disco DeDisco 473e6bc45a rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00

433 lines
16 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, JsonResponse
from django.shortcuts import redirect, render
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.dashboard.forms import ExistingPostLineForm, LineForm
from apps.dashboard.models import Line, Post
from apps.drama.models import Note
from apps.epic.utils import _compute_distinctions
from apps.lyric.models import PaymentMethod, Token, User, Wallet
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, shoptalk="Placeholder") 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["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}"
else:
entry["shoptalk"] = "Placeholder"
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 _recent_posts(user, limit=3):
return (
Post
.objects
.filter(Q(owner=user) | Q(shared_with=user))
.annotate(last_line=Max('lines__id'))
.order_by('-last_line')
.distinct()[:limit]
)
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)
def new_post(request):
form = LineForm(data=request.POST)
if form.is_valid():
nupost = Post.objects.create()
if request.user.is_authenticated:
nupost.owner = request.user
nupost.save()
form.save(for_post=nupost)
return redirect(nupost)
else:
context = {
"form": form,
"page_class": "page-billboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "billboard")
context["recent_posts"] = _recent_posts(request.user)
return render(request, "apps/billboard/billboard.html", context)
def view_post(request, post_id):
our_post = Post.objects.get(id=post_id)
if our_post.owner:
if not request.user.is_authenticated:
return redirect("/")
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
return HttpResponseForbidden()
form = ExistingPostLineForm(for_post=our_post)
if request.method == "POST":
form = ExistingPostLineForm(for_post=our_post, data=request.POST)
if form.is_valid():
form.save()
return redirect(our_post)
return render(request, "apps/dashboard/post.html", {"post": our_post, "form": form})
def my_posts(request, user_id):
owner = User.objects.get(id=user_id)
if not request.user.is_authenticated:
return redirect("/")
if request.user.id != owner.id:
return HttpResponseForbidden()
return render(request, "apps/dashboard/my_posts.html", {"owner": owner})
def share_post(request, post_id):
our_post = Post.objects.get(id=post_id)
try:
recipient = User.objects.get(email=request.POST["recipient"])
if recipient == request.user:
return redirect(our_post)
our_post.shared_with.add(recipient)
except User.DoesNotExist:
pass
messages.success(request, "An invite has been sent if that address is registered.")
return redirect(our_post)
@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", "")
request.user.username = username
request.user.save(update_fields=["username"])
return redirect("/")
@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_natus_preview(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",
})
@login_required(login_url="/")
def sky_preview(request):
return _sky_natus_preview(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',
])
note_payload = None
if user.sky_chart_data:
note, created = Note.grant_if_new(user, "stargazer")
if created:
note_payload = {
"slug": note.slug,
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
"earned_at": note.earned_at.isoformat(),
}
return JsonResponse({"saved": True, "note": note_payload})
@login_required(login_url="/")
def sky_natus_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)