app.services

Service layer package for the room-booking system.

This package encapsulates the core business logic and authentication integrations of the application, separating them from the routing and data access layers. It contains modules for user management, authentication strategies, and other business workflows.

 1"""
 2Service layer package for the room-booking system.
 3
 4This package encapsulates the core business logic and authentication integrations
 5of the application, separating them from the routing and data access layers.
 6It contains modules for user management, authentication strategies, and other
 7business workflows.
 8"""
 9
10from .booking_service import (
11    BookingConflictError,
12    BookingNotFoundError,
13    BookingServiceError,
14    BookingStateError,
15    approve_booking,
16    cancel_booking,
17    deny_booking,
18    get_all_bookings,
19    get_pending_bookings,
20    get_user_bookings,
21    process_booking_action,
22    submit_booking,
23)
24from .notification_service import (
25    NotificationNotFoundError,
26    NotificationServiceError,
27    get_notification_for_user,
28    get_notifications,
29    get_unread_count,
30    mark_read,
31    send_notification,
32)
33
34from .room_service import (
35    RoomNotFoundError,
36    RoomServiceError,
37)
38
39__all__ = [
40    "BookingServiceError",
41    "BookingNotFoundError",
42    "BookingConflictError",
43    "BookingStateError",
44    "submit_booking",
45    "approve_booking",
46    "deny_booking",
47    "cancel_booking",
48    "get_user_bookings",
49    "get_all_bookings",
50    "get_pending_bookings",
51    "process_booking_action",
52    "NotificationServiceError",
53    "NotificationNotFoundError",
54    "RoomServiceError",
55    "RoomNotFoundError",
56    "send_notification",
57    "get_notifications",
58    "get_notification_for_user",
59    "mark_read",
60    "get_unread_count",
61]
class BookingServiceError(builtins.ValueError):
27class BookingServiceError(ValueError):
28    """Base booking workflow error."""

Base booking workflow error.

class BookingNotFoundError(app.services.BookingServiceError):
31class BookingNotFoundError(BookingServiceError):
32    """Raised when a booking or timeslot cannot be found."""

Raised when a booking or timeslot cannot be found.

class BookingConflictError(app.services.BookingServiceError):
35class BookingConflictError(BookingServiceError):
36    """Raised when a slot cannot be booked because it is unavailable."""

Raised when a slot cannot be booked because it is unavailable.

class BookingStateError(app.services.BookingServiceError):
39class BookingStateError(BookingServiceError):
40    """Raised when a booking lifecycle transition is invalid."""

Raised when a booking lifecycle transition is invalid.

