diff --git a/contract/__manifest__.py b/contract/__manifest__.py index 68f343c25..4752f3c2b 100644 --- a/contract/__manifest__.py +++ b/contract/__manifest__.py @@ -20,9 +20,11 @@ 'depends': ['base', 'account', 'product'], "external_dependencies": {"python": ["dateutil"]}, 'data': [ + 'security/groups.xml', 'security/contract_tag.xml', 'security/ir.model.access.csv', 'security/contract_security.xml', + 'security/contract_terminate_reason.xml', 'report/report_contract.xml', 'report/contract_views.xml', 'data/contract_cron.xml', @@ -30,6 +32,7 @@ 'data/mail_template.xml', 'wizards/contract_line_wizard.xml', 'wizards/contract_manually_create_invoice.xml', + 'wizards/contract_contract_terminate.xml', 'views/abstract_contract_line.xml', 'views/contract.xml', 'views/contract_line.xml', @@ -37,6 +40,7 @@ 'views/contract_template_line.xml', 'views/res_partner_view.xml', 'views/res_config_settings.xml', + 'views/contract_terminate_reason.xml', ], 'installable': True, } diff --git a/contract/models/__init__.py b/contract/models/__init__.py index 6d46b4b61..e1b4b30cb 100644 --- a/contract/models/__init__.py +++ b/contract/models/__init__.py @@ -12,3 +12,4 @@ from . import res_partner from . import contract_tag from . import res_company from . import res_config_settings +from . import contract_terminate_reason diff --git a/contract/models/contract.py b/contract/models/contract.py index bed38d0bb..e6f55cd8b 100644 --- a/contract/models/contract.py +++ b/contract/models/contract.py @@ -7,7 +7,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import api, fields, models -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, UserError from odoo.tools.translate import _ @@ -93,6 +93,29 @@ class ContractContract(models.Model): ) tag_ids = fields.Many2many(comodel_name="contract.tag", string="Tags") note = fields.Text(string="Notes") + is_terminated = fields.Boolean( + string="Terminated", readonly=True, copy=False + ) + terminate_reason_id = fields.Many2one( + comodel_name="contract.terminate.reason", + string="Termination Reason", + ondelete="restrict", + readonly=True, + copy=False, + track_visibility="onchange", + ) + terminate_comment = fields.Text( + string="Termination Comment", + readonly=True, + copy=False, + track_visibility="onchange", + ) + terminate_date = fields.Date( + string="Termination Date", + readonly=True, + copy=False, + track_visibility="onchange", + ) @api.multi def _inverse_partner_id(self): @@ -458,3 +481,43 @@ class ContractContract(models.Model): domain = self._get_contracts_to_invoice_domain(date_ref) contracts_to_invoice = self.search(domain) return contracts_to_invoice._recurring_create_invoice(date_ref) + + @api.multi + def action_terminate_contract(self): + self.ensure_one() + context = {"default_contract_id": self.id} + return { + 'type': 'ir.actions.act_window', + 'name': _('Terminate Contract'), + 'res_model': 'contract.contract.terminate', + 'view_type': 'form', + 'view_mode': 'form', + 'target': 'new', + 'context': context, + } + + @api.multi + def _terminate_contract( + self, terminate_reason_id, terminate_comment, terminate_date + ): + self.ensure_one() + if not self.env.user.has_group("contract.can_terminate_contract"): + raise UserError(_('You are not allowed to terminate contracts.')) + self.contract_line_ids.filtered('is_stop_allowed').stop(terminate_date) + self.write({ + 'is_terminated': True, + 'terminate_reason_id': terminate_reason_id.id, + 'terminate_comment': terminate_comment, + 'terminate_date': terminate_date, + }) + return True + + @api.multi + def action_cancel_contract_termination(self): + self.ensure_one() + self.write({ + 'is_terminated': False, + 'terminate_reason_id': False, + 'terminate_comment': False, + 'terminate_date': False, + }) diff --git a/contract/models/contract_line.py b/contract/models/contract_line.py index 81c09e05e..0c4de8667 100644 --- a/contract/models/contract_line.py +++ b/contract/models/contract_line.py @@ -296,9 +296,19 @@ class ContractLine(models.Model): 'successor_contract_line_id', 'predecessor_contract_line_id', 'is_canceled', + 'contract_id.is_terminated', ) def _compute_allowed(self): for rec in self: + if rec.contract_id.is_terminated: + rec.update({ + 'is_plan_successor_allowed': False, + 'is_stop_plan_successor_allowed': False, + 'is_stop_allowed': False, + 'is_cancel_allowed': False, + 'is_un_cancel_allowed': False, + }) + continue if rec.date_start: allowed = get_allowed( rec.date_start, @@ -310,13 +320,14 @@ class ContractLine(models.Model): rec.is_canceled, ) if allowed: - rec.is_plan_successor_allowed = allowed.plan_successor - rec.is_stop_plan_successor_allowed = ( - allowed.stop_plan_successor - ) - rec.is_stop_allowed = allowed.stop - rec.is_cancel_allowed = allowed.cancel - rec.is_un_cancel_allowed = allowed.uncancel + rec.update({ + 'is_plan_successor_allowed': allowed.plan_successor, + 'is_stop_plan_successor_allowed': + allowed.stop_plan_successor, + 'is_stop_allowed': allowed.stop, + 'is_cancel_allowed': allowed.cancel, + 'is_un_cancel_allowed': allowed.uncancel, + }) @api.constrains('is_auto_renew', 'successor_contract_line_id', 'date_end') def _check_allowed(self): @@ -1163,7 +1174,7 @@ class ContractLine(models.Model): ).id return { 'type': 'ir.actions.act_window', - 'name': 'Resiliate contract line', + 'name': 'Terminate contract line', 'res_model': 'contract.line.wizard', 'view_type': 'form', 'view_mode': 'form', @@ -1251,6 +1262,7 @@ class ContractLine(models.Model): @api.model def _contract_line_to_renew_domain(self): return [ + ('contract_id.is_terminated', '=', False), ('is_auto_renew', '=', True), ('is_canceled', '=', False), ('termination_notice_date', '<=', fields.Date.context_today(self)), diff --git a/contract/models/contract_terminate_reason.py b/contract/models/contract_terminate_reason.py new file mode 100644 index 000000000..21f2c0c1c --- /dev/null +++ b/contract/models/contract_terminate_reason.py @@ -0,0 +1,15 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ContractTerminateReason(models.Model): + + _name = 'contract.terminate.reason' + _description = 'Contract Termination Reason' + + name = fields.Char(required=True) + terminate_comment_required = fields.Boolean( + string="Require a termination comment", default=True + ) diff --git a/contract/security/contract_terminate_reason.xml b/contract/security/contract_terminate_reason.xml new file mode 100644 index 000000000..b34cd9089 --- /dev/null +++ b/contract/security/contract_terminate_reason.xml @@ -0,0 +1,27 @@ + + + + + + + contract.terminate.reason access manager + + + + + + + + + + contract.terminate.reason access user + + + + + + + + + diff --git a/contract/security/groups.xml b/contract/security/groups.xml new file mode 100644 index 000000000..a954b2a21 --- /dev/null +++ b/contract/security/groups.xml @@ -0,0 +1,12 @@ + + + + + + + Contract: Can Terminate Contracts + + + + diff --git a/contract/tests/test_contract.py b/contract/tests/test_contract.py index 9f613de8c..53daf7180 100644 --- a/contract/tests/test_contract.py +++ b/contract/tests/test_contract.py @@ -6,7 +6,7 @@ from collections import namedtuple from datetime import timedelta from dateutil.relativedelta import relativedelta from odoo import fields -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, UserError from odoo.tests import common @@ -110,6 +110,9 @@ class TestContractBase(common.SavepointCase): ) cls.acct_line.product_id.is_auto_renew = True cls.contract.company_id.create_new_line_at_contract_line_renew = True + cls.terminate_reason = cls.env['contract.terminate.reason'].create({ + 'name': 'terminate_reason' + }) class TestContract(TestContractBase): @@ -2364,3 +2367,51 @@ class TestContract(TestContractBase): self.assertEqual( self.acct_line.recurring_next_date, to_date('2019-06-01') ) + + def test_action_terminate_contract(self): + action = self.contract.action_terminate_contract() + wizard = ( + self.env[action['res_model']] + .with_context(action['context']) + .create( + { + 'terminate_date': '2018-03-01', + 'terminate_reason_id': self.terminate_reason.id, + 'terminate_comment': 'terminate_comment', + } + ) + ) + self.assertEqual(wizard.contract_id, self.contract) + with self.assertRaises(UserError): + wizard.terminate_contract() + group_can_terminate_contract = self.env.ref( + "contract.can_terminate_contract" + ) + group_can_terminate_contract.users |= self.env.user + wizard.terminate_contract() + self.assertTrue(self.contract.is_terminated) + self.assertEqual(self.contract.terminate_date, to_date('2018-03-01')) + self.assertEqual( + self.contract.terminate_reason_id.id, self.terminate_reason.id + ) + self.assertEqual(self.contract.terminate_comment, 'terminate_comment') + self.contract.action_cancel_contract_termination() + self.assertFalse(self.contract.is_terminated) + self.assertFalse(self.contract.terminate_reason_id) + self.assertFalse(self.contract.terminate_comment) + + def test_terminate_date_before_last_date_invoiced(self): + self.contract.recurring_create_invoice() + self.assertEqual( + self.acct_line.last_date_invoiced, to_date('2018-02-14') + ) + group_can_terminate_contract = self.env.ref( + "contract.can_terminate_contract" + ) + group_can_terminate_contract.users |= self.env.user + with self.assertRaises(ValidationError): + self.contract._terminate_contract( + self.terminate_reason, + 'terminate_comment', + to_date('2018-02-13'), + ) diff --git a/contract/views/contract.xml b/contract/views/contract.xml index 33492798e..cabb8f0a6 100644 --- a/contract/views/contract.xml +++ b/contract/views/contract.xml @@ -7,16 +7,38 @@ contract.contract + + + This contract was terminated for the reason on . + + + + + @@ -38,41 +60,43 @@ class="oe_edit_only"/> - - - + + + - + - + - + - @@ -80,15 +104,18 @@ - - + + + + + + + + contract.terminate.reason + + + + + + + + + + + + + + + contract.terminate.reason + + + + + + + + + + Contract Termination Reason + contract.terminate.reason + tree,form + + + + Contract Termination Reason + + + + + + diff --git a/contract/wizards/__init__.py b/contract/wizards/__init__.py index 1fa21bcc9..32e54545d 100644 --- a/contract/wizards/__init__.py +++ b/contract/wizards/__init__.py @@ -1,2 +1,3 @@ from . import contract_line_wizard from . import contract_manually_create_invoice +from . import contract_contract_terminate diff --git a/contract/wizards/contract_contract_terminate.py b/contract/wizards/contract_contract_terminate.py new file mode 100644 index 000000000..45c4d457b --- /dev/null +++ b/contract/wizards/contract_contract_terminate.py @@ -0,0 +1,38 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ContractContractTerminate(models.TransientModel): + + _name = 'contract.contract.terminate' + _description = "Terminate Contract Wizard" + + contract_id = fields.Many2one( + comodel_name="contract.contract", + string="Contract", + required=True, + ondelete="cascade", + ) + terminate_reason_id = fields.Many2one( + comodel_name="contract.terminate.reason", + string="Termination Reason", + required=True, + ondelete="cascade", + ) + terminate_comment = fields.Text(string="Termination Comment") + terminate_date = fields.Date(string="Termination Date", required=True) + terminate_comment_required = fields.Boolean( + related="terminate_reason_id.terminate_comment_required" + ) + + @api.multi + def terminate_contract(self): + for wizard in self: + wizard.contract_id._terminate_contract( + wizard.terminate_reason_id, + wizard.terminate_comment, + wizard.terminate_date, + ) + return True diff --git a/contract/wizards/contract_contract_terminate.xml b/contract/wizards/contract_contract_terminate.xml new file mode 100644 index 000000000..7b478c2bc --- /dev/null +++ b/contract/wizards/contract_contract_terminate.xml @@ -0,0 +1,34 @@ + + + + + + + contract.contract.terminate + + + + + + + + + + + + + + + + + diff --git a/product_contract/models/sale_order.py b/product_contract/models/sale_order.py index c5e9d416e..c34a118eb 100644 --- a/product_contract/models/sale_order.py +++ b/product_contract/models/sale_order.py @@ -17,6 +17,18 @@ class SaleOrder(models.Model): compute='_compute_need_contract_creation' ) + @api.constrains('state') + def check_contact_is_not_terminated(self): + for rec in self: + if rec.state not in ( + 'sale', + 'done', + 'cancel', + ) and rec.order_line.filtered('contract_id.is_terminated'): + raise ValidationError( + _("You can't upsell or downsell a terminated contract") + ) + @api.depends('order_line.contract_id', 'state') def _compute_need_contract_creation(self): for rec in self: diff --git a/product_contract/models/sale_order_line.py b/product_contract/models/sale_order_line.py index 26a30ed38..a422d087e 100644 --- a/product_contract/models/sale_order_line.py +++ b/product_contract/models/sale_order_line.py @@ -50,6 +50,17 @@ class SaleOrderLine(models.Model): copy=False, ) + @api.constrains('contract_id') + def check_contact_is_not_terminated(self): + for rec in self: + if ( + rec.order_id.state not in ('sale', 'done', 'cancel') + and rec.contract_id.is_terminated + ): + raise ValidationError( + _("You can't upsell or downsell a terminated contract") + ) + @api.multi @api.depends('product_id') def _compute_contract_template_id(self): diff --git a/product_contract/tests/test_sale_order.py b/product_contract/tests/test_sale_order.py index 0a938258b..623eb52df 100644 --- a/product_contract/tests/test_sale_order.py +++ b/product_contract/tests/test_sale_order.py @@ -342,3 +342,18 @@ class TestSaleOrder(TransactionCase): self.env['contract.contract'].search(action['domain']), self.sale.order_line.mapped('contract_id'), ) + + def test_check_contact_is_not_terminated(self): + self.contract.is_terminated = True + with self.assertRaises(ValidationError): + self.order_line1.contract_id = self.contract + + def test_check_contact_is_not_terminated(self): + self.order_line1.contract_id = self.contract + self.sale.action_confirm() + self.contract.is_terminated = True + self.sale.action_cancel() + with self.assertRaises(ValidationError): + self.sale.action_draft() + self.contract.is_terminated = False + self.sale.action_draft() diff --git a/product_contract/views/sale_order.xml b/product_contract/views/sale_order.xml index 4fd1b90b9..23147e660 100644 --- a/product_contract/views/sale_order.xml +++ b/product_contract/views/sale_order.xml @@ -41,6 +41,7 @@ domain="['|',('contract_template_id','=',contract_template_id), ('contract_template_id','=',False), ('partner_id','=',parent.partner_id), + ('is_terminated','=',False), ]"/>
This contract was terminated for the reason on .