From abf8be886101dbd747606a7c072d14850a3f5cb2 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Thu, 16 Apr 2026 03:03:19 -0400 Subject: [PATCH] =?UTF-8?q?MY=20SKY:=20dashboard=20applet=20+=20full-page?= =?UTF-8?q?=20natal=20chart=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User model: sky_birth_dt/lat/lon/place/house_system/chart_data fields (lyric migration 0018) - Applet seed: my-sky (6×6, dashboard context) via applets migration 0009 - dashboard/sky/ — full monoapplet page: natus form + D3 wheel, LS key scoped to dashboard:sky; saves to User model - dashboard/sky/preview/ — PySwiss proxy (same logic as epic:natus_preview, no seat check) - dashboard/sky/save/ — persists 6 sky fields to User via update_fields - _applet-my-sky.html: tile with h2 link to /dashboard/sky/ - 2 FTs (applet→link→form, localStorage persistence) + 12 ITs — all green Co-Authored-By: Claude Sonnet 4.6 --- .../applets/migrations/0009_my_sky_applet.py | 24 ++ .../tests/integrated/test_sky_views.py | 152 ++++++++ src/apps/dashboard/urls.py | 3 + src/apps/dashboard/views.py | 119 ++++++ .../lyric/migrations/0018_user_sky_fields.py | 40 ++ src/apps/lyric/models.py | 8 + src/functional_tests/test_my_sky.py | 118 ++++++ .../dashboard/_partials/_applet-my-sky.html | 6 + src/templates/apps/dashboard/sky.html | 347 ++++++++++++++++++ 9 files changed, 817 insertions(+) create mode 100644 src/apps/applets/migrations/0009_my_sky_applet.py create mode 100644 src/apps/dashboard/tests/integrated/test_sky_views.py create mode 100644 src/apps/lyric/migrations/0018_user_sky_fields.py create mode 100644 src/functional_tests/test_my_sky.py create mode 100644 src/templates/apps/dashboard/_partials/_applet-my-sky.html create mode 100644 src/templates/apps/dashboard/sky.html diff --git a/src/apps/applets/migrations/0009_my_sky_applet.py b/src/apps/applets/migrations/0009_my_sky_applet.py new file mode 100644 index 0000000..07ccd1f --- /dev/null +++ b/src/apps/applets/migrations/0009_my_sky_applet.py @@ -0,0 +1,24 @@ +from django.db import migrations + + +def seed_my_sky_applet(apps, schema_editor): + Applet = apps.get_model('applets', 'Applet') + Applet.objects.get_or_create( + slug='my-sky', + defaults={ + 'name': 'My Sky', + 'grid_cols': 6, + 'grid_rows': 6, + 'context': 'dashboard', + }, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('applets', '0008_game_kit_applets'), + ] + + operations = [ + migrations.RunPython(seed_my_sky_applet, migrations.RunPython.noop) + ] diff --git a/src/apps/dashboard/tests/integrated/test_sky_views.py b/src/apps/dashboard/tests/integrated/test_sky_views.py new file mode 100644 index 0000000..d907a86 --- /dev/null +++ b/src/apps/dashboard/tests/integrated/test_sky_views.py @@ -0,0 +1,152 @@ +"""Integration tests for the My Sky dashboard views. + +sky_view — GET /dashboard/sky/ → renders sky template +sky_preview — GET /dashboard/sky/preview/ → proxies to PySwiss (no DB write) +sky_save — POST /dashboard/sky/save/ → saves natal data to User model +""" + +import json +from unittest.mock import patch, MagicMock + +from django.test import TestCase +from django.urls import reverse + +from apps.lyric.models import User + + +class SkyViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="star@test.io") + self.client.force_login(self.user) + + def test_sky_view_renders_template(self): + response = self.client.get(reverse("sky")) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "apps/dashboard/sky.html") + + def test_sky_view_requires_login(self): + self.client.logout() + response = self.client.get(reverse("sky")) + self.assertEqual(response.status_code, 302) + self.assertIn("/?next=", response["Location"]) + + def test_sky_view_passes_preview_and_save_urls(self): + response = self.client.get(reverse("sky")) + self.assertContains(response, reverse("sky_preview")) + self.assertContains(response, reverse("sky_save")) + + +class SkyPreviewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="star@test.io") + self.client.force_login(self.user) + self.url = reverse("sky_preview") + + def test_requires_login(self): + self.client.logout() + response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"}) + self.assertEqual(response.status_code, 302) + self.assertIn("/?next=", response["Location"]) + + def test_missing_params_returns_400(self): + response = self.client.get(self.url, {"date": "1990-06-15"}) + self.assertEqual(response.status_code, 400) + + def test_invalid_lat_returns_400(self): + response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"}) + self.assertEqual(response.status_code, 400) + + @patch("apps.dashboard.views.http_requests") + def test_proxies_to_pyswiss_and_returns_chart(self, mock_requests): + chart_payload = { + "planets": {"Sun": {"degree": 84.5, "sign": "Gemini", "retrograde": False}}, + "houses": {"cusps": [0]*12}, + "elements": {"Fire": 1, "Earth": 0, "Air": 0, "Water": 0}, + "house_system": "O", + } + tz_response = MagicMock() + tz_response.json.return_value = {"timezone": "Europe/London"} + tz_response.raise_for_status = MagicMock() + + chart_response = MagicMock() + chart_response.json.return_value = chart_payload + chart_response.raise_for_status = MagicMock() + + mock_requests.get.side_effect = [tz_response, chart_response] + + response = self.client.get(self.url, { + "date": "1990-06-15", "time": "09:30", + "lat": "51.5074", "lon": "-0.1278", + }) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("planets", data) + # Earth→Stone rename applied + self.assertIn("Stone", data["elements"]) + self.assertNotIn("Earth", data["elements"]) + self.assertIn("timezone", data) + self.assertIn("distinctions", data) + + +class SkySaveTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="star@test.io") + self.client.force_login(self.user) + self.url = reverse("sky_save") + + def _post(self, payload): + return self.client.post( + self.url, + data=json.dumps(payload), + content_type="application/json", + ) + + def test_requires_login(self): + self.client.logout() + response = self._post({}) + self.assertEqual(response.status_code, 302) + self.assertIn("/?next=", response["Location"]) + + def test_get_not_allowed(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 405) + + def test_saves_sky_fields_to_user(self): + chart = {"planets": {}, "houses": {}, "elements": {}} + payload = { + "birth_dt": "1990-06-15T08:30:00", + "birth_lat": 51.5074, + "birth_lon": -0.1278, + "birth_place": "London, UK", + "house_system": "O", + "chart_data": chart, + } + response = self._post(payload) + self.assertEqual(response.status_code, 200) + + self.user.refresh_from_db() + self.assertEqual(str(self.user.sky_birth_dt), "1990-06-15 08:30:00+00:00") + self.assertAlmostEqual(float(self.user.sky_birth_lat), 51.5074, places=3) + self.assertAlmostEqual(float(self.user.sky_birth_lon), -0.1278, places=3) + self.assertEqual(self.user.sky_birth_place, "London, UK") + self.assertEqual(self.user.sky_house_system, "O") + self.assertEqual(self.user.sky_chart_data, chart) + + def test_invalid_json_returns_400(self): + response = self.client.post( + self.url, data="not json", content_type="application/json" + ) + self.assertEqual(response.status_code, 400) + + def test_response_contains_saved_flag(self): + payload = { + "birth_dt": "1990-06-15T08:30:00", + "birth_lat": 51.5, + "birth_lon": -0.1, + "birth_place": "", + "house_system": "O", + "chart_data": {}, + } + data = self._post(payload).json() + self.assertTrue(data["saved"]) diff --git a/src/apps/dashboard/urls.py b/src/apps/dashboard/urls.py index a9d43b8..d58351d 100644 --- a/src/apps/dashboard/urls.py +++ b/src/apps/dashboard/urls.py @@ -14,4 +14,7 @@ urlpatterns = [ path('wallet/setup-intent', views.setup_intent, name='setup_intent'), path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'), 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'), + path('sky/save/', views.sky_save, name='sky_save'), ] diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index b449f9e..08ea0e0 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -1,4 +1,9 @@ +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 @@ -236,3 +241,117 @@ def save_payment_method(request): 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) + + from apps.epic.views import _compute_distinctions + 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): + 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": request.user.sky_chart_data, + "saved_birth_dt": request.user.sky_birth_dt, + "saved_birth_place": request.user.sky_birth_place, + }) + + +@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_dt_str = body.get('birth_dt', '') + if birth_dt_str: + try: + naive = datetime.fromisoformat(birth_dt_str.replace('Z', '+00:00')) + user.sky_birth_dt = naive if naive.tzinfo else naive.replace( + tzinfo=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_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_house_system', 'sky_chart_data', + ]) + return JsonResponse({"saved": True}) diff --git a/src/apps/lyric/migrations/0018_user_sky_fields.py b/src/apps/lyric/migrations/0018_user_sky_fields.py new file mode 100644 index 0000000..7468a17 --- /dev/null +++ b/src/apps/lyric/migrations/0018_user_sky_fields.py @@ -0,0 +1,40 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('lyric', '0017_ap_keypair_fields'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='sky_birth_dt', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='user', + name='sky_birth_lat', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=9, null=True), + ), + migrations.AddField( + model_name='user', + name='sky_birth_lon', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=9, null=True), + ), + migrations.AddField( + model_name='user', + name='sky_birth_place', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='user', + name='sky_house_system', + field=models.CharField(blank=True, default='O', max_length=1), + ), + migrations.AddField( + model_name='user', + name='sky_chart_data', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index fe97d7b..5e2b752 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -47,6 +47,14 @@ class User(AbstractBaseUser): ap_public_key = models.TextField(blank=True, default="") ap_private_key = models.TextField(blank=True, default="") + # Personal natal chart (My Sky) — independent of any game room/character + sky_birth_dt = models.DateTimeField(null=True, blank=True) + sky_birth_lat = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True) + sky_birth_lon = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True) + sky_birth_place = models.CharField(max_length=255, blank=True) + sky_house_system = models.CharField(max_length=1, blank=True, default="O") + sky_chart_data = models.JSONField(null=True, blank=True) + is_staff = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False) diff --git a/src/functional_tests/test_my_sky.py b/src/functional_tests/test_my_sky.py new file mode 100644 index 0000000..00cced9 --- /dev/null +++ b/src/functional_tests/test_my_sky.py @@ -0,0 +1,118 @@ +"""Functional tests for the My Sky dashboard feature. + +My Sky is a dashboard applet linking to /dashboard/sky/ — a full-page +natus (natal chart) interface where the user can save their personal sky +to their account (stored on the User model, independent of any game room). +""" + +from selenium.webdriver.common.by import By + +from apps.applets.models import Applet +from apps.lyric.models import User + +from .base import FunctionalTest + + +class MySkyAppletTest(FunctionalTest): + """My Sky applet appears on the dashboard and links to the sky page.""" + + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="my-sky", + defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"}, + ) + self.gamer = User.objects.create(email="stargazer@test.io") + + # ── T1 ─────────────────────────────────────────────────────────────────── + + def test_my_sky_applet_links_to_sky_page_with_form(self): + """Applet is visible on dashboard; link leads to /dashboard/sky/ with + all natus form fields present.""" + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.live_server_url) + + # 1. Applet is on the dashboard + applet = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_applet_my_sky") + ) + + # 2. Heading contains a link whose text is "My Sky" + link = applet.find_element(By.CSS_SELECTOR, "h2 a") + self.assertIn("MY SKY", link.text.upper()) + + # 3. Clicking the link navigates to /dashboard/sky/ + link.click() + self.wait_for( + lambda: self.assertRegex(self.browser.current_url, r"/dashboard/sky/$") + ) + + # 4. All natus form fields are present + self.browser.find_element(By.ID, "id_nf_date") + self.browser.find_element(By.ID, "id_nf_time") + self.browser.find_element(By.ID, "id_nf_place") + self.browser.find_element(By.ID, "id_nf_lat") + self.browser.find_element(By.ID, "id_nf_lon") + self.browser.find_element(By.ID, "id_nf_tz") + self.browser.find_element(By.ID, "id_natus_confirm") + + +class MySkyLocalStorageTest(FunctionalTest): + """My Sky form fields persist to localStorage across visits.""" + + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="my-sky", + defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"}, + ) + self.gamer = User.objects.create(email="stargazer@test.io") + self.sky_url = self.live_server_url + "/dashboard/sky/" + + def _fill_form(self): + """Set date, lat, lon directly — bypasses Nominatim network call.""" + self.browser.execute_script( + "document.getElementById('id_nf_date').value = '1990-06-15';" + "document.getElementById('id_nf_lat').value = '51.5074';" + "document.getElementById('id_nf_lon').value = '-0.1278';" + "document.getElementById('id_nf_place').value = 'London, UK';" + "document.getElementById('id_nf_tz').value = 'Europe/London';" + ) + # Fire input events so the localStorage save listener triggers + self.browser.execute_script(""" + ['id_nf_date','id_nf_lat','id_nf_lon','id_nf_place','id_nf_tz'].forEach(id => { + document.getElementById(id) + .dispatchEvent(new Event('input', {bubbles: true})); + }); + """) + + def _field_values(self): + return self.browser.execute_script(""" + return { + date: document.getElementById('id_nf_date').value, + lat: document.getElementById('id_nf_lat').value, + lon: document.getElementById('id_nf_lon').value, + place: document.getElementById('id_nf_place').value, + tz: document.getElementById('id_nf_tz').value, + }; + """) + + # ── T2 ─────────────────────────────────────────────────────────────────── + + def test_sky_form_fields_repopulated_after_page_refresh(self): + """Form values survive a full page refresh via localStorage.""" + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.sky_url) + + self.wait_for(lambda: self.browser.find_element(By.ID, "id_nf_date")) + self._fill_form() + + self.browser.refresh() + + self.wait_for(lambda: self.browser.find_element(By.ID, "id_nf_date")) + values = self._field_values() + self.assertEqual(values["date"], "1990-06-15") + self.assertEqual(values["lat"], "51.5074") + self.assertEqual(values["lon"], "-0.1278") + self.assertEqual(values["place"], "London, UK") + self.assertEqual(values["tz"], "Europe/London") diff --git a/src/templates/apps/dashboard/_partials/_applet-my-sky.html b/src/templates/apps/dashboard/_partials/_applet-my-sky.html new file mode 100644 index 0000000..4263480 --- /dev/null +++ b/src/templates/apps/dashboard/_partials/_applet-my-sky.html @@ -0,0 +1,6 @@ +
+

My Sky

+
diff --git a/src/templates/apps/dashboard/sky.html b/src/templates/apps/dashboard/sky.html new file mode 100644 index 0000000..ea676cd --- /dev/null +++ b/src/templates/apps/dashboard/sky.html @@ -0,0 +1,347 @@ +{% extends "core/base.html" %} +{% load static %} + +{% block title_text %}My Sky{% endblock title_text %} +{% block header_text %}Dashsky{% endblock header_text %} + +{% block content %} +
+ +
+ + {# ── Form column ─────────────────────────────────────────────────── #} +
+
+
+ +
+ + +
+ +
+ + + Local time at birth place. Use 12:00 if unknown. +
+ +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ +
+ +
+
{# /.natus-form-main #} + + +
+ + {# ── Wheel column ────────────────────────────────────────────────── #} +
+ +
+ +
{# /.natus-modal-body #} +
{# /.sky-page #} + +{# Planet hover tooltip — position:fixed escapes any overflow:hidden ancestor #} + + + + + +{% endblock %}