Skip to content

Commit 601585f

Browse files
authored
Merge pull request bcgov#101 from tom0827/ENGAGE-96
[ENGAGE-96] Event Widget selectable timezone
2 parents ea71cdf + f485565 commit 601585f

File tree

18 files changed

+250
-70
lines changed

18 files changed

+250
-70
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""add timezone to event
2+
3+
Revision ID: c5ee420f661a
4+
Revises: a72e849d93ad
5+
Create Date: 2025-10-14 12:01:15.322303
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
# revision identifiers, used by Alembic.
12+
revision = 'c5ee420f661a'
13+
down_revision = 'a72e849d93ad'
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade():
19+
# ### commands auto generated by Alembic - please adjust! ###
20+
op.add_column('event_item', sa.Column('timezone', sa.String(length=50), nullable=True))
21+
op.execute("UPDATE event_item SET timezone = 'Canada/Pacific'")
22+
# ### end Alembic commands ###
23+
24+
25+
def downgrade():
26+
# ### commands auto generated by Alembic - please adjust! ###
27+
op.drop_column('event_item', 'timezone')
28+
# ### end Alembic commands ###

met-api/src/met_api/models/event_item.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class EventItem(BaseModel): # pylint: disable=too-few-public-methods, too-many-
2424
url_label = db.Column(db.String(100), comment='Label to show for href links')
2525
sort_index = db.Column(db.Integer, nullable=True, default=1)
2626
widget_events_id = db.Column(db.Integer, ForeignKey('widget_events.id', ondelete='CASCADE'), nullable=True)
27+
timezone = db.Column(db.String(50))
2728

2829
@classmethod
2930
def save_event_items(cls, event_items: list) -> None:

met-api/src/met_api/schemas/event_item.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,39 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
"""Manager for widget link schema and export."""
14+
"""Manager for Event Item Schemas."""
1515

16+
from marshmallow import fields
17+
import pytz
1618
from met_api.models import EventItem as EventItemModel
17-
1819
from .base_schema import BaseSchema
1920

2021

22+
class LocalizedDateTime(fields.DateTime):
23+
"""Custom Marshmallow field to output datetime in the instance's timezone."""
24+
25+
def __init__(self, tz_field_name, *args, **kwargs):
26+
"""Initialize the field with the name of the timezone field."""
27+
super().__init__(*args, **kwargs)
28+
self.tz_field_name = tz_field_name
29+
30+
def _serialize(self, value, attr, obj, **kwargs):
31+
if value is None:
32+
return None
33+
tz_name = getattr(obj, self.tz_field_name, None)
34+
if tz_name:
35+
tz = pytz.timezone(tz_name)
36+
value = value.astimezone(tz)
37+
return super()._serialize(value, attr, obj, **kwargs)
38+
39+
2140
class EventItemSchema(BaseSchema): # pylint: disable=too-many-ancestors, too-few-public-methods
2241
"""This is the schema for the Contact link model."""
2342

43+
start_date = LocalizedDateTime(tz_field_name='timezone', timezone=True)
44+
end_date = LocalizedDateTime(tz_field_name='timezone', timezone=True)
45+
timezone = fields.String()
46+
2447
class Meta(BaseSchema.Meta): # pylint: disable=too-few-public-methods
2548
"""Maps all of the Widget Events fields to a default schema."""
2649

met-api/src/met_api/services/widget_events_service.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from met_api.exceptions.business_exception import BusinessException
66
from met_api.models.event_item import EventItem as EventItemsModel
77
from met_api.models.widget_events import WidgetEvents as WidgetEventsModel
8+
from met_api.utils.datetime import to_utc
89

910

1011
class WidgetEventsService:
@@ -45,13 +46,13 @@ def _create_event_model(widget_id, event_details: dict):
4546
event.widget_id = widget_id
4647
event.title = event_details.get('title')
4748
event.type = event_details.get('type')
48-
sort_index = WidgetEventsService._find_higest_sort_index(widget_id)
49+
sort_index = WidgetEventsService._find_highest_sort_index(widget_id)
4950
event.sort_index = sort_index + 1
5051
event.flush()
5152
return event
5253

