Skip to content

Commit cdbce38

Browse files
feat(spp_dci_openg2p): add SR-import wizard for operator-driven registrant ingest
Replaces the manual setup_spdci_demo.py script with a UI flow under Registry → Import from External Registry (DCI). The wizard fires DCI search-sync requests against the configured OpenG2P SR data source, previews matched records, and imports the selected ones as res.partner + spp.registry.id rows on the SP — optionally enrolling them into a target program in one step. Discovery semantics: SPDCI search-sync is lookup-only (no "list all registrants" operation), so the wizard offers two practical modes: - Range sweep: contiguous identifier range (e.g., IND-NSR-0001.. IND-NSR-0015). Works against the OpenG2P playground. - Identifier list: paste/type identifiers one per line. Matches the production-shaped workflow where the SR operator hands over a partner list out of band. Scope: captures only the bare minimum partner fields (given_name, surname, sex, birth_date) plus a UIN reg_id. Eligibility rules continue to read income_level etc. on demand via the CEL ↔ DCI bridge — this wizard is NOT a full SR replica. Implementation: - spp.dci.sr.import.wizard (TransientModel) with three-state form (configure → preview → done) - spp.dci.sr.import.wizard.line for preview rows; carries already_exists + existing_partner_id so the operator can see which UINs are already on the SP - View renders all states on one form, gated by `invisible="state != 'X'"` per pane - Menuitem under spp_registry.spp_main_menu_root, sequence=90 Tests: - 10 new tests in test_sr_import_wizard.py covering identifier collection (range padding, list dedup/comments, empty-input validation), preview (matched/not_found/error/already_exists), import (selected only, skip-existing, auto-enroll), and back-to-configure state reset. - Module total: 44 tests passing. Module manifest gains spp_registry as a dependency for the menu parent xmlid; security/ir.model.access.csv added for the two TransientModel records.
1 parent 27aecd0 commit cdbce38

8 files changed

Lines changed: 843 additions & 0 deletions

File tree

spp_dci_openg2p/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from . import models
44
from . import services
5+
from . import wizards
56

67
_logger = logging.getLogger(__name__)
78

spp_dci_openg2p/__manifest__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212
"depends": [
1313
"spp_cel_dci_bridge",
1414
"spp_vocabulary",
15+
"spp_registry",
1516
],
1617
"external_dependencies": {"python": []},
1718
"data": [
19+
"security/ir.model.access.csv",
1820
"data/openg2p_id_types.xml",
1921
"data/openg2p_data_source.xml",
2022
"data/openg2p_data_provider.xml",
2123
"data/openg2p_cel_variables.xml",
24+
"views/sr_import_wizard_views.xml",
2225
],
2326
"installable": True,
2427
"application": False,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2+
access_spp_dci_sr_import_wizard_user,spp.dci.sr.import.wizard user,model_spp_dci_sr_import_wizard,base.group_user,1,1,1,1
3+
access_spp_dci_sr_import_wizard_line_user,spp.dci.sr.import.wizard.line user,model_spp_dci_sr_import_wizard_line,base.group_user,1,1,1,1

