• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

akvo / iwsims / #59

18 Jun 2026 07:20AM UTC coverage: 88.033% (-0.1%) from 88.13%
#59

push

coveralls-python

web-flow
Merge 5dfcb298b into a6f6761c9

5183 of 6053 branches covered (85.63%)

Branch coverage included in aggregate %.

9979 of 11170 relevant lines covered (89.34%)

0.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.56
backend/api/v1/v1_visualization/dashboard_views.py
1
from rest_framework.decorators import api_view
1✔
2
from rest_framework.response import Response
1✔
3
from rest_framework import status
1✔
4
from drf_spectacular.utils import (
1✔
5
    extend_schema, OpenApiParameter, OpenApiResponse,
6
)
7
from drf_spectacular.types import OpenApiTypes
1✔
8
from rest_framework.generics import get_object_or_404
1✔
9
from api.v1.v1_forms.models import Forms
1✔
10
from api.v1.v1_forms.constants import QuestionTypes
1✔
11
from api.v1.v1_visualization.dashboard_serializers import (
1✔
12
    ValuesFilterSerializer,
13
    ValuesResponseSerializer,
14
    EscalationResponseSerializer,
15
    ProgressResponseSerializer,
16
)
17
from api.v1.v1_visualization.dashboard_examples import (
1✔
18
    VALUES_EXAMPLES,
19
    ESCALATION_EXAMPLES,
20
    PROGRESS_EXAMPLES,
21
)
22
from api.v1.v1_visualization.values_functions import (
1✔
23
    handle_count_mode,
24
    handle_option_question,
25
    handle_number_question,
26
)
27
from api.v1.v1_visualization.escalation_functions import (
1✔
28
    handle_escalation,
29
)
30
from api.v1.v1_visualization.progress_functions import (
1✔
31
    handle_progress,
32
)
33
from api.v1.v1_visualization.functions import (
1✔
34
    get_values_by_question_name,
35
    resolve_default_administration_id,
36
    split_criteria_by_form,
37
)
38
from api.v1.v1_visualization.dashboard_serializers import (
1✔
39
    EscalationFilterSerializer,
40
    ProgressFilterSerializer,
41
)
42
from utils.custom_serializer_fields import (
1✔
43
    validate_serializers_message,
44
)
45

46