5354
@staticmethod
54-
def _find_higest_sort_index(widget_id):
55+
def _find_highest_sort_index(widget_id):
5556
# find the highest sort order of the widget event
5657
sort_index = 0
5758
widget_events = WidgetEventsModel.get_all_by_widget_id(widget_id)
@@ -74,10 +75,11 @@ def _create_event_item(event, widget_events_id):
7475
event_item.description = event.get('description')
7576
event_item.location_name = event.get('location_name')
7677
event_item.location_address = event.get('location_address')
77-
event_item.start_date = event.get('start_date')
78-
event_item.end_date = event.get('end_date')
78+
event_item.start_date = to_utc(event.get('start_date'), event.get('timezone'))
79+
event_item.end_date = to_utc(event.get('end_date'), event.get('timezone'))
7980
event_item.url = event.get('url')
8081
event_item.url_label = event.get('url_label')
82+
event_item.timezone = event.get('timezone')
8183
event_item.widget_events_id = widget_events_id
8284
return event_item
8385

@@ -94,7 +96,12 @@ def update_event_item(widget_id, event_id, item_id, request_json):
9496
raise BusinessException(
9597
error='Invalid widgets and event',
9698
status_code=HTTPStatus.BAD_REQUEST)
97-
99+
# If timezone is being updated use new one otherwise use existing one.
100+
timezone = request_json.get('timezone', event_item.timezone)
101+
if 'start_date' in request_json:
102+
request_json['start_date'] = to_utc(request_json.get('start_date'), timezone)
103+
if 'end_date' in request_json:
104+
request_json['end_date'] = to_utc(request_json.get('end_date'), timezone)
98105
WidgetEventsService._update_from_dict(event_item, request_json)
99106
event_item.commit()
100107

met-api/src/met_api/utils/datetime.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,16 @@ def convert_and_format_to_utc_str(date_val: datetime, dt_format='%Y-%m-%d %H:%M:
4747
utc_datetime_str = date_val_utc.strftime(dt_format)
4848

4949
return utc_datetime_str
50+
51+
52+
def to_utc(dt_str, tz_str):
53+
"""Convert a naive datetime string in a given timezone to a UTC datetime string."""
54+
# Parse naive datetime
55+
dt = datetime.fromisoformat(dt_str)
56+
# Convert timezone string to tzinfo object
57+
tz = pytz.timezone(tz_str)
58+
# Localize (attach tzinfo)
59+
dt_local = tz.localize(dt)
60+
# Convert to UTC
61+
dt_utc = dt_local.astimezone(pytz.utc)
62+
return dt_utc.isoformat()

met-api/tests/unit/api/test_widget_event.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from met_api.utils.enums import ContentType
2424
from met_api.constants.event_types import EventTypes
25-
from tests.utilities.factory_scenarios import TestEventnfo, TestJwtClaims, TestWidgetInfo
25+
from tests.utilities.factory_scenarios import TestEventInfo, TestJwtClaims, TestWidgetInfo
2626
from tests.utilities.factory_utils import factory_auth_header, factory_engagement_model, factory_widget_model
2727

2828

@@ -34,7 +34,7 @@ def test_create_events(client, jwt, session): # pylint:disable=unused-argument
3434
engagement = factory_engagement_model()
3535
TestWidgetInfo.widget1['engagement_id'] = engagement.id
3636
widget = factory_widget_model(TestWidgetInfo.widget1)
37-
event_info = TestEventnfo.event_meetup
37+
event_info = TestEventInfo.event_meetup
3838
headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.no_role)
3939

4040
data = {
@@ -61,7 +61,7 @@ def test_widget_events_sort(client, jwt, session): # pylint:disable=unused-argu
6161
event_widget_info_1 = TestWidgetInfo.widget1
6262
event_widget_info_1['engagement_id'] = engagement.id
6363
widget = factory_widget_model(TestWidgetInfo.widget1)
64-
event_info = TestEventnfo.event_openhouse
64+
event_info = TestEventInfo.event_openhouse
6565
headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.no_role)
6666
data = {
6767
**event_info,
@@ -75,7 +75,7 @@ def test_widget_events_sort(client, jwt, session): # pylint:disable=unused-argu
7575
)
7676
assert rv.status_code == 200
7777

78-
event_info = TestEventnfo.event_virtual
78+
event_info = TestEventInfo.event_virtual
7979
headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.no_role)
8080
data = {
8181
**event_info,

met-api/tests/utilities/factory_scenarios.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from enum import Enum
2121

2222
from faker import Faker
23+
import pytz
2324

2425
from met_api.config import get_named_config
2526
from met_api.constants.comment_status import Status as CommentStatus
@@ -519,7 +520,7 @@ class TestCommentInfo(dict, Enum):
519520
}
520521

521522

522-
class TestEventnfo(dict, Enum):
523+
class TestEventInfo(dict, Enum):
523524
"""Test scenarios of event."""
524525

525526
event_meetup = {
@@ -535,6 +536,7 @@ class TestEventnfo(dict, Enum):
535536
'end_date': (datetime.now() + timedelta(weeks=+1)).strftime('%Y-%m-%d'),
536537
'url': fake.url(),
537538
'url_label': fake.name(),
539+
'timezone': pytz.timezone('Canada/Pacific').zone
538540
}
539541
]
540542
}
@@ -552,6 +554,7 @@ class TestEventnfo(dict, Enum):
552554
'end_date': (datetime.now() + timedelta(weeks=+1)).strftime('%Y-%m-%d'),
553555
'url': fake.url(),
554556
'url_label': fake.name(),
557+
'timezone': pytz.timezone('Canada/Pacific').zone
555558
}
556559
]
557560
}
@@ -569,6 +572,7 @@ class TestEventnfo(dict, Enum):
569572
'end_date': (datetime.now() + timedelta(weeks=+1)).strftime('%Y-%m-%d'),
570573
'url': fake.url(),
571574
'url_label': fake.name(),
575+
'timezone': pytz.timezone('Canada/Pacific').zone
572576
}
573577
]
574578
}

met-web/src/components/engagement/form/EngagementWidgets/Events/EventInfoPaper.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { When } from 'react-if';
88
import { Event } from 'models/event';
99
import { formatDate } from 'components/common/dateHelper';
1010
import { EventsContext } from './EventsContext';
11+
import { TIMEZONE_OPTIONS } from 'constants/timezones';
1112

1213
export interface EventInfoPaperProps {
1314
event: Event;
@@ -79,10 +80,18 @@ const EventInfoPaper = ({ event, removeEvent, ...rest }: EventInfoPaperProps) =>
7980
</Grid>
8081
<Grid item xs={9}>
8182
<MetParagraph overflow="hidden" textOverflow={'ellipsis'} whiteSpace="nowrap">
82-
{`${formatDate(eventItem.start_date, 'h:mm a')} to ${formatDate(
83-
eventItem.end_date,
84-
'h:mm a',
85-
)} PT`}
83+
{`${new Date(eventItem.start_date).toLocaleTimeString('en-US', {
84+
hour: 'numeric',
85+
minute: '2-digit',
86+
hour12: true,
87+
})} to ${new Date(eventItem.end_date).toLocaleTimeString('en-US', {
88+
hour: 'numeric',
89+
minute: '2-digit',
90+
hour12: true,
91+
})} ${
92+
TIMEZONE_OPTIONS.find((tz: { value: string }) => tz.value === eventItem.timezone)
93+
?.label || ''
94+
}`}
8695
</MetParagraph>
8796
</Grid>
8897
</Grid>

met-web/src/components/engagement/form/EngagementWidgets/Events/InPersonEventFormDrawer.tsx

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useContext, useState, useEffect } from 'react';
22
import Box from '@mui/material/Box';
33
import Drawer from '@mui/material/Drawer';
44
import Divider from '@mui/material/Divider';
5-
import { Grid } from '@mui/material';
5+
import { Grid, MenuItem } from '@mui/material';
66
import { MetHeader3, MetLabel, PrimaryButton, SecondaryButton } from 'components/common';
77
import { useForm, FormProvider, SubmitHandler } from 'react-hook-form';
88
import { yupResolver } from '@hookform/resolvers/yup';
@@ -13,13 +13,11 @@ import ControlledTextField from 'components/common/ControlledInputComponents/Con
1313
import { openNotification } from 'services/notificationService/notificationSlice';
1414
import { postEvent, patchEvent, PatchEventProps } from 'services/widgetService/EventService';
1515
import { Event, EVENT_TYPE } from 'models/event';
16-
import { formatToUTC, formatDate } from 'components/common/dateHelper';
16+
import { formatDate } from 'components/common/dateHelper';
1717
import { formEventDates } from './utils';
18-
import dayjs from 'dayjs';
19-
import tz from 'dayjs/plugin/timezone';
2018
import { updatedDiff } from 'deep-object-diff';
21-
22-
dayjs.extend(tz);
19+
import ControlledSelect from 'components/common/ControlledInputComponents/ControlledSelect';
20+
import { TIMEZONE_OPTIONS, TIMEZONES } from 'constants/timezones';
2321

2422
const schema = yup
2523
.object({
@@ -35,6 +33,7 @@ const schema = yup
3533
date: yup.string().defined().required('Date cannot be empty'),
3634
time_from: yup.string().required('Time from cannot be empty'),
3735
time_to: yup.string().required('Time to cannot be empty'),
36+
timezone: yup.string().oneOf(Object.values(TIMEZONES)).defined().default(TIMEZONES.CANADA_PACIFIC),
3837
})
3938
.required();
4039

