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

Fix loading entity by intent using uuid xformid #1538

Merged
merged 9 commits into from
Jun 2, 2024
Merged
41 changes: 26 additions & 15 deletions src/backend/app/central/central_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,21 +317,23 @@ async def get_form_list(db: Session) -> list:


async def update_project_xform(
task_ids: list[int],
xform_id: str,
odk_id: int,
xform_data: BytesIO,
form_file_ext: str,
category: str,
task_count: int,
odk_credentials: project_schemas.ODKCentralDecrypted,
) -> None:
"""Update and publish the XForm for a project.

Args:
task_ids (List[int]): List of task IDs.
xform_id (str): The UUID of the existing XForm in ODK Central.
odk_id (int): ODK Central form ID.
xform_data (BytesIO): XForm data.
form_file_ext (str): Extension of the form file.
category (str): Category of the XForm.
task_count (int): The number of tasks in a project.
odk_credentials (project_schemas.ODKCentralDecrypted): ODK Central creds.
"""
xform_data = await read_and_test_xform(
Expand All @@ -342,20 +344,20 @@ async def update_project_xform(
updated_xform_data = await update_survey_xform(
xform_data,
category,
task_ids,
task_count,
existing_id=xform_id,
)

xform_obj = get_odk_form(odk_credentials)

# NOTE calling createForm for an existing form will update it
form_name = category
xform_obj.createForm(
odk_id,
updated_xform_data,
form_name,
xform_id,
)
# The draft form must be published after upload
xform_obj.publishForm(odk_id, form_name)
xform_obj.publishForm(odk_id, xform_id)


async def read_and_test_xform(
Expand Down Expand Up @@ -487,11 +489,12 @@ async def update_entity_registration_xform(
async def update_survey_xform(
form_data: BytesIO,
category: str,
task_ids: list[int],
task_count: int,
existing_id: Optional[str] = None,
) -> BytesIO:
"""Update fields in the XForm to work with FMTM.

The 'id' field is set to random UUID (xFormId)
The 'id' field is set to random UUID (xFormId) unless existing_id is specified
The 'name' field is set to the category name.
The upload media must match the (entity) dataset name (with .csv).
The task_id options are populated as choices in the form.
Expand All @@ -501,13 +504,18 @@ async def update_survey_xform(
form_data (str): The input form data.
category (str): The form category, used to name the dataset (entity list)
and the .csv file containing the geometries.
task_ids (list): List of task IDs to insert as choices in form.
task_count (int): The number of tasks in a project.
existing_id (str): An existing XForm ID in ODK Central, for updating.

Returns:
BytesIO: The XForm data.
"""
log.debug(f"Updating XML keys in survey XForm: {category}")
xform_id = uuid.uuid4()

if existing_id:
xform_id = existing_id
else:
xform_id = uuid.uuid4()

namespaces = {
"h": "http://www.w3.org/1999/xhtml",
Expand Down Expand Up @@ -551,9 +559,9 @@ async def update_survey_xform(
instance_task_ids = Element("instance", id="task_id")
root_element = SubElement(instance_task_ids, "root")
# Create sub-elements for each task ID, <itextId> <name> pairs
for index, task_id in enumerate(task_ids):
for task_id in range(1, task_count + 1):
item = SubElement(root_element, "item")
SubElement(item, "itextId").text = f"task_id-{index}"
SubElement(item, "itextId").text = f"task_id-{task_id}"
SubElement(item, "name").text = str(task_id)
model_element.append(instance_task_ids)

Expand All @@ -572,8 +580,8 @@ async def update_survey_xform(
translation.remove(existing_text)

# Append new <text> elements for each task_id
for index, task_id in enumerate(task_ids):
new_text = Element("text", id=f"task_id-{index}")
for task_id in range(1, task_count + 1):
new_text = Element("text", id=f"task_id-{task_id}")
value_element = Element("value")
value_element.text = str(task_id)
new_text.append(value_element)
Expand All @@ -584,7 +592,10 @@ async def update_survey_xform(
".//xforms:bind[@nodeset='/data/all/form_category']", namespaces
)
if form_category_update is not None:
form_category_update.set("calculate", f"once('{category}')")
if category.endswith("s"):
# Plural to singular
category = category[:-1]
form_category_update.set("calculate", f"once('{category.rstrip('s')}')")

return BytesIO(ElementTree.tostring(root))

Expand Down
6 changes: 3 additions & 3 deletions src/backend/app/projects/project_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ async def generate_odk_central_project_content(
xlsform: BytesIO,
form_category: str,
form_file_ext: str,
task_ids: list[int],
task_count: int,
db: Session,
) -> str:
"""Populate the project in ODK Central with XForm, Appuser, Permissions."""
Expand Down Expand Up @@ -886,7 +886,7 @@ async def generate_odk_central_project_content(
updated_xform = await central_crud.update_survey_xform(
xform,
form_category,
task_ids,
task_count,
)
# Upload survey XForm
log.info("Uploading survey XForm to ODK Central")
Expand Down Expand Up @@ -997,7 +997,7 @@ async def generate_project_files(
xlsform,
form_category,
form_file_ext,
list(task_extract_dict.keys()),
len(task_extract_dict.keys()),
db,
)
log.debug(
Expand Down
8 changes: 4 additions & 4 deletions src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ async def set_odk_entities_mapping_status(
entity_details must be a JSON body with params:
{
"entity_id": "string",
"label": "task <TASK_ID> feature <FEATURE_ID>",
"label": "Task <TASK_ID> Feature <FEATURE_ID>",
"status": 0
}
"""
Expand Down Expand Up @@ -936,6 +936,7 @@ async def download_form(

@router.post("/update-form")
async def update_project_form(
xform_id: str = Form(...),
category: str = Form(...),
upload: Optional[UploadFile] = File(None),
db: Session = Depends(database.get_db),
Expand Down Expand Up @@ -978,15 +979,14 @@ async def update_project_form(

# Get ODK Central credentials for project
odk_creds = await project_deps.get_odk_credentials(db, project.id)
# Get task id list
task_list = await tasks_crud.get_task_id_list(db, project.id)
# Update ODK Central form data
await central_crud.update_project_xform(
task_list,
xform_id,
project.odkid,
new_xform_data,
file_ext,
category,
len(project.tasks),
odk_creds,
)

Expand Down
9 changes: 9 additions & 0 deletions src/backend/app/projects/project_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ class ProjectBase(BaseModel):
"""Base project model."""

outline: Any = Field(exclude=True)
forms: Any = Field(exclude=True)

id: int
odkid: int
Expand Down Expand Up @@ -332,6 +333,14 @@ def organisation_logo(self) -> Optional[str]:
f"/{self.organisation_id}/logo.png"
)

@computed_field
@property
def xform_id(self) -> Optional[str]:
"""Compute the XForm ID from the linked DbXForm."""
if not self.forms:
return None
return self.forms[0].odk_form_id


class ProjectWithTasks(ProjectBase):
"""Project plus list of tasks objects."""
Expand Down
8 changes: 4 additions & 4 deletions src/backend/pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ dependencies = [
"cryptography>=42.0.1",
"defusedxml>=0.7.1",
"osm-login-python==1.0.3",
"osm-fieldwork==0.10.0",
"osm-fieldwork==0.10.1",
"osm-rawdata==0.3.0",
"fmtm-splitter==1.2.1",
]
Expand Down
11 changes: 4 additions & 7 deletions src/frontend/src/api/CreateProjectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,13 +347,10 @@ const PostFormUpdate: Function = (url: string, projectData: any) => {
const postFormUpdate = async (url, projectData) => {
try {
const formFormData = new FormData();
formFormData.append('project_id', projectData.project_id);
if (projectData.category) {
formFormData.append('category', projectData.category);
}
if (projectData.upload) {
formFormData.append('upload', projectData.upload);
}
formFormData.append('xform_id', projectData.xformId);
formFormData.append('category', projectData.category);
formFormData.append('upload', projectData.upload);

const postFormUpdateResponse = await axios.post(url, formFormData);
const resp: ProjectDetailsModel = postFormUpdateResponse.data;
// dispatch(CreateProjectActions.SetIndividualProjectDetails(modifiedResponse));
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/api/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const ProjectById = (existingProjectList, projectId) => {
tasks_mapped: projectResp.tasks_mapped,
tasks_validated: projectResp.tasks_validated,
xform_category: projectResp.xform_category,
xform_id: projectResp?.xform_id,
tasks_bad: projectResp.tasks_bad,
data_extract_url: projectResp.data_extract_url,
instructions: projectResp?.project_info?.per_task_instructions,
Expand Down
23 changes: 16 additions & 7 deletions src/frontend/src/components/DialogTaskActions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,22 @@ export default function Dialog({ taskId, feature, map, view }) {
type="submit"
className="fmtm-font-bold !fmtm-rounded fmtm-text-sm !fmtm-py-2 !fmtm-w-full fmtm-flex fmtm-justify-center"
onClick={() => {
// XForm name is constructed from lower case project title with underscores
const projectName = projectInfo.title.toLowerCase().split(' ').join('_');
const projectCategory = projectInfo.xform_category;
const formName = `${projectName}_${projectCategory}`;
document.location.href = `odkcollect://form/${formName}?task_id=${taskId}`;
// TODO add this to each feature popup to pre-load a selected entity
// document.location.href = `odkcollect://form/${formName}?${geomFieldName}=${entityId}`;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);

if (isMobile) {
document.location.href = `odkcollect://form/${projectInfo.xform_id}?task_id=${taskId}`;
} else {
dispatch(
CommonActions.SetSnackBar({
open: true,
message: 'Requires a mobile phone with ODK Collect.',
variant: 'warning',
duration: 3000,
}),
);
}
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const FormUpdateTab = ({ projectId }) => {
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [error, setError] = useState({ formError: '', categoryError: '' });

const xFormId = CoreModules.useAppSelector((state) => state.project.projectInfo.xform_id);
const formCategoryList = useAppSelector((state) => state.createproject.formCategoryList);
const sortedFormCategoryList = formCategoryList.slice().sort((a, b) => a.title.localeCompare(b.title));
const customFileValidity = useAppSelector((state) => state.createproject.customFileValidity);
Expand Down Expand Up @@ -64,6 +65,7 @@ const FormUpdateTab = ({ projectId }) => {
if (validateForm()) {
dispatch(
PostFormUpdate(`${import.meta.env.VITE_API_URL}/projects/update-form?project_id=${projectId}`, {
xformId: xFormId,
category: selectedCategory,
upload: uploadForm && uploadForm?.[0]?.url,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useEffect, useState } from 'react';
import CoreModules from '@/shared/CoreModules';
import AssetModules from '@/shared/AssetModules';
import { CommonActions } from '@/store/slices/CommonSlice';
import Button from '@/components/common/Button';
import { ProjectActions } from '@/store/slices/ProjectSlice';
import environment from '@/environment';
Expand All @@ -13,8 +14,8 @@ import ProjectTaskStatus from '@/api/ProjectTaskStatus';
import MapStyles from '@/hooks/MapStyles';

type TaskFeatureSelectionPopupPropType = {
featureProperties: TaskFeatureSelectionProperties | null;
taskId: number;
featureProperties: TaskFeatureSelectionProperties | null;
taskFeature: Record<string, any>;
map: any;
view: any;
Expand Down Expand Up @@ -128,24 +129,22 @@ const TaskFeatureSelectionPopup = ({
}
isLoading={updateEntityStatusLoading}
onClick={() => {
// XForm name is constructed from lower case project title with underscores
const projectName = projectInfo.title.toLowerCase().split(' ').join('_');
const projectCategory = projectInfo.xform_category;
const formName = `${projectName}_${projectCategory}`;

const xformId = projectInfo.xform_id;
const entity = entityOsmMap.find((x) => x.osm_id === featureProperties?.osm_id);
const entityUuid = entity ? entity.id : null;

if (!formName || !entityUuid) {
if (!xformId || !entityUuid) {
return;
}

dispatch(
UpdateEntityStatus(`${import.meta.env.VITE_API_URL}/projects/${currentProjectId}/entity/status`, {
entity_id: entityUuid,
status: 1,
label: '',
label: `Task ${taskId} Feature ${entity.osm_id}`,
}),
);

if (task_status === 'READY') {
dispatch(
ProjectTaskStatus(
Expand All @@ -163,7 +162,23 @@ const TaskFeatureSelectionPopup = ({
);
}

document.location.href = `odkcollect://form/${formName}?existing=${entityUuid}`;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);

if (isMobile) {
// Load entity in ODK Collect by intent
document.location.href = `odkcollect://form/${xformId}?task_id=${taskId}&existing=${entityUuid}`;
} else {
dispatch(
CommonActions.SetSnackBar({
open: true,
message: 'Requires a mobile phone with ODK Collect.',
variant: 'warning',
duration: 3000,
}),
);
}
}}
/>
</div>
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/models/project/projectModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type projectInfoType = {
description: string;
short_description: string;
xform_category: string;
xform_id: string;
data_extract_url: string;
odk_token: string;
num_contributors: any;
Expand Down
Loading