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

Separate out primary mapping geometry from new feature geometry (user select during proj create) #2225

Merged
merged 8 commits into from
Feb 25, 2025
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
repos:
# Versioning: Commit messages & changelog
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.2.1
rev: v4.2.2
hooks:
- id: commitizen
stages: [commit-msg]

# Lint / autoformat: Python code
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: "v0.9.6"
rev: "v0.9.7"
hooks:
# Run the linter
- id: ruff
Expand All @@ -21,7 +21,7 @@ repos:

# Deps: ensure Python uv lockfile is up to date
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.6.0
rev: 0.6.2
hooks:
- id: uv-lock
files: src/backend/pyproject.toml
Expand Down Expand Up @@ -50,7 +50,7 @@ repos:

# Autoformat: YAML, JSON, Markdown, etc.
- repo: https://github.com/pycontribs/mirrors-prettier
rev: v3.5.1
rev: v3.5.2
hooks:
- id: prettier
args:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,11 @@ Alternatively see the [docs](https://docs.fmtm.dev) for various deployment guide
|βœ…| πŸ“± features turn green once mapped |
|βœ…| πŸ“± better support for mapping **new** points, lines, polygons |
|βœ…| πŸ“± navigation and capability for routing to map features |
|βœ…| πŸ–₯️ organization creation and management |
|βš™οΈ| πŸ“± integrate ODK Web Forms (to avoid switching apps) |
|βš™οΈ| πŸ–₯️ multiple approaches to task splitting algorithm |
|βš™οΈ| πŸ–₯️ user role management per project |
| | πŸ“± fully offline field mapping |
| | πŸ–₯️ organization creation and management |
| | πŸ–₯️ simplify project creation with basic / advanced workflows |
| | πŸ–₯️ improvements to the validation criteria and workflow |
| | πŸ–₯️ export (+merge) the final data to OpenStreetMap |
Expand Down
5 changes: 2 additions & 3 deletions docs/manuals/project-managers.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,8 @@ Alternatively, request the creation of a new organisation for your team:

![image](https://github.com/user-attachments/assets/64aeda34-c682-4fdc-8c2f-1fd83e29c61f)

13. The step 3 is to choose the form category of the project. Meaning if you want
to survey each household or healthcare or educational institutes.
You can upload the custom XLS form by clicking on the checkbox.
13. Upload your XLSForm. Here you download pre-defined forms from FMTM.
Some are specifically designed to work with OpenStreetMap.
Click on "Next" to proceed.

![image](https://github.com/user-attachments/assets/cdf1e050-42ec-4149-bf97-0d841bc5117f)
Expand Down
6 changes: 3 additions & 3 deletions docs/manuals/xlsform-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,10 @@ overview of the injected fields and their purposes:
4. If no feature is selected, the user would be prompted to take a GPS coordinate
of new feature.
Note: One of these two options must be filled up to proceed.
5. We also dedicate few rows for calculating form category, osm ID,
Task Id and mapping status used on FMTM.
5. We also dedicate few rows for calculating OSM ID,
Task ID and mapping status used on FMTM.
6. We then ask mappers to answer if the feature exist in reality?
If yes, the custom form uploaded by user is proceeded.
If yes, user proceeds with form submission.
7. If no, the user is prompted to capture an image (if available) and the form
is terminated with a message:
"You cannot proceed with data acquisition if the building does not exist."
Expand Down
5 changes: 4 additions & 1 deletion src/backend/app/auth/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,14 @@ async def wrap_check_access(


async def project_manager(
project: Annotated[DbProject, Depends(get_project)],
project_id: int,
db: Annotated[Connection, Depends(db_conn)],
current_user: Annotated[AuthUser, Depends(login_required)],
) -> ProjectUserDict:
"""A project manager for a specific project."""
# NOTE here we get the project manually to avoid warnings before the project
# if fully created yet (about odk_token not existing)
project = await DbProject.one(db, project_id, warn_on_missing_token=False)
return await wrap_check_access(
project,
db,
Expand Down
24 changes: 12 additions & 12 deletions src/backend/app/central/central_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,16 +274,16 @@ def get_project_form_xml(

async def append_fields_to_user_xlsform(
xlsform: BytesIO,
form_category: str = "buildings",
additional_entities: list[str] = None,
existing_id: str = None,
new_geom_type: DbGeomType = DbGeomType.POLYGON,
form_name: str = "buildings",
additional_entities: Optional[list[str]] = None,
existing_id: Optional[str] = None,
new_geom_type: Optional[DbGeomType] = DbGeomType.POLYGON,
) -> tuple[str, BytesIO]:
"""Helper to return the intermediate XLSForm prior to convert."""
log.debug("Appending mandatory FMTM fields to XLSForm")
return await append_mandatory_fields(
xlsform,
form_category=form_category,
form_name=form_name,
additional_entities=additional_entities,
existing_id=existing_id,
new_geom_type=new_geom_type,
Expand All @@ -292,15 +292,15 @@ async def append_fields_to_user_xlsform(

async def validate_and_update_user_xlsform(
xlsform: BytesIO,
form_category: str = "buildings",
additional_entities: list[str] = None,
existing_id: str = None,
new_geom_type: DbGeomType = DbGeomType.POLYGON,
form_name: str = "buildings",
additional_entities: Optional[list[str]] = None,
existing_id: Optional[str] = None,
new_geom_type: Optional[DbGeomType] = DbGeomType.POLYGON,
) -> BytesIO:
"""Wrapper to append mandatory fields and validate user uploaded XLSForm."""
xform_id, updated_file_bytes = await append_fields_to_user_xlsform(
xlsform,
form_category=form_category,
form_name=form_name,
additional_entities=additional_entities,
existing_id=existing_id,
new_geom_type=new_geom_type,
Expand All @@ -315,7 +315,7 @@ async def update_project_xform(
xform_id: str,
odk_id: int,
xlsform: BytesIO,
category: str,
# osm_category: str,
odk_credentials: central_schemas.ODKCentralDecrypted,
) -> None:
"""Update and publish the XForm for a project.
Expand All @@ -324,7 +324,6 @@ async def update_project_xform(
xform_id (str): The UUID of the existing XForm in ODK Central.
odk_id (int): ODK Central form ID.
xlsform (UploadFile): XForm data.
category (str): Category of the XForm.
odk_credentials (central_schemas.ODKCentralDecrypted): ODK Central creds.

Returns: None
Expand All @@ -337,6 +336,7 @@ async def update_project_xform(
xform_obj.createForm(
odk_id,
xform_bytesio,
# NOTE this variable is incorrectly named and should be form_id
form_name=xform_id,
)
# The draft form must be published after upload
Expand Down
15 changes: 3 additions & 12 deletions src/backend/app/central/central_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@
from contextlib import asynccontextmanager
from io import BytesIO
from pathlib import Path
from typing import Optional

from fastapi import File, UploadFile
from fastapi import UploadFile
from fastapi.exceptions import HTTPException
from osm_fieldwork.OdkCentralAsync import OdkDataset, OdkForm

Expand Down Expand Up @@ -65,8 +64,8 @@ async def get_async_odk_form(odk_creds: ODKCentralDecrypted):

async def validate_xlsform_extension(xlsform: UploadFile):
"""Validate an XLSForm has .xls or .xlsx extension."""
file = Path(xlsform.filename)
file_ext = file.suffix.lower()
filename = Path(xlsform.filename)
file_ext = filename.suffix.lower()

allowed_extensions = [".xls", ".xlsx"]
if file_ext not in allowed_extensions:
Expand All @@ -80,11 +79,3 @@ async def validate_xlsform_extension(xlsform: UploadFile):
async def read_xlsform(xlsform: UploadFile) -> BytesIO:
"""Read an XLSForm, validate extension, return wrapped in BytesIO."""
return await validate_xlsform_extension(xlsform)


async def read_optional_xlsform(
xlsform: Optional[UploadFile] = File(None),
) -> Optional[BytesIO]:
"""Read an XLSForm, validate extension, return wrapped in BytesIO."""
if xlsform:
return await validate_xlsform_extension(xlsform)
4 changes: 2 additions & 2 deletions src/backend/app/db/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,9 @@ class XLSFormType(StrEnum, Enum):
The the value is the user facing form name (e.g. healthcare).
"""

buildings = "buildings"
buildings = "OSM Buildings"
# highways = "highways"
health = "healthcare"
health = "OSM Healthcare"
# toilets = "toilets"
# religious = "religious"
# landusage = "landusage"
Expand Down
16 changes: 11 additions & 5 deletions src/backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1026,7 +1026,7 @@ class DbProject(BaseModel):
custom_tms_url: Optional[str] = None
status: Optional[ProjectStatus] = None
visibility: Optional[ProjectVisibility] = None
xform_category: Optional[str] = None
osm_category: Optional[str] = None
odk_form_id: Optional[str] = None
xlsform_content: Optional[bytes] = None
mapper_level: Optional[MappingLevel] = None
Expand All @@ -1036,11 +1036,11 @@ class DbProject(BaseModel):
odk_central_user: Optional[str] = None
odk_central_password: Optional[str] = None
odk_token: Optional[str] = None
data_extract_type: Optional[str] = None
data_extract_url: Optional[str] = None
task_split_dimension: Optional[int] = None
task_num_buildings: Optional[int] = None
new_geom_type: Optional[DbGeomType] = None
primary_geom_type: Optional[DbGeomType] = None # the main geometries surveyed
new_geom_type: Optional[DbGeomType] = None # when new geometries are drawn
geo_restrict_distance_meters: Optional[PositiveInt] = None
geo_restrict_force_error: Optional[bool] = None
hashtags: Optional[list[str]] = None
Expand Down Expand Up @@ -1087,7 +1087,13 @@ def set_odk_credentials_on_project(
)

@classmethod
async def one(cls, db: Connection, project_id: int, minimal: bool = False) -> Self:
async def one(
cls,
db: Connection,
project_id: int,
minimal: bool = False,
warn_on_missing_token: bool = True,
) -> Self:
"""Get project by ID, including all tasks and other details."""
# Simpler query without additional metadata
if minimal:
Expand Down Expand Up @@ -1231,7 +1237,7 @@ async def one(cls, db: Connection, project_id: int, minimal: bool = False) -> Se
if db_project is None:
raise KeyError(f"Project ({project_id}) not found.")

if db_project.odk_token is None:
if warn_on_missing_token and db_project.odk_token is None:
log.warning(
f"Project ({db_project.id}) has no 'odk_token' set. "
"The QRCode will not work!"
Expand Down
6 changes: 3 additions & 3 deletions src/backend/app/db/postgis_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,17 +774,17 @@ def merge_polygons(
) from e


def get_osm_geometries(form_category, geometry):
def get_osm_geometries(osm_category, geometry):
"""Request a snapshot based on the provided geometry.

Args:
form_category(str): feature category type (eg: buildings).
osm_category(str): feature category type (eg: buildings).
geometry (str): The geometry data in JSON format.

Returns:
dict: The JSON response containing the snapshot data.
"""
config_filename = XLSFormType(form_category).name
config_filename = XLSFormType(osm_category).name
data_model = f"{data_models_path}/{config_filename}.yaml"

with open(data_model, "rb") as data_model_yaml:
Expand Down
10 changes: 5 additions & 5 deletions src/backend/app/helpers/helper_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@

@router.get("/download-template-xlsform")
async def download_template(
category: XLSFormType,
form_type: XLSFormType,
):
"""Download an XLSForm template to fill out."""
filename = XLSFormType(category).name
xlsform_path = f"{xlsforms_path}/{filename}.xls"
"""Download example XLSForm from FMTM."""
form_filename = XLSFormType(form_type).name
xlsform_path = f"{xlsforms_path}/{form_filename}.xls"
if Path(xlsform_path).exists():
return FileResponse(xlsform_path, filename="form.xls")
return FileResponse(xlsform_path, filename=f"{form_filename}.xls")
else:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Form not found")

Expand Down
Loading
Loading