Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
249894e
chore: work on room availability api, start frontend
ajaygandecha Sep 10, 2025
67d97c8
chore: work on skeleton ui for new room reservations
ajaygandecha Sep 10, 2025
cf9be21
chore: upgrade to angular v19
ajaygandecha Sep 10, 2025
3387999
chore: upgrade to angular material v19
ajaygandecha Sep 10, 2025
f260406
chore: upgrade to angular v20
ajaygandecha Sep 10, 2025
2d41239
chore: upgrade to angular material v20
ajaygandecha Sep 10, 2025
73f946c
fix: remove incorrect changes from the angular upgrade
ajaygandecha Sep 10, 2025
b1fc274
chore: add core frontend selection logic in grid
ajaygandecha Sep 10, 2025
0ee671b
chore: add user selection and reservation draft sections
ajaygandecha Sep 10, 2025
03e3d5f
chore: handle draft room reservations
ajaygandecha Sep 10, 2025
1c0359a
chore: handle group room reservations
ajaygandecha Sep 10, 2025
718e76f
chore: ensure instructors have no limits for reservations
ajaygandecha Sep 10, 2025
eb299e2
chore: add date picker to ui
ajaygandecha Sep 10, 2025
506147f
chore: add base responsiveness
ajaygandecha Sep 10, 2025
6d91a0e
chore: small ui optimizations
ajaygandecha Sep 10, 2025
6f051ba
fix: fix card width issue in some pages
ajaygandecha Sep 10, 2025
450e5b4
chore: add basic operating hours feature
ajaygandecha Sep 11, 2025
fe3caa8
chore: work on basic op hrs feature
ajaygandecha Sep 11, 2025
753aabf
chore: add tooltip on disabled reserve button
ajaygandecha Sep 13, 2025
94261e1
fix: ensure that office hours policy also blocks reservation
ajaygandecha Sep 13, 2025
4789008
fix: add draft validation against existing office hours
ajaygandecha Sep 13, 2025
460e3ee
Merge branch 'main' into feat/room-reservations
ajaygandecha Sep 18, 2025
252d7c4
build: update package lock
ajaygandecha Sep 19, 2025
53403cf
chore: add user-dependent coworking policy
ajaygandecha Sep 19, 2025
fd4f27a
chore: change office hours conflict status
ajaygandecha Sep 19, 2025
f06b0b5
chore: allow overlapping room reservations for instructors
ajaygandecha Sep 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend/api/coworking/reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Sequence
from datetime import datetime

from ...models.coworking.reservation import GetRoomAvailabilityResponse
from backend.models.room import Room
from ..authentication import registered_user
from ...services.coworking.reservation import ReservationException, ReservationService
Expand All @@ -15,7 +16,7 @@
ReservationRequest,
ReservationPartial,
ReservationState,
ReservationMapDetails
ReservationMapDetails,
)

__authors__ = ["Kris Jordan, Yuvraj Jain"]
Expand Down Expand Up @@ -91,3 +92,13 @@ def get_total_hours_study_room_reservations(
) -> str:
"""Allows a user to know how many hours they have reserved in all study rooms (Excludes CSXL)."""
return reservation_svc.get_total_time_user_reservations(subject)


@api.get("/rooms/availability", tags=["Coworking"])
def get_room_availability(
date: datetime | None = None,
subject: User = Depends(registered_user),
reservation_svc: ReservationService = Depends(),
) -> GetRoomAvailabilityResponse:
"""Determines the room availability at a given time for a user."""
return reservation_svc.get_room_availability(subject, date=date)
34 changes: 34 additions & 0 deletions backend/models/coworking/reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,37 @@ class ReservationDetails(Reservation):
extendable: bool = False
extendable_at: datetime | None
extendable_until: datetime | None


# New models for room reservations
class RoomAvailabilityState(str, Enum):
AVAILABLE = "AVAILABLE"
RESERVED = "RESERVED"
YOUR_RESERVATION = "YOUR_RESERVATION"
UNAVAILABLE = "UNAVAILABLE"


class GetRoomAvailabilityResponse_Slot(BaseModel):
start_time: datetime
end_time: datetime


class GetRoomAvailabilityResponse_RoomAvailability(BaseModel):
state: RoomAvailabilityState
description: str | None = None


class GetRoomAvailabilityResponse_Room(BaseModel):
room: str
capacity: int
minimum_reservers: int
availability: dict[
str, GetRoomAvailabilityResponse_RoomAvailability
] # [timeslot : availability]


