Skip to content

Commit 922e920

Browse files
committed
Fix merge conflict
2 parents 6d0ca19 + 67a158b commit 922e920

23 files changed

Lines changed: 620 additions & 380 deletions

File tree

app/backend/src/couchers/i18n/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"event_in_past": "The event must be in the future.",
7474
"event_not_an_organizer": "That user is not an organizer.",
7575
"event_not_found": "Event not found.",
76+
"event_timezone_not_found": "Could not find a timezone for the event location.",
7677
"event_too_far_in_future": "The event needs to start within the next year.",
7778
"event_too_long": "Events cannot last longer than 7 days.",
7879
"event_transfer_permission_denied": "You're not allowed to transfer that event.",
@@ -111,6 +112,7 @@
111112
"invalid_delivery_method": "Invalid delivery method.",
112113
"invalid_email": "Invalid email.",
113114
"invalid_endpoint": "Invalid push notification endpoint.",
115+
"invalid_event_start_end_datetime": "Invalid event start or end date and time.",
114116
"invalid_guide_location": "You need to either supply an address and location or neither for a guide.",
115117
"invalid_host_request_status": "You can't set the host request status to that.",
116118
"invalid_public_trip_status": "You can't set the public trip status to that.",
@@ -134,6 +136,7 @@
134136
"missing_discussion_content": "Missing discussion content.",
135137
"missing_discussion_title": "Missing discussion title.",
136138
"missing_event_address_or_location": "Missing event address or location.",
139+
"missing_event_start_end_datetime": "Missing event start or end date and time.",
137140
"missing_event_content": "Missing event content.",
138141
"missing_event_title": "Missing event title.",
139142
"missing_page_address": "Missing page address.",
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Add event timezone column
2+
3+
Revision ID: 0172
4+
Revises: 0171
5+
Create Date: 2026-07-02 13:47:21.368662
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "0172"
14+
down_revision = "0171"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
op.add_column("event_occurrences", sa.Column("timezone", sa.String(), nullable=True))
21+
22+
op.execute(
23+
"""
24+
UPDATE event_occurrences
25+
SET timezone = (
26+
SELECT tzid
27+
FROM timezone_areas
28+
WHERE ST_Contains(timezone_areas.geom, event_occurrences.geom)
29+
LIMIT 1
30+
)
31+
WHERE event_occurrences.geom IS NOT NULL
32+
"""
33+
)
34+
35+
op.execute(
36+
"""
37+
UPDATE event_occurrences
38+
SET timezone = 'Etc/UTC'
39+
WHERE timezone IS NULL
40+
"""
41+
)
42+
43+
op.alter_column("event_occurrences", "timezone", nullable=False)
44+
45+
46+
def downgrade() -> None:
47+
op.drop_column("event_occurrences", "timezone")

app/backend/src/couchers/models/events.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ class EventOccurrence(Base, kw_only=True):
134134
# The physical address string. Legacy online events have been migrated to put the link in here.
135135
address: Mapped[str] = mapped_column(String)
136136

137-
timezone = "Etc/UTC"
137+
# IANA timezone identifier of the event
138+
timezone: Mapped[str] = mapped_column(String)
138139

139140
# time during which the event takes place; this is a range type (instead of separate start+end times) which
140141
# simplifies database constraints, etc

app/backend/src/couchers/servicers/events.py

Lines changed: 108 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import logging
22
from datetime import datetime, timedelta
33
from typing import Any, cast
4+
from zoneinfo import ZoneInfo
45

56
import grpc
7+
from geoalchemy2 import WKBElement
68
from google.protobuf import empty_pb2
79
from psycopg.types.range import TimestamptzRange
8-
from sqlalchemy import Select, select
10+
from sqlalchemy import Select, func, select
911
from sqlalchemy.orm import Session
1012
from sqlalchemy.sql import and_, func, or_, update
1113

@@ -32,6 +34,7 @@
3234
User,
3335
)
3436
from couchers.models.notifications import NotificationTopicAction
37+
from couchers.models.static import TimezoneArea
3538
from couchers.moderation.utils import create_moderation
3639
from couchers.notifications.notify import notify
3740
from couchers.proto import events_pb2, events_pb2_grpc, notification_data_pb2
@@ -44,11 +47,11 @@
4447
from couchers.utils import (
4548
Timestamp_from_datetime,
4649
create_coordinate,
50+
datetime_to_iso8601_local,
4751
dt_from_millis,
4852
millis_from_dt,
4953
not_none,
5054
now,
51-
to_aware_datetime,
5255
)
5356

