Skip to content

Commit fcef501

Browse files
[SCRUM-48] Integrate search endpoint (#44)
* Implemented basic search functionality on currently retrieved items * Profile navigation logic * Rename fetching profile * Refactor for readability * Change back to using user id for profiles * Fix get search * Some refactoring for readability * Implement get search results * revert email changes * Removed unused imports and duplicate defn * remove response model validation * Remove unnecessary reponse validation * Make it work! * fuzzy searching * for convenience --------- Co-authored-by: Vallens Kho <69379333+V-X-L-U@users.noreply.github.com> Co-authored-by: Vallens <vallens.kho@gmail.com>
1 parent 61906d6 commit fcef501

File tree

16 files changed

+376
-315
lines changed

16 files changed

+376
-315
lines changed

backend/app/main.py

Lines changed: 96 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,10 @@ async def validation_exception_handler(request, exc):
127127

128128
@app.get(
129129
'/search',
130-
response_model=SearchGetResponse,
131-
responses={'500': {'model': ErrorResponse}},
130+
response_model=None,
131+
responses={
132+
'200': {'model': SearchGetResponse},
133+
'500': {'model': ErrorResponse}},
132134
)
133135
async def get_search(
134136
# Used in for full text search
@@ -153,86 +155,94 @@ async def get_search(
153155
"""
154156
search listings
155157
"""
156-
price_order = 0
157158
limit = min(max(limit, 1), 30)
158-
try:
159-
if query:
160-
# Full text search
161-
search_stage = {
162-
"$search": {
163-
"index": "Full_text_index_listings",
164-
"text": {
165-
"query": query,
166-
"path": {
167-
"wildcard": "*",
168-
},
159+
160+
# Selecting which order to sort by
161+
# Default to most recent first
162+
sort_field = {"date_posted": -1}
163+
if price_type == "price-high-to-low":
164+
sort_field = {"price": -1}
165+
elif price_type == "price-low-to-high":
166+
sort_field = {"price": 1}
167+
elif price_type == "date-recent":
168+
sort_field = {"date_posted": -1}
169+
170+
if query:
171+
# Full text search
172+
search_stage = {
173+
"$search": {
174+
"index": "full_text_index_listings_11APR2025",
175+
"text": {
176+
"query": query,
177+
"path": {
178+
"wildcard": "*",
169179
},
180+
"fuzzy": {
181+
"maxEdits": 2,
182+
"prefixLength": 0,
183+
"maxExpansions": 50
184+
}
170185
},
171-
}
172-
else:
173-
search_stage = {"$search": {
174-
"index": "Full_text_index_listings",
186+
"sort": sort_field,
187+
},
188+
}
189+
else:
190+
search_stage = {
191+
"$search": {
192+
"index": "full_text_index_listings_11APR2025",
175193
# This is always true; essentially just gets all documents
176194
"exists": {"path": "_id"},
177-
}}
178-
179-
if next:
180-
search_stage["$search"]["searchAfter"] = next
195+
"sort": sort_field,
196+
},
197+
}
181198

182-
# Selecting which order to sort by
183-
# Default to most recent first
184-
sort_stage = {"$sort": {"date_posted": -1}}
185-
if price_type == "price-high-to-low":
186-
sort_stage = {"$sort": {"price": -1}}
187-
elif price_type == "price-low-to-high":
188-
sort_stage = {"$sort": {"price": 1}}
189-
elif price_type == "date-recent":
190-
sort_stage = {"$sort": {"date_posted": -1}}
199+
if next:
200+
search_stage["$search"]["searchAfter"] = next
191201

192-
pipeline = [
193-
search_stage,
194-
# Price filter with stable sort using _id as tiebreaker
195-
{"$sort": {"price": price_order, "_id": 1}
196-
} if price_order in [-1, 1] else {"$sort": {"_id": 1}},
197-
# Lower and upper limits of price (0 -> +inf by default)
198-
{
199-
"$match": {
200-
"price": {"$gte": lower_price, "$lte": upper_price}}} if price_type is not None else None,
201-
# Condition filter
202-
{
203-
"$match": {
204-
"condition": condition}} if condition else None,
205-
# Date filter
206-
{
207-
"$match": {
208-
"date_posted": {"$gte": dateutil_parse(date_range).isoformat()}}}
209-
if date_range is not None else None,
210-
{
211-
"$match": {
212-
"campus": campus}} if campus else None,
213-
{"$limit": limit},
214-
{"$project": {
215-
"id": {"$toString": "$_id"},
216-
"title": 1,
217-
"price": 1,
218-
"description": 1,
219-
"seller_id": 1,
220-
"pictures": 1,
221-
"condition": 1,
222-
"category": 1,
223-
"date_posted": 1,
224-
"campus": 1,
225-
# Provide pagination tokens generated by Atlas Search
226-
# so that clients can provide this on next query
227-
"paginationToken": {"$meta": "searchSequenceToken"},
228-
}}
229-
]
202+
pipeline = [
203+
search_stage,
204+
# Lower and upper limits of price (0 -> +inf by default)
205+
{
206+
"$match": {
207+
"price": {"$gte": lower_price, "$lte": upper_price}}} if price_type is not None else None,
208+
# Condition filter
209+
{
210+
"$match": {
211+
"condition": condition}} if condition else None,
212+
# Date filter
213+
{
214+
"$match": {
215+
"date_posted": {"$gte": dateutil_parse(date_range).isoformat()}}}
216+
if date_range is not None else None,
217+
# Campus filter
218+
{
219+
"$match": {
220+
"campus": campus}} if campus else None,
221+
{"$limit": limit},
222+
{"$project": {
223+
"id": {"$toString": "$_id"},
224+
"title": 1,
225+
"price": 1,
226+
"description": 1,
227+
"seller_id": 1,
228+
"pictures": 1,
229+
"condition": 1,
230+
"category": 1,
231+
"date_posted": 1,
232+
"campus": 1,
233+
# Provide pagination tokens generated by Atlas Search
234+
# so that clients can provide this on next query
235+
"paginationToken": {"$meta": "searchSequenceToken"},
236+
}}
237+
]
238+
239+
# Clean the pipeline of any None values
240+
pipeline = [stage for stage in pipeline if stage is not None]
230241

231-
# Clean the pipeline of any None values
232-
pipeline = [stage for stage in pipeline if stage is not None]
242+
try:
233243

234244
cursor = listings_collection.aggregate(pipeline)
235-
listings = await cursor.to_list(length=limit)
245+
listings = await cursor.to_list()
236246

237247
# Prepping to return
238248
response_data = [
@@ -251,17 +261,19 @@ async def get_search(
251261
for listing in listings
252262
]
253263

264+
print([(listing["_id"], listing["price"]) for listing in listings])
265+
254266
if not response_data:
255267
return SearchGetResponse(listings=[],
256268
total=0,
257-
next_page_token=None)
269+
next_page_token="")
258270

259271
listings = ListingsGetResponseAll(
260272
listings=response_data,
261273
total=len(response_data),
262274
next_page_token=listings[-1].get("paginationToken")
263275
)
264-
276+
print(f"Next page token: {listings.next_page_token}")
265277
return SearchGetResponse(listings=listings.listings,
266278
total=listings.total,
267279
next_page_token=listings.next_page_token)
@@ -327,6 +339,7 @@ async def get_listing(listing_id: str) -> Union[ListingGetResponseItem, ErrorRes
327339
"campus": 1,
328340
"date_posted": 1,
329341
"seller_name": "$seller_doc.display_name",
342+
"seller_email": "$seller_doc.email",
330343
}
331344
}
332345
]
@@ -346,6 +359,7 @@ async def get_listing(listing_id: str) -> Union[ListingGetResponseItem, ErrorRes
346359
description=listing.get("description"),
347360
seller_id=listing.get("seller_id"),
348361
seller_name=listing.get("seller_name", ""),
362+
seller_email=listing.get("seller_email", ""),
349363
pictures=listing.get("pictures", []),
350364
category=listing.get("category"),
351365
condition=listing.get("condition"),
@@ -358,8 +372,10 @@ async def get_listing(listing_id: str) -> Union[ListingGetResponseItem, ErrorRes
358372

359373

360374
@app.get('/listings',
361-
response_model=ListingsGetResponseAll,
362-
responses={'500': {'model': ErrorResponse}},
375+
response_model=None,
376+
responses={
377+
'200': {'model': ListingsGetResponseAll},
378+
'500': {'model': ErrorResponse}},
363379
)
364380
async def get_listings(
365381
query: Optional[str] = Query(None, description="query"),
@@ -378,7 +394,7 @@ async def get_listings(
378394
print("GOT Query", query)
379395
search_stage = {
380396
"$search": {
381-
"index": "Full_text_index_listings",
397+
"index": "full_text_index_listings_11APR2025",
382398
"text": {
383399
"query": query,
384400
"path": {
@@ -391,7 +407,7 @@ async def get_listings(
391407
print("NO QUERY")
392408
search_stage = {
393409
"$search": {
394-
"index": "Full_text_index_listings",
410+
"index": "full_text_index_listings_11APR2025",
395411
# This is always true; essentially just gets all documents
396412
"exists": {"path": "_id"},
397413
}
@@ -426,7 +442,7 @@ async def get_listings(
426442
listings = await cursor.to_list(length=limit)
427443

428444
if not listings:
429-
return ListingsGetResponseAll(listings=[], total=0, next_page_token=None)
445+
return ListingsGetResponseAll(listings=[], total=0, next_page_token="")
430446

431447
# Convert documents to Pydantic models
432448
response_data = [
@@ -474,11 +490,9 @@ async def post_listings(
474490
Create a new listing
475491
"""
476492
try:
477-
478493
# Prepare data for MongoDB
479494
listing_data = body.dict()
480-
listing_data["date_posted"] = datetime.now(
481-
timezone.utc).isoformat() # Ensure date is handled properly
495+
listing_data["date_posted"] = datetime.now(timezone.utc)
482496
listing_data["seller_id"] = current_user["id"]
483497

484498
# Insert into MongoDB
@@ -670,7 +684,6 @@ async def delete_saved_item(saved_item_id: str = Query(..., description="ID of t
670684
return ErrorResponse(detail="Internal Server Error. Please try again later.")
671685

672686

673-
674687
######################################## USER ENDPOINTS ########################################
675688

676689

backend/app/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class ListingGetResponseItem(BaseModel):
6161
price: float
6262
description: Optional[str] = None
6363
seller_id: str
64+
seller_email: Optional[str] = None
6465
pictures: List[str]
6566
category: Optional[str] = None
6667
condition: str
@@ -82,7 +83,6 @@ class ListingsPostRequest(BaseModel):
8283
title: str
8384
price: float
8485
description: Optional[str] = None
85-
seller_id: str
8686
pictures: List[str]
8787
category: Optional[str] = None
8888
condition: str

backend/scripts/listings_help.py

Lines changed: 66 additions & 0 deletions
Large diffs are not rendered by default.

frontend/lib/item_listing/components/listing_loading.component.dart

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,6 @@ import 'package:flutter/material.dart';
33
class ListingLoadingComponent extends StatelessWidget {
44
const ListingLoadingComponent({super.key});
55

6-
// Widget _buildSearchBarSkeleton() {
7-
// return Padding(
8-
// padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
9-
// child: Row(
10-
// children: [
11-
// Expanded(
12-
// child: Container(
13-
// height: 50,
14-
// decoration: BoxDecoration(
15-
// color: Colors.grey[200],
16-
// borderRadius: BorderRadius.circular(30),
17-
// ),
18-
// ),
19-
// ),
20-
// const SizedBox(width: 8.0),
21-
// Container(
22-
// width: 50,
23-
// height: 50,
24-
// decoration: BoxDecoration(
25-
// color: Colors.grey[200],
26-
// borderRadius: BorderRadius.circular(30),
27-
// ),
28-
// ),
29-
// ],
30-
// ),
31-
// );
32-
// }
33-
346
Widget _buildTrendingLabelSkeleton() {
357
return Padding(
368
padding: const EdgeInsets.only(left: 16.0, bottom: 5.0, top: 5.0),

frontend/lib/item_listing/repository/listing_repo.dart

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import 'package:dio/dio.dart';
2+
import 'package:utm_marketplace/item_listing/model/filter_options.model.dart';
3+
import 'package:utm_marketplace/item_listing/model/listing.model.dart';
14
import 'package:utm_marketplace/shared/dio/dio.dart';
25

36
class ListingRepo {
47
final Map<String, dynamic> _cache = {};
58

6-
Future<dynamic> fetchData(
9+
Future<dynamic> getListings(
710
{int limit = 6, String? nextPageToken, String? query}) async {
811
final cacheKey = '$limit-$nextPageToken-$query';
912
if (_cache.containsKey(cacheKey)) {
@@ -36,4 +39,40 @@ class ListingRepo {
3639
throw Exception('Failed to load listings: $e');
3740
}
3841
}
42+
43+
Future<ListingModel> getSearchResults(
44+
{String? searchQuery,
45+
int limit = 5,
46+
String? nextPageToken,
47+
FilterOptions? filterOptions}) async {
48+
Response response;
49+
try {
50+
final queryParams = <String, String>{
51+
if (searchQuery != null) 'query': searchQuery,
52+
'limit': limit.toString(),
53+
if (nextPageToken != null) 'next': nextPageToken,
54+
if (filterOptions?.priceType != null)
55+
'price_type': filterOptions!.priceType!,
56+
if (filterOptions?.lowerPrice != null)
57+
'lower_price': filterOptions!.lowerPrice!.toString(),
58+
if (filterOptions?.upperPrice != null)
59+
'upper_price': filterOptions!.upperPrice!.toString(),
60+
if (filterOptions?.condition != null)
61+
'condition': filterOptions!.condition!,
62+
if (filterOptions?.dateRange != null)
63+
'date_range': filterOptions!.dateRange!.toIso8601String(),
64+
if (filterOptions?.campus != null) 'campus': filterOptions!.campus!,
65+
};
66+
67+
response = await dio.get('/search', queryParameters: queryParams);
68+
69+
if (response.statusCode == 200) {
70+
return ListingModel.fromJson(response.data);
71+
}
72+
} catch (e) {
73+
throw Exception("Failed to load search results: $e");
74+
}
75+
76+
throw Exception("Request failed with status code: ${response.statusCode}");
77+
}
3978
}

0 commit comments

Comments
 (0)