refactor: extract apply_applet_toggle, rooms_for_user & natus helpers to utils; DRY toggle views
- epic/utils.py (new): _planet_house, _compute_distinctions, rooms_for_user - applets/utils.py: apply_applet_toggle replaces 5 copy-pasted toggle loops - dashboard/views.py: use apply_applet_toggle; fix double free_tokens/tithe_tokens query in wallet(); promote _compute_distinctions import to module level - gameboard/views.py: use apply_applet_toggle & rooms_for_user; fix double free_tokens query in toggle_game_applets - billboard/views.py: use apply_applet_toggle & rooms_for_user - SCSS: %tt-token-fields placeholder in _tooltips.scss; _gameboard & _game-kit @extend it - epic/tests/unit/test_utils.py (new): coverage for _planet_house fallback path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,16 @@
|
|||||||
from apps.applets.models import Applet, UserApplet
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
|
||||||
|
|
||||||
|
def apply_applet_toggle(user, context, checked_slugs):
|
||||||
|
"""Persist applet visibility choices for a given context."""
|
||||||
|
for applet in Applet.objects.filter(context=context):
|
||||||
|
UserApplet.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
applet=applet,
|
||||||
|
defaults={"visible": applet.slug in checked_slugs},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def applet_context(user, context):
|
def applet_context(user, context):
|
||||||
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
|
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
|
||||||
applets = {a.slug: a for a in Applet.objects.filter(context=context)}
|
applets = {a.slug: a for a in Applet.objects.filter(context=context)}
|
||||||
|
|||||||
@@ -2,19 +2,15 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.db.models import Max, Q
|
from django.db.models import Max, Q
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
from apps.applets.models import Applet, UserApplet
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||||
from apps.applets.utils import applet_context
|
|
||||||
from apps.drama.models import GameEvent, ScrollPosition
|
from apps.drama.models import GameEvent, ScrollPosition
|
||||||
from apps.epic.models import GateSlot, Room, RoomInvite
|
from apps.epic.models import Room
|
||||||
|
from apps.epic.utils import rooms_for_user
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def billboard(request):
|
def billboard(request):
|
||||||
my_rooms = Room.objects.filter(
|
my_rooms = rooms_for_user(request.user).order_by("-created_at")
|
||||||
Q(owner=request.user) |
|
|
||||||
Q(gate_slots__gamer=request.user) |
|
|
||||||
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
|
|
||||||
).distinct().order_by("-created_at")
|
|
||||||
|
|
||||||
recent_room = (
|
recent_room = (
|
||||||
Room.objects.filter(
|
Room.objects.filter(
|
||||||
@@ -50,12 +46,7 @@ def billboard(request):
|
|||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def toggle_billboard_applets(request):
|
def toggle_billboard_applets(request):
|
||||||
checked = request.POST.getlist("applets")
|
checked = request.POST.getlist("applets")
|
||||||
for applet in Applet.objects.filter(context="billboard"):
|
apply_applet_toggle(request.user, "billboard", checked)
|
||||||
UserApplet.objects.update_or_create(
|
|
||||||
user=request.user,
|
|
||||||
applet=applet,
|
|
||||||
defaults={"visible": applet.slug in checked},
|
|
||||||
)
|
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
return render(request, "apps/billboard/_partials/_applets.html", {
|
return render(request, "apps/billboard/_partials/_applets.html", {
|
||||||
"applets": applet_context(request.user, "billboard"),
|
"applets": applet_context(request.user, "billboard"),
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ from django.shortcuts import redirect, render
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
|
||||||
from apps.applets.models import Applet, UserApplet
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||||
from apps.applets.utils import applet_context
|
|
||||||
from apps.dashboard.forms import ExistingNoteItemForm, ItemForm
|
from apps.dashboard.forms import ExistingNoteItemForm, ItemForm
|
||||||
from apps.dashboard.models import Item, Note
|
from apps.dashboard.models import Item, Note
|
||||||
|
from apps.epic.utils import _compute_distinctions
|
||||||
from apps.lyric.models import PaymentMethod, Token, User, Wallet
|
from apps.lyric.models import PaymentMethod, Token, User, Wallet
|
||||||
|
|
||||||
|
|
||||||
@@ -142,12 +142,7 @@ def set_profile(request):
|
|||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def toggle_applets(request):
|
def toggle_applets(request):
|
||||||
checked = request.POST.getlist("applets")
|
checked = request.POST.getlist("applets")
|
||||||
for applet in Applet.objects.filter(context="dashboard"):
|
apply_applet_toggle(request.user, "dashboard", checked)
|
||||||
UserApplet.objects.update_or_create(
|
|
||||||
user=request.user,
|
|
||||||
applet=applet,
|
|
||||||
defaults={"visible": applet.slug in checked},
|
|
||||||
)
|
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
return render(request, "apps/dashboard/_partials/_applets.html", {
|
return render(request, "apps/dashboard/_partials/_applets.html", {
|
||||||
"applets": applet_context(request.user, "dashboard"),
|
"applets": applet_context(request.user, "dashboard"),
|
||||||
@@ -160,18 +155,18 @@ def toggle_applets(request):
|
|||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
@ensure_csrf_cookie
|
@ensure_csrf_cookie
|
||||||
def wallet(request):
|
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", {
|
return render(request, "apps/dashboard/wallet.html", {
|
||||||
"wallet": request.user.wallet,
|
"wallet": request.user.wallet,
|
||||||
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
|
||||||
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||||
"free_tokens": list(request.user.tokens.filter(
|
"free_tokens": free_tokens,
|
||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
"tithe_tokens": tithe_tokens,
|
||||||
).order_by("expires_at")),
|
"free_count": len(free_tokens),
|
||||||
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
"tithe_count": len(tithe_tokens),
|
||||||
"free_count": request.user.tokens.filter(
|
|
||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
||||||
).count(),
|
|
||||||
"tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(),
|
|
||||||
"applets": applet_context(request.user, "wallet"),
|
"applets": applet_context(request.user, "wallet"),
|
||||||
"page_class": "page-wallet",
|
"page_class": "page-wallet",
|
||||||
})
|
})
|
||||||
@@ -197,12 +192,7 @@ def kit_bag(request):
|
|||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def toggle_wallet_applets(request):
|
def toggle_wallet_applets(request):
|
||||||
checked = request.POST.getlist("applets")
|
checked = request.POST.getlist("applets")
|
||||||
for applet in Applet.objects.filter(context="wallet"):
|
apply_applet_toggle(request.user, "wallet", checked)
|
||||||
UserApplet.objects.update_or_create(
|
|
||||||
user=request.user,
|
|
||||||
applet=applet,
|
|
||||||
defaults={"visible": applet.slug in checked},
|
|
||||||
)
|
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
return render(request, "apps/wallet/_partials/_applets.html", {
|
return render(request, "apps/wallet/_partials/_applets.html", {
|
||||||
"applets": applet_context(request.user, "wallet"),
|
"applets": applet_context(request.user, "wallet"),
|
||||||
@@ -300,7 +290,6 @@ def _sky_natus_preview(request):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return HttpResponse(status=502)
|
return HttpResponse(status=502)
|
||||||
|
|
||||||
from apps.epic.views import _compute_distinctions
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if 'elements' in data and 'Earth' in data['elements']:
|
if 'elements' in data and 'Earth' in data['elements']:
|
||||||
data['elements']['Stone'] = data['elements'].pop('Earth')
|
data['elements']['Stone'] = data['elements'].pop('Earth')
|
||||||
|
|||||||
13
src/apps/epic/tests/unit/test_utils.py
Normal file
13
src/apps/epic/tests/unit/test_utils.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.epic.utils import _planet_house
|
||||||
|
|
||||||
|
|
||||||
|
class PlanetHouseFallbackTest(SimpleTestCase):
|
||||||
|
def test_returns_1_when_no_cusp_matches(self):
|
||||||
|
# Pathological cusps list: all 12 cusps identical (zero-width arcs).
|
||||||
|
# No range has start < end, and the wrap-around condition is also
|
||||||
|
# never satisfied, so the loop exhausts without returning — hitting
|
||||||
|
# the fallback `return 1`.
|
||||||
|
cusps = [0.0] * 12
|
||||||
|
self.assertEqual(_planet_house(180.0, cusps), 1)
|
||||||
41
src/apps/epic/utils.py
Normal file
41
src/apps/epic/utils.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from apps.epic.models import Room, RoomInvite
|
||||||
|
|
||||||
|
|
||||||
|
def _planet_house(degree, cusps):
|
||||||
|
"""Return 1-based house number for a planet at ecliptic degree.
|
||||||
|
|
||||||
|
cusps is the 12-element list from PySwiss where cusps[i] is the start of
|
||||||
|
house i+1. Handles the wrap-around case where a cusp crosses 0°/360°.
|
||||||
|
"""
|
||||||
|
degree = degree % 360
|
||||||
|
for i in range(12):
|
||||||
|
start = cusps[i] % 360
|
||||||
|
end = cusps[(i + 1) % 12] % 360
|
||||||
|
if start < end:
|
||||||
|
if start <= degree < end:
|
||||||
|
return i + 1
|
||||||
|
else: # wrap-around: e.g. cusp at 350° → next at 10°
|
||||||
|
if degree >= start or degree < end:
|
||||||
|
return i + 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_distinctions(planets, houses):
|
||||||
|
"""Return dict {house_number_str: planet_count} for all 12 houses."""
|
||||||
|
cusps = houses['cusps']
|
||||||
|
counts = {str(i): 0 for i in range(1, 13)}
|
||||||
|
for planet_data in planets.values():
|
||||||
|
h = _planet_house(planet_data['degree'], cusps)
|
||||||
|
counts[str(h)] += 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def rooms_for_user(user):
|
||||||
|
"""Return a queryset of rooms the user owns, has a gate slot in, or is invited to."""
|
||||||
|
return Room.objects.filter(
|
||||||
|
Q(owner=user) |
|
||||||
|
Q(gate_slots__gamer=user) |
|
||||||
|
Q(invites__invitee_email=user.email, invites__status=RoomInvite.PENDING)
|
||||||
|
).distinct()
|
||||||
@@ -22,6 +22,7 @@ from apps.epic.models import (
|
|||||||
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
|
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
|
||||||
select_token, sig_deck_cards,
|
select_token, sig_deck_cards,
|
||||||
)
|
)
|
||||||
|
from apps.epic.utils import _compute_distinctions, _planet_house
|
||||||
from apps.lyric.models import Token
|
from apps.lyric.models import Token
|
||||||
|
|
||||||
|
|
||||||
@@ -923,35 +924,6 @@ def tarot_deal(request, room_id):
|
|||||||
|
|
||||||
# ── Natus (natal chart) ───────────────────────────────────────────────────────
|
# ── Natus (natal chart) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _planet_house(degree, cusps):
|
|
||||||
"""Return 1-based house number for a planet at ecliptic degree.
|
|
||||||
|
|
||||||
cusps is the 12-element list from PySwiss where cusps[i] is the start of
|
|
||||||
house i+1. Handles the wrap-around case where a cusp crosses 0°/360°.
|
|
||||||
"""
|
|
||||||
degree = degree % 360
|
|
||||||
for i in range(12):
|
|
||||||
start = cusps[i] % 360
|
|
||||||
end = cusps[(i + 1) % 12] % 360
|
|
||||||
if start < end:
|
|
||||||
if start <= degree < end:
|
|
||||||
return i + 1
|
|
||||||
else: # wrap-around: e.g. cusp at 350° → next at 10°
|
|
||||||
if degree >= start or degree < end:
|
|
||||||
return i + 1
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_distinctions(planets, houses):
|
|
||||||
"""Return dict {house_number_str: planet_count} for all 12 houses."""
|
|
||||||
cusps = houses['cusps']
|
|
||||||
counts = {str(i): 0 for i in range(1, 13)}
|
|
||||||
for planet_data in planets.values():
|
|
||||||
h = _planet_house(planet_data['degree'], cusps)
|
|
||||||
counts[str(h)] += 1
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def natus_preview(request, room_id):
|
def natus_preview(request, room_id):
|
||||||
"""Proxy GET to PySwiss /api/chart/ and augment with distinction counts.
|
"""Proxy GET to PySwiss /api/chart/ and augment with distinction counts.
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Q
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.applets.utils import applet_context
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||||
from apps.applets.models import Applet, UserApplet
|
from apps.epic.models import DeckVariant, Room
|
||||||
from apps.epic.models import DeckVariant, Room, RoomInvite
|
from apps.epic.utils import rooms_for_user
|
||||||
from apps.lyric.models import Token
|
from apps.lyric.models import Token
|
||||||
|
|
||||||
|
|
||||||
@@ -37,24 +36,18 @@ def gameboard(request):
|
|||||||
"free_count": len(free_tokens),
|
"free_count": len(free_tokens),
|
||||||
"applets": applet_context(request.user, "gameboard"),
|
"applets": applet_context(request.user, "gameboard"),
|
||||||
"page_class": "page-gameboard",
|
"page_class": "page-gameboard",
|
||||||
"my_games": Room.objects.filter(
|
"my_games": rooms_for_user(request.user),
|
||||||
Q(owner=request.user) |
|
|
||||||
Q(gate_slots__gamer=request.user) |
|
|
||||||
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
|
|
||||||
).distinct(),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def toggle_game_applets(request):
|
def toggle_game_applets(request):
|
||||||
checked = request.POST.getlist("applets")
|
checked = request.POST.getlist("applets")
|
||||||
for applet in Applet.objects.filter(context="gameboard"):
|
apply_applet_toggle(request.user, "gameboard", checked)
|
||||||
UserApplet.objects.update_or_create(
|
|
||||||
user=request.user,
|
|
||||||
applet=applet,
|
|
||||||
defaults={"visible": applet.slug in checked},
|
|
||||||
)
|
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
|
free_tokens = list(request.user.tokens.filter(
|
||||||
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
|
).order_by("expires_at"))
|
||||||
return render(request, "apps/gameboard/_partials/_applets.html", {
|
return render(request, "apps/gameboard/_partials/_applets.html", {
|
||||||
"applets": applet_context(request.user, "gameboard"),
|
"applets": applet_context(request.user, "gameboard"),
|
||||||
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
||||||
@@ -63,17 +56,9 @@ def toggle_game_applets(request):
|
|||||||
"equipped_trinket_id": request.user.equipped_trinket_id,
|
"equipped_trinket_id": request.user.equipped_trinket_id,
|
||||||
"equipped_deck_id": request.user.equipped_deck_id,
|
"equipped_deck_id": request.user.equipped_deck_id,
|
||||||
"deck_variants": list(request.user.unlocked_decks.all()),
|
"deck_variants": list(request.user.unlocked_decks.all()),
|
||||||
"free_tokens": list(request.user.tokens.filter(
|
"free_tokens": free_tokens,
|
||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
"free_count": len(free_tokens),
|
||||||
).order_by("expires_at")),
|
"my_games": rooms_for_user(request.user),
|
||||||
"free_count": request.user.tokens.filter(
|
|
||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
||||||
).count(),
|
|
||||||
"my_games": Room.objects.filter(
|
|
||||||
Q(owner=request.user) |
|
|
||||||
Q(gate_slots__gamer=request.user) |
|
|
||||||
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
|
|
||||||
).distinct(),
|
|
||||||
})
|
})
|
||||||
return redirect("gameboard")
|
return redirect("gameboard")
|
||||||
|
|
||||||
@@ -154,12 +139,7 @@ def game_kit(request):
|
|||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def toggle_game_kit_sections(request):
|
def toggle_game_kit_sections(request):
|
||||||
checked = request.POST.getlist("applets")
|
checked = request.POST.getlist("applets")
|
||||||
for applet in Applet.objects.filter(context="game-kit"):
|
apply_applet_toggle(request.user, "game-kit", checked)
|
||||||
UserApplet.objects.update_or_create(
|
|
||||||
user=request.user,
|
|
||||||
applet=applet,
|
|
||||||
defaults={"visible": applet.slug in checked},
|
|
||||||
)
|
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
return render(request, "apps/gameboard/_partials/_game_kit_sections.html",
|
return render(request, "apps/gameboard/_partials/_game_kit_sections.html",
|
||||||
_game_kit_context(request.user))
|
_game_kit_context(request.user))
|
||||||
|
|||||||
@@ -113,10 +113,7 @@
|
|||||||
.tt {
|
.tt {
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
|
|
||||||
.tt-title { font-size: 1rem; }
|
@extend %tt-token-fields;
|
||||||
.tt-description { padding: 0.125rem; font-size: 0.75rem; }
|
|
||||||
.tt-shoptalk { font-size: 0.75rem; opacity: 0.75; }
|
|
||||||
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
|
|
||||||
|
|
||||||
// Buttons positioned on left edge of the fixed inline tooltip
|
// Buttons positioned on left edge of the fixed inline tooltip
|
||||||
.tt-equip-btns {
|
.tt-equip-btns {
|
||||||
|
|||||||
@@ -124,10 +124,7 @@ body.page-gameboard {
|
|||||||
|
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
|
|
||||||
.tt-title { font-size: 1rem; }
|
@extend %tt-token-fields;
|
||||||
.tt-description { padding: 0.125rem; font-size: 0.75rem; }
|
|
||||||
.tt-shoptalk { font-size: 0.75rem; opacity: 0.75; }
|
|
||||||
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
|
|
||||||
|
|
||||||
.tt-equip-btns {
|
.tt-equip-btns {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
// and override z-index; inline .tt cards use position:absolute within their
|
// and override z-index; inline .tt cards use position:absolute within their
|
||||||
// parent token container.
|
// parent token container.
|
||||||
|
|
||||||
|
// Shared token tooltip field sizes — used by #id_tooltip_portal and .tt
|
||||||
|
%tt-token-fields {
|
||||||
|
.tt-title { font-size: 1rem; }
|
||||||
|
.tt-description { padding: 0.125rem; font-size: 0.75rem; }
|
||||||
|
.tt-shoptalk { font-size: 0.75rem; opacity: 0.75; }
|
||||||
|
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
|
||||||
|
}
|
||||||
|
|
||||||
.token-tooltip,
|
.token-tooltip,
|
||||||
.tt {
|
.tt {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user