Skip to content

Commit d873816

Browse files
authored
Merge branch 'master' into james/claude-code-config
2 parents e58b6a8 + 149f36c commit d873816

20 files changed

Lines changed: 374 additions & 99 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 5.0.2 on 2026-02-13 23:08
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("market", "0004_rename_address_sublet_street_address_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="sublet",
15+
name="latitude",
16+
field=models.FloatField(blank=True, null=True),
17+
),
18+
migrations.AddField(
19+
model_name="sublet",
20+
name="longitude",
21+
field=models.FloatField(blank=True, null=True),
22+
),
23+
]

backend/market/models.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import hashlib
2+
import hmac
3+
import math
4+
5+
from django.conf import settings
16
from django.contrib.auth.models import AbstractUser
27
from django.core.exceptions import ValidationError
38
from django.core.validators import MinValueValidator
@@ -42,7 +47,6 @@ class Meta:
4247
def __str__(self):
4348
return f"Offer for {self.listing} made by {self.user}"
4449

45-
4650
class Category(models.Model):
4751
name = models.CharField(max_length=100, unique=True)
4852

@@ -130,12 +134,46 @@ class Sublet(Listing):
130134
baths = models.PositiveIntegerField()
131135
start_date = models.DateField()
132136
end_date = models.DateField()
137+
latitude = models.FloatField(null=True, blank=True)
138+
longitude = models.FloatField(null=True, blank=True)
133139

134140
def clean(self):
135141
super().clean()
136142
if self.start_date and self.end_date and self.start_date >= self.end_date:
137143
raise ValidationError({"end_date": "End date must be after start date"})
138144

145+
def _calculate_approximate_location(self, latitude, longitude):
146+
if latitude is None or longitude is None:
147+
return None, None
148+
149+
lat_str = f"{float(latitude):.9f}"
150+
lon_str = f"{float(longitude):.9f}"
151+
seed = hmac.new(
152+
settings.SECRET_KEY.encode(),
153+
f"{lat_str}{lon_str}".encode(),
154+
hashlib.sha256,
155+
).hexdigest()
156+
157+
offset_factor = int(seed[:8], 16) / 0xFFFFFFFF
158+
159+
offset_distance = 0.0005 + (offset_factor * 0.0013)
160+
angle = offset_factor * 2 * math.pi
161+
162+
lat_offset = offset_distance * math.sin(angle)
163+
lon_offset = offset_distance * math.cos(angle)
164+
165+
approx_lat = float(latitude) + lat_offset
166+
approx_lon = float(longitude) + lon_offset
167+
return approx_lat, approx_lon
168+
169+
@property
170+
def approximate_location(self):
171+
if self.latitude is not None and self.longitude is not None:
172+
approximate_location = self._calculate_approximate_location(
173+
self.latitude, self.longitude)
174+
return approximate_location
175+
return None, None
176+
139177
def save(self, *args, **kwargs):
140178
self.full_clean()
141179
super().save(*args, **kwargs)

backend/market/serializers.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
from django.contrib.auth import get_user_model
23
from django.core.exceptions import ValidationError as ModelValidationError
34
from profanity_check import predict
@@ -98,10 +99,32 @@ def get_condition(self, obj):
9899

99100

100101
class SubletDataSerializer(ModelSerializer):
102+
latitude = SerializerMethodField()
103+
longitude = SerializerMethodField()
104+
101105
class Meta:
102106
model = Sublet
103-
fields = ["street_address", "beds", "baths", "start_date", "end_date"]
107+
fields = [
108+
"street_address",
109+
"beds",
110+
"baths",
111+
"start_date",
112+
"end_date",
113+
"latitude",
114+
"longitude",
115+
]
116+
117+
def get_latitude(self, obj):
118+
approx_lat, _ = obj.approximate_location
119+
if approx_lat is not None:
120+
return float(approx_lat)
121+
return None
104122

123+
def get_longitude(self, obj):
124+
_, approx_lon = obj.approximate_location
125+
if approx_lon is not None:
126+
return float(approx_lon)
127+
return None
105128

