Skip to content

Commit 66c1264

Browse files
authored
Merge pull request #130 from OpenSPP/fix/batch-approval-wizard-delete-lines
fix(spp_change_request_v2): fix batch approval wizard line deletion
2 parents 16e6e42 + 5c3ac8b commit 66c1264

4 files changed

Lines changed: 199 additions & 94 deletions

File tree

spp_change_request_v2/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "OpenSPP Change Request V2",
3-
"version": "19.0.2.0.1",
3+
"version": "19.0.2.0.2",
44
"sequence": 50,
55
"category": "OpenSPP",
66
"summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention",

spp_change_request_v2/tests/test_ux_wizards.py

Lines changed: 131 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,108 @@ def test_wizard_onchange_clears_mismatched_registrant(self):
225225
self.assertFalse(wizard.registrant_id)
226226

227227

228+
@tagged("post_install", "-at_install", "cr_ux")
229+
class TestBatchApprovalWizardBase(TestChangeRequestBase):
230+
"""Tests for batch approval wizard that don't require pending CRs."""
231+
232+
def _create_wizard(self, cr_ids, action_type="approve", **kwargs):
233+
"""Helper to create a batch wizard via create_from_selection."""
234+
result = (
235+
self.env["spp.cr.batch.approval.wizard"].with_context(active_ids=cr_ids).create_from_selection(action_type)
236+
)
237+
wizard = self.env["spp.cr.batch.approval.wizard"].browse(result["res_id"])
238+
if kwargs:
239+
wizard.write(kwargs)
240+
return wizard
241+
242+
def test_create_from_selection_no_active_ids(self):
243+
"""Test create_from_selection raises error with no active_ids."""
244+
with self.assertRaises(UserError):
245+
self.env["spp.cr.batch.approval.wizard"].create_from_selection("approve")
246+
247+
def test_create_from_selection_returns_action(self):
248+
"""Test create_from_selection returns a valid window action."""
249+
cr_type = self.env["spp.change.request.type"].search([], limit=1)
250+
if not cr_type:
251+
self.skipTest("No CR type available")
252+
draft_cr = self.env["spp.change.request"].create(
253+
{"request_type_id": cr_type.id, "registrant_id": self.group.id}
254+
)
255+
result = (
256+
self.env["spp.cr.batch.approval.wizard"]
257+
.with_context(active_ids=[draft_cr.id])
258+
.create_from_selection("approve")
259+
)
260+
self.assertEqual(result["type"], "ir.actions.act_window")
261+
self.assertEqual(result["res_model"], "spp.cr.batch.approval.wizard")
262+
self.assertEqual(result["target"], "new")
263+
self.assertTrue(result["res_id"])
264+
265+
def test_create_from_selection_action_types(self):
266+
"""Test create_from_selection sets action_type correctly."""
267+
cr_type = self.env["spp.change.request.type"].search([], limit=1)
268+
if not cr_type:
269+
self.skipTest("No CR type available")
270+
draft_cr = self.env["spp.change.request"].create(
271+
{"request_type_id": cr_type.id, "registrant_id": self.group.id}
272+
)
273+
for action_type in ("approve", "reject", "revision"):
274+
result = (
275+
self.env["spp.cr.batch.approval.wizard"]
276+
.with_context(active_ids=[draft_cr.id])
277+
.create_from_selection(action_type)
278+
)
279+
wizard = self.env["spp.cr.batch.approval.wizard"].browse(result["res_id"])
280+
self.assertEqual(wizard.action_type, action_type)
281+
282+
def test_error_message_not_pending(self):
283+
"""Test error message for CR not in pending state."""
284+
cr_type = self.env["spp.change.request.type"].search([], limit=1)
285+
if not cr_type:
286+
self.skipTest("No CR type available")
287+
draft_cr = self.env["spp.change.request"].create(
288+
{"request_type_id": cr_type.id, "registrant_id": self.group.id}
289+
)
290+
wizard = self._create_wizard([draft_cr.id])
291+
line = wizard.line_ids[0]
292+
self.assertFalse(line.can_process)
293+
self.assertIn("Not pending approval", line.error_message)
294+
295+
def test_batch_approve_requires_valid_crs(self):
296+
"""Test batch approve fails if no valid CRs."""
297+
cr_type = self.env["spp.change.request.type"].search([], limit=1)
298+
if not cr_type:
299+
self.skipTest("No CR type available")
300+
draft_cr = self.env["spp.change.request"].create(
301+
{"request_type_id": cr_type.id, "registrant_id": self.group.id}
302+
)
303+
wizard = self._create_wizard([draft_cr.id])
304+
self.assertEqual(wizard.valid_count, 0)
305+
with self.assertRaises(UserError):
306+
wizard.action_confirm()
307+
308+
def test_batch_wizard_line_removal(self):
309+
"""Test that lines can be removed from a saved wizard."""
310+
cr_type = self.env["spp.change.request.type"].search([], limit=1)
311+
if not cr_type:
312+
self.skipTest("No CR type available")
313+
crs = self.env["spp.change.request"]
314+
for _i in range(3):
315+
crs |= self.env["spp.change.request"].create(
316+
{"request_type_id": cr_type.id, "registrant_id": self.group.id}
317+
)
318+
wizard = self._create_wizard(crs.ids)
319+
self.assertEqual(len(wizard.line_ids), 3)
320+
321+
# Remove one line
322+
wizard.line_ids[0].unlink()
323+
wizard.invalidate_recordset()
324+
self.assertEqual(len(wizard.line_ids), 2)
325+
326+
228327
@tagged("post_install", "-at_install", "cr_ux")
229328
class TestBatchApprovalWizard(TestChangeRequestBase):
230-
"""Tests for the batch approval wizard."""
329+
"""Tests for batch approval wizard that require pending CRs."""
231330

