Skip to content

Commit

Permalink
feat: configurable XLSForm new_feature question geometry type (#329)
Browse files Browse the repository at this point in the history
* build: update container versions for compose pytest stack

* feat: all the geometry question type to be configured for append_mandatory_fields

* fix: update mandatory xlsform new_feature field text to be generic
  • Loading branch information
spwoodcock authored Jan 10, 2025
1 parent 755e24a commit ec9d11a
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 112 deletions.
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ services:
# retries: 3

proxy:
image: "docker.io/nginx:1.25.3-bookworm"
image: "docker.io/nginx:1.27.0-bookworm"
depends_on:
central:
condition: service_healthy
Expand All @@ -68,7 +68,7 @@ services:
restart: "unless-stopped"

central:
image: "ghcr.io/hotosm/fmtm/odkcentral:v2024.1.0"
image: "ghcr.io/hotosm/fmtm/odkcentral:v2024.2.1"
depends_on:
central-db:
condition: service_healthy
Expand Down Expand Up @@ -98,7 +98,7 @@ services:
restart: "unless-stopped"

central-db:
image: "postgis/postgis:14-3.4-alpine"
image: "postgis/postgis:17-3.5-alpine"
environment:
- POSTGRES_USER=odk
- POSTGRES_PASSWORD=odk
Expand Down
243 changes: 135 additions & 108 deletions osm_fieldwork/form_components/mandatory_fields.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/python3

# Copyright (c) 2020, 2021, 2022, 2023, 2024 Humanitarian OpenStreetMap Team
# Copyright (c) Humanitarian OpenStreetMap Team
#
# This file is part of OSM-Fieldwork.
#
Expand Down Expand Up @@ -40,120 +40,147 @@
"""

from datetime import datetime
from enum import Enum

import pandas as pd

current_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

meta_data = [
{"type": "start", "name": "start"},
{"type": "end", "name": "end"},
{"type": "today", "name": "today"},
{"type": "phonenumber", "name": "phonenumber"},
{"type": "deviceid", "name": "deviceid"},
{"type": "username", "name": "username"},
{
"type": "email",
"name": "email",
},
]
class DbGeomType(str, Enum):
"""Type for new geometries collected."""

meta_df = pd.DataFrame(meta_data)
POINT = "POINT"
POLYGON = "POLYGON"
LINESTRING = "LINESTRING"

mandatory_data = [
{
"type": "note",
"name": "instructions",
"label::english(en)": """Welcome ${username}. This survey form was generated
by HOT's FMTM to record ${form_category} map features.""",
"label::nepali(ne)": """स्वागत छ ${username}। ${form_category} नक्सा Data रेकर्ड गर्न HOT को FMTM द्वारा
यो सर्वेक्षण फारम उत्पन्न भएको थियो।""",
},
{"notes": "Fields essential to FMTM"},
{"type": "start-geopoint", "name": "warmup", "notes": "collects location on form start"},
{"type": "select_one_from_file features.csv", "name": "feature", "label::english(en)": "Geometry", "appearance": "map"},
{
"type": "geopoint",
"name": "new_feature",
"label::english(en)": "Alternatively, take a gps coordinates of a new feature",
"label::nepali(ne)": "वैकल्पिक रूपमा, नयाँ सुविधाको GPS निर्देशांक लिनुहोस्।",
"appearance": "placement-map",
"relevant": "${feature}= ''",
"required": "yes",
},
{
"type": "calculate",
"name": "form_category",
"label::english(en)": "FMTM form category",
"appearance": "minimal",
"calculation": "once('Unkown')",
},
{
"type": "calculate",
"name": "xid",
"notes": "e.g. OSM ID",
"label::english(en)": "Feature ID",
"appearance": "minimal",
"calculation": "if(${feature} != '', instance('features')/root/item[name=${feature}]/osm_id, '')",
},
{
"type": "calculate",
"name": "xlocation",
"notes": "e.g. OSM Geometry",
"label::english(en)": "Feature Geometry",
"appearance": "minimal",
"calculation": "if(${feature} != '', instance('features')/root/item[name=${feature}]/geometry, ${new_feature})",
"save_to": "geometry",
},
{
"type": "calculate",
"name": "task_id",
"notes": "e.g. FMTM Task ID",
"label::english(en)": "Task ID",
"appearance": "minimal",
"calculation": "if(${feature} != '', instance('features')/root/item[name=${feature}]/task_id, '')",
"save_to": "task_id",
},
{
"type": "calculate",
"name": "status",
"notes": "Update the Entity 'status' field",
"label::english(en)": "Mapping Status",
"appearance": "minimal",
"calculation": """if(${new_feature} != '', 2,
if(${building_exists} = 'no', 5,
if(${digitisation_correct} = 'no', 6,
${status})))""",
"default": "2",
"trigger": "${new_feature}",
"save_to": "status",
},
{
"type": "select_one yes_no",
"name": "building_exists",
"label::english(en)": "Does this feature exist?",
"label::nepali(ne)": "के यो भवन अवस्थित छ?",
"relevant": "${feature} != '' ",
},
{
"type": "calculate",
"name": "submission_ids",
"notes": "Update the submission ids",
"label::english(en)": "Submission ids",
"appearance": "minimal",
"calculation": """if(
instance('features')/root/item[name=${feature}]/submission_ids = '',
${instanceID},
concat(instance('features')/root/item[name=${feature}]/submission_ids, ',', ${instanceID})
)""",
"save_to": "submission_ids",
},
]

