Spaces:
Sleeping
Sleeping
Jack commited on
Commit ·
30d3465
1
Parent(s): 4fd350e
fixed hosting page
Browse files- app/main.py +43 -1
- app/static/styles.css +145 -1
- app/templates/host_dashboard.html +121 -43
- tests/test_app.py +56 -1
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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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>{{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
</article>
|
| 24 |
</section>
|
| 25 |
|
| 26 |
-
<section class="
|
| 27 |
-
{% for
|
| 28 |
-
<article class="panel">
|
| 29 |
-
<
|
| 30 |
-
|
| 31 |
-
<
|
| 32 |
-
<
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
<
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
</article>
|
| 45 |
{% endfor %}
|
| 46 |
</section>
|
| 47 |
|
| 48 |
<section class="panel">
|
| 49 |
-
<
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
<
|
| 53 |
-
|
| 54 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|