diff --git a/docsource/modules190-200.rst b/docsource/modules190-200.rst index 8da46d62bbc..500e01b0add 100644 --- a/docsource/modules190-200.rst +++ b/docsource/modules190-200.rst @@ -708,7 +708,7 @@ Module coverage 19.0 -> 20.0 +---------------------------------------------------+----------------------+-------------------------------------------------+ | mail_plugin | | | +---------------------------------------------------+----------------------+-------------------------------------------------+ -| maintenance | | | +| maintenance |Done | | +---------------------------------------------------+----------------------+-------------------------------------------------+ | marketing_card | | | +---------------------------------------------------+----------------------+-------------------------------------------------+ diff --git a/openupgrade_scripts/scripts/maintenance/20.0.1.0/noupdate_changes.xml b/openupgrade_scripts/scripts/maintenance/20.0.1.0/noupdate_changes.xml new file mode 100644 index 00000000000..8820c3475d3 --- /dev/null +++ b/openupgrade_scripts/scripts/maintenance/20.0.1.0/noupdate_changes.xml @@ -0,0 +1,6 @@ + + + + build + + diff --git a/openupgrade_scripts/scripts/maintenance/20.0.1.0/post-migration.py b/openupgrade_scripts/scripts/maintenance/20.0.1.0/post-migration.py new file mode 100644 index 00000000000..a8a3b7ddc70 --- /dev/null +++ b/openupgrade_scripts/scripts/maintenance/20.0.1.0/post-migration.py @@ -0,0 +1,160 @@ +# Copyright 2026 Don Kendall +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +def _carry_completion_into_state(env): + """19.0 recorded that a request was finished on its STAGE; 20.0 on the request. + + 19.0 had no lifecycle field. A request was finished when it sat in a stage + whose `maintenance.stage.done` was set -- "Repaired" and "Scrap" in the + standard data -- and `write` read `stage_id.done` to decide whether to spawn + the next occurrence of a recurring request. + + 20.0 adds `maintenance.request.state`, required, defaulting to 'normal' + ("In Progress"), and moves every consumer onto it: `maintenance_open_count` + counts `state not in ('done', 'cancelled')`, the equipment's effective date + -- and so its MTBF -- counts corrective requests with `state == 'done'`, and + `write` maintains `close_date` from it. + + The column is new, required and defaulted, so the ORM fills every existing + row with 'normal' and nothing raises: every request ever completed comes out + reading as still in progress, and the equipment statistics computed from + them read as though no maintenance had ever been finished. + + The seed shows the contradiction directly -- two of its five requests carry + a `close_date` while reading "In Progress", a combination 20.0's own + `create` exists to prevent:: + + if request.close_date and request.state != 'done': + request.close_date = False + + Only rows still on the ORM's default are touched, so nothing another script + has already decided is overwritten. + """ + openupgrade.logged_query( + env.cr, + """ + UPDATE maintenance_request r + SET state = 'done' + FROM maintenance_stage s + WHERE s.id = r.stage_id AND s.done AND r.state = 'normal' + """, + ) + + +def _carry_the_assignee_into_the_user_list(env): + """user_id (many2one) becomes user_ids (many2many), and nothing carries it. + + 19.0 assigned a request to one technician through `user_id`. 20.0 drops that + field for `user_ids`, a many2many whose compute only ever adds the + equipment's technician:: + + @api.depends('company_id', 'equipment_id') + def _compute_user_ids(self): + ... + technician = (request.equipment_id.technician_user_id + or request.equipment_id.category_id.technician_user_id) + + So a request assigned to anyone else -- a stand-in, a second-line engineer, + whoever took it over -- comes out assigned to nobody, or to the wrong person. + + **This seed cannot show it.** All five of its requests are assigned to + exactly the technician their equipment carries, so the many2many comes out + right by coincidence and every after-the-fact comparison agrees. The evidence + is the pair of field definitions, and the migration test builds the row the + seed lacks: a request assigned to someone who is not the equipment's + technician. + + `user_id` is read straight rather than through a legacy name: 20.0 declares + no field of that name on this model, so the upgrade leaves the column alone + and nothing can have overwritten it. + + 20.0's own company filter is applied rather than ignored. `_compute_user_ids` + drops a user who does not belong to the request's company, so inserting one + here would put a name on the request that the next recompute takes off again. + The ones that excludes are reported rather than dropped in silence. + """ + openupgrade.logged_query( + env.cr, + """ + INSERT INTO maintenance_request_res_users_rel + (maintenance_request_id, res_users_id) + SELECT r.id, r.user_id + FROM maintenance_request r + JOIN res_company_users_rel cu + ON cu.user_id = r.user_id AND cu.cid = r.company_id + WHERE r.user_id IS NOT NULL + ON CONFLICT DO NOTHING + """, + ) + env.cr.execute( + """ + SELECT count(*) + FROM maintenance_request r + WHERE r.user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM res_company_users_rel cu + WHERE cu.user_id = r.user_id AND cu.cid = r.company_id + ) + """ + ) + outside = env.cr.fetchone()[0] + if outside: + openupgrade.message( + env.cr, + "maintenance", + False, + False, + "%s maintenance request(s) were assigned to a user who does not " + "belong to the request's company; 20.0's _compute_user_ids removes " + "such a user, so the assignment was not carried over", + outside, + ) + + +def _report_the_kanban_state_that_has_no_home(env): + """19.0's kanban_state is gone and no 20.0 value certainly means the same. + + 19.0 carried a review indicator beside the stage: 'normal' (In Progress), + 'blocked' (Blocked) and 'done' (Ready for next stage). 20.0 has no separate + indicator; `state` covers that axis with 'changes_requested' and 'approved'. + + The shapes line up loosely and the meanings do not. "Blocked" says the work + cannot proceed -- a missing part, a machine in use -- where "Changes + Requested" says a reviewer asked for something, so a request blocked on a + delivery would come out claiming a review that never happened. Nothing is + written, the count is reported, and the 19.0 answer stays in its column where + an administrator can act on it. + + Every request in this seed is on 'normal', which is what `state` defaults to + anyway, so on a standard database this reports nothing at all. + """ + env.cr.execute( + """ + SELECT kanban_state, count(*) FROM maintenance_request + WHERE kanban_state IS NOT NULL AND kanban_state != 'normal' + GROUP BY kanban_state + """ + ) + for value, count in env.cr.fetchall(): + openupgrade.message( + env.cr, + "maintenance", + False, + False, + "%s maintenance request(s) were on kanban_state '%s'; 20.0 has no " + "value that certainly means the same, so state was left alone and " + "maintenance_request.kanban_state still holds the 19.0 answer", + count, + value, + ) + + +@openupgrade.migrate() +def migrate(env, version): + openupgrade.load_data(env, "maintenance", "20.0.1.0/noupdate_changes.xml") + _carry_completion_into_state(env) + _carry_the_assignee_into_the_user_list(env) + _report_the_kanban_state_that_has_no_home(env) diff --git a/openupgrade_scripts/scripts/maintenance/20.0.1.0/upgrade_analysis.txt b/openupgrade_scripts/scripts/maintenance/20.0.1.0/upgrade_analysis.txt new file mode 100644 index 00000000000..8d9b0fd4a0e --- /dev/null +++ b/openupgrade_scripts/scripts/maintenance/20.0.1.0/upgrade_analysis.txt @@ -0,0 +1,70 @@ +---Models in module 'maintenance'--- +---Fields in module 'maintenance'--- +hr_maintenance / maintenance.equipment / equipment_assign_to (selection): selection_keys removed: [department, employee] +maintenance / maintenance.equipment / equipment_assign_to (selection): previously in module hr_maintenance +maintenance / maintenance.equipment / equipment_req_properties_definition (properties_definition): NEW +maintenance / maintenance.request / archive (boolean) : DEL +maintenance / maintenance.request / email_cc (char) : DEL +maintenance / maintenance.request / equipment_req_properties (properties): NEW hasdefault: compute +maintenance / maintenance.request / instruction_google_slide (char): DEL +maintenance / maintenance.request / instruction_pdf (binary) : DEL attachment: True +maintenance / maintenance.request / instruction_type (selection) : DEL selection_keys: ['google_slide', 'pdf', 'text'] +maintenance / maintenance.request / kanban_state (selection) : DEL required, selection_keys: ['blocked', 'done', 'normal'] +maintenance / maintenance.request / request_date (date) : DEL +maintenance / maintenance.request / state (selection) : NEW required, selection_keys: ['approved', 'cancelled', 'changes_requested', 'done', 'normal'], hasdefault: default +maintenance / maintenance.request / user_id (many2one) : DEL relation: res.users +maintenance / maintenance.request / user_ids (many2many) : NEW relation: res.users, hasdefault: compute +maintenance / maintenance.stage / done (boolean) : DEL +maintenance / maintenance.stage / maintenance_team_ids (many2many): NEW relation: maintenance.team +---XML records in module 'maintenance'--- +NEW ir.access: maintenance.access_equipment_category_admin_user +NEW ir.access: maintenance.access_equipment_category_user +NEW ir.access: maintenance.access_mail_activity_type_equipment_manager +NEW ir.access: maintenance.access_maintenance_stage_admin_user +NEW ir.access: maintenance.access_maintenance_stage_user +NEW ir.access: maintenance.access_maintenance_team_admin_user +NEW ir.access: maintenance.access_maintenance_team_user +NEW ir.access: maintenance.equipment_request_rule_admin_user +NEW ir.access: maintenance.equipment_request_rule_user +NEW ir.access: maintenance.equipment_rule_admin_user +NEW ir.access: maintenance.equipment_rule_user +NEW ir.access: maintenance.maintenance_equipment_category_comp_rule +NEW ir.access: maintenance.maintenance_equipment_comp_rule +NEW ir.access: maintenance.maintenance_request_comp_rule +NEW ir.access: maintenance.maintenance_team_comp_rule +ir.actions.act_window: maintenance.hr_equipment_request_action_from_equipment (deleted domain) +ir.actions.act_window: maintenance.hr_equipment_todo_request_action_from_dashboard (deleted domain) +ir.actions.act_window: maintenance.mail_activity_type_action_config_maintenance (deleted domain) +DEL ir.model.access: maintenance.access_equipment_admin_user +DEL ir.model.access: maintenance.access_equipment_category_admin_user +DEL ir.model.access: maintenance.access_equipment_category_user +DEL ir.model.access: maintenance.access_equipment_user +DEL ir.model.access: maintenance.access_mail_activity_type_equipment_manager +DEL ir.model.access: maintenance.access_maintenance_stage_admin_user +DEL ir.model.access: maintenance.access_maintenance_stage_user +DEL ir.model.access: maintenance.access_maintenance_system_user +DEL ir.model.access: maintenance.access_maintenance_team_admin_user +DEL ir.model.access: maintenance.access_maintenance_team_user +ir.model.constraint: maintenance.constraint_maintenance_equipment_serial_no (changed definition: is now 'False' ('unique(serial_no)')) +DEL ir.rule: maintenance.equipment_request_rule_admin_user (noupdate) +DEL ir.rule: maintenance.equipment_request_rule_user (noupdate) +DEL ir.rule: maintenance.equipment_rule_admin_user (noupdate) +DEL ir.rule: maintenance.equipment_rule_user (noupdate) +DEL ir.rule: maintenance.maintenance_equipment_category_comp_rule (noupdate) +DEL ir.rule: maintenance.maintenance_equipment_comp_rule (noupdate) +DEL ir.rule: maintenance.maintenance_request_comp_rule (noupdate) +DEL ir.rule: maintenance.maintenance_team_comp_rule (noupdate) +DEL ir.ui.menu: maintenance.menu_m_request_calendar +NEW ir.ui.view: maintenance.hr_equipment_request_view_card +NEW ir.ui.view: maintenance.hr_equipment_stage_view_form +mail.activity.type: maintenance.mail_act_maintenance_request (noupdate switched) +mail.message.subtype: maintenance.mt_cat_mat_assign (noupdate switched) +mail.message.subtype: maintenance.mt_cat_req_created (noupdate switched) +mail.message.subtype: maintenance.mt_mat_assign (noupdate switched) +mail.message.subtype: maintenance.mt_req_created (noupdate switched) +mail.message.subtype: maintenance.mt_req_status (noupdate switched) +maintenance.stage: maintenance.stage_0 (noupdate switched) +maintenance.stage: maintenance.stage_1 (noupdate switched) +maintenance.stage: maintenance.stage_3 (noupdate switched) +maintenance.stage: maintenance.stage_4 (noupdate switched) +maintenance.team: maintenance.equipment_team_maintenance (noupdate switched) diff --git a/openupgrade_scripts/scripts/maintenance/tests/data_maintenance_migration.py b/openupgrade_scripts/scripts/maintenance/tests/data_maintenance_migration.py new file mode 100644 index 00000000000..122aa1a3caf --- /dev/null +++ b/openupgrade_scripts/scripts/maintenance/tests/data_maintenance_migration.py @@ -0,0 +1,104 @@ +env = locals().get("env") + +# 19.0 records completion on the stage and assignment on a many2one; 20.0 +# records the first on the request and the second on a many2many computed from +# the equipment. The seed can show neither: every one of its requests sits on a +# stage that is not done, and every one is assigned to exactly the technician +# its equipment carries, so the many2many comes out right whether anything +# carried it or not. +# +# The fixtures are filed under a module that does not exist. _process_end +# removes data rows belonging to modules it has just loaded, so an id claiming +# to be maintenance's would be deleted at the end of the run. +request_model = env["maintenance.request"] +stage_model = env["maintenance.stage"] + +done_stage = stage_model.search([("done", "=", True)], limit=1) +open_stage = stage_model.search([("done", "=", False)], limit=1) +assert done_stage and open_stage, "the seed has no pair of stages to tell apart" + +company = env.ref("base.main_company") +# Two different users, both in the request's company: the script applies 20.0's +# own company filter, so a technician outside it would be excluded by design and +# the test would be asserting the wrong thing. +users = env["res.users"].search( + [("company_ids", "in", company.id), ("active", "=", True)], order="id", limit=2 +) +assert len(users) == 2, "the seed has fewer than two users in the main company" +equipment_technician, other_technician = users[0], users[1] + +equipment = env["maintenance.equipment"].create( + { + "name": "ou19-equipment", + "company_id": company.id, + "technician_user_id": equipment_technician.id, + } +) + +# 1. Finished in 19.0's terms: it sits in a stage marked done. 20.0 must read +# it as done, or every count and every MTBF over it is computed as though the +# maintenance had never been finished. +finished = request_model.create( + { + "name": "ou19-request-finished", + "company_id": company.id, + "equipment_id": equipment.id, + "stage_id": done_stage.id, + "user_id": equipment_technician.id, + } +) + +# 2. Not finished. This is the half that says the carry is targeted: a blanket +# write would satisfy the assertion above and still be wrong. +open_request = request_model.create( + { + "name": "ou19-request-open", + "company_id": company.id, + "equipment_id": equipment.id, + "stage_id": open_stage.id, + "user_id": equipment_technician.id, + } +) + +# 3. Assigned to someone who is NOT the equipment's technician -- the row the +# seed does not contain. 20.0's _compute_user_ids adds the equipment's +# technician and nobody else, so without the carry this assignment is lost. +reassigned = request_model.create( + { + "name": "ou19-request-reassigned", + "company_id": company.id, + "equipment_id": equipment.id, + "stage_id": open_stage.id, + "user_id": other_technician.id, + } +) + +# 4. Blocked. 20.0 has no value that certainly means this, so the migration must +# report it and leave state on its default rather than invent an answer. +blocked = request_model.create( + { + "name": "ou19-request-blocked", + "company_id": company.id, + "equipment_id": equipment.id, + "stage_id": open_stage.id, + "user_id": equipment_technician.id, + } +) +blocked.write({"kanban_state": "blocked"}) + +for record, name in ( + (finished, "ou19_request_finished"), + (open_request, "ou19_request_open"), + (reassigned, "ou19_request_reassigned"), + (blocked, "ou19_request_blocked"), +): + env["ir.model.data"].create( + { + "module": "__ou19__", + "name": name, + "model": "maintenance.request", + "res_id": record.id, + } + ) + +env.cr.commit() diff --git a/openupgrade_scripts/scripts/maintenance/tests/test_maintenance_migration.py b/openupgrade_scripts/scripts/maintenance/tests/test_maintenance_migration.py new file mode 100644 index 00000000000..a0a244bb55f --- /dev/null +++ b/openupgrade_scripts/scripts/maintenance/tests/test_maintenance_migration.py @@ -0,0 +1,120 @@ +from odoo.tests import TransactionCase + +from odoo.addons.openupgrade_framework import openupgrade_test + + +@openupgrade_test +class TestMaintenanceMigration(TransactionCase): + """Assertions on completion and assignment surviving the 19.0 model change. + + Every method opens by resolving its fixture. The data snippet runs in a + separate process through the 19.0 shell, and if its commit were lost it + would exit 0 having written nothing -- an assertion whose empty case is a + pass would then report success for a migration that never happened. + """ + + def _request(self, name): + record = self.env.ref(f"__ou19__.{name}", raise_if_not_found=False) + self.assertTrue( + record, f"{name} is missing: the 19.0 fixture never reached the database" + ) + return record + + def _stage_is_done(self, request): + """Read maintenance_stage.done from the column, not through the ORM. + + 20.0 drops the field. The column survives the upgrade -- Odoo leaves a + removed field's column in place -- which is how the migration reads it + too, but `stage.done` through the ORM is an AttributeError. + """ + self.env.cr.execute( + "SELECT done FROM maintenance_stage WHERE id = %s", (request.stage_id.id,) + ) + row = self.env.cr.fetchone() + self.assertTrue(row, "the fixture's stage is gone") + return row[0] + + def test_a_request_finished_in_19_0_reads_as_done(self): + """19.0 said so through its stage; 20.0 has to say it on the request.""" + request = self._request("ou19_request_finished") + self.assertTrue( + self._stage_is_done(request), + "the fixture is no longer on a stage marked done", + ) + self.assertEqual( + request.state, + "done", + "a completed maintenance request came out reading as In Progress, so " + "it is counted as open and its equipment's MTBF ignores it", + ) + + def test_a_request_still_open_is_left_where_the_default_puts_it(self): + """The half that says the carry is targeted rather than blanket.""" + request = self._request("ou19_request_open") + self.assertFalse(self._stage_is_done(request)) + self.assertEqual( + request.state, "normal", "an unfinished request was marked done" + ) + + def test_the_seed_agrees_stage_by_stage(self): + """Not just the fixture: every request on a done stage must read done. + + The fixture proves the rule fires; this proves it fired everywhere, + which is what a missed row would look like. + """ + self.env.cr.execute( + """ + SELECT count(*) FROM maintenance_request r + JOIN maintenance_stage s ON s.id = r.stage_id + WHERE s.done AND r.state != 'done' + """ + ) + self.assertEqual( + self.env.cr.fetchone()[0], + 0, + "a request on a done stage is still not marked done", + ) + + def test_an_assignee_who_is_not_the_equipment_technician_survives(self): + """The row the seed does not contain, and the only one that can fail. + + Every request in the seed is assigned to exactly its equipment's + technician, so _compute_user_ids reproduces the assignment by itself and + an assertion over those rows passes whether anything carried it or not. + """ + request = self._request("ou19_request_reassigned") + self.env.cr.execute( + "SELECT user_id FROM maintenance_request WHERE id = %s", (request.id,) + ) + assignee = self.env.cr.fetchone()[0] + self.assertTrue(assignee, "the fixture lost its 19.0 user_id") + self.assertNotEqual( + assignee, + request.equipment_id.technician_user_id.id, + "the fixture no longer distinguishes the assignee from the " + "equipment's technician, so it can no longer fail", + ) + self.assertIn( + assignee, + request.user_ids.ids, + "the 19.0 assignee is not among the technicians, so the request " + "came out assigned to whoever the equipment names instead", + ) + + def test_a_blocked_request_was_not_given_an_invented_state(self): + """20.0 offers no value that certainly means 19.0's 'blocked'.""" + request = self._request("ou19_request_blocked") + self.env.cr.execute( + "SELECT kanban_state FROM maintenance_request WHERE id = %s", (request.id,) + ) + self.assertEqual( + self.env.cr.fetchone()[0], + "blocked", + "the 19.0 answer is gone, so an administrator cannot act on it", + ) + self.assertEqual( + request.state, + "normal", + "a request blocked on something outside a review was given a " + "review state it never had", + )