Skip to content

Commit 43665c1

Browse files
Merge pull request #192 from akvo/feature/191-mt-004-single-language-onboarding
[#191] Dynamic Onboarding - Single Language Bypass & Configurable Data Consent
2 parents 1c0f164 + ee4c1af commit 43665c1

14 files changed

Lines changed: 1392 additions & 133 deletions

‎backend/config.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,11 @@ def default_language(self) -> str:
285285
codes = self.supported_language_codes
286286
return codes[0] if codes else "en"
287287

288+
@property
289+
def is_single_language(self) -> bool:
290+
"""Return True if 1 or 0 supported languages are configured."""
291+
return len(self.supported_language_codes) <= 1
292+
288293
# Dynamic Age Groups
289294
age_groups: list = _config.get(
290295
"age_groups",

‎backend/config.template.json‎

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,21 @@
8888
"delimiter": " > ",
8989
"levels": [
9090
{ "level_index": 0, "name": "country", "display": { "en": "Country" } },
91-
{ "level_index": 1, "name": "region", "display": { "en": "Region", "sw": "Mkoa" } },
92-
{ "level_index": 2, "name": "district", "display": { "en": "District", "sw": "Wilaya" } },
93-
{ "level_index": 3, "name": "ward", "display": { "en": "Ward", "sw": "Kata" } }
91+
{
92+
"level_index": 1,
93+
"name": "region",
94+
"display": { "en": "Region", "sw": "Mkoa" }
95+
},
96+
{
97+
"level_index": 2,
98+
"name": "district",
99+
"display": { "en": "District", "sw": "Wilaya" }
100+
},
101+
{
102+
"level_index": 3,
103+
"name": "ward",
104+
"display": { "en": "Ward", "sw": "Kata" }
105+
}
94106
]
95107
},
96108
"onboarding": {
@@ -115,12 +127,52 @@
115127
"sw": "Vizuri! Lugha uliyopendelea imewekwa kuwa Kiswahili."
116128
}
117129
},
130+
{
131+
"field_name": "consent",
132+
"db_field": "data_consent",
133+
"enabled": true,
134+
"required": true,
135+
"priority": 1,
136+
"field_type": "boolean",
137+
"extraction_method": "extract_consent",
138+
"max_attempts": 3,
139+
"labels": {
140+
"en": "Data Consent",
141+
"sw": "Idhini ya Data"
142+
},
143+
"questions": {
144+
"en": "Before we begin, do you agree to allow us to store and use your information to provide personalized agricultural advice and support?\n\nReply with *Yes* to continue or *No* to decline.",
145+
"sw": "Kabla ya kuanza, je, unakubali kuturuhusu kuhifadhi na kutumia taarifa zako ili kutoa ushauri na usaidizi wa kibinafsi wa kilimo?\n\nJibu *Ndio* kuendelea au *Hapana* kukataa."
146+
},
147+
"affirmative_keywords": {
148+
"en": [
149+
"yes",
150+
"ok",
151+
"okay",
152+
"agree",
153+
"i agree",
154+
"accepted",
155+
"accept",
156+
"1",
157+
"y"
158+
],
159+
"sw": ["ndio", "ndiyo", "sawa", "kubali", "nakubali", "1"]
160+
},
161+
"declined_keywords": {
162+
"en": ["no", "decline", "reject", "2", "n"],
163+
"sw": ["hapana", "2"]
164+
},
165+
"success_messages": {
166+
"en": "Thank you for your consent!",
167+
"sw": "Asante kwa idhini yako!"
168+
}
169+
},
118170
{
119171
"field_name": "full_name",
120172
"db_field": "full_name",
121173
"enabled": true,
122174
"required": true,
123-
"priority": 1,
175+
"priority": 2,
124176
"field_type": "string",
125177
"max_attempts": 1,
126178
"labels": {
@@ -141,7 +193,7 @@
141193
"db_field": "customer_administrative",
142194
"enabled": true,
143195
"required": true,
144-
"priority": 2,
196+
"priority": 3,
145197
"field_type": "location",
146198
"extraction_method": "extract_location",
147199
"matching_method": "resolve_administration_ambiguity",
@@ -164,7 +216,7 @@
164216
"db_field": "crop_type",
165217
"enabled": true,
166218
"required": true,
167-
"priority": 3,
219+
"priority": 4,
168220
"field_type": "string",
169221
"extraction_method": "extract_crop_type",
170222
"matching_method": "resolve_crop_ambiguity",
@@ -187,7 +239,7 @@
187239
"db_field": "gender",
188240
"enabled": true,
189241
"required": false,
190-
"priority": 4,
242+
"priority": 5,
191243
"field_type": "enum",
192244
"extraction_method": "extract_gender",
193245
"max_attempts": 2,
@@ -209,7 +261,7 @@
209261
"db_field": "birth_year",
210262
"enabled": true,
211263
"required": false,
212-
"priority": 5,
264+
"priority": 6,
213265
"field_type": "integer",
214266
"extraction_method": "extract_birth_year",
215267
"max_attempts": 2,

‎backend/models/customer.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ def data_consent_given(self) -> bool | None:
173173
def data_consent_given(self, value: bool | None):
174174
self.set_profile_field("data_consent_given", value)
175175

176+
@property
177+
def data_consent(self) -> bool | None:
178+
"""Alias for data_consent_given for dynamic field lookups."""
179+
return self.data_consent_given
180+
181+
@data_consent.setter
182+
def data_consent(self, value: bool | None):
183+
self.data_consent_given = value
184+
176185
# Account deletion request tracking
177186
@property
178187
def delete_requested(self) -> bool:

‎backend/routers/whatsapp.py‎

Lines changed: 10 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -331,61 +331,6 @@ async def whatsapp_webhook(
331331
# Don't record the "delete" message - return early
332332
return {"status": "success", "message": "Delete confirmation sent"}
333333

334-
# ========================================
335-
# DATA CONSENT CHECK (after language, before other fields)
336-
# ========================================
337-
# Handle consent response if consent was asked (language already set)
338-
if (
339-
customer.data_consent_asked
340-
and not customer.data_consent_given
341-
and customer.language is not None
342-
):
343-
whatsapp_service = WhatsAppService()
344-
lang = customer.language_code
345-
346-
# Check for affirmative response
347-
consent_responses = [
348-
"yes",
349-
"ok",
350-
"okay",
351-
"ndio",
352-
"ndiyo",
353-
"sawa",
354-
"agree",
355-
"i agree",
356-
"accepted",
357-
"accept",
358-
"kubali",
359-
"nakubali",
360-
"ya",
361-
"setuju",
362-
"oke",
363-
"y",
364-
]
365-
if Body.lower().strip() in consent_responses:
366-
# Consent given - mark and continue to onboarding
367-
customer.data_consent_given = True
368-
db.commit()
369-
370-
whatsapp_service.send_message(
371-
phone_number, t("consent.data_sharing.accepted", lang)
372-
)
373-
# Fall through to continue onboarding
374-
else:
375-
# Not affirmative - send decline message and DELETE customer
376-
whatsapp_service.send_message(
377-
phone_number, t("consent.data_sharing.declined", lang)
378-
)
379-
380-
# Delete customer row (fresh start on next message)
381-
db.delete(customer)
382-
db.commit()
383-
384-
return {
385-
"status": "success",
386-
"message": "Consent declined, customer deleted",
387-
}
388-
389334
# ========================================
390335
# GENERIC ONBOARDING: Collect all required profile fields
391336
# ========================================
@@ -420,6 +365,16 @@ async def whatsapp_webhook(
420365
f"field: {customer.current_onboarding_field})"
421366
)
422367

368+
# If onboarding was aborted (e.g. consent declined),
369+
# delete customer
370+
if onboarding_response.status == "aborted":
371+
db.delete(customer)
372+
db.commit()
373+
return {
374+
"status": "success",
375+
"message": "Onboarding aborted, customer deleted",
376+
}
377+
423378
# If onboarding still in progress, don't process message further
424379
# Note: "awaiting_selection" is a response status,
425380
# not in OnboardingStatus enum

‎backend/schemas/onboarding_schemas.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ class OnboardingFieldConfig:
127127
questions: Optional[Union[Dict[str, str], str]] = None
128128
success_messages: Optional[Union[Dict[str, str], str]] = None
129129
success_message_template: Optional[str] = None # Legacy template fallback
130+
affirmative_keywords: Optional[Union[Dict[str, List[str]], List[str]]] = (
131+
None
132+
)
133+
declined_keywords: Optional[Union[Dict[str, List[str]], List[str]]] = None
130134

131135
def get_label(self, lang: Optional[str] = None) -> str:
132136
"""Get localized field label or fall back to title-cased name."""
@@ -304,6 +308,14 @@ def load_onboarding_fields() -> List[OnboardingFieldConfig]:
304308
"success_message_template",
305309
base.success_message_template if base else None,
306310
),
311+
affirmative_keywords=(
312+
cfg.get("affirmative_keywords")
313+
or (base.affirmative_keywords if base else None)
314+
),
315+
declined_keywords=(
316+
cfg.get("declined_keywords")
317+
or (base.declined_keywords if base else None)
318+
),
307319
)
308320
)
309321

‎backend/services/customer_service.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ def create_customer(
4242
language: Optional[str] = None,
4343
) -> Customer:
4444
"""Create a new customer with minimal fields."""
45+
if language is None and settings.is_single_language:
46+
language = settings.default_language
47+
4548
customer = Customer(phone_number=phone_number, language=language)
4649
try:
4750
self.db.add(customer)

0 commit comments

Comments
 (0)