232331
@classmethod
233332
def setUpClass(cls):
@@ -237,7 +336,6 @@ def setUpClass(cls):
237336

238337
def setUp(self):
239338
super().setUp()
240-
# Create multiple pending CRs for batch testing
241339
cr_type = self.env["spp.change.request.type"].search([("approval_definition_id", "!=", False)], limit=1)
242340

243341
if not cr_type:
@@ -254,56 +352,57 @@ def setUp(self):
254352
cr.action_submit_for_approval()
255353
self.pending_crs |= cr
256354

355+
def _create_wizard(self, cr_ids, action_type="approve", **kwargs):
356+
"""Helper to create a batch wizard via create_from_selection."""
357+
result = (
358+
self.env["spp.cr.batch.approval.wizard"].with_context(active_ids=cr_ids).create_from_selection(action_type)
359+
)
360+
wizard = self.env["spp.cr.batch.approval.wizard"].browse(result["res_id"])
361+
if kwargs:
362+
wizard.write(kwargs)
363+
return wizard
364+
257365
def test_batch_wizard_initialization(self):
258366
"""Test batch wizard initializes with selected CRs."""
259-
wizard = self.env["spp.cr.batch.approval.wizard"].with_context(active_ids=self.pending_crs.ids).create({})
367+
wizard = self._create_wizard(self.pending_crs.ids)
260368

261369
self.assertEqual(wizard.total_count, 3)
262370
self.assertEqual(len(wizard.line_ids), 3)
263371

264372
def test_batch_wizard_counts(self):
265373
"""Test batch wizard computes valid/invalid counts correctly."""
266-
wizard = self.env["spp.cr.batch.approval.wizard"].with_context(active_ids=self.pending_crs.ids).create({})
374+
wizard = self._create_wizard(self.pending_crs.ids)
267375

268376
# All should be valid if user can approve
269377
self.assertEqual(wizard.total_count, wizard.valid_count + wizard.invalid_count)
270378

271-
def test_batch_approve_requires_valid_crs(self):
272-
"""Test batch approve fails if no valid CRs."""
273-
# Create wizard with draft CR (cannot be approved)
274-
cr_type = self.env["spp.change.request.type"].search([], limit=1)
275-
draft_cr = self.env["spp.change.request"].create(
276-
{
277-
"request_type_id": cr_type.id,
278-
"registrant_id": self.group.id,
279-
}
280-
)
281-
282-
wizard = self.env["spp.cr.batch.approval.wizard"].with_context(active_ids=[draft_cr.id]).create({})
283-
284-
# Should have 0 valid CRs
285-
self.assertEqual(wizard.valid_count, 0)
379+
def test_batch_reject_requires_comment(self):
380+
"""Test batch reject requires a reason."""
381+
wizard = self._create_wizard(self.pending_crs.ids, action_type="reject", comment="")
286382

383+
# Should fail without comment
287384
with self.assertRaises(UserError):
288385
wizard.action_confirm()
289386

290-
def test_batch_reject_requires_comment(self):
291-
"""Test batch reject requires a reason."""
292-
wizard = (
293-
self.env["spp.cr.batch.approval.wizard"]
294-
.with_context(active_ids=self.pending_crs.ids)
295-
.create(
296-
{
297-
"action_type": "reject",
298-
"comment": "",
299-
}
300-
)
301-
)
387+
def test_batch_revision_requires_comment(self):
388+
"""Test batch revision requires notes."""
389+
wizard = self._create_wizard(self.pending_crs.ids, action_type="revision", comment="")
302390

