Skip to content

Commit 16443cd

Browse files
committed
Fixing shift based approvals
1 parent 7bc2566 commit 16443cd

File tree

14 files changed

+324
-144
lines changed

14 files changed

+324
-144
lines changed

backend/tuber/api/hotels.py

Lines changed: 78 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from tuber.api.util import *
1212
from .room_matcher import rematch_hotel_block, clear_hotel_block
1313
from .uber import export_requests
14+
from .util import paginate
1415
import time
1516

1617
@app.route("/api/event/<int:event>/hotel/<int:hotel_block>/room/<int:room_id>/remove_roommates", methods=["POST"])
@@ -128,22 +129,19 @@ def room_details(event):
128129
HotelRoomRequest, HotelRoomRequest.badge == RoomNightAssignment.badge
129130
).filter(HotelRoom.id.in_(rooms)).distinct().options(
130131
selectinload(HotelRoomRequest.roommate_requests).load_only(
131-
Badge.id, Badge.public_name)
132-
).options(
132+
Badge.id, Badge.public_name),
133133
selectinload(
134-
HotelRoomRequest.roommate_anti_requests).load_only(Badge.id, Badge.public_name)
135-
).options(
134+
HotelRoomRequest.roommate_anti_requests).load_only(Badge.id, Badge.public_name),
136135
selectinload(HotelRoomRequest.room_night_requests).load_only(
137-
RoomNightRequest.room_night, RoomNightRequest.requested)
138-
).options(
139-
selectinload(HotelRoomRequest.room_night_approvals).load_only(
140-
RoomNightApproval.room_night, RoomNightApproval.approved
141-
)
142-
).options(
136+
RoomNightRequest.room_night, RoomNightRequest.requested),
137+
selectinload(HotelRoom.roommates).load_only(
138+
Badge.id, Badge.public_name
139+
).selectinload(Badge.manually_approved_nights),
143140
selectinload(HotelRoom.roommates).load_only(
144-
Badge.id, Badge.public_name)
141+
Badge.id, Badge.public_name
142+
).selectinload(Badge.shift_overlap_nights),
145143
).all()
146-
144+
147145
for hotel_room, request in hotel_rooms:
148146
if not hotel_room.id in gender_prefs:
149147
gender_prefs[hotel_room.id] = set()
@@ -165,11 +163,10 @@ def room_details(event):
165163
nights = set()
166164
for night_request in request.room_night_requests:
167165
if night_request.requested:
168-
#if room_nights[night_request.room_night].restricted:
169-
# for approval in request.room_night_approvals:
170-
# if approval.room_night == night_request.room_night and approval.approved:
171-
# nights.add(night_request.room_night)
172-
#else:
166+
approved_nights = set()
167+
for roommate in hotel_room.roommates:
168+
approved_nights.update([x.id for x in roommate.approved_hotel_nights])
169+
if night_request.room_night in approved_nights:
173170
nights.add(night_request.room_night)
174171
assigned_nights = [x.room_night for x in rnas_by_badge[request.badge]]
175172
missing_nights = nights.difference(set(assigned_nights))
@@ -219,21 +216,18 @@ def matching_roommates(event):
219216

220217
badges = db.query(Badge).join(HotelRoomRequest, HotelRoomRequest.badge == Badge.id).filter(
221218
or_(HotelRoomRequest.declined == False, HotelRoomRequest.declined == None)
222-
).filter(Badge.event == event, Badge.search_name.contains(g.data.get('search', "").lower())).order_by(Badge.public_name).limit(20).all()
219+
).filter(Badge.event == event, Badge.search_name.contains(g.data.get('search', "").lower())
220+
).options(selectinload(Badge.shift_overlap_nights), selectinload(Badge.manually_approved_nights)
221+
).order_by(Badge.public_name).limit(20).all()
223222

224223
results = []
225224
for badge in badges:
226225
missing = []
226+
approved_nights = badge.approved_hotel_nights
227227
for night in badge.room_night_requests:
228228
assign = False
229-
if night.requested:
230-
#if room_nights[night.room_night].restricted:
231-
# for approval in badge.room_night_approvals:
232-
# if approval.room_night == night.room_night and approval.approved:
233-
# assign = True
234-
# break
235-
#else:
236-
assign = True
229+
if night.requested and night.room_night in [x.id for x in approved_nights]:
230+
assign = True
237231
for assignment in badge.room_night_assignments:
238232
if assignment.room_night == night.room_night:
239233
assign = False
@@ -265,11 +259,46 @@ def room_search(event):
265259
return jsonify(hotel_rooms=HotelRoom.serialize(rooms, serialize_relationships=True), count=count), 200
266260

