Use datetime for start & end in events

Storing date & time individually did originally seem like a better idea,
as it allowed nice indexing of the days which makes for faster lookups,
however, since standalone day & time informations don't carry timezone
data, doing this is problematic.

One option would be to introduce another field for the timezone, but
this just seems to be needlessly to complex, instead, store the start &
end event times as full datetimes. These will still be indexed and
should still result in sufficiently fast lookups.

Additionally, this also adds a compound index for start time & end time,
to improve speeds when searching by both at once.
This commit is contained in:
Peter Vacho 2024-12-29 23:14:21 +01:00
parent f7a3da7893
commit b1d3fa0600
Signed by: school
GPG key ID: 8CFC3837052871B4
3 changed files with 51 additions and 62 deletions

View file

@ -5,7 +5,7 @@
# You should NOT run this script in production. # You should NOT run this script in production.
import asyncio import asyncio
from datetime import date, time from datetime import UTC, datetime
from typing import cast, final from typing import cast, final
from beanie import PydanticObjectId from beanie import PydanticObjectId
@ -93,10 +93,8 @@ EVENTS: list[EventConstructData] = [
title="Event 1", title="Event 1",
description="Description 1", description="Description 1",
category_ids=[], category_ids=[],
start_date=date(2025, 1, 1), start_time=datetime(2025, 1, 1, 12, 00, tzinfo=UTC),
start_time=time(12, 00), end_time=datetime(2025, 1, 1, 12, 30, tzinfo=UTC),
end_date=date(2025, 1, 1),
end_time=time(12, 30),
color=Color("#ff0000"), color=Color("#ff0000"),
), ),
owner_username="user1", owner_username="user1",
@ -108,10 +106,8 @@ EVENTS: list[EventConstructData] = [
title="Event 2", title="Event 2",
description="Description 2", description="Description 2",
category_ids=[], category_ids=[],
start_date=date(2025, 1, 1), start_time=datetime(2025, 1, 1, 12, 00, tzinfo=UTC),
start_time=time(12, 00), end_time=datetime(2025, 1, 1, 12, 30, tzinfo=UTC),
end_date=date(2025, 1, 1),
end_time=time(12, 30),
color=Color("#ff0000"), color=Color("#ff0000"),
), ),
owner_username="user2", owner_username="user2",

View file

