Skip to content

Commit e608e2b

Browse files
authored
Merge pull request #4 from raven0034/main
Move tz to localize and centralise inc ORM reconstructor
2 parents 5af6dac + 93e7837 commit e608e2b

File tree

7 files changed

+102
-57
lines changed

7 files changed

+102
-57
lines changed

DEVELOPER.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1+
<!-- omit from toc -->
12
# Developer documentation for fulcrum
23

34
This documentation is intended for developers working on fulcrum. This explains how to set up fulcrum locally, why some design decisions were made, and anything else you should need. If you need any help, message in #tech-team on the UWCS discord.
45

56
If you're a user pls see the [user documentation](USER.md) instead.
67

8+
<!-- omit from toc -->
79
## Table of Contents
810

911
- [Running](#running)
1012
- [SSL Note](#ssl-note)
11-
- [libcairo-2.dll](#libcairo-2dll)
13+
- [Timezones Note](#timezones-note)
1214
- [Stack](#stack)
1315
- [File Structure](#file-structure)
1416
- [Database schema](#database-schema)
1517
- [API](#api)
18+
- [Creating and managing API keys](#creating-and-managing-api-keys)
1619
- [Styling](#styling)
1720
- [Icons](#icons)
1821
- [Updating phosphor icons](#updating-phosphor-icons)
@@ -22,6 +25,7 @@ If you're a user pls see the [user documentation](USER.md) instead.
2225
- [Warwick Weeks](#warwick-weeks)
2326
- [Warwick Map API](#warwick-map-api)
2427
- [Testing](#testing)
28+
- [Ideas for the future](#ideas-for-the-future)
2529

2630
## Running
2731

@@ -62,6 +66,12 @@ The app uses UWCS's keycloak server for authentication. This is set to enable bo
6266

6367
If you are not exec, you can bypass auth by setting the environment variable `DEV` to `1`.
6468

69+
### Timezones Note
70+
71+
The database currently stores and interprets all datetimes as naive Europe/London dts. An ORM reconstructor is used in the `Event` model to make localising of read dts consistent with what (mostly) previously occurred with a newly constructed `Event`. The API now returns full ISO-8601 dt strings for external usage.
72+
73+
Using `replace` on `tzinfo` with pytz causes inconsistent behaviour, notably causing weird offsets of 1 minute, hence the use of `localize` instead. This is due to LMT shenanigans (see Raven's braindump about this if you're interested: https://discord.com/channels/189453139584221185/292281159495450625/1421431330440216696)
74+
6575
## Stack
6676

6777
Note the site uses the same stack (Flask, SQLAlchemy) as [CS139](https://warwick.ac.uk/fac/sci/dcs/teaching/modules/cs139/), to enable easy maintenance and development by most DCS students. Notably this means that there is no frontend framework (e.g. React, Vue) and the site is rendered server-side. Should this change in the future, the API is set up to partially support this (although it will probably need some tweaking). If some js is complex, I would reccomend using a pre-existing library (see bootstrap and tags) as this will make maintenance easier.

config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
from datetime import datetime
44
from pathlib import Path
55

6-
import pytz
76
import requests
7+
from pytz import timezone
88

99
# custom colours
1010
colours = {
@@ -86,7 +86,7 @@
8686
# +5 years for future proofing
8787
# note, this requires the app to be restarted every 5 years
8888
# (however given the current state of UWCS uptime, this is guaranteed to happen)
89-
_now = datetime.now(tz=pytz.timezone("Europe/London"))
89+
_now = datetime.now(tz=timezone("Europe/London"))
9090
_academic_year = _now.year if _now.month <= 9 else _now.year + 1 # noqa: PLR2004
9191
try:
9292
warwick_weeks = [

events/ui.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from datetime import datetime, timedelta
22

3-
import pytz
43
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
54
from flask import Response as FlaskResponse
65
from icalendar import Calendar, Event
6+
from pytz import timezone
77
from werkzeug.datastructures import ImmutableMultiDict
88
from werkzeug.wrappers import Response
99

@@ -367,7 +367,7 @@ def get_ical() -> Response:
367367
"dtend",
368368
event.end_time if event.end_time else event.start_time + timedelta(hours=1),
369369
)
370-
ical_event.add("dtstamp", datetime.now(tz=pytz.timezone("Europe/London")))
370+
ical_event.add("dtstamp", datetime.now(timezone("Europe/London")))
371371
ical_event.add(
372372
"url",
373373
url_for(

events/utils.py

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,38 @@
33
from typing import Match
44
from xml.etree import ElementTree as ET
55

6-
import pytz
76
from markdown import Markdown, markdown
87
from markdown.extensions import Extension
98
from markdown.inlinepatterns import InlineProcessor
109
from markdown.treeprocessors import Treeprocessor
1110
from markupsafe import escape
11+
from pytz import timezone
1212
from sqlalchemy import func
1313

1414
from config import colours, old_dates, phosphor_icons, warwick_weeks # , room_mapping
1515
from schema import Event, Tag, Week, db
1616

17+
LONDON = timezone("Europe/London")
1718

1819
def get_datetime_from_string(date_str: str) -> datetime | str:
19-
"""Convert a date string in the format 'YYYY-MM-DDTHH:MM' to a datetime object."""
20+
"""
21+
Convert datetime str in the format 'YYYY-MM-DDTHH:MM'
22+
to a Europe/London tz-aware datetime object
23+
"""
24+
2025
try:
21-
return datetime.strptime(date_str, "%Y-%m-%dT%H:%M").replace(
22-
tzinfo=pytz.timezone("Europe/London")
23-
)
26+
return LONDON.localize(datetime.strptime(date_str, "%Y-%m-%dT%H:%M")) # noqa: DTZ007
2427
except ValueError:
2528
return "Invalid date format, expected 'YYYY-MM-DDTHH:MM'"
2629

2730

2831
def get_date_from_string(date_str: str) -> datetime | str:
29-
"""Convert a date string in the format 'YYYY-MM-DD' to a datetime object."""
32+
"""
33+
Convert datetime str in the format 'YYYY-MM-DD' to a Europe/London tz-aware datetime
34+
"""
35+
3036
try:
31-
return datetime.strptime(date_str, "%Y-%m-%d").replace(
32-
tzinfo=pytz.timezone("Europe/London")
33-
)
37+
return LONDON.localize(datetime.strptime(date_str, "%Y-%m-%d")) # noqa: DTZ007
3438
except ValueError:
3539
return "Invalid date format, expected 'YYYY-MM-DD'"
3640

@@ -49,11 +53,6 @@ def create_event( # noqa: PLR0913
4953
) -> Event | str:
5054
"""Create an event"""
5155

52-
# convert start_time and normalise end_time
53-
start_time = start_time.replace(tzinfo=pytz.timezone("Europe/London"))
54-
55-
end_time = end_time.replace(tzinfo=pytz.timezone("Europe/London"))
56-
5756
if end_time < start_time:
5857
return "End time cannot be before start time"
5958

@@ -220,6 +219,7 @@ def get_week_by_date(date: datetime) -> Week | None: # noqa: PLR0911, PLR0912
220219
def create_week_from_date(date: datetime) -> Week | None:
221220
"""Create a week from a given date"""
222221

222+
# date.date() should already be localised by this point
223223
week = Week.query.filter(
224224
(date.date() >= Week.start_date) & (date.date() <= Week.end_date) # type: ignore
225225
).first()
@@ -260,7 +260,7 @@ def create_repeat_event( # noqa: PLR0913
260260
location_url,
261261
icon,
262262
colour,
263-
start_time,
263+
start_time, # localised in create_event
264264
end_time,
265265
tags,
266266
)
@@ -304,12 +304,10 @@ def get_event_by_id(event_id: int) -> Event | None:
304304

305305
def get_event_by_slug(year: int, term: int, week: int, slug: str) -> Event | None:
306306
"""Get an event by slug"""
307-
return (
308-
Event.query.filter(
307+
return Event.query.filter(
309308
Event.week.has(academic_year=year, term=term, week=week),
310309
Event.slug == slug, # type: ignore
311-
)
312-
).first()
310+
).first()
313311

314312

315313
# shamelessly stolen from docs (https://python-markdown.github.io/extensions/api/#example_3)
@@ -375,8 +373,9 @@ def prepare_event(event: Event) -> dict:
375373
def group_events(events: list[Event]) -> list[dict]:
376374
"""Group events by term, week, and day"""
377375

378-
# initalise dictionary
376+
# initialise dictionary
379377
# this is not too nested i have no clue what youre talking about
378+
# R: human equivalent of when copilot goes loopy
380379
grouped_events = defaultdict(
381380
lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
382381
)
@@ -431,12 +430,13 @@ def get_events_by_time(
431430
query = query.filter(Event.draft.is_(False)) # type: ignore
432431

433432
# order by start_time, end_time, and name
434-
return query.order_by(Event.start_time, Event.end_time, Event.name).all() # type: ignore
435-
433+
return query.order_by(
434+
Event.start_time, Event.end_time, Event.name # type: ignore
435+
).all()
436436

437437
def get_upcoming_events(include_drafts: bool = False) -> list[Event]:
438438
"""Get all events in this week, and future weeks"""
439-
now = datetime.now(pytz.timezone("Europe/London"))
439+
now = datetime.now(LONDON)
440440
week = get_week_by_date(now)
441441

442442
if not week:
@@ -448,8 +448,8 @@ def get_upcoming_events(include_drafts: bool = False) -> list[Event]:
448448
query = query.filter(Event.draft.is_(False)) # type: ignore
449449

450450
return query.order_by(
451-
Event.start_time, Event.end_time, Event.name # type: ignore
452-
).all()
451+
Event.start_time, Event.end_time, Event.name # type: ignore
452+
).all()
453453

454454

455455
def get_all_events(include_drafts: bool = False) -> list[Event]:
@@ -466,7 +466,7 @@ def get_all_events(include_drafts: bool = False) -> list[Event]:
466466

467467
def get_week_events() -> list[Event]:
468468
"""Get all events in the current week"""
469-
now = datetime.now(pytz.timezone("Europe/London"))
469+
now = datetime.now(LONDON)
470470
week = get_week_by_date(now)
471471

472472
if not week:
@@ -494,7 +494,7 @@ def get_events_in_week_range(start: Week, end: Week) -> list[Event]:
494494

495495
def get_days_events(days: int) -> list[Event]:
496496
"""Get all events in the next <days> days"""
497-
now = datetime.now(pytz.timezone("Europe/London"))
497+
now = datetime.now(LONDON)
498498
end_date = now + timedelta(days=days)
499499

500500
return (
@@ -541,21 +541,22 @@ def edit_event( # noqa: PLR0913
541541
event.location_url = (
542542
location_url if location_url is not _KEEP else event.location_url
543543
)
544+
544545
if icon is not _KEEP:
545546
event.icon = icon.lower() if icon is not None else event.icon # type: ignore
546547
if icon in phosphor_icons:
547548
event.icon = f"ph-{icon}"
548549
event.colour = colour if colour is not _KEEP else event.colour
549550

550551
event.start_time = (
551-
start_time.replace(tzinfo=pytz.timezone("Europe/London")) # type: ignore
552+
start_time # type: ignore
552553
if start_time is not _KEEP
553-
else event.start_time.replace(tzinfo=pytz.timezone("Europe/London")) # type: ignore
554+
else event.start_time # type: ignore
554555
)
555556
event.end_time = (
556-
end_time.replace(tzinfo=pytz.timezone("Europe/London")) # type: ignore
557+
end_time # type: ignore
557558
if end_time is not _KEEP
558-
else event.end_time.replace(tzinfo=pytz.timezone("Europe/London")) # type: ignore
559+
else event.end_time # type: ignore
559560
)
560561

561562
# update the week associated with the event

schema.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import re
22
from datetime import date, datetime, timedelta
33

4-
import pytz
54
from flask import Flask, url_for
65
from flask_sqlalchemy import SQLAlchemy
6+
from pytz import timezone
77
from sqlalchemy import and_, func
8-
from sqlalchemy.orm import foreign
8+
from sqlalchemy.orm import foreign, reconstructor
99

1010
from config import colours, custom_icons, icons
1111

12+
LONDON = timezone("Europe/London")
13+
1214
db = SQLAlchemy()
1315

1416

@@ -148,9 +150,42 @@ def __init__( # noqa: PLR0913
148150
self.location_url = location_url
149151
self.icon = icon
150152
self.colour = colour if colour else "blue"
151-
# ensure times are london-time
152-
self.start_time = start_time.replace(tzinfo=pytz.timezone("Europe/London"))
153-
self.end_time = end_time.replace(tzinfo=pytz.timezone("Europe/London"))
153+
self.start_time = start_time
154+
self.end_time = end_time
155+
self._localise_times()
156+
157+
@reconstructor
158+
def reinit(self) -> None:
159+
"""
160+
Localise start and end times on db load
161+
Previously only happened in __init__ ie only new instantiations
162+
Removes need to remember to manually localise everywhere
163+
"""
164+
165+
self._localise_times()
166+
167+
def _localise_times(self) -> None:
168+
"""
169+
Helper to localise start and end times of events
170+
171+
Only localise if not already tz-aware - allows external localisation
172+
173+
For tz-aware DB, harden to strictly enforce 'Europe/London' e.g.
174+
```
175+
self.end_time.tzinfo.zone == LONDON.zone
176+
```
177+
None is fine rn and cleaner
178+
Use localize instead of .replace to avoid LMT offset issue
179+
180+
Should not need to localise start_time or end_time outside this
181+
"""
182+
183+
self.start_time = LONDON.localize(self.start_time) \
184+
if self.start_time.tzinfo is None \
185+
else self.start_time
186+
self.end_time = LONDON.localize(self.end_time) \
187+
if self.end_time.tzinfo is None \
188+
else self.end_time
154189

155190
def __repr__(self) -> str:
156191
return (
@@ -257,7 +292,7 @@ class APIKey(db.Model):
257292
def __init__(self, key_hash: str, owner: str) -> None:
258293
self.key_hash = key_hash
259294
self.owner = owner
260-
self.created_at = datetime.now(pytz.timezone("Europe/London"))
295+
self.created_at = datetime.now(LONDON)
261296
self.active = True
262297

263298
def __repr__(self) -> str:

0 commit comments

Comments
 (0)