Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docsource/modules190-200.rst
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,7 @@ Module coverage 19.0 -> 20.0
+---------------------------------------------------+----------------------+-------------------------------------------------+
| mail_plugin | | |
+---------------------------------------------------+----------------------+-------------------------------------------------+
| maintenance | | |
| maintenance |Done | |
+---------------------------------------------------+----------------------+-------------------------------------------------+
| marketing_card | | |
+---------------------------------------------------+----------------------+-------------------------------------------------+
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<record id="mail_act_maintenance_request" model="mail.activity.type">
<field name="icon">build</field>
</record>
</odoo>
160 changes: 160 additions & 0 deletions openupgrade_scripts/scripts/maintenance/20.0.1.0/post-migration.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading