diff --git a/backend/Dockerfile b/backend/Dockerfile index 54d58c12..009d0215 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # Local Development Stage -FROM python:3.11-slim-bookworm as development-stage +FROM python:3.11-slim-bookworm AS development-stage WORKDIR /app COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt @@ -7,7 +7,7 @@ COPY ./src /app/src ENTRYPOINT ["/bin/sh", "-c", "uvicorn src.main:app --host 0.0.0.0 --port 8000 --log-level info --reload"] # Production Build Stage -FROM python:3.11-slim-bookworm as production-stage +FROM python:3.11-slim-bookworm AS production-stage WORKDIR /app COPY logging.conf /app/logging.conf COPY requirements.txt /app/requirements.txt diff --git a/backend/src/routers/analysis_discussion_router.py b/backend/src/routers/analysis_discussion_router.py index 9d1b6426..453c3016 100644 --- a/backend/src/routers/analysis_discussion_router.py +++ b/backend/src/routers/analysis_discussion_router.py @@ -13,7 +13,11 @@ @router.get("/{analysis_name}/discussions") -def get_analysis_discussions(analysis_name: str, repositories=Depends(database)): +def get_analysis_discussions( + analysis_name: str, + repositories=Depends(database), + username: VerifyUser = Security(get_current_user) #pylint: disable=unused-argument +): """ Returns a list of discussion posts for a given analysis """ found_analysis = repositories['analysis'].find_by_name(analysis_name) diff --git a/backend/src/routers/analysis_router.py b/backend/src/routers/analysis_router.py index b7d384b7..7a37f2cd 100644 --- a/backend/src/routers/analysis_router.py +++ b/backend/src/routers/analysis_router.py @@ -29,7 +29,7 @@ @router.get("", tags=["analysis"], response_model=List[Analysis]) -def get_all_analyses(repositories=Depends(database)): +def get_all_analyses(repositories=Depends(database), username: VerifyUser = Security(get_current_user)): #pylint: disable=unused-argument """Returns every analysis available""" return repositories["analysis"].all() @@ -76,7 +76,11 @@ async def create_file( @router.get("/{analysis_name}", tags=["analysis"], response_model=Analysis, response_model_exclude_none=True) -def get_analysis_by_name(analysis_name: str, repositories=Depends(database)): +def get_analysis_by_name( + analysis_name: str, + repositories=Depends(database), + username: VerifyUser = Security(get_current_user) #pylint: disable=unused-argument +): """Returns analysis case data by calling method to find case by it's analysis_name""" analysis = repositories["analysis"].find_by_name(analysis_name) @@ -116,15 +120,24 @@ def update_event( raise HTTPException(status_code=409, detail=str(exception)) from exception -@router.get("/download/{file_id}") -def download_file_by_id(file_id: str, repositories=Depends(database)): +@router.get("/download/{file_id}", tags=["analysis"]) +def download_file_by_id( + file_id: str, + repositories=Depends(database), + username: VerifyUser = Security(get_current_user) #pylint: disable=unused-argument +): """ Returns a file from GridFS using the file's id """ grid_fs_file = repositories['bucket'].stream_analysis_file_by_id(file_id) return StreamingResponse(grid_fs_file, media_type=grid_fs_file.content_type) -@router.get("/{analysis_name}/download/{file_name}") -def download(analysis_name: str, file_name: str, repositories=Depends(database)): +@router.get("/{analysis_name}/download/{file_name}", tags=["analysis"]) +def download( + analysis_name: str, + file_name: str, + repositories=Depends(database), + username: VerifyUser = Security(get_current_user) #pylint: disable=unused-argument +): """ Returns a file saved to an analysis from GridFS by file name """ # Does file exist by name in the given analysis? file = repositories['analysis'].find_file_by_name(analysis_name, file_name) @@ -135,7 +148,7 @@ def download(analysis_name: str, file_name: str, repositories=Depends(database)) return StreamingResponse(repositories['bucket'].stream_analysis_file_by_id(file['attachment_id'])) -@router.put("/{analysis_name}/attach/{third_party_enum}") +@router.put("/{analysis_name}/attach/{third_party_enum}", tags=["analysis"]) def attach_third_party_link( analysis_name: str, third_party_enum: ThirdPartyLinkType, diff --git a/backend/src/routers/annotation_router.py b/backend/src/routers/annotation_router.py index c42f88c0..d447f2c9 100644 --- a/backend/src/routers/annotation_router.py +++ b/backend/src/routers/annotation_router.py @@ -31,6 +31,7 @@ def annotate_analysis( background_tasks: BackgroundTasks, repositories=Depends(database), annotation_task_queue=Depends(annotation_queue), + authorized=Security(get_authorization, scopes=["write"]) #pylint: disable=unused-argument ): """ Placeholder to initiate annotations for an analysis. This queueing/running diff --git a/docker-compose.yml b/docker-compose.yml index fcc611ae..d7d7daa5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.5' - services: reverse-proxy: image: traefik diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c9dcdc6a..6948a66a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,5 @@ # Local development stage -FROM node:20.8-alpine3.18 as development-stage +FROM node:20.8-alpine3.18 AS development-stage WORKDIR /app COPY package.json /app/ COPY yarn.lock /app/ @@ -10,7 +10,7 @@ EXPOSE 3000 ENTRYPOINT ["yarn", "dev:host"] # Production Build stage -FROM node:20.8-alpine3.18 as production-build +FROM node:20.8-alpine3.18 AS production-build WORKDIR /app COPY ./src /app/src/ COPY package.json /app/ @@ -23,7 +23,7 @@ ENV VITE_ROSALUTION_VERSION=$VERSION_BUILD_TAG RUN yarn install --frozen-lockfile && yarn build --base=/rosalution/ -FROM nginx:1.25.2-alpine3.18 as production-stage +FROM nginx:1.25.2-alpine3.18 AS production-stage COPY etc/default.conf /etc/nginx/conf.d/ COPY --from=production-build /app/dist/ /usr/share/nginx/html/ diff --git a/frontend/package.json b/frontend/package.json index 1d416a93..9be7f56a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,7 @@ "@fortawesome/free-solid-svg-icons": "6.4.2", "@fortawesome/vue-fontawesome": "3.0.3", "@rollup/plugin-strip": "3.0.4", - "vue": "3.3.4", + "vue": "3.5.12", "vue-router": "4.2.5" }, "devDependencies": { diff --git a/frontend/src/components/AnalysisView/useActionMenu.js b/frontend/src/components/AnalysisView/useActionMenu.js new file mode 100644 index 00000000..356248e9 --- /dev/null +++ b/frontend/src/components/AnalysisView/useActionMenu.js @@ -0,0 +1,38 @@ +import {ref} from 'vue'; +import {StatusType, getWorkflowStatusIcon} from '@/config.js'; + + +/** + * Builds an actionMenu that can be used for the main header + * @return {Object} {actionChoices, builder} to return the actions to render and the builder to update the choices + */ +export function useActionMenu() { + const actionChoices = ref([]); + + const builder = { + addWorkflowActions: (latest, operation) => { + if ( latest in StatusType ) { + for ( const [text, nextEvent, nextStatus] of StatusType[latest].transitions) { + builder.addMenuAction(text, getWorkflowStatusIcon(nextStatus), () => { + operation(nextEvent); + }); + } + } + }, + addMenuAction: (text, icon, operation) => { + actionChoices.value.push({ + icon: icon, + text: text, + operation: operation, + }); + }, + addDivider: () => { + actionChoices.value.push({divider: true}); + }, + clear: () => { + actionChoices.value = []; + }, + }; + + return {actionChoices, builder}; +} diff --git a/frontend/src/components/Dialogs/Toast.vue b/frontend/src/components/Dialogs/Toast.vue deleted file mode 100644 index 9beb944f..00000000 --- a/frontend/src/components/Dialogs/Toast.vue +++ /dev/null @@ -1,134 +0,0 @@ - - - - - diff --git a/frontend/src/components/Dialogs/ToastDialog.vue b/frontend/src/components/Dialogs/ToastDialog.vue new file mode 100644 index 00000000..9ec327e8 --- /dev/null +++ b/frontend/src/components/Dialogs/ToastDialog.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/frontend/src/config.js b/frontend/src/config.js index e338a6a2..ae348cf0 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.js @@ -1,27 +1,58 @@ +import {EventType} from './enums'; -export const StatusType = Object.freeze({ +const DEFAULT_TRANSITIONS = [ + ['Approve', EventType.APPROVE, 'Approved'], + ['Hold', EventType.HOLD, 'On-Hold'], + ['Decline', EventType.DECLINE, 'Declined'], +]; + +const StatusType = Object.freeze({ 'Preparation': { icon: 'asterisk', color: '--rosalution-status-annotation', + actionText: 'Mark Ready', + transitions: [['Mark Ready', EventType.READY, 'Ready']], }, 'Ready': { icon: 'clipboard-check', color: '--rosalution-status-ready', + transitions: [['Mark Active', EventType.OPEN, 'Active']], }, 'Active': { icon: 'book-open', color: '--rosalution-status-active', + transitions: DEFAULT_TRANSITIONS, }, 'Approved': { icon: 'check', color: '--rosalution-status-approved', + transitions: DEFAULT_TRANSITIONS, }, 'On-Hold': { icon: 'pause', color: '--rosalution-status-on-hold', + transitions: DEFAULT_TRANSITIONS, }, 'Declined': { icon: 'x', color: '--rosalution-status-declined', + transitions: DEFAULT_TRANSITIONS, }, }); + +/** + * Helper method that returns the Icon for a Workflow status. If none exist for + * that status a question mark is returned. + * + * @param {String} status the workflow status + * @return {String} the string value of the icon in that workflow + */ +function getWorkflowStatusIcon(status) { + if ( status in StatusType ) { + return StatusType[status].icon; + } + + return 'question'; +} + +export {StatusType, getWorkflowStatusIcon}; diff --git a/frontend/src/requests.js b/frontend/src/requests.js index 447e800f..a21ea94e 100644 --- a/frontend/src/requests.js +++ b/frontend/src/requests.js @@ -162,7 +162,4 @@ export default { async putForm(url, data) { return await sendFormData('PUT', url, data); }, - async deleteForm(url, data) { - return await sendFormData('DELETE', url, data); - }, }; diff --git a/frontend/src/stores/analysisStore.js b/frontend/src/stores/analysisStore.js new file mode 100644 index 00000000..f40f6aa6 --- /dev/null +++ b/frontend/src/stores/analysisStore.js @@ -0,0 +1,222 @@ +import {reactive} from 'vue'; + +import Analyses from '@/models/analyses.js'; + +export const analysisStore = reactive({ + analysis: { + name: '', + sections: [], + }, + updatedContent: {}, + + analysisName() { + return this.analysis?.name; + }, + + latestStatus() { + return this.analysis?.latest_status; + }, + + async getAnalysis(analysisName) { + this.analysis = await Analyses.getAnalysis(analysisName); + }, + + downloadAttachment(attachmentToDownload) { + Analyses.downloadSupportingEvidence(attachmentToDownload.attachment_id, attachmentToDownload.name); + }, + + clear() { + this.analysis = { + name: '', + sections: [], + }; + + this.updatedContent = {}; + }, + + // ----------------------------------- + // Edit Operations + // ----------------------------------- + + addUpdatedContent(header, field, value) { + if (!(header in this.updatedContent)) { + this.updatedContent[header] = {}; + } + + this.updatedContent[header][field] = value; + }, + + async saveChanges() { + const updatedSections = await Analyses.updateAnalysisSections( + this.analysis.name, + this.updatedContent, + ); + + const updated = { + sections: updatedSections, + }; + + this.forceUpdate(updated); + this.updatedContent = {}; + }, + + cancelChanges() { + this.updatedContent = {}; + }, + + async pushEvent(eventType) { + const updatedAnalysis = await Analyses.pushAnalysisEvent(this.analysisName(), eventType); + this.forceUpdate(updatedAnalysis); + }, + + async attachThirdPartyLink(thirdParty, data) { + const updatedAnalysis = await Analyses.attachThirdPartyLink(this.analysis.name, thirdParty, data); + + analysisStore.forceUpdate(updatedAnalysis); + }, + + // ----------------------------------- + // Section Images + // ----------------------------------- + + async attachSectionImage(sectionName, field, attachment) { + const updatedSectionField = await Analyses.attachSectionImage( + this.analysis.name, + sectionName, + field, + attachment.data, + ); + + const sectionWithReplacedField = this.replaceFieldInSection(sectionName, updatedSectionField); + this.replaceAnalysisSection(sectionWithReplacedField); + }, + + async updateSectionImage(fileId, sectionName, field, attachment) { + const updatedSectionField = await Analyses.updateSectionImage( + this.analysis.name, + sectionName, + field, + fileId, + attachment.data, + ); + + const sectionWithReplacedField = this.replaceFieldInSection(sectionName, updatedSectionField); + this.replaceAnalysisSection(sectionWithReplacedField); + }, + + async removeSectionImage(fileId, sectionName, field) { + const updatedSectionField = await Analyses.removeSectionAttachment(this.analysis.name, sectionName, field, fileId); + + const sectionWithReplacedField = this.replaceFieldInSection(sectionName, updatedSectionField); + this.replaceAnalysisSection(sectionWithReplacedField); + }, + + /** + * Section Attachments + */ + + async attachSectionAttachment(section, field, attachment) { + const updatedSectionField = + await Analyses.attachSectionSupportingEvidence(this.analysis.name, section, field, attachment); + const sectionWithReplacedField = this.replaceFieldInSection(section, updatedSectionField); + this.replaceAnalysisSection(sectionWithReplacedField); + }, + + async removeSectionAttachment(section, field, attachmentId) { + const updatedSectionField = + await Analyses.removeSectionAttachment(this.analysis.name, section, field, attachmentId); + + const sectionWithReplacedField = this.replaceFieldInSection(section, updatedSectionField); + this.replaceAnalysisSection(sectionWithReplacedField); + }, + + // ----------------------------------- + // Section Attachments + // ----------------------------------- + + replaceFieldInSection(sectionName, updatedField) { + const sectionToUpdate = this.analysis.sections.find((section) => { + return section.header == sectionName; + }); + + const fieldToUpdate = sectionToUpdate.content.find((row) => { + return row.field == updatedField['field']; + }); + + fieldToUpdate.value = updatedField.value; + + return sectionToUpdate; + }, + + replaceAnalysisSection(sectionToReplace) { + const originalSectionIndex = this.analysis.sections.findIndex( + (section) => section.header == sectionToReplace.header, + ); + this.analysis.sections.splice(originalSectionIndex, 1, sectionToReplace); + }, + + /** + * Discussions + */ + + async addDiscussionPost(newPostContent) { + const discussions = await Analyses.postNewDiscussionThread(this.analysis.name, newPostContent); + this.analysis.discussions = discussions; + }, + + async editDiscussionPost(postId, postContent) { + const discussions = await Analyses.editDiscussionThreadById(this.analysis.name, postId, postContent); + this.analysis.discussions = discussions; + }, + + async deleteDiscussionPost(postId) { + const discussions = await Analyses.deleteDiscussionThreadById(this.analysis.name, postId); + this.analysis.discussions = discussions; + }, + + // ----------------------------------- + // Analysis Attachments + // ----------------------------------- + + async addAttachment(attachment) { + const updatedAnalysisAttachments = await Analyses.attachSupportingEvidence( + this.analysis.name, + attachment, + ); + this.analysis.supporting_evidence_files.splice(0); + this.analysis.supporting_evidence_files.push( + ...updatedAnalysisAttachments, + ); + }, + + async updateAttachment(updatedAttachment) { + const updatedAnalysisAttachments = await Analyses.updateSupportingEvidence( + this.analysis.name, + updatedAttachment, + ); + this.analysis.supporting_evidence_files.splice(0); + this.analysis.supporting_evidence_files.push( + ...updatedAnalysisAttachments, + ); + }, + + async removeAttachment(attachmentToDelete) { + await Analyses.removeSupportingEvidence( + this.analysis.name, + attachmentToDelete.attachment_id, + ); + const attachmentIndex = this.analysis.supporting_evidence_files.findIndex((attachment) => { + return attachment.name == attachmentToDelete.name; + }); + + this.analysis.supporting_evidence_files.splice(attachmentIndex, 1); + }, + + // ----------------------------------- + // Analysis Operations + // ----------------------------------- + + forceUpdate(updatedAnalysis) { + Object.assign(this.analysis, updatedAnalysis); + }, +}); diff --git a/frontend/src/toast.js b/frontend/src/toast.js deleted file mode 100644 index 80b26860..00000000 --- a/frontend/src/toast.js +++ /dev/null @@ -1,54 +0,0 @@ -import {reactive} from 'vue'; - -const state = reactive({ - type: 'info', - active: false, - message: '', -}); - - -// ----------------------------------- -// Private Methods -// ----------------------------------- -let close; - -const dialogPromise = () => new Promise((resolve) => (close = resolve)); - -const open = (message) => { - state.message = message; - state.active = true; - return dialogPromise(); -}; - -const reset = () => { - state.active = false; - state.message = ''; - state.type = 'info'; -}; - -// ----------------------------------- -// Public interface -// ----------------------------------- - -export default { - get state() { - return state; - }, - success(message) { - state.type = 'success'; - return open(message); - }, - info(message) { - state.type = 'info'; - return open(message); - }, - error(message) { - state.type = 'error'; - return open(message); - }, - cancel() { - close(); - reset(); - }, -}; - diff --git a/frontend/src/views/AnalysisView.vue b/frontend/src/views/AnalysisView.vue index 1245fab1..87e49477 100644 --- a/frontend/src/views/AnalysisView.vue +++ b/frontend/src/views/AnalysisView.vue @@ -2,608 +2,603 @@
- +
-