47
@extend_schema(
1✔
48
    description="Generic visualization values endpoint",
49
    tags=["Visualization"],
50
    responses={
51
        200: OpenApiResponse(
52
            response=ValuesResponseSerializer,
53
            description=(
54
                "Aggregated data shaped by group_by / stack_by."
55
                " See examples for per-use-case shapes."
56
            ),
57
        ),
58
        400: OpenApiResponse(
59
            description="Invalid query parameters.",
60
        ),
61
    },
62
    examples=VALUES_EXAMPLES,
63
    parameters=[
64
        OpenApiParameter(
65
            name="form_id", required=False,
66
            type=OpenApiTypes.INT,
67
            location=OpenApiParameter.QUERY,
68
            description=(
69
                "Registration or monitoring form ID. "
70
                "Required unless question_name is provided."
71
            ),
72
        ),
73
        OpenApiParameter(
74
            name="question_id", required=False,
75
            type=OpenApiTypes.INT,
76
            location=OpenApiParameter.QUERY,
77
        ),
78
        OpenApiParameter(
79
            name="question_name", required=False,
80
            type=OpenApiTypes.STR,
81
            location=OpenApiParameter.QUERY,
82
            description=(
83
                "Query by question name across all monitoring forms "
84
                "using mv_cross_form_latest. "
85
                "Either question_name OR form_id is required."
86
            ),
87
        ),
88
        OpenApiParameter(
89
            name="monitoring", required=False,
90
            type=OpenApiTypes.STR,
91
            location=OpenApiParameter.QUERY,
92
            enum=["latest", "all"],
93
        ),
94
        OpenApiParameter(
95
            name="group_by", required=False,
96
            type=OpenApiTypes.STR,
97
            location=OpenApiParameter.QUERY,
98
            enum=[
99
                "date", "month", "id",
100
                "parent_id", "option",
101
            ],
102
        ),
103
        OpenApiParameter(
104
            name="stack_by", required=False,
105
            type=OpenApiTypes.STR,
106
            location=OpenApiParameter.QUERY,
107
            enum=["option", "parent_id"],
108
        ),
109
        OpenApiParameter(
110
            name="sum_by", required=False,
111
            type=OpenApiTypes.STR,
112
            location=OpenApiParameter.QUERY,
113
            enum=["id", "parent_id"],
114
        ),
115
        OpenApiParameter(
116
            name="value_type", required=False,
117
            type=OpenApiTypes.STR,
118
            location=OpenApiParameter.QUERY,
119
            enum=["number", "percentage"],
120
        ),
121
        OpenApiParameter(
122
            name="repeat_agg", required=False,
123
            type=OpenApiTypes.STR,
124
            location=OpenApiParameter.QUERY,
125
            enum=[
126
                "average", "sum", "max", "min", "last",
127
            ],
128
        ),
129
        OpenApiParameter(
130
            name="from_date", required=False,
131
            type=OpenApiTypes.DATE,
132
            location=OpenApiParameter.QUERY,
133
        ),
134
        OpenApiParameter(
135
            name="to_date", required=False,
136
            type=OpenApiTypes.DATE,
137
            location=OpenApiParameter.QUERY,
138
        ),
139
        OpenApiParameter(
140
            name="date_question_name", required=False,
141
            type=OpenApiTypes.INT,
142
            location=OpenApiParameter.QUERY,
143
        ),
144
        OpenApiParameter(
145
            name="administration_id", required=False,
146
            type=OpenApiTypes.INT,
147
            location=OpenApiParameter.QUERY,
148
        ),
149
        OpenApiParameter(
150
            name="option_value", required=False,
151
            type=OpenApiTypes.STR,
152
            location=OpenApiParameter.QUERY,
153
            description=(
154
                "With question_name + sum_by=parent_id, count only "
155
                "parents whose latest option value matches."
156
            ),
157
        ),
158
        OpenApiParameter(
159
            name="rolling_months", required=False,
160
            type=OpenApiTypes.INT,
161
            location=OpenApiParameter.QUERY,
162
            description=(
163
                "With question_name, keep only parents whose latest "
164
                "answer is within the last N months."
165
            ),
166
        ),
167
        OpenApiParameter(
168
            name="criteria", required=False,
169
            type=OpenApiTypes.STR,
170
            location=OpenApiParameter.QUERY,
171
            description=(
172
                "AND-joined multi-criteria filter. Format: "
173
                "'type:qid:value,...'. Types: option_equals, "
174
                "option_contains, option_in (pipe-delimited "
175
                "values), threshold_gt, threshold_lt."
176
            ),
177
        ),
178
    ],
179
)
180
@api_view(["GET"])
1✔
181
def visualization_values(request, version):
1✔
182
    """Generic visualization values endpoint.
183

184
    Returns aggregated data for charts, KPIs, and tables.
185
    All configuration via query parameters.
186
    """
187
    serializer = ValuesFilterSerializer(
1✔
188
        data=request.query_params
189
    )
190
    if not serializer.is_valid():
1✔
191
        return Response(
1✔
192
            {"message": validate_serializers_message(
193
                serializer.errors
194
            )},
195
            status=status.HTTP_400_BAD_REQUEST,
196
        )
197

198
    validated = serializer.validated_data
1✔
199
    question_name = validated.get("question_name")
1✔
200

201
    # Global cross-form path: question_name with NO form_id. With a
202
    # form_id, the serializer resolves the question by name and we fall
203
    # through to the form-scoped rich handlers below.