267261

262+
@app.route("/api/event/<int:event>/hotel/list_requests", methods=["GET"])
263+
def list_requests(event):
264+
if not check_permission("hotel_block.*.read"):
265+
return "", 403
266+
267+
query = db.query(HotelRoomRequest)
268+
if "hotel_block" in g.data:
269+
query = query.filter(HotelRoomRequest.hotel_block == int(g.data['hotel_block']))
270+
query = query.options(
271+
selectinload(HotelRoomRequest.badge_obj, Badge.manually_approved_nights),
272+
selectinload(HotelRoomRequest.badge_obj, Badge.shift_overlap_nights)
273+
)
274+
275+
query = paginate(query, HotelRoomRequest, event)
276+
277+
if request.args.get("count", False, type=lambda x: x.lower() == "true"):
278+
return json.dumps(query.count()), 200
279+
requests = query.all()
280+
281+
room_nights = db.query(HotelRoomNight).filter(HotelRoomNight.event == event).all()
282+
283+
return [
284+
{
285+
"first_name": x.badge_obj.first_name,
286+
"last_name": x.badge_obj.last_name,
287+
"notes": x.notes,
288+
"id": x.id,
289+
"approved_nights": {y.id: y in x.badge_obj.approved_hotel_nights for y in room_nights},
290+
"requested_nights": {z.id: z.id in [y.room_night for y in x.room_night_requests if y.requested] for z in room_nights}
291+
} for x in requests
292+
]
293+
294+
268295
@app.route("/api/event/<int:event>/hotel/<int:hotel_block>/request_search", methods=["GET"])
269296
def request_search(event, hotel_block):
270297
if not check_permission("hotel_block.*.read"):
271298
return "", 403
272299

300+
room_nights = db.query(HotelRoomNight).filter(HotelRoomNight.event == event).all()
301+
273302
assigned_nights = db.query(RoomNightRequest.id).filter(RoomNightRequest.requested).join(
274303
RoomNightAssignment, and_(RoomNightAssignment.badge == RoomNightRequest.badge,
275304
RoomNightAssignment.room_night == RoomNightRequest.room_night)
@@ -280,7 +309,10 @@ def request_search(event, hotel_block):
280309
HotelRoomRequest.hotel_block == hotel_block,
281310
or_(HotelRoomRequest.declined == False, HotelRoomRequest.declined == None),
282311
HotelRoomRequest.room_night_requests.any(and_(RoomNightRequest.requested, not_(RoomNightRequest.id.in_(assigned_nights))))
283-
).join(Badge, Badge.id == HotelRoomRequest.badge)
312+
).join(Badge, Badge.id == HotelRoomRequest.badge).options(
313+
selectinload(HotelRoomRequest.badge_obj, Badge.shift_overlap_nights),
314+
selectinload(HotelRoomRequest.badge_obj, Badge.manually_approved_nights)
315+
)
284316
if g.data['search_term']:
285317
reqs = reqs.filter(
286318
or_(Badge.search_name.contains(g.data['search_term'].lower()), func.lower(
@@ -295,7 +327,18 @@ def request_search(event, hotel_block):
295327
if g.data['order'] == "desc":
296328
sort = sort.desc()
297329
reqs = reqs.order_by(sort).offset(int(g.data['offset'])).limit(int(g.data['limit'])).all()
298-
return jsonify(requests=HotelRoomRequest.serialize(reqs, serialize_relationships=True, deep=True), count=count), 200
330+
331+
results = [{
332+
"id": req.id,
333+
"approved_nights": {x.id: x in req.badge_obj.approved_hotel_nights for x in room_nights},
334+
"requested_nights": {x.id: x.id in [y.room_night for y in req.room_night_requests if y.requested] for x in room_nights},
335+
"notes": req.notes,
336+
"first_name": req.first_name,
337+
"last_name": req.last_name,
338+
"public_name": req.badge_obj.public_name,
339+
"badge": req.badge,
340+
} for req in reqs]
341+
return jsonify(requests=results, count=count), 200
299342

300343

301344
@app.route("/api/event/<int:event>/hotel/submitted_requests", methods=["GET"])
@@ -382,7 +425,7 @@ def block_assignments(event):
382425
@app.route("/api/event/<int:event>/hotel/requests/<int:department>", methods=["GET"])
383426
def hotel_requests(event, department):
384427
requests = db.query(Badge, HotelRoomRequest).join(BadgeToDepartment, BadgeToDepartment.badge == Badge.id).filter(
385-
BadgeToDepartment.department == department).join(HotelRoomRequest, HotelRoomRequest.badge == BadgeToDepartment.badge).all()
428+
BadgeToDepartment.department == department).join(HotelRoomRequest, HotelRoomRequest.badge == BadgeToDepartment.badge).options(selectinload(Badge.manually_approved_nights), selectinload(Badge.shift_overlap_nights)).all()
386429
res = []
387430
for req in requests:
388431
badge, roomrequest = req
@@ -399,6 +442,8 @@ def hotel_requests(event, department):
399442
"id": btr.id,
400443
"requested": btr.requested,
401444
"room_night": btr.room_night,
445+
"approved_by_anyone": btr.room_night in [x.id for x in badge.manually_approved_nights],
446+
"approved_by_shifts": btr.room_night in [x.id for x in badge.shift_overlap_nights],
402447
"approved": rna.approved if rna else None
403448
} for btr, rna in room_nights}
404449
})
@@ -410,20 +455,20 @@ def hotel_requests(event, department):
410455
def hotel_approve(event, department):
411456
if check_permission("hotel_request.*.approve", event=event, department=department):
412457
room_night_request = db.query(RoomNightRequest).filter(
413-
RoomNightRequest.room_night == request.json['room_night'], RoomNightRequest.badge == request.json['badge']).one_or_none()
458+
RoomNightRequest.room_night == int(request.json['room_night']), RoomNightRequest.badge == int(request.json['badge'])).one_or_none()
414459
if not room_night_request:
415460
return "Could not find corresponding request.", 404
416461
approval = db.query(RoomNightApproval).filter(RoomNightApproval.badge ==
417-
request.json['badge'], RoomNightApproval.room_night == request.json['room_night'], RoomNightApproval.department == department).one_or_none()
462+
int(request.json['badge']), RoomNightApproval.room_night == int(request.json['room_night']), RoomNightApproval.department == department).one_or_none()
418463
if request.json['approved'] is None:
419464
if approval:
420465
db.delete(approval)
421466
else:
422467
if not approval:
423468
approval = RoomNightApproval(
424-
event=event, badge=request.json['badge'], department=department)
469+
event=event, badge=int(request.json['badge']), department=department)
425470
approval.approved = request.json['approved']
426-
approval.room_night = request.json['room_night']
471+
approval.room_night = int(request.json['room_night'])
427472
db.add(approval)
428473
hotel_room_request = db.query(HotelRoomRequest).filter(
429474
HotelRoomRequest.badge == room_night_request.badge).one()