mandatory_df = pd.DataFrame(mandatory_data)
meta_df = pd.DataFrame(
[
{"type": "start", "name": "start"},
{"type": "end", "name": "end"},
{"type": "today", "name": "today"},
{"type": "phonenumber", "name": "phonenumber"},
{"type": "deviceid", "name": "deviceid"},
{"type": "username", "name": "username"},
{
"type": "email",
"name": "email",
},
]
)


def get_mandatory_fields(new_geom_type: DbGeomType):
"""Return the mandatory fields data."""
if new_geom_type == DbGeomType.POINT:
geom_field = "geopoint"
elif new_geom_type == DbGeomType.POLYGON:
geom_field = "geoshape"
elif new_geom_type == DbGeomType.LINESTRING:
geom_field = "geotrace"
else:
raise ValueError(f"Unsupported geometry type: {new_geom_type}")

return [
{
"type": "note",
"name": "instructions",
"label::english(en)": """Welcome ${username}. This survey form was generated
by HOT's FMTM to record ${form_category} map features.""",
"label::nepali(ne)": """स्वागत छ ${username}। ${form_category} नक्सा Data रेकर्ड गर्न HOT को FMTM द्वारा
यो सर्वेक्षण फारम उत्पन्न भएको थियो।""",
},
{"notes": "Fields essential to FMTM"},
{"type": "start-geopoint", "name": "warmup", "notes": "collects location on form start"},
{"type": "select_one_from_file features.csv", "name": "feature", "label::english(en)": "Geometry", "appearance": "map"},
{
"type": geom_field,
"name": "new_feature",
"label::english(en)": "Please draw a new geometry",
"label::nepali(ne)": "कृपया नयाँ ज्यामिति कोर्नुहोस्।",
"appearance": "placement-map",
"relevant": "${feature}= ''",
"required": "yes",
},
{
"type": "calculate",
"name": "form_category",
"label::english(en)": "FMTM form category",
"appearance": "minimal",
"calculation": "once('Unkown')",
},
{
"type": "calculate",
"name": "xid",
"notes": "e.g. OSM ID",
"label::english(en)": "Feature ID",
"appearance": "minimal",
"calculation": "if(${feature} != '', instance('features')/root/item[name=${feature}]/osm_id, '')",
},
{
"type": "calculate",
"name": "xlocation",
"notes": "e.g. OSM Geometry",
"label::english(en)": "Feature Geometry",
"appearance": "minimal",
"calculation": "if(${feature} != '', instance('features')/root/item[name=${feature}]/geometry, ${new_feature})",
"save_to": "geometry",
},
{
"type": "calculate",
"name": "task_id",
"notes": "e.g. FMTM Task ID",
"label::english(en)": "Task ID",
"appearance": "minimal",
"calculation": "if(${feature} != '', instance('features')/root/item[name=${feature}]/task_id, '')",
"save_to": "task_id",
},
{
"type": "calculate",
"name": "status",
"notes": "Update the Entity 'status' field",
"label::english(en)": "Mapping Status",
"appearance": "minimal",
"calculation": """if(${new_feature} != '', 2,
if(${building_exists} = 'no', 5,
if(${digitisation_correct} = 'no', 6,
${status})))""",
"default": "2",
"trigger": "${new_feature}",
"save_to": "status",
},
{
"type": "select_one yes_no",
"name": "building_exists",
"label::english(en)": "Does this feature exist?",
"label::nepali(ne)": "के यो भवन अवस्थित छ?",
"relevant": "${feature} != '' ",
},
{
"type": "calculate",
"name": "submission_ids",
"notes": "Update the submission ids",
"label::english(en)": "Submission ids",
"appearance": "minimal",
"calculation": """if(
instance('features')/root/item[name=${feature}]/submission_ids = '',
${instanceID},
concat(instance('features')/root/item[name=${feature}]/submission_ids, ',', ${instanceID})
)""",
"save_to": "submission_ids",
},
]


