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|
| Location | 4/17/2022 at 9:40 AM | Capacity |
| 4th Floor Fitness | 5 | 8.93% |
| 3rd Floor Fitness | 4 | 5.88% |
| 2nd Floor Strength | 21 | 13.13% |
| Basketball Courts | 0 | 0.00% |
| MPR | 3 | 3.00% |
| Climbing Wall | 0 | 0.00% |
| 1st floor Fitness | 2 | 3.08% |
| Pool-Shallow | 0 | 0.00% |
| Pool-Deep | 0 | 0.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):