@@ -53,8 +52,8 @@ const InPersonEventFormDrawer = () => {
5352
} = useContext(EventsContext);
5453
const [isCreating, setIsCreating] = useState(false);
5554
const eventItemToEdit = eventToEdit ? eventToEdit.event_items[0] : null;
56-
const startDate = dayjs(eventItemToEdit ? eventItemToEdit?.start_date : '').tz('US/Pacific');
57-
const endDate = dayjs(eventItemToEdit ? eventItemToEdit?.end_date : '').tz('US/Pacific');
55+
const startDate = eventItemToEdit ? new Date(eventItemToEdit.start_date) : null;
56+
const endDate = eventItemToEdit ? new Date(eventItemToEdit.end_date) : null;
5857
const methods = useForm<InPersonEventForm>({
5958
resolver: yupResolver(schema),
6059
});
@@ -70,8 +69,12 @@ const InPersonEventFormDrawer = () => {
7069
methods.setValue('location_name', eventItemToEdit?.location_name || '');
7170
methods.setValue('location_address', eventItemToEdit?.location_address || '');
7271
methods.setValue('date', eventItemToEdit ? formatDate(eventItemToEdit.start_date) : '');
73-
methods.setValue('time_from', pad(startDate.hour()) + ':' + pad(startDate.minute()) || '');
74-
methods.setValue('time_to', pad(endDate.hour()) + ':' + pad(endDate.minute()) || '');
72+
methods.setValue(
73+
'time_from',
74+
startDate ? pad(startDate.getHours()) + ':' + pad(startDate.getMinutes()) || '' : '',
75+
);
76+
methods.setValue('time_to', endDate ? pad(endDate.getHours()) + ':' + pad(endDate.getMinutes()) || '' : '');
77+
methods.setValue('timezone', eventItemToEdit?.timezone || TIMEZONES.CANADA_PACIFIC);
7578
}, [eventToEdit]);
7679

7780
const { handleSubmit, reset } = methods;
@@ -86,8 +89,8 @@ const InPersonEventFormDrawer = () => {
8689
}) as PatchEventProps;
8790

8891
await patchEvent(widget.id, eventToEdit.id, eventItemToEdit.id, {
89-
start_date: formatToUTC(dateFrom),
90-
end_date: formatToUTC(dateTo),
92+
start_date: dateFrom,
93+
end_date: dateTo,
9194
...eventUpdatesToPatch,
9295
});
9396

@@ -98,7 +101,7 @@ const InPersonEventFormDrawer = () => {
98101

99102
const createEvent = async (data: InPersonEventForm) => {
100103
const validatedData = await schema.validate(data);
101-
const { description, location_address, location_name, date, time_from, time_to } = validatedData;
104+
const { description, location_address, location_name, date, time_from, time_to, timezone } = validatedData;
102105
const { dateFrom, dateTo } = formEventDates(date, time_from, time_to);
103106
if (widget) {
104107
const createdWidgetEvent = await postEvent(widget.id, {
@@ -109,8 +112,9 @@ const InPersonEventFormDrawer = () => {
109112
description: description,
110113
location_name: location_name,
111114
location_address: location_address,
112-
start_date: formatToUTC(dateFrom),
113-
end_date: formatToUTC(dateTo),
115+
timezone: timezone,
116+
start_date: dateFrom,
117+
end_date: dateTo,
114118
},
115119
],
116120
});
@@ -250,6 +254,26 @@ const InPersonEventFormDrawer = () => {
250254
size="small"
251255
/>
252256
</Grid>
257+
<Grid item>
258+
<MetLabel sx={{ marginBottom: '2px' }}>Timezone</MetLabel>
259+
<ControlledSelect
260+
name="timezone"
261+
variant="outlined"
262+
label=" "
263+
InputLabelProps={{
264+
shrink: false,
265+
}}
266+
fullWidth
267+
size="small"
268+
defaultValue={TIMEZONES.CANADA_PACIFIC}
269+
>
270+
{TIMEZONE_OPTIONS.map((option) => (
271+
<MenuItem key={option.value} value={option.value}>
272+
{option.label}
273+
</MenuItem>
274+
))}
275+
</ControlledSelect>
276+
</Grid>
253277
<Grid
254278
item
255279
xs={12}

0 commit comments

Comments
 (0)