5457
logger = logging.getLogger(__name__)
@@ -220,6 +223,60 @@ def _get_event_and_occurrence_one_or_none(
220223
return result._tuple() if result else None
221224

222225

226+
def _check_location(location: events_pb2.EventLocation | None, context: CouchersContext) -> tuple[WKBElement, str]:
227+
# As protobuf parses a missing value as 0.0, this is not a permitted event coordinate value
228+
if not location or not location.address:
229+
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location")
230+
if location.lat == 0 and location.lng == 0:
231+
# No events allowed on Null Island
232+
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
233+
234+
geom = create_coordinate(location.lat, location.lng)
235+
return (geom, location.address)
236+
237+
238+
def _check_timezone_at(geom: WKBElement, context: CouchersContext, session: Session) -> ZoneInfo:
239+
timezone_id = session.execute(
240+
select(TimezoneArea.tzid).where(func.ST_Contains(TimezoneArea.geom, func.ST_PointOnSurface(geom))).limit(1)
241+
).scalar_one_or_none()
242+
if not timezone_id:
243+
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_timezone_not_found")
244+
245+
return ZoneInfo(timezone_id)
246+
247+
248+
def _check_iso8601_local_datetime(value: str, timezone: ZoneInfo, context: CouchersContext) -> datetime:
249+
if not value:
250+
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_start_end_datetime")
251+
252+
try:
253+
naive_datetime = datetime.fromisoformat(value)
254+
except ValueError:
255+
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_event_start_end_datetime")
256+
257+
if naive_datetime.tzinfo is not None:
258+
# Expected a local datetime, otherwise we have two sources of timezones.
259+
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_event_start_end_datetime")
260+
261+
return naive_datetime.replace(tzinfo=timezone).replace(second=0, microsecond=0)
262+
263+
264+
def _update_datetime(
265+
new_iso8601_local: str | None,
266+
new_timezone: ZoneInfo,
267+
old_datetime: datetime,
268+
old_timezone: ZoneInfo,
269+
context: CouchersContext,
270+
) -> datetime:
271+
if new_iso8601_local is None and new_timezone != old_timezone:
272+
# Local time wasn't updated, but the timezone changed so the effective datetime/timestamp may have changed.
273+
new_iso8601_local = datetime_to_iso8601_local(old_datetime.astimezone(old_timezone))
274+
if new_iso8601_local is None:
275+
return old_datetime # No change
276+
# New effective datetime/timestamp
277+
return _check_iso8601_local_datetime(new_iso8601_local, new_timezone, context)
278+
279+
223280
def _check_occurrence_time_validity(start_time: datetime, end_time: datetime, context: CouchersContext) -> None:
224281
if start_time < now():
225282
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_in_past")
@@ -394,20 +451,11 @@ def CreateEvent(
394451
if not request.content:
395452
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content")
396453

397-
# As protobuf parses a missing value as 0.0, this is not a permitted event coordinate value
398-
if not (
399-
request.HasField("location") and request.location.address and request.location.lat and request.location.lng
400-
):
401-
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location")
402-
if request.location.lat == 0 and request.location.lng == 0:
403-
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
404-
geom = create_coordinate(request.location.lat, request.location.lng)
405-
address = request.location.address
406-
407-
start_time = to_aware_datetime(request.start_time)
408-
end_time = to_aware_datetime(request.end_time)
409-
410-
_check_occurrence_time_validity(start_time, end_time, context)
454+
geom, address = _check_location(request.location if request.HasField("location") else None, context)
455+
timezone = _check_timezone_at(geom, context, session)
456+
start_datetime = _check_iso8601_local_datetime(request.start_datetime_iso8601_local, timezone, context)
457+
end_datetime = _check_iso8601_local_datetime(request.end_datetime_iso8601_local, timezone, context)
458+
_check_occurrence_time_validity(start_datetime, end_datetime, context)
411459

412460
if request.parent_community_id:
413461
parent_node = session.execute(
@@ -452,9 +500,9 @@ def create_occurrence(moderation_state_id: int) -> int:
452500
content=request.content,
453501
geom=geom,
454502
address=address,
503+
timezone=timezone.key,
455504
photo_key=request.photo_key if request.photo_key != "" else None,
456-
# timezone=timezone,
457-
during=TimestamptzRange(start_time, end_time),
505+
during=TimestamptzRange(start_datetime, end_datetime),
458506
creator_user_id=context.user_id,
459507
moderation_state_id=moderation_state_id,
460508
)
@@ -525,19 +573,12 @@ def ScheduleEvent(
525573
) -> events_pb2.Event:
526574
if not request.content:
527575
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content")
528-
if not (
529-
request.HasField("location") and request.location.address and request.location.lat and request.location.lng
530-
):
531-
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location")
532-
if request.location.lat == 0 and request.location.lng == 0:
533-
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
534-
geom = create_coordinate(request.location.lat, request.location.lng)
535-
address = request.location.address
536-
537-
start_time = to_aware_datetime(request.start_time)
538-
end_time = to_aware_datetime(request.end_time)
539576

540-
_check_occurrence_time_validity(start_time, end_time, context)
577+
geom, address = _check_location(request.location if request.HasField("location") else None, context)
578+
timezone = _check_timezone_at(geom, context, session)
579+
start_datetime = _check_iso8601_local_datetime(request.start_datetime_iso8601_local, timezone, context)
580+
end_datetime = _check_iso8601_local_datetime(request.end_datetime_iso8601_local, timezone, context)
581+
_check_occurrence_time_validity(start_datetime, end_datetime, context)
541582

542583
res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
543584
if not res:
@@ -557,7 +598,7 @@ def ScheduleEvent(
557598
):
558599
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "photo_not_found")
559600

560-
during = TimestamptzRange(start_time, end_time)
601+
during = TimestamptzRange(start_datetime, end_datetime)
561602

562603
# && is the overlap operator for ranges
563604
if (
@@ -582,8 +623,8 @@ def create_occurrence(moderation_state_id: int) -> int:
582623
content=request.content,
583624
geom=geom,
584625
address=address,
626+
timezone=timezone.key,
585627
photo_key=request.photo_key if request.photo_key != "" else None,
586-
# timezone=timezone,
587628
during=during,
588629
creator_user_id=context.user_id,
589630
moderation_state_id=moderation_state_id,
@@ -647,30 +688,45 @@ def UpdateEvent(
647688
if request.HasField("photo_key"):
648689
occurrence_update["photo_key"] = request.photo_key.value
649690

691+
old_timezone = ZoneInfo(occurrence.timezone)
692+
timezone: ZoneInfo = old_timezone
650693
if request.HasField("location"):
651694
notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION)
652-
if request.location.lat == 0 and request.location.lng == 0:
653-
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
654-
occurrence_update["geom"] = create_coordinate(request.location.lat, request.location.lng)
655-
occurrence_update["address"] = request.location.address
656-
657-
if request.HasField("start_time") or request.HasField("end_time"):
658-
if request.update_all_future:
659-
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_cant_update_all_times")
660-
if request.HasField("start_time"):
661-
notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME)
662-
start_time = to_aware_datetime(request.start_time)
663-
else:
664-
start_time = occurrence.start_time
665-
if request.HasField("end_time"):
666-
notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME)
667-
end_time = to_aware_datetime(request.end_time)
668-
else:
669-
end_time = occurrence.end_time
695+
geom, address = _check_location(request.location, context)
696+
timezone = _check_timezone_at(geom, context, session)
697+
occurrence_update["geom"] = geom
698+
occurrence_update["address"] = address
699+
occurrence_update["timezone"] = timezone.key
700+
701+
if timezone != old_timezone and request.update_all_future:
702+
# Not implemented: We'd need to change and recheck the datetimes on all existing occurrences
703+
context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_cant_update_all_times")
704+
705+
# Determine the new start/end datetimes, which may have changed explicitly or because of a timezone change
706+
start_datetime = _update_datetime(
707+
request.start_datetime_iso8601_local.value if request.HasField("start_datetime_iso8601_local") else None,
708+
timezone,
709+
old_datetime=occurrence.start_time,
710+
old_timezone=old_timezone,
711+
context=context,
712+
)
713+
if start_datetime != occurrence.start_time:
714+
notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME)
715+
716+
end_datetime = _update_datetime(
717+
request.end_datetime_iso8601_local.value if request.HasField("end_datetime_iso8601_local") else None,
718+
timezone,
719+
old_datetime=occurrence.end_time,
720+
old_timezone=old_timezone,
721+
context=context,
722+
)
723+
if end_datetime != occurrence.end_time:
724+
notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME)
670725

