Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions alembic/versions/a3c8e5f17b92_room_and_block_locks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Room locks and a per-room export stamp

Adds room_assignment.locked (admin lock) and room_assignment.exported_at
(when a hotel export first included the row). exported_at is backfilled
from the export log: the earliest booking export after the row was
created and, for inactive rows, before their last change. Rows on an
entry with the Passkey export flag count as exported.

Revision ID: a3c8e5f17b92
Revises: f7c3d9e1a2b4
Create Date: 2026-09-23 12:00:00.000000
"""


# revision identifiers, used by Alembic.
revision = 'a3c8e5f17b92'
down_revision = 'f7c3d9e1a2b4'
branch_labels = None
depends_on = None

import sqlalchemy as sa
from alembic import op

from uber.config import c


def upgrade():
op.add_column('room_assignment', sa.Column('locked', sa.Boolean(), server_default='false', nullable=False))
op.add_column('room_assignment', sa.Column('exported_at', sa.DateTime(timezone=True), nullable=True))

live = ', '.join(str(int(s)) for s in c.HOTEL_LIVE_ASSIGNMENT_STATUSES)
op.execute(sa.text(f"""
UPDATE room_assignment ra
SET exported_at = (
SELECT min(log.exported_at)
FROM hotel_export_log log
JOIN hotel_room_inventory inv ON inv.hotel_id = log.hotel_id
WHERE inv.id = ra.inventory_id
AND log.export_type = 'room_export'
AND log.exported_at >= ra.created
AND (ra.status IN ({live})
OR log.exported_at < coalesce(ra.last_modified_at, ra.last_updated)))
WHERE ra.exported_at IS NULL
"""))
op.execute(sa.text("""
UPDATE room_assignment ra
SET exported_at = now()
FROM lottery_application app
WHERE app.id = ra.lottery_application_id
AND app.export_locked
AND ra.exported_at IS NULL
"""))


def downgrade():
op.drop_column('room_assignment', 'exported_at')
op.drop_column('room_assignment', 'locked')
84 changes: 83 additions & 1 deletion tests/hotel/test_award_transitions.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""The COMPLETE <-> AWARDED transition: the RoomAssignment
after_insert/after_delete SQL listeners and the ORM-side
after_insert/after_update/after_delete SQL listeners and the ORM-side
LotteryApplication.sync_award_status must implement the same rule.

The listeners run on the raw connection during flush, so the ORM object is
stale afterwards - tests flush and then expire/refresh the app to observe.
"""

import contextlib
from datetime import date

import uber.tasks.hotel as hotel_tasks
from uber.config import c

