from odoo import _, api, fields, models, Command from odoo.osv import expression from odoo.tools import create_index from odoo.tools.misc import format_datetime from odoo.exceptions import UserError, ValidationError from odoo.addons.account.models.company import SOFT_LOCK_DATE_FIELDS from datetime import date class AccountLockException(models.Model): _name = "account.lock_exception" _description = "Account Lock Exception" active = fields.Boolean( string='Active', default=True, ) state = fields.Selection( selection=[ ('active', 'Active'), ('revoked', 'Revoked'), ('expired', 'Expired'), ], string="State", compute='_compute_state', search='_search_state' ) company_id = fields.Many2one( 'res.company', string='Company', required=True, readonly=True, default=lambda self: self.env.company, ) # An exception w/o user_id is an exception for everyone user_id = fields.Many2one( 'res.users', string='User', default=lambda self: self.env.user, ) reason = fields.Char( string='Reason', ) # An exception without `end_datetime` is valid forever end_datetime = fields.Datetime( string='End Date', ) # The changed lock date lock_date_field = fields.Selection( selection=[ ('fiscalyear_lock_date', 'Global Lock Date'), ('tax_lock_date', 'Tax Return Lock Date'), ('sale_lock_date', 'Sales Lock Date'), ('purchase_lock_date', 'Purchase Lock Date'), ], string="Lock Date Field", required=True, help="Technical field identifying the changed lock date", ) lock_date = fields.Date( string="Changed Lock Date", help="Technical field giving the date the lock date was changed to.", ) company_lock_date = fields.Date( string="Original Lock Date", copy=False, help="Technical field giving the date the company lock date at the time the exception was created.", ) # (Non-stored) computed lock date fields; c.f. res.company fiscalyear_lock_date = fields.Date( string="Global Lock Date", compute="_compute_lock_dates", search="_search_fiscalyear_lock_date", help="The date the Global Lock Date is set to by this exception. If the lock date is not changed it is set to the maximal date.", ) tax_lock_date = fields.Date( string="Tax Return Lock Date", compute="_compute_lock_dates", search="_search_tax_lock_date", help="The date the Tax Lock Date is set to by this exception. If the lock date is not changed it is set to the maximal date.", ) sale_lock_date = fields.Date( string='Sales Lock Date', compute="_compute_lock_dates", search="_search_sale_lock_date", help="The date the Sale Lock Date is set to by this exception. If the lock date is not changed it is set to the maximal date.", ) purchase_lock_date = fields.Date( string='Purchase Lock Date', compute="_compute_lock_dates", search="_search_purchase_lock_date", help="The date the Purchase Lock Date is set to by this exception. If the lock date is not changed it is set to the maximal date.", ) def init(self): super().init() create_index( self.env.cr, indexname='account_lock_exception_company_id_end_datetime_idx', tablename=self._table, expressions=['company_id', 'user_id', 'end_datetime'], where="active = TRUE" ) def _compute_display_name(self): for record in self: record.display_name = _("Lock Date Exception %s", record.id) @api.depends('active', 'end_datetime') def _compute_state(self): for record in self: if not record.active: record.state = 'revoked' elif record.end_datetime and record.end_datetime < self.env.cr.now(): record.state = 'expired' else: record.state = 'active' @api.depends('lock_date_field', 'lock_date') def _compute_lock_dates(self): for exception in self: for field in SOFT_LOCK_DATE_FIELDS: if field == exception.lock_date_field: exception[field] = exception.lock_date else: exception[field] = date.max def _search_state(self, operator, value): if operator not in ['=', '!='] or value not in ['revoked', 'expired', 'active']: raise UserError(_('Operation not supported')) normal_domain_for_equals = [] if value == 'revoked': normal_domain_for_equals = [ ('active', '=', False), ] elif value == 'expired': normal_domain_for_equals = [ '&', ('active', '=', True), ('end_datetime', '<', self.env.cr.now()), ] elif value == 'active': normal_domain_for_equals = [ '&', ('active', '=', True), '|', ('end_datetime', '=', None), ('end_datetime', '>=', self.env.cr.now()), ] if operator == '=': return normal_domain_for_equals else: return ['!'] + normal_domain_for_equals def _search_lock_date(self, field, operator, value): if operator not in ['<', '<='] or not value: raise UserError(_('Operation not supported')) return ['&', ('lock_date_field', '=', field), '|', ('lock_date', '=', False), ('lock_date', operator, value), ] def _search_fiscalyear_lock_date(self, operator, value): return self._search_lock_date('fiscalyear_lock_date', operator, value) def _search_tax_lock_date(self, operator, value): return self._search_lock_date('tax_lock_date', operator, value) def _search_sale_lock_date(self, operator, value): return self._search_lock_date('sale_lock_date', operator, value) def _search_purchase_lock_date(self, operator, value): return self._search_lock_date('purchase_lock_date', operator, value) def _invalidate_affected_user_lock_dates(self): affected_lock_date_fields = {exception.lock_date_field for exception in self} self.env['res.company'].invalidate_model( fnames=[f'user_{field}' for field in list(affected_lock_date_fields)], ) @api.model_create_multi def create(self, vals_list): # Preprocess arguments: # 1. Parse lock date arguments # E.g. to create an exception for 'fiscalyear_lock_date' to '2024-01-01' put # {'fiscalyear_lock_date': '2024-01-01'} in the create vals. # The same thing works for all other fields in SOFT_LOCK_DATE_FIELDS. # 2. Fetch company lock date for vals in vals_list: if 'lock_date' not in vals or 'lock_date_field' not in vals: # Use vals[field] (for field in SOFT_LOCK_DATE_FIELDS) to init the data changed_fields = [field for field in SOFT_LOCK_DATE_FIELDS if field in vals] if len(changed_fields) != 1: raise ValidationError(_("A single exception must change exactly one lock date field.")) field = changed_fields[0] vals['lock_date_field'] = field vals['lock_date'] = vals.pop(field) company = self.env['res.company'].browse(vals.get('company_id', self.env.company.id)) if 'company_lock_date' not in vals: vals['company_lock_date'] = company[vals['lock_date_field']] exceptions = super().create(vals_list) # Log the creation of the exception and the changed field on the company chatter for exception in exceptions: company = exception.company_id # Create tracking values to display the lock date change in the chatter field = exception.lock_date_field value = exception.lock_date field_info = exception.fields_get([field])[field] tracking_values = self.env['mail.tracking.value']._create_tracking_values( company[field], value, field, field_info, exception, ) tracking_value_ids = [Command.create(tracking_values)] # In case there is no explicit end datetime "forever" is implied by not mentioning an end datetime end_datetime_string = _(" valid until %s", format_datetime(self.env, exception.end_datetime)) if exception.end_datetime else "" reason_string = _(" for '%s'", exception.reason) if exception.reason else "" company_chatter_message = _( "%(exception)s for %(user)s%(end_datetime_string)s%(reason)s.", exception=exception._get_html_link(title=_("Exception")), user=exception.user_id.display_name if exception.user_id else _("everyone"), end_datetime_string=end_datetime_string, reason=reason_string, ) company.sudo().message_post( body=company_chatter_message, tracking_value_ids=tracking_value_ids, ) exceptions._invalidate_affected_user_lock_dates() return exceptions def copy(self, default=None): raise UserError(_('You cannot duplicate a Lock Date Exception.')) def _recreate(self): """ 1. Copy all exceptions in self but update the company lock date. 2. Revoke all exceptions in self. 3. Return the new records from step 1. """ if not self: return self.env['account.lock_exception'] vals_list = self.with_context(active_test=False).copy_data() new_records = self.create(vals_list) self.sudo().action_revoke() return new_records def action_revoke(self): """Revokes an active exception.""" if not self.env.user.has_group('account.group_account_manager'): raise UserError(_("You cannot revoke Lock Date Exceptions. Ask someone with the 'Adviser' role.")) for record in self: if record.state == 'active': record_sudo = record.sudo() record_sudo.active = False record_sudo.end_datetime = fields.Datetime.now() record._invalidate_affected_user_lock_dates() @api.model def _get_active_exceptions_domain(self, company, soft_lock_date_fields): return [ *expression.OR([(field, '<', company[field])] for field in soft_lock_date_fields if company[field]), ('company_id', '=', company.id), ('state', '=', 'active'), # checks the datetime ] def _get_audit_trail_during_exception_domain(self): self.ensure_one() common_message_domain = [ ('date', '>=', self.create_date), ] if self.user_id: common_message_domain.append(('create_uid', '=', self.user_id.id)) if self.end_datetime: common_message_domain.append(('date', '<=', self.end_datetime)) # Add restrictions on the accounting date to avoid unnecessary entries min_date = self.lock_date max_date = self.company_lock_date move_date_domain = [] tracking_old_datetime_domain = [] tracking_new_datetime_domain = [] if min_date: move_date_domain.append([('date', '>=', min_date)]) tracking_old_datetime_domain.append([('tracking_value_ids.old_value_datetime', '>=', min_date)]) tracking_new_datetime_domain.append([('tracking_value_ids.new_value_datetime', '>=', min_date)]) if max_date: move_date_domain.append([('date', '<=', max_date)]) tracking_old_datetime_domain.append([('tracking_value_ids.old_value_datetime', '<=', max_date)]) tracking_new_datetime_domain.append([('tracking_value_ids.new_value_datetime', '<=', max_date)]) return [ ('company_id', 'child_of', self.company_id.id), ('audit_trail_message_ids', 'any', common_message_domain), '|', # The date was changed from or to a value inside the excepted period ('audit_trail_message_ids', 'any', [ ('tracking_value_ids.field_id', '=', self.env['ir.model.fields']._get('account.move', 'date').id), '|', *expression.AND(tracking_old_datetime_domain), *expression.AND(tracking_new_datetime_domain), ]), # The date of the move is inside the excepted period and sth. was changed on the move *expression.AND(move_date_domain), ] def action_show_audit_trail_during_exception(self): self.ensure_one() return { 'name': _("Journal Items"), 'type': 'ir.actions.act_window', 'res_model': 'account.move.line', 'view_mode': 'list,form', 'domain': [('move_id', 'any', self._get_audit_trail_during_exception_domain())], }