303-
# Should fail without comment
304391
with self.assertRaises(UserError):
305392
wizard.action_confirm()
306393

394+
def test_error_message_mixed_crs(self):
395+
"""Test error messages with mix of pending and draft CRs."""
396+
cr_type = self.env["spp.change.request.type"].search([], limit=1)
397+
draft_cr = self.env["spp.change.request"].create(
398+
{"request_type_id": cr_type.id, "registrant_id": self.group.id}
399+
)
400+
wizard = self._create_wizard(self.pending_crs.ids + [draft_cr.id])
401+
self.assertTrue(wizard.total_count > 0)
402+
invalid_lines = wizard.line_ids.filtered(lambda ln: not ln.can_process)
403+
for line in invalid_lines:
404+
self.assertTrue(line.error_message)
405+
307406

308407
@tagged("post_install", "-at_install", "cr_ux")
309408
class TestConflictComparisonWizard(TestChangeRequestBase):

spp_change_request_v2/views/batch_approval_wizard_views.xml

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
<!-- Selected requests -->
9696
<notebook>
9797
<page string="Selected Requests" name="requests">
98-
<field name="line_ids" nolabel="1" readonly="1">
98+
<field name="line_ids" nolabel="1">
9999
<list
100100
decoration-muted="not can_process"
101101
decoration-danger="not can_process"
@@ -115,12 +115,6 @@
115115
invisible="not can_process"
116116
title="Ready to process"
117117
/>
118-
<button
119-
name="action_remove_line"
120-
type="object"
121-
icon="fa-trash"
122-
title="Remove from batch"
123-
/>
124118
</list>
125119
</field>
126120
</page>
@@ -211,39 +205,40 @@
211205
</record>
212206

213207
<!-- ══════════════════════════════════════════════════════════════════════════
214-
BATCH APPROVAL - SERVER ACTION (for list view context menu)
208+
BATCH APPROVAL - SERVER ACTIONS (for list view context menu)
209+
Creates the wizard record before opening it, so One2many line
210+
operations (delete, buttons) work without "Please save first" errors.
215211
══════════════════════════════════════════════════════════════════════════ -->
216-
<!-- Note: Security is enforced via ir.model.access.csv - only validators can access wizard -->
217-
<record id="action_batch_approve" model="ir.actions.act_window">
212+
<record id="server_action_batch_approve" model="ir.actions.server">
218213
<field name="name">Batch Approve</field>
219-
<field name="res_model">spp.cr.batch.approval.wizard</field>
220-
<field name="view_mode">form</field>
221-
<field name="view_id" ref="view_batch_approval_wizard_form" />
222-
<field name="target">new</field>
214+
<field name="model_id" ref="model_spp_change_request" />
223215
<field name="binding_model_id" ref="model_spp_change_request" />
224216
<field name="binding_view_types">list</field>
225-
<field name="context">{'default_action_type': 'approve'}</field>
217+
<field name="state">code</field>
218+
<field name="code">
219+
action = env["spp.cr.batch.approval.wizard"].with_context(active_ids=records.ids).create_from_selection("approve")
220+
</field>
226221
</record>
227222

228-
<record id="action_batch_reject" model="ir.actions.act_window">
223+
<record id="server_action_batch_reject" model="ir.actions.server">
229224
<field name="name">Batch Decline</field>
230-
<field name="res_model">spp.cr.batch.approval.wizard</field>
231-
<field name="view_mode">form</field>
232-
<field name="view_id" ref="view_batch_approval_wizard_form" />
233-
<field name="target">new</field>
225+
<field name="model_id" ref="model_spp_change_request" />
234226
<field name="binding_model_id" ref="model_spp_change_request" />
235227
<field name="binding_view_types">list</field>
236-
<field name="context">{'default_action_type': 'reject'}</field>
228+
<field name="state">code</field>
229+
<field name="code">
230+
action = env["spp.cr.batch.approval.wizard"].with_context(active_ids=records.ids).create_from_selection("reject")
231+
</field>
237232
</record>
238233