from tests.hotel.factories import (N, make_application, make_assignment,
Expand Down Expand Up @@ -129,3 +133,81 @@ def test_sync_award_status_agrees_with_listeners(session):
session.flush()
app.sync_award_status(session)
assert app.status == c.WITHDRAWN


def test_after_update_cancelling_last_live_room_demotes(session):
inv, attendee, app = _fixture(session, c.COMPLETE)
run = make_run(session)
ra = make_assignment(session, attendee, inv, status=c.SECURED,
lottery_application_id=app.id,
check_in=N[1], check_out=N[3])
app.lottery_run_id = run.id
session.flush()
session.expire(app)
assert app.status == c.AWARDED

ra.status = c.CANCELLED
session.flush()
session.expire(app)

assert app.status == c.COMPLETE
assert app.lottery_run_id is None


def test_after_update_keeps_awarded_while_a_sibling_is_live(session):
inv, attendee, app = _fixture(session, c.COMPLETE)
first = make_assignment(session, attendee, inv, status=c.SECURED,
lottery_application_id=app.id, check_in=N[1], check_out=N[3])
make_assignment(session, attendee, inv, status=c.ASSIGNED,
lottery_application_id=app.id, check_in=N[1], check_out=N[3])

first.status = c.EXPIRED
session.flush()
session.expire(app)

assert app.status == c.AWARDED


def test_after_update_reinstating_a_room_promotes(session):
inv, attendee, app = _fixture(session, c.COMPLETE)
ra = make_assignment(session, attendee, inv, status=c.CANCELLED,
lottery_application_id=app.id, check_in=N[1], check_out=N[3])
session.expire(app)
assert app.status == c.COMPLETE

ra.status = c.ASSIGNED
session.flush()
session.expire(app)

assert app.status == c.AWARDED


def test_sync_award_status_sees_unflushed_status_changes(session):
"""Sessions don't autoflush; the resync must count the caller's own
pending changes."""
inv, attendee, app = _fixture(session, c.COMPLETE)
ra = make_assignment(session, attendee, inv, status=c.ASSIGNED,
lottery_application_id=app.id, check_in=N[1], check_out=N[3])
session.expire(app)
assert app.status == c.AWARDED

ra.status = c.CANCELLED
app.sync_award_status(session)

assert app.status == c.COMPLETE


def test_expiry_task_returns_application_to_complete(session, no_cherrypy_session, monkeypatch):
inv, attendee, app = _fixture(session, c.COMPLETE)
make_assignment(session, attendee, inv, status=c.ASSIGNED, payment_type='credit_card',
lottery_application_id=app.id, check_in=N[1], check_out=N[3],
deposit_cutoff_date=date(2026, 1, 1))
session.expire(app)
assert app.status == c.AWARDED
monkeypatch.setattr(session, 'commit', session.flush)
monkeypatch.setattr(hotel_tasks, 'Session', lambda: contextlib.nullcontext(session))

hotel_tasks.expire_unsecured_assignments()
session.expire(app)

assert app.status == c.COMPLETE
3 changes: 2 additions & 1 deletion tests/hotel/test_exports_timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import cherrypy
from pytz import UTC

from uber.config import c
from uber.hotel.exports import (changed_rooms_between, hotel_activity_timeline,
render_booking_export, store_export_file,
unprocessed_imports)
Expand Down Expand Up @@ -282,7 +283,7 @@ def test_api_export_attribution_defaults_to_api(session, monkeypatch):
attendee = make_attendee(session)
app = make_application(session, attendee)
make_assignment(session, attendee, inventory=inv,
check_in=N[1], check_out=N[3],
check_in=N[1], check_out=N[3], status=c.SECURED, cc_token='tok',
lottery_application_id=app.id)
session.flush()

Expand Down
60 changes: 60 additions & 0 deletions tests/hotel/test_guarantee_reminder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""The card reminder email (hotel_lottery_guarantee_reminder) fires for an
awarded entry with an unlocked room still waiting on a card, within a
week of the deadline; not for secured, master-bill, or locked rooms, or
for group members.
"""

from datetime import date, timedelta

from uber.config import c
from uber.models import AutomatedEmail

from tests.hotel.factories import (make_application, make_assignment,
make_attendee, make_hotel, make_inventory)

IDENT = 'hotel_lottery_guarantee_reminder'


def _fires(app):
fixture = AutomatedEmail._fixtures.get(IDENT)
assert fixture is not None, 'the reminder fixture is registered'
return bool(fixture.filter(app))


def _entry(session, **room):
me = make_attendee(session)
app = make_application(session, me, status=c.AWARDED)
inv = make_inventory(session, make_hotel(session), quantity=5)
params = dict(status=c.ASSIGNED, payment_type='credit_card',
lottery_application_id=app.id, booking_url='',
deposit_cutoff_date=date.today() + timedelta(days=3))
params.update(room)
ra = make_assignment(session, me, inv, **params)
session.flush()
return app, ra


def test_fires_without_a_booking_link(session):
app, _ = _entry(session)
assert not app.booking_url_ready
assert _fires(app)


def test_quiet_once_every_room_is_secured(session):
app, _ = _entry(session, status=c.SECURED, cc_token='tok')
assert not _fires(app)


def test_quiet_for_master_bill_rooms(session):
app, _ = _entry(session, payment_type='masterbill')
assert not _fires(app)


def test_quiet_for_locked_rooms(session):
app, _ = _entry(session, locked=True)
assert not _fires(app)


def test_quiet_until_a_week_before_the_deadline(session):
app, _ = _entry(session, deposit_cutoff_date=date.today() + timedelta(days=30))
assert not _fires(app)
131 changes: 131 additions & 0 deletions tests/hotel/test_hotel_transfer_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Every hotel export and import that includes a room leaves a Tracking
row on it (c.HOTEL_EXPORT / c.HOTEL_IMPORT), so the application history
shows them in order with the room's own edits. Those rows are markers,
not changes, so the hotel timeline's change counts leave them out.
"""

from datetime import datetime, timedelta, timezone

import uber.site_sections.hotel_lottery_admin as hla
from uber.config import c
from uber.hotel.exports import (booking_export_data, changed_rooms_between,
hotel_activity_timeline, record_hotel_transfer,
store_export_file)
from uber.hotel.imports import (apply_cancellation_rows, apply_confirmation_rows,
import_confirmation_file)
from uber.models import initialize_db
from uber.models.tracking import Tracking

from tests.hotel.factories import (N, make_application, make_assignment,
make_attendee, make_hotel, make_inventory)


def _setup(session, **room):
hotel = make_hotel(session, name='Gaylord National')
inv = make_inventory(session, hotel, quantity=5)
me = make_attendee(session)
app = make_application(session, me, status=c.AWARDED)
ra = make_assignment(session, me, inv, check_in=N[1], check_out=N[3],
status=c.SECURED, lottery_application_id=app.id, **room)
return hotel, app, ra


def _clear_tracking(session, *ids):
"""Drop the automatic 'created' rows the factories leave behind (the
tracking listener queues them for the next flush, so flush first)."""
session.flush()
session.query(Tracking).filter(Tracking.fk_id.in_(ids)).delete(synchronize_session=False)
session.flush()


def _rows(session, ra, action):
return session.query(Tracking).filter_by(
model='RoomAssignment', fk_id=ra.id, action=action).all()


def test_spreadsheet_export_marks_each_room(session, no_cherrypy_session):
hotel, app, ra = _setup(session)
other = make_assignment(session, make_attendee(session), make_inventory(
session, make_hotel(session), quantity=5), check_in=N[1], check_out=N[3])
_, rows = booking_export_data(session, hotel.id)

store_export_file(session, hotel, b'x', 'gaylord_bookings.csv', 'text/csv',
source='admin', record_count=len(rows), exported_by='Hotel Admin',
assignment_ids=[row[0] for row in rows])

(mark,) = _rows(session, ra, c.HOTEL_EXPORT)
assert mark.data == 'Sent to Gaylord National: gaylord_bookings.csv'
assert mark.who == 'Hotel Admin'
assert not _rows(session, other, c.HOTEL_EXPORT), 'not in this file'


def test_import_marks_matched_rooms_even_when_nothing_changes(session, no_cherrypy_session, tmp_path, monkeypatch):
monkeypatch.setattr(c, 'UPLOADED_FILES_DIR', str(tmp_path), raising=False)
hotel, app, ra = _setup(session, hotel_confirmation_number='GN-1')
raw = f'confirmation_num,hotel_confirmation_number\n{app.confirmation_num},GN-1\n'.encode()

result = import_confirmation_file(session, raw, 'gaylord_confs.csv', hotel=hotel,
source='admin', uploaded_by='Hotel Admin')

assert result['updated'] == 0 and result['unchanged'] == 1
(mark,) = _rows(session, ra, c.HOTEL_IMPORT)
assert mark.data == 'Hotel import gaylord_confs.csv: confirmation # GN-1'
assert mark.who == 'Hotel Admin'
assert mark.links == f'hotel_import_file({result["record"].id})'


def test_shared_row_appliers_mark_rooms_only_when_applying(session, no_cherrypy_session):
hotel, app, ra = _setup(session)
row = {'confirmation_num': app.confirmation_num, 'hotel_confirmation_number': 'GN-2'}

apply_confirmation_rows(session, [row], apply_changes=False, filename='preview.csv')
assert not _rows(session, ra, c.HOTEL_IMPORT), 'a preview leaves no history'

apply_confirmation_rows(session, [row], apply_changes=True, filename='confs.csv')
apply_cancellation_rows(session, [{'confirmation_num': app.confirmation_num,
'cancellation_confirmation_number': 'CX-9'}],
apply_changes=True, filename='cancels.csv')

notes = sorted(m.data for m in _rows(session, ra, c.HOTEL_IMPORT))
assert notes == ['Hotel import cancels.csv: cancellation # CX-9',
'Hotel import confs.csv: confirmation # GN-2']
assert ra.status == c.CANCELLED


def test_history_page_merges_room_rows_in_time_order(session, no_cherrypy_session):
initialize_db()
hotel, app, ra = _setup(session)
_clear_tracking(session, app.id, ra.id)
t0 = datetime(2026, 9, 1, 12, tzinfo=timezone.utc)
session.add(Tracking(model='LotteryApplication', fk_id=app.id, action=c.UPDATED,
data="status='x -> y'", when=t0))
session.add(Tracking(model='RoomAssignment', fk_id=ra.id, action=c.UPDATED,
data="assigned_check_out_date='a -> b'", when=t0 + timedelta(days=2)))
session.flush()
record_hotel_transfer(session, c.HOTEL_EXPORT, [ra.id], 'Sent to Gaylord National: f.csv')
session.query(Tracking).filter_by(action=c.HOTEL_EXPORT, fk_id=ra.id).update(
{'when': t0 + timedelta(days=1)}, synchronize_session=False)

func = hla.Root.history
while hasattr(func, '__wrapped__'):
func = func.__wrapped__
ctx = func(hla.Root(), session, id=app.id)

assert [(t.model, t.action) for t in ctx['changes']] == [
('LotteryApplication', c.UPDATED),
('RoomAssignment', c.HOTEL_EXPORT),
('RoomAssignment', c.UPDATED)]
assert ctx['rooms'][ra.id] == ra


def test_marker_rows_are_not_counted_as_changes(session, no_cherrypy_session):
hotel, app, ra = _setup(session)
_clear_tracking(session, ra.id)
export = store_export_file(session, hotel, b'x', 'f.csv', 'text/csv',
source='admin', record_count=1, assignment_ids=[ra.id])
record_hotel_transfer(session, c.HOTEL_IMPORT, [ra.id], 'Hotel import g.csv: no numbers')

assert changed_rooms_between(session, hotel.id, None, None) == []
gaps = [row for row in hotel_activity_timeline(session, hotel.id) if row['kind'] == 'changes']
assert gaps == [], 'export/import markers are not room changes'
assert export.record_count == 1
Loading
Loading