spp_dci_openg2p/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
from . import test_install
33
from . import test_openg2p_dci_client
44
from . import test_openg2p_social_service
5+
from . import test_sr_import_wizard
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
"""SR-import wizard tests."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
from odoo.tests.common import TransactionCase, tagged
6+
7+
8+
def _sr_response(reg_records):
9+
"""Shape that matches OpenG2P SR's actual envelope."""
10+
return {
11+
"header": {"status": "succ"},
12+
"message": {
13+
"search_response": [
14+
{
15+
"reference_id": "r1",
16+
"status": "succ",
17+
"data": {
18+
"reg_type": "Individual",
19+
"reg_record_type": "Individual",
20+
"reg_records": reg_records,
21+
},
22+
}
23+
]
24+
},
25+
}
26+
27+
28+
def _not_found_response():
29+
return {"header": {"status": "rjct"}, "message": {"search_response": []}}
30+
31+
32+
def _payload(given, surname, sex="male", birth_date="1990-01-01"):
33+
return {
34+
"demographic_info": {
35+
"name": {"given_name": given, "surname": surname},
36+
"sex": sex,
37+
"birth_date": birth_date,
38+
}
39+
}
40+
41+
42+
@tagged("post_install", "-at_install")
43+
class TestSrImportWizard(TransactionCase):
44+
@classmethod
45+
def setUpClass(cls):
46+
super().setUpClass()
47+
cls.data_source = cls.env.ref("spp_dci_openg2p.openg2p_dr_source")
48+
cls.uin_type = cls.env.ref("spp_dci_openg2p.id_type_uin")
49+
50+
def _wizard(self, **overrides):
51+
defaults = {
52+
"data_source_id": self.data_source.id,
53+
"discovery_mode": "range",
54+
"range_prefix": "IND-NSR-",
55+
"range_start": 1,
56+
"range_end": 3,
57+
"range_pad": 4,
58+
}
59+
defaults.update(overrides)
60+
return self.env["spp.dci.sr.import.wizard"].create(defaults)
61+
62+
# ------------------------------------------------------------------
63+
# Identifier collection
64+
# ------------------------------------------------------------------
65+
66+
def test_collect_identifiers_range_pads_correctly(self):
67+
wiz = self._wizard(range_start=1, range_end=5, range_pad=4)
68+
idents = wiz._collect_identifiers()
69+
self.assertEqual(
70+
idents,
71+
["IND-NSR-0001", "IND-NSR-0002", "IND-NSR-0003", "IND-NSR-0004", "IND-NSR-0005"],
72+
)
73+
74+
def test_collect_identifiers_list_strips_comments_and_dedupes(self):
75+
wiz = self._wizard(
76+
discovery_mode="list",
77+
identifier_list_raw="# header\nIND-NSR-0001\n\nIND-NSR-0002\nIND-NSR-0001\n",
78+
)
79+
self.assertEqual(wiz._collect_identifiers(), ["IND-NSR-0001", "IND-NSR-0002"])
80+
81+
def test_collect_identifiers_rejects_empty_range(self):
82+
from odoo.exceptions import UserError
83+
84+
wiz = self._wizard(range_start=10, range_end=5)
85+
with self.assertRaises(UserError):
86+
wiz._collect_identifiers()
87+
88+
def test_collect_identifiers_rejects_empty_list(self):
89+
from odoo.exceptions import UserError
90+
91+
wiz = self._wizard(discovery_mode="list", identifier_list_raw=" \n#only comments\n")
92+
with self.assertRaises(UserError):
93+
wiz._collect_identifiers()
94+
95+
# ------------------------------------------------------------------
96+
# Preview step
97+
# ------------------------------------------------------------------
98+
99+
@patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient")
100+
def test_preview_matched_not_found_and_existing_partner(self, mock_client_class):
101+
# 0001 matches, 0002 already on SP, 0003 not found
102+
existing_partner = self.env["res.partner"].create(
103+
{"name": "Existing", "is_registrant": True, "is_group": False}
104+
)
105+
self.env["spp.registry.id"].create(
106+
{
107+
"partner_id": existing_partner.id,
108+
"id_type_id": self.uin_type.id,
109+
"value": "IND-NSR-0002",
110+
}
111+
)
112+
113+
def search(**kwargs):
114+
v = kwargs.get("query_value")
115+
if v == "IND-NSR-0001":
116+
return _sr_response([_payload("Alex", "Rivera")])
117+
if v == "IND-NSR-0002":
118+
return _sr_response([_payload("Priya", "Rivera", sex="female")])
119+
return _not_found_response()
120+
121+
mock_client = MagicMock()
122+
mock_client.search.side_effect = search
123+
mock_client_class.return_value = mock_client
124+
125+
wiz = self._wizard(range_start=1, range_end=3)
126+
wiz.action_preview()
127+
128+
self.assertEqual(wiz.state, "preview")
129+
self.assertEqual(len(wiz.preview_line_ids), 3)
130+
131+
by_uin = {line.uin: line for line in wiz.preview_line_ids}
132+
self.assertEqual(by_uin["IND-NSR-0001"].status, "matched")
133+
self.assertEqual(by_uin["IND-NSR-0001"].given_name, "Alex")
134+
self.assertEqual(by_uin["IND-NSR-0001"].surname, "Rivera")
135+
self.assertFalse(by_uin["IND-NSR-0001"].already_exists)
136+
self.assertTrue(by_uin["IND-NSR-0001"].selected)
137+
138+
self.assertEqual(by_uin["IND-NSR-0002"].status, "matched")
139+
self.assertTrue(by_uin["IND-NSR-0002"].already_exists)
140+
self.assertEqual(by_uin["IND-NSR-0002"].existing_partner_id, existing_partner)
141+
self.assertFalse(by_uin["IND-NSR-0002"].selected) # not pre-selected
142+
143+
self.assertEqual(by_uin["IND-NSR-0003"].status, "not_found")
144+
self.assertFalse(by_uin["IND-NSR-0003"].selected)
145+
146+
@patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient")
147+
def test_preview_captures_service_error_per_subject(self, mock_client_class):
148+
mock_client = MagicMock()
149+
mock_client.search.side_effect = RuntimeError("HTTP 500 from OpenG2P")
150+
mock_client_class.return_value = mock_client
151+
152+
wiz = self._wizard(range_start=1, range_end=2)
153+
wiz.action_preview()
154+
155+
for line in wiz.preview_line_ids:
156+
self.assertEqual(line.status, "error")
157+
self.assertIn("HTTP 500", line.error_message)
158+
self.assertFalse(line.selected)
159+
160+
# ------------------------------------------------------------------
161+
# Import step
162+
# ------------------------------------------------------------------
163+
164+
@patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient")
165+
def test_import_creates_partners_and_reg_ids_for_selected_only(self, mock_client_class):
166+
def search(**kwargs):
167+
v = kwargs.get("query_value")
168+
payloads = {
169+
"IND-NSR-0001": _payload("Alex", "Rivera"),
170+
"IND-NSR-0002": _payload("Priya", "Rivera", sex="female"),
171+
}
172+
if v in payloads:
173+
return _sr_response([payloads[v]])
174+
return _not_found_response()
175+
176+
mock_client = MagicMock()
177+
mock_client.search.side_effect = search
178+
mock_client_class.return_value = mock_client
179+
180+
wiz = self._wizard(range_start=1, range_end=2)
181+
wiz.action_preview()
182+
183+
# Deselect IND-NSR-0002 — only 0001 should import
184+
for line in wiz.preview_line_ids:
185+
if line.uin == "IND-NSR-0002":
186+
line.selected = False
187+
188+
wiz.action_import()
189+
190+
self.assertEqual(wiz.state, "done")
191+
regs = self.env["spp.registry.id"].search([("value", "=", "IND-NSR-0001")])
192+
self.assertEqual(len(regs), 1)
193+
partner = regs.partner_id
194+
# spp_registry auto-computes individual name as
195+
# "FAMILY_NAME, GIVEN_NAME" (uppercased) — assert the canonical
196+
# form, not the raw "Alex Rivera" we passed in.
197+
self.assertEqual(partner.name, "RIVERA, ALEX")
198+
self.assertEqual(partner.given_name, "Alex")
199+
self.assertEqual(partner.family_name, "Rivera")
200+
self.assertTrue(partner.is_registrant)
201+
self.assertFalse(partner.is_group)
202+
203+
# 0002 was deselected — no partner created
204+
self.assertFalse(self.env["spp.registry.id"].search([("value", "=", "IND-NSR-0002")]))
205+
206+
@patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient")
207+
def test_import_skips_already_existing_partners(self, mock_client_class):
208+
existing = self.env["res.partner"].create({"name": "Existing", "is_registrant": True, "is_group": False})
209+
self.env["spp.registry.id"].create(
210+
{
211+
"partner_id": existing.id,
212+
"id_type_id": self.uin_type.id,
213+
"value": "IND-NSR-0001",
214+
}
215+
)
216+
217+
mock_client = MagicMock()
218+
mock_client.search.side_effect = lambda **k: _sr_response([_payload("Alex", "Rivera")])
219+
mock_client_class.return_value = mock_client
220+
221+
wiz = self._wizard(range_start=1, range_end=1)
222+
wiz.action_preview()
223+
224+
# Operator manually checks the box even though "already on SP"
225+
for line in wiz.preview_line_ids:
226+
line.selected = True
227+
228+
wiz.action_import()
229+
230+
# Still only one partner with this UIN — existing one untouched
231+
regs = self.env["spp.registry.id"].search([("value", "=", "IND-NSR-0001")])
232+
self.assertEqual(len(regs), 1)
233+
self.assertEqual(regs.partner_id, existing)
234+
self.assertEqual(existing.name, "Existing") # not renamed
235+
236+
@patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient")
237+
def test_import_auto_enrolls_into_program_when_set(self, mock_client_class):
238+
program = self.env["spp.program"].search([], limit=1)
239+
if not program:
240+
self.skipTest("no spp.program in this environment")
241+
242+
mock_client = MagicMock()
243+
mock_client.search.side_effect = lambda **k: _sr_response([_payload("Alex", "Rivera")])
244+
mock_client_class.return_value = mock_client
245+
246+
wiz = self._wizard(
247+
range_start=1,
248+
range_end=1,
249+
auto_enroll_program_id=program.id,
250+
)
251+
wiz.action_preview()
252+
wiz.action_import()
253+
254+
regs = self.env["spp.registry.id"].search([("value", "=", "IND-NSR-0001")])
255+
mems = self.env["spp.program.membership"].search(
256+
[("partner_id", "=", regs.partner_id.id), ("program_id", "=", program.id)]
257+
)
258+
self.assertEqual(len(mems), 1)
259+
self.assertEqual(mems.state, "draft")
260+
261+
def test_back_to_configure_clears_preview(self):
262+
wiz = self._wizard()
263+
# Skip the live preview — fabricate one line manually
264+
self.env["spp.dci.sr.import.wizard.line"].create(
265+
{
266+
"wizard_id": wiz.id,
267+
"uin": "IND-NSR-0001",
268+
"status": "matched",
269+
"given_name": "Alex",
270+
"surname": "Rivera",
271+
"selected": True,
272+
}
273+
)
274+
wiz.state = "preview"
275+
276+
wiz.action_back_to_configure()
277+
278+
self.assertEqual(wiz.state, "configure")
279+
self.assertFalse(wiz.preview_line_ids)

0 commit comments

Comments
 (0)