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 @@
-
-
-
-
-
-
- {{title}}
-
-
- {{ toast.state.message }}
-
-
-
-
-
-
-
-
-
-
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 @@
+
+
+
+
+
+
+ {{title}}
+
+
+ {{ state.message }}
+
+
+
+
+
+
+
+
+
+
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 @@
-