mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-31 18:22:22 +01:00
fix(tasks): render daily brief calendars in local time (#6926)
This commit is contained in:
+37
-5
@@ -10,7 +10,7 @@ import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Tuple
|
||||
|
||||
from src.auth_helpers import owner_filter
|
||||
@@ -1957,6 +1957,23 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
||||
return str(e), False
|
||||
|
||||
|
||||
def _daily_brief_utc_boundary(local_dt, tz=None):
|
||||
"""Convert a local wall-clock boundary using the offset at that boundary."""
|
||||
if local_dt.tzinfo is None and tz is not None:
|
||||
local_dt = local_dt.replace(tzinfo=tz)
|
||||
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _daily_brief_local_start(event, tz=None):
|
||||
"""Keep date-only/floating rows local; convert only stored UTC instants."""
|
||||
start = event.dtstart
|
||||
if event.is_utc and not event.all_day:
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=timezone.utc)
|
||||
return start.astimezone(tz).replace(tzinfo=None)
|
||||
return start
|
||||
|
||||
|
||||
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""Build a short morning digest: today's calendar events, unread email count
|
||||
+ top-N senders/subjects, active todos."""
|
||||
@@ -1966,10 +1983,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
|
||||
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
||||
from routes.email_helpers import _imap_connect, _decode_header
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
# ----- Calendar: today's events -----
|
||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
tomorrow = today + _td(days=1)
|
||||
utc_today = _daily_brief_utc_boundary(today)
|
||||
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
|
||||
# v2 review HIGH-12: gate the OR-null branch on single-user
|
||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||
# user's daily brief must not include another user's notes or
|
||||
@@ -1982,13 +2002,25 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
or_(
|
||||
and_(
|
||||
CalendarEvent.is_utc.is_(True),
|
||||
CalendarEvent.all_day.is_not(True),
|
||||
CalendarEvent.dtstart < utc_tomorrow,
|
||||
CalendarEvent.dtend > utc_today,
|
||||
),
|
||||
and_(
|
||||
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
),
|
||||
),
|
||||
CalendarEvent.status != "cancelled",
|
||||
)
|
||||
if owner:
|
||||
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
||||
events = ev_q.order_by(CalendarEvent.dtstart).all()
|
||||
events = ev_q.all()
|
||||
events.sort(key=_daily_brief_local_start)
|
||||
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||
if owner:
|
||||
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
if events:
|
||||
plain.append("Calendar:")
|
||||
for e in events:
|
||||
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
|
||||
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
|
||||
loc = f" @ {e.location}" if e.location else ""
|
||||
plain.append(f" {t} {e.summary}{loc}")
|
||||
plain.append("")
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Daily brief must query and display each calendar storage kind in local time."""
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from core import database
|
||||
from src import builtin_actions
|
||||
|
||||
|
||||
@contextmanager
|
||||
def system_timezone(name):
|
||||
previous = os.environ.get('TZ')
|
||||
os.environ['TZ'] = name
|
||||
time.tzset()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop('TZ', None)
|
||||
else:
|
||||
os.environ['TZ'] = previous
|
||||
time.tzset()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(time, 'tzset'), reason='requires changing the process local timezone')
|
||||
@pytest.mark.parametrize('zone', ['Europe/Berlin', 'America/Los_Angeles'])
|
||||
def test_daily_brief_selects_local_day_and_orders_mixed_storage(monkeypatch, zone):
|
||||
engine = create_engine('sqlite://', poolclass=StaticPool)
|
||||
for model in [database.CalendarCal, database.CalendarEvent, database.Note]:
|
||||
model.__table__.create(engine)
|
||||
sessions = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(database, 'SessionLocal', sessions)
|
||||
from core.auth import AuthManager
|
||||
monkeypatch.setattr(AuthManager, 'is_configured', property(lambda self: True))
|
||||
from routes import email_helpers
|
||||
def no_email(*args):
|
||||
raise RuntimeError('No email account in this calendar-only test')
|
||||
monkeypatch.setattr(email_helpers, '_imap_connect', no_email)
|
||||
try:
|
||||
with system_timezone(zone):
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
def utc(value):
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
with sessions() as db:
|
||||
for owner in ['alice', 'bob', None]:
|
||||
db.add(database.CalendarCal(id=owner or 'shared', owner=owner, name=owner or 'shared'))
|
||||
db.flush()
|
||||
def add(uid, start, end, *, utc_row=False, all_day=False, owner='alice', status='confirmed'):
|
||||
db.add(database.CalendarEvent(uid=uid, calendar_id=owner or 'shared', summary=uid,
|
||||
dtstart=utc(start) if utc_row and not all_day else start,
|
||||
dtend=utc(end) if utc_row and not all_day else end,
|
||||
is_utc=utc_row, all_day=all_day, status=status))
|
||||
add('early', today+timedelta(minutes=30), today+timedelta(hours=1), utc_row=True)
|
||||
add('utc-ten', today+timedelta(hours=10), today+timedelta(hours=11), utc_row=True)
|
||||
add('local-nine', today+timedelta(hours=9), today+timedelta(hours=10))
|
||||
add('all-day', today, today+timedelta(days=1), utc_row=True, all_day=True)
|
||||
add('next-day', today+timedelta(days=1,minutes=30), today+timedelta(days=1,hours=1), utc_row=True)
|
||||
add('ended', today-timedelta(hours=1), today, utc_row=True)
|
||||
add('cancelled', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, status='cancelled')
|
||||
add('foreign', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, owner='bob')
|
||||
add('unowned', today+timedelta(hours=12), today+timedelta(hours=13), owner=None)
|
||||
db.commit()
|
||||
text, success = asyncio.run(builtin_actions.action_daily_brief('alice'))
|
||||
assert success, text
|
||||
lines = ['all day all-day', '00:30 early', '09:00 local-nine', '10:00 utc-ten']
|
||||
assert all(line in text for line in lines), text
|
||||
assert [text.index(line) for line in lines] == sorted(text.index(line) for line in lines), text
|
||||
for excluded in ['next-day', 'ended', 'cancelled', 'foreign', 'unowned']:
|
||||
assert excluded not in text, text
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('day, hours', [('2026-03-29', 23), ('2026-10-25', 25)])
|
||||
def test_utc_day_window_uses_offset_at_each_boundary(day, hours):
|
||||
start = datetime.fromisoformat(day)
|
||||
zone = ZoneInfo('Europe/Berlin')
|
||||
begin = builtin_actions._daily_brief_utc_boundary(start, zone)
|
||||
end = builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1), zone)
|
||||
assert end-begin == timedelta(hours=hours)
|
||||
if hasattr(time, 'tzset'):
|
||||
with system_timezone('Europe/Berlin'):
|
||||
assert builtin_actions._daily_brief_utc_boundary(start) == begin
|
||||
assert builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1)) == end
|
||||
|
||||
|
||||
@pytest.mark.parametrize('utc_row, all_day, expected', [
|
||||
(True, False, datetime(2026, 6, 1, 10)),
|
||||
(False, False, datetime(2026, 6, 1, 8)),
|
||||
(True, True, datetime(2026, 6, 1, 8)),
|
||||
])
|
||||
def test_local_start_preserves_floating_and_date_only_values(utc_row, all_day, expected):
|
||||
event = SimpleNamespace(dtstart=datetime(2026,6,1,8), is_utc=utc_row, all_day=all_day)
|
||||
assert builtin_actions._daily_brief_local_start(event, ZoneInfo('Europe/Berlin')) == expected
|
||||
Reference in New Issue
Block a user