Files
python-tdd/src/apps/dashboard/views.py
Disco DeDisco 86a349b64e
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
wallet shop: free ($0) RWS + Fiorentine decks — FREE ITEM claim unlocks to Game Kit — TDD
- model: DeckVariant.free_in_shop flag (0015 schema); data migration 0016
  seeds RWS + Minchiate Fiorentine True (Earthman stays False — it's auto-
  granted at signup, not shopped)
- view: _free_decks_for decorates the free-in-shop catalog w. a per-user
  .owned flag; shop_claim_free POST endpoint adds the deck to unlocked_decks
  (idempotent M2M add) — the free_in_shop filter is the guard that stops the
  $0 endpoint unlocking paid/auto-granted decks (404 otherwise). free_decks
  wired into both the wallet view + toggle_wallet_applets HX context
- url: wallet/shop/claim (action, no trailing slash)
- template: free-deck tiles reuse the deck's own Game Kit tooltip prose
  (name / card-count / description / stock-version line) + a $0 .tt-price
  pinned top-right like paid tiles; .tt-micro carries .tt-free-btn (FREE
  ITEM) or the same .tt-already-owned pill once owned; reuses
  _deck_stack_icon.html
- js: wallet-shop.js _onFreeClick → _doClaimFree POSTs deck_slug → reload
  (server-rendered owned pill, same posture as the BUY reload). No guard
  portal — free = one-click. Rides the SAME delegated roots as BUY +
  idempotent wiring
- css: FREE ITEM wraps to 2 lines like BUY ITEM (extend the mini-portal
  .tt-buy-btn white-space:normal rule to .tt-free-btn); shop deck tiles get
  the Game Kit fan-out on hover/active by adding .shop-tile-deck to the
  .deck-stack-icon splay trigger list — DRY, no transform duplication
- tests: 8 ITs (shop_claim_free behaviors + free_decks context owned flag);
  FT claims RWS → 'Already owned' swap → id_kit_tarot_deck appears in Game
  Kit; 3 Jasmine specs F1-F3 (claim POST / no-guard / idempotent wiring);
  679 dashboard+epic green, no regressions
- trap: hover-hidden microtooltip btn → .text is '' under Selenium; read
  get_attribute('textContent') instead [[feedback-selenium-opacity-zero]]

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:51:21 -04:00

