From 5427d5532301f2f9a01c1235f6d4b7c88fe818d1 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Tue, 28 Jan 2025 12:07:10 -0800 Subject: [PATCH 1/6] feat(forms): adds ability to export forms --- src/dispatch/document/service.py | 36 ++++- src/dispatch/enums.py | 3 +- src/dispatch/forms/service.py | 137 +++++++++++++++++- src/dispatch/forms/views.py | 19 ++- .../src/document/template/TemplateTable.vue | 2 +- .../dispatch/src/document/template/store.js | 6 + src/dispatch/static/dispatch/src/forms/api.js | 12 ++ .../src/forms/table/BulkEditSheet.vue | 59 ++++++++ .../src/forms/table/DeleteBulkDialog.vue | 43 ++++++ .../src/forms/table/ExportFormsDialog.vue | 74 ++++++++++ .../static/dispatch/src/forms/table/Table.vue | 6 +- .../static/dispatch/src/forms/table/store.js | 70 +++++++++ 12 files changed, 457 insertions(+), 10 deletions(-) create mode 100644 src/dispatch/static/dispatch/src/forms/table/BulkEditSheet.vue create mode 100644 src/dispatch/static/dispatch/src/forms/table/DeleteBulkDialog.vue create mode 100644 src/dispatch/static/dispatch/src/forms/table/ExportFormsDialog.vue diff --git a/src/dispatch/document/service.py b/src/dispatch/document/service.py index 8d60b732f7b3..edcbc22fd9df 100644 --- a/src/dispatch/document/service.py +++ b/src/dispatch/document/service.py @@ -2,7 +2,7 @@ from pydantic.error_wrappers import ErrorWrapper, ValidationError from datetime import datetime -from dispatch.enums import DocumentResourceReferenceTypes +from dispatch.enums import DocumentResourceReferenceTypes, DocumentResourceTemplateTypes from dispatch.exceptions import ExistsError from dispatch.project import service as project_service from dispatch.search_filter import service as search_filter_service @@ -29,6 +29,17 @@ def get_by_incident_id_and_resource_type( ) +def get_project_forms_export_template(*, db_session, project_id: int) -> Optional[Document]: + """Fetches the project forms export template.""" + resource_type = DocumentResourceTemplateTypes.forms + return ( + db_session.query(Document) + .filter(Document.project_id == project_id) + .filter(Document.resource_type == resource_type) + .one_or_none() + ) + + def get_incident_faq_document(*, db_session, project_id: int): """Fetches incident faq document.""" return ( @@ -67,7 +78,7 @@ def get_all(*, db_session) -> List[Optional[Document]]: def create(*, db_session, document_in: DocumentCreate) -> Document: """Creates a new document.""" - # handle the special case of only allowing 1 FAQ document per-project + # handle the special case of only allowing 1 FAQ / Forms Export document per-project project = project_service.get_by_name_or_raise( db_session=db_session, project_in=document_in.project ) @@ -93,6 +104,27 @@ def create(*, db_session, document_in: DocumentCreate) -> Document: model=DocumentCreate, ) + if document_in.resource_type == DocumentResourceTemplateTypes.forms: + forms_doc = ( + db_session.query(Document) + .filter(Document.resource_type == DocumentResourceTemplateTypes.forms) + .filter(Document.project_id == project.id) + .one_or_none() + ) + if forms_doc: + raise ValidationError( + [ + ErrorWrapper( + ExistsError( + msg="Forms export template document already defined for this project.", + document=forms_doc.name, + ), + loc="document", + ) + ], + model=DocumentCreate, + ) + filters = [ search_filter_service.get(db_session=db_session, search_filter_id=f.id) for f in document_in.filters diff --git a/src/dispatch/enums.py b/src/dispatch/enums.py index ef8ec8603967..2fd2ca8a9615 100644 --- a/src/dispatch/enums.py +++ b/src/dispatch/enums.py @@ -74,7 +74,8 @@ class DocumentResourceTemplateTypes(DispatchEnum): executive = "dispatch-executive-report-document-template" incident = "dispatch-incident-document-template" review = "dispatch-incident-review-document-template" - tracking = "dispatch-incident-sheet-template" + tracking = "dispatch-incident-tracking-template" + forms = "dispatch-forms-export-template" class EventType(DispatchEnum): diff --git a/src/dispatch/forms/service.py b/src/dispatch/forms/service.py index 153fd59bd56f..697ed3d3e7e1 100644 --- a/src/dispatch/forms/service.py +++ b/src/dispatch/forms/service.py @@ -1,12 +1,18 @@ import logging -from typing import Optional +import json +from typing import List, Optional +from datetime import datetime from sqlalchemy.orm import Session +from dispatch.database.core import resolve_attr from .models import Forms, FormsUpdate from .scoring import calculate_score +from dispatch.document import service as document_service from dispatch.individual import service as individual_service from dispatch.forms.type import service as form_type_service +from dispatch.plugin import service as plugin_service +from dispatch.project import service as project_service log = logging.getLogger(__name__) @@ -69,3 +75,132 @@ def delete(*, db_session, forms_id: int): form = db_session.query(Forms).filter(Forms.id == forms_id).one_or_none() db_session.delete(form) db_session.commit() + + +def build_form_doc(form_schema: str, form_data: str) -> str: + # Used to build the read-only answers given the questions in form_schema and the answers in form_data + schema = json.loads(form_schema) + data = json.loads(form_data) + output_qa = [] + + for item in schema: + name = item["name"] + question = item["title"] + # find the key in form_data corresponding to this name + answer = data.get(name) + if answer and isinstance(answer, list) and len(answer) == 0: + answer = "" + # add the question and answer to the output_qa list + if answer: + output_qa.append(f"{question}: {answer}") + + return "\n".join(output_qa) + + +def export(*, db_session: Session, ids: List[int]) -> List[str]: + """Exports forms.""" + folders = [] + # get all the forms given the ids + forms = db_session.query(Forms).filter(Forms.id.in_(ids)).all() + # from the forms, group all of the forms by their project id + project_ids = list(set([form.project_id for form in forms])) + # for each project id + for project_id in project_ids: + # ensure there is a document plugin active + document_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=project_id, plugin_type="document" + ) + if not document_plugin: + log.warning( + f"Forms for project id ${project_id} not exported. No document plugin enabled." + ) + continue + + storage_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=project_id, plugin_type="storage" + ) + if not storage_plugin: + log.warning( + f"Forms for project id ${project_id} not exported. No storage plugin enabled." + ) + continue + + # create a storage folder for the forms in the root project folder + external_storage_root_id = storage_plugin.configuration.root_id + + if not external_storage_root_id: + log.warning( + f"Forms for project id ${project_id} not exported. No external storage root id configured." + ) + continue + + project = project_service.get(db_session=db_session, project_id=project_id) + if not project: + log.warning(f"Forms for project id ${project_id} not exported. Project not found.") + continue + + form_export_template = document_service.get_project_forms_export_template( + db_session=db_session, project_id=project_id + ) + if not form_export_template: + log.warning( + f"Forms for project id ${project_id} not exported. No form export template document configured." + ) + continue + + # create a folder name that includes the date and time + folder_name = f"Exported forms {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + folder = storage_plugin.instance.create_file( + parent_id=external_storage_root_id, name=folder_name + ) + folders.append(folder["weblink"]) + + # get the subset of forms that have this project id + project_forms = [form for form in forms if form.project_id == project_id] + + # for each form, get the incident + for form in project_forms: + # meta-data yada yada + export_document_name = f"{form.incident.name}-{form.form_type.name}-{form.id}" + export_document = storage_plugin.instance.copy_file( + folder_id=folder["id"], + file_id=form_export_template.resource_id, + name=export_document_name, + ) + storage_plugin.instance.move_file( + new_folder_id=folder["id"], file_id=export_document["id"] + ) + document_kwargs = { + "commander_fullname": form.incident.commander.individual.name, + "conference_challenge": resolve_attr(form.incident, "conference.challenge"), + "conference_weblink": resolve_attr(form.incident, "conference.weblink"), + "conversation_weblink": resolve_attr(form.incident, "conversation.weblink"), + "description": form.incident.description, + "document_weblink": resolve_attr(form.incident, "incident_document.weblink"), + "name": form.incident.name, + "priority": form.incident.incident_priority.name, + "reported_at": form.incident.reported_at.strftime("%m/%d/%Y %H:%M:%S"), + "closed_at": ( + form.incident.closed_at.strftime("%m/%d/%Y %H:%M:%S") + if form.incident.closed_at + else "" + ), + "resolution": form.incident.resolution, + "severity": form.incident.incident_severity.name, + "status": form.incident.status, + "storage_weblink": resolve_attr(form.incident, "storage.weblink"), + "ticket_weblink": resolve_attr(form.incident, "ticket.weblink"), + "title": form.incident.title, + "type": form.incident.incident_type.name, + "summary": form.incident.summary, + "form_status": form.status, + "form_type": form.form_type.name, + "form_data": build_form_doc(form.form_type.form_schema, form.form_data), + "attorney_form_data": form.attorney_form_data, + "attorney_status": form.attorney_status, + "attorney_questions": form.attorney_questions, + "attorney_analysis": form.attorney_analysis, + } + document_plugin.instance.update(export_document["id"], **document_kwargs) + + return folders diff --git a/src/dispatch/forms/views.py b/src/dispatch/forms/views.py index 25254b8ac9f6..36ae770bd7df 100644 --- a/src/dispatch/forms/views.py +++ b/src/dispatch/forms/views.py @@ -1,12 +1,14 @@ import logging -from fastapi import APIRouter, HTTPException, status, Depends, Response +from fastapi import APIRouter, HTTPException, status, Depends, Response, Query from pydantic.error_wrappers import ErrorWrapper, ValidationError +from typing import List from sqlalchemy.exc import IntegrityError from dispatch.auth.permissions import ( FeedbackDeletePermission, PermissionsDependency, + SensitiveProjectActionPermission, ) from dispatch.database.core import DbSession from dispatch.auth.service import CurrentUser @@ -16,7 +18,7 @@ from dispatch.forms.type.service import send_email_to_service from .models import FormsRead, FormsUpdate, FormsPagination -from .service import get, create, update, delete +from .service import get, create, update, delete, export log = logging.getLogger(__name__) router = APIRouter() @@ -76,6 +78,19 @@ def create_forms( ) from None +@router.post( + "/export", + summary="Exports forms", + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], +) +def export_forms( + db_session: DbSession, + ids: List[int], +): + """Exports forms.""" + return export(db_session=db_session, ids=ids) + + @router.put( "/{forms_id}/{individual_contact_id}", response_model=FormsRead, diff --git a/src/dispatch/static/dispatch/src/document/template/TemplateTable.vue b/src/dispatch/static/dispatch/src/document/template/TemplateTable.vue index 57297c012211..e316f8a3e9d6 100644 --- a/src/dispatch/static/dispatch/src/document/template/TemplateTable.vue +++ b/src/dispatch/static/dispatch/src/document/template/TemplateTable.vue @@ -8,7 +8,7 @@ - + { + return this.delete(form_obj.id, 0) + }) + ) + }, + + exportForms(ids) { + return API.post(`${resource}/export`, ids) + }, } diff --git a/src/dispatch/static/dispatch/src/forms/table/BulkEditSheet.vue b/src/dispatch/static/dispatch/src/forms/table/BulkEditSheet.vue new file mode 100644 index 000000000000..3756d4c185b2 --- /dev/null +++ b/src/dispatch/static/dispatch/src/forms/table/BulkEditSheet.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/dispatch/static/dispatch/src/forms/table/DeleteBulkDialog.vue b/src/dispatch/static/dispatch/src/forms/table/DeleteBulkDialog.vue new file mode 100644 index 000000000000..0e9da142df30 --- /dev/null +++ b/src/dispatch/static/dispatch/src/forms/table/DeleteBulkDialog.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/dispatch/static/dispatch/src/forms/table/ExportFormsDialog.vue b/src/dispatch/static/dispatch/src/forms/table/ExportFormsDialog.vue new file mode 100644 index 000000000000..8387c93aa2be --- /dev/null +++ b/src/dispatch/static/dispatch/src/forms/table/ExportFormsDialog.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/dispatch/static/dispatch/src/forms/table/Table.vue b/src/dispatch/static/dispatch/src/forms/table/Table.vue index 50e6b4bd24da..9fe5bbe05d09 100644 --- a/src/dispatch/static/dispatch/src/forms/table/Table.vue +++ b/src/dispatch/static/dispatch/src/forms/table/Table.vue @@ -11,7 +11,6 @@ - @@ -98,6 +97,7 @@ + @@ -111,7 +111,7 @@ import AttorneyEditForm from "./AttorneyEditForm.vue" import DeleteDialog from "@/forms/DeleteDialog.vue" import Participant from "@/incident/Participant.vue" import RouterUtils from "@/router/utils" -import TableExportDialog from "@/task/TableExportDialog.vue" +import BulkEditSheet from "@/forms/table/BulkEditSheet.vue" import TableFilterDialog from "@/forms/table/TableFilterDialog.vue" export default { @@ -121,7 +121,7 @@ export default { DeleteDialog, NewEditDialog, Participant, - TableExportDialog, + BulkEditSheet, TableFilterDialog, AttorneyEditForm, }, diff --git a/src/dispatch/static/dispatch/src/forms/table/store.js b/src/dispatch/static/dispatch/src/forms/table/store.js index 4ced979d0089..ab2adafc3d21 100644 --- a/src/dispatch/static/dispatch/src/forms/table/store.js +++ b/src/dispatch/static/dispatch/src/forms/table/store.js @@ -31,6 +31,8 @@ const state = { dialogs: { showCreateEdit: false, showDeleteDialog: false, + showDeleteBulkDialog: false, + showExportDialog: false, }, table: { rows: { @@ -49,12 +51,15 @@ const state = { }, }, loading: false, + bulkEditLoading: false, }, form_types: [], current_page: 1, page_schema: null, incident_id: null, project_id: null, + executive_template_document: null, + exported_folders: null, } const getters = { @@ -160,6 +165,14 @@ const actions = { commit("SET_DIALOG_DELETE", true) commit("SET_SELECTED", form) }, + showExportDialog({ commit }, form) { + commit("SET_DIALOG_EXPORT", true) + commit("SET_SELECTED", form) + }, + showDeleteBulkDialog({ commit }, form) { + commit("SET_DIALOG_DELETE_BULK", true) + commit("SET_SELECTED", form) + }, closeCreateEdit({ commit }) { commit("SET_DIALOG_CREATE_EDIT", false) commit("RESET_SELECTED") @@ -168,6 +181,15 @@ const actions = { commit("SET_DIALOG_DELETE", false) commit("RESET_SELECTED") }, + closeDeleteBulkDialog({ commit }) { + commit("SET_DIALOG_DELETE_BULK", false) + commit("RESET_SELECTED") + }, + closeExportDialog({ commit }) { + commit("SET_EXPORTED_FOLDERS", null) + commit("SET_DIALOG_EXPORT", false) + commit("RESET_SELECTED") + }, saveAsDraft({ commit, dispatch }) { state.selected.status = "Draft" save({ commit, dispatch }) @@ -192,6 +214,39 @@ const actions = { commit("SET_SELECTED_LOADING", false) }) }, + deleteBulk({ commit, dispatch }) { + commit("SET_DIALOG_DELETE_BULK", false) + commit("SET_BULK_EDIT_LOADING", true) + return FormsApi.bulkDelete(state.table.rows.selected) + .then(function () { + commit("SET_SELECTED_LOADING", false) + commit("RESET_TABLE_ROWS_SELECTED") + dispatch("closeRemove") + dispatch("getAll") + commit( + "notification_backend/addBeNotification", + { text: "Form type deleted successfully.", type: "success" }, + { root: true } + ) + commit("SET_BULK_EDIT_LOADING", false) + }) + .catch(() => { + commit("SET_BULK_EDIT_LOADING", false) + }) + }, + exportForms({ commit }) { + commit("SET_BULK_EDIT_LOADING", true) + const ids = state.table.rows.selected.map((row) => row.id) + return FormsApi.exportForms(ids) + .then(function (response) { + commit("SET_SELECTED_LOADING", false) + commit("SET_EXPORTED_FOLDERS", response.data) + commit("SET_BULK_EDIT_LOADING", false) + }) + .catch(() => { + commit("SET_BULK_EDIT_LOADING", false) + }) + }, } const mutations = { @@ -226,12 +281,27 @@ const mutations = { SET_DIALOG_DELETE(state, value) { state.dialogs.showDeleteDialog = value }, + SET_DIALOG_DELETE_BULK(state, value) { + state.dialogs.showDeleteBulkDialog = value + }, + SET_DIALOG_EXPORT(state, value) { + state.dialogs.showExportDialog = value + }, RESET_SELECTED(state) { // do not reset project let project = state.selected.project state.selected = { ...getDefaultSelectedState() } state.selected.project = project }, + SET_BULK_EDIT_LOADING(state, value) { + state.table.bulkEditLoading = value + }, + RESET_TABLE_ROWS_SELECTED(state) { + state.table.rows.selected = [] + }, + SET_EXPORTED_FOLDERS(state, value) { + state.exported_folders = value + }, } export default { From 93ba62f5bfac2fc3ff21cd0c024912a69daa803c Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Tue, 28 Jan 2025 12:19:57 -0800 Subject: [PATCH 2/6] fixing lint errors --- src/dispatch/forms/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/forms/views.py b/src/dispatch/forms/views.py index 36ae770bd7df..ac6bef37c87f 100644 --- a/src/dispatch/forms/views.py +++ b/src/dispatch/forms/views.py @@ -1,5 +1,5 @@ import logging -from fastapi import APIRouter, HTTPException, status, Depends, Response, Query +from fastapi import APIRouter, HTTPException, status, Depends, Response from pydantic.error_wrappers import ErrorWrapper, ValidationError from typing import List From 210875da185609fa7ed720201e54139857002152 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Tue, 28 Jan 2025 12:23:20 -0800 Subject: [PATCH 3/6] fixing lint errors --- src/dispatch/forms/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/forms/service.py b/src/dispatch/forms/service.py index 697ed3d3e7e1..3852313be478 100644 --- a/src/dispatch/forms/service.py +++ b/src/dispatch/forms/service.py @@ -103,7 +103,7 @@ def export(*, db_session: Session, ids: List[int]) -> List[str]: # get all the forms given the ids forms = db_session.query(Forms).filter(Forms.id.in_(ids)).all() # from the forms, group all of the forms by their project id - project_ids = list(set([form.project_id for form in forms])) + project_ids = list({form.project_id for form in forms}) # for each project id for project_id in project_ids: # ensure there is a document plugin active From 10b41c82affd3476b4ea6cb2f5932f69370f9997 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Tue, 28 Jan 2025 12:24:15 -0800 Subject: [PATCH 4/6] fixing lint errors --- src/dispatch/forms/service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/dispatch/forms/service.py b/src/dispatch/forms/service.py index 3852313be478..ec3f1b8e4f74 100644 --- a/src/dispatch/forms/service.py +++ b/src/dispatch/forms/service.py @@ -102,9 +102,9 @@ def export(*, db_session: Session, ids: List[int]) -> List[str]: folders = [] # get all the forms given the ids forms = db_session.query(Forms).filter(Forms.id.in_(ids)).all() - # from the forms, group all of the forms by their project id + # from the forms, get all unique project ids project_ids = list({form.project_id for form in forms}) - # for each project id + for project_id in project_ids: # ensure there is a document plugin active document_plugin = plugin_service.get_active_instance( @@ -160,7 +160,6 @@ def export(*, db_session: Session, ids: List[int]) -> List[str]: # for each form, get the incident for form in project_forms: - # meta-data yada yada export_document_name = f"{form.incident.name}-{form.form_type.name}-{form.id}" export_document = storage_plugin.instance.copy_file( folder_id=folder["id"], From a242b4d3623f0e8f8e848dc7701caee1ce81b3a3 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Tue, 28 Jan 2025 12:24:37 -0800 Subject: [PATCH 5/6] fixing lint errors --- src/dispatch/forms/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/forms/service.py b/src/dispatch/forms/service.py index ec3f1b8e4f74..6ddf1f194b28 100644 --- a/src/dispatch/forms/service.py +++ b/src/dispatch/forms/service.py @@ -158,7 +158,7 @@ def export(*, db_session: Session, ids: List[int]) -> List[str]: # get the subset of forms that have this project id project_forms = [form for form in forms if form.project_id == project_id] - # for each form, get the incident + # for each form, create a document from the template and update it with the form data for form in project_forms: export_document_name = f"{form.incident.name}-{form.form_type.name}-{form.id}" export_document = storage_plugin.instance.copy_file( From ceff2e59a27666d3fea91f29489136b1e6a21c85 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Tue, 28 Jan 2025 16:46:52 -0800 Subject: [PATCH 6/6] removing unused fields --- .../static/dispatch/src/forms/table/ExportFormsDialog.vue | 1 - src/dispatch/static/dispatch/src/forms/table/store.js | 1 - 2 files changed, 2 deletions(-) diff --git a/src/dispatch/static/dispatch/src/forms/table/ExportFormsDialog.vue b/src/dispatch/static/dispatch/src/forms/table/ExportFormsDialog.vue index 8387c93aa2be..4417919a03f7 100644 --- a/src/dispatch/static/dispatch/src/forms/table/ExportFormsDialog.vue +++ b/src/dispatch/static/dispatch/src/forms/table/ExportFormsDialog.vue @@ -54,7 +54,6 @@ export default { ...mapFields("forms_table", [ "dialogs.showExportDialog", "table.rows.selected", - "executive_template_document", "exported_folders", ]), }, diff --git a/src/dispatch/static/dispatch/src/forms/table/store.js b/src/dispatch/static/dispatch/src/forms/table/store.js index ab2adafc3d21..c47b7d23fb70 100644 --- a/src/dispatch/static/dispatch/src/forms/table/store.js +++ b/src/dispatch/static/dispatch/src/forms/table/store.js @@ -58,7 +58,6 @@ const state = { page_schema: null, incident_id: null, project_id: null, - executive_template_document: null, exported_folders: null, }