106129
# Unified serializer for all listing types (Items and Sublets); used for CRUD operations
107130
class ListingSerializer(ListingTypeMixin, ModelSerializer):
@@ -265,12 +288,23 @@ def _create_item(self, validated_data, additional_data):
265288
def _create_sublet(self, validated_data, additional_data):
266289
tags = validated_data.pop("tags", None)
267290

291+
latitude = additional_data.get("latitude")
292+
longitude = additional_data.get("longitude")
293+
294+
295+
if latitude is not None:
296+
latitude = float(latitude)
297+
if longitude is not None:
298+
longitude = float(longitude)
299+
268300
sublet = Sublet.objects.create(
269301
street_address=additional_data.get("street_address"),
270302
beds=additional_data.get("beds"),
271303
baths=additional_data.get("baths"),
272304
start_date=additional_data.get("start_date"),
273305
end_date=additional_data.get("end_date"),
306+
latitude=latitude,
307+
longitude=longitude,
274308
**validated_data,
275309
)
276310

@@ -323,10 +357,16 @@ def _update_item(self, instance, additional_data):
323357

324358
def _update_sublet(self, instance, additional_data):
325359
sublet = instance.sublet
326-
sublet_fields = ["street_address", "beds", "baths", "start_date", "end_date"]
327-
for field in sublet_fields:
360+
str_fields = ["street_address", "beds", "baths", "start_date", "end_date"]
361+
float_fields = ["latitude", "longitude"]
362+
for field in str_fields:
328363
if field in additional_data:
329364
setattr(sublet, field, additional_data[field])
365+
366+
for field in float_fields:
367+
if field in additional_data:
368+
value = additional_data[field]
369+
setattr(sublet, field, float(value) if value is not None else None)
330370
sublet.full_clean()
331371
sublet.save()
332372

backend/market/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def retrieve(self, request, *args, **kwargs):
189189
serializer_class = ListingSerializer
190190
else:
191191
serializer_class = ListingSerializerPublic
192-
serializer = serializer_class(instance)
192+
serializer = serializer_class(instance, context={"request": request})
193193
return Response(serializer.data)
194194

195195

frontend/app/items/[id]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
23
import { getListingOrNotFound } from "@/lib/actions";
4+
import { queryKeys } from "@/lib/queryKeys";
35

46
export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) {
57
const { id } = await params;
68
const item = await getListingOrNotFound(id);
79

8-
return <ListingDetail listing={item} initialIsFavorited={item.is_favorited ?? false} />;
10+
// seed the cache with the already-fetched listing (no additional fetch).
11+
// We use setQueryData instead of prefetchQuery to preserve the notFound() throw above.
12+
const queryClient = new QueryClient();
13+
queryClient.setQueryData(queryKeys.listing(item.id), item);
14+
15+
return (
16+
<HydrationBoundary state={dehydrate(queryClient)}>
17+
<ListingDetail listingId={item.id} />
18+
</HydrationBoundary>
19+
);
920
}

frontend/app/page.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { getCurrentUser, getItems } from "@/lib/actions";
3+
import { queryKeys } from "@/lib/queryKeys";
24
import { PageHeader } from "@/components/common/PageHeader";
35
import { ItemFilters } from "@/components/filters/ItemFilters";
46
import { ListingsGrid } from "@/components/listings/ListingsGrid";
57

68
export default async function ItemsPage() {
7-
const [items, currentUser] = await Promise.all([getItems({ pageParam: 1 }), getCurrentUser()]);
9+
const queryClient = new QueryClient();
10+
11+
const [, currentUser] = await Promise.all([
12+
queryClient.fetchInfiniteQuery({
13+
queryKey: queryKeys.listings("items"),
14+
queryFn: () => getItems({ pageParam: 1 }),
15+
initialPageParam: 1,
16+
getNextPageParam: (lastPage, allPages) =>
17+
lastPage.results.length > 0 ? allPages.length + 1 : undefined,
18+
pages: 1,
19+
}),
20+
getCurrentUser(),
21+
]);
822

923
return (
1024
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
1125
<PageHeader title="Browse Items" description="Discover the latest items on sale at Penn" />
1226
<ItemFilters />
13-
<ListingsGrid type="items" listings={items} currentUser={currentUser} />
27+
<HydrationBoundary state={dehydrate(queryClient)}>
28+
<ListingsGrid type="items" currentUser={currentUser} />
29+
</HydrationBoundary>
1430
</div>
1531
);
1632
}

