Skip to content

Commit f41e91f

Browse files
committed
feat: wip replace json endpoints with htmx
1 parent a997cd5 commit f41e91f

27 files changed

Lines changed: 4135 additions & 396 deletions
125 KB
Loading

docs/manuals/qfield-by-hand.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Setting Up QField Manually
2+
3+
- FieldTM should be able to configure a QField project automatically.
4+
- Failing this, the steps below help to configure a QField project
5+
manually.
6+
7+
## 1. Create An XLSForm
8+
9+
- An XLSForm should be created, with question types as normal
10+
(choice fields, text input, image upload, etc).
11+
- It is **essential** to ensure that a question of type
12+
`geopoint`, `geotrace`, or `geoshape` is included in the form,
13+
so that QGIS knows which type of geometries you wish to collect.
14+
- Alternatively, if you are mapping existing geometries, the
15+
geometry type can be determined by QGIS by selecting the
16+
geometry later in a later stage.
17+
18+
## 2. Convert XLSForm To QGIS
19+
20+
- Open QGIS and install the XLSFormConverter plugin (by OpenGISch).
21+
- Go to the processing toolbox, and open the XLSFormConverter
22+
processing tool.
23+
24+
You will be presented with a set of field that need to be filled
25+
out:
26+
27+
![xlsformconverter](../images/qfield-xlsformconverter.png)
28+
29+
- Select the XLSForm you have created as the first field.
30+
- Ensure the 'Project language' matches exactly what is defined
31+
in the XLSForm.
32+
- If you are mapping existing geometries, here you can load the
33+
layer into QGIS, then select this layer to 'Pre-fill the project...'.
34+
- It is also a good idea to load the project AOI into QGIS, then
35+
select this layer as the 'Project Extent'. This will ensure
36+
QField zooms to this location on first load (if you don’t do this,
37+
users will have to navigate to the AOI manually or using GPS).
38+
- It's good to select the project CRS as standard (EPSG:4326 generally).
39+
- Once you have completed this, a survey layer will be added to your
40+
QGIS project. By default, this will output to a temporary directory.
41+
Open the project in the temporary directory to continue.
42+
- You can then add a boundary for the AOI and a basemap as you would
43+
in any normal QGIS project.
44+
45+
## 3. Configure The Project Further
46+
47+
- You can configure the project in any way that you like, within the
48+
constraints of what you can do in QGIS (eg. styling, labels, etc).
49+
QField users will see those same styles when they use the app.
50+
- Basemaps such as satellite or drone imagery can be added as layers,
51+
into a 'mutually exclusive' basemap group if preferred.
52+
- Symbology can be updated to vary based on data type. For example,
53+
if a question asks the user to define the feature 'category', the
54+
symbol on the map can reflect the categories (with a legend).
55+
- You may wish to add additional layers to the project, e.g.:
56+
- Task boundaries with mapper assignments.
57+
- Location of cell towers to get phone signal.
58+
- Contours to show terrain.
59+
- Other georeferenced data, such as scanned in archive maps,
60+
or drawn community maps.
61+
62+
## 4. Syncing The Project To The Cloud
63+
64+
- Install QField Sync in QGIS.
65+
- You need to register an account at
66+
[https://app.qfield.cloud](https://app.qfield.cloud).
67+
- QField Cloud is the bridge between QGIS and the users'
68+
interaction with the project.
69+
- Log in to QField via the QField Sync plugin in QGIS.
70+
- Click on the cloud icon and click create new project.
71+
- Choose 'Convert currently open project to a cloud project'.
72+
- Add a name, choose HOTOSM as the owner, then convert and upload.
73+
74+
## 5. Open In The QField App
75+
76+
- Load the QField app and log in with your account.
77+
- Download the newly created project and open it.
78+
- It's possible to share the project with other users
79+
by a QRCode to scan.
80+
- Mapped data can be synced at an interval (defined in the
81+
QFieldSync project settings), or manually by the user.
82+
- The manager can then sync the uploaded data into their
83+
desktop QGIS project, to track progress.
84+
85+
## Useful reference and resources
86+
87+
- <https://xlsform.org/en/>
88+
- <https://docs.getodk.org/form-question-types/>
89+
- <https://docs.qfield.org/>

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ nav:
8383
- Field Mapping Examples: manuals/field-mapping-examples.md
8484
- Visualising Data Externally: https://docs.getodk.org/tutorial-mapping-households
8585
- ODKCollect Offline Maps: https://docs.getodk.org/collect-offline-maps
86+
- QField Project By Hand: manuals/qfield-by-hand.md
8687
- Developer Guide:
8788
- Practices:
8889
- Dev Practices: https://docs.hotosm.org/dev-practices

src/backend/app/central/central_crud.py

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939

4040
from app.central import central_deps, central_schemas
4141
from app.config import settings
42-
from app.db.enums import DbGeomType, EntityState
42+
from app.db.enums import DbGeomType
4343
from app.db.models import DbProject, DbTemplateXLSForm
4444
from app.db.postgis_utils import (
4545
geojson_to_javarosa_geom,
@@ -52,25 +52,6 @@
5252
log = logging.getLogger(__name__)
5353

5454

55-
STATUS_VISUALS = {
56-
EntityState.MARKED_BAD.value: {
57-
"fill": "#ff0000",
58-
"marker-color": "#ff0000",
59-
"stroke": "#cc0000",
60-
},
61-
EntityState.SURVEY_SUBMITTED.value: {
62-
"fill": "#00ff00",
63-
"marker-color": "#00ff00",
64-
"stroke": "#00cc00",
65-
},
66-
EntityState.VALIDATED.value: {
67-
"fill": "#008000",
68-
"marker-color": "#008000",
69-
"stroke": "#006600",
70-
},
71-
}
72-
73-
7455
def get_odk_project(odk_central: Optional[central_schemas.ODKCentral] = None):
7556
"""Helper function to get the OdkProject with credentials."""
7657
if odk_central:

src/backend/app/central/central_routes.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -200,22 +200,24 @@ async def upload_project_xlsform(
200200
)
201201

202202
# Write XLS form content to db
203+
# Ensure BytesIO is at the beginning before reading
204+
project_xlsform.seek(0)
203205
xlsform_db_bytes = project_xlsform.getvalue()
204206
if len(xlsform_db_bytes) == 0 or not xform_id:
205207
raise HTTPException(
206208
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
207209
detail="There was an error modifying the XLSForm!",
208210
)
209-
log.debug(f"Setting project XLSForm db data for xFormId: {xform_id}")
211+
log.debug(
212+
f"Setting project XLSForm db data for xFormId: {xform_id}, bytes length: {len(xlsform_db_bytes)}"
213+
)
210214
await DbProject.update(
211215
db,
212216
project_id,
213-
ProjectUpdate(
214-
xlsform_content=xlsform_db_bytes,
215-
odk_form_id=xform_id,
216-
),
217+
ProjectUpdate(xlsform_content=xlsform_db_bytes),
217218
)
218219
await db.commit()
220+
log.debug(f"Successfully saved XLSForm to database for project {project_id}")
219221

220222
return {"message": "Your form is valid"}
221223

@@ -318,7 +320,6 @@ async def refresh_appuser_token(
318320
project = current_user.get("project")
319321
project_id = project.id
320322
project_odk_id = project.external_project_id
321-
project_xform_id = project.odk_form_id
322323
# ODK credentials not stored on project, use None to fall back to env vars
323324
project_odk_creds = None
324325

@@ -371,7 +372,6 @@ async def upload_form_media(
371372
project = current_user.get("project")
372373
project_id = project.id
373374
project_odk_id = project.external_project_id
374-
project_xform_id = project.odk_form_id
375375
# ODK credentials not stored on project, use None to fall back to env vars
376376
project_odk_creds = None
377377

@@ -453,7 +453,6 @@ async def list_form_media(
453453
project = current_user.get("project")
454454
project_id = project.id
455455
project_odk_id = project.external_project_id
456-
project_xform_id = project.odk_form_id
457456
# ODK credentials not stored on project, use None to fall back to env vars
458457
project_odk_creds = None
459458

@@ -495,7 +494,6 @@ async def get_form_media(
495494
project = current_user.get("project")
496495
project_id = project.id
497496
project_odk_id = project.external_project_id
498-
project_xform_id = project.odk_form_id
499497
# ODK credentials not stored on project, use None to fall back to env vars
500498
project_odk_creds = None
501499

src/backend/app/db/enums.py

Lines changed: 1 addition & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
#
1818
"""Enum definitions to translate values into human enum strings."""
1919

20-
from enum import Enum, IntEnum, StrEnum
20+
from enum import Enum, StrEnum
2121

2222

2323
class ProjectStatus(StrEnum, Enum):
@@ -29,14 +29,6 @@ class ProjectStatus(StrEnum, Enum):
2929
COMPLETED = "COMPLETED"
3030

3131

32-
class OrganisationType(StrEnum, Enum):
33-
"""An organisation's subscription type."""
34-
35-
FREE = "FREE"
36-
DISCOUNTED = "DISCOUNTED"
37-
FULL_FEE = "FULL_FEE"
38-
39-
4032
class ProjectPriority(StrEnum, Enum):
4133
"""All possible project priority levels."""
4234

@@ -58,59 +50,6 @@ class ProjectRole(StrEnum, Enum):
5850
PROJECT_ADMIN = "PROJECT_ADMIN"
5951

6052

61-
class MappingLevel(StrEnum, Enum):
62-
"""The mapping level the mapper has achieved."""
63-
64-
BEGINNER = "BEGINNER"
65-
INTERMEDIATE = "INTERMEDIATE"
66-
ADVANCED = "ADVANCED"
67-
68-
69-
class TaskEvent(StrEnum, Enum):
70-
"""Task events via API.
71-
72-
`MAP` -- Set to *locked for mapping*, i.e. mapping in progress.
73-
`FINISH` -- Set to *unlocked to validate*, i.e. is mapped.
74-
`VALIDATE` -- Set to *locked for validation*, i.e. validation in progress.
75-
`GOOD` -- Set the state to *unlocked done*.
76-
`BAD` -- Set the state *unlocked to map* again, to be mapped once again.
77-
`SPLIT` -- Set the state *unlocked done* then generate additional
78-
subdivided task areas.
79-
`MERGE` -- Set the state *unlocked done* then generate additional
80-
merged task area.
81-
`ASSIGN` -- For a requester user to assign a task to another user.
82-
Set the state *locked for mapping* passing in the required user id.
83-
Also notify the user they should map the area.
84-
`COMMENT` -- Keep the state the same, but simply add a comment.
85-
"""
86-
87-
MAP = "MAP"
88-
FINISH = "FINISH"
89-
VALIDATE = "VALIDATE"
90-
GOOD = "GOOD"
91-
BAD = "BAD"
92-
SPLIT = "SPLIT"
93-
MERGE = "MERGE"
94-
ASSIGN = "ASSIGN"
95-
COMMENT = "COMMENT"
96-
RESET = "RESET"
97-
98-
99-
class EntityState(IntEnum, Enum):
100-
"""State options for Entities in ODK.
101-
102-
NOTE here we started with int enums and it's hard to migrate.
103-
NOTE we will continue to use int values in the form.
104-
NOTE we keep BAD=6 for legacy reasons too.
105-
"""
106-
107-
READY = 0
108-
OPENED_IN_ODK = 1
109-
SURVEY_SUBMITTED = 2
110-
VALIDATED = 5
111-
MARKED_BAD = 6
112-
113-
11453
class TaskSplitType(StrEnum, Enum):
11554
"""Task splitting type for area-splitter."""
11655

src/backend/app/db/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ class DbProject:
323323
updated_at: Optional[AwareDatetime] = None
324324
# Encrypted ODK appuser token (may be null until generated)
325325
odk_token: Optional[str] = None
326+
# GeoJSON data extract stored directly in database (replaces S3 URL approach)
327+
data_extract_geojson: Optional[dict] = None
326328

327329
@classmethod
328330
async def one(
@@ -447,6 +449,10 @@ async def create(cls, db: AsyncConnection, project_in: Self) -> Self:
447449
value_placeholders.append(f"ST_GeomFromGeoJSON(%({key})s)")
448450
# Must be string json for db input
449451
model_dump[key] = json.dumps(model_dump[key])
452+
elif key == "data_extract_geojson" and isinstance(model_dump[key], dict):
453+
# Convert GeoJSON dict to JSON string for JSONB column
454+
value_placeholders.append(f"%({key})s::jsonb")
455+
model_dump[key] = json.dumps(model_dump[key])
450456
else:
451457
value_placeholders.append(f"%({key})s")
452458

@@ -527,6 +533,12 @@ async def update(
527533
# Remove plaintext password if present
528534
model_dump.pop("external_project_password", None)
529535

536+
# Convert dict/JSONB fields to JSON strings for database
537+
for key in list(model_dump.keys()):
538+
if key == "data_extract_geojson" and isinstance(model_dump[key], dict):
539+
# Convert GeoJSON dict to JSON string for JSONB column
540+
model_dump[key] = json.dumps(model_dump[key])
541+
530542
placeholders = [f"{key} = %({key})s" for key in model_dump.keys()]
531543

532544
# NOTE we want a trackable hashtag DOMAIN-PROJECT_ID

0 commit comments

Comments
 (0)