Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
23 changes: 23 additions & 0 deletions backend/src/api/migrations/0057_cartitem_search_cartitem_term.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-07-30 18:45

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0056_sitestatistic'),
]

operations = [
migrations.AddField(
model_name='cartitem',
name='search',
field=models.CharField(blank=True, null=True),
),
migrations.AddField(
model_name='cartitem',
name='term',
field=models.CharField(blank=True, null=True),
),
]
6 changes: 6 additions & 0 deletions backend/src/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,12 @@ class CartItem(models.Model):

series = models.ForeignKey(GEOSeries, on_delete=models.CASCADE)
added_at = models.DateTimeField(null=True, blank=True)

# the user's query at the time of adding to the cart
search = models.CharField(null=True, blank=True)
# the ontology term that they selected at the time of adding to the cart
term = models.CharField(null=True, blank=True)

cart = models.ForeignKey(
"Cart", null=True, blank=True, on_delete=models.CASCADE, related_name="items"
)
Expand Down
11 changes: 9 additions & 2 deletions backend/src/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,12 +315,19 @@ class DatabaseStatsSerializer(serializers.Serializer):
class CartItemSerializer(serializers.ModelSerializer):
"""Serializer for CartItem model."""

id = serializers.CharField(source="series.series_id", read_only=True)
id = serializers.CharField(source="series.gse", read_only=True)
search = serializers.CharField(read_only=True)
term = serializers.CharField(read_only=True)
added = serializers.DateTimeField(source="added_at", read_only=True)

class Meta:
model = CartItem
fields = ["id", "added"]
fields = [
"id",
"search",
"term",
"added"
]


class CartSerializer(serializers.ModelSerializer):
Expand Down
92 changes: 18 additions & 74 deletions backend/src/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,16 +680,19 @@ def create(self, request, *args, **kwargs):
{
"studies": [
{
"id": "GSE35357",
"added": "2025-12-12T11:21:35.895Z"
"search": "Hepatocyte",
"term": "CL:0000182",
"added": "2026-07-30T18:46:33.885000Z"
},
{
"id": "GSE149008",
"added": "2025-12-12T11:21:36.627Z"
"search": "Hepatocyte",
"term": "CL:0000182",
"added": "2026-07-30T18:46:34.551000Z"
},
{
"id": "GSE45968",
"added": "2025-12-12T11:21:37.293Z"
"search": "Hepatocyte",
"term": "CL:0000182",
"added": "2026-07-30T18:46:35.153000Z"
}
],
"name": "yowza"
Expand All @@ -705,79 +708,20 @@ def create(self, request, *args, **kwargs):
# create CartItem objects for each series
for series_data in series_list:
series_id = series_data["id"]
search = series_data.get("search")
term = series_data.get("term")
added_at = series_data.get("added")
try:
series = GEOSeries.objects.get(gse=series_id)
CartItem.objects.create(series=series, added_at=added_at, cart=cart)
CartItem.objects.create(
series=series,
search=search,
term=term,
added_at=added_at,
cart=cart
)
except GEOSeries.DoesNotExist:
continue # skip invalid series ids

serializer = self.get_serializer(cart)
return Response(serializer.data, status=status.HTTP_201_CREATED)

# provide a /download action to download cart contents
@action(
detail=False,
methods=["post"],
url_path="download",
permission_classes=[AllowAny],
)
def download(self, request):
"""
API endpoint for downloading cart contents.

Expects a JSON body with the following structure:
{
"ids": [
"GSE35357",
"GSE149008",
"GSE45968"
]
}

Query parameters:
- type (optional): 'json' or 'csv' (default: 'json')
- filename (optional): desired filename (default: 'cart_download')
"""
series_ids = request.data.get("ids", [])
download_type = request.query_params.get("type", "json")
filename = request.query_params.get("filename", "cart_download")

series_qs = GEOSeries.objects.filter(gse__in=series_ids)

if download_type == "json":
# prepare JSON response
data = {
"studies": [
{
"id": series.gse,
"title": series.title,
"summary": series.summary,
}
for series in series_qs
]
}
response = Response(data, content_type="application/json")
response["Content-Disposition"] = f'attachment; filename="{filename}.json"'
return response

elif download_type == "csv":
# prepare CSV response
response = HttpResponse(content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = f'attachment; filename="{filename}.csv"'

# Helps Excel correctly detect UTF-8
response.write("\ufeff")

writer = csv.writer(response)
writer.writerow(["GEOSeries ID", "Title", "Summary"])
for series in series_qs:
writer.writerow([series.gse, series.title, series.summary])

return response

else:
return Response(
{"error": "Unsupported download type"},
status=status.HTTP_400_BAD_REQUEST,
)
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const routes = [
},
},
{
path: "studies/:search?",
path: "studies/:term?",
element: <Studies />,
},
{
Expand Down
28 changes: 3 additions & 25 deletions frontend/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import analytics from "react-ga4";
import z from "zod";
import { api, request } from "@/api";
import { cart, ontologies, samples, stats, studies } from "@/api/types";
import { downloadBlob } from "@/util/download";

/** get project wide stats */
export const getStats = async () => {
Expand All @@ -24,20 +23,20 @@ export const ontologySearch = async (search: string) => {

/** search for studies and get full details */
export const studySearch = async ({
search = "",
term = "",
ordering = "",
offset = 0,
limit = 100,
facets = {} as Record<string, string[]>,
}) => {
const url = new URL(`${api}/study/search/`);
url.searchParams.set("query", search);
url.searchParams.set("query", term);
url.searchParams.set("ordering", ordering);
url.searchParams.set("offset", String(offset));
url.searchParams.set("limit", String(limit));
for (const [facet, values] of Object.entries(facets))
for (const value of values) url.searchParams.append(facet, value);
analytics.event("study_search", { search, ordering, facets });
analytics.event("study_search", { term, ordering, facets });
const data = await request(url, studies);
return data;
};
Expand Down Expand Up @@ -119,24 +118,3 @@ export const shareCart = async (shareCart: ShareCart) => {
const data = await request(url, cart, options);
return data;
};

/** download cart data */
export const downloadCart = async (
ids: string[],
filename: string,
type: string,
) => {
const url = new URL(`${api}/cart/download/`);
url.searchParams.set("type", type);
url.searchParams.set("filename", filename);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: { ids },
parse: "blob",
} as const;
analytics.event("download_cart", { ids, filename, type });
const data = await request(url, z.instanceof(Blob), options);
if (type === "csv") downloadBlob(data, filename, "csv");
if (type === "json") downloadBlob(data, filename, "json");
};
2 changes: 2 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export const cart = z.object({
z.object({
id: z.string(),
added: z.string(),
search: z.string(),
term: z.string(),
}),
),
});
Expand Down
Loading