204
    if question_name and not validated.get("form_id"):
1✔
205
        params = {
1✔
206
            "administration_id": resolve_default_administration_id(
207
                validated.get("administration_id"),
208
            ),
209
            "group_by": validated.get("group_by"),
210
            "value_type": validated.get("value_type", "number"),
211
            "sum_by": validated.get("sum_by"),
212
            "option_value": validated.get("option_value"),
213
            "rolling_months": validated.get("rolling_months"),
214
            "from_date": validated.get("from_date"),
215
            "to_date": validated.get("to_date"),
216
            "parent_form_id": validated.get("parent_form_id"),
217
        }
218
        data, labels = get_values_by_question_name(question_name, params)
1✔
219
        return Response(
1✔
220
            {"data": data, "labels": labels},
221
            status=status.HTTP_200_OK,
222
        )
223

224
    form = get_object_or_404(
1✔
225
        Forms, pk=validated["form_id"]
226
    )
227
    question = validated.get("question")
1✔
228

229
    params = {
1✔
230
        "monitoring": validated.get(
231
            "monitoring", "latest"
232
        ),
233
        "group_by": validated.get("group_by"),
234
        "stack_by": validated.get("stack_by"),
235
        "sum_by": validated.get("sum_by"),
236
        "value_type": validated.get(
237
            "value_type", "number"
238
        ),
239
        "repeat_agg": validated.get(
240
            "repeat_agg", "average"
241
        ),
242
        "from_date": validated.get("from_date"),
243
        "to_date": validated.get("to_date"),
244
        "date_question_name": validated.get(
245
            "date_question_name"
246
        ),
247
        "administration_id": resolve_default_administration_id(
248
            validated.get("administration_id"),
249
        ),
250
        "option_value": validated.get("option_value"),
251
        "criteria": validated.get("criteria"),
252
        "parent_criteria": validated.get("parent_criteria"),
253
        "include_unanswered": validated.get(
254
            "include_unanswered", False
255
        ),
256
        "include_empty": validated.get(
257
            "include_empty", False
258
        ),
259
    }
260

261
    # Route to handler
262
    if not question:
1✔
263
        result = handle_count_mode(form, params)
1✔
264
    elif question.type == QuestionTypes.number:
1✔
265
        result = handle_number_question(
1✔
266
            form, question, params
267
        )
268
    elif question.type in [
1!
269
        QuestionTypes.option,
270
        QuestionTypes.multiple_option,
271
    ]:
272
        result = handle_option_question(
1✔
273
            form, question, params
274
        )
275
    else:
276
        result = handle_count_mode(form, params)
×
277

278
    # Format response
279
    if isinstance(result, dict):
1✔
280
        return Response(result, status=status.HTTP_200_OK)
1✔
281
    data, labels = result
1✔
282
    return Response(
1✔
283
        {"data": data, "labels": labels},
284
        status=status.HTTP_200_OK,
285
    )
286

287

