Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 'Download' button to record set attachment viewer #6052

Open
wants to merge 24 commits into
base: production
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
902b762
WIP
alesan99 Jan 6, 2025
9410f27
Lint code with ESLint and Prettier
alesan99 Jan 6, 2025
dd48454
WIP use attachmentLocations instead
alesan99 Jan 7, 2025
551bf58
Lint code with ESLint and Prettier
alesan99 Jan 7, 2025
8e9154b
Add functional download all button and archive notification
alesan99 Jan 8, 2025
d529864
Add download button to individual attachment cells
alesan99 Jan 8, 2025
e7b0fc5
Fix missing icon
alesan99 Jan 13, 2025
fcc557e
Stream zip file to client instead of using a notification
alesan99 Jan 15, 2025
2ef03dc
Merge branch 'production' into issue-609
alesan99 Jan 15, 2025
5a5a746
Cleanup imports
alesan99 Jan 15, 2025
f8a5f8f
Misc. code improvements
alesan99 Jan 21, 2025
930ea7c
Lint code with ESLint and Prettier
alesan99 Jan 21, 2025
503338c
Add loading bar
alesan99 Jan 21, 2025
e598352
Merge branch 'issue-609' of https://github.com/specify/specify7 into …
alesan99 Jan 21, 2025
3c2aa19
Lint code with ESLint and Prettier
alesan99 Jan 21, 2025
6b12230
Use record set name as zip file name
alesan99 Jan 22, 2025
8b1dea4
Merge branch 'issue-609' of https://github.com/specify/specify7 into …
alesan99 Jan 22, 2025
fd3caf7
Lint code with ESLint and Prettier
alesan99 Jan 22, 2025
3f92de5
Download All button directly downloads if there is only one attachment
alesan99 Jan 23, 2025
8e38152
Lint code with ESLint and Prettier
alesan99 Jan 23, 2025
3cc24bd
Fix unhandled errors for missing fields
alesan99 Jan 24, 2025
76cf405
Merge branch 'production' into issue-609
acwhite211 Jan 24, 2025
9b86f9e
Merge branch 'production' into issue-609
alesan99 Jan 27, 2025
22743ac
Lint code with ESLint and Prettier
alesan99 Jan 27, 2025
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
1 change: 1 addition & 0 deletions specifyweb/attachment_gw/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
url(r'^get_upload_params/$', views.get_upload_params),
url(r'^get_token/$', views.get_token),
url(r'^proxy/$', views.proxy),
url(r'^download_all/$', views.download_all),
url(r'^dataset/$', views.datasets),
url(r'^dataset/(?P<ds_id>\d+)/$', views.dataset),

Expand Down
68 changes: 67 additions & 1 deletion specifyweb/attachment_gw/views.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import os
import re
import hmac
import json
import logging
import time
import shutil
from tempfile import mkdtemp
from os.path import splitext
from uuid import uuid4
from xml.etree import ElementTree
from datetime import datetime

import requests
from django.conf import settings
from django.http import HttpResponse, HttpResponseBadRequest, \
StreamingHttpResponse
from django.db import transaction
from django.utils.translation import gettext as _
from django.views.decorators.cache import cache_control
from django.views.decorators.cache import cache_control, never_cache
from django.views.decorators.http import require_POST
from ..notifications.models import Message

from specifyweb.middleware.general import require_http_methods
from specifyweb.specify.views import login_maybe_required, openapi
Expand Down Expand Up @@ -296,6 +303,65 @@ def proxy(request):
(chunk for chunk in response.iter_content(512 * 1024)),
content_type=response.headers['Content-Type'])

@require_POST
@login_maybe_required
@never_cache
def download_all(request):
"""
Download all attachments from a list of attachment locations and put them into a zip file.
"""
try:
r = json.load(request)
except ValueError as e:
return HttpResponseBadRequest(e)

attachmentLocations = r['attachmentlocations']
origFileNames = r['origfilenames']

filename = 'attachments_%s.zip' % datetime.now().isoformat()
path = os.path.join(settings.DEPOSITORY_DIR, filename)

make_attachment_zip(attachmentLocations, origFileNames, get_collection(request), path)

