diff --git a/backend/gsr_booking/api_wrapper.py b/backend/gsr_booking/api_wrapper.py index 49e9b3ad..9ff53e58 100644 --- a/backend/gsr_booking/api_wrapper.py +++ b/backend/gsr_booking/api_wrapper.py @@ -1,5 +1,6 @@ import datetime from enum import Enum +from abc import ABC, abstractmethod import requests from bs4 import BeautifulSoup @@ -8,9 +9,14 @@ from django.utils import timezone from django.utils.timezone import make_aware from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout - +from django.db.models import Q, F, Sum, Prefetch +from django.db.models.functions import Coalesce from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation from gsr_booking.serializers import GSRBookingSerializer, GSRSerializer +from random import randint +from django.contrib.auth import get_user_model + +User = get_user_model() BASE_URL = "https://libcal.library.upenn.edu" @@ -31,158 +37,35 @@ class CreditType(Enum): class APIError(ValueError): pass - -class BookingWrapper: - def __init__(self): - self.WLW = WhartonLibWrapper() - self.LCW = LibCalWrapper() - - def is_wharton(self, user): - penn_labs = Group.objects.get(name="Penn Labs") - me_group = Group.objects.get(name="Me", owner=user) - membership = GroupMembership.objects.filter(group=me_group).first() - return membership.check_wharton() or user in penn_labs.members.all() - - def book_room(self, gid, rid, room_name, start, end, user, group_book=None): - gsr = get_object_or_404(GSR, gid=gid) - - # error catching on view side - if gsr.kind == GSR.KIND_WHARTON: - booking_id = self.WLW.book_room(rid, start, end, user, gsr.lid).get("booking_id") - else: - booking_id = self.LCW.book_room(rid, start, end, user).get("booking_id") - - # creates booking on database - # TODO: break start / end time into smaller chunks and pool credit for group booking - booking = GSRBooking.objects.create( - user=user, - booking_id=str(booking_id), - gsr=gsr, - room_id=rid, - room_name=room_name, - start=datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z"), - end=datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z"), - ) - - # create reservation with single-person-group containing user - # TODO: create reservation with group that frontend passes in - if not group_book: - single_person_group = get_object_or_404(Group, owner=user) - reservation = Reservation.objects.create( - start=start, end=end, creator=user, group=single_person_group - ) - booking.reservation = reservation - booking.save() - - return booking - - def get_availability(self, lid, gid, start, end, user): - # checks which GSR class to use - gsr = get_object_or_404(GSR, gid=gid) - - if gsr.kind == GSR.KIND_WHARTON: - rooms = self.WLW.get_availability(lid, start, end, user.username) - return {"name": gsr.name, "gid": gsr.gid, "rooms": rooms} - else: - rooms = self.LCW.get_availability(lid, start, end) - # cleans data to match Wharton wrapper - try: - lc_gsr = [x for x in rooms["categories"] if x["cid"] == int(gid)][0] - except IndexError: - raise APIError("Unknown GSR") - - for room in lc_gsr["rooms"]: - for availability in room["availability"]: - availability["start_time"] = availability["from"] - availability["end_time"] = availability["to"] - del availability["from"] - del availability["to"] - - context = {"name": lc_gsr["name"], "gid": lc_gsr["cid"]} - context["rooms"] = [ - {"room_name": x["name"], "id": x["id"], "availability": x["availability"]} - for x in lc_gsr["rooms"] - ] - return context - +class AbstractBookingWrapper(ABC): + @abstractmethod + def book_room(self, rid, start, end, user): + pass + + @abstractmethod def cancel_room(self, booking_id, user): - try: - # gets reservations from wharton for a user - wharton_bookings = self.WLW.get_reservations(user) - except APIError as e: - # don't throw error if the student is non-wharton - if str(e) == "Wharton: GSR view restricted to Wharton Pennkeys": - wharton_bookings = [] - else: - raise APIError(f"Error: {str(e)}") - wharton_booking_ids = [str(x["booking_id"]) for x in wharton_bookings] - try: - gsr_booking = GSRBooking.objects.filter(booking_id=booking_id).first() - # checks if the booking_id is a wharton booking_id - if booking_id in wharton_booking_ids: - self.WLW.cancel_room(user, booking_id) - else: - # defaults to wharton because it is in wharton_booking_ids - self.LCW.cancel_room(user, booking_id) - except APIError as e: - raise APIError(f"Error: {str(e)}") + pass - if gsr_booking: - # updates GSR booking after done - gsr_booking.is_cancelled = True - gsr_booking.save() - - reservation = gsr_booking.reservation - all_cancelled = True - # loops through all reservation bookings and checks if all - # corresponding bookings are cancelled - for booking in GSRBooking.objects.filter(reservation=reservation): - if not booking.is_cancelled: - all_cancelled = False - break - if all_cancelled: - reservation.is_cancelled = True - reservation.save() + @abstractmethod + def get_availability(self, lid, start, end, user): + pass + @abstractmethod def get_reservations(self, user): - bookings = self.LCW.get_reservations(user) + self.WLW.get_reservations(user) - - # TODO: toggle this for everyone - group = Group.objects.get(name="Penn Labs") - if user in group.members.all(): - for booking in bookings: - gsr_booking = GSRBooking.objects.filter(booking_id=booking["booking_id"]).first() - if not gsr_booking: - booking["room_name"] = "[Me] " + booking["room_name"] - else: - # TODO: change this once we release the "Me" group - if user == gsr_booking.reservation.creator: - booking["room_name"] = "[Me] " + booking["room_name"] - else: - booking["room_name"] = ( - f"[{gsr_booking.reservation.group.name}] " + booking["room_name"] - ) - return bookings - - def check_credits(self, user): - wharton_booking_credits = self.WLW.check_credits(user) - libcal_booking_credits = self.LCW.check_credits(user) - credits_merged = libcal_booking_credits.copy() - credits_merged.update(wharton_booking_credits) - return credits_merged + pass + # @abstractmethod + # def check_credits(self, user): + # pass -class WhartonLibWrapper: - def request(self, *args, **kwargs): +class WhartonBookingWrapper(AbstractBookingWrapper): + def request(self, *arg, **kwargs): """Make a signed request to the libcal API.""" - - headers = {"Authorization": f"Token {settings.WHARTON_TOKEN}"} - # add authorization headers - kwargs["headers"] = headers - + kwargs["headers"] = {"Authorization": f"Token {settings.WHARTON_TOKEN}"} + try: - response = requests.request(*args, **kwargs) + response = requests.request(*arg, **kwargs) except (ConnectTimeout, ReadTimeout, ConnectionError): raise APIError("Wharton: Connection timeout") @@ -190,16 +73,30 @@ def request(self, *args, **kwargs): if response.status_code == 403 or response.status_code == 401: raise APIError("Wharton: GSR view restricted to Wharton Pennkeys") return response - - def is_wharton(self, username): - url = f"{WHARTON_URL}{username}/privileges" - try: - response = self.request("GET", url).json() - return response["type"] != "None" - except APIError: - return False - - def get_availability(self, lid, start, end, username): + + def book_room(self, rid, start, end, user): + """Books room if pennkey is valid""" + payload = { + "start": start, + "end": end, + "pennkey": user.username, + "room": rid, + } + url = f"{WHARTON_URL}{user.username}/student_reserve" + response = self.request("POST", url, json=payload).json() + if "error" in response: + raise APIError("Wharton: " + response["error"]) + return response + + def cancel_room(self, booking_id, user): + """Cancels reservation given booking id""" + url = f"{WHARTON_URL}{user.username}/reservations/{booking_id}/cancel" + response = self.request("DELETE", url).json() + if "detail" in response: + raise APIError("Wharton: " + response["detail"]) + return response + + def get_availability(self, lid, start, end, user): """Returns a list of rooms and their availabilities""" current_time = timezone.localtime() search_date = ( @@ -209,8 +106,9 @@ def get_availability(self, lid, start, end, username): ) # hits availability route for a given lid and date - url = f"{WHARTON_URL}{username}/availability/{lid}/{str(search_date)}" + url = f"{WHARTON_URL}{user.username}/availability/{lid}/{str(search_date)}" rooms = self.request("GET", url).json() + if "closed" in rooms and rooms["closed"]: return [] @@ -234,117 +132,37 @@ def get_availability(self, lid, start, end, username): valid_slots.append(slot) room["availability"] = valid_slots return rooms - - def book_room(self, rid, start, end, user, lid): - """Books room if pennkey is valid""" - - start_date = datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") - end_date = datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") - duration = int((end_date.timestamp() - start_date.timestamp()) / 60) - - if self.check_credits(user)[lid] < duration: - raise APIError("Not Enough Credits to Book") - - payload = { - "start": start, - "end": end, - "pennkey": user.username, - "room": rid, - } - url = f"{WHARTON_URL}{user.username}/student_reserve" - response = self.request("POST", url, json=payload).json() - if "error" in response: - raise APIError("Wharton: " + response["error"]) - return response - + def get_reservations(self, user): - booking_ids = set() - - reservations = Reservation.objects.filter( - creator=user, end__gte=timezone.localtime(), is_cancelled=False - ) - group_gsrs = GSRBooking.objects.filter( - reservation__in=reservations, gsr__in=GSR.objects.filter(kind=GSR.KIND_WHARTON) - ) - - wharton_bookings = GSRBookingSerializer( - GSRBooking.objects.filter( - user=user, - gsr__in=GSR.objects.filter(kind=GSR.KIND_WHARTON), - end__gte=timezone.localtime(), - is_cancelled=False, - ).union(group_gsrs), - many=True, - ).data - - for wharton_booking in wharton_bookings: - booking_ids.add(wharton_booking["booking_id"]) - + url = f"{WHARTON_URL}{user.username}/reservations" + bookings = self.request("GET", url).json()["bookings"] + + bookings = [booking for booking in bookings if datetime.datetime.strptime(booking["end"], "%Y-%m-%dT%H:%M:%S%z") >= timezone.localtime()] + + return [ + { + "booking_id": str(booking["booking_id"]), + "gid": booking["lid"], # their lid is our gid + "room_id": booking["rid"], + "room_name": booking["room"], + "start": booking["start"], + "end": booking["end"], + } + for booking in bookings + ] + + def is_wharton(self, user): + url = f"{WHARTON_URL}{user.username}/privileges" try: - url = f"{WHARTON_URL}{user.username}/reservations" - bookings = self.request("GET", url).json()["bookings"] - # ignore this because this route is used by everyone - for booking in bookings: - booking["lid"] = GSR.objects.get(gid=booking["lid"]).lid - # checks if reservation is within time range - if ( - datetime.datetime.strptime(booking["end"], "%Y-%m-%dT%H:%M:%S%z") - >= timezone.localtime() - ): - # filtering for lid here works because Wharton buildings have distinct lid's - if str(booking["booking_id"]) not in booking_ids: - context = { - "booking_id": str(booking["booking_id"]), - "gsr": GSRSerializer(GSR.objects.get(lid=booking["lid"])).data, - "room_id": booking["rid"], - "room_name": booking["room"], - "start": booking["start"], - "end": booking["end"], - } - wharton_bookings.append(context) - booking_ids.add(str(booking["booking_id"])) + response = self.request("GET", url) + if response.status_code != 200: + return None + res_json = response.json() + return res_json.get("type") == "whartonMBA" or res_json.get("type") == "whartonUGR" except APIError: - pass - - return wharton_bookings - - def cancel_room(self, user, booking_id): - """Cancels reservation given booking id""" - wharton_booking = GSRBooking.objects.filter(booking_id=booking_id) - username = user.username - if wharton_booking.exists(): - gsr_booking = wharton_booking.first() - gsr_booking.is_cancelled = True - gsr_booking.save() - # changing username if booking is in database - username = gsr_booking.user.username - url = f"{WHARTON_URL}{username}/reservations/{booking_id}/cancel" - response = self.request("DELETE", url).json() - if "detail" in response: - raise APIError("Wharton: " + response["detail"]) - return response + return None - def check_credits(self, user): - # gets all current reservations from wharton availability route - wharton_lids = GSR.objects.filter(kind=GSR.KIND_WHARTON).values_list("lid", flat=True) - # wharton get 90 minutes of credit at any moment - default_credit = 90 if self.is_wharton(user.username) else 0 - wharton_credits = {lid: default_credit for lid in wharton_lids} - reservations = self.get_reservations(user) - for reservation in reservations: - # determines if ARB or Huntsman - room_type = reservation["gsr"]["lid"] - if room_type in wharton_credits: - # accumulates total minutes - start = datetime.datetime.strptime(reservation["start"], "%Y-%m-%dT%H:%M:%S%z") - end = datetime.datetime.strptime(reservation["end"], "%Y-%m-%dT%H:%M:%S%z") - wharton_credits[room_type] -= int((end.timestamp() - start.timestamp()) / 60) - - # 90 minutes at any given time - return wharton_credits - - -class LibCalWrapper: +class LibCalBookingWrapper(AbstractBookingWrapper): def __init__(self): self.token = None self.expiration = timezone.localtime() @@ -358,14 +176,15 @@ def update_token(self): "client_secret": settings.LIBCAL_SECRET, "grant_type": "client_credentials", } + response = requests.post(f"{API_URL}/1.1/oauth/token", body).json() if "error" in response: raise APIError(f"LibCal: {response['error']}, {response.get('error_description')}") self.expiration = timezone.localtime() + datetime.timedelta(seconds=response["expires_in"]) self.token = response["access_token"] - - def request(self, *args, **kwargs): + + def request(self, *arg, **kwargs): """Make a signed request to the libcal API.""" self.update_token() @@ -376,92 +195,14 @@ def request(self, *args, **kwargs): kwargs["headers"].update(headers) else: kwargs["headers"] = headers - + try: - return requests.request(*args, **kwargs) + return requests.request(*arg, **kwargs) except (ConnectTimeout, ReadTimeout, ConnectionError): raise APIError("LibCal: Connection timeout") - - def get_availability(self, lid, start=None, end=None): - """Returns a list of rooms and their availabilities""" - - # adjusts url based on start and end times - range_str = "availability" - if start: - start_datetime = datetime.datetime.combine( - datetime.datetime.strptime(start, "%Y-%m-%d").date(), datetime.datetime.min.time() - ) - range_str += "=" + start - if end and not start == end: - range_str += "," + end - else: - start_datetime = None - - response = self.request("GET", f"{API_URL}/1.1/space/categories/{lid}").json() - if "error" in response: - raise APIError("LibCal: " + response["error"]) - output = {"id": lid, "categories": []} - - # if there aren't any rooms associated with this location, return - if len(response) < 1: - return output - - if "error" in response[0]: - raise APIError("LibCal: " + response[0]["error"]) - - if "categories" not in response[0]: - return output - - # filters categories and then gets extra information on each room - categories = response[0]["categories"] - id_to_category = {i["cid"]: i["name"] for i in categories} - categories = ",".join([str(x["cid"]) for x in categories]) - response = self.request("GET", f"{API_URL}/1.1/space/category/{categories}").json() - for category in response: - cat_out = {"cid": category["cid"], "name": id_to_category[category["cid"]], "rooms": []} - - # ignore equipment categories - if cat_out["name"].endswith("Equipment"): - continue - - items = category["items"] - items = ",".join([str(x) for x in items]) - # hits this route for extra information - response = self.request("GET", f"{API_URL}/1.1/space/item/{items}?{range_str}") - - if response.ok: - for room in response.json(): - if room["id"] in ROOM_BLACKLIST: - continue - # remove extra fields - if "formid" in room: - del room["formid"] - # enforce date filter - # API returns dates outside of the range, fix this manually - if start_datetime: - out_times = [] - for time in room["availability"]: - parsed_start = datetime.datetime.strptime( - time["from"][:-6], "%Y-%m-%dT%H:%M:%S" - ) - if parsed_start >= start_datetime: - out_times.append(time) - room["availability"] = out_times - cat_out["rooms"].append(room) - if cat_out["rooms"]: - output["categories"].append(cat_out) - return output - - def book_room(self, rid, start, end, user, test=False): - """Books a room given the required information.""" - - start_date = datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") - end_date = datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") - duration = int((end_date.timestamp() - start_date.timestamp()) / 60) - - if self.check_credits(user, start_date)[CreditType.LIBCAL.value] < duration: - raise APIError("Not Enough Credits to Book") - + + def book_room(self, rid, start, end, user): + """Books room if pennkey is valid""" # turns parameters into valid json format, then books room payload = { "start": start, @@ -471,7 +212,7 @@ def book_room(self, rid, start, end, user, test=False): "nickname": f"{user.username} GSR Booking", "q43": f"{user.username} GSR Booking", "bookings": [{"id": rid, "to": end}], - "test": test, + "test": False, "q2555": "5", "q2537": "5", "q3699": self.get_affiliation(user.email), @@ -489,38 +230,76 @@ def book_room(self, rid, start, end, user, test=False): res_json = response.json() # corrects keys in response - if "error" not in res_json: - if "errors" in res_json: - errors = res_json["errors"] - if isinstance(errors, list): - errors = " ".join(errors) - res_json["error"] = BeautifulSoup( - errors.replace("\n", " "), "html.parser" - ).text.strip() - del res_json["errors"] + if "error" not in res_json and "errors" in res_json: + errors = res_json["errors"] + if isinstance(errors, list): + errors = " ".join(errors) + res_json["error"] = BeautifulSoup( + errors.replace("\n", " "), "html.parser" + ).text.strip() + del res_json["errors"] if "error" in res_json: raise APIError("LibCal: " + res_json["error"]) return res_json def get_reservations(self, user): + pass - reservations = Reservation.objects.filter( - creator=user, end__gte=timezone.localtime(), is_cancelled=False - ) - group_gsrs = GSRBooking.objects.filter( - reservation__in=reservations, gsr__in=GSR.objects.filter(kind=GSR.KIND_LIBCAL) - ) + def cancel_room(self, booking_id, user): + """Cancels room""" + # gsr_booking = get_object_or_404(GSRBooking, booking_id=booking_id) + # if gsr_booking: + # if user != gsr_booking.user and user != gsr_booking.reservation.creator: + # raise APIError("Error: Unauthorized: This reservation was booked by someone else.") + # gsr_booking.is_cancelled = True + # gsr_booking.save() + response = self.request("POST", f"{API_URL}/1.1/space/cancel/{booking_id}").json() + if "error" in response[0]: + raise APIError("LibCal: " + response[0]["error"]) + return response + + def get_availability(self, gid, start, end, user): + """Returns a list of rooms and their availabilities""" - return GSRBookingSerializer( - GSRBooking.objects.filter( - user=user, - gsr__in=GSR.objects.filter(kind=GSR.KIND_LIBCAL), - end__gte=timezone.localtime(), - is_cancelled=False, - ).union(group_gsrs), - many=True, - ).data + # adjusts url based on start and end times + range_str = "availability" + if start: + start_datetime = datetime.datetime.combine( + datetime.datetime.strptime(start, "%Y-%m-%d").date(), datetime.datetime.min.time() + ) + range_str += "=" + start + if end and not start == end: + range_str += "," + end + else: + start_datetime = None + + # filters categories and then gets extra information on each room + + response = self.request("GET", f"{API_URL}/1.1/space/category/{gid}").json() + items = response[0]["items"] + items = ",".join([str(x) for x in items]) + response = self.request("GET", f"{API_URL}/1.1/space/item/{items}?{range_str}") + if response.status_code != 200: + raise APIError(f"GSR Reserve: Error {response.status_code} when reserving data") + + rooms = [{"room_name": room['name'], 'id': room['id'], 'availability': room['availability']} for room in response.json() if room["id"] not in ROOM_BLACKLIST] + for room in rooms: + # remove extra fields + if "formid" in room: + del room["formid"] + # enforce date filter + # API returns dates outside of the range, fix this manually + room["availability"] = [ + { + 'start_time': time['from'], + 'end_time': time['to'] + } + for time in room["availability"] + if not start_datetime or datetime.datetime.strptime(time["from"][:-6], "%Y-%m-%dT%H:%M:%S") >= start_datetime + ] + return rooms + def get_affiliation(self, email): """Gets school from email""" if "wharton" in email: @@ -532,38 +311,175 @@ def get_affiliation(self, email): else: return "Other" - def cancel_room(self, user, booking_id): - """Cancels room""" - gsr_booking = get_object_or_404(GSRBooking, booking_id=booking_id) - if gsr_booking: - if user != gsr_booking.user and user != gsr_booking.reservation.creator: + +class BookingHandler: + def __init__(self, WBW=None, LBW=None): + self.WBW = WBW or WhartonBookingWrapper() + self.LBW = LBW or LibCalBookingWrapper() + + + def format_members(self, members): + return [ + (User(**{ k[6:]: v for k, v in member.items() if k.startswith('user__') }), # temp user object + member['left']) + for member in members + ] + + def get_wharton_members(self, group, gsr_id): + now = timezone.localtime() + ninty_min = datetime.timedelta(minutes=90) + zero_min = datetime.timedelta(minutes=0) + ret = GroupMembership.objects.filter(group=group, is_wharton=True).values('user').annotate(left=ninty_min-Coalesce(Sum( + F('user__gsrbooking__end')-F('user__gsrbooking__start'), + filter=Q(user__gsrbooking__gsr__gid=gsr_id) & Q(user__gsrbooking__is_cancelled=False) & Q(user__gsrbooking__end__gte=now) + ), zero_min)).filter(Q(left__gt=zero_min)).values('user__id', 'user__username', 'left').order_by('?')[:3] + return self.format_members(ret) + + # locally i get this: + # SELECT "auth_user"."username", (5400000000 - COALESCE(SUM(django_timestamp_diff("gsr_booking_gsrbooking"."end", "gsr_booking_gsrbooking"."start")) FILTER (WHERE ("gsr_booking_gsr"."gid" = 1 AND NOT "gsr_booking_gsrbooking"."is_cancelled" AND "gsr_booking_gsrbooking"."end" >= 2023-10-22 20:04:00.804135)), 0)) AS "left" FROM "gsr_booking_groupmembership" LEFT OUTER JOIN "auth_user" ON ("gsr_booking_groupmembership"."user_id" = "auth_user"."id") LEFT OUTER JOIN "gsr_booking_gsrbooking" ON ("auth_user"."id" = "gsr_booking_gsrbooking"."user_id") LEFT OUTER JOIN "gsr_booking_gsr" ON ("gsr_booking_gsrbooking"."gsr_id" = "gsr_booking_gsr"."id") WHERE ("gsr_booking_groupmembership"."group_id" = 3 AND "gsr_booking_groupmembership"."is_wharton") GROUP BY "gsr_booking_groupmembership"."user_id", "auth_user"."username" HAVING (5400000000 - COALESCE(SUM(django_timestamp_diff("gsr_booking_gsrbooking"."end", "gsr_booking_gsrbooking"."start")) FILTER (WHERE ("gsr_booking_gsr"."gid" = 1 AND NOT "gsr_booking_gsrbooking"."is_cancelled" AND "gsr_booking_gsrbooking"."end" >= 2023-10-22 20:04:00.804135)), 0)) > 0 ORDER BY RAND() ASC LIMIT 3 + + def get_libcal_members(self, group): + day_start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + datetime.timedelta(days=1) + two_hours = datetime.timedelta(hours=2) + zero_min = datetime.timedelta(minutes=0) + + ret = GroupMembership.objects.filter(group=group).values('user').annotate(left=two_hours-Coalesce(Sum( + F('user__gsrbooking__end')-F('user__gsrbooking__start'), + filter=Q(user__gsrbooking__gsr__kind=GSR.KIND_LIBCAL) & Q(user__gsrbooking__is_cancelled=False) & Q(user__gsrbooking__start__gte=day_start) & Q(user__gsrbooking__end__lte=day_end) + ), zero_min)).filter(Q(left__gt=zero_min)).values('user__id', 'user__username', 'user__first_name', 'user__last_name', 'user__email', 'left').order_by('?')[:4] + return self.format_members(ret) + + # SELECT "auth_user"."username", (7200000000 - COALESCE(SUM(django_timestamp_diff("gsr_booking_gsrbooking"."end", "gsr_booking_gsrbooking"."start")) FILTER (WHERE ("gsr_booking_gsr"."kind" = LIBCAL AND NOT "gsr_booking_gsrbooking"."is_cancelled" AND "gsr_booking_gsrbooking"."start" >= 2023-10-30 04:00:00 AND "gsr_booking_gsrbooking"."end" <= 2023-10-31 04:00:00)), 0)) AS "left" FROM "gsr_booking_groupmembership" LEFT OUTER JOIN "auth_user" ON ("gsr_booking_groupmembership"."user_id" = "auth_user"."id") LEFT OUTER JOIN "gsr_booking_gsrbooking" ON ("auth_user"."id" = "gsr_booking_gsrbooking"."user_id") LEFT OUTER JOIN "gsr_booking_gsr" ON ("gsr_booking_gsrbooking"."gsr_id" = "gsr_booking_gsr"."id") WHERE "gsr_booking_groupmembership"."group_id" = 3 GROUP BY "gsr_booking_groupmembership"."user_id", "auth_user"."username" HAVING (7200000000 - COALESCE(SUM(django_timestamp_diff("gsr_booking_gsrbooking"."end", "gsr_booking_gsrbooking"."start")) FILTER (WHERE ("gsr_booking_gsr"."kind" = LIBCAL AND NOT "gsr_booking_gsrbooking"."is_cancelled" AND "gsr_booking_gsrbooking"."start" >= 2023-10-30 04:00:00 AND "gsr_booking_gsrbooking"."end" <= 2023-10-31 04:00:00)), 0)) > 0 ORDER BY RAND() ASC LIMIT 4 + + def book_room(self, gid, rid, room_name, start, end, user, group=None): + # NOTE when booking with a group, we are only querying our db for existing bookings, so users in a group who book through wharton may screw up the query + gsr = get_object_or_404(GSR, gid=gid) + start=datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") + end=datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") + + book_func = self.WBW.book_room if gsr.kind == GSR.KIND_WHARTON else self.LBW.book_room + members = [(user, datetime.timedelta(days=99))] if group is None else self.get_wharton_members(group, gsr.id) if gsr.kind == GSR.KIND_WHARTON else self.get_libcal_members(group) + + total_time_available = sum([time_available for _, time_available in members], datetime.timedelta(minutes=0)) + + if (end - start) >= total_time_available: + raise APIError("Error: Not enough credits") + + reservation = Reservation.objects.create( + start=start, end=end, creator=user, group=Group.objects.get_or_create(name="Me", owner=user)[0] if group is None else group + # we should allow None groups and get rid of Me Groups + ) + + curr_start = start + try: + for curr_user, time_available in members: + curr_end = curr_start + min(time_available, end-curr_start) + + + booking_id = book_func(rid, curr_start.strftime("%Y-%m-%dT%H:%M:%S%z"), curr_end.strftime("%Y-%m-%dT%H:%M:%S%z"), curr_user)["booking_id"] + booking = GSRBooking.objects.create( + user_id=curr_user.id, + booking_id=str(booking_id), + gsr=gsr, + room_id=rid, + room_name=room_name, + start=curr_start, + end=curr_end + ) + booking.reservation = reservation + booking.save() + + if (curr_start := curr_end) >= end: + break + except APIError as e: + raise APIError(f"{str(e)}. Was only able to book {start.strftime('%H:%M')} - {curr_start.strftime('%H:%M')}") + + return reservation + + def cancel_room(self, booking_id, user): + # I think this should prefetch reservation, reservation__gsrbooking_set, and gsr ? + if gsr_booking := GSRBooking.objects.filter(booking_id=booking_id).prefetch_related(Prefetch('reservation__gsrbooking_set'), Prefetch('gsr')).first(): + if gsr_booking.user != user and gsr_booking.reservation.creator != user: raise APIError("Error: Unauthorized: This reservation was booked by someone else.") + + (self.WBW.cancel_room if gsr_booking.gsr.kind == GSR.KIND_WHARTON else self.LBW.cancel_room)(booking_id, gsr_booking.user) + gsr_booking.is_cancelled = True gsr_booking.save() - response = self.request("POST", f"{API_URL}/1.1/space/cancel/{booking_id}").json() - if "error" in response[0]: - raise APIError("LibCal: " + response[0]["error"]) - return response - def check_credits(self, user, lc_start=None): - # default to beginning of day - if lc_start is None: - lc_start = make_aware(datetime.datetime.now()) - lc_start = lc_start.replace(second=0, microsecond=0, minute=0, hour=0) - - lc_end = lc_start + datetime.timedelta(days=1) - - # filters for all reservations for the given date - reservations = GSRBooking.objects.filter( - gsr__in=GSR.objects.filter(kind=GSR.KIND_LIBCAL), - start__gte=lc_start, - end__lte=lc_end, - is_cancelled=False, - user=user, - ) - total_minutes = 0 - for reservation in reservations: - # accumulates total minutes over all reservations - total_minutes += int((reservation.end.timestamp() - reservation.start.timestamp()) / 60) - # 120 minutes per day - return {CreditType.LIBCAL.value: 120 - total_minutes} + reservation = gsr_booking.reservation + if all(booking.is_cancelled for booking in reservation.gsrbooking_set.all()): + reservation.is_cancelled = True + reservation.save() + else: + try: + self.WBW.cancel_room(booking_id, user) + except APIError: + try: + self.LBW.cancel_room(booking_id, user) + except APIError: + raise APIError("Error: Unknown booking id") + + + def get_availability(self, lid, gid, start, end, user, group=None): + gsr = get_object_or_404(GSR, gid=gid) + + # select a random user from the group if booking wharton gsr + if gsr.kind == GSR.KIND_WHARTON and group is not None: + wharton_members = group.memberships.filter(is_wharton=True) + if (n := wharton_members.count()) == 0: + raise APIError("Error: Non Wharton cannot book Wharton GSR") + user = wharton_members[randint(0, n-1)].user + + rooms = self.WBW.get_availability(lid, start, end, user) if gsr.kind == GSR.KIND_WHARTON else self.LBW.get_availability(gid, start, end, user) + return {"name": gsr.name, "gid": gsr.gid, "rooms": rooms} + + + def get_reservations(self, user, group=None): + q = Q(user=user) | Q(reservation__creator=user) if group else Q(user=user) + bookings = GSRBooking.objects.filter(q, is_cancelled=False, end__gte=timezone.localtime()).prefetch_related(Prefetch('reservation')) + + if group: + ret = [] + for booking in bookings: + data = GSRBookingSerializer(booking).data + if booking.reservation.creator == user: + data['room_name'] = f"[Me] {data['room_name']}" + else: + data['room_name'] = f"[{group.name}] {data['room_name']}" + ret.append(data) + else: + ret = GSRBookingSerializer(bookings, many=True).data + + # deal with bookings made directly through wharton (not us) + try: + wharton_bookings = self.WBW.get_reservations(user) # is this bad? + except APIError: + return ret + + if len(wharton_bookings) == 0: + return ret + + booking_ids = set([booking['booking_id'] for booking in ret]) + wharton_bookings = [booking for booking in wharton_bookings if booking['booking_id'] not in booking_ids] + if len(wharton_bookings) == 0: + return ret + + wharton_gsr_datas = {gsr.gid: GSRSerializer(gsr).data for gsr in GSR.objects.filter(kind=GSR.KIND_WHARTON)} + for booking in wharton_bookings: + if booking['booking_id'] in booking_ids: + continue + booking['gsr'] = wharton_gsr_datas[booking['gid']] + del booking['gid'] + ret.append(booking) + return ret + + def check_credits(self, user): + pass # seems like its unused on the frontend + + +# initialize singletons +WhartonGSRBooker = WhartonBookingWrapper() +LibCalGSRBooker = LibCalBookingWrapper() +GSRBooker = BookingHandler(WhartonGSRBooker, LibCalGSRBooker) \ No newline at end of file diff --git a/backend/gsr_booking/group_logic.py b/backend/gsr_booking/group_logic.py index df821a53..f946db1c 100644 --- a/backend/gsr_booking/group_logic.py +++ b/backend/gsr_booking/group_logic.py @@ -1,106 +1,106 @@ -import datetime -import random - -from django.contrib.auth import get_user_model -from django.shortcuts import get_object_or_404 - -from gsr_booking.api_wrapper import APIError, BookingWrapper, CreditType -from gsr_booking.models import GSR, GroupMembership, Reservation - - -User = get_user_model() - - -class GroupBook: - def __init__(self): - self.bw = BookingWrapper() - - def get_wharton_users(self, group): - """ - Returns list of wharton users of a Group in random ordering - """ - # TODO: filter for pennkey allowed - wharton_users = list(GroupMembership.objects.filter(group=group, is_wharton=True)) - # shuffle to prevent sequential booking - random.shuffle(wharton_users) - return wharton_users - - def get_all_users(self, group): - """ - Returns list of all users of a Group in random ordering - """ - # TODO: filter for pennkey allowed - all_users = list(GroupMembership.objects.filter(group=group)) - # shuffle to prevent sequential booking - random.shuffle(all_users) - return all_users - - def book_room(self, gid, rid, room_name, start, end, user, group): - """ - Book function for Group - """ - # TODO: check credits - gsr = get_object_or_404(GSR, gid=gid) - - start = datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") - end = datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") - - # sets users and credit_id depending on the gsr type - if gsr.kind == GSR.KIND_WHARTON: - users = self.get_wharton_users(group) - credit_id = gsr.lid - else: - users = self.get_all_users(group) - credit_id = CreditType.LIBCAL.value - - total_credits = sum([self.bw.check_credits(usr.user).get(credit_id, 0) for usr in users]) - duration = int((end.timestamp() - start.timestamp()) / 60) - if total_credits < duration: - raise APIError("Not Enough Credits to Book") - if duration % 30 != 0: - raise APIError("Invalid duration") - - # creates reservation object to be used to group each booking - reservation = Reservation.objects.create(start=start, end=end, creator=user, group=group) - - # we could potentially repeat using a user to make a 30 min booking so - # loop until total duration booked - while duration > 0: - # loop through each user and try to make a 30 min booking - # under that user if they have enough credits - for usr in users: - credit = self.bw.check_credits(usr.user).get(credit_id, 0) - if credit < 30: - continue - curr_end = start + datetime.timedelta(minutes=30) # end of current booking - booking = self.bw.book_room( - gid, - rid, - room_name, - start.strftime("%Y-%m-%dT%H:%M:%S%z"), - curr_end.strftime("%Y-%m-%dT%H:%M:%S%z"), - usr.user, - group_book=True, - ) - booking.reservation = reservation - booking.save() - # update new start and duration appropriately - start = curr_end - duration -= 30 - if duration <= 0: - break - return reservation - - def get_availability(self, lid, gid, start, end, user, group): - """ - Availability function for Group - """ - - gsr = GSR.objects.filter(gid=gid).first() - if gsr.kind == GSR.KIND_WHARTON: - # check if wharton users is non-empty - wharton_user = GroupMembership.objects.filter(group=group, is_wharton=True).first() - if wharton_user: - return self.bw.get_availability(lid, gid, start, end, wharton_user.user) - - return self.bw.get_availability(lid, gid, start, end, user) +# import datetime +# import random + +# from django.contrib.auth import get_user_model +# from django.shortcuts import get_object_or_404 + +# from gsr_booking.api_wrapper import APIError, BookingWrapper, CreditType +# from gsr_booking.models import GSR, GroupMembership, Reservation + + +# User = get_user_model() + + +# class GroupBook: +# def __init__(self): +# self.bw = BookingWrapper() + +# def get_wharton_users(self, group): +# """ +# Returns list of wharton users of a Group in random ordering +# """ +# # TODO: filter for pennkey allowed +# wharton_users = list(GroupMembership.objects.filter(group=group, is_wharton=True)) +# # shuffle to prevent sequential booking +# random.shuffle(wharton_users) +# return wharton_users + +# def get_all_users(self, group): +# """ +# Returns list of all users of a Group in random ordering +# """ +# # TODO: filter for pennkey allowed +# all_users = list(GroupMembership.objects.filter(group=group)) +# # shuffle to prevent sequential booking +# random.shuffle(all_users) +# return all_users + +# def book_room(self, gid, rid, room_name, start, end, user, group): +# """ +# Book function for Group +# """ +# # TODO: check credits +# gsr = get_object_or_404(GSR, gid=gid) + +# start = datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") +# end = datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") + +# # sets users and credit_id depending on the gsr type +# if gsr.kind == GSR.KIND_WHARTON: +# users = self.get_wharton_users(group) +# credit_id = gsr.lid +# else: +# users = self.get_all_users(group) +# credit_id = CreditType.LIBCAL.value + +# total_credits = sum([self.bw.check_credits(usr.user).get(credit_id, 0) for usr in users]) +# duration = int((end.timestamp() - start.timestamp()) / 60) +# if total_credits < duration: +# raise APIError("Not Enough Credits to Book") +# if duration % 30 != 0: +# raise APIError("Invalid duration") + +# # creates reservation object to be used to group each booking +# reservation = Reservation.objects.create(start=start, end=end, creator=user, group=group) + +# # we could potentially repeat using a user to make a 30 min booking so +# # loop until total duration booked +# while duration > 0: +# # loop through each user and try to make a 30 min booking +# # under that user if they have enough credits +# for usr in users: +# credit = self.bw.check_credits(usr.user).get(credit_id, 0) +# if credit < 30: +# continue +# curr_end = start + datetime.timedelta(minutes=30) # end of current booking +# booking = self.bw.book_room( +# gid, +# rid, +# room_name, +# start.strftime("%Y-%m-%dT%H:%M:%S%z"), +# curr_end.strftime("%Y-%m-%dT%H:%M:%S%z"), +# usr.user, +# group_book=True, +# ) +# booking.reservation = reservation +# booking.save() +# # update new start and duration appropriately +# start = curr_end +# duration -= 30 +# if duration <= 0: +# break +# return reservation + +# def get_availability(self, lid, gid, start, end, user, group): +# """ +# Availability function for Group +# """ + +# gsr = GSR.objects.filter(gid=gid).first() +# if gsr.kind == GSR.KIND_WHARTON: +# # check if wharton users is non-empty +# wharton_user = GroupMembership.objects.filter(group=group, is_wharton=True).first() +# if wharton_user: +# return self.bw.get_availability(lid, gid, start, end, wharton_user.user) + +# return self.bw.get_availability(lid, gid, start, end, user) diff --git a/backend/gsr_booking/models.py b/backend/gsr_booking/models.py index fe36fec9..85ec97de 100644 --- a/backend/gsr_booking/models.py +++ b/backend/gsr_booking/models.py @@ -45,6 +45,7 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def check_wharton(self): + return WhartonGSRBooker.is_wharton(self.user) # not using api_wrapper.py to prevent circular dependency url = f"https://apps.wharton.upenn.edu/gsr/api/v1/{self.user.username}/privileges" try: @@ -120,7 +121,7 @@ class Reservation(models.Model): start = models.DateTimeField(default=timezone.now) end = models.DateTimeField(default=timezone.now) creator = models.ForeignKey(User, on_delete=models.CASCADE) - group = models.ForeignKey(Group, on_delete=models.CASCADE) + group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True) is_cancelled = models.BooleanField(default=False) reminder_sent = models.BooleanField(default=False) @@ -136,3 +137,5 @@ class GSRBooking(models.Model): start = models.DateTimeField(default=timezone.now) end = models.DateTimeField(default=timezone.now) is_cancelled = models.BooleanField(default=False) + +from gsr_booking.api_wrapper import WhartonGSRBooker # import at end to prevent circular dependency \ No newline at end of file diff --git a/backend/gsr_booking/urls.py b/backend/gsr_booking/urls.py index fd10e57e..96488bde 100644 --- a/backend/gsr_booking/urls.py +++ b/backend/gsr_booking/urls.py @@ -6,7 +6,6 @@ BookRoom, CancelRoom, CheckWharton, - CreditsView, GroupMembershipViewSet, GroupViewSet, Locations, @@ -31,5 +30,4 @@ path("book/", BookRoom.as_view(), name="book"), path("cancel/", CancelRoom.as_view(), name="cancel"), path("reservations/", ReservationsView.as_view(), name="reservations"), - path("credits/", CreditsView.as_view(), name="credits"), ] diff --git a/backend/gsr_booking/views.py b/backend/gsr_booking/views.py index c6dd3ab1..c870e61f 100644 --- a/backend/gsr_booking/views.py +++ b/backend/gsr_booking/views.py @@ -9,8 +9,8 @@ from rest_framework.response import Response from rest_framework.views import APIView -from gsr_booking.api_wrapper import APIError, BookingWrapper -from gsr_booking.group_logic import GroupBook +from gsr_booking.api_wrapper import APIError, GSRBooker # umbrella class used for accessing GSR API's (needed for token authentication) +from gsr_booking.api_wrapper import WhartonGSRBooker from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking from gsr_booking.serializers import ( GroupMembershipSerializer, @@ -164,9 +164,7 @@ def get_queryset(self): ) -# umbrella class used for accessing GSR API's (needed for token authentication) -BW = BookingWrapper() -GB = GroupBook() + class Locations(generics.ListAPIView): @@ -193,7 +191,7 @@ def get_queryset(self): class CheckWharton(APIView): def get(self, request): - return Response({"is_wharton": BW.is_wharton(request.user)}) + return Response({"is_wharton": WhartonGSRBooker.is_wharton(request.user)}) class Availability(APIView): @@ -211,11 +209,12 @@ def get(self, request, lid, gid): end = request.GET.get("end") try: - group = Group.objects.get(name="Penn Labs") - if request.user in group.members.all(): - return Response(GB.get_availability(lid, gid, start, end, request.user, group)) - else: - return Response(BW.get_availability(lid, gid, start, end, request.user)) + # group = Group.objects.get(name="Penn Labs") + # if request.user in group.members.all(): + # return Response(GB.get_availability(lid, gid, start, end, request.user, group)) + # else: + # return Response(BW.get_availability(lid, gid, start, end, request.user)) + return Response(GSRBooker.get_availability(lid, gid, start, end, request.user, request.user.booking_groups.filter(name="Penn Labs").first())) except APIError as e: return Response({"error": str(e)}, status=400) @@ -233,11 +232,7 @@ def post(self, request): room_name = request.data["room_name"] try: - group = Group.objects.get(name="Penn Labs") - if request.user in group.members.all(): - GB.book_room(gid, room_id, room_name, start, end, request.user, group) - else: - BW.book_room(gid, room_id, room_name, start, end, request.user) + GSRBooker.book_room(gid, room_id, room_name, start, end, request.user, request.user.booking_groups.filter(name="Penn Labs").first()) return Response({"detail": "success"}) except APIError as e: return Response({"error": str(e)}, status=400) @@ -254,7 +249,7 @@ def post(self, request): booking_id = request.data["booking_id"] try: - BW.cancel_room(booking_id, request.user) + GSRBooker.cancel_room(booking_id, request.user) return Response({"detail": "success"}) except APIError as e: return Response({"error": str(e)}, status=400) @@ -268,15 +263,5 @@ class ReservationsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - return Response(BW.get_reservations(request.user)) - - -class CreditsView(APIView): - """ - Gets credits for a User - """ - - permission_classes = [IsAuthenticated] + return Response(GSRBooker.get_reservations(request.user, request.user.booking_groups.filter(name="Penn Labs").first())) - def get(self, request): - return Response(BW.check_credits(request.user))