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

feat(forms): adds ability to export forms #5739

Merged
merged 7 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 34 additions & 2 deletions src/dispatch/document/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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
)
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/dispatch/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
136 changes: 135 additions & 1 deletion src/dispatch/forms/service.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand Down Expand Up @@ -69,3 +75,131 @@ 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, get all unique project ids
project_ids = list({form.project_id for form in forms})

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, 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(
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
17 changes: 16 additions & 1 deletion src/dispatch/forms/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import logging
from fastapi import APIRouter, HTTPException, status, Depends, Response
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
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</v-col>
</v-row>
<v-row no-gutters class="justify-space-between">
<v-col v-for="document in templateDocumentTypes" :key="document.resource_type">
<v-col v-for="document in templateDocumentTypes" :key="document.resource_type" cols="4">
<v-card
@click.stop="createEditShow({ resource_type: document.resource_type })"
variant="outlined"
Expand Down
6 changes: 6 additions & 0 deletions src/dispatch/static/dispatch/src/document/template/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export const templateDocumentTypes = [
description: "Create a new tracking template",
icon: "mdi-file-document-multiple-outline",
},
{
resource_type: "dispatch-forms-export-template",
title: "Forms Export",
description: "Create a new forms export template",
icon: "mdi-file-export-outline",
},
]

const state = {
Expand Down
12 changes: 12 additions & 0 deletions src/dispatch/static/dispatch/src/forms/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,16 @@ export default {
sendEmailToService(formId) {
return API.post(`${resource}/completed/${formId}`)
},

bulkDelete(forms) {
return Promise.all(
forms.map((form_obj) => {
return this.delete(form_obj.id, 0)
})
)
},

exportForms(ids) {
return API.post(`${resource}/export`, ids)
},
}
59 changes: 59 additions & 0 deletions src/dispatch/static/dispatch/src/forms/table/BulkEditSheet.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<template>
<v-bottom-sheet
v-model="showBulkEdit"
:scrim="false"
persistent
no-click-animation
:retain-focus="false"
>
<export-forms-dialog />
<delete-bulk-dialog />
<v-card :loading="bulkEditLoading" rounded="0">
<v-list>
<v-list-item>
<v-list-item-subtitle>{{ selected.length }} selected</v-list-item-subtitle>

<template #append>
<v-btn variant="text" @click="showExportDialog()">
<v-icon>mdi-account-arrow-right</v-icon>
Export
</v-btn>
<v-btn variant="text" color="primary" @click="showDeleteBulkDialog()">
<v-icon color="primary">mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-list-item>
</v-list>
</v-card>
</v-bottom-sheet>
</template>

<script>
import { mapFields } from "vuex-map-fields"
import { mapActions } from "vuex"

import ExportFormsDialog from "@/forms/table/ExportFormsDialog.vue"
import DeleteBulkDialog from "@/forms/table/DeleteBulkDialog.vue"

export default {
name: "FormsBulkEditSheet",

components: {
DeleteBulkDialog,
ExportFormsDialog,
},

computed: {
...mapFields("forms_table", ["table.rows.selected", "table.bulkEditLoading"]),

showBulkEdit: function () {
return this.selected?.length ? true : false
},
},

methods: {
...mapActions("forms_table", ["deleteBulk", "showExportDialog", "showDeleteBulkDialog"]),
},
}
</script>
Loading
Loading