fix timezone shenanigans

This commit is contained in:
ultrablob 2025-05-03 21:19:55 -04:00
parent 75dbaebccd
commit e9f63e8658
2 changed files with 62 additions and 22 deletions

View file

@ -22,7 +22,8 @@ services:
- "8004:8000" - "8004:8000"
environment: environment:
# FILL THESE IN # FILL THESE IN
BEAVERHABITS_URL: "" TIMEZONE: EST
BEAVERHABITS_URL: "" # no path, just the base URL
BEAVERHABITS_USERNAME: "" # yes i know this is bad BEAVERHABITS_USERNAME: "" # yes i know this is bad
BEAVERHABITS_PASSWORD: "" # however, i don't care BEAVERHABITS_PASSWORD: "" # however, i don't care
restart: unless-stopped restart: unless-stopped

81
app.py
View file

@ -1,7 +1,8 @@
import os import os
import time import time
import logging import logging
import datetime from datetime import datetime, timedelta, date
from zoneinfo import ZoneInfo
import threading import threading
from flask import Flask, request, Response, abort, jsonify from flask import Flask, request, Response, abort, jsonify
@ -12,12 +13,14 @@ import requests
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Configuration via ENV # Configuration via ENV
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
BEAVER_URL = os.getenv("BEAVERHABITS_URL", "") BEAVER_URL = os.getenv("BEAVERHABITS_URL", "http://beaverhabits.lan")
USERNAME = os.getenv("BEAVERHABITS_USERNAME", "") USERNAME = os.getenv("BEAVERHABITS_USERNAME", "")
PASSWORD = os.getenv("BEAVERHABITS_PASSWORD", "") PASSWORD = os.getenv("BEAVERHABITS_PASSWORD", "")
# How long to reuse a token (secs) # How long to reuse a token (secs)
TOKEN_TTL = int(os.getenv("BEAVERHABITS_TOKEN_TTL", "3600")) TOKEN_TTL = int(os.getenv("BEAVERHABITS_TOKEN_TTL", "3600"))
TZ = ZoneInfo(os.getenv("TIMEZONE", "UTC"))
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -35,6 +38,10 @@ NS = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
for p, uri in NS.items(): for p, uri in NS.items():
ET.register_namespace(p, uri) ET.register_namespace(p, uri)
def to_bytes(elem: ET.Element) -> bytes:
"""Serialize an ElementTree Element to UTF-8 XML with declaration."""
return ET.tostring(elem, encoding="utf-8", xml_declaration=True)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Beaver Habits token cache with TTL # Beaver Habits token cache with TTL
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -73,51 +80,85 @@ def beaver_headers() -> dict[str,str]:
return {"Accept": "application/json", return {"Accept": "application/json",
"Authorization": f"Bearer {get_token()}"} "Authorization": f"Bearer {get_token()}"}
# -----------------------------------------------------------------------------
# Utility: Datetime functions
# -----------------------------------------------------------------------------
def local_now() -> datetime:
"""
Return now as an aware datetime in our configured TZ.
"""
return datetime.now(TZ)
def next_midnight_local(now: datetime) -> datetime:
"""
Given an aware datetime now, return tomorrow at 00:00 in the same TZ.
"""
tomorrow = now.date() + timedelta(days=1)
# Year, month, day at midnight
return datetime(tomorrow.year, tomorrow.month, tomorrow.day, tzinfo=TZ)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Utility: build iCalendar VTODO # Utility: build iCalendar VTODO
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def make_vtodo(uid: str, summary: str, completed: bool) -> bytes: def make_vtodo(uid: str, summary: str, completed: bool) -> bytes:
"""
Construct a VCALENDAR/VTODO where:
- DTSTAMP = local_now()
- DUE = next_midnight_local(local_now())
"""
now = local_now()
due = next_midnight_local(now)
cal = Calendar() cal = Calendar()
cal.add("prodid", "-//CalDAV⇆BeaverHabits Proxy//EN") cal.add("prodid", "-//CalDAV⇆BeaverHabits Proxy//EN")
cal.add("version", "2.0") cal.add("version", "2.0")
todo = Todo() todo = Todo()
todo.add("uid", uid) todo.add("uid", uid)
todo.add("summary", summary) todo.add("summary", summary)
now = datetime.datetime.now(datetime.timezone.utc)
todo.add("dtstamp", now) todo.add("dtstamp", now)
todo.add("due", now + datetime.timedelta(days=1)) # todo.add("due", due) # causes problems
todo.add("status", "COMPLETED" if completed else "NEEDS-ACTION") todo.add("status", "COMPLETED" if completed else "NEEDS-ACTION")
cal.add_component(todo) cal.add_component(todo)
return cal.to_ical() return cal.to_ical()
def to_bytes(elem: ET.Element) -> bytes:
return ET.tostring(elem, encoding="utf-8", xml_declaration=True)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Helper: fetch habits + today's completion from Beaver # Helper: fetch habits + today's completion from Beaver
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def fetch_tasks() -> dict[str, dict]: def fetch_tasks() -> dict[str, dict]:
"""Return a dict of {habit_id: {SUMMARY, STATUS, DTSTAMP, DUE}}.""" """
Fetch habit list + today's completion from Beaver Habits.
Returns a dict:
{ habit_id: { "SUMMARY": <name>, "STATUS": <flag> } }
DATES are handled later by make_vtodo().
"""
try: try:
r = _session.get(f"{BEAVER_URL}/api/v1/habits", headers=beaver_headers(), timeout=5) r = _session.get(f"{BEAVER_URL}/api/v1/habits",
headers=beaver_headers(), timeout=5)
r.raise_for_status() r.raise_for_status()
habits = r.json() habits = r.json()
except Exception as e: except Exception as e:
log.error("Failed to list habits: %s", e) log.error("Failed to list habits: %s", e)
return {} return {}
today = datetime.date.today().strftime("%d-%m-%Y") # Use the same date format Beaver expects
today_str = local_now().strftime("%d-%m-%Y")
out: dict[str, dict] = {} out: dict[str, dict] = {}
for h in habits: for h in habits:
hid = h["id"] hid = h["id"]
name = h["name"] name = h["name"]
# check completion for today
try: try:
params = { params = {
"date_fmt": "%d-%m-%Y", "date_fmt": "%d-%m-%Y",
"date_start": today, "date_start": today_str,
"date_end": today, "date_end": today_str,
"sort": "asc" "sort": "asc",
} }
rc = _session.get( rc = _session.get(
f"{BEAVER_URL}/api/v1/habits/{hid}/completions", f"{BEAVER_URL}/api/v1/habits/{hid}/completions",
@ -127,19 +168,17 @@ def fetch_tasks() -> dict[str, dict]:
) )
rc.raise_for_status() rc.raise_for_status()
done_list = rc.json() done_list = rc.json()
completed = today in done_list completed = today_str in done_list
except Exception: except Exception:
log.warning("Failed to get completions for %s", hid) log.warning("Failed to fetch completions for habit %s", hid)
completed = False completed = False
out[hid] = { out[hid] = {
"SUMMARY": name, "SUMMARY": name,
"STATUS": "COMPLETED" if completed else "NEEDS-ACTION", "STATUS": "COMPLETED" if completed else "NEEDS-ACTION",
"DTSTAMP": datetime.datetime.now(datetime.timezone.utc),
"DUE": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1),
} }
return out
return out
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Standard PROPFIND / discovery endpoints # Standard PROPFIND / discovery endpoints
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -256,7 +295,7 @@ def single_task(uid):
summary = tasks_map[uid]["SUMMARY"] summary = tasks_map[uid]["SUMMARY"]
completed = (tasks_map[uid]["STATUS"] == "COMPLETED") completed = (tasks_map[uid]["STATUS"] == "COMPLETED")
today = datetime.date.today() today = date.today()
if request.method == "GET": if request.method == "GET":
return Response(make_vtodo(uid, summary, completed), return Response(make_vtodo(uid, summary, completed),