from datetime import timedelta from odoo import models, fields, api, _ from odoo.exceptions import UserError, AccessError class LaundryOrderUnlockWizard(models.TransientModel): """Manager-only wizard that grants a TIMED unlock window on a locked laundry.order. The wizard does not unlock the order permanently — it sets `manager_unlocked_until` to a future timestamp. The lock compute on `laundry.order.locked` re-evaluates against `now()` on every read, so once the window passes the order auto-relocks. No manual re-lock action is needed; nothing can drift. Audit trail: - reason is logged as a chatter note via mail.message - manager_unlocked_by stores the actor - manager_unlock_reason stores the rationale on the order itself """ _name = 'laundry.order.unlock.wizard' _description = 'Unlock Laundry Order for Editing' DEFAULT_DURATION = 30 # minutes MAX_DURATION = 240 # minutes (4 hours) order_id = fields.Many2one( 'laundry.order', string='Order', required=True, readonly=True, ondelete='cascade', ) order_name = fields.Char( related='order_id.name', readonly=True, ) order_source_type = fields.Selection( related='order_id.source_type', readonly=True, ) order_state = fields.Selection( related='order_id.state', readonly=True, ) duration_minutes = fields.Integer( string='Unlock For (minutes)', default=DEFAULT_DURATION, required=True, help='How long the order will accept edits before re-locking ' 'automatically. Capped at %s minutes.' % MAX_DURATION, ) reason = fields.Char( string='Reason', required=True, help='Logged in the chatter for audit. Required.', ) @api.constrains('duration_minutes') def _check_duration(self): for wiz in self: if wiz.duration_minutes < 1: raise UserError(_('Unlock duration must be at least 1 minute.')) if wiz.duration_minutes > self.MAX_DURATION: raise UserError(_( 'Unlock duration is capped at %s minutes.', self.MAX_DURATION, )) def action_unlock(self): self.ensure_one() if not self.env.user.has_group( 'laundry_management.group_laundry_manager_override' ): raise AccessError(_( 'Only users with the "Laundry / Manager Override" ' 'privilege can unlock locked orders.' )) if not self.reason or not self.reason.strip(): raise UserError(_('A reason is required to unlock an order.')) unlock_until = ( fields.Datetime.now() + timedelta(minutes=self.duration_minutes) ) # The wizard writes only manager_unlocked_* fields, which are NOT # in LOCKED_HEADER_FIELDS — the lock guard is a no-op for this # write, so we don't need a context bypass. self.order_id.sudo().write({ 'manager_unlocked_until': unlock_until, 'manager_unlocked_by': self.env.user.id, 'manager_unlock_reason': self.reason.strip(), }) self.order_id.message_post( body=_( 'Order unlocked for %(minutes)s minutes by ' '%(user)s. Re-locks automatically at ' '%(until)s.
Reason: %(reason)s', minutes=self.duration_minutes, user=self.env.user.name, until=fields.Datetime.to_string(unlock_until), reason=self.reason.strip(), ), subtype_xmlid='mail.mt_note', ) return { 'type': 'ir.actions.act_window', 'res_model': 'laundry.order', 'res_id': self.order_id.id, 'view_mode': 'form', 'target': 'current', } def action_relock_now(self): """Optional: managers can also re-lock immediately (clears the unlock window early). Same access control as unlock.""" self.ensure_one() if not self.env.user.has_group( 'laundry_management.group_laundry_manager_override' ): raise AccessError(_('Only override managers can re-lock.')) self.order_id.sudo().write({ 'manager_unlocked_until': False, }) self.order_id.message_post( body=_( 'Order re-locked early by %(user)s.', user=self.env.user.name, ), subtype_xmlid='mail.mt_note', ) return {'type': 'ir.actions.act_window_close'}