def submit_booking( user: app.models.User, room_id: int, slot_ids: list[int], recurrence_freq: str | app.models.RecurrenceFrequency, recurrence_end_date, session: sqlmodel.orm.session.Session) -> app.models.Booking:
106def submit_booking(
107    user: User,
108    room_id: int,
109    slot_ids: list[int],
110    recurrence_freq: str | RecurrenceFrequency,
111    recurrence_end_date,
112    session: Session,
113) -> Booking:
114    recurrence_frequency = RecurrenceFrequency(recurrence_freq)
115    anchor_slots = _get_slots_by_id(session, slot_ids)
116
117    for slot in anchor_slots:
118        if slot.room_id != room_id:
119            raise BookingConflictError(
120                f"TimeSlot {slot.id} does not belong to room {room_id}."
121            )
122
123    if recurrence_frequency == RecurrenceFrequency.WEEKLY:
124        if recurrence_end_date is None:
125            raise BookingServiceError(
126                "recurrence_end_date is required when recurrence_freq is weekly."
127            )
128        earliest_date = min(slot.slot_date for slot in anchor_slots)
129        if recurrence_end_date < earliest_date:
130            raise BookingServiceError(
131                "recurrence_end_date must be on or after the anchor slot date."
132            )
133    elif recurrence_end_date is not None:
134        raise BookingStateError(
135            "recurrence_end_date can only be provided for weekly recurrence."
136        )
137
138    target_slots = _resolve_recurrence(
139        session, anchor_slots, recurrence_frequency, recurrence_end_date
140    )
141
142    unavailable_slots = [
143        slot.id for slot in target_slots if slot.status != TimeslotStatus.AVAILABLE
144    ]
145    if unavailable_slots:
146        slot_list = ", ".join(str(slot_id) for slot_id in unavailable_slots)
147        raise BookingConflictError(f"TimeSlot(s) unavailable: {slot_list}")
148
149    for slot in target_slots:
150        slot.hold()
151
152    booking = Booking(
153        userID=user.id,
154        submittedByRole=user.role,
155        roomID=room_id,
156        status=BookingStatus.PENDING,
157        recurrenceFrequency=recurrence_frequency,
158        recurrenceEndDate=recurrence_end_date,
159        timeSlots=target_slots,
160    )
161    session.add(booking)
162    session.commit()
163    session.refresh(booking)
164    return _get_booking(session, booking.id)
def approve_booking( booking_id: int, session: sqlmodel.orm.session.Session) -> app.models.Booking:
167def approve_booking(booking_id: int, session: Session) -> Booking:
168    booking = _get_booking(session, booking_id)
169    if booking.status != BookingStatus.PENDING:
170        raise BookingStateError("Only pending bookings can be approved.")
171
172    invalid_slots = [
173        slot.id for slot in booking.timeSlots if slot.status != TimeslotStatus.HELD
174    ]
175    if invalid_slots:
176        slot_list = ", ".join(str(slot_id) for slot_id in invalid_slots)
177        raise BookingStateError(f"Booking slots are not held: {slot_list}")
178
179    for slot in booking.timeSlots:
180        slot.book()
181
182    booking.status = BookingStatus.APPROVED
183    session.add(booking)
184    session.commit()
185    session.refresh(booking)
186    return _get_booking(session, booking.id)
def deny_booking( booking_id: int, session: sqlmodel.orm.session.Session) -> app.models.Booking:
189def deny_booking(booking_id: int, session: Session) -> Booking:
190    booking = _get_booking(session, booking_id)
191    if booking.status != BookingStatus.PENDING:
192        raise BookingStateError("Only pending bookings can be denied.")
193
194    invalid_slots = [
195        slot.id for slot in booking.timeSlots if slot.status != TimeslotStatus.HELD
196    ]
197    if invalid_slots:
198        slot_list = ", ".join(str(slot_id) for slot_id in invalid_slots)
199        raise BookingStateError(f"Booking slots cannot be denied: {slot_list}")
200
201    for slot in booking.timeSlots:
202        slot.release()
203
204    booking.status = BookingStatus.DENIED
205    session.add(booking)
206    session.commit()
207    session.refresh(booking)
208    return _get_booking(session, booking.id)
def cancel_booking( booking_id: int, session: sqlmodel.orm.session.Session) -> app.models.Booking:
211def cancel_booking(booking_id: int, session: Session) -> Booking:
212    booking = _get_booking(session, booking_id)
213    if booking.status != BookingStatus.APPROVED:
214        raise BookingStateError("Only approved bookings can be cancelled.")
215
216    invalid_slots = [
217        slot.id for slot in booking.timeSlots if slot.status != TimeslotStatus.BOOKED
218    ]
219    if invalid_slots:
220        slot_list = ", ".join(str(slot_id) for slot_id in invalid_slots)
221        raise BookingStateError(f"Booking slots cannot be cancelled: {slot_list}")
222
223    for slot in booking.timeSlots:
224        slot.release()
225
226    booking.status = BookingStatus.CANCELLED
227    session.add(booking)
228    session.commit()
229    session.refresh(booking)
230    return _get_booking(session, booking.id)
def get_user_bookings( user_id, session: sqlmodel.orm.session.Session) -> list[app.models.Booking]:
242def get_user_bookings(user_id, session: Session) -> list[Booking]:
243    statement = (
244        select(Booking)
245        .where(Booking.userID == user_id)
246        .options(selectinload(Booking.timeSlots))
247        .order_by(Booking.createdAt.desc(), Booking.id.desc())
248    )
249    return list(session.exec(statement))
def get_all_bookings( session: sqlmodel.orm.session.Session, status: str | app.models.BookingStatus | None = None) -> list[app.models.Booking]:
252def get_all_bookings(
253    session: Session, status: str | BookingStatus | None = None
254) -> list[Booking]:
255    statement = select(Booking).options(selectinload(Booking.timeSlots))
256    if status is not None:
257        statement = statement.where(Booking.status == BookingStatus(status))
258
259    statement = statement.order_by(Booking.createdAt.desc(), Booking.id.desc())
260    return list(session.exec(statement))
def get_pending_bookings( session: sqlmodel.orm.session.Session) -> list[app.models.Booking]:
233def get_pending_bookings(session: Session) -> list[Booking]:
234    statement = (
235        select(Booking)
236        .where(Booking.status == BookingStatus.PENDING)
237        .options(selectinload(Booking.timeSlots))
238    )
239    return list(session.exec(statement))
def process_booking_action( booking_id: int, action: str, session: sqlmodel.orm.session.Session) -> app.models.Booking:
269def process_booking_action(booking_id: int, action: str, session: Session) -> Booking:
270    """Execute a booking lifecycle action and send the corresponding notification."""
271    action_fn, notification_type = _ACTION_MAP[action]
272    booking = action_fn(booking_id, session)
273    send_notification(booking.userID, booking.id, notification_type, session)
274    return booking

