Jack commited on
Commit
30d3465
·
1 Parent(s): 4fd350e

fixed hosting page

Browse files
app/main.py CHANGED
@@ -718,6 +718,7 @@ def host_dashboard(request: Request, db: Session = Depends(get_db)):
718
  current_user = require_host(request, db, "/host")
719
  if isinstance(current_user, RedirectResponse):
720
  return current_user
 
721
  listings = db.scalars(
722
  select(Listing)
723
  .where(Listing.host_id == current_user.id)
@@ -731,10 +732,51 @@ def host_dashboard(request: Request, db: Session = Depends(get_db)):
731
  .options(joinedload(Booking.guest), joinedload(Booking.listing))
732
  .order_by(Booking.check_in)
733
  ).all()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
  return templates.TemplateResponse(
735
  request=request,
736
  name="host_dashboard.html",
737
- context=build_context(request, db, listings=listings, reservations=reservations),
 
 
 
 
 
 
 
 
 
 
738
  )
739
 
740
 
 
718
  current_user = require_host(request, db, "/host")
719
  if isinstance(current_user, RedirectResponse):
720
  return current_user
721
+ today = date.today()
722
  listings = db.scalars(
723
  select(Listing)
724
  .where(Listing.host_id == current_user.id)
 
732
  .options(joinedload(Booking.guest), joinedload(Booking.listing))
733
  .order_by(Booking.check_in)
734
  ).all()
735
+ reservations_by_listing: dict[int, list[Booking]] = {}
736
+ for booking in reservations:
737
+ reservations_by_listing.setdefault(booking.listing_id, []).append(booking)
738
+
739
+ confirmed_reservation_count = 0
740
+ blocked_window_count = 0
741
+ upcoming_reservations = [
742
+ booking for booking in reservations if booking.status == "confirmed" and booking.check_out >= today
743
+ ]
744
+ host_cards = []
745
+ for listing in listings:
746
+ listing_reservations = reservations_by_listing.get(listing.id, [])
747
+ confirmed_count = sum(1 for booking in listing_reservations if booking.status == "confirmed")
748
+ confirmed_reservation_count += confirmed_count
749
+ blocked_windows = sorted(listing.blocked_ranges, key=lambda blocked_window: blocked_window["start"])
750
+ blocked_window_count += len(blocked_windows)
751
+ upcoming_listing_reservations = [
752
+ booking for booking in listing_reservations if booking.status == "confirmed" and booking.check_out >= today
753
+ ]
754
+ host_cards.append(
755
+ {
756
+ "listing": listing,
757
+ "blocked_windows": blocked_windows,
758
+ "confirmed_count": confirmed_count,
759
+ "upcoming_count": len(upcoming_listing_reservations),
760
+ "next_arrival": min(
761
+ (booking.check_in for booking in upcoming_listing_reservations),
762
+ default=None,
763
+ ),
764
+ }
765
+ )
766
  return templates.TemplateResponse(
767
  request=request,
768
  name="host_dashboard.html",
769
+ context=build_context(
770
+ request,
771
+ db,
772
+ listings=listings,
773
+ reservations=reservations,
774
+ host_cards=host_cards,
775
+ today=today,
776
+ blocked_window_count=blocked_window_count,
777
+ confirmed_reservation_count=confirmed_reservation_count,
778
+ upcoming_reservations=upcoming_reservations,
779
+ ),
780
  )
781
 
782
 
