-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathentity_export.py
More file actions
283 lines (236 loc) · 8.68 KB
/
Copy pathentity_export.py
File metadata and controls
283 lines (236 loc) · 8.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# ABOUTME: Temporal activities for exporting coverage data to ODK entity lists
# ABOUTME: Handles fetching pixel and location data and creating entities via Ona API
from temporalio import activity
from typing import List, Dict, Any
from db.connection import get_db_connection, return_db_connection
import requests
import mercantile
def get_pixel_boundary_coords(quadkey: str) -> str:
"""
Get the boundary coordinates of a pixel from its quadkey.
Returns a string in ODK geoshape format:
"lat1 lon1 0 0;lat2 lon2 0 0;lat3 lon3 0 0;lat4 lon4 0 0;lat1 lon1 0 0"
The corners are ordered counter-clockwise starting from southwest:
SW -> SE -> NE -> NW -> SW (closed polygon)
"""
try:
# Convert quadkey to tile
tile = mercantile.quadkey_to_tile(quadkey)
# Get bounding box (west, south, east, north)
bounds = mercantile.bounds(tile)
# Create corners in counter-clockwise order starting from SW
# Format: lat lon 0 0 for each corner, separated by semicolons
sw = f"{bounds.south} {bounds.west} 0 0"
se = f"{bounds.south} {bounds.east} 0 0"
ne = f"{bounds.north} {bounds.east} 0 0"
nw = f"{bounds.north} {bounds.west} 0 0"
# Close the polygon by repeating first point
return f"{sw};{se};{ne};{nw};{sw}"
except Exception as e:
print(f"Error calculating boundary for quadkey {quadkey}: {e}")
# Fallback to empty geoshape
return ""
@activity.defn
async def fetch_pixel_coverage_activity(
campaign_id: str,
indicator_id: str,
round_ids: List[str]
) -> List[Dict[str, Any]]:
"""
Fetch pixel coverage data filtered by selected rounds.
Args:
campaign_id: Area ID
indicator_id: Indicator ID
round_ids: List of round IDs to filter by
Returns:
List of pixel coverage records with pixel details
"""
conn = get_db_connection()
cursor = conn.cursor()
try:
# Get round numbers from round IDs
placeholders = ','.join(['%s'] * len(round_ids))
cursor.execute(f"""
SELECT round_number
FROM rounds
WHERE id IN ({placeholders})
""", tuple(round_ids))
round_numbers = [row[0] for row in cursor.fetchall()]
if not round_numbers:
return []
# Fetch pixel coverage data with pixel details
# Join with pixels table to get adm4_pcode and other fields
cursor.execute("""
SELECT DISTINCT
pc.id,
pc.quadkey,
p.latitude,
p.longitude,
p.adm4_pcode,
pc.rounds
FROM coverage_pixel pc
JOIN pixels p ON p.quadkey = pc.quadkey
WHERE pc.campaign_id = %s
AND pc.indicator_id = %s
AND pc.rounds && %s::integer[]
ORDER BY pc.quadkey
""", (campaign_id, indicator_id, round_numbers))
pixels = []
for row in cursor.fetchall():
pixel_id, quadkey, latitude, longitude, adm4_pcode, rounds = row
pixels.append({
'id': str(pixel_id),
'quadkey': quadkey,
'latitude': float(latitude) if latitude else None,
'longitude': float(longitude) if longitude else None,
'adm4_pcode': adm4_pcode or '',
'rounds': rounds or []
})
return pixels
finally:
cursor.close()
return_db_connection(conn)
@activity.defn
async def fetch_location_coverage_activity(
campaign_id: str,
indicator_id: str,
round_ids: List[str]
) -> List[Dict[str, Any]]:
"""
Fetch location coverage data filtered by selected rounds.
Returns list of location coverage records with location details.
"""
conn = get_db_connection()
cursor = conn.cursor()
try:
# Get round numbers from round IDs
placeholders = ','.join(['%s'] * len(round_ids))
cursor.execute(f"""
SELECT round_number
FROM rounds
WHERE id IN ({placeholders})
""", tuple(round_ids))
round_numbers = [row[0] for row in cursor.fetchall()]
if not round_numbers:
return []
cursor.execute("""
SELECT DISTINCT
c.id,
l.external_id,
l.latitude,
l.longitude,
l.quadkey,
c.rounds
FROM coverage c
JOIN locations l ON l.id = c.location_id
WHERE c.campaign_id = %s
AND c.indicator_id = %s
AND c.rounds && %s::integer[]
ORDER BY l.external_id
""", (campaign_id, indicator_id, round_numbers))
locations = []
for row in cursor.fetchall():
cov_id, external_id, latitude, longitude, quadkey, rounds = row
locations.append({
'id': str(cov_id),
'external_id': external_id or '',
'latitude': float(latitude) if latitude else None,
'longitude': float(longitude) if longitude else None,
'quadkey': quadkey or '',
'rounds': rounds or []
})
return locations
finally:
cursor.close()
return_db_connection(conn)
@activity.defn
async def create_odk_entity_activity(
project_id: str,
entity_data: Dict[str, Any],
geometry_type: str = 'centroid',
entity_type: str = 'pixel'
) -> Dict[str, Any]:
"""
Create a single ODK entity via Ona API.
Args:
project_id: Project ID to get ODK credentials
entity_data: Entity data dict (pixel or location fields)
geometry_type: 'centroid' or 'boundary' - how to represent pixel geometry
entity_type: 'pixel' or 'location'
Returns:
Dict with success status and created entity info
Raises:
Exception if entity creation fails
"""
conn = get_db_connection()
cursor = conn.cursor()
try:
# Get ODK credentials from project
cursor.execute("""
SELECT odk_api_key, odk_host_url, ona_entity_list_id
FROM projects
WHERE id = %s
""", (project_id,))
project_data = cursor.fetchone()
if not project_data:
raise Exception(f'Project {project_id} not found')
api_key, host_url, entity_list_id = project_data
if not api_key or not host_url or not entity_list_id:
raise Exception('ODK credentials or entity list not configured for this project')
# Remove trailing slash from host_url
host_url = host_url.rstrip('/')
if entity_type == 'location':
# Locations always use point geometry
label = entity_data['external_id']
geometry = f"{entity_data['latitude']} {entity_data['longitude']} 0 0"
details = entity_data['quadkey']
else:
# Pixels use configurable geometry
label = entity_data['quadkey']
if geometry_type == 'boundary':
geometry = get_pixel_boundary_coords(entity_data['quadkey'])
else:
geometry = f"{entity_data['latitude']} {entity_data['longitude']} 0 0"
details = entity_data['adm4_pcode']
# Build entity payload
entity_payload = {
'label': label,
'data': {
'geometry': geometry,
'status': 'not_visited',
'details': details
}
}
# Make POST request to Ona API
headers = {
'Authorization': f'Token {api_key}',
'Content-Type': 'application/json'
}
response = requests.post(
f'{host_url}/api/v2/entity-lists/{entity_list_id}/entities',
headers=headers,
json=entity_payload,
timeout=30
)
# Raise exception on error (stops workflow immediately)
if response.status_code not in [200, 201]:
error_msg = f'Ona API returned status {response.status_code}'
try:
error_detail = response.json()
error_msg += f': {error_detail}'
except:
error_msg += f': {response.text}'
raise Exception(error_msg)
entity_result = response.json()
return {
'success': True,
'label': label,
'entity_uuid': entity_result.get('uuid')
}
except requests.exceptions.Timeout:
raise Exception('Request to Ona API timed out')
except requests.exceptions.ConnectionError:
raise Exception('Could not connect to Ona API')
finally:
cursor.close()
return_db_connection(conn)