new core.middleware sets cookie for scroll timestamp view to local browser time, w. new corresponding tests in core.tests.UTs.test_middleware; apps.lyric.templatetags.lyric_extras determines timestamp format based on duration elapsed since timestamp; apps.bill.tests.ITs.test_views renamed, now also asserts scroll renders event body and time in columns
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
@@ -143,6 +143,11 @@ class BillscrollViewTest(TestCase):
|
|||||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
self.assertEqual(response.context["scroll_position"], 250)
|
self.assertEqual(response.context["scroll_position"], 250)
|
||||||
|
|
||||||
|
def test_scroll_renders_event_body_and_time_columns(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertContains(response, 'class="drama-event-body"')
|
||||||
|
self.assertContains(response, 'class="drama-event-time"')
|
||||||
|
|
||||||
|
|
||||||
class SaveScrollPositionTest(TestCase):
|
class SaveScrollPositionTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from django import template
|
from django import template
|
||||||
|
from django.utils import dateformat, timezone
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
@@ -17,6 +18,29 @@ def truncate_email(email):
|
|||||||
|
|
||||||
return local + "@" + domain_name + "." + domain_tld
|
return local + "@" + domain_name + "." + domain_tld
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def relative_ts(dt):
|
||||||
|
"""Return a compact relative timestamp string for a datetime value.
|
||||||
|
|
||||||
|
< 24 h → "3:07 a.m."
|
||||||
|
< 7 d → "Thu"
|
||||||
|
< 1 y → "07 Mar"
|
||||||
|
≥ 1 y → "07 Mar 2025"
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return ""
|
||||||
|
local_dt = timezone.localtime(dt)
|
||||||
|
diff = timezone.now() - dt
|
||||||
|
if diff.total_seconds() < 86400:
|
||||||
|
return dateformat.format(local_dt, "g:i a")
|
||||||
|
elif diff.days < 7:
|
||||||
|
return dateformat.format(local_dt, "D")
|
||||||
|
elif diff.days < 365:
|
||||||
|
return dateformat.format(local_dt, "d M")
|
||||||
|
else:
|
||||||
|
return dateformat.format(local_dt, "d M Y")
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def display_name(user):
|
def display_name(user):
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|||||||
29
src/core/middleware.py
Normal file
29
src/core/middleware.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import zoneinfo
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class TimezoneMiddleware:
|
||||||
|
"""Activate the user's local timezone from the ``user_tz`` cookie.
|
||||||
|
|
||||||
|
The cookie is set client-side via ``Intl.DateTimeFormat().resolvedOptions().timeZone``
|
||||||
|
on every page load, so it reflects the browser's OS timezone rather than
|
||||||
|
the server's configured TIME_ZONE. Invalid or absent cookies fall back to
|
||||||
|
Django's default (UTC).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
tz_name = request.COOKIES.get("user_tz")
|
||||||
|
if tz_name:
|
||||||
|
try:
|
||||||
|
timezone.activate(zoneinfo.ZoneInfo(tz_name))
|
||||||
|
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
||||||
|
timezone.deactivate()
|
||||||
|
else:
|
||||||
|
timezone.deactivate()
|
||||||
|
response = self.get_response(request)
|
||||||
|
timezone.deactivate()
|
||||||
|
return response
|
||||||
@@ -79,6 +79,7 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'core.middleware.TimezoneMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
|||||||
0
src/core/tests/__init__.py
Normal file
0
src/core/tests/__init__.py
Normal file
0
src/core/tests/unit/__init__.py
Normal file
0
src/core/tests/unit/__init__.py
Normal file
41
src/core/tests/unit/test_middleware.py
Normal file
41
src/core/tests/unit/test_middleware.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import RequestFactory, SimpleTestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.middleware import TimezoneMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class TimezoneMiddlewareTest(SimpleTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.middleware = TimezoneMiddleware(lambda r: HttpResponse())
|
||||||
|
|
||||||
|
def test_activates_valid_timezone_from_cookie(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def get_response(request):
|
||||||
|
captured["tz"] = str(timezone.get_current_timezone())
|
||||||
|
return HttpResponse()
|
||||||
|
|
||||||
|
middleware = TimezoneMiddleware(get_response)
|
||||||
|
request = self.factory.get("/")
|
||||||
|
request.COOKIES["user_tz"] = "America/New_York"
|
||||||
|
middleware(request)
|
||||||
|
self.assertEqual(captured["tz"], "America/New_York")
|
||||||
|
|
||||||
|
def test_deactivates_after_response(self):
|
||||||
|
# Timezone activation must not leak into subsequent requests
|
||||||
|
request = self.factory.get("/")
|
||||||
|
request.COOKIES["user_tz"] = "America/New_York"
|
||||||
|
self.middleware(request)
|
||||||
|
self.assertEqual(str(timezone.get_current_timezone()), "UTC")
|
||||||
|
|
||||||
|
def test_invalid_timezone_cookie_does_not_raise(self):
|
||||||
|
request = self.factory.get("/")
|
||||||
|
request.COOKIES["user_tz"] = "Not/ATimezone"
|
||||||
|
self.middleware(request) # must not raise
|
||||||
|
|
||||||
|
def test_missing_cookie_does_not_raise(self):
|
||||||
|
request = self.factory.get("/")
|
||||||
|
self.middleware(request) # must not raise
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
from .base import FunctionalTest
|
from .base import FunctionalTest
|
||||||
@@ -284,3 +287,68 @@ class BillscrollAppletsTest(FunctionalTest):
|
|||||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||||
)
|
)
|
||||||
self.assertIn("Coin-on-a-String", scroll.text)
|
self.assertIn("Coin-on-a-String", scroll.text)
|
||||||
|
|
||||||
|
|
||||||
|
class BillscrollEntryLayoutTest(FunctionalTest):
|
||||||
|
"""
|
||||||
|
FT: each drama entry renders as a 90/10 row — event body at 90%,
|
||||||
|
relative timestamp at 10%; timestamp text format varies with age.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.founder = User.objects.create(email="founder@layout.io")
|
||||||
|
self.room = Room.objects.create(name="Layout Chamber", owner=self.founder)
|
||||||
|
# A fresh (< 24 h) event — timestamp is auto_now_add so always recent
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Fresh Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
# An old (> 1 year) event — backdate via queryset update to bypass auto_now_add
|
||||||
|
old = record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||||
|
slot_number=2, token_type="coin",
|
||||||
|
token_display="Ancient Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
GameEvent.objects.filter(pk=old.pk).update(
|
||||||
|
timestamp=timezone.now() - datetime.timedelta(days=400)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _go_to_scroll(self):
|
||||||
|
self.create_pre_authenticated_session("founder@layout.io")
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
|
||||||
|
)
|
||||||
|
return self.wait_for(
|
||||||
|
lambda: self.browser.find_elements(By.CSS_SELECTOR, ".drama-event")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 1 — each entry has a body column and a time column #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_each_drama_entry_has_body_and_time_columns(self):
|
||||||
|
events = self._go_to_scroll()
|
||||||
|
self.assertEqual(len(events), 2)
|
||||||
|
for event_el in events:
|
||||||
|
event_el.find_element(By.CSS_SELECTOR, ".drama-event-body")
|
||||||
|
event_el.find_element(By.CSS_SELECTOR, ".drama-event-time")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 2 — recent entry timestamp shows HH:MM a.m./p.m. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_recent_event_shows_time_format(self):
|
||||||
|
events = self._go_to_scroll()
|
||||||
|
recent_ts = events[0].find_element(By.CSS_SELECTOR, ".drama-event-time")
|
||||||
|
self.assertRegex(recent_ts.text, r"\d+:\d+\s+[ap]\.m\.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 3 — entry > 1 year old shows DD Mon YYYY #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_old_event_shows_date_with_year(self):
|
||||||
|
events = self._go_to_scroll()
|
||||||
|
old_ts = events[1].find_element(By.CSS_SELECTOR, ".drama-event-time")
|
||||||
|
self.assertRegex(old_ts.text, r"\d{2}\s+[A-Z][a-z]{2}\s+\d{4}")
|
||||||
|
|||||||
@@ -131,6 +131,24 @@ body.page-billscroll {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Drama event entries: 90 / 10 column split ─────────────────────────────
|
||||||
|
|
||||||
|
.drama-event {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
.drama-event-body {
|
||||||
|
flex: 0 0 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drama-event-time {
|
||||||
|
flex: 0 0 20%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── My Scrolls list ────────────────────────────────────────────────────────
|
// ── My Scrolls list ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#id_applet_billboard_my_scrolls {
|
#id_applet_billboard_my_scrolls {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}">
|
<section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}">
|
||||||
{% for event in events %}
|
{% for event in events %}
|
||||||
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
||||||
<span class="event-body">
|
<span class="drama-event-body">
|
||||||
<strong>{{ event.actor|display_name }}</strong>
|
<strong>{{ event.actor|display_name }}</strong>
|
||||||
{{ event.to_prose }}<br>
|
{{ event.to_prose }}
|
||||||
<time class="event-time" datetime="{{ event.timestamp|date:'c' }}">
|
|
||||||
{{ event.timestamp|date:"N j, g:i a" }}
|
|
||||||
</time>
|
|
||||||
</span>
|
</span>
|
||||||
|
<time class="drama-event-time" datetime="{{ event.timestamp|date:'c' }}">
|
||||||
|
{{ event.timestamp|relative_ts }}
|
||||||
|
</time>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p class="event-empty"><small>No events yet.</small></p>
|
<p class="event-empty"><small>No events yet.</small></p>
|
||||||
|
|||||||
@@ -146,6 +146,7 @@
|
|||||||
<script src="{% static "apps/applets/applets.js" %}"></script>
|
<script src="{% static "apps/applets/applets.js" %}"></script>
|
||||||
<script src="{% static "apps/dashboard/game-kit.js" %}"></script>
|
<script src="{% static "apps/dashboard/game-kit.js" %}"></script>
|
||||||
<script>
|
<script>
|
||||||
|
document.cookie = 'user_tz=' + Intl.DateTimeFormat().resolvedOptions().timeZone + '; path=/; SameSite=Lax';
|
||||||
document.body.addEventListener('htmx:configRequest', function(evt) {
|
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||||
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');
|
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user