app/static/styles.css CHANGED
@@ -67,6 +67,7 @@ pre {
67
  display: flex;
68
  align-items: center;
69
  justify-content: space-between;
 
70
  gap: 20px;
71
  padding: 18px 22px;
72
  border-radius: 999px;
@@ -77,6 +78,8 @@ pre {
77
  display: flex;
78
  align-items: center;
79
  gap: 14px;
 
 
80
  }
81
 
82
  .brand strong {
@@ -127,6 +130,8 @@ pre {
127
  .main-nav {
128
  flex-wrap: wrap;
129
  justify-content: center;
 
 
130
  }
131
 
132
  .main-nav a,
@@ -136,6 +141,17 @@ pre {
136
 
137
  .nav-actions {
138
  justify-content: flex-end;
 
 
 
 
 
 
 
 
 
 
 
139
  }
140
 
141
  .primary-button,
@@ -795,6 +811,113 @@ textarea {
795
  display: grid;
796
  }
797
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
  .code-block {
799
  padding: 14px 16px;
800
  border-radius: 18px;
@@ -804,7 +927,7 @@ textarea {
804
  }
805
 
806
  .stats-row {
807
- grid-template-columns: repeat(3, minmax(0, 1fr));
808
  }
809
 
810
  .stat-card {
@@ -926,6 +1049,7 @@ td {
926
  }
927
 
928
  .site-header {
 
929
  border-radius: 28px;
930
  }
931
 
@@ -954,6 +1078,17 @@ td {
954
  .listing-gallery .hero-image {
955
  grid-row: auto;
956
  }
 
 
 
 
 
 
 
 
 
 
 
957
  }
958
 
959
  @media (max-width: 720px) {
@@ -979,4 +1114,13 @@ td {
979
  width: 100%;
980
  height: 200px;
981
  }
 
 
 
 
 
 
 
 
 
982
  }
 
67
  display: flex;
68
  align-items: center;
69
  justify-content: space-between;
70
+ flex-wrap: wrap;
71
  gap: 20px;
72
  padding: 18px 22px;
73
  border-radius: 999px;
 
78
  display: flex;
79
  align-items: center;
80
  gap: 14px;
81
+ flex: 1 1 220px;
82
+ min-width: 0;
83
  }
84
 
85
  .brand strong {
 
130
  .main-nav {
131
  flex-wrap: wrap;
132
  justify-content: center;
133
+ flex: 1 1 280px;
134
+ min-width: 0;
135
  }
136
 
137
  .main-nav a,
 
141
 
142
  .nav-actions {
143
  justify-content: flex-end;
144
+ flex: 1 1 280px;
145
+ flex-wrap: wrap;
146
+ min-width: 0;
147
+ }
148
+
149
+ .nav-actions form {
150
+ display: flex;
151
+ }
152
+
153
+ .persona-pill {
154
+ flex-wrap: wrap;
155
  }
156
 
157
  .primary-button,
 
811
  display: grid;
812
  }
813
 
814
+ .host-listings-grid,
815
+ .host-card-body,
816
+ .host-card-sections,
817
+ .host-reservation-list,
818
+ .host-reservation-meta {
819
+ display: grid;
820
+ gap: 18px;
821
+ }
822
+
823
+ .host-listings-grid {
824
+ grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
825
+ }
826
+
827
+ .host-card {
828
+ display: grid;
829
+ grid-template-columns: minmax(0, 280px) minmax(0, 1fr);
830
+ gap: 20px;
831
+ align-items: start;
832
+ }
833
+
834
+ .host-card-media {
835
+ display: block;
836
+ }
837
+
838
+ .host-card-heading {
839
+ margin-bottom: 0;
840
+ }
841
+
842
+ .host-card-heading h2 {
843
+ margin-bottom: 6px;
844
+ }
845
+
846
+ .host-metric-row {
847
+ display: grid;
848
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
849
+ gap: 12px;
850
+ }
851
+
852
+ .host-metric,
853
+ .host-subpanel,
854
+ .host-reservation-card {
855
+ border-radius: 20px;
856
+ border: 1px solid var(--line);
857
+ background: rgba(255, 255, 255, 0.72);
858
+ }
859
+
860
+ .host-metric {
861
+ display: grid;
862
+ gap: 4px;
863
+ padding: 14px 16px;
864
+ }
865
+
866
+ .host-metric strong {
867
+ font-size: 1.4rem;
868
+ font-family: "Iowan Old Style", "Georgia", serif;
869
+ }
870
+
871
+ .host-metric span,
872
+ .host-inline-note {
873
+ color: var(--muted);
874
+ }
875
+
876
+ .host-subpanel {
877
+ display: grid;
878
+ gap: 12px;
879
+ padding: 16px;
880
+ }
881
+
882
+ .host-subpanel h3,
883
+ .host-reservation-header h3 {
884
+ margin: 0;
885
+ font-family: "Iowan Old Style", "Georgia", serif;
886
+ }
887
+
888
+ .host-form-row {
889
+ display: grid;
890
+ grid-template-columns: repeat(2, minmax(0, 1fr));
891
+ gap: 12px;
892
+ }
893
+
894
+ .host-chip-list {
895
+ display: flex;
896
+ flex-wrap: wrap;
897
+ gap: 10px;
898
+ }
899
+
900
+ .host-inline-note {
901
+ margin: 0;
902
+ }
903
+
904
+ .host-reservation-card {
905
+ display: grid;
906
+ gap: 16px;
907
+ padding: 18px;
908
+ }
909
+
910
+ .host-reservation-header {
911
+ display: flex;
912
+ align-items: start;
913
+ justify-content: space-between;
914
+ gap: 16px;
915
+ }
916
+
917
+ .host-reservation-meta {
918
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
919
+ }
920
+
921
  .code-block {
922
  padding: 14px 16px;
923
  border-radius: 18px;
 
927
  }
928
 
929
  .stats-row {
930
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
931
  }
932
 
933
  .stat-card {
 
1049
  }
1050
 
1051
  .site-header {
1052
+ display: grid;
1053
  border-radius: 28px;
1054
  }
1055
 
 
1078
  .listing-gallery .hero-image {
1079
  grid-row: auto;
1080
  }
1081
+
1082
+ .main-nav,
1083
+ .nav-actions {
1084
+ width: 100%;
1085
+ flex-direction: row;
1086
+ justify-content: flex-start;
1087
+ }
1088
+
1089
+ .host-card {
1090
+ grid-template-columns: 1fr;
1091
+ }
1092
  }
1093
 
1094
  @media (max-width: 720px) {
 
1114
  width: 100%;
1115
  height: 200px;
1116
  }
1117
+
1118
+ .host-form-row {
1119
+ grid-template-columns: 1fr;
1120
+ }
1121
+
1122
+ .host-reservation-header {
1123
+ flex-direction: column;
1124
+ align-items: flex-start;
1125
+ }
1126
  }
app/templates/host_dashboard.html CHANGED
@@ -6,6 +6,7 @@
6
  <p class="eyebrow">Hosting Console</p>
7
  <h1>Manage listings and reservations</h1>
8
  </div>
 
9
  </section>
10
 
11
  <section class="stats-row">
@@ -19,57 +20,134 @@
19
  </article>
20
  <article class="stat-card">
21
  <span>Confirmed</span>
22
- <strong>{{ reservations|selectattr("status", "equalto", "confirmed")|list|length }}</strong>
 
 
 
 
23
  </article>
24
  </section>
25
 
26
- <section class="three-up">
27
- {% for listing in listings %}
28
- <article class="panel">
29
- <img class="panel-image" src="{{ listing.primary_image }}" alt="{{ listing.title }}" />
30
- <h2>{{ listing.title }}</h2>
31
- <p>{{ listing.city }} · {{ listing.neighborhood }}</p>
32
- <p>${{ "%.0f"|format(listing.price_per_night) }} / night · {{ listing.max_guests }} guests max</p>
33
- <form class="stacked-form" method="post" action="/host/listings/{{ listing.id }}/block">
34
- <label>
35
- Block start
36
- <input type="date" name="start_date" required />
37
- </label>
38
- <label>
39
- Block end
40
- <input type="date" name="end_date" required />
41
- </label>
42
- <button class="secondary-button wide-button" type="submit">Block Dates</button>
43
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  </article>
45
  {% endfor %}
46
  </section>
47
 
48
  <section class="panel">
49
- <h2>Reservations</h2>
50
- <div class="table-wrap">
51
- <table>
52
- <thead>
53
- <tr>
54
- <th>Code</th>
55
- <th>Listing</th>
56
- <th>Guest</th>
57
- <th>Dates</th>
58
- <th>Status</th>
59
- </tr>
60
- </thead>
61
- <tbody>
62
- {% for booking in reservations %}
63
- <tr>
64
- <td>{{ booking.confirmation_code }}</td>
65
- <td>{{ booking.listing.title }}</td>
66
- <td>{{ booking.guest.name }}</td>
67
- <td>{{ booking.check_in.isoformat() }} to {{ booking.check_out.isoformat() }}</td>
68
- <td><span class="status-pill {{ booking.status }}">{{ booking.status }}</span></td>
69
- </tr>
70
- {% endfor %}
71
- </tbody>
72
- </table>
73
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  </section>
 
 
 
 
 
 
 
75
  {% endblock %}
 
6
  <p class="eyebrow">Hosting Console</p>
7
  <h1>Manage listings and reservations</h1>
8
  </div>
9
+ <span class="helper-copy">Review availability, add calendar blocks, and keep upcoming guest details visible at every screen size.</span>
10
  </section>
11
 
12
  <section class="stats-row">
 
20
  </article>
21
  <article class="stat-card">
22
  <span>Confirmed</span>
23
+ <strong>{{ confirmed_reservation_count }}</strong>
24
+ </article>
25
+ <article class="stat-card">
26
+ <span>Blocked windows</span>
27
+ <strong>{{ blocked_window_count }}</strong>
28
  </article>
29
  </section>
30
 
31
+ <section class="host-listings-grid">
32
+ {% for card in host_cards %}
33
+ <article class="panel host-card">
34
+ <a class="host-card-media" href="{{ request.app.url_path_for('listing_detail', slug=card.listing.slug) }}">
35
+ <img class="panel-image" src="{{ card.listing.primary_image }}" alt="{{ card.listing.title }}" />
36
+ </a>
37
+ <div class="host-card-body">
38
+ <div class="panel-heading host-card-heading">
39
+ <div>
40
+ <p class="eyebrow compact">{{ card.listing.city }}</p>
41
+ <h2>{{ card.listing.title }}</h2>
42
+ <p>{{ card.listing.neighborhood }} · ${{ "%.0f"|format(card.listing.price_per_night) }} / night · {{ card.listing.max_guests }} guests max</p>
43
+ </div>
44
+ <a class="inline-link" href="{{ request.app.url_path_for('listing_detail', slug=card.listing.slug) }}">Open listing</a>
45
+ </div>
46
+
47
+ <div class="host-metric-row">
48
+ <article class="host-metric">
49
+ <strong>{{ card.confirmed_count }}</strong>
50
+ <span>Confirmed reservations</span>
51
+ </article>
52
+ <article class="host-metric">
53
+ <strong>{{ card.upcoming_count }}</strong>
54
+ <span>Upcoming arrivals</span>
55
+ </article>
56
+ <article class="host-metric">
57
+ <strong>{{ card.blocked_windows|length }}</strong>
58
+ <span>Calendar blocks</span>
59
+ </article>
60
+ </div>
61
+
62
+ <div class="host-card-sections">
63
+ <section class="host-subpanel">
64
+ <h3>Block dates</h3>
65
+ <form class="stacked-form" method="post" action="/host/listings/{{ card.listing.id }}/block">
66
+ <div class="host-form-row">
67
+ <label>
68
+ Block start
69
+ <input type="date" name="start_date" min="{{ today.isoformat() }}" required />
70
+ </label>
71
+ <label>
72
+ Block end
73
+ <input type="date" name="end_date" min="{{ today.isoformat() }}" required />
74
+ </label>
75
+ </div>
76
+ <button class="secondary-button wide-button" type="submit">Save block</button>
77
+ </form>
78
+ </section>
79
+
80
+ <section class="host-subpanel">
81
+ <h3>Existing blocks</h3>
82
+ {% if card.blocked_windows %}
83
+ <div class="host-chip-list">
84
+ {% for blocked_window in card.blocked_windows %}
85
+ <span class="chip muted">{{ blocked_window.start }} to {{ blocked_window.end }}</span>
86
+ {% endfor %}
87
+ </div>
88
+ {% else %}
89
+ <p class="helper-copy">No blocked dates yet for this listing.</p>
90
+ {% endif %}
91
+ {% if card.next_arrival %}
92
+ <p class="host-inline-note">Next arrival: {{ card.next_arrival.isoformat() }}</p>
93
+ {% endif %}
94
+ </section>
95
+ </div>
96
+ </div>
97
  </article>
98
  {% endfor %}
99
  </section>
100
 
101
  <section class="panel">
102
+ <div class="panel-heading">
103
+ <div>
104
+ <h2>Upcoming reservations</h2>
105
+ <p class="helper-copy">Guest details stay readable without relying on a horizontal scroll table.</p>
106
+ </div>
107
+ <span class="helper-copy">{{ upcoming_reservations|length }} upcoming stay{{ "" if upcoming_reservations|length == 1 else "s" }}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  </div>
109
+
110
+ {% if upcoming_reservations %}
111
+ <div class="host-reservation-list">
112
+ {% for booking in upcoming_reservations %}
113
+ <article class="host-reservation-card">
114
+ <div class="host-reservation-header">
115
+ <div>
116
+ <p class="eyebrow compact">Reservation {{ booking.confirmation_code }}</p>
117
+ <h3>{{ booking.listing.title }}</h3>
118
+ <p>{{ booking.guest.name }} · {{ booking.guest.hometown }}</p>
119
+ </div>
120
+ <span class="status-pill {{ booking.status }}">{{ booking.status }}</span>
121
+ </div>
122
+ <div class="host-reservation-meta">
123
+ <div class="trip-detail-card">
124
+ <strong>Dates</strong>
125
+ <span>{{ booking.check_in.isoformat() }} to {{ booking.check_out.isoformat() }}</span>
126
+ </div>
127
+ <div class="trip-detail-card">
128
+ <strong>Stay details</strong>
129
+ <span>{{ booking.nights }} nights · {{ booking.guests }} guests</span>
130
+ </div>
131
+ <div class="trip-detail-card">
132
+ <strong>Total</strong>
133
+ <span>${{ "%.0f"|format(booking.total_price) }}</span>
134
+ </div>
135
+ </div>
136
+ </article>
137
+ {% endfor %}
138
+ </div>
139
+ {% else %}
140
+ <div class="empty-state">
141
+ <h3>No upcoming reservations.</h3>
142
+ <p>New guest bookings will appear here with dates, totals, and stay details.</p>
143
+ </div>
144
+ {% endif %}
145
  </section>
146
+
147
+ {% if not host_cards %}
148
+ <section class="empty-state">
149
+ <h3>No listings yet.</h3>
150
+ <p>Switch to a seeded host account with listings to manage calendar blocks and reservations.</p>
151
+ </section>
152
+ {% endif %}
153
  {% endblock %}
tests/test_app.py CHANGED
@@ -3,9 +3,17 @@ from datetime import datetime
3
 
4
  from sqlalchemy import select
5
  from sqlalchemy.orm import joinedload
 
6
 
7
  from app.database import SessionLocal
8
- from app.main import _parse_optional_float, _parse_optional_int, merge_ranges
 
 
 
 
 
 
 
9
  from app.models import Booking, Listing, TaskDefinition
10
  from app.seed import reset_database
11
  from app.tasks import evaluate_task
@@ -15,6 +23,22 @@ class WebArenaAirbnbTests(unittest.TestCase):
15
  def setUp(self):
16
  reset_database()
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def test_seeded_task_catalog_exists(self):
19
  with SessionLocal() as db:
20
  tasks = db.scalars(
@@ -74,6 +98,37 @@ class WebArenaAirbnbTests(unittest.TestCase):
74
  self.assertEqual(_parse_optional_int("4"), 4)
75
  self.assertEqual(_parse_optional_float("4.5"), 4.5)
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  if __name__ == "__main__":
79
  unittest.main()
 
3
 
4
  from sqlalchemy import select
5
  from sqlalchemy.orm import joinedload
6
+ from starlette.requests import Request
7
 
8
  from app.database import SessionLocal
9
+ from app.main import (
10
+ app,
11
+ _parse_optional_float,
12
+ _parse_optional_int,
13
+ block_listing_dates,
14
+ host_dashboard,
15
+ merge_ranges,
16
+ )
17
  from app.models import Booking, Listing, TaskDefinition
18
  from app.seed import reset_database
19
  from app.tasks import evaluate_task
 
23
  def setUp(self):
24
  reset_database()
25
 
26
+ def _build_request(self, path: str, user_id: int | None = None, method: str = "GET") -> Request:
27
+ scope = {
28
+ "type": "http",
29
+ "method": method,
30
+ "path": path,
31
+ "headers": [],
32
+ "query_string": b"",
33
+ "session": {"user_id": user_id} if user_id is not None else {},
34
+ "client": ("127.0.0.1", 12345),
35
+ "server": ("testserver", 80),
36
+ "scheme": "http",
37
+ "http_version": "1.1",
38
+ "app": app,
39
+ }
40
+ return Request(scope)
41
+
42
  def test_seeded_task_catalog_exists(self):
43
  with SessionLocal() as db:
44
  tasks = db.scalars(
 
98
  self.assertEqual(_parse_optional_int("4"), 4)
99
  self.assertEqual(_parse_optional_float("4.5"), 4.5)
100
 
101
+ def test_host_dashboard_renders_host_management_sections(self):
102
+ with SessionLocal() as db:
103
+ response = host_dashboard(self._build_request("/host", user_id=2), db)
104
+ body = response.body.decode()
105
+
106
+ self.assertIn("Manage listings and reservations", body)
107
+ self.assertIn("Existing blocks", body)
108
+ self.assertIn("Upcoming reservations", body)
109
+ self.assertIn("Annex Glass Loft", body)
110
+
111
+ def test_block_listing_dates_updates_host_calendar_and_redirects(self):
112
+ with SessionLocal() as db:
113
+ response = block_listing_dates(
114
+ request=self._build_request("/host/listings/1/block", user_id=2, method="POST"),
115
+ listing_id=1,
116
+ start_date="2026-04-04",
117
+ end_date="2026-04-07",
118
+ db=db,
119
+ )
120
+ listing = db.get(Listing, 1)
121
+
122
+ self.assertEqual(response.status_code, 303)
123
+ self.assertEqual(response.headers["location"], "/host?notice=Blocked%20dates%20on%20Annex%20Glass%20Loft.")
124
+ self.assertEqual(
125
+ listing.blocked_ranges,
126
+ [
127
+ {"start": "2026-04-03", "end": "2026-04-07"},
128
+ {"start": "2026-06-12", "end": "2026-06-15"},
129
+ ],
130
+ )
131
+
132
 
133
  if __name__ == "__main__":
134
  unittest.main()