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 ExistingNoteItemForm, ItemForm from apps.dashboard.models import Item, Note from apps.drama.models import Recognition from apps.epic.utils import _compute_distinctions from apps.lyric.models import PaymentMethod, Token, User, Wallet APPLET_ORDER = ["wallet", "new-note", "my-notes", "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}, ] _RECOGNITION_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 Recognition.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 = _RECOGNITION_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 Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette=""): base.add(r.palette) return base def _recent_notes(user, limit=3): return ( Note .objects .filter(Q(owner=user) | Q(shared_with=user)) .annotate(last_item=Max('item__id')) .order_by('-last_item') .distinct()[:limit] ) def home_page(request): context = { "form": ItemForm(), "palettes": _palettes_for_user(request.user), "page_class": "page-dashboard", } if request.user.is_authenticated: context["applets"] = applet_context(request.user, "dashboard") context["recent_notes"] = _recent_notes(request.user) return render(request, "apps/dashboard/home.html", context) def new_note(request): form = ItemForm(data=request.POST) if form.is_valid(): nunote = Note.objects.create() if request.user.is_authenticated: nunote.owner = request.user nunote.save() form.save(for_note=nunote) return redirect(nunote) else: context = { "form": form, "palettes": _palettes_for_user(request.user), "page_class": "page-dashboard", } if request.user.is_authenticated: context["applets"] = applet_context(request.user, "dashboard") context["recent_notes"] = _recent_notes(request.user) return render(request, "apps/dashboard/home.html", context) def view_note(request, note_id): our_note = Note.objects.get(id=note_id) if our_note.owner: if not request.user.is_authenticated: return redirect("/") if request.user != our_note.owner and request.user not in our_note.shared_with.all(): return HttpResponseForbidden() form = ExistingNoteItemForm(for_note=our_note) if request.method == "POST": form = ExistingNoteItemForm(for_note=our_note, data=request.POST) if form.is_valid(): form.save() return redirect(our_note) return render(request, "apps/dashboard/note.html", {"note": our_note, "form": form}) def my_notes(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_notes.html", {"owner": owner}) def share_note(request, note_id): our_note = Note.objects.get(id=note_id) try: recipient = User.objects.get(email=request.POST["recipient"]) if recipient == request.user: return redirect(our_note) our_note.shared_with.add(recipient) except User.DoesNotExist: pass messages.success(request, "An invite has been sent if that address is registered.") return redirect(our_note) @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), "form": ItemForm(), "recent_notes": _recent_notes(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', ]) recognition_payload = None if user.sky_chart_data: recog, created = Recognition.grant_if_new(user, "stargazer") if created: recognition_payload = { "slug": recog.slug, "title": "Stargazer", "description": "You saved your first personal sky chart.", "earned_at": recog.earned_at.isoformat(), } return JsonResponse({"saved": True, "recognition": recognition_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)