class GetRoomAvailabilityResponse(BaseModel):
is_instructor: bool
slot_labels: list[str]
slots: dict[str, GetRoomAvailabilityResponse_Slot] # [timeslot : availability]
rooms: list[GetRoomAvailabilityResponse_Room]
5 changes: 2 additions & 3 deletions backend/services/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,9 @@ def get_welcome_overview(self, subject: User | None) -> WelcomeOverview:

# Load operating hours
now = datetime.now()
coworking_policy = self._policies_svc.policy_for_user(subject)
operating_hours = self._operating_hours_svc.schedule(
TimeRange(
start=now, end=now + self._policies_svc.reservation_window(subject)
)
TimeRange(start=now, end=now + coworking_policy.reservation_window)
)

# Load future reservations for a given user.
Expand Down
1 change: 1 addition & 0 deletions backend/services/coworking/operating_hours.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def create(self, subject: User, time_range: TimeRange) -> OperatingHours:
self._permission_svc.enforce(
subject, "coworking.operating_hours.create", "coworking/operating_hours"
)
self._session.flush()

conflicts = self.schedule(time_range)
if len(conflicts) > 0:
Expand Down
184 changes: 137 additions & 47 deletions backend/services/coworking/policy.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
"""Service that manages policies around the reservation system."""

from enum import Enum
from fastapi import Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from datetime import timedelta, datetime, time

from backend.entities.academics.section_member_entity import SectionMemberEntity
from backend.models.roster_role import RosterRole
from ...database import db_session
from ...models import User
from abc import ABC, abstractmethod
from pydantic import BaseModel

__authors__ = ["Kris Jordan, Yuvraj Jain"]
__copyright__ = "Copyright 2023-24"
Expand Down Expand Up @@ -69,66 +76,149 @@
}


class PolicyService:
"""RoleService is the access layer to the role data model, its members, and permissions.
class CoworkingPolicyType(Enum):
"""
Determines the different types of coworking policies.

We are carving out a simple service for looking up policies in anticipation of having different policies
for different groups of users (e.g. majors, ambassadors, LAs, etc).
These are mapped to numbers for comparison purposes to determine which policy to use
if multiple policies match, with the greatest number taking the highest priority.
"""

def __init__(self): ...
STUDENT = 0
"""Policy for regular users"""

INSTRUCTOR = 1
"""Policy for regular instructors"""


class CoworkingPolicy(BaseModel):

def walkin_window(self, _subject: User) -> timedelta:
"""How far into the future can walkins be reserved?"""
return timedelta(minutes=10)
walkin_window: timedelta
"""How far into the future can walkins be reserved?"""

def walkin_initial_duration(self, _subject: User) -> timedelta:
"""When making a walkin, this sets how long the initial reservation is for."""
return timedelta(hours=2)
walkin_initial_duration: timedelta
"""When making a walkin, this sets how long the initial reservation is for."""

def reservation_window(self, _subject: User) -> timedelta:
"""Returns the number of days in advance the user can make reservations."""
return timedelta(weeks=1)
reservation_window: timedelta
"""Returns the number of days in advance the user can make reservations."""

def minimum_reservation_duration(self) -> timedelta:
"""The minimum amount of time a reservation can be made for."""
return timedelta(minutes=10)
minimum_reservation_duration: timedelta
"""The minimum amount of time a reservation can be made for."""

def maximum_initial_reservation_duration(self, _subject: User) -> timedelta:
"""The maximum amount of time a reservation can be made for before extending."""
return timedelta(hours=2)
maximum_initial_reservation_duration: timedelta
"""The maximum amount of time a reservation can be made for before extending."""

# Implement and involve in testing once extending a reservation functionality is added.
# def extend_window(self, _subject: User) -> timedelta:
# """When no reservation follows a given reservation, within this period preceeding the end of a reservation the user is able to extend their reservation by an hour."""
# return timedelta(minutes=15 * -1)
reservation_draft_timeout: timedelta

# def extend_duration(self, _subject: User) -> timedelta:
# return timedelta(hours=1)
reservation_checkin_timeout: timedelta

def reservation_draft_timeout(self) -> timedelta:
return timedelta(minutes=5)
room_reservation_weekly_limit: timedelta
"""The maximum amount of hours a student can reserve the study rooms outside of the csxl."""

def reservation_checkin_timeout(self) -> timedelta:
return timedelta(minutes=10)
allow_overlapping_room_reservations: bool
"""Whether or not to allow the user to reserve two rooms at the same time (useful for instructors)"""


class PolicyService:
"""RoleService is the access layer to the role data model, its members, and permissions.

We are carving out a simple service for looking up policies in anticipation of having different policies
for different groups of users (e.g. majors, ambassadors, LAs, etc).
"""

def room_reservation_weekly_limit(self) -> timedelta:
"""The maximum amount of hours a student can reserve the study rooms outside of the csxl."""
return timedelta(hours=6)
def __init__(self, session: Session = Depends(db_session)):
self._session = session
# Set policies here
self._policies: dict[CoworkingPolicyType, CoworkingPolicy] = {
CoworkingPolicyType.STUDENT: CoworkingPolicy(
walkin_window=timedelta(minutes=10),
walkin_initial_duration=timedelta(hours=2),
reservation_window=timedelta(weeks=1),
minimum_reservation_duration=timedelta(minutes=10),
maximum_initial_reservation_duration=timedelta(hours=2),
reservation_draft_timeout=timedelta(minutes=5),
reservation_checkin_timeout=timedelta(minutes=10),
room_reservation_weekly_limit=timedelta(hours=6),
allow_overlapping_room_reservations=False,
),
CoworkingPolicyType.INSTRUCTOR: CoworkingPolicy(
walkin_window=timedelta(minutes=10),
walkin_initial_duration=timedelta(hours=2),
reservation_window=timedelta(weeks=8),
minimum_reservation_duration=timedelta(minutes=10),
maximum_initial_reservation_duration=timedelta(hours=2),
reservation_draft_timeout=timedelta(minutes=5),
reservation_checkin_timeout=timedelta(minutes=10),
room_reservation_weekly_limit=timedelta(hours=168), # 24hrs * 7days
allow_overlapping_room_reservations=True,
),
}

def default_policy(self) -> CoworkingPolicy:
"""Returns the default policy.
NOTE: At some point, this likely should be phased out.
"""
return self._policies[CoworkingPolicyType.STUDENT]

def policy_for_user(self, _subject: User) -> CoworkingPolicy:
"""Determines the coworking policy to use for a given subject."""

# Determine if the user is an instructor for a course.
is_instructor_query = (
select(func.count())
.select_from(SectionMemberEntity)
.where(
SectionMemberEntity.user_id == _subject.id,
SectionMemberEntity.member_role == RosterRole.INSTRUCTOR,
)
)
is_instructor = self._session.execute(is_instructor_query).scalar() > 0
if is_instructor:
return self._policies[CoworkingPolicyType.INSTRUCTOR]

# Otherwise, the user is a regular user.
# NOTE: In the future, this can be extended easily to add extra policies
# for ambassadors, etc.
return self._policies[CoworkingPolicyType.STUDENT]

# def walkin_window(self, policy: CoworkingPolicy) -> timedelta:
# """How far into the future can walkins be reserved?"""
# return self._policy(_subject).walkin_window

# def walkin_initial_duration(self, _subject: User) -> timedelta:
# """When making a walkin, this sets how long the initial reservation is for."""
# return self._policy(_subject).walkin_initial_duration

# def reservation_window(self, _subject: User) -> timedelta:
# """Returns the number of days in advance the user can make reservations."""
# return self._policy(_subject).reservation_window

# def minimum_reservation_duration(self, _subject: User) -> timedelta:
# """The minimum amount of time a reservation can be made for."""
# return self._policy(_subject).minimum_reservation_duration

# def maximum_initial_reservation_duration(self, _subject: User) -> timedelta:
# """The maximum amount of time a reservation can be made for before extending."""
# return self._policy(_subject).maximum_initial_reservation_duration

# # Implement and involve in testing once extending a reservation functionality is added.
# # def extend_window(self, _subject: User) -> timedelta:
# # """When no reservation follows a given reservation, within this period preceeding the end of a reservation the user is able to extend their reservation by an hour."""
# # return timedelta(minutes=15 * -1)

# # def extend_duration(self, _subject: User) -> timedelta:
# # return timedelta(hours=1)

# def reservation_draft_timeout(self, _subject: User) -> timedelta:
# return self._policy(_subject).reservation_draft_timeout

# def reservation_checkin_timeout(self, _subject: User) -> timedelta:
# return self._policy(_subject).reservation_checkin_timeout

# def room_reservation_weekly_limit(self, _subject: User) -> timedelta:
# """The maximum amount of hours a student can reserve the study rooms outside of the csxl."""
# return self._policy(_subject).room_reservation_weekly_limit

def office_hours(self, date: datetime):
day = date.weekday()
if day == MONDAY:
return OH_HOURS[MONDAY]
elif day == TUESDAY:
return OH_HOURS[TUESDAY]
elif day == WEDNESDAY:
return OH_HOURS[WEDNESDAY]
elif day == THURSDAY:
return OH_HOURS[THURSDAY]
elif day == FRIDAY:
return OH_HOURS[FRIDAY]
elif day == SATURDAY:
return OH_HOURS[SATURDAY]
else:
return OH_HOURS[SUNDAY]
return OH_HOURS[day]
Loading