def create_survey_df(new_geom_type: DbGeomType) -> pd.DataFrame:
"""Create the survey sheet dataframe.
We do this in a function to allow the geometry type
for new data to be specified.
"""
fields = get_mandatory_fields(new_geom_type)
mandatory_df = pd.DataFrame(fields)
return pd.concat([meta_df, mandatory_df])

# Define the survey sheet
survey_df = pd.concat([meta_df, mandatory_df])

# Define entities sheet
entities_data = [
Expand All @@ -174,7 +201,7 @@
settings_data = [
{
"form_id": "mandatory_fields",
"version": current_datetime,
"version": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"form_title": "Mandatory Fields Form",
"allow_choice_duplicates": "yes",
}
Expand Down
26 changes: 25 additions & 1 deletion osm_fieldwork/update_xlsform.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
#!/usr/bin/python3

# Copyright (c) Humanitarian OpenStreetMap Team
#
# This file is part of OSM-Fieldwork.
#
# This is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with OSM-Fieldwork. If not, see <https:#www.gnu.org/licenses/>.
#

"""Update an existing XLSForm with additional fields useful for field mapping."""

import logging
Expand All @@ -11,7 +31,7 @@

from osm_fieldwork.form_components.choice_fields import choices_df, digitisation_choices_df
from osm_fieldwork.form_components.digitisation_fields import digitisation_df
from osm_fieldwork.form_components.mandatory_fields import entities_df, meta_df, settings_df, survey_df
from osm_fieldwork.form_components.mandatory_fields import DbGeomType, create_survey_df, entities_df, meta_df, settings_df

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -254,6 +274,7 @@ async def append_mandatory_fields(
form_category: str,
additional_entities: list[str] = None,
existing_id: str = None,
new_geom_type: DbGeomType = DbGeomType.POINT,
) -> tuple[str, BytesIO]:
"""Append mandatory fields to the XLSForm for use in FMTM.
Expand All @@ -265,6 +286,8 @@ async def append_mandatory_fields(
The values should be plural, so that 's' will be stripped in the
field name.
existing_id(str): an existing UUID to use for the form_id, else random uuid4.
new_geom_type (DbGeomType): the type of geometry required when collecting
new geometry data: point, line, polygon.
Returns:
tuple(str, BytesIO): the xFormId + the update XLSForm wrapped in BytesIO.
Expand All @@ -280,6 +303,7 @@ async def append_mandatory_fields(
custom_sheets = standardize_xlsform_sheets(custom_sheets)

log.debug("Merging survey sheet XLSForm data")
survey_df = create_survey_df(new_geom_type)
custom_sheets["survey"] = merge_dataframes(survey_df, custom_sheets.get("survey"), digitisation_df)
# Hardcode the form_category value for the start instructions
if form_category.endswith("s"):
Expand Down

0 comments on commit ec9d11a

Please sign in to comment.