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 7 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
74 changes: 73 additions & 1 deletion specifyweb/attachment_gw/views.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import os
import traceback
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
from threading import Thread

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 +305,69 @@ 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
"""
try:
r = json.load(request)
except ValueError as e:
return HttpResponseBadRequest(e)

logger.info('Downloading attachments for %s', r)

user = request.specify_user
collection = request.specify_collection

attachmentLocations = r['attachmentlocations']
origFilenames = r['origfilenames']
# collection = r['collection']

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

def do_export():
try:
make_attachment_zip(attachmentLocations, origFilenames, path)
except Exception as e:
tb = traceback.format_exc()
logger.error('make_attachment_zip failed: %s', tb)
Message.objects.create(user=user, content=json.dumps({
'type': 'attachment-archive-failed',
'exception': str(e),
'traceback': tb if settings.DEBUG else None,
}))
else:
Message.objects.create(user=user, content=json.dumps({
'type': 'attachment-archive-complete',
'file': filename
}))

# # Send zip as a notification? It may be possible to automatically send it as a download? Maybe not a good idea if server is under load.
# # Or stream the zip file back to the client with StreamingHttpResponse
alesan99 marked this conversation as resolved.
Show resolved Hide resolved

thread = Thread(target=do_export)
thread.daemon = True
thread.start()
return HttpResponse('OK', content_type='text/plain')

def make_attachment_zip(attachmentLocations, origFilenames, output_file):
output_dir = mkdtemp()
try:
for attachmentLocation in attachmentLocations:
response = requests.get(server_urls['read'] + '?' + f'coll=Paleo&type=O&filename={attachmentLocation}')
if response.status_code == 200:
with open(os.path.join(output_dir, attachmentLocation), '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), [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 @@ -17,6 +17,8 @@ import { Dialog, dialogClassNames } from '../Molecules/Dialog';
import { defaultAttachmentScale } from '.';
import { AttachmentGallery } from './Gallery';
import { getAttachmentRelationship } from './utils';
import { ping } from '../../utils/ajax/ping';
import { keysToLowerCase } from '../../utils/utils';

const haltIncrementSize = 300;

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

const handleDownloadAllAttachments = (): void => {
const attachmentLocations: readonly string[] = attachmentsRef.current.attachments
alesan99 marked this conversation as resolved.
Show resolved Hide resolved
.map((attachment) => attachment.attachmentLocation)
.filter((location): location is string => location !== null);
const origFilenames: readonly string[] = attachmentsRef.current.attachments
alesan99 marked this conversation as resolved.
Show resolved Hide resolved
.map((attachment) => attachment.origFilename)
.filter((filename): filename is string => filename !== null);

void ping('/attachment_gw/download_all/', {
method: 'POST',
body: keysToLowerCase({
attachmentLocations,
origFilenames,
}),
errorMode: 'dismissible',
});
};

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

/*
Expand Down Expand Up @@ -110,7 +130,17 @@ export function RecordSetAttachments<SCHEMA extends AnySchema>({
{showAttachments && (
<Dialog
buttons={
<Button.DialogClose>{commonText.close()}</Button.DialogClose>
<>
<Button.Info
disabled={fetchedCount.current !== records.length}
onClick={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 @@ -120,6 +120,34 @@ export const notificationRenderers: IR<
</>
);
},
'attachment-archive-complete'(notification) {
return (
<>
{notificationsText.attachmentArchiveCompleted()}
<Link.Success
className="w-fit"
download
href={`/static/depository/${notification.payload.file}`}
>
{notificationsText.download()}
</Link.Success>
</>
);
},
'attachment-archive-failed'(notification) {
return (
<>
{notificationsText.attachmentArchiveFailed()}
<Link.Success
className="w-fit"
download
href={`data:application/json:${JSON.stringify(notification.payload)}`}
>
{notificationsText.exception()}
</Link.Success>
</>
);
},
'dataset-ownership-transferred'(notification) {
return (
<StringToJsx
Expand Down
4 changes: 4 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,8 @@ export const attachmentsText = createDictionary({
під час читання файлу сталася помилка.
`,
},

downloadAll: {
'en-us': 'Download All',
},
} as const);
6 changes: 6 additions & 0 deletions specifyweb/frontend/js_src/lib/localization/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export const notificationsText = createDictionary({
'uk-ua': 'Експорт запиту в KML завершено.',
'de-ch': 'Der Abfrageexport nach KML wurde abgeschlossen.',
},
attachmentArchiveCompleted: {
'en-us': 'Attachment archive created.',
},
attachmentArchiveFailed: {
'en-us': 'Attachment archive creation failed.',
},
dataSetOwnershipTransferred: {
'en-us': `
<userName /> transferred the ownership of the <dataSetName /> dataset to
Expand Down
Loading