288
@extend_schema(
1✔
289
    description="Escalation table with dynamic criteria and columns",
290
    tags=["Visualization"],
291
    responses={
292
        200: OpenApiResponse(
293
            response=EscalationResponseSerializer,
294
            description=(
295
                "Paginated escalation results. Column keys in"
296
                " `results[]` follow the request's `columns=` spec."
297
            ),
298
        ),
299
        400: OpenApiResponse(
300
            description="Invalid query parameters.",
301
        ),
302
        404: OpenApiResponse(
303
            description="form_id not found.",
304
        ),
305
    },
306
    examples=ESCALATION_EXAMPLES,
307
    parameters=[
308
        OpenApiParameter(
309
            name="monitoring_form_id", required=True,
310
            type=OpenApiTypes.INT,
311
            location=OpenApiParameter.QUERY,
312
        ),
313
        OpenApiParameter(
314
            name="criteria", required=True,
315
            type=OpenApiTypes.STR,
316
            location=OpenApiParameter.QUERY,
317
        ),
318
        OpenApiParameter(
319
            name="columns", required=True,
320
            type=OpenApiTypes.STR,
321
            location=OpenApiParameter.QUERY,
322
        ),
323
        OpenApiParameter(
324
            name="page", required=False,
325
            type=OpenApiTypes.INT,
326
            location=OpenApiParameter.QUERY,
327
        ),
328
        OpenApiParameter(
329
            name="page_size", required=False,
330
            type=OpenApiTypes.INT,
331
            location=OpenApiParameter.QUERY,
332
        ),
333
        OpenApiParameter(
334
            name="administration_id", required=False,
335
            type=OpenApiTypes.INT,
336
            location=OpenApiParameter.QUERY,
337
        ),
338
        OpenApiParameter(
339
            name="from_date", required=False,
340
            type=OpenApiTypes.DATE,
341
            location=OpenApiParameter.QUERY,
342
        ),
343
        OpenApiParameter(
344
            name="to_date", required=False,
345
            type=OpenApiTypes.DATE,
346
            location=OpenApiParameter.QUERY,
347
        ),
348
        OpenApiParameter(
349
            name="date_question_name", required=False,
350
            type=OpenApiTypes.INT,
351
            location=OpenApiParameter.QUERY,
352
        ),
353
        OpenApiParameter(
354
            name="filter_criteria", required=False,
355
            type=OpenApiTypes.STR,
356
            location=OpenApiParameter.QUERY,
357
            description=(
358
                "Optional AND-narrowing criteria layered "
359
                "on top of the OR escalation criteria "
360
                "(shared grammar with /values)."
361
            ),
362
        ),
363
    ],
364
)
365
@api_view(["GET"])
1✔
366
def visualization_escalation(request, form_id, version):
1✔
367
    """Escalation table with query-param-driven criteria."""
368
    parent_form = get_object_or_404(Forms, pk=form_id)
1✔
369

370
    serializer = EscalationFilterSerializer(
1✔
371
        data=request.query_params
372
    )
373
    if not serializer.is_valid():
1✔
374
        return Response(
1✔
375
            {"message": validate_serializers_message(
376
                serializer.errors
377
            )},
378
            status=status.HTTP_400_BAD_REQUEST,
379
        )
380

381
    validated = serializer.validated_data
1✔
382
    result = handle_escalation(
1✔
383
        parent_form=parent_form,
384
        monitoring_form_id=validated["monitoring_form_id"],
385
        criteria=validated["criteria"],
386
        columns=validated["columns"],
387
        params={
388
            "page": validated.get("page", 1),
389
            "page_size": validated.get("page_size", 20),
390
            "administration_id": resolve_default_administration_id(
391
                validated.get("administration_id"),
392
            ),
393
            "from_date": validated.get("from_date"),
394
            "to_date": validated.get("to_date"),
395
            "date_question_name": validated.get(
396
                "date_question_name"
397
            ),
398
            "filter_criteria": validated.get("filter_criteria"),
399
            "query_string": [
400
                (k, v)
401
                for k, values in request.query_params.lists()
402
                for v in values
403
            ],
404
        },
405
    )
406
    return Response(result, status=status.HTTP_200_OK)
1✔
407

408

