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 28 commits into
base: production
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
a8933f0
Add better error handling
alesan99 Feb 4, 2025
ae2628d
Merge branch 'production' into issue-609
acwhite211 Feb 5, 2025
5a9c1ce
Lint code with ESLint and Prettier
acwhite211 Feb 5, 2025
e1e4764
Fix ajax error handling in blob responses
alesan99 Feb 5, 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
74 changes: 73 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,71 @@ 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)

try:
make_attachment_zip(attachmentLocations, origFileNames, get_collection(request), path)
except Exception as e:
return HttpResponseBadRequest(e)

if not os.path.exists(path):
return HttpResponseBadRequest('Attachment archive not found')

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] if i < len(origFileNames) else attachmentLocation
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,16 +5,22 @@ 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 type { AnySchema } from '../DataModel/helperTypes';
import { LoadingContext } from '../Core/Contexts';
import type { AnySchema, SerializedResource } from '../DataModel/helperTypes';
import type { SpecifyResource } from '../DataModel/legacyTypes';
import { serializeResource } from '../DataModel/serializers';
import type { CollectionObjectAttachment } from '../DataModel/types';
import type { Attachment,CollectionObjectAttachment } from '../DataModel/types';
import { Dialog, dialogClassNames } from '../Molecules/Dialog';
import { downloadFile } from '../Molecules/FilePicker';
import { defaultAttachmentScale } from '.';
import { fetchOriginalUrl } from './attachments';
import { AttachmentGallery } from './Gallery';
import { getAttachmentRelationship } from './utils';

Expand All @@ -23,11 +29,13 @@ const haltIncrementSize = 300;
export function RecordSetAttachments<SCHEMA extends AnySchema>({
records,
onFetch: handleFetch,
name,
}: {
readonly records: RA<SpecifyResource<SCHEMA> | undefined>;
readonly onFetch:
| ((index: number) => Promise<RA<number | undefined> | void>)
| undefined;
readonly name: string | undefined;
}): JSX.Element {
const fetchedCount = React.useRef<number>(0);

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

const handleDownloadAllAttachments = async (): Promise<void> => {
if (attachmentsRef.current === undefined) return;
if (attachments?.attachments.length === 1) {
const attachment = attachmentsRef.current.attachments[0];
if (attachment === undefined) return;
const serialized = serializeResource(attachment)
fetchOriginalUrl(serialized as SerializedResource<Attachment>).then(
(url) => {
downloadFile(attachment.origFilename, `/attachment_gw/proxy/${new URL(url!).search}`, true)
}
)
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 ?? attachment.attachmentLocation)
.filter((name): name is string => name !== null);

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

if (response.status === Http.OK) {
const fileName = `Attachments - ${(name || new Date().toDateString()).replaceAll(':', '')}.zip`
downloadFile(fileName, response.data);
} else {
throw new Error(`Attachment archive download failed: ${response}`);
}

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

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

/*
Expand All @@ -98,6 +149,7 @@ export function RecordSetAttachments<SCHEMA extends AnySchema>({
);

const isComplete = fetchedCount.current === records.length;
const downloadAllAttachmentsDisabled = !isComplete || attachments?.attachments.length === 0;

return (
<>
Expand All @@ -110,7 +162,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 @@ -233,7 +233,11 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
{hasAttachments &&
!hasSeveralResourceType &&
!resource?.isNew() ? (
<RecordSetAttachments records={records} onFetch={handleFetch} />
<RecordSetAttachments
name={title as string}
records={records}
onFetch={handleFetch}
/>
) : undefined}
{table.view === 'GeologicTimePeriod' ? (
<ChronoChart />
Expand Down
24 changes: 18 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,38 @@ export function FilePicker({
*/
export const downloadFile = async (
fileName: string,
text: string
data: Blob | string,
isUrl?: boolean
): 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 dataUrl: 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 (isUrl === true) {
element.setAttribute('href', data as string);
element.setAttribute('download', fileName);
} else if (typeof data === 'string') {
element.setAttribute(
'href',
`data:text/plain;charset=utf-8,${encodeURIComponent(data)}`
);
element.setAttribute('download', fileName);
} else if (data instanceof Blob) {
dataUrl = URL.createObjectURL(data);
element.setAttribute('href', dataUrl);
element.setAttribute('download', fileName);
}

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

element.click();
fileDownloaded = true;
if (dataUrl !== undefined) URL.revokeObjectURL(dataUrl);
globalThis.setTimeout(() => {
iframe.remove();
resolve();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ const isDarkMode = ({
}: PreferencesVisibilityContext): boolean => isDarkMode || isRedirecting;

// Navigator may not be defined in some environments, like non-browser environments
const altKeyName = typeof navigator !== 'undefined' && navigator?.userAgent?.includes('Mac')
? 'Option'
: 'Alt';
const altKeyName =
typeof navigator !== 'undefined' && navigator?.userAgent?.includes('Mac')
? 'Option'
: 'Alt';

/**
* Have to be careful as preferences may be used before schema is loaded
Expand Down
Loading
Loading