Compare commits

...

4 commits

3 changed files with 147 additions and 21 deletions

View file

@ -1,7 +1,9 @@
from datetime import datetime
from typing import Annotated, cast, final
from typing import Annotated, Literal, cast, final
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.responses import Response
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,
end_from: Annotated[datetime | None, Query()] = None,
end_to: Annotated[datetime | None, Query()] = None,
invite_status: Annotated[Literal["pending", "accepted"] | None, Query()] = None,
) -> list[EventData]:
"""Get all events that given user was invited to.
These are the events that the user has already accepted the invite for.
Events pending acceptance are not included.
These are the events that the user has already accepted the invite for
and the events that the user has a pending invite for.
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.
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:
raise HTTPException(
@ -249,23 +256,45 @@ async def get_user_invited_events(
detail="You can only access your own invite categories",
)
# Initial query (all events the user is attending)
query = Event.find(expr(Event.attendees).id == user_id)
# Initial queries (all events for the user)
# 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 start_from is not None:
query = query.find(expr(Event.start_time) >= start_from)
if start_to is not None:
query = query.find(expr(Event.start_time) <= start_to)
if end_from is not None:
query = query.find(expr(Event.end_time) >= end_from)
if end_to is not None:
query = query.find(expr(Event.end_time) <= end_to)
if invite_status is None or invite_status == "pending":
invitations = await Invitation.find(
expr(Invitation.invitee).id == user_id,
expr(Invitation.status) == "pending",
).to_list()
event_ids = get_id_list([invite.event for invite in invitations], allow_links=True)
event_ids = set(event_ids) # Remove duplicates
# We can't set this earlier as the other find calls would reset it
query = query.find(fetch_links=True)
query = Event.find(In(expr(Event.id), event_ids))
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]

View file

@ -2,9 +2,10 @@ from datetime import UTC, datetime
from typing import Literal, cast, final
from beanie import Link, PydanticObjectId
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, HTTPException, Response, status
from pydantic import BaseModel
from src.db.models.invitation import Invitation
from src.db.models.notification import Notification
from src.db.models.user import User
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:
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_at = datetime.now(UTC)
notification = await notification.replace()
@ -110,6 +114,92 @@ async def read_notification(notification_id: PydanticObjectId, user: CurrentUser
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.include_router(base_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")
def get_id_list[T: Document](items: Sequence[T | Link[T]]) -> list[PydanticObjectId]:
"""Extracts the ids from a list of db items."""
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.
:param items: List of db items.
:param allow_links: If True, allows returning reference ids of unfetched Link objects.
"""
id_list: list[PydanticObjectId] = []
for item in items:
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:
raise MissingIdError(item)