Skip to content

Commit d02728a

Browse files
committed
Implement ability to download multiple metadata files in a zip
1 parent 2ad207a commit d02728a

File tree

5 files changed

+175
-61
lines changed

5 files changed

+175
-61
lines changed

nmdc_server/api.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import csv
2+
import io
23
import json
34
import logging
45
import time
@@ -7,6 +8,7 @@
78
from io import BytesIO, StringIO
89
from typing import Any, Dict, List, Optional, Union, cast
910
from uuid import UUID, uuid4
11+
import zipfile
1012

1113
import httpx
1214
import requests
@@ -375,17 +377,51 @@ async def get_biosample_source(biosample_id: str):
375377
tags=["biosample"],
376378
)
377379
async def search_biosample_source(
378-
biosample_query: query.BiosampleQuerySchema = query.BiosampleQuerySchema(),
380+
q: query.SearchQuery = query.SearchQuery(),
379381
db: Session = Depends(get_db),
380382
):
381383
biosample_search = BiosampleSearch()
382-
biosamples = biosample_query.query(db).all()
383-
results = biosample_search.get_records_by_id([biosample.id for biosample in biosamples])
384+
biosample_ids = crud.search_biosample(db, q.conditions, []).with_entities(models.Biosample.id).all()
385+
results = biosample_search.get_records_by_id([id for (id,) in biosample_ids])
384386
if not results:
385387
raise HTTPException(status_code=404, detail="Could not retrieve source data for biosamples")
386388
return results
387389

388390

