Skip to content

Commit 06f2a14

Browse files
authored
Merge pull request #32 from onaio/odk
Odk
2 parents f170c94 + 272f974 commit 06f2a14

11 files changed

Lines changed: 220 additions & 107 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/components/ProjectSettings.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ const ProjectSettings: React.FC<ProjectSettingsProps> = ({
6363
setError(null);
6464
setOnaProjects([]);
6565
setOnaEntityLists([]);
66+
67+
// Auto-load entity lists when project is configured but no entity list is set
68+
if (project.ona_project_id && !project.ona_entity_list_id) {
69+
loadEntityListsForProject(project.ona_project_id);
70+
}
6671
}
6772
}, [project, isOpen]);
6873

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/db/migrations/add_sampled_only_filter.sql

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,14 @@ BEGIN
7171
SELECT
7272
p.quadkey,
7373
p.geometry,
74-
(pm.metadata->>'population')::numeric AS population,
74+
p.population,
7575
lc.building_count
7676
FROM pixels p
7777
JOIN pixel_area pa ON p.quadkey = pa.quadkey
7878
JOIN campaign_areas ca ON pa.campaign_area_id = ca.id
7979
LEFT JOIN coverage_pixel cp ON p.quadkey = cp.quadkey
8080
AND cp.campaign_id = target_campaign_id
8181
AND (target_indicator_id IS NULL OR cp.indicator_id = target_indicator_id)
82-
LEFT JOIN pixel_metadata pm ON p.quadkey = pm.quadkey
8382
LEFT JOIN LATERAL (
8483
SELECT COUNT(*)::integer AS building_count
8584
FROM locations l

truecover-backend/db/migrations/optimize_pixels_by_campaign.sql

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,11 @@ BEGIN
6464
SELECT
6565
p.quadkey,
6666
p.geometry,
67-
(pm.metadata->>'population')::numeric AS population,
67+
p.population,
6868
lc.building_count
6969
FROM pixels p
7070
JOIN pixel_area pa ON p.quadkey = pa.quadkey
7171
JOIN campaign_areas ca ON pa.campaign_area_id = ca.id
72-
LEFT JOIN pixel_metadata pm ON p.quadkey = pm.quadkey
7372
LEFT JOIN LATERAL (
7473
SELECT COUNT(*)::integer AS building_count
7574
FROM locations l

truecover-backend/routes/campaigns.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -581,10 +581,9 @@ def compute_pixels_for_area(user, area_id):
581581
WITH pixel_stats AS (
582582
SELECT
583583
COUNT(*) as pixel_count,
584-
COALESCE(SUM((pm.metadata->>'population')::numeric), 0) as total_population
584+
COALESCE(SUM(p.population), 0) as total_population
585585
FROM pixel_area pa
586586
JOIN pixels p ON pa.quadkey = p.quadkey
587-
LEFT JOIN pixel_metadata pm ON pa.quadkey = pm.quadkey
588587
WHERE pa.campaign_area_id = %s
589588
),
590589
location_counts AS (
@@ -696,10 +695,9 @@ def compute_all_pixels_for_campaign(user, campaign_id):
696695
WITH pixel_stats AS (
697696
SELECT
698697
COUNT(*) as pixel_count,
699-
COALESCE(SUM((pm.metadata->>'population')::numeric), 0) as total_population
698+
COALESCE(SUM(p.population), 0) as total_population
700699
FROM pixel_area pa
701700
JOIN pixels p ON pa.quadkey = p.quadkey
702-
LEFT JOIN pixel_metadata pm ON pa.quadkey = pm.quadkey
703701
WHERE pa.campaign_area_id = %s
704702
),
705703
location_counts AS (

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/enrichment.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,17 @@ async def enrich_area_pixels(
341341
metadata = pixel_metadata.metadata || EXCLUDED.metadata,
342342
updated_at = NOW()
343343
""", updates)
344+
345+
# Also write to pixels.population column for population data
346+
if metadata_field_name == 'population':
347+
pop_updates = [
348+
(json.loads(metadata_json)[metadata_field_name], quadkey)
349+
for quadkey, metadata_json in updates
350+
]
351+
cursor.executemany("""
352+
UPDATE pixels SET population = %s WHERE quadkey = %s
353+
""", pop_updates)
354+
344355
conn.commit()
345356
total_updated += len(updates)
346357

@@ -354,11 +365,11 @@ async def enrich_area_pixels(
354365
SET cached_population = sub.total_pop
355366
FROM (
356367
SELECT pa.campaign_area_id,
357-
COALESCE(SUM((pm.metadata->>'population')::numeric), 0) as total_pop
368+
COALESCE(SUM(p.population), 0) as total_pop
358369
FROM pixel_area pa
359-
JOIN pixel_metadata pm ON pa.quadkey = pm.quadkey
370+
JOIN pixels p ON pa.quadkey = p.quadkey
360371
JOIN campaign_areas ca2 ON pa.campaign_area_id = ca2.id
361-
WHERE ca2.campaign_id = %s AND pm.metadata ? 'population'
372+
WHERE ca2.campaign_id = %s
362373
GROUP BY pa.campaign_area_id
363374
) sub
364375
WHERE ca.id = sub.campaign_area_id

truecover-backend/temporal/activities/entity_export.py

Lines changed: 86 additions & 15 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
@@ -85,7 +85,7 @@ async def fetch_pixel_coverage_activity(
8585
p.adm4_pcode,
8686
pc.rounds
8787
FROM coverage_pixel pc
88-
JOIN pixels p ON p.quadkey = pc.quadkey AND p.campaign_id = pc.campaign_id
88+
JOIN pixels p ON p.quadkey = pc.quadkey
8989
WHERE pc.campaign_id = %s
9090
AND pc.indicator_id = %s
9191
AND pc.rounds && %s::integer[]
@@ -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

truecover-backend/temporal/activities/rounds.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,8 @@ async def fetch_coverage_for_sampling(
116116
pop_filter = ""
117117
pop_params = []
118118

119-
if min_population is not None and population_field:
120-
import re
121-
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', population_field):
122-
raise ValueError(f"Invalid population field name: {population_field}")
123-
pop_join = "LEFT JOIN pixel_metadata pm ON p.quadkey = pm.quadkey"
124-
pop_filter = f"AND (pm.metadata->>'{population_field}')::float >= %s"
119+
if min_population is not None:
120+
pop_filter = "AND p.population >= %s"
125121
pop_params = [min_population]
126122

127123
if allow_revisit:

0 commit comments

Comments
 (0)