Skip to content

Commit ab3dcb1

Browse files
committed
Add location (building) support to ODK entity export
The entity export workflow now handles both pixel and location rounds. Rounds are split by sampling_target in the route, and the workflow fetches from coverage_pixel or coverage tables accordingly. Location entities use external_id as label with point geometry and quadkey as details. The geometry type picker is only shown when pixel rounds are selected.
1 parent c2cb92d commit ab3dcb1

5 files changed

Lines changed: 194 additions & 89 deletions

File tree

truecover-app/src/components/ExportLocationsModal.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ const ExportLocationsModal: React.FC<ExportLocationsModalProps> = ({
4242
// Entity export workflow state
4343
const [exportWorkflowId, setExportWorkflowId] = useState<string | null>(null);
4444
const [exportProgress, setExportProgress] = useState<{
45-
total_pixels: number;
46-
created_pixels: number;
47-
current_quadkey: string;
45+
total_entities: number;
46+
created_entities: number;
47+
current_label: string;
4848
} | null>(null);
4949
const [isExporting, setIsExporting] = useState(false);
5050
const [pixelGeometryType, setPixelGeometryType] = useState<'centroid' | 'boundary'>('centroid');
@@ -748,8 +748,11 @@ const ExportLocationsModal: React.FC<ExportLocationsModalProps> = ({
748748
)}
749749
</div>
750750

751-
{/* Pixel Geometry Type Selection */}
752-
<div>
751+
{/* Pixel Geometry Type Selection - only shown when pixel rounds are selected */}
752+
{selectedRoundIds.some(id => {
753+
const round = rounds.find(r => r.id === id);
754+
return round?.sampling_target === 'pixels';
755+
}) && <div>
753756
<label className="block text-sm font-mono font-bold text-tactical-text-primary uppercase tracking-wider mb-2">
754757
Pixel Geometry Type
755758
</label>
@@ -787,7 +790,7 @@ const ExportLocationsModal: React.FC<ExportLocationsModalProps> = ({
787790
</div>
788791
</label>
789792
</div>
790-
</div>
793+
</div>}
791794
</>
792795
)}
793796

@@ -826,12 +829,12 @@ const ExportLocationsModal: React.FC<ExportLocationsModalProps> = ({
826829
<div className="flex justify-between">
827830
<span className="text-tactical-text-dim">Progress:</span>
828831
<span className="text-tactical-text-primary font-bold">
829-
{exportProgress.created_pixels} / {exportProgress.total_pixels} pixels
832+
{exportProgress.created_entities} / {exportProgress.total_entities} entities
830833
</span>
831834
</div>
832-
{exportProgress.current_quadkey && (
835+
{exportProgress.current_label && (
833836
<div className="text-xs text-tactical-text-dim">
834-
Current: {exportProgress.current_quadkey}
837+
Current: {exportProgress.current_label}
835838
</div>
836839
)}
837840
</div>

truecover-app/src/services/api.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -962,15 +962,15 @@ export const entityExportApi = {
962962
workflow_id: string;
963963
status: string;
964964
progress?: {
965-
total_pixels: number;
966-
created_pixels: number;
967-
current_quadkey: string;
965+
total_entities: number;
966+
created_entities: number;
967+
current_label: string;
968968
error_message: string | null;
969969
};
970970
result?: {
971971
success: boolean;
972-
total_pixels: number;
973-
created_pixels: number;
972+
total_entities: number;
973+
created_entities: number;
974974
message: string;
975975
};
976976
error?: string;

truecover-backend/routes/entity_export.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
# ABOUTME: Routes for exporting pixel coverage data to ODK entity lists
1+
# ABOUTME: Routes for exporting coverage data (pixels and locations) to ODK entity lists
22
# ABOUTME: Handles starting and monitoring Temporal workflows for entity creation
33

44
from flask import Blueprint, jsonify, request
55
from auth.middleware import require_auth
66
from auth.helpers import check_campaign_access
7+
from db.connection import get_db_connection, return_db_connection
78

89
entity_export_bp = Blueprint('entity_export', __name__)
910

@@ -39,6 +40,29 @@ def start_entity_export_workflow(user, campaign_id):
3940
if not project_id:
4041
return jsonify({'error': 'project_id is required'}), 400
4142

43+
# Split rounds by sampling_target
44+
conn = get_db_connection()
45+
cursor = conn.cursor()
46+
try:
47+
placeholders = ','.join(['%s'] * len(round_ids))
48+
cursor.execute(f"""
49+
SELECT id, COALESCE(sampling_target, 'locations') as sampling_target
50+
FROM rounds
51+
WHERE id IN ({placeholders})
52+
""", tuple(round_ids))
53+
54+
pixel_round_ids = []
55+
location_round_ids = []
56+
for row in cursor.fetchall():
57+
rid, target = str(row[0]), row[1]
58+
if target == 'pixels':
59+
pixel_round_ids.append(rid)
60+
else:
61+
location_round_ids.append(rid)
62+
finally:
63+
cursor.close()
64+
return_db_connection(conn)
65+
4266
# Generate workflow ID
4367
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
4468
workflow_id = f"entity-export-{campaign_id}-{timestamp}"
@@ -48,7 +72,7 @@ async def start_workflow():
4872
client = await get_temporal_client()
4973
handle = await client.start_workflow(
5074
EntityExportWorkflow.run,
51-
args=[campaign_id, indicator_id, round_ids, project_id, geometry_type],
75+
args=[campaign_id, indicator_id, pixel_round_ids, location_round_ids, project_id, geometry_type],
5276
id=workflow_id,
5377
task_queue="truecover-tasks"
5478
)

truecover-backend/temporal/activities/entity_export.py

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
# ABOUTME: Temporal activities for exporting pixel coverage data to ODK entity lists
2-
# ABOUTME: Handles fetching pixel data and creating entities via Ona API
1+
# ABOUTME: Temporal activities for exporting coverage data to ODK entity lists
2+
# ABOUTME: Handles fetching pixel and location data and creating entities via Ona API
33

44
from temporalio import activity
55
from typing import List, Dict, Any
@@ -111,19 +111,84 @@ async def fetch_pixel_coverage_activity(
111111
return_db_connection(conn)
112112

113113

114+
@activity.defn
115+
async def fetch_location_coverage_activity(
116+
campaign_id: str,
117+
indicator_id: str,
118+
round_ids: List[str]
119+
) -> List[Dict[str, Any]]:
120+
"""
121+
Fetch location coverage data filtered by selected rounds.
122+
123+
Returns list of location coverage records with location details.
124+
"""
125+
conn = get_db_connection()
126+
cursor = conn.cursor()
127+
128+
try:
129+
# Get round numbers from round IDs
130+
placeholders = ','.join(['%s'] * len(round_ids))
131+
cursor.execute(f"""
132+
SELECT round_number
133+
FROM rounds
134+
WHERE id IN ({placeholders})
135+
""", tuple(round_ids))
136+
137+
round_numbers = [row[0] for row in cursor.fetchall()]
138+
139+
if not round_numbers:
140+
return []
141+
142+
cursor.execute("""
143+
SELECT DISTINCT
144+
c.id,
145+
l.external_id,
146+
l.latitude,
147+
l.longitude,
148+
l.quadkey,
149+
c.rounds
150+
FROM coverage c
151+
JOIN locations l ON l.id = c.location_id
152+
WHERE c.campaign_id = %s
153+
AND c.indicator_id = %s
154+
AND c.rounds && %s::integer[]
155+
ORDER BY l.external_id
156+
""", (campaign_id, indicator_id, round_numbers))
157+
158+
locations = []
159+
for row in cursor.fetchall():
160+
cov_id, external_id, latitude, longitude, quadkey, rounds = row
161+
locations.append({
162+
'id': str(cov_id),
163+
'external_id': external_id or '',
164+
'latitude': float(latitude) if latitude else None,
165+
'longitude': float(longitude) if longitude else None,
166+
'quadkey': quadkey or '',
167+
'rounds': rounds or []
168+
})
169+
170+
return locations
171+
172+
finally:
173+
cursor.close()
174+
return_db_connection(conn)
175+
176+
114177
@activity.defn
115178
async def create_odk_entity_activity(
116179
project_id: str,
117-
pixel_data: Dict[str, Any],
118-
geometry_type: str = 'centroid'
180+
entity_data: Dict[str, Any],
181+
geometry_type: str = 'centroid',
182+
entity_type: str = 'pixel'
119183
) -> Dict[str, Any]:
120184
"""
121185
Create a single ODK entity via Ona API.
122186
123187
Args:
124188
project_id: Project ID to get ODK credentials
125-
pixel_data: Pixel data dict with id, quadkey, lat, lng, adm4_pcode
189+
entity_data: Entity data dict (pixel or location fields)
126190
geometry_type: 'centroid' or 'boundary' - how to represent pixel geometry
191+
entity_type: 'pixel' or 'location'
127192
128193
Returns:
129194
Dict with success status and created entity info
@@ -154,21 +219,27 @@ async def create_odk_entity_activity(
154219
# Remove trailing slash from host_url
155220
host_url = host_url.rstrip('/')
156221

157-
# Format geometry based on project setting
158-
if geometry_type == 'boundary':
159-
# Use pixel boundary (geoshape polygon)
160-
geometry = get_pixel_boundary_coords(pixel_data['quadkey'])
222+
if entity_type == 'location':
223+
# Locations always use point geometry
224+
label = entity_data['external_id']
225+
geometry = f"{entity_data['latitude']} {entity_data['longitude']} 0 0"
226+
details = entity_data['quadkey']
161227
else:
162-
# Use pixel centroid (geopoint)
163-
geometry = f"{pixel_data['latitude']} {pixel_data['longitude']} 0 0"
228+
# Pixels use configurable geometry
229+
label = entity_data['quadkey']
230+
if geometry_type == 'boundary':
231+
geometry = get_pixel_boundary_coords(entity_data['quadkey'])
232+
else:
233+
geometry = f"{entity_data['latitude']} {entity_data['longitude']} 0 0"
234+
details = entity_data['adm4_pcode']
164235

165236
# Build entity payload
166237
entity_payload = {
167-
'label': pixel_data['quadkey'],
238+
'label': label,
168239
'data': {
169240
'geometry': geometry,
170241
'status': 'not_visited',
171-
'details': pixel_data['adm4_pcode']
242+
'details': details
172243
}
173244
}
174245

@@ -199,7 +270,7 @@ async def create_odk_entity_activity(
199270

200271
return {
201272
'success': True,
202-
'quadkey': pixel_data['quadkey'],
273+
'label': label,
203274
'entity_uuid': entity_result.get('uuid')
204275
}
205276

0 commit comments

Comments
 (0)