391+
# Download multiple metadata lists as a zip file given a list of endpoint labels.
392+
# Endpoint labels are mapped to functions that retrieve JSON
393+
@router.post(
394+
"/download_metadata",
395+
tags=["bulk_download"]
396+
)
397+
async def download_metadata(
398+
q: query.MultiSearchQuery,
399+
db: Session = Depends(get_db)
400+
):
401+
402+
endpoint_map = {
403+
"biosamples": search_biosample_source,
404+
"studies": search_study_source,
405+
}
406+
407+
zip_buffer = io.BytesIO()
408+
409+
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
410+
for endpoint_name in q.endpoints:
411+
if endpoint_name in endpoint_map:
412+
data = await endpoint_map[endpoint_name](q, db)
413+
json_str = json.dumps(data, indent=2, ensure_ascii=False)
414+
zip_file.writestr(f"{endpoint_name}.json", json_str.encode('utf-8'))
415+
416+
zip_buffer.seek(0)
417+
418+
return Response(
419+
content=zip_buffer.getvalue(),
420+
media_type="application/zip",
421+
headers={"Content-Disposition": "attachment; filename=metadata.zip"}
422+
)
423+
424+
389425
@router.get(
390426
"/envo/tree",
391427
response_model=schemas.EnvoTreeResponse,
@@ -592,6 +628,24 @@ async def get_study_source(study_id: str):
592628
return source_study
593629

594630

631+
# Get a list of study source data via the Runtime API
632+
# based on supplied conditions
633+
@router.post(
634+
"/study/search/source",
635+
tags=["study"],
636+
)
637+
async def search_study_source(
638+
q: query.SearchQuery = query.SearchQuery(),
639+
db: Session = Depends(get_db),
640+
):
641+
study_search = StudySearch()
642+
study_ids = crud.search_study(db, q.conditions).with_entities(models.Study.id).all()
643+
results = study_search.get_records_by_id([id for (id,) in study_ids])
644+
if not results:
645+
raise HTTPException(status_code=404, detail="Could not retrieve source data for studies")
646+
return results
647+
648+
595649
# data_generation
596650
# Note the intermingling of the terms "data generation" and "omics processing."
597651
# The Berkeley schema (NMDC schema v11) did away with the phrase "omics processing."

nmdc_server/query.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,7 @@ def omics_processing_for_biosample_ids(self, db: Session, biosample_ids):
821821

822822
class BiosampleQuerySchema(BaseQuerySchema):
823823
data_object_filter: List[DataObjectFilter] = []
824+
endpoints: List[str] = []
824825

825826
@property
826827
def table(self) -> Table:
@@ -1039,6 +1040,10 @@ class SearchQuery(BaseModel):
10391040
conditions: List[ConditionSchema] = []
10401041

10411042

1043+
class MultiSearchQuery(SearchQuery):
1044+
endpoints: List[str] = []
1045+
1046+
10421047
class ConditionResultSchema(SimpleConditionSchema):
10431048
model_config = ConfigDict(from_attributes=True)
10441049

web/src/components/BulkDownload.vue

Lines changed: 91 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import DownloadDialog from '@/components/DownloadDialog.vue';
77
import useBulkDownload from '@/use/useBulkDownload';
88
import { humanFileSize } from '@/data/utils';
99
import { api } from '@/data/api';
10+
import { downloadBlob } from '@/utils';
11+
// import { downloadJson } from '@/utils';
1012
1113
export default defineComponent({
1214
@@ -30,6 +32,7 @@ export default defineComponent({
3032
const downloadMenuOpen = ref(false);
3133
const treeMenuOpen = ref(false);
3234
const tab = ref('one');
35+
const metadataDownloadSelected = ref<string[]>([]);
3336
3437
const {
3538
loading,
@@ -46,7 +49,7 @@ export default defineComponent({
4649
return `${labelString})`;
4750
}
4851
49-
const options = computed(() => {
52+
const dataProductOptions = computed(() => {
5053
if (!downloadOptions.value || typeof downloadOptions.value !== 'object') {
5154
return [];
5255
}
@@ -61,6 +64,17 @@ export default defineComponent({
6164
}));
6265
});
6366
67+
const metadataOptions = computed(() => [
68+
{
69+
id: 'biosamples',
70+
label: 'Biosamples',
71+
},
72+
{
73+
id: 'studies',
74+
label: 'Studies',
75+
},
76+
]);
77+
6478
async function createAndDownload() {
6579
const val = await download();
6680
termsDialog.value = false;
@@ -72,19 +86,9 @@ export default defineComponent({
7286
}
7387
7488
async function downloadSamplesMetadata() {
75-
const samples = await api.searchBiosampleSource(stateRefs.conditions.value)
76-
console.log(samples);
77-
// .then((data) => {
78-
// const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
79-
// const url = window.URL.createObjectURL(blob);
80-
// const link = document.createElement('a');
81-
// link.href = url;
82-
// link.setAttribute('download', 'samples_metadata.json');
83-
// document.body.appendChild(link);
84-
// link.click();
85-
// document.body.removeChild(link);
86-
// window.URL.revokeObjectURL(url);
87-
// });
89+
const endpoints = metadataDownloadSelected.value;
90+
const blob = await api.getMetadataZip(stateRefs.conditions.value, endpoints);
91+
downloadBlob(blob, 'metadata.zip');
8892
}
8993
9094
watch(
@@ -98,7 +102,8 @@ export default defineComponent({
98102
99103
return {
100104
bulkDownloadSelected: stateRefs.bulkDownloadSelected,
101-
options,
105+
dataProductOptions,
106+
metadataOptions,
102107
loading,
103108
downloadSummary,
104109
termsDialog,
@@ -109,6 +114,7 @@ export default defineComponent({
109114
downloadMenuOpen,
110115
treeMenuOpen,
111116
downloadSamplesMetadata,
117+
metadataDownloadSelected,
112118
};
113119
},
114120
});
@@ -150,9 +156,46 @@ export default defineComponent({
150156
>
151157
<v-tab value="data-products">
152158
Data Products
159+
<v-tooltip
160+
location="top"
161+
min-width="300px"
162+
max-width="300px"
163+
>
164+
<template #activator="{ props }">
165+
<v-icon
166+
class="ml-2"
167+
size="small"
168+
v-bind="props"
169+
>
170+
mdi-help-circle
171+
</v-icon>
172+
</template>
173+
<span>
174+
Choose a group of files to download based on file type
175+
from the currently filtered search results.
176+
</span>
177+
</v-tooltip>
153178
</v-tab>
154179
<v-tab value="metadata">
155180
Metadata
181+
<v-tooltip
182+
location="top"
183+
min-width="300px"
184+
max-width="300px"
185+
>
186+
<template #activator="{ props }">
187+
<v-icon
188+
class="ml-2"
189+
size="small"
190+
v-bind="props"
191+
>
192+
mdi-help-circle
193+
</v-icon>
194+
</template>
195+
<span>
196+
Download metadata as JSON for the currently filtered search results.
197+
</span>
198+
</v-tooltip>
156199
</v-tab>
157200
</v-tabs>
158201
<v-divider />
@@ -165,44 +208,18 @@ export default defineComponent({
165208
variant="flat"
166209
class="pa-3 d-flex flex-column"
167210
>
168-
<div class="d-flex flex-row align-center justify-space-between mb-2">
169-
<div class="d-flex flex-row align-center mr-3">
170-
<div class="pr-2 text-caption font-weight-bold">
171-
Bulk Download
172-
</div>
173-
<v-tooltip
174-
left
175-
nudge-bottom="20px"
176-
min-width="300px"
177-
max-width="300px"
178-
>
179-
<template #activator="{ props }">
180-
<v-icon
181-
size="small"
182-
v-bind="props"
183-
>
184-
mdi-help-circle
185-
</v-icon>
186-
</template>
187-
<span>
188-
Choose a group of files to download based on file type
189-
from the currently filtered search results.
190-
</span>
191-
</v-tooltip>
192-
</div>
193-
<span
194-
class="text-caption font-weight-bold white--text"
195-
>
196-
<template v-if="downloadSummary.count === 0">
197-
No files selected
198-
</template>
199-
<template v-else>
200-
Download {{ downloadSummary.count }} files
201-
from {{ searchResultCount }} sample search results.
202-
(Download archive size {{ humanFileSize(downloadSummary.size) }})
203-
</template>
204-
</span>
205-
</div>
211+
<span
212+
class="text-caption font-weight-bold"
213+
>
214+
<template v-if="downloadSummary.count === 0">
215+
No files selected
216+
</template>
217+
<template v-else>
218+
Download {{ downloadSummary.count }} files
219+
from {{ searchResultCount }} sample search results.
220+
(Download archive size {{ humanFileSize(downloadSummary.size) }})
221+
</template>
222+
</span>
206223
<div>
207224
<div @click.stop>
208225
<Treeselect
@@ -212,8 +229,8 @@ export default defineComponent({
212229
multiple
213230
value-consists-of="LEAF_PRIORITY"
214231
open-direction="below"
215-
:options="options"
216-
placeholder="Select file type"
232+
:options="dataProductOptions"
233+
placeholder="Select file types"
217234
z-index="2001"
218235
@open="treeMenuOpen = true"
219236
@close="treeMenuOpen = false"
@@ -233,7 +250,7 @@ export default defineComponent({
233250
<v-icon class="pr-3">
234251
mdi-download
235252
</v-icon>
236-
Download ZIP
253+
Download Data Products ZIP
237254
</v-btn>
238255
</template>
239256
<DownloadDialog
@@ -246,13 +263,29 @@ export default defineComponent({
246263
</v-tabs-window-item>
247264
<v-tabs-window-item value="metadata">
248265
<v-sheet class="pa-5">
266+
<div @click.stop>
267+
<Treeselect
268+
v-model="metadataDownloadSelected"
269+
append-to-body
270+
class="flex-1-1-0"
271+
multiple
272+
value-consists-of="LEAF_PRIORITY"
273+
open-direction="below"
274+
:options="metadataOptions"
275+
placeholder="Select metadata types"
276+
z-index="2001"
277+
@open="treeMenuOpen = true"
278+
@close="treeMenuOpen = false"
279+
/>
280+
</div>
249281
<v-btn
282+
class="mt-3"
250283
@click="downloadSamplesMetadata"
251284
>
252285
<v-icon class="pr-3">
253286
mdi-download
254287
</v-icon>
255-
Samples JSON
288+
Download Metadata Zip
256289
</v-btn>
257290
</v-sheet>
258291
</v-tabs-window-item>
@@ -263,7 +296,7 @@ export default defineComponent({
263296

264297
<style scoped>
265298
.download-menu {
266-
width: 500px;
299+
width: 550px;
267300
}
268301
269302
.vue3-treeselect {

web/src/data/api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,16 @@ async function searchBiosampleSource(conditions: Condition[]) {
582582
return data;
583583
}
584584

585+
async function getMetadataZip(conditions: Condition[], endpoints: string[]) {
586+
const { data } = await client.post<any>(
587+
`download_metadata`,
588+
{ conditions, endpoints },
589+
{ responseType: 'blob' }
590+
591+
);
592+
return data;
593+
}
594+
585595
async function getStudySource(id: string): Promise<StudyResultFromSource> {
586596
const { data } = await client.get<StudyResultFromSource>(`study/${id}/source`);
587597
return data;
@@ -1013,6 +1023,7 @@ const api = {
10131023
getEnvironmentSankeyAggregation,
10141024
getEnvoTrees,
10151025
getFacetSummary,
1026+
getMetadataZip,
10161027
getStudy,
10171028
getStudySource,
10181029
getSubmissionSchema,

0 commit comments

Comments
 (0)