671-
_check_occurrence_time_validity(start_time, end_time, context)
726+
if start_datetime != occurrence.start_time or end_datetime != occurrence.end_time:
727+
_check_occurrence_time_validity(start_datetime, end_datetime, context)
672728

673-
during = TimestamptzRange(start_time, end_time)
729+
during = TimestamptzRange(start_datetime, end_datetime)
674730

675731
# && is the overlap operator for ranges
676732
if (
@@ -689,10 +745,6 @@ def UpdateEvent(
689745

690746
occurrence_update["during"] = during
691747

692-
# TODO
693-
# if request.HasField("timezone"):
694-
# occurrence_update["timezone"] = request.timezone
695-
696748
# allow editing any event which hasn't ended more than 24 hours before now
697749
# when editing all future events, we edit all which have not yet ended
698750

app/backend/src/couchers/utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ def to_timezone(value: Timestamp | datetime, timezone: tzinfo) -> datetime:
130130
return value.astimezone(timezone)
131131

132132

133+
def datetime_to_iso8601_local(value: datetime) -> str:
134+
"""
135+
Gets a local ISO 8601 representation of a datetime, without timezone information.
136+
This loses information and requires parsers to assume a timezone, so use with care.
137+
"""
138+
return value.replace(tzinfo=None).isoformat()
139+
140+
133141
def now() -> datetime:
134142
return datetime.now(tz=UTC)
135143

app/backend/src/tests/test_admin.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
reporting_pb2,
3939
requests_pb2,
4040
)
41-
from couchers.utils import Timestamp_from_datetime, now, parse_date
41+
from couchers.utils import Timestamp_from_datetime, datetime_to_iso8601_local, now, parse_date
4242
from tests.fixtures.db import add_users_to_new_moderation_list, generate_user, make_friends
4343
from tests.fixtures.misc import EmailCollector, PushCollector
4444
from tests.fixtures.sessions import (
@@ -760,9 +760,8 @@ def test_DeleteEvent(db):
760760
lat=0.1,
761761
lng=0.2,
762762
),
763-
start_time=Timestamp_from_datetime(start_time),
764-
end_time=Timestamp_from_datetime(end_time),
765-
timezone="UTC",
763+
start_datetime_iso8601_local=datetime_to_iso8601_local(start_time),
764+
end_datetime_iso8601_local=datetime_to_iso8601_local(end_time),
766765
)
767766
)
768767
event_id = res.event_id

app/backend/src/tests/test_communities.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
)
2727
from couchers.proto import api_pb2, auth_pb2, communities_pb2, discussions_pb2, events_pb2, pages_pb2
2828
from couchers.tasks import enforce_community_memberships
29-
from couchers.utils import Timestamp_from_datetime, create_coordinate, create_polygon_lat_lng, now, to_multi
29+
from couchers.utils import create_coordinate, create_polygon_lat_lng, datetime_to_iso8601_local, now, to_multi
3030
from tests.fixtures.db import generate_user, get_user_id_and_token
3131
from tests.fixtures.misc import Moderator
3232
from tests.fixtures.sessions import (
@@ -215,9 +215,8 @@ def create_event(
215215
lat=0.1,
216216
lng=0.2,
217217
),
218-
start_time=Timestamp_from_datetime(now() + start_td),
219-
end_time=Timestamp_from_datetime(now() + start_td + timedelta(hours=2)),
220-
timezone="UTC",
218+
start_datetime_iso8601_local=datetime_to_iso8601_local(now() + start_td),
219+
end_datetime_iso8601_local=datetime_to_iso8601_local(now() + start_td + timedelta(hours=2)),
221220
)
222221
)
223222
api.TransferEvent(

0 commit comments

Comments
 (0)