409
@extend_schema(
1✔
410
    description="Progress computation with configurable formulas",
411
    tags=["Visualization"],
412
    responses={
413
        200: OpenApiResponse(
414
            response=ProgressResponseSerializer,
415
            description=(
416
                "Progress computation result. Shape depends on "
417
                "requested components and formula. See examples."
418
            ),
419
        ),
420
        400: OpenApiResponse(
421
            description="Invalid query parameters.",
422
        ),
423
        404: OpenApiResponse(
424
            description="form_id not found.",
425
        ),
426
    },
427
    examples=PROGRESS_EXAMPLES,
428
    parameters=[
429
        OpenApiParameter(
430
            name="monitoring_form_id", required=True,
431
            type=OpenApiTypes.INT,
432
            location=OpenApiParameter.QUERY,
433
        ),
434
        OpenApiParameter(
435
            name="components", required=True,
436
            type=OpenApiTypes.STR,
437
            location=OpenApiParameter.QUERY,
438
        ),
439
        OpenApiParameter(
440
            name="filter_question_id", required=False,
441
            type=OpenApiTypes.INT,
442
            location=OpenApiParameter.QUERY,
443
        ),
444
        OpenApiParameter(
445
            name="filter_option_value", required=False,
446
            type=OpenApiTypes.STR,
447
            location=OpenApiParameter.QUERY,
448
        ),
449
        OpenApiParameter(
450
            name="scope_question_id", required=False,
451
            type=OpenApiTypes.INT,
452
            location=OpenApiParameter.QUERY,
453
            description=(
454
                "Question whose answer determines which "
455
                "components apply per datapoint (e.g. "
456
                "project type). Components with "
457
                "applicable_types are filtered to match."
458
            ),
459
        ),
460
        OpenApiParameter(
461
            name="administration_id", required=False,
462
            type=OpenApiTypes.INT,
463
            location=OpenApiParameter.QUERY,
464
        ),
465
        OpenApiParameter(
466
            name="from_date", required=False,
467
            type=OpenApiTypes.DATE,
468
            location=OpenApiParameter.QUERY,
469
        ),
470
        OpenApiParameter(
471
            name="to_date", required=False,
472
            type=OpenApiTypes.DATE,
473
            location=OpenApiParameter.QUERY,
474
        ),
475
        OpenApiParameter(
476
            name="date_question_name", required=False,
477
            type=OpenApiTypes.INT,
478
            location=OpenApiParameter.QUERY,
479
        ),
480
        OpenApiParameter(
481
            name="criteria", required=False,
482
            type=OpenApiTypes.STR,
483
            location=OpenApiParameter.QUERY,
484
            description=(
485
                "AND-joined multi-criteria filter "
486
                "(same grammar as /values)."
487
            ),
488
        ),
489
    ],
490
)
491
@api_view(["GET"])
1✔
492
def visualization_progress(request, form_id, version):
1✔
493
    """Progress computation endpoint."""
494
    parent_form = get_object_or_404(Forms, pk=form_id)
1✔
495

496
    serializer = ProgressFilterSerializer(
1✔
497
        data=request.query_params
498
    )
499
    if not serializer.is_valid():
1✔
500
        return Response(
1✔
501
            {"message": validate_serializers_message(
502
                serializer.errors
503
            )},
504
            status=status.HTTP_400_BAD_REQUEST,
505
        )
506

507
    validated = serializer.validated_data
1✔
508
    mon_criteria, parent_criteria = split_criteria_by_form(
1✔
509
        validated.get("criteria"),
510
        validated["monitoring_form_id"],
511
        parent_form.id,
512
    )
513
    result = handle_progress(
1✔
514
        parent_form=parent_form,
515
        monitoring_form_id=validated["monitoring_form_id"],
516
        components=validated["components"],
517
        params={
518
            "filter_question_name": validated.get(
519
                "filter_question_name"
520
            ),
521
            "filter_option_value": validated.get(
522
                "filter_option_value"
523
            ),
524
            "scope_question_name": validated.get(
525
                "scope_question_name"
526
            ),
527
            "administration_id": resolve_default_administration_id(
528
                validated.get("administration_id"),
529
            ),
530
            "from_date": validated.get("from_date"),
531
            "to_date": validated.get("to_date"),
532
            "date_question_name": validated.get(
533
                "date_question_name"
534
            ),
535
            "criteria": mon_criteria,
536
            "parent_criteria": parent_criteria,
537
        },
538
    )
539
    return Response(result, status=status.HTTP_200_OK)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc