diff --git a/Pipfile b/Pipfile new file mode 100644 index 00000000..0757494b --- /dev/null +++ b/Pipfile @@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] + +[requires] +python_version = "3.11" diff --git a/backend/penndata/management/commands/get_fitness_snapshot.py b/backend/penndata/management/commands/get_fitness_snapshot.py index ec0792a8..dd6d5318 100644 --- a/backend/penndata/management/commands/get_fitness_snapshot.py +++ b/backend/penndata/management/commands/get_fitness_snapshot.py @@ -1,8 +1,11 @@ +import zoneinfo + import requests -from bs4 import BeautifulSoup -from dateutil import parser +from django.conf import settings from django.core.management.base import BaseCommand -from django.utils import timezone +from django.db.models import Q +from django.utils.dateparse import parse_datetime +from django.utils.timezone import make_aware from penndata.models import FitnessRoom, FitnessSnapshot @@ -12,77 +15,68 @@ def cap_string(s): def get_usages(): - - # count/capacities default to 0 since spreadsheet number appears blank if no one there - locations = [ - "4th Floor Fitness", - "3rd Floor Fitness", - "2nd Floor Strength", - "Basketball Courts", - "MPR", - "Climbing Wall", - "1st Floor Fitness", - "Pool-Shallow", - "Pool-Deep", - ] - usages = {location: {"count": 0, "capacity": 0} for location in locations} - - date = timezone.localtime() # default if can't get date from spreadsheet - try: resp = requests.get( - ( - "https://docs.google.com/spreadsheets/u/0/d/e/" - "2PACX-1vSX91_MlAjJo5uVLznuy7BFnUgiBOI28oBCReLRKKo76L" - "-k8EFgizAYXpIKPBX_c76wC3aztn3BogD4" - "/pubhtml/sheet?headers=false&gid=0" - ) + "https://goboardapi.azurewebsites.net/api/FacilityCount/GetCountsByAccount", + params={"AccountAPIKey": settings.FITNESS_TOKEN}, ) + data = resp.json() except ConnectionError: return None - - html = resp.content.decode("utf8") - soup = BeautifulSoup(html, "html5lib") - if not (embedded_spreadsheet := soup.find("tbody")): + except requests.exceptions.JSONDecodeError: return None - table_rows = embedded_spreadsheet.findChildren("tr") - for i, row in enumerate(table_rows): - cells = row.findChildren("td") - if i == 0: - date = timezone.make_aware(parser.parse(cells[1].getText())) - elif (location := cap_string(cells[0].getText())) in usages: - try: - count = int(cells[1].getText()) - capacity = float(cells[2].getText().strip("%")) - usages[location] = {"count": count, "capacity": capacity} - except ValueError: - pass - else: - print(f"Unknown location: {location}") - return usages, date + def location_aware_datetime(time_str): + date = parse_datetime(time_str) + timezone = zoneinfo.ZoneInfo("America/New_York") + return make_aware(date, timezone=timezone) + + usages = { + location["LocationName"]: { + "count": location["LastCount"], + "capacity": location["TotalCapacity"], + "last_updated": location_aware_datetime(location["LastUpdatedDateAndTime"]), + } + for location in data + } + return usages class Command(BaseCommand): - help = "Captures a new Fitness Snapshot for every Laundry room." + help = "Captures a new Fitness Snapshot for every Fitness room." def handle(self, *args, **kwargs): - usage_by_location, date = get_usages() + # Don't update locations for which we already have a room with a matching last_updated date. + # Fixed the O(n^2) issue by loading everything into memory. Should be fine since there's + # not many rooms, and 1 snapshot returned per room + all_rooms = FitnessRoom.objects.all() + all_room_names = set(room.name for room in all_rooms) + query = Q() + for room_name, room_usage in get_usages().items(): + query |= Q(room__name=room_name, date=room_usage["last_updated"]) + existing_snapshots = FitnessSnapshot.objects.filter(query) + existing_room_date_pairs = set( + (snapshot.room.name, snapshot.date) for snapshot in existing_snapshots + ) - # prevent double creating FitnessSnapshots - if FitnessSnapshot.objects.filter(date=date).exists(): - self.stdout.write("FitnessSnapshots already exist for this date!") - return + def exists(record): + (name, usage) = record + if name not in all_room_names: + return False + if (name, usage["last_updated"]) in existing_room_date_pairs: + return False + return True + usage_by_location = filter(exists, get_usages().items()) FitnessSnapshot.objects.bulk_create( [ FitnessSnapshot( room=FitnessRoom.objects.get_or_create(name=room_name)[0], - date=date, + date=room_usage["last_updated"], count=room_usage["count"], capacity=room_usage["capacity"], ) - for room_name, room_usage in usage_by_location.items() + for (room_name, room_usage) in usage_by_location ] ) diff --git a/backend/penndata/management/commands/load_fitness_rooms.py b/backend/penndata/management/commands/load_fitness_rooms.py index b7797d47..3d572828 100644 --- a/backend/penndata/management/commands/load_fitness_rooms.py +++ b/backend/penndata/management/commands/load_fitness_rooms.py @@ -6,24 +6,23 @@ class Command(BaseCommand): def handle(self, *args, **kwargs): fitness_rooms = [ - "4th Floor Fitness", - "3rd Floor Fitness", - "2nd Floor Strength", - "Basketball Courts", - "MPR", "Climbing Wall", + "Rec Lounge", "1st Floor Fitness", - "Pool-Shallow", - "Pool-Deep", + "Court 1", + "Court 2", + "Court 3", + "Multipurpose Room", + "2nd Floor Weight Room", + "3rd Floor Fitness Room", + "4th Floor Fitness Room", + "Studio 409", + "Sheerr Pool", ] for room in fitness_rooms: obj, _ = FitnessRoom.objects.get_or_create(name=room) if obj.image_url == "": - s3_image_name = ( - room.replace(" ", "_") + (".png" if "2nd" in room else ".jpg") - if "Pool" not in room - else "Pool.jpeg" - ) + s3_image_name = room.replace(" ", "_") + ".jpg" obj.image_url = ( f"https://s3.us-east-2.amazonaws.com/penn.mobile/pottruck/{s3_image_name}" ) diff --git a/backend/pennmobile/settings/base.py b/backend/pennmobile/settings/base.py index 33230002..d9b7ed3a 100644 --- a/backend/pennmobile/settings/base.py +++ b/backend/pennmobile/settings/base.py @@ -180,6 +180,9 @@ LIBCAL_SECRET = os.environ.get("LIBCAL_SECRET", None) WHARTON_TOKEN = os.environ.get("WHARTON_TOKEN", None) +# Fitness Token +FITNESS_TOKEN = os.environ.get("FITNESS_TOKEN", None) + # Upload file storage DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", None) diff --git a/backend/tests/penndata/fitness_snapshot.html b/backend/tests/penndata/fitness_snapshot.html deleted file mode 100644 index e42202e4..00000000 --- a/backend/tests/penndata/fitness_snapshot.html +++ /dev/null @@ -1,106 +0,0 @@ - -PottruckWebCounts - Google Drive
1
Location4/17/2022 at 9:40 AMCapacity
2
4th Floor Fitness58.93%
3
3rd Floor Fitness45.88%
4
2nd Floor Strength2113.13%
5
Basketball Courts00.00%
6
MPR33.00%
7
Climbing Wall00.00%
8
1st floor Fitness23.08%
9
Pool-Shallow00.00%
10
Pool-Deep00.00%
\ No newline at end of file diff --git a/backend/tests/penndata/fitness_snapshot.js b/backend/tests/penndata/fitness_snapshot.js new file mode 100644 index 00000000..32269ef0 --- /dev/null +++ b/backend/tests/penndata/fitness_snapshot.js @@ -0,0 +1,236 @@ +[ + { + "LocationId": 10183, + "TotalCapacity": 1000, + "LocationName": "Sport Club Practices", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-30T19:15:09.71", + "LastCount": 35, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 2106, + "FacilityName": "Competitive Sports Staff", + "IsClosed": false + }, + { + "LocationId": 9608, + "TotalCapacity": 50, + "LocationName": "Climbing Wall", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-24T15:13:10.543", + "LastCount": 0, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": true + }, + { + "LocationId": 10222, + "TotalCapacity": 25, + "LocationName": "Rec Lounge", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:04.817", + "LastCount": 0, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 9609, + "TotalCapacity": 65, + "LocationName": "1st Floor Fitness", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:05.553", + "LastCount": 12, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 9606, + "TotalCapacity": 10, + "LocationName": "Court 1", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:06.37", + "LastCount": 1, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 9889, + "TotalCapacity": 10, + "LocationName": "Court 2", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:06.99", + "LastCount": 0, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 9890, + "TotalCapacity": 10, + "LocationName": "Court 3", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:07.613", + "LastCount": 10, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 9607, + "TotalCapacity": 100, + "LocationName": "Multipurpose Room", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:08.367", + "LastCount": 14, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 9610, + "TotalCapacity": 178, + "LocationName": "2nd Floor Weight Room", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:08.99", + "LastCount": 20, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 9611, + "TotalCapacity": 68, + "LocationName": "3rd Floor Fitness Room", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:09.693", + "LastCount": 6, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 9616, + "TotalCapacity": 56, + "LocationName": "4th Floor Fitness Room", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:10.287", + "LastCount": 3, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 9617, + "TotalCapacity": 100, + "LocationName": "Studio 409", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:11.083", + "LastCount": 11, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + }, + { + "LocationId": 11079, + "TotalCapacity": 50, + "LocationName": "Sheerr Pool", + "CountOfParticipants": 0, + "PercetageCapacity": 0, + "LastUpdatedDateAndTime": "2025-10-31T07:39:11.71", + "LastCount": 6, + "MinColor": "#ffc90e", + "MidColor": null, + "MaxColor": "#ed1c24", + "MinCapacityRange": 60, + "MaxCapacityRange": 90, + "CountCapacityColorEnabled": true, + "FacilityId": 1961, + "FacilityName": "Pottruck Health & Fitness Center", + "IsClosed": false + } +] \ No newline at end of file diff --git a/backend/tests/penndata/test_views.py b/backend/tests/penndata/test_views.py index 525c5dbb..6459da1c 100644 --- a/backend/tests/penndata/test_views.py +++ b/backend/tests/penndata/test_views.py @@ -16,9 +16,18 @@ def fakeFitnessGet(url, *args, **kwargs): - if "docs.google.com/spreadsheets/" in url: - with open("tests/penndata/fitness_snapshot.html", "rb") as f: - m = mock.MagicMock(content=f.read()) + if "goboardapi.azurewebsites.net/api" in url: + with open("tests/penndata/fitness_snapshot.js", "rb") as f: + raw = f.read() + m = mock.MagicMock() + m.content = raw + m.status_code = 200 + m.headers = {"Content-Type": "application/json"} + try: + parsed = json.loads(raw.decode("utf-8")) + except Exception: + parsed = None + m.json = mock.MagicMock(return_value=parsed) return m else: raise NotImplementedError @@ -205,17 +214,35 @@ def test_get_fitness_snapshot(self): call_command("get_fitness_snapshot") # checks that all fitness snapshots have been accounted for - self.assertEqual(FitnessSnapshot.objects.all().count(), 9) + fitness_snapshots = FitnessSnapshot.objects.all() + original_fitness_snapshots = set(fitness_snapshots.values_list("room", "date")) + self.assertEqual(fitness_snapshots.count(), 12) # asserts that fields are correct, and that all snapshots # have been accounted for - for snapshot in FitnessSnapshot.objects.all(): + for snapshot in fitness_snapshots: self.assertTrue(snapshot.count >= 0) + # Does not create duplicate snapshots call_command("get_fitness_snapshot") + fitness_snapshots = FitnessSnapshot.objects.all() + new_fitness_snapshots = set(fitness_snapshots.values_list("room", "date")) + self.assertEqual(fitness_snapshots.count(), 12) + self.assertEqual(original_fitness_snapshots, new_fitness_snapshots) + + # Outdated snapshot timestamp gets updated to newest snapshot timestamp + first_snapshot = FitnessSnapshot.objects.first() + first_snapshot.date = timezone.now() - datetime.timedelta(days=10) + first_snapshot.save() + fitness_snapshots = FitnessSnapshot.objects.all() + new_fitness_snapshots = set(fitness_snapshots.values_list("room", "date")) + self.assertFalse(original_fitness_snapshots <= new_fitness_snapshots) - # does not create duplicate snapshots - self.assertEqual(FitnessSnapshot.objects.all().count(), 9) + call_command("get_fitness_snapshot") + fitness_snapshots = FitnessSnapshot.objects.all() + new_fitness_snapshots = set(fitness_snapshots.values_list("room", "date")) + self.assertEqual(fitness_snapshots.count(), 13) + self.assertTrue(original_fitness_snapshots <= new_fitness_snapshots) class TestFitnessUsage(TestCase):