feat: clock + place games
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import ClockTimer, Press
|
||||
|
||||
|
||||
@admin.register(ClockTimer)
|
||||
class ClockTimerAdmin(admin.ModelAdmin):
|
||||
list_display = ("event", "end_time", "initial_duration")
|
||||
list_editable = ("initial_duration",)
|
||||
list_filter = ("event",)
|
||||
|
||||
|
||||
@admin.register(Press)
|
||||
class PressAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"event",
|
||||
"player",
|
||||
"pressed_at",
|
||||
"remaining_before_press",
|
||||
"seconds_added",
|
||||
)
|
||||
list_filter = ("event", "player")
|
||||
@@ -0,0 +1,79 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
from ninja import Router
|
||||
from ninja.decorators import decorate_view
|
||||
|
||||
from games.decorators import require_game_enabled
|
||||
|
||||
from .models import PRESS_BONUS_SECONDS, ClockTimer, Press
|
||||
from .schemas import ClockStateOut, PlayerStatsOut
|
||||
from .scoring import ranked_players
|
||||
|
||||
router = Router()
|
||||
|
||||
PREFIX = "/events/{event_path}/clock"
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/current", response=ClockStateOut)
|
||||
@decorate_view(require_game_enabled(path="clock"))
|
||||
def current_state(request: HttpRequest, event_path: str):
|
||||
timer = ClockTimer.current(request.event)
|
||||
timer.reset_if_expired()
|
||||
now = timezone.now()
|
||||
|
||||
next_press_at = None
|
||||
cooldown_seconds = None
|
||||
if request.user.is_authenticated:
|
||||
last_press = (
|
||||
Press.objects.filter(player=request.user, event=request.event)
|
||||
.order_by("-pressed_at")
|
||||
.first()
|
||||
)
|
||||
if last_press is not None:
|
||||
candidate = last_press.pressed_at + timedelta(
|
||||
seconds=last_press.cooldown_seconds
|
||||
)
|
||||
if candidate > now:
|
||||
next_press_at = candidate
|
||||
cooldown_seconds = last_press.cooldown_seconds
|
||||
|
||||
return {
|
||||
"server_time": now,
|
||||
"end_time": timer.end_time,
|
||||
"initial_duration": timer.initial_duration,
|
||||
"press_bonus_seconds": PRESS_BONUS_SECONDS,
|
||||
"next_press_at": next_press_at,
|
||||
"cooldown_seconds": cooldown_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/me", response=PlayerStatsOut)
|
||||
@decorate_view(require_game_enabled(path="clock"))
|
||||
def my_stats(request: HttpRequest, event_path: str):
|
||||
ranked = list(
|
||||
ranked_players(request.event)
|
||||
.values("id", "points", "rank")
|
||||
.order_by("-points", "id")
|
||||
)
|
||||
total_players = len(ranked)
|
||||
my_index = next(
|
||||
(i for i, row in enumerate(ranked) if row["id"] == request.user.id), None
|
||||
)
|
||||
|
||||
mine = ranked[my_index] if my_index is not None else None
|
||||
above = ranked[my_index - 1] if my_index is not None and my_index > 0 else None
|
||||
below = (
|
||||
ranked[my_index + 1]
|
||||
if my_index is not None and my_index + 1 < total_players
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"points": mine["points"] if mine else 0.0,
|
||||
"rank": mine["rank"] if mine else total_players,
|
||||
"total_players": total_players,
|
||||
"points_above": above["points"] if above else None,
|
||||
"points_below": below["points"] if below else None,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ClockConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "clock"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
@@ -0,0 +1,3 @@
|
||||
def clock_group(event_path: str) -> str:
|
||||
"""Channel group for one event's clock room - every event ticks independently."""
|
||||
return f"clock_room_{event_path}"
|
||||
@@ -0,0 +1,152 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from channels.db import database_sync_to_async
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event
|
||||
from games.models import Game
|
||||
|
||||
from .broadcast import clock_group
|
||||
from .models import PRESS_BONUS_SECONDS, ClockTimer, Press, random_cooldown_seconds
|
||||
from .wire import encode, to_epoch_ms
|
||||
|
||||
|
||||
class ClockConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
|
||||
self.event = await self._get_enabled_event(self.event_path)
|
||||
if self.event is None:
|
||||
await self.close()
|
||||
return
|
||||
|
||||
self.group_name = clock_group(self.event_path)
|
||||
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||
await self.accept()
|
||||
await self.send(text_data=await self._initial_state(self.scope.get("user")))
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
if getattr(self, "group_name", None):
|
||||
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||
|
||||
async def receive(self, text_data=None, bytes_data=None):
|
||||
if text_data == "press":
|
||||
await self._handle_press()
|
||||
|
||||
async def _handle_press(self):
|
||||
user = self.scope.get("user")
|
||||
if user is None or not user.is_authenticated:
|
||||
await self.send(text_data=encode("r", "a"))
|
||||
return
|
||||
|
||||
result = await self._record_press(user)
|
||||
|
||||
if result["rejected"]:
|
||||
await self.send(text_data=encode("r", "c", result["next"], result["now"]))
|
||||
return
|
||||
|
||||
# The cooldown outcome is only useful to the player who just pressed -
|
||||
# broadcasting it to every connection would be pure waste.
|
||||
await self.send(text_data=encode("k", result["next"], result["now"]))
|
||||
|
||||
# Everyone else only needs to know the shared timer moved.
|
||||
await self.channel_layer.group_send(
|
||||
self.group_name,
|
||||
{
|
||||
"type": "tick",
|
||||
"end": result["end"],
|
||||
"now": result["now"],
|
||||
"pressed": True,
|
||||
},
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
def _get_enabled_event(self, event_path):
|
||||
event = Event.objects.filter(path=event_path).first()
|
||||
if event is None:
|
||||
return None
|
||||
if not Game.objects.enabled_for_event(event).filter(path="clock").exists():
|
||||
return None
|
||||
return event
|
||||
|
||||
@database_sync_to_async
|
||||
def _record_press(self, user):
|
||||
with transaction.atomic():
|
||||
timer = ClockTimer.objects.select_for_update().get(event=self.event)
|
||||
now = timezone.now()
|
||||
timer.reset_if_expired(now)
|
||||
|
||||
last_press = (
|
||||
Press.objects.filter(player=user, event=self.event)
|
||||
.order_by("-pressed_at")
|
||||
.first()
|
||||
)
|
||||
if last_press is not None:
|
||||
next_allowed = last_press.pressed_at + timedelta(
|
||||
seconds=last_press.cooldown_seconds
|
||||
)
|
||||
if next_allowed > now:
|
||||
return {
|
||||
"rejected": True,
|
||||
"next": to_epoch_ms(next_allowed),
|
||||
"now": to_epoch_ms(now),
|
||||
}
|
||||
|
||||
remaining = (timer.end_time - now).total_seconds()
|
||||
cooldown = random_cooldown_seconds()
|
||||
press = Press.objects.create(
|
||||
event=self.event,
|
||||
player=user,
|
||||
remaining_before_press=remaining,
|
||||
seconds_added=PRESS_BONUS_SECONDS,
|
||||
cooldown_seconds=cooldown,
|
||||
)
|
||||
timer.end_time = timer.end_time + timedelta(seconds=PRESS_BONUS_SECONDS)
|
||||
timer.save(update_fields=["end_time"])
|
||||
|
||||
return {
|
||||
"rejected": False,
|
||||
"end": to_epoch_ms(timer.end_time),
|
||||
"now": to_epoch_ms(now),
|
||||
"next": to_epoch_ms(press.pressed_at + timedelta(seconds=cooldown)),
|
||||
}
|
||||
|
||||
@database_sync_to_async
|
||||
def _initial_state(self, user):
|
||||
timer = ClockTimer.current(self.event)
|
||||
timer.reset_if_expired()
|
||||
now = timezone.now()
|
||||
|
||||
next_press_at = None
|
||||
if user is not None and user.is_authenticated:
|
||||
last_press = (
|
||||
Press.objects.filter(player=user, event=self.event)
|
||||
.order_by("-pressed_at")
|
||||
.first()
|
||||
)
|
||||
if last_press is not None:
|
||||
candidate = last_press.pressed_at + timedelta(
|
||||
seconds=last_press.cooldown_seconds
|
||||
)
|
||||
if candidate > now:
|
||||
next_press_at = to_epoch_ms(candidate)
|
||||
|
||||
return encode(
|
||||
"s",
|
||||
to_epoch_ms(timer.end_time),
|
||||
to_epoch_ms(now),
|
||||
timer.initial_duration,
|
||||
PRESS_BONUS_SECONDS,
|
||||
next_press_at,
|
||||
)
|
||||
|
||||
async def tick(self, event):
|
||||
await self.send(
|
||||
text_data=encode(
|
||||
"t",
|
||||
event["end"],
|
||||
event["now"],
|
||||
"1" if event.get("pressed") else "",
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 6.0.5 on 2026-09-16 23:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("events", "0002_seed_polylan_43"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ClockTimer",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("initial_duration", models.PositiveIntegerField(default=60)),
|
||||
("end_time", models.DateTimeField()),
|
||||
(
|
||||
"event",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="clock_timer",
|
||||
to="events.event",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Press",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("pressed_at", models.DateTimeField(auto_now_add=True)),
|
||||
("remaining_before_press", models.FloatField()),
|
||||
("seconds_added", models.PositiveIntegerField(default=5)),
|
||||
("cooldown_seconds", models.FloatField()),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="clock_presses",
|
||||
to="events.event",
|
||||
),
|
||||
),
|
||||
(
|
||||
"player",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="clock_presses",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-pressed_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event
|
||||
from events.scoping import EventScopedManager
|
||||
|
||||
DEFAULT_TIMER_DURATION = 60
|
||||
PRESS_BONUS_SECONDS = 5
|
||||
PRESS_COOLDOWN_MIN_SECONDS = PRESS_BONUS_SECONDS
|
||||
PRESS_COOLDOWN_MAX_SECONDS = PRESS_BONUS_SECONDS * 2
|
||||
|
||||
|
||||
def random_cooldown_seconds() -> float:
|
||||
return random.uniform(PRESS_COOLDOWN_MIN_SECONDS, PRESS_COOLDOWN_MAX_SECONDS)
|
||||
|
||||
|
||||
class ClockTimer(models.Model):
|
||||
"""The shared countdown for one event. One row per event, reset in place.
|
||||
|
||||
Unlike the enable/disable toggle for every other game (EventGame.enabled,
|
||||
checked before a client can even reach this app), there's no separate
|
||||
is_enabled flag here - the registry is the single source of truth.
|
||||
"""
|
||||
|
||||
event = models.OneToOneField(
|
||||
Event, on_delete=models.CASCADE, related_name="clock_timer"
|
||||
)
|
||||
initial_duration = models.PositiveIntegerField(default=DEFAULT_TIMER_DURATION)
|
||||
end_time = models.DateTimeField()
|
||||
|
||||
def __str__(self):
|
||||
return f"ClockTimer({self.event}, end_time={self.end_time.isoformat()})"
|
||||
|
||||
@classmethod
|
||||
def current(cls, event: Event) -> "ClockTimer":
|
||||
"""Fetch the event's singleton timer, creating it on first-ever run."""
|
||||
obj, _ = cls.objects.get_or_create(
|
||||
event=event,
|
||||
defaults={
|
||||
"end_time": timezone.now() + timedelta(seconds=DEFAULT_TIMER_DURATION)
|
||||
},
|
||||
)
|
||||
return obj
|
||||
|
||||
def reset_if_expired(self, now=None) -> bool:
|
||||
"""Resets end_time back to a fresh window if it has passed. Returns True if reset."""
|
||||
now = now or timezone.now()
|
||||
if self.end_time > now:
|
||||
return False
|
||||
|
||||
self.end_time = now + timedelta(seconds=self.initial_duration)
|
||||
self.save(update_fields=["end_time"])
|
||||
return True
|
||||
|
||||
|
||||
class Press(models.Model):
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="clock_presses")
|
||||
player = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses"
|
||||
)
|
||||
pressed_at = models.DateTimeField(auto_now_add=True)
|
||||
remaining_before_press = models.FloatField()
|
||||
seconds_added = models.PositiveIntegerField(default=PRESS_BONUS_SECONDS)
|
||||
# Randomized 1x-2x PRESS_BONUS_SECONDS at press time and stored, since it's no
|
||||
# longer a fixed constant that can be recomputed later.
|
||||
cooldown_seconds = models.FloatField()
|
||||
|
||||
objects = models.Manager()
|
||||
for_event = EventScopedManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-pressed_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.player} pressed at {self.remaining_before_press:.1f}s left ({self.event})"
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import re_path
|
||||
|
||||
from .consumers import ClockConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r"^ws/clock/(?P<event_path>[\w-]+)/$", ClockConsumer.as_asgi()),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class ClockStateOut(Schema):
|
||||
server_time: datetime
|
||||
end_time: datetime
|
||||
initial_duration: int
|
||||
press_bonus_seconds: int
|
||||
next_press_at: datetime | None
|
||||
cooldown_seconds: float | None
|
||||
|
||||
|
||||
class PlayerStatsOut(Schema):
|
||||
points: float
|
||||
rank: int
|
||||
total_players: int
|
||||
points_above: float | None
|
||||
points_below: float | None
|
||||
@@ -0,0 +1,37 @@
|
||||
from django.db.models import ExpressionWrapper, F, FloatField, Q, Sum, Value, Window
|
||||
from django.db.models.functions import Coalesce, Power, Rank
|
||||
|
||||
from events.models import Event
|
||||
|
||||
# points = 1000 * (1 / (remaining_seconds + 0.5)) ** 2, computed per press and
|
||||
# summed per player. Never stored - always derived live from Press rows.
|
||||
POINTS_BASE = 1000.0
|
||||
POINTS_OFFSET = 0.5
|
||||
POINTS_EXPONENT = -2.0
|
||||
|
||||
|
||||
def _press_points_expression():
|
||||
base = ExpressionWrapper(
|
||||
F("clock_presses__remaining_before_press") + Value(POINTS_OFFSET),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
return ExpressionWrapper(
|
||||
Value(POINTS_BASE) * Power(base, Value(POINTS_EXPONENT)),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
|
||||
|
||||
def ranked_players(event: Event):
|
||||
"""All users annotated with their live total points for this event and
|
||||
DB-computed RANK(). Points only ever come from presses made in `event` -
|
||||
a player's history in another event never leaks into this leaderboard."""
|
||||
from accounts.models import CustomUser
|
||||
|
||||
points = Coalesce(
|
||||
Sum(_press_points_expression(), filter=Q(clock_presses__event=event)),
|
||||
Value(0.0),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
return CustomUser.objects.annotate(points=points).annotate(
|
||||
rank=Window(expression=Rank(), order_by=F("points").desc())
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from games.models import EventGame
|
||||
|
||||
from .tasks import start_clock_heartbeat
|
||||
|
||||
|
||||
@receiver(post_save, sender=EventGame)
|
||||
def on_event_game_saved(sender, instance, **kwargs):
|
||||
if instance.game.path == "clock" and instance.enabled:
|
||||
start_clock_heartbeat()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Self-rescheduling heartbeat, replacing pplace's standalone `watch_timer`
|
||||
management command with the project's existing self-rescheduling django-tasks
|
||||
pattern (see submissions.tasks.sync_event_game_scopes_task).
|
||||
|
||||
Resets any event's timer once it expires - even if nobody presses or
|
||||
reconnects to notice - and gives already-connected clients a periodic tick.
|
||||
Note this is a safety net, not the only way the timer gets reset: every press
|
||||
and every new connection (see consumers.py) also calls reset_if_expired(), so
|
||||
the game works correctly even if this chain is never started or dies -
|
||||
connected-but-idle clients just won't see the countdown restart until someone
|
||||
acts.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.tasks import task
|
||||
from django.utils import timezone
|
||||
|
||||
from games.models import EventGame
|
||||
|
||||
from .broadcast import clock_group
|
||||
from .models import ClockTimer
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
HEARTBEAT_INTERVAL_SECONDS = 1
|
||||
# One chain ticks every event with the game enabled - this lock keeps a
|
||||
# second chain from starting underneath an already-running one (e.g. two
|
||||
# EventGame saves close together). Refreshed every tick, wide enough that a
|
||||
# single slow tick doesn't let a second chain slip in.
|
||||
HEARTBEAT_LOCK_KEY = "clock:heartbeat:lock"
|
||||
HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5
|
||||
|
||||
|
||||
@task
|
||||
def clock_heartbeat_task():
|
||||
event_games = list(
|
||||
EventGame.objects.filter(game__path="clock", enabled=True).select_related(
|
||||
"event"
|
||||
)
|
||||
)
|
||||
|
||||
if not event_games:
|
||||
# Nothing left to tick - let the chain die. start_clock_heartbeat()
|
||||
# starts a fresh one next time an event enables the game.
|
||||
cache.delete(HEARTBEAT_LOCK_KEY)
|
||||
return
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
for event_game in event_games:
|
||||
event = event_game.event
|
||||
ClockTimer.current(event)
|
||||
with transaction.atomic():
|
||||
timer = ClockTimer.objects.select_for_update().get(event=event)
|
||||
timer.reset_if_expired()
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
clock_group(event.path),
|
||||
{
|
||||
"type": "tick",
|
||||
"end": to_epoch_ms(timer.end_time),
|
||||
"now": to_epoch_ms(timezone.now()),
|
||||
"pressed": False,
|
||||
},
|
||||
)
|
||||
|
||||
cache.set(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
|
||||
clock_heartbeat_task.using(
|
||||
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
|
||||
).enqueue()
|
||||
|
||||
|
||||
def start_clock_heartbeat():
|
||||
"""Enqueues the heartbeat chain, unless one is already running."""
|
||||
if cache.add(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
|
||||
clock_heartbeat_task.enqueue()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Compact wire format for the clock websocket.
|
||||
|
||||
JSON's braces/quotes/keys are pure overhead for messages this small and this
|
||||
frequent (a heartbeat every second, to every connection). Each message is
|
||||
instead a short string: a one-letter type tag followed by '|'-separated
|
||||
fields, in a fixed order the client already knows from this table (missing/
|
||||
null fields are written as an empty segment):
|
||||
|
||||
s|<end>|<now>|<duration>|<bonus>|<next> initial state, once per connection
|
||||
t|<end>|<now>|<pressed> shared timer update ("pressed" is "1" or "")
|
||||
k|<next>|<now> your press was accepted
|
||||
r|c|<next>|<now> your press was rejected: on cooldown
|
||||
r|a your press was rejected: not logged in
|
||||
|
||||
Client -> server has exactly one message, sent as the bare literal "press"
|
||||
(no fields, so no delimiter needed).
|
||||
|
||||
Whether the game is enabled at all for this event (EventGame.enabled) is
|
||||
checked once, at connect time - a connection is refused outright rather than
|
||||
accepted-but-disabled, so there's no "disabled" case in this wire format.
|
||||
"""
|
||||
|
||||
DELIMITER = "|"
|
||||
|
||||
|
||||
def encode(*parts) -> str:
|
||||
return DELIMITER.join("" if p is None else str(p) for p in parts)
|
||||
|
||||
|
||||
def to_epoch_ms(dt) -> int:
|
||||
"""Epoch-ms integer: far cheaper than an ISO-8601 string and needs no
|
||||
client-side parsing (the frontend already does its clock math in epoch ms)."""
|
||||
return int(dt.timestamp() * 1000)
|
||||
Reference in New Issue
Block a user