Compare commits
4 commits
516bac573a
...
d66ce2f450
Author | SHA1 | Date | |
---|---|---|---|
Peter Vacho | d66ce2f450 | ||
Peter Vacho | 0cec3463fb | ||
Peter Vacho | 1a24a779d9 | ||
Peter Vacho | 82e82b9317 |
|
@ -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)
|
||||||
|
# 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)
|
query = Event.find(expr(Event.attendees).id == user_id)
|
||||||
|
queries.append(query)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
# Filter by date-time
|
||||||
if start_from is not None:
|
if start_from is not None:
|
||||||
query = query.find(expr(Event.start_time) >= start_from)
|
new_query = new_query.find(expr(Event.start_time) >= start_from)
|
||||||
if start_to is not None:
|
if start_to is not None:
|
||||||
query = query.find(expr(Event.start_time) <= start_to)
|
new_query = new_query.find(expr(Event.start_time) <= start_to)
|
||||||
if end_from is not None:
|
if end_from is not None:
|
||||||
query = query.find(expr(Event.end_time) >= end_from)
|
new_query = new_query.find(expr(Event.end_time) >= end_from)
|
||||||
if end_to is not None:
|
if end_to is not None:
|
||||||
query = query.find(expr(Event.end_time) <= end_to)
|
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
|
# We can't set this earlier as the other find calls would reset it
|
||||||
query = query.find(fetch_links=True)
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
if not allow_links:
|
||||||
raise UnfetchedLinkError(item)
|
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)
|
||||||
|
|
Loading…
Reference in a new issue