@ -1,4 +1,4 @@
from datetime import date, datetime, time from datetime import datetime
from typing import Annotated, cast, final from typing import Annotated, cast, final
from beanie import DeleteRules, Link, PydanticObjectId from beanie import DeleteRules, Link, PydanticObjectId
@ -31,10 +31,8 @@ class _BaseEventData(BaseModel):
title: Annotated[str, StringConstraints(min_length=1, max_length=50)] title: Annotated[str, StringConstraints(min_length=1, max_length=50)]
description: Annotated[str, StringConstraints(max_length=1000)] description: Annotated[str, StringConstraints(max_length=1000)]
category_ids: list[PydanticObjectId] category_ids: list[PydanticObjectId]
start_date: date start_time: datetime
start_time: time end_time: datetime
end_date: date
end_time: time
color: Color color: Color
@field_validator("color", mode="after") @field_validator("color", mode="after")
@ -77,9 +75,7 @@ class EventData(_BaseEventData):
title=event.title, title=event.title,
description=event.description, description=event.description,
category_ids=get_id_list(event.categories), category_ids=get_id_list(event.categories),
start_date=event.start_date,
start_time=event.start_time, start_time=event.start_time,
end_date=event.end_date,
end_time=event.end_time, end_time=event.end_time,
color=Color(event.color), color=Color(event.color),
attendee_ids=get_id_list(event.attendees), attendee_ids=get_id_list(event.attendees),
@ -104,9 +100,7 @@ class EventCreateData(_BaseEventData):
title=self.title, title=self.title,
description=self.description, description=self.description,
categories=await from_id_list(self.category_ids, Category, link_return=True), categories=await from_id_list(self.category_ids, Category, link_return=True),
start_date=self.start_date,
start_time=self.start_time, start_time=self.start_time,
end_date=self.end_date,
end_time=self.end_time, end_time=self.end_time,
color=self.color.as_hex(format="long"), color=self.color.as_hex(format="long"),
attendees=[], attendees=[],
@ -120,9 +114,7 @@ class EventCreateData(_BaseEventData):
title=self.title, title=self.title,
description=self.description, description=self.description,
categories=await from_id_list(self.category_ids, Category), categories=await from_id_list(self.category_ids, Category),
start_date=self.start_date,
start_time=self.start_time, start_time=self.start_time,
end_date=self.end_date,
end_time=self.end_time, end_time=self.end_time,
color=self.color.as_hex(format="long"), color=self.color.as_hex(format="long"),
) )
@ -135,10 +127,8 @@ class PartialEventUpdateData(_BaseEventData):
title: Annotated[str, StringConstraints(min_length=1, max_length=50)] | None = None # pyright: ignore[reportIncompatibleVariableOverride] title: Annotated[str, StringConstraints(min_length=1, max_length=50)] | None = None # pyright: ignore[reportIncompatibleVariableOverride]
description: Annotated[str, StringConstraints(max_length=1000)] | None = None # pyright: ignore[reportIncompatibleVariableOverride] description: Annotated[str, StringConstraints(max_length=1000)] | None = None # pyright: ignore[reportIncompatibleVariableOverride]
category_ids: list[PydanticObjectId] | None = None # pyright: ignore[reportIncompatibleVariableOverride] category_ids: list[PydanticObjectId] | None = None # pyright: ignore[reportIncompatibleVariableOverride]
start_date: date | None = None # pyright: ignore[reportIncompatibleVariableOverride] start_time: datetime | None = None # pyright: ignore[reportIncompatibleVariableOverride]
start_time: time | None = None # pyright: ignore[reportIncompatibleVariableOverride] end_time: datetime | None = None # pyright: ignore[reportIncompatibleVariableOverride]
end_date: date | None = None # pyright: ignore[reportIncompatibleVariableOverride]
end_time: time | None = None # pyright: ignore[reportIncompatibleVariableOverride]
color: Color | None = None # pyright: ignore[reportIncompatibleVariableOverride] color: Color | None = None # pyright: ignore[reportIncompatibleVariableOverride]
async def update_event(self, event: Event) -> Event: async def update_event(self, event: Event) -> Event:
@ -152,12 +142,8 @@ class PartialEventUpdateData(_BaseEventData):
update_dct["description"] = self.description update_dct["description"] = self.description
if self.category_ids is not None: if self.category_ids is not None:
update_dct["categories"] = await from_id_list(self.category_ids, Category) update_dct["categories"] = await from_id_list(self.category_ids, Category)
if self.start_date is not None:
update_dct["start_date"] = self.start_date
if self.start_time is not None: if self.start_time is not None:
update_dct["start_time"] = self.start_time update_dct["start_time"] = self.start_time
if self.end_date is not None:
update_dct["end_date"] = self.end_date
if self.end_time is not None: if self.end_time is not None:
update_dct["end_time"] = self.end_time update_dct["end_time"] = self.end_time
if self.color is not None: if self.color is not None:
@ -170,17 +156,17 @@ class PartialEventUpdateData(_BaseEventData):
async def get_user_events( async def get_user_events(
user_id: PydanticObjectId, user_id: PydanticObjectId,
user: CurrentUserDep, user: CurrentUserDep,
start_date_from: Annotated[date | None, Query()] = None, start_from: Annotated[datetime | None, Query()] = None,
start_date_to: Annotated[date | None, Query()] = None, start_to: Annotated[datetime | None, Query()] = None,
end_date_from: Annotated[date | None, Query()] = None, end_from: Annotated[datetime | None, Query()] = None,
end_date_to: Annotated[date | None, Query()] = None, end_to: Annotated[datetime | None, Query()] = None,
) -> list[EventData]: ) -> list[EventData]:
"""Get all events that belong to given user. """Get all events that belong to given user.
Optionally, it's possible to use query params to filter the events Optionally, it's possible to use query params to filter the events
based on the date of the event. The dates are specified in ISO 8601 format. based on the date-time of the event. The dates are specified in ISO 8601 format.
E.g.: /users/{user_id}/events?start_date_from=2025-01-01&start_date_to=2025-01-10 E.g.: /users/{user_id}/events?start_from=2024-12-29T13:48:53.228234Z
""" """
if user_id != user.id: if user_id != user.id:
raise HTTPException( raise HTTPException(
@ -191,15 +177,15 @@ async def get_user_events(
# Initial query (all events for the user) # Initial query (all events for the user)
query = Event.find(expr(Event.user).id == user.id) query = Event.find(expr(Event.user).id == user.id)
# Filter by date # Filter by date-time
if start_date_from is not None: if start_from is not None:
query = query.find(expr(Event.start_date) >= start_date_from) query = query.find(expr(Event.start_time) >= start_from)
if start_date_to is not None: if start_to is not None:
query = query.find(expr(Event.start_date) <= start_date_to) query = query.find(expr(Event.start_time) <= start_to)
if end_date_from is not None: if end_from is not None:
query = query.find(expr(Event.end_date) >= end_date_from) query = query.find(expr(Event.end_time) >= end_from)
if end_date_to is not None: if end_to is not None:
query = query.find(expr(Event.end_date) <= end_date_to) query = query.find(expr(Event.end_time) <= end_to)
query = query.find(fetch_links=True) query = query.find(fetch_links=True)
@ -211,10 +197,10 @@ async def get_user_events(
async def get_user_invited_events( async def get_user_invited_events(
user_id: PydanticObjectId, user_id: PydanticObjectId,
user: CurrentUserDep, user: CurrentUserDep,
start_date_from: Annotated[date | None, Query()] = None, start_from: Annotated[datetime | None, Query()] = None,
start_date_to: Annotated[date | None, Query()] = None, start_to: Annotated[datetime | None, Query()] = None,
end_date_from: Annotated[date | None, Query()] = None, end_from: Annotated[datetime | None, Query()] = None,
end_date_to: Annotated[date | None, Query()] = None, end_to: Annotated[datetime | None, Query()] = None,
) -> list[EventData]: ) -> list[EventData]:
"""Get all events that given user was invited to. """Get all events that given user was invited to.
@ -222,9 +208,9 @@ async def get_user_invited_events(
Events pending acceptance are not included. Events pending acceptance are not included.
Optionally, it's possible to use query params to filter the events Optionally, it's possible to use query params to filter the events
based on the date of the event. The dates are specified in ISO 8601 format. based on the date-time of the event. The dates are specified in ISO 8601 format.
E.g.: /users/{user_id}/events/invited?start_date_from=2025-01-01&start_date_to=2025-01-10 E.g.: /users/{user_id}/events/invited?start_from=2024-12-29T13:48:53.228234Z
""" """
if user_id != user.id: if user_id != user.id:
raise HTTPException( raise HTTPException(
@ -235,15 +221,15 @@ async def get_user_invited_events(
# Initial query (all events the user is attending) # Initial query (all events the user is attending)
query = Event.find(expr(Event.attendees).id == user_id, fetch_links=True) query = Event.find(expr(Event.attendees).id == user_id, fetch_links=True)
# Filter by date # Filter by date-time
if start_date_from is not None: if start_from is not None:
query = query.find(expr(Event.start_date) >= start_date_from) query = query.find(expr(Event.start_time) >= start_from)
if start_date_to is not None: if start_to is not None:
query = query.find(expr(Event.start_date) <= start_date_to) query = query.find(expr(Event.start_time) <= start_to)
if end_date_from is not None: if end_from is not None:
query = query.find(expr(Event.end_date) >= end_date_from) query = query.find(expr(Event.end_time) >= end_from)
if end_date_to is not None: if end_to is not None:
query = query.find(expr(Event.end_date) <= end_date_to) query = query.find(expr(Event.end_time) <= end_to)
events = await query.to_list() events = await query.to_list()
return [EventData.from_event(event) for event in events] return [EventData.from_event(event) for event in events]

View file

@ -3,6 +3,7 @@ from typing import Annotated, ClassVar, final
from beanie import Document, Indexed from beanie import Document, Indexed
from pydantic import Field from pydantic import Field
from pymongo import ASCENDING
from src.db.models.category import Category from src.db.models.category import Category
from src.db.models.user import User from src.db.models.user import User
@ -17,10 +18,8 @@ class Event(Document):
title: str title: str
description: str description: str
categories: Annotated[list[BeanieLink[Category]], Indexed()] categories: Annotated[list[BeanieLink[Category]], Indexed()]
start_date: Annotated[date, Indexed()] start_time: Annotated[datetime, Indexed()]
start_time: time end_time: Annotated[datetime, Indexed()]
end_date: Annotated[date, Indexed()]
end_time: time
color: str # Stored as a hex string (# + 6 characters) color: str # Stored as a hex string (# + 6 characters)
attendees: Annotated[list[BeanieLink[User]], Indexed()] attendees: Annotated[list[BeanieLink[User]], Indexed()]
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@ -29,3 +28,11 @@ class Event(Document):
class Settings: class Settings:
name: ClassVar = "events" name: ClassVar = "events"
bson_encoders: ClassVar = {datetime: str, time: str, date: str} bson_encoders: ClassVar = {datetime: str, time: str, date: str}
indexes: ClassVar = [
# On top of just having start & end time standalone indexes
# also make a compound index for fast searching by both at once
[
("start_time", ASCENDING),
("end_time", ASCENDING),
],
]