def file_iterator(file_path, chunk_size=512 * 1024):
with open(file_path, 'rb') as f:
while chunk := f.read(chunk_size):
yield chunk
os.remove(file_path)

response = StreamingHttpResponse(
file_iterator(path),
content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response

def make_attachment_zip(attachmentLocations, origFileNames, collection, output_file):
output_dir = mkdtemp()
try:
fileNameAppearances = {}
for i, attachmentLocation in enumerate(attachmentLocations):
data = {
'filename': attachmentLocation,
'coll': collection,
'type': 'O',
'token': generate_token(get_timestamp(), attachmentLocation)
}
response = requests.get(server_urls['read'], params=data)
if response.status_code == 200:
downloadFileName = origFileNames[i]
fileNameAppearances[downloadFileName] = fileNameAppearances.get(downloadFileName, 0) + 1
if fileNameAppearances[downloadFileName] > 1:
downloadOrigName = os.path.splitext(downloadFileName)[0]
downloadExtension = os.path.splitext(downloadFileName)[1]
downloadFileName = f'{downloadOrigName}_{fileNameAppearances[downloadFileName]}{downloadExtension}'
with open(os.path.join(output_dir, downloadFileName), 'wb') as f:
f.write(response.content)

basename = re.sub(r'\.zip$', '', output_file)
shutil.make_archive(basename, 'zip', output_dir, logger=logger)
finally:
shutil.rmtree(output_dir)

@transaction.atomic()
@login_maybe_required
@require_http_methods(['GET', 'POST', 'HEAD'])
Expand Down
1 change: 1 addition & 0 deletions specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const icons = {
documentReport: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm2 10a1 1 0 10-2 0v3a1 1 0 102 0v-3zm2-3a1 1 0 011 1v5a1 1 0 11-2 0v-5a1 1 0 011-1zm4-1a1 1 0 10-2 0v7a1 1 0 102 0V8z" fillRule="evenodd" /></svg>,
documentSearch: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2h-1.528A6 6 0 004 9.528V4z" /><path clipRule="evenodd" d="M8 10a4 4 0 00-3.446 6.032l-1.261 1.26a1 1 0 101.414 1.415l1.261-1.261A4 4 0 108 10zm-2 4a2 2 0 114 0 2 2 0 01-4 0z" fillRule="evenodd" /></svg>,
dotsVertical: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" /></svg>,
download: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" /></svg>,
duplicate: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 9a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9z" /><path d="M5 3a2 2 0 00-2 2v6a2 2 0 002 2V5h8a2 2 0 00-2-2H5z" /></svg>,
exclamation: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" fillRule="evenodd" /></svg>,
exclamationCircle: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" fillRule="evenodd" /></svg>,
Expand Down
27 changes: 27 additions & 0 deletions specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import { TableIcon } from '../Molecules/TableIcon';
import { hasTablePermission } from '../Permissions/helpers';
import { AttachmentPreview } from './Preview';
import { getAttachmentRelationship, tablesWithAttachments } from './utils';
import { fetchOriginalUrl } from './attachments';
import { useAsyncState } from '../../hooks/useAsyncState';
import { serializeResource } from '../DataModel/serializers';
import { Link } from '../Atoms/Link';
import { notificationsText } from '../../localization/notifications';

export function AttachmentCell({
attachment,
Expand All @@ -37,6 +42,15 @@ export function AttachmentCell({
}): JSX.Element {
const table = f.maybe(attachment.tableID ?? undefined, getAttachmentTable);

const serialized = React.useMemo(
() => serializeResource(attachment),
[attachment]
);
const [originalUrl] = useAsyncState(
React.useCallback(async () => fetchOriginalUrl(serialized as SerializedResource<Attachment>), [serialized]),
false
);

return (
<div className="relative">
{typeof handleViewRecord === 'function' &&
Expand All @@ -61,6 +75,19 @@ export function AttachmentCell({
handleOpen();
}}
/>
{typeof originalUrl === 'string' && (
<Link.Icon
className="absolute right-0 top-0"
download={new URL(originalUrl).searchParams.get(
'downloadname'
)}
href={`/attachment_gw/proxy/${new URL(originalUrl).search}`}
target="_blank"
onClick={undefined}
icon="download"
title={notificationsText.download()}
/>
)}
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ import { useBooleanState } from '../../hooks/useBooleanState';
import { useCachedState } from '../../hooks/useCachedState';
import { attachmentsText } from '../../localization/attachments';
import { commonText } from '../../localization/common';
import { Http } from '../../utils/ajax/definitions';
import { ajax } from '../../utils/ajax/index';
import { f } from '../../utils/functools';
import type { RA } from '../../utils/types';
import { filterArray } from '../../utils/types';
import { keysToLowerCase } from '../../utils/utils';
import { Button } from '../Atoms/Button';
import { LoadingContext } from '../Core/Contexts';
import type { AnySchema } from '../DataModel/helperTypes';
import type { SpecifyResource } from '../DataModel/legacyTypes';
import { serializeResource } from '../DataModel/serializers';
import type { CollectionObjectAttachment } from '../DataModel/types';
import { Dialog, dialogClassNames } from '../Molecules/Dialog';
import { downloadFile } from '../Molecules/FilePicker';
import { defaultAttachmentScale } from '.';
import { AttachmentGallery } from './Gallery';
import { getAttachmentRelationship } from './utils';
Expand All @@ -23,11 +28,13 @@ const haltIncrementSize = 300;
export function RecordSetAttachments<SCHEMA extends AnySchema>({
records,
onFetch: handleFetch,
recordSetName,
}: {
readonly records: RA<SpecifyResource<SCHEMA> | undefined>;
readonly onFetch:
| ((index: number) => Promise<RA<number | undefined> | void>)
| undefined;
readonly recordSetName: string | undefined;
}): JSX.Element {
const fetchedCount = React.useRef<number>(0);

Expand Down Expand Up @@ -81,6 +88,41 @@ export function RecordSetAttachments<SCHEMA extends AnySchema>({
);
const attachmentsRef = React.useRef(attachments);

const downloadAllAttachmentsDisabled = fetchedCount.current !== records.length || records.length <= 1 || fetchedCount.current <= 1;
const handleDownloadAllAttachments = async (): Promise<void> => {
if (attachmentsRef.current === undefined) return;
const attachmentLocations = attachmentsRef.current.attachments
.map((attachment) => attachment.attachmentLocation)
.filter((name): name is string => name !== null);
const origFilenames = attachmentsRef.current.attachments
.map((attachment) => attachment.origFilename)
.filter((name): name is string => name !== null);

try {
const response = await ajax<Blob>('/attachment_gw/download_all/', {
method: 'POST',
body: keysToLowerCase({
attachmentLocations,
origFilenames,
}),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/octet-stream',
},
});

if (response.status === Http.OK) {
downloadFile(`Attachments - ${recordSetName || new Date().toDateString()}.zip`, response.data);
} else {
console.error('Attachment archive download failed', response);
}
} catch (error) {
console.error('Attachment archive download failed', error);
}

};
const loading = React.useContext(LoadingContext);

if (typeof attachments === 'object') attachmentsRef.current = attachments;

/*
Expand Down Expand Up @@ -110,7 +152,18 @@ export function RecordSetAttachments<SCHEMA extends AnySchema>({
{showAttachments && (
<Dialog
buttons={
<Button.DialogClose>{commonText.close()}</Button.DialogClose>
<>
<Button.Info
disabled={downloadAllAttachmentsDisabled}
title={attachmentsText.downloadAllDescription()}
onClick={(): void => loading(handleDownloadAllAttachments())}
>
{attachmentsText.downloadAll()}
</Button.Info>
<Button.DialogClose>
{commonText.close()}
</Button.DialogClose>
</>
}
className={{
container: dialogClassNames.wideContainer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
defaultIndex,
table,
viewName,
recordSetName,
title,
headerButtons,
dialog,
Expand All @@ -58,6 +59,7 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
*/
readonly ids: RA<number | undefined>;
readonly newResource: SpecifyResource<SCHEMA> | undefined;
readonly recordSetName?: string | undefined;
readonly title: LocalizedString | undefined;
readonly headerButtons?: JSX.Element;
readonly dialog: 'modal' | 'nonModal' | false;
Expand Down Expand Up @@ -233,7 +235,11 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
{hasAttachments &&
!hasSeveralResourceType &&
!resource?.isNew() ? (
<RecordSetAttachments records={records} onFetch={handleFetch} />
<RecordSetAttachments
records={records}
recordSetName={recordSetName}
onFetch={handleFetch}
/>
) : undefined}
{table.view === 'GeologicTimePeriod' ? (
<ChronoChart />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ function RecordSet<SCHEMA extends AnySchema>({
isInRecordSet
isLoading={isLoading}
newResource={currentRecord.isNew() ? currentRecord : undefined}
recordSetName={recordSet.isNew() ? undefined : recordSet.get('name')}
table={currentRecord.specifyTable}
title={
recordSet.isNew()
Expand Down
20 changes: 14 additions & 6 deletions specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,26 +137,34 @@ export function FilePicker({
*/
export const downloadFile = async (
fileName: string,
text: string
data: Blob | string
): Promise<void> =>
new Promise((resolve) => {
let fileDownloaded = false;
const iframe = document.createElement('iframe');
iframe.classList.add('absolute', 'hidden');
iframe.addEventListener('load', () => {
if (iframe.contentWindow === null || fileDownloaded) return;
let url: string | undefined;
const element = iframe.contentWindow.document.createElement('a');
element.setAttribute(
'href',
`data:text/plain;charset=utf-8,${encodeURIComponent(text)}`
);
element.setAttribute('download', fileName);
if (typeof data === 'string') {
element.setAttribute(
'href',
`data:text/plain;charset=utf-8,${encodeURIComponent(data)}`
);
element.setAttribute('download', fileName);
} else {
url = URL.createObjectURL(data);
element.setAttribute('href', url);
element.setAttribute('download', fileName);
}

element.style.display = 'none';
iframe.contentWindow.document.body.append(element);

element.click();
fileDownloaded = true;
if (url !== undefined) URL.revokeObjectURL(url);
globalThis.setTimeout(() => {
iframe.remove();
resolve();
Expand Down
6 changes: 6 additions & 0 deletions specifyweb/frontend/js_src/lib/localization/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,4 +676,10 @@ export const attachmentsText = createDictionary({
під час читання файлу сталася помилка.
`,
},
downloadAll: {
'en-us': 'Download All',
},
downloadAllDescription: {
'en-us': 'Download all found attachments',
},
} as const);
20 changes: 16 additions & 4 deletions specifyweb/frontend/js_src/lib/utils/ajax/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ import { handleAjaxResponse } from './response';

// FEATURE: make all back-end endpoints accept JSON

export type MimeType = 'application/json' | 'text/plain' | 'text/xml';
export type MimeType =
| 'application/json'
| 'application/octet-stream'
| 'text/plain'
| 'text/xml';

export type AjaxResponseObject<RESPONSE_TYPE> = {
/*
* Parsed response
* Parser is selected based on the value of options.headers.Accept:
* - application/json - json
* - text/xml - xml
* - application/octet-stream - binary data
* - else (i.e text/plain) - string
*/
readonly data: RESPONSE_TYPE;
Expand Down Expand Up @@ -114,6 +119,7 @@ export async function ajax<RESPONSE_TYPE = string>(
}
if (method === 'GET' && typeof pendingRequests[url] === 'object')
return pendingRequests[url] as Promise<AjaxResponseObject<RESPONSE_TYPE>>;
const acceptBlobResponse = accept === 'application/octet-stream';
pendingRequests[url] = fetch(url, {
...options,
method,
Expand All @@ -135,16 +141,22 @@ export async function ajax<RESPONSE_TYPE = string>(
...(typeof accept === 'string' ? { Accept: accept } : {}),
},
})
.then(async (response) => Promise.all([response, response.text()]))
.then(async (response) =>
Promise.all([
response,
acceptBlobResponse ? response.blob() : response.text(),
])
)
.then(
([response, text]: readonly [Response, string]) => {
([response, text]: readonly [Response, Blob | string]) => {
extractAppResourceId(url, response);
return handleAjaxResponse<RESPONSE_TYPE>({
expectedErrors,
accept,
errorMode,
response,
text,
text: typeof text === 'string' ? text : '',
data: typeof text === 'string' ? undefined : text,
});
},
// This happens when request is aborted (i.e, page is restarting)
Expand Down
Loading
Loading