Skip to content

Commit

Permalink
task/WC-21 - Digital Rocks Cover Image Upload (#1063)
Browse files Browse the repository at this point in the history
* Added cover image upload functionality

- User can choose a cover image to upload when creating a project
- Added file processing function for cover image
- Updated project post and patch requests to accept multipart/form-data
- File is served to user as a tapis postit

* Added cover image support for review and publication systems

* update cover image listing logic

* set cover image postits to expire after 24 hours
  • Loading branch information
shayanaijaz authored Feb 24, 2025
1 parent 7add4a0 commit 056b184
Show file tree
Hide file tree
Showing 10 changed files with 208 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ const DynamicForm = ({ initialFormFields, onChange }) => {
name={field.name}
label={field.label}
type="file"
accept={field?.validation?.accept}
description={field?.description}
required={field?.validation?.required}
onChange={(event) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const DataFilesProjectEditDescriptionModalAddon = ({ setValidationSchema }) => {
});
});
} else {
if (field.type === 'file') {
return;
}
setFieldValue(field.name, metadata[field.name]);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const excludeKeys = [
'sample',
'digital_dataset',
'file_objs',
'cover_image',
'cover_image_url',
];

const DataFilesProjectFileListingMetadataAddon = ({
Expand All @@ -33,6 +35,8 @@ const DataFilesProjectFileListingMetadataAddon = ({
license,
doi,
keywords,
cover_image,
cover_image_url,
}) => {
const dateOptions = { month: 'long', day: 'numeric', year: 'numeric' };
const dateLabel = publication_date ? 'Publication Date' : 'Created';
Expand All @@ -45,6 +49,8 @@ const DataFilesProjectFileListingMetadataAddon = ({
license: license ?? 'None',
...(doi && { doi }),
...(keywords && { keywords }),
...(cover_image && { cover_image }),
...(cover_image_url && { cover_image_url }),
};
};

Expand Down Expand Up @@ -105,8 +111,9 @@ const DataFilesProjectFileListingMetadataAddon = ({
<DataDisplay
data={getProjectMetadata(metadata)}
path={path}
excludeKeys={[]}
excludeKeys={excludeKeys}
modalData={getProjectModalMetadata(metadata)}
coverImage={metadata.cover_image}
/>
</>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ const ProjectDescription = ({ project }) => {
License: project.license ?? 'None',
};

if (project.cover_image) {
projectData['Cover Image'] = (
<a href={project.cover_image_url} target='_blank' rel="noreferrer" className='wb-link'>
{project.cover_image.split('/').pop()}
</a>
);
}

if (project.keywords) {
projectData['Keywords'] = project.keywords;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import { Section, SectionContent, LoadingSpinner, Button } from '_common';
import { useLocation, Link } from 'react-router-dom';
import styles from './DataDisplay.module.scss';
import { useFileListing } from 'hooks/datafiles';
import { useDispatch } from 'react-redux';

// Function to format the dict key from snake_case to Label Case i.e. data_type -> Data Type
Expand Down Expand Up @@ -80,9 +79,17 @@ const processModalViewableData = (data) => {
}));
};

const DataDisplay = ({ data, path, excludeKeys, modalData }) => {
const location = useLocation();

const processCoverImage = (data) => {
return [{
label: 'Cover Image',
value:
<a href={data.cover_image_url} target='_blank' rel="noreferrer" className='wb-link'>
{data.cover_image.split('/').pop()}
</a>
}]
}

const DataDisplay = ({ data, path, excludeKeys, modalData, coverImage }) => {
//filter out empty values and unwanted keys
let processedData = Object.entries(data)
.filter(([key, value]) => value !== '' && !excludeKeys.includes(key))
Expand All @@ -91,6 +98,10 @@ const DataDisplay = ({ data, path, excludeKeys, modalData }) => {
value: typeof value === 'string' ? formatLabel(value) : value,
}));

if (coverImage) {
processedData.unshift(...processCoverImage(data));
}

if (path) {
processedData.unshift(...processSampleAndOriginData(data, path));
}
Expand Down
49 changes: 41 additions & 8 deletions client/src/redux/sagas/projects.sagas.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { put, takeLatest, call } from 'redux-saga/effects';
import queryStringParser from 'query-string';
import { fetchUtil } from 'utils/fetchUtil';
import Cookies from 'js-cookie';

export async function fetchProjectsListing(queryString, rootSystem) {
const q = queryStringParser.stringify({ query_string: queryString });
Expand Down Expand Up @@ -63,14 +64,30 @@ export function* showSharedWorkspaces(action) {
}

export async function fetchCreateProject(project) {

const formData = new FormData();

const { file, ...projectMetadata } = project.metadata; // Exclude the file
formData.append('metadata', JSON.stringify(projectMetadata));

if (file) {
formData.append('cover_image', file);
}

Object.entries(project)
.filter(([key, value]) => value != null && key !== 'metadata')
.forEach(([key, value]) => {
formData.append(key, value);
});

const result = await fetchUtil({
url: `/api/projects/`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(project),
headers: { 'X-CSRFToken': Cookies.get('csrftoken') },
credentials: 'same-origin',
body: formData,
});

return result.response;
}

Expand Down Expand Up @@ -166,14 +183,30 @@ export function* setMember(action) {
}

export async function setTitleDescriptionUtil(projectId, data) {

const formData = new FormData();

const { file, ...projectMetadata } = data.metadata; // Exclude the file
formData.append('metadata', JSON.stringify(projectMetadata));

if (file) {
formData.append('cover_image', file);
}

Object.entries(data)
.filter(([key, value]) => value != null && key !== 'metadata')
.forEach(([key, value]) => {
formData.append(key, value);
});

const result = await fetchUtil({
url: `/api/projects/${projectId}/`,
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
headers: { 'X-CSRFToken': Cookies.get('csrftoken') },
credentials: 'same-origin',
body: formData,
});

return result.response;
}

Expand Down
1 change: 1 addition & 0 deletions server/portal/apps/_custom/drp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ class DrpProjectMetadata(DrpMetadataModel):
is_review_project: Optional[bool] = None
is_published_project: Optional[bool] = None
guest_users: list[DrpGuestUser] = []
cover_image: Optional[str] = None

class DrpDatasetMetadata(DrpMetadataModel):
"""Model for Base DRP Dataset Metadata"""
Expand Down
76 changes: 66 additions & 10 deletions server/portal/apps/projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
from portal.apps._custom.drp import constants
from portal.apps.projects.workspace_operations.graph_operations import add_node_to_project, initialize_project_graph, get_node_from_path
from portal.apps.projects.tasks import process_file, sync_files_without_metadata
from portal.libs.files.file_processing import resize_cover_image
from portal.libs.agave.utils import service_account
from django.http.multipartparser import MultiPartParser

LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -133,22 +136,35 @@ def get(self, request, root_system=None):
@transaction.atomic
def post(self, request): # pylint: disable=no-self-use
"""POST handler."""
data = json.loads(request.body)
title = data['title']
description = data['description']
metadata = data['metadata']
title = request.POST.get('title')
description = request.POST.get('description')
metadata = request.POST.get('metadata')
cover_image = request.FILES.get('cover_image')

workspace_number = increment_workspace_count()
workspace_id = f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{settings.PORTAL_PROJECTS_ID_PREFIX}-{workspace_number}"
system_id = f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{settings.PORTAL_PROJECTS_ID_PREFIX}-{workspace_number}"

if metadata is not None:
metadata["projectId"] = workspace_id
metadata = json.loads(metadata)

if cover_image:
metadata['cover_image'] = f'media/{settings.PORTAL_PROJECTS_ID_PREFIX}-{workspace_number}/cover_image/{cover_image.name}'

metadata["projectId"] = system_id
project_meta = create_project_metadata(metadata)
initialize_project_graph(project_meta.project_id)

client = request.user.tapis_oauth.client
system_id = create_shared_workspace(client, title, request.user.username, description, workspace_number)

# Upload cover image to media folder
if cover_image:
service_client = service_account()
resized_file = resize_cover_image(cover_image)
service_client.files.insert(systemId=settings.PORTAL_PROJECTS_ROOT_SYSTEM_NAME,
path=f'media/{settings.PORTAL_PROJECTS_ID_PREFIX}-{workspace_number}/cover_image/{cover_image.name}',
file=resized_file)

return JsonResponse(
{
'status': 200,
Expand Down Expand Up @@ -191,6 +207,20 @@ def get(self, request, project_id=None, system_id=None):
prj.update(get_ordered_value(project.name, project.value))
prj["projectId"] = project_id

if prj["cover_image"] is not None:
service_client = service_account()

if prj["is_published_project"]:
root_system = settings.PORTAL_PROJECTS_PUBLISHED_ROOT_SYSTEM_NAME
elif prj["is_review_project"]:
root_system = settings.PORTAL_PROJECTS_REVIEW_ROOT_SYSTEM_NAME
else:
root_system = settings.PORTAL_PROJECTS_ROOT_SYSTEM_NAME

postit = service_client.files.createPostIt(systemId=root_system, path=prj['cover_image'], allowedUses=-1,
validSeconds=86400)
prj["cover_image_url"] = postit.redeemUrl

if not getattr(prj, 'is_review_project', False) and not getattr(prj, 'is_published_project', False):
sync_files_without_metadata.delay(client.access_token.access_token, f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{project_id}")
except:
Expand Down Expand Up @@ -236,17 +266,43 @@ def patch(
:param request: Request object
:param str project_id: Project Id.
"""
data = json.loads(request.body)
metadata = data['metadata']
query_dict, multi_value_dict = MultiPartParser(request.META, request,
request.upload_handlers).parse()

title = query_dict.get('title')
description = query_dict.get('description')
metadata = query_dict.get('metadata')
cover_image = multi_value_dict.get('cover_image')

project_id_full = f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{project_id}"
client = request.user.tapis_oauth.client

workspace_def = update_project(client, project_id, data['title'], data['description'])
workspace_def = update_project(client, project_id, title, description)

if metadata is not None:
entity = patch_project_entity(project_id_full, metadata)
metadata = json.loads(metadata)

if cover_image:
metadata['cover_image'] = f'media/{project_id}/cover_image/{cover_image.name}'

entity = patch_project_entity(project_id_full, metadata)
workspace_def.update(get_ordered_value(entity.name, entity.value))
workspace_def["projectId"] = project_id

# Upload cover image to media folder
if cover_image:
service_client = service_account()
resized_file = resize_cover_image(cover_image)
service_client.files.insert(systemId=settings.PORTAL_PROJECTS_ROOT_SYSTEM_NAME,
path=f'media/{project_id}/cover_image/{cover_image.name}',
file=resized_file)

# Get the postit for the cover image
postit = service_client.files.createPostIt(systemId=settings.PORTAL_PROJECTS_ROOT_SYSTEM_NAME,
path=f'media/{project_id}/cover_image/{cover_image.name}',
allowedUses=-1,
validSeconds=86400)
workspace_def["cover_image_url"] = postit.redeemUrl

return JsonResponse(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ def _transfer_files(client, source_system_id, dest_system_id):
transfer = service_client.files.createTransferTask(elements=transfer_elements)
return transfer

def _transfer_cover_image(source_system_id, dest_system_id, cover_image_path):

if not cover_image_path:
logger.info('No cover image found for project, skipping transfer.')
return None

service_client = service_account()

# Transfer the cover image to the destination system
transfer_elements = [
{
'sourceURI': f'tapis://{source_system_id}/{cover_image_path}',
'destinationURI': f'tapis://{dest_system_id}/{cover_image_path}'
}
]

transfer = service_client.files.createTransferTask(elements=transfer_elements)
logger.info(f"Transfer task created for cover image: {transfer.uuid}")
return transfer


def _check_transfer_status(service_client, transfer_task_id):
transfer_details = service_client.files.getTransferTask(transferTaskId=transfer_task_id)
return transfer_details.status
Expand Down Expand Up @@ -155,6 +176,9 @@ def publish_project(self, project_id: str, version: Optional[int] = 1):
# transfer files
client = service_account()
transfer = _transfer_files(client, review_system_id, published_system_id)
cover_image_transfer = _transfer_cover_image(settings.PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME,
settings.PORTAL_PROJECTS_PUBLISHED_ROOT_SYSTEM_NAME,
project_meta.value.get("coverImage", None))

poll_tapis_file_transfer.apply_async(
args=(transfer.uuid, False),
Expand All @@ -180,6 +204,9 @@ def copy_graph_and_files_for_review_system(self, user_access_token, source_works

client = user_account(user_access_token)
transfer = _transfer_files(client, source_system_id, review_system_id)
cover_image_trasnfer = _transfer_cover_image(settings.PORTAL_PROJECTS_ROOT_SYSTEM_NAME,
settings.PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME,
review_project.value.get("coverImage", None))

logger.info(f'Transfer task submmited with id {transfer.uuid}')

Expand Down
Loading

0 comments on commit 056b184

Please sign in to comment.