Compare commits

...

4 commits

3 changed files with 147 additions and 21 deletions

View file

@ -1,7 +1,9 @@
from datetime import datetime from datetime import datetime
from typing import Annotated, cast, final from typing import Annotated, Literal, cast, final
from beanie import Link, PydanticObjectId from beanie import Link, PydanticObjectId
from beanie.odm.operators.find.comparison import In
from beanie.odm.queries.find import FindMany
from fastapi import APIRouter, Body, HTTPException, Query, status from fastapi import APIRouter, Body, HTTPException, Query, status
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel, StringConstraints, field_serializer, field_validator from pydantic import BaseModel, StringConstraints, field_serializer, field_validator
@ -232,16 +234,21 @@ async def get_user_invited_events(
start_to: Annotated[datetime | None, Query()] = None, start_to: Annotated[datetime | None, Query()] = None,
end_from: Annotated[datetime | None, Query()] = None, end_from: Annotated[datetime | None, Query()] = None,
end_to: Annotated[datetime | None, Query()] = None, end_to: Annotated[datetime | None, Query()] = None,
invite_status: Annotated[Literal["pending", "accepted"] | 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.
These are the events that the user has already accepted the invite for. These are the events that the user has already accepted the invite for
Events pending acceptance are not included. and the events that the user has a pending invite for.
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-time 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_from=2024-12-29T13:48:53.228234Z E.g.: /users/{user_id}/events/invited?start_from=2024-12-29T13:48:53.228234Z
It's also possible to filter the events based on the invite status.
E.g.: /users/{user_id}/events/invited?invite_status=accepted
""" """
if user_id != user.id: if user_id != user.id:
raise HTTPException( raise HTTPException(
@ -249,23 +256,45 @@ async def get_user_invited_events(
detail="You can only access your own invite categories", detail="You can only access your own invite categories",
) )
# Initial query (all events the user is attending) # Initial queries (all events for the user)
query = Event.find(expr(Event.attendees).id == user_id) # NOTE: We can't use a single query, as we would need a union query for that
# which as far as I could tell is not supported by Beanie (annoying).
queries: list[FindMany[Event]] = []
if invite_status is None or invite_status == "accepted":
query = Event.find(expr(Event.attendees).id == user_id)
queries.append(query)
# Filter by date-time if invite_status is None or invite_status == "pending":
if start_from is not None: invitations = await Invitation.find(
query = query.find(expr(Event.start_time) >= start_from) expr(Invitation.invitee).id == user_id,
if start_to is not None: expr(Invitation.status) == "pending",
query = query.find(expr(Event.start_time) <= start_to) ).to_list()
if end_from is not None: event_ids = get_id_list([invite.event for invite in invitations], allow_links=True)
query = query.find(expr(Event.end_time) >= end_from) event_ids = set(event_ids) # Remove duplicates
if end_to is not None:
query = query.find(expr(Event.end_time) <= end_to)
# We can't set this earlier as the other find calls would reset it query = Event.find(In(expr(Event.id), event_ids))
query = query.find(fetch_links=True) queries.append(query)
events: list[Event] = []
for query in queries:
new_query = query
# Filter by date-time
if start_from is not None:
new_query = new_query.find(expr(Event.start_time) >= start_from)
if start_to is not None:
new_query = new_query.find(expr(Event.start_time) <= start_to)
if end_from is not None:
new_query = new_query.find(expr(Event.end_time) >= end_from)
if end_to is not None:
new_query = new_query.find(expr(Event.end_time) <= end_to)
# We can't set this earlier as the other find calls would reset it
new_query = new_query.find(fetch_links=True)
cur_events = await new_query.to_list()
events.extend(cur_events)
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

@ -2,9 +2,10 @@ from datetime import UTC, datetime
from typing import Literal, cast, final from typing import Literal, cast, final
from beanie import Link, PydanticObjectId from beanie import Link, PydanticObjectId
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, Response, status
from pydantic import BaseModel from pydantic import BaseModel
from src.db.models.invitation import Invitation
from src.db.models.notification import Notification from src.db.models.notification import Notification
from src.db.models.user import User from src.db.models.user import User
from src.utils.db import MissingIdError, UnfetchedLinkError, expr from src.utils.db import MissingIdError, UnfetchedLinkError, expr
@ -103,6 +104,9 @@ async def read_notification(notification_id: PydanticObjectId, user: CurrentUser
if cast(User, notification.user).id != user.id: if cast(User, notification.user).id != user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN, "You can only access your own notifications.") raise HTTPException(status.HTTP_403_FORBIDDEN, "You can only access your own notifications.")
if notification.read:
raise HTTPException(status.HTTP_409_CONFLICT, "Notification is already marked as read.")
notification.read = True notification.read = True
notification.read_at = datetime.now(UTC) notification.read_at = datetime.now(UTC)
notification = await notification.replace() notification = await notification.replace()
@ -110,6 +114,92 @@ async def read_notification(notification_id: PydanticObjectId, user: CurrentUser
return NotificationData.from_notification(notification) return NotificationData.from_notification(notification)
@notifications_router.post("/{notification_id}/unread")
async def unread_notification(notification_id: PydanticObjectId, user: CurrentUserDep) -> NotificationData:
"""Mark a notification as unread."""
notification = await Notification.get(notification_id, fetch_links=True)
if notification is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Notification not found.")
if user.id is None:
raise MissingIdError(user)
if cast(User, notification.user).id != user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN, "You can only access your own notifications.")
if not notification.read:
raise HTTPException(status.HTTP_409_CONFLICT, "Notification is already marked as unread.")
notification.read = False
notification.read_at = None
notification = await notification.replace()
return NotificationData.from_notification(notification)
@notifications_router.delete("/{notification_id}")
async def delete_notification(notification_id: PydanticObjectId, user: CurrentUserDep) -> Response:
"""Delete a notification."""
notification = await Notification.get(notification_id, fetch_links=True)
if notification is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Notification not found.")
if user.id is None:
raise MissingIdError(user)
if cast(User, notification.user).id != user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN, "You can only access your own notifications.")
# Clean up associated invitations, if any
if notification.event_type == "invitation":
# Decline the incoming invite
if notification.message == "new-invitation":
invitation = await Invitation.get(notification.data, fetch_links=True)
if invitation is not None and invitation.status == "pending":
# Just a sanity check, the user should always be invitee here
if cast(User, invitation.invitee).id != user.id:
raise RuntimeError("User is not the invitee of the pending invitation.")
invitation.status = "declined"
invitation.responded_at = datetime.now(tz=UTC)
invitation = await invitation.replace()
# Send back a notification to the invitor about the decline
new_notification = Notification(
user=invitation.invitor,
event_type="invitation",
message="invitation-declined",
data=str(cast(PydanticObjectId, invitation.id)),
read=False,
)
_ = await new_notification.create()
# Delete the accepted/declined invitation
elif notification.message in {"invitation-declined", "invitation-accepted"}:
invitation = await Invitation.get(notification.data, fetch_links=True)
if invitation is not None:
# Just a sanity check, the user should always be owner here
if cast(User, invitation.invitor).id != user.id:
raise RuntimeError("User is not the owner of the accepted/declined invitation.")
# NOTE: It might be worth it to check whether the user has deleted their new-invitation notification
# first, otherwise, the frontend will not be able to show that old invitation in the notification
# however, the frontend can handle this case for now by just showing that the invitation was deleted.
# But it could be a good idea to implement this in the future. Note that if this is added, we'll also
# need to add a reverse-check, for when the user deletes the new-invitation notification, we should
# also check if the owner has deleted the accepted/declined invitation and delete it as well.
_ = await invitation.delete()
# This should never happen, just a sanity check, in case we add some more messages
else:
raise RuntimeError(f"Unknown invitation message: {notification.message}")
_ = await notification.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT)
router = APIRouter() router = APIRouter()
router.include_router(base_router) router.include_router(base_router)
router.include_router(notifications_router) router.include_router(notifications_router)

View file

@ -41,12 +41,19 @@ class IdNotFoundError[T: Document](ValueError):
super().__init__(f"Item with id {item_id} of type {typ.__name__} not found") super().__init__(f"Item with id {item_id} of type {typ.__name__} not found")
def get_id_list[T: Document](items: Sequence[T | Link[T]]) -> list[PydanticObjectId]: def get_id_list[T: Document](items: Sequence[T | Link[T]], *, allow_links: bool = False) -> list[PydanticObjectId]:
"""Extracts the ids from a list of db items.""" """Extracts the ids from a list of db items.
:param items: List of db items.
:param allow_links: If True, allows returning reference ids of unfetched Link objects.
"""
id_list: list[PydanticObjectId] = [] id_list: list[PydanticObjectId] = []
for item in items: for item in items:
if isinstance(item, Link): if isinstance(item, Link):
raise UnfetchedLinkError(item) if not allow_links:
raise UnfetchedLinkError(item)
id_list.append(item.ref.id)
continue
if item.id is None: if item.id is None:
raise MissingIdError(item) raise MissingIdError(item)