frontend/app/sublets/[id]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
23
import { getListingOrNotFound } from "@/lib/actions";
4+
import { queryKeys } from "@/lib/queryKeys";
35

46
export default async function SubletPage({ params }: { params: Promise<{ id: string }> }) {
57
const { id } = await params;
68
const sublet = await getListingOrNotFound(id);
79

8-
return <ListingDetail listing={sublet} initialIsFavorited={sublet.is_favorited ?? false} />;
10+
// seed the cache with the already-fetched listing (no additional fetch).
11+
// We use setQueryData instead of prefetchQuery to preserve the notFound() throw above.
12+
const queryClient = new QueryClient();
13+
queryClient.setQueryData(queryKeys.listing(sublet.id), sublet);
14+
15+
return (
16+
<HydrationBoundary state={dehydrate(queryClient)}>
17+
<ListingDetail listingId={sublet.id} />
18+
</HydrationBoundary>
19+
);
920
}

frontend/app/sublets/page.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { getCurrentUser, getSublets } from "@/lib/actions";
3+
import { queryKeys } from "@/lib/queryKeys";
24
import { PageHeader } from "@/components/common/PageHeader";
35
import { SubletFilters } from "@/components/filters/SubletFilters";
46
import { ListingsGrid } from "@/components/listings/ListingsGrid";
57

68
export default async function SubletsPage() {
7-
const [sublets, currentUser] = await Promise.all([
8-
getSublets({ pageParam: 1 }),
9+
const queryClient = new QueryClient();
10+
11+
const [, currentUser] = await Promise.all([
12+
queryClient.fetchInfiniteQuery({
13+
queryKey: queryKeys.listings("sublets"),
14+
queryFn: () => getSublets({ pageParam: 1 }),
15+
initialPageParam: 1,
16+
getNextPageParam: (lastPage, allPages) =>
17+
lastPage.results.length > 0 ? allPages.length + 1 : undefined,
18+
pages: 1,
19+
}),
920
getCurrentUser(),
1021
]);
1122

1223
return (
1324
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
1425
<PageHeader title="Browse Sublets" description="Find your perfect housing solution at Penn" />
1526
<SubletFilters />
16-
<ListingsGrid type="sublets" listings={sublets} currentUser={currentUser} />
27+
<HydrationBoundary state={dehydrate(queryClient)}>
28+
<ListingsGrid type="sublets" currentUser={currentUser} />
29+
</HydrationBoundary>
1730
</div>
1831
);
1932
}

frontend/components/listings/ListingsGrid.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,15 @@ import { Spinner } from "@/components/ui/spinner";
55
import { ListingsCard } from "@/components/listings/ListingsCard";
66
import { NoListingsFound } from "@/components/listings/NoListingsFound";
77
import { useListings } from "@/hooks/useListings";
8-
import { Item, PaginatedResponse, Sublet, User } from "@/lib/types";
8+
import { ListingTypes, User } from "@/lib/types";
99

10-
type Props =
11-
| {
12-
type: "items";
13-
listings: PaginatedResponse<Item>;
14-
currentUser: User;
15-
}
16-
| {
17-
type: "sublets";
18-
listings: PaginatedResponse<Sublet>;
19-
currentUser: User;
20-
};
10+
type Props = {
11+
type: ListingTypes;
12+
currentUser: User;
13+
};
2114

22-
export const ListingsGrid = ({ type, listings, currentUser }: Props) => {
23-
const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type, listings });
15+
export const ListingsGrid = ({ type, currentUser }: Props) => {
16+
const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type });
2417

2518
const totalResults = data?.pages.reduce((acc, page) => acc + page.results.length, 0) || 0;
2619
const isEmpty = totalResults === 0;

0 commit comments

Comments
 (0)