recognition: Recognition model w. grant_if_new; sky_save returns stargazer on first chart save — TDD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-22 02:13:29 -04:00
parent 83ce238a2f
commit be061f6bc2
5 changed files with 205 additions and 3 deletions

View File

@@ -2,7 +2,8 @@
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
sky_save — POST /dashboard/sky/save → saves natal data to User model;
grants Stargazer Recognition on first save with real chart_data
"""
import json
@@ -11,6 +12,7 @@ from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.urls import reverse
from apps.drama.models import Recognition
from apps.lyric.models import User
@@ -221,3 +223,68 @@ class SkyPreviewErrorPathTest(TestCase):
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
self.assertEqual(response.status_code, 502)
_REAL_CHART = {
"planets": {"Sun": {"degree": 66.7, "sign": "Gemini", "retrograde": False}},
"houses": {"cusps": [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150]},
"elements": {"Fire": 1, "Stone": 2, "Air": 4, "Water": 0, "Time": 1, "Space": 1},
"aspects": [],
}
class SkySaveRecognitionTest(TestCase):
"""sky_save grants the Stargazer Recognition on the first save with real chart_data."""
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, chart_data=_REAL_CHART):
return self.client.post(
self.url,
data=json.dumps({
"birth_dt": "1990-06-15T08:30:00",
"birth_lat": 51.5074,
"birth_lon": -0.1278,
"birth_place": "London, UK",
"birth_tz": "Europe/London",
"house_system": "O",
"chart_data": chart_data,
}),
content_type="application/json",
)
def test_first_save_with_chart_data_returns_stargazer_recognition(self):
data = self._post().json()
self.assertIn("recognition", data)
recog = data["recognition"]
self.assertEqual(recog["slug"], "stargazer")
self.assertIn("title", recog)
self.assertIn("description", recog)
self.assertIn("earned_at", recog)
def test_first_save_creates_recognition_in_db(self):
self._post()
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_second_save_returns_null_recognition(self):
self._post()
data = self._post().json()
self.assertIsNone(data["recognition"])
def test_second_save_does_not_create_duplicate_recognition(self):
self._post()
self._post()
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_save_with_empty_chart_data_does_not_grant_recognition(self):
data = self._post(chart_data={}).json()
self.assertIsNone(data["recognition"])
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 0)
def test_save_with_null_chart_data_does_not_grant_recognition(self):
data = self._post(chart_data=None).json()
self.assertIsNone(data["recognition"])
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 0)

View File

@@ -17,6 +17,7 @@ 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
@@ -372,7 +373,19 @@ def sky_save(request):
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
])
return JsonResponse({"saved": True})
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="/")

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-04-22 06:11
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0003_alter_gameevent_verb'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Recognition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=60)),
('earned_at', models.DateTimeField()),
('palette', models.CharField(blank=True, max_length=60, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recognitions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['earned_at'],
'unique_together': {('user', 'slug')},
},
),
]

View File

@@ -168,3 +168,28 @@ class ScrollPosition(models.Model):
def record(room, verb, actor=None, **data):
"""Record a game event in the drama log."""
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
class Recognition(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="recognitions",
)
slug = models.SlugField(max_length=60)
earned_at = models.DateTimeField()
palette = models.CharField(max_length=60, null=True, blank=True)
class Meta:
unique_together = [("user", "slug")]
ordering = ["earned_at"]
def __str__(self):
return f"{self.user.email}{self.slug}"
@classmethod
def grant_if_new(cls, user, slug):
from django.utils import timezone
return cls.objects.get_or_create(
user=user, slug=slug,
defaults={"earned_at": timezone.now()},
)

View File

@@ -1,7 +1,8 @@
from django.test import TestCase
from django.db import IntegrityError
from django.utils import timezone
from apps.drama.models import GameEvent, ScrollPosition, record
from apps.drama.models import GameEvent, Recognition, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
@@ -173,3 +174,69 @@ class ScrollPositionModelTest(TestCase):
defaults={"position": 200},
)
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)
class RecognitionModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="earner@test.io")
def test_can_create_recognition(self):
recog = Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.assertEqual(Recognition.objects.count(), 1)
self.assertEqual(recog.slug, "stargazer")
self.assertEqual(recog.user, self.user)
def test_palette_is_null_by_default(self):
recog = Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.assertIsNone(recog.palette)
def test_palette_can_be_set(self):
recog = Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.assertEqual(recog.palette, "palette-bardo")
def test_unique_per_user_and_slug(self):
Recognition.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
with self.assertRaises(IntegrityError):
Recognition.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
def test_different_users_can_share_slug(self):
other = User.objects.create(email="other@test.io")
Recognition.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Recognition.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.assertEqual(Recognition.objects.count(), 2)
def test_str_includes_slug_and_email(self):
recog = Recognition.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
s = str(recog)
self.assertIn("stargazer", s)
self.assertIn("earner@test.io", s)
def test_grant_if_new_creates_on_first_call(self):
recog, created = Recognition.grant_if_new(self.user, "stargazer")
self.assertTrue(created)
self.assertEqual(recog.slug, "stargazer")
self.assertIsNotNone(recog.earned_at)
def test_grant_if_new_is_idempotent(self):
Recognition.grant_if_new(self.user, "stargazer")
recog, created = Recognition.grant_if_new(self.user, "stargazer")
self.assertFalse(created)
self.assertEqual(Recognition.objects.count(), 1)
def test_grant_if_new_does_not_overwrite_palette(self):
Recognition.objects.create(
user=self.user, slug="stargazer",
earned_at=timezone.now(), palette="palette-bardo",
)
recog, created = Recognition.grant_if_new(self.user, "stargazer")
self.assertFalse(created)
self.assertEqual(recog.palette, "palette-bardo")