11import logging
22from datetime import datetime , timedelta
33from typing import Any , cast
4+ from zoneinfo import ZoneInfo
45
56import grpc
7+ from geoalchemy2 import WKBElement
68from google .protobuf import empty_pb2
79from psycopg .types .range import TimestamptzRange
8- from sqlalchemy import Select , select
10+ from sqlalchemy import Select , func , select
911from sqlalchemy .orm import Session
1012from sqlalchemy .sql import and_ , func , or_ , update
1113
3234 User ,
3335)
3436from couchers .models .notifications import NotificationTopicAction
37+ from couchers .models .static import TimezoneArea
3538from couchers .moderation .utils import create_moderation
3639from couchers .notifications .notify import notify
3740from couchers .proto import events_pb2 , events_pb2_grpc , notification_data_pb2
4447from 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
5457logger = 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+
223280def _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
0 commit comments