Execute a booking lifecycle action and send the corresponding notification.

class NotificationServiceError(builtins.ValueError):
15class NotificationServiceError(ValueError):
16    """Base notification service error."""

Base notification service error.

class NotificationNotFoundError(app.services.NotificationServiceError):
19class NotificationNotFoundError(NotificationServiceError):
20    """Raised when a notification cannot be found."""

Raised when a notification cannot be found.

class RoomServiceError(builtins.ValueError):
19class RoomServiceError(ValueError):
20    """Base room service error."""

Base room service error.

class RoomNotFoundError(app.services.RoomServiceError):
23class RoomNotFoundError(RoomServiceError):
24    """Raised when a room cannot be found."""

Raised when a room cannot be found.

def send_notification( user_id, booking_id: int, notification_type: str | app.models.NotificationType, session: sqlmodel.orm.session.Session) -> app.models.Notification:
27def send_notification(
28    user_id,
29    booking_id: int,
30    notification_type: str | NotificationType,
31    session: Session,
32) -> Notification:
33    normalized_type = NotificationType(notification_type)
34    notification = Notification(
35        userID=user_id,
36        bookingID=booking_id,
37        message=_build_message(normalized_type, booking_id),
38        type=normalized_type,
39        isRead=False,
40    )
41    session.add(notification)
42    session.commit()
43    session.refresh(notification)
44    return notification
def get_notifications( user_id, session: sqlmodel.orm.session.Session) -> list[app.models.Notification]:
47def get_notifications(user_id, session: Session) -> list[Notification]:
48    statement = (
49        select(Notification)
50        .where(Notification.userID == user_id)
51        .order_by(Notification.createdAt.desc(), Notification.id.desc())
52    )
53    return list(session.exec(statement))
def get_notification_for_user( notification_id: int, user_id, session: sqlmodel.orm.session.Session) -> app.models.Notification:
55def get_notification_for_user(notification_id: int, user_id, session: Session) -> Notification:
56    notification = session.get(Notification, notification_id)
57    if notification is None or notification.userID != user_id:
58        raise NotificationNotFoundError(
59            f"Notification {notification_id} was not found."
60        )
61    return notification
def mark_read( notification_id: int, session: sqlmodel.orm.session.Session) -> app.models.Notification:
64def mark_read(notification_id: int, session: Session) -> Notification:
65    notification = session.get(Notification, notification_id)
66    if notification is None:
67        raise NotificationNotFoundError(
68            f"Notification {notification_id} was not found."
69        )
70
71    notification.isRead = True
72    session.add(notification)
73    session.commit()
74    session.refresh(notification)
75    return notification
def get_unread_count(user_id, session: sqlmodel.orm.session.Session) -> int:
78def get_unread_count(user_id, session: Session) -> int:
79    statement = select(Notification).where(
80        Notification.userID == user_id, Notification.isRead.is_(False)
81    )
82    return len(list(session.exec(statement)))