639 lines
26 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 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
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},
{"name": "palette-baltimore", "label": "Baltimore", "locked": True},
{"name": "palette-maryland", "label": "Maryland", "locked": True},
]
_NOTE_TITLES = {
"stargazer": "Stargazer",
"schizo": "Schizo",
"nomad": "Nomad",
"baltimorean": "Baltimorean",
}
# 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")
# 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)
# 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"])
# bawlmorese is the pronoun-side trigger for the Baltimorean Note unlock —
# mirrors sky_save's stargazer grant. Grant is idempotent (grant_if_new
# returns no Brief on the second + later calls) so the 204 path resumes
# naturally after the first unlock.
if choice == "bawlmorese":
_note, _created, brief = Note.grant_if_new(request.user, "baltimorean")
if brief is not None:
return JsonResponse({"brief": brief.to_banner_dict()})
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")
def _shop_items_for(user):
"""Decorate the active ShopItem catalog w. per-user availability so the
template can render `.btn-disabled` + 'Already owned' microtooltip
for `max_owned`-capped items the user already holds. Items are returned
in `display_order` ASC (matches the seeded `tithe-1` < `tithe-5` < `band-1`)."""
items = []
for item in ShopItem.objects.filter(active=True).order_by("display_order", "slug"):
item.available = item.is_available_for(user)
items.append(item)
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):
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))
shop_items = _shop_items_for(request.user)
default_pm = request.user.payment_methods.order_by("-pk").first()
return render(request, "apps/dashboard/wallet.html", {
"wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
"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,
"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"):
default_pm = request.user.payment_methods.order_by("-pk").first()
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() if request.user.is_staff else None,
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
"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)),
"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})
# ── Shop: PaymentIntent flow ─────────────────────────────────────────────────
# Three endpoints split fulfillment responsibility:
# /shop/buy creates a Purchase (PENDING) + a Stripe PaymentIntent.
# Returns client_secret so Stripe.js can confirmCardPayment
# (handles 3DS natively).
# /shop/confirm sync follow-up after Stripe.js confirms client-side. Pulls
# PI status from Stripe; if SUCCEEDED, calls Purchase.fulfill()
# immediately (faster UX than waiting for the webhook round-trip).
# /stripe/webhook async fulfillment from Stripe's webhook delivery. Same
# Purchase.fulfill() call — whichever (confirm or webhook)
# lands first wins; the other becomes a no-op via fulfill()'s
# idempotent guard.
#
# Decisions locked 2026-05-21 in [[project-wallet-shop-expansion]]:
# * Webhook is THE authoritative source for fulfillment (resilient to 3DS,
# network drops, browser closes during checkout).
# * Confirm endpoint is a UX-speedup belt-and-suspenders; never required.
# * Webhook idempotency via Purchase.fulfill()'s status==SUCCEEDED guard.
# * No STRIPE_LIVE_MODE setting — env-var swap is all that's needed.
@login_required(login_url="/")
def shop_buy(request):
"""Create a Stripe PaymentIntent + a PENDING Purchase row.
Body: `shop_item_slug` (form-encoded).
Returns: 200 `{client_secret, purchase_id}` on success;
402 if the user has no saved PaymentMethod;
404 if the slug doesn't match an active ShopItem;
409 if the item's max_owned cap is reached for this user.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
slug = request.POST.get("shop_item_slug", "")
item = ShopItem.objects.filter(slug=slug, active=True).first()
if item is None:
return HttpResponse(status=404)
if not item.is_available_for(request.user):
return HttpResponse(status=409)
pm = request.user.payment_methods.order_by("-pk").first()
if pm is None:
return HttpResponse(status=402)
intent = stripe.PaymentIntent.create(
amount=item.price_cents,
currency="usd",
customer=request.user.stripe_customer_id,
payment_method=pm.stripe_pm_id,
# `automatic_payment_methods` so Stripe.js picks the right confirm
# method (cards, wallets, etc.) without us hard-coding payment-method-
# type plumbing. `allow_redirects=never` keeps the 3DS challenge in
# the same window — Stripe.js handles the modal natively.
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
metadata={
# Webhook handler looks up the Purchase by this on
# `payment_intent.succeeded`. Belt-and-suspenders w. looking up
# by `stripe_payment_intent_id` (also unique).
"purchase_id": "_pending_", # overwritten after Purchase.save() below
},
)
purchase = Purchase.objects.create(
user=request.user,
shop_item=item,
stripe_payment_intent_id=intent.id,
amount_cents=item.price_cents,
granted_writs=item.granted_writs,
)
# Now we have purchase.pk — backfill the metadata on the PI so the
# webhook handler can resolve back to it.
stripe.PaymentIntent.modify(
intent.id, metadata={"purchase_id": str(purchase.pk)},
)
return JsonResponse({
"client_secret": intent.client_secret,
"purchase_id": purchase.pk,
})
@login_required(login_url="/")
def shop_confirm(request):
"""Sync follow-up after Stripe.js confirms client-side. Polls the PI
once + fulfills if SUCCEEDED. Idempotent w. the webhook handler via
`Purchase.fulfill()`'s status guard.
Body: `purchase_id` (form-encoded).
Returns: 200 always (sync fulfillment is best-effort; webhook is
authoritative). 404 if the purchase doesn't belong to this user.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
purchase_id = request.POST.get("purchase_id")
purchase = Purchase.objects.filter(
pk=purchase_id, user=request.user,
).first()
if purchase is None:
return HttpResponse(status=404)
if purchase.status != Purchase.SUCCEEDED:
intent = stripe.PaymentIntent.retrieve(purchase.stripe_payment_intent_id)
if intent.status == "succeeded":
purchase.fulfill()
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
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
`Purchase.fulfill()` (idempotent w. `/shop/confirm`).
Always returns 2xx (even on unknown event types or already-fulfilled
purchases) — Stripe retries on 5xx, which would just deliver the same
event repeatedly. 4xx is reserved for signature mismatch (a genuine
auth failure that Stripe should NOT retry).
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
payload = request.body
sig_header = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, Exception) as e:
# ValueError = invalid payload; SignatureVerificationError = bad sig.
# Either way, refuse — Stripe will alert if it can't deliver.
if isinstance(e, ValueError) or "Signature" in type(e).__name__:
return HttpResponse(status=400)
raise
if event["type"] == "payment_intent.succeeded":
intent = event["data"]["object"]
purchase_id = intent.get("metadata", {}).get("purchase_id")
purchase = None
if purchase_id and purchase_id.isdigit():
purchase = Purchase.objects.filter(pk=int(purchase_id)).first()
# Fall-back lookup by PI ID in case metadata's missing for any reason.
if purchase is None:
purchase = Purchase.objects.filter(
stripe_payment_intent_id=intent.get("id", ""),
).first()
if purchase is not None:
purchase.fulfill()
return HttpResponse(status=200)
# ── 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)