239-
<record id="action_batch_request_changes" model="ir.actions.act_window">
234+
<record id="server_action_batch_request_changes" model="ir.actions.server">
240235
<field name="name">Batch Request Changes</field>
241-
<field name="res_model">spp.cr.batch.approval.wizard</field>
242-
<field name="view_mode">form</field>
243-
<field name="view_id" ref="view_batch_approval_wizard_form" />
244-
<field name="target">new</field>
236+
<field name="model_id" ref="model_spp_change_request" />
245237
<field name="binding_model_id" ref="model_spp_change_request" />
246238
<field name="binding_view_types">list</field>
247-
<field name="context">{'default_action_type': 'revision'}</field>
239+
<field name="state">code</field>
240+
<field name="code">
241+
action = env["spp.cr.batch.approval.wizard"].with_context(active_ids=records.ids).create_from_selection("revision")
242+
</field>
248243
</record>
249244
</odoo>

spp_change_request_v2/wizards/batch_approval_wizard.py

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -79,44 +79,60 @@ class SPPCRBatchApprovalWizard(models.TransientModel):
7979
)
8080

8181
# ══════════════════════════════════════════════════════════════════════════
82-
# DEFAULT
82+
# CREATION
8383
# ══════════════════════════════════════════════════════════════════════════
8484

8585
@api.model
86-
def default_get(self, fields_list):
87-
"""Initialize wizard with selected change requests."""
88-
res = super().default_get(fields_list)
86+
def create_from_selection(self, action_type="approve"):
87+
"""Create and populate wizard from selected change requests.
8988
89+
Called by server actions to create a saved wizard record before
90+
opening the form, so that One2many operations (delete lines, etc.)
91+
work without 'Please save your changes first' errors.
92+
"""
9093
active_ids = self.env.context.get("active_ids", [])
91-
if active_ids and "line_ids" in fields_list:
92-
crs = self.env["spp.change.request"].browse(active_ids)
93-
# Prefetch all needed fields in a single query to avoid N+1
94-
crs.read(["approval_state", "can_approve", "display_state"])
95-
lines = []
96-
for cr in crs:
97-
can_process = cr.approval_state == "pending" and cr.can_approve
98-
lines.append(
99-
Command.create(
100-
{
101-
"change_request_id": cr.id,
102-
"can_process": can_process,
103-
"error_message": "" if can_process else self._get_error_message(cr),
104-
}
105-
)
94+
if not active_ids:
95+
raise UserError(_("No change requests selected."))
96+
97+
crs = self.env["spp.change.request"].browse(active_ids)
98+
crs.read(["approval_state", "can_approve", "display_state"])
99+
100+
lines = []
101+
for change_request in crs:
102+
can_process = change_request.approval_state == "pending" and change_request.can_approve
103+
lines.append(
104+
Command.create(
105+
{
106+
"change_request_id": change_request.id,
107+
"can_process": can_process,
108+
"error_message": "" if can_process else self._get_error_message(change_request),
109+
}
106110
)
107-
res["line_ids"] = lines
111+
)
108112

109-
return res
113+
wizard = self.create(
114+
{
115+
"action_type": action_type,
116+
"line_ids": lines,
117+
}
118+
)
110119

111-
def _get_error_message(self, cr):
112-
"""Get error message explaining why CR cannot be processed.
120+
return {
121+
"type": "ir.actions.act_window",
122+
"name": _("Batch Approval"),
123+
"res_model": "spp.cr.batch.approval.wizard",
124+
"res_id": wizard.id,
125+
"view_mode": "form",
126+
"view_id": self.env.ref("spp_change_request_v2.view_batch_approval_wizard_form").id,
127+
"target": "new",
128+
}
113129

114-
Note: Plain strings are used here instead of _() to avoid translation
115-
context issues during default_get() in test scenarios.
116-
"""
117-
if cr.approval_state != "pending":
118-
return f"Not pending approval (state: {cr.display_state})"
119-
if not cr.can_approve:
130+
@staticmethod
131+
def _get_error_message(change_request):
132+
"""Get error message explaining why CR cannot be processed."""
133+
if change_request.approval_state != "pending":
134+
return f"Not pending approval (state: {change_request.display_state})"
135+
if not change_request.can_approve:
120136
return "You are not authorized to approve this request"
121137
return ""
122138

@@ -311,11 +327,6 @@ class SPPCRBatchApprovalLine(models.TransientModel):
311327
)
312328
result_message = fields.Char()
313329

314-
def action_remove_line(self):
315-
"""Remove this line from the batch wizard."""
316-
self.ensure_one()
317-
self.unlink()
318-
319330
@api.depends("change_request_id")
320331
def _compute_document_count(self):
321332
# Batch prefetch document_ids to avoid N+1 queries

0 commit comments

Comments
 (0)