backend/tuber/api/uber.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,42 @@ def import_shifts(event):
2626
"department_id": dept.uber_id
2727
}
2828
}
29-
shifts = requests.post(event_obj.uber_url, headers=headers, json=req).json()['result']
29+
jobs = requests.post(event_obj.uber_url, headers=headers, json=req).json()['result']
30+
db.query(Job).filter(Job.department == dept.id, Job.event == event).delete()
31+
for job in jobs:
32+
job_obj = Job(
33+
name=job["name"],
34+
description=job["description"],
35+
department=dept.id,
36+
event=event
37+
)
38+
db.add(job_obj)
39+
db.flush()
40+
start_time = datetime.datetime.strptime(job["start_time"], "%Y-%m-%d %H:%M:%S.%f")
41+
end_time = datetime.datetime.strptime(job["end_time"], "%Y-%m-%d %H:%M:%S.%f")
42+
shift_obj = Shift(
43+
event=event,
44+
job=job_obj.id,
45+
starttime=start_time,
46+
duration=(end_time-start_time).seconds,
47+
slots=job["slots"],
48+
filledslots=job["slots_taken"],
49+
weighting=1.0
50+
)
51+
db.add(shift_obj)
52+
db.flush()
53+
for shift in job["shifts"]:
54+
try:
55+
badge = db.query(Badge).filter(Badge.event==event, Badge.uber_id==shift["attendee"]["id"]).one()
56+
assignment = ShiftAssignment(
57+
event=event,
58+
badge=badge.id,
59+
shift=shift_obj.id
60+
)
61+
db.add(assignment)
62+
except sqlalchemy.exc.NoResultFound:
63+
print(f"Failed to find attendee for {shift['attendee']['id']} (Not hotel elligible?)")
64+
db.commit()
3065
return "null", 200
3166

3267
def get_nights(url, headers):
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Added timezone to event
2+
3+
Revision ID: 28e0b36a0af8
4+
Revises: afc23d43704e
5+
Create Date: 2025-06-25 21:54:34.030171
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '28e0b36a0af8'
14+
down_revision = 'afc23d43704e'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
with op.batch_alter_table('event', schema=None) as batch_op:
22+
batch_op.add_column(sa.Column('timezone', sa.String(), nullable=False, server_default="America/New_York"))
23+
24+
# ### end Alembic commands ###
25+
26+
27+
def downgrade():
28+
# ### commands auto generated by Alembic - please adjust! ###
29+
with op.batch_alter_table('event', schema=None) as batch_op:
30+
batch_op.drop_column('timezone')
31+
32+
# ### end Alembic commands ###
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Added shift start and end times to hotelroomnight
2+
3+
Revision ID: afc23d43704e
4+
Revises: 44ea59b15fb5
5+
Create Date: 2025-06-25 21:22:11.475460
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = 'afc23d43704e'
14+
down_revision = '44ea59b15fb5'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
with op.batch_alter_table('hotel_room_night', schema=None) as batch_op:
22+
batch_op.add_column(sa.Column('shift_starttime', sa.DateTime(), nullable=True))
23+
batch_op.add_column(sa.Column('shift_endtime', sa.DateTime(), nullable=True))
24+
25+
# ### end Alembic commands ###
26+
27+
28+
def downgrade():
29+
# ### commands auto generated by Alembic - please adjust! ###
30+
with op.batch_alter_table('hotel_room_night', schema=None) as batch_op:
31+
batch_op.drop_column('shift_endtime')
32+
batch_op.drop_column('shift_starttime')
33+
34+
# ### end Alembic commands ###

backend/tuber/models/__init__.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
1+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
22
from sqlalchemy.ext.declarative import declarative_base
33
import sqlalchemy.inspection
44
from sqlalchemy.types import JSON
@@ -11,6 +11,37 @@
1111
import json
1212

1313

14+
class TimeZone(sqlalchemy.types.TypeDecorator):
15+
"""
16+
Stores and retrieves timezone-aware datetimes as string objects.
17+
18+
In the database, the timezone is stored as a string (e.g., 'America/New_York').
19+
In the Python application, it's represented as a `zoneinfo.ZoneInfo` object.
20+
"""
21+
impl = sqlalchemy.types.String(100)
22+
cache_ok = True # The type is immutable, so it's safe to cache
23+
24+
def process_bind_param(self, value: ZoneInfo | None, dialect) -> str | None:
25+
"""
26+
Takes a ZoneInfo object from the app and converts it to a string for the DB.
27+
"""
28+
if value is None:
29+
return None
30+
if not isinstance(value, ZoneInfo):
31+
raise TypeError("TimeZone column requires a zoneinfo.ZoneInfo object")
32+
return value.key
33+
34+
def process_result_value(self, value: str | None, dialect) -> ZoneInfo | None:
35+
"""
36+
Takes a string from the DB and converts it to a ZoneInfo object for the app.
37+
"""
38+
if value is None:
39+
return None
40+
try:
41+
return ZoneInfo(value)
42+
except ZoneInfoNotFoundError:
43+
raise ValueError(f"Invalid timezone '{value}' found in database")
44+
1445
def model_permissions(name):
1546
"""
1647
Retrieves the permissions of the current user on the given model class.
@@ -69,8 +100,12 @@ def transform(inst, column): return getattr(inst, column.name)
69100
def transform(inst, column): return json.loads(
70101
getattr(inst, column.name) or "{}")
71102
elif type(column.type) is Date:
72-
def transform(inst, calumn): return getattr(
103+
def transform(inst, column): return getattr(
73104
inst, column.name).strftime("%Y-%m-%d")
105+
elif type(column.type) is TimeZone:
106+
def transform(inst, column): return str(getattr(
107+
inst, column.name
108+
))
74109
for i in range(len(instances)):
75110
data[i][column.name] = transform(instances[i], column)
76111

@@ -173,10 +208,16 @@ def filter_columns(cls, instance, perms, existing=None):
173208
key = column.name
174209
val = instance[key]
175210
if type(column.type) is DateTime:
176-
instance[key] = datetime.datetime.strptime(
177-
val, '%Y-%m-%dT%H:%M:%S.%f')
211+
if val.endswith("Z"):
212+
instance[key] = datetime.datetime.strptime(
213+
val, '%Y-%m-%dT%H:%M:%S.%fZ', ).replace(tzinfo=datetime.timezone.utc)
214+
else:
215+
instance[key] = datetime.datetime.strptime(
216+
val, '%Y-%m-%dT%H:%M:%S.%f')
178217
elif type(column.type) is JSON:
179218
instance[key] = json.dumps(val)
219+
elif type(column.type) is TimeZone:
220+
instance[key] = ZoneInfo(val)
180221
for relation in relations:
181222
# new = []
182223
if relation.key in instance:

0 commit comments

Comments
 (0)