From 089d4232fbd532042d522a22f0732e835710a5da Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Tue, 6 Aug 2019 18:19:01 +0200 Subject: [PATCH 1/3] [IMP] shopinvader: Allows to download paid invoices for confirmed sale orders --- shopinvader/controllers/main.py | 9 +- shopinvader/services/__init__.py | 1 + shopinvader/services/abstract_mail.py | 14 --- shopinvader/services/invoice.py | 136 ++++++++++++++++++++++++++ shopinvader/services/sale.py | 18 ++++ shopinvader/services/service.py | 14 +++ shopinvader/tests/__init__.py | 4 +- shopinvader/tests/test_invoice.py | 83 ++++++++++++++++ shopinvader/tests/test_sale.py | 47 +++++++++ 9 files changed, 310 insertions(+), 16 deletions(-) create mode 100644 shopinvader/services/invoice.py create mode 100644 shopinvader/tests/test_invoice.py diff --git a/shopinvader/controllers/main.py b/shopinvader/controllers/main.py index 69f331a608..52bd27b1ea 100644 --- a/shopinvader/controllers/main.py +++ b/shopinvader/controllers/main.py @@ -7,7 +7,7 @@ from odoo.addons.base_rest.controllers import main from odoo.exceptions import MissingError -from odoo.http import request +from odoo.http import request, route _logger = logging.getLogger(__name__) @@ -18,6 +18,13 @@ class InvaderController(main.RestController): _collection_name = "shopinvader.backend" _default_auth = "api_key" + @route(["/shopinvader/invoice//download"], methods=["GET"]) + def invoice_download(self, _id=None, **params): + params["id"] = _id + return self._process_method( + "invoice", "download", _id=_id, params=params + ) + @classmethod def _get_partner_from_headers(cls, headers): partner_model = request.env["shopinvader.partner"] diff --git a/shopinvader/services/__init__.py b/shopinvader/services/__init__.py index 9b6d527da3..e24e270411 100644 --- a/shopinvader/services/__init__.py +++ b/shopinvader/services/__init__.py @@ -10,3 +10,4 @@ from . import sale from . import address from . import customer +from . import invoice diff --git a/shopinvader/services/abstract_mail.py b/shopinvader/services/abstract_mail.py index 3de86d6606..8a0f01bef7 100644 --- a/shopinvader/services/abstract_mail.py +++ b/shopinvader/services/abstract_mail.py @@ -20,20 +20,6 @@ def ask_email(self, _id): def _validator_ask_email(self): return {} - def _is_logged(self): - """ - Check if the current partner is a real partner (not the anonymous one - and not empty) - :return: bool - """ - logged = False - if ( - self.partner - and self.partner != self.shopinvader_backend.anonymous_partner_id - ): - logged = True - return logged - def _get_email_notification_type(self, record): """ Based on the given record, get the notification type. diff --git a/shopinvader/services/invoice.py b/shopinvader/services/invoice.py new file mode 100644 index 0000000000..4e5dfdb955 --- /dev/null +++ b/shopinvader/services/invoice.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import mimetypes + +from odoo import _ +from odoo.addons.base_rest.components.service import ( + skip_secure_response, + to_int, +) +from odoo.addons.component.core import Component +from odoo.exceptions import MissingError +from odoo.http import content_disposition, request +from odoo.osv import expression + + +class InvoiceService(Component): + _inherit = "base.shopinvader.service" + _name = "shopinvader.invoice.service" + _usage = "invoice" + _expose_model = "account.invoice" + _description = "Service providing a method to download invoices" + + # The following method are 'public' and can be called from the controller. + # All params are untrusted so please check it ! + + @skip_secure_response + def download(self, _id, **params): + """ + Get invoice file. This method is also callable by HTTP GET + """ + invoice = self._get(_id) + headers, content = self._get_binary_content(invoice) + if not content: + raise MissingError(_("No image found for partner %s") % _id) + response = request.make_response(content, headers) + response.status_code = 200 + return response + + def to_openapi(self): + res = super(InvoiceService, self).to_openapi() + # Manually add route for HTTP GET download + response = self._get_openapi_default_responses() + response["200"] = {"description": "The file to download"} + parameters = self._get_openapi_default_parameters() + parameters.append( + { + "schema": {"type": "integer"}, + "description": "Item id", + "required": True, + "name": "id", + "in": "path", + } + ) + res["paths"]["/{id}/download"] = { + "get": { + "responses": response, + "parameters": parameters, + "summary": "Get the invoice file", + } + } + return res + + # Validator + + def _validator_download(self): + return {"id": {"type": "integer", "required": True, "coerce": to_int}} + + # Private implementation + + def _get_base_search_domain(self): + """ + This method must provide a domain used to retrieve the requested + invoice. + + This domain MUST TAKE CARE of restricting the access to the invoices + visible for the current customer + :return: Odoo domain + """ + # The partner must be set and not be the anonymous one + if not self._is_logged(): + return expression.FALSE_DOMAIN + + # here we only allow access to invoices linked to a sale order of the + # current customer + so_domain = [ + ("partner_id", "=", self.partner.id), + ("shopinvader_backend_id", "=", self.shopinvader_backend.id), + ("typology", "=", "sale"), + ] + # invoice_ids on sale.order is a computed field... + # to avoid to duplicate the logic, we search for the sale oders + # and check if the invoice_id is into the list of sale.invoice_ids + sales = self.env["sale.order"].search(so_domain) + invoice_ids = sales.mapped("invoice_ids").ids + return expression.normalize_domain( + [("id", "in", invoice_ids), ("state", "=", "paid")] + ) + + def _get_binary_content(self, invoice): + """ + Generate the invoice report.... + :param invoice: + :returns: (headers, content) + """ + # ensure the report is generated + invoice_report_def = invoice.invoice_print() + report_name = invoice_report_def["report_name"] + report_type = invoice_report_def["report_type"] + content, format = self.env["ir.actions.report.xml"].render_report( + res_ids=invoice.ids, + name=report_name, + data={"report_type": report_type}, + ) + report = self._get_report(report_name, report_type) + filename = self._get_binary_content_filename(invoice, report, format) + mimetype = mimetypes.guess_type(filename) + if mimetype: + mimetype = mimetype[0] + headers = [ + ("Content-Type", mimetype), + ("X-Content-Type-Options", "nosniff"), + ("Content-Disposition", content_disposition(filename)), + ("Content-Length", len(content)), + ] + return headers, content + + def _get_report(self, report_name, report_type): + domain = [ + ("report_type", "=", report_type), + ("report_name", "=", report_name), + ] + return self.env["ir.actions.report.xml"].search(domain) + + def _get_binary_content_filename(self, invoice, report, format): + return "{}.{}".format(report.name, format) diff --git a/shopinvader/services/sale.py b/shopinvader/services/sale.py index 7b074c9299..efdded3770 100644 --- a/shopinvader/services/sale.py +++ b/shopinvader/services/sale.py @@ -86,3 +86,21 @@ def _launch_notification(self, target, notif_type): return super(SaleService, self)._launch_notification( target, notif_type ) + + def _convert_one_sale(self, sale): + res = super(SaleService, self)._convert_one_sale(sale) + res["invoices"] = self._convert_invoices(sale.sudo()) + return res + + def _convert_invoices(self, sale): + res = [] + for invoice in sale.invoice_ids.filtered(lambda i: i.state == "paid"): + res.append(self._convert_one_invoice(invoice)) + return res + + def _convert_one_invoice(self, invoice): + return { + "id": invoice.id, + "name": invoice.number, + "date": invoice.date_invoice or None, + } diff --git a/shopinvader/services/service.py b/shopinvader/services/service.py index 82aa36dda1..c95692a634 100644 --- a/shopinvader/services/service.py +++ b/shopinvader/services/service.py @@ -124,6 +124,20 @@ def _get_openapi_default_parameters(self): ) return defaults + def _is_logged(self): + """ + Check if the current partner is a real partner (not the anonymous one + and not empty) + :return: bool + """ + logged = False + if ( + self.partner + and self.partner != self.shopinvader_backend.anonymous_partner_id + ): + logged = True + return logged + @property def shopinvader_response(self): """ diff --git a/shopinvader/tests/__init__.py b/shopinvader/tests/__init__.py index c35517e151..47d71fd1e1 100644 --- a/shopinvader/tests/__init__.py +++ b/shopinvader/tests/__init__.py @@ -11,6 +11,8 @@ from . import test_shopinvader_category_binding_wizard from . import test_customer from . import test_shopinvader_partner -from . import test_res_partner from . import test_shopinvader_variant_seo_title from . import test_shopinvader_partner_binding +from . import test_res_partner +from . import test_invoice + diff --git a/shopinvader/tests/test_invoice.py b/shopinvader/tests/test_invoice.py new file mode 100644 index 0000000000..041156679e --- /dev/null +++ b/shopinvader/tests/test_invoice.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import mock +from odoo.exceptions import MissingError + +from .common import CommonCase + + +class TestInvoice(CommonCase): + def setUp(self, *args, **kwargs): + super(TestInvoice, self).setUp(*args, **kwargs) + self.sale = self.env.ref("shopinvader.sale_order_2") + self.partner = self.env.ref("shopinvader.partner_1") + with self.work_on_services(partner=self.partner) as work: + self.sale_service = work.component(usage="sales") + self.invoice_service = work.component(usage="invoice") + self.invoice = self._confirm_and_invoice_sale(self.sale) + + def _confirm_and_invoice_sale(self, sale): + sale.action_confirm() + for line in sale.order_line: + line.write({"qty_delivered": line.product_uom_qty}) + invoice_id = sale.action_invoice_create() + invoice = self.env["account.invoice"].browse(invoice_id) + invoice.action_move_create() + return invoice + + def test_01(self): + """ + Data + * A confirmed sale order with an invoice not yet paid + Case: + * Try to download the image + Expected result: + * MissingError should be raised + """ + with self.assertRaises(MissingError): + self.invoice_service.download(self.invoice.id) + + def test_02(self): + """ + Data + * A confirmed sale order with a paid invoice + Case: + * Try to download the image + Expected result: + * An http response with the file to download + """ + self.invoice.confirm_paid() + with mock.patch( + "openerp.addons.shopinvader.services.invoice.content_disposition" + ) as mocked_cd, mock.patch( + "openerp.addons.shopinvader.services.invoice.request" + ) as mocked_request: + mocked_cd.return_value = "attachment; filename=test" + make_response = mock.MagicMock() + mocked_request.make_response = make_response + self.invoice_service.download(self.invoice.id) + self.assertEqual(1, make_response.call_count) + content, headers = make_response.call_args[0] + self.assertTrue(content) + self.assertIn( + ("Content-Disposition", "attachment; filename=test"), headers + ) + + def test_03(self): + """ + Data + * A confirmed sale order with a paid invoice but not for the + current customer + Case: + * Try to download the image + Expected result: + * MissingError should be raised + """ + sale = self.env.ref("sale.sale_order_1") + sale.shopinvader_backend_id = self.backend + self.assertNotEqual(sale.partner_id, self.partner) + invoice = self._confirm_and_invoice_sale(sale) + invoice.confirm_paid() + with self.assertRaises(MissingError): + self.invoice_service.download(invoice.id) diff --git a/shopinvader/tests/test_sale.py b/shopinvader/tests/test_sale.py index 3bebe77642..da3ec5bd51 100644 --- a/shopinvader/tests/test_sale.py +++ b/shopinvader/tests/test_sale.py @@ -17,6 +17,14 @@ def setUp(self, *args, **kwargs): with self.work_on_services(partner=self.partner) as work: self.service = work.component(usage="sales") + def _confirm_and_invoice_sale(self): + self.sale.action_confirm() + for line in self.sale.order_line: + line.write({"qty_delivered": line.product_uom_qty}) + invoice_id = self.sale.action_invoice_create() + self.invoice = self.env["account.invoice"].browse(invoice_id) + self.invoice.action_move_create() + def test_read_sale(self): self.sale.action_confirm_cart() res = self.service.get(self.sale.id) @@ -75,3 +83,42 @@ def test_ask_email_invoice(self): domain = [("name", "=", description), ("date_created", ">=", now)] self.service.dispatch("ask_email_invoice", _id=self.sale.id) self.assertEquals(self.env["queue.job"].search_count(domain), 1) + + def test_invoice_01(self): + """ + Data + * A confirmed sale order with an invoice not yet paid + Case: + * Load data + Expected result: + * No invoice information returned + """ + self._confirm_and_invoice_sale() + self.assertNotEqual(self.invoice.state, "paid") + res = self.service.get(self.sale.id) + self.assertFalse(res["invoices"]) + + def test_invoice_02(self): + """ + Data + * A confirmed sale order with a paid invoice + Case: + * Load data + Expected result: + * Invoice information must be filled + """ + self._confirm_and_invoice_sale() + self.invoice.confirm_paid() + self.assertEqual(self.invoice.state, "paid") + res = self.service.get(self.sale.id) + self.assertTrue(res) + self.assertEqual( + res["invoices"], + [ + { + "id": self.invoice.id, + "name": self.invoice.number, + "date": self.invoice.date_invoice, + } + ], + ) From 263d4a529e5c3a0532f6847a6719f11943aead8c Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 7 Aug 2019 10:40:28 +0200 Subject: [PATCH 2/3] [FIX] shopinvader: Fir forward port from 9.0 invoice.confirm_paid -> .invoice.action_invoice_paid --- shopinvader/tests/test_invoice.py | 5 +++-- shopinvader/tests/test_sale.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/shopinvader/tests/test_invoice.py b/shopinvader/tests/test_invoice.py index 041156679e..7be37caef7 100644 --- a/shopinvader/tests/test_invoice.py +++ b/shopinvader/tests/test_invoice.py @@ -23,6 +23,7 @@ def _confirm_and_invoice_sale(self, sale): line.write({"qty_delivered": line.product_uom_qty}) invoice_id = sale.action_invoice_create() invoice = self.env["account.invoice"].browse(invoice_id) + invoice.action_invoice_open() invoice.action_move_create() return invoice @@ -47,7 +48,7 @@ def test_02(self): Expected result: * An http response with the file to download """ - self.invoice.confirm_paid() + self.invoice.action_invoice_paid() with mock.patch( "openerp.addons.shopinvader.services.invoice.content_disposition" ) as mocked_cd, mock.patch( @@ -78,6 +79,6 @@ def test_03(self): sale.shopinvader_backend_id = self.backend self.assertNotEqual(sale.partner_id, self.partner) invoice = self._confirm_and_invoice_sale(sale) - invoice.confirm_paid() + invoice.action_invoice_paid() with self.assertRaises(MissingError): self.invoice_service.download(invoice.id) diff --git a/shopinvader/tests/test_sale.py b/shopinvader/tests/test_sale.py index da3ec5bd51..5789af36be 100644 --- a/shopinvader/tests/test_sale.py +++ b/shopinvader/tests/test_sale.py @@ -23,6 +23,7 @@ def _confirm_and_invoice_sale(self): line.write({"qty_delivered": line.product_uom_qty}) invoice_id = self.sale.action_invoice_create() self.invoice = self.env["account.invoice"].browse(invoice_id) + self.invoice.action_invoice_open() self.invoice.action_move_create() def test_read_sale(self): @@ -108,7 +109,7 @@ def test_invoice_02(self): * Invoice information must be filled """ self._confirm_and_invoice_sale() - self.invoice.confirm_paid() + self.invoice.action_invoice_paid() self.assertEqual(self.invoice.state, "paid") res = self.service.get(self.sale.id) self.assertTrue(res) From eb2e48e3fbeb1c26ae8c518588562073ca98b37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Honor=C3=A9?= Date: Tue, 17 Sep 2019 16:36:05 +0200 Subject: [PATCH 3/3] Fix conflict with 10.0 + Fix unit test fail --- shopinvader/tests/__init__.py | 1 - shopinvader/tests/test_invoice.py | 30 ++++++++++++++++++++++++++++-- shopinvader/tests/test_sale.py | 27 ++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/shopinvader/tests/__init__.py b/shopinvader/tests/__init__.py index 47d71fd1e1..1bc82ef0bd 100644 --- a/shopinvader/tests/__init__.py +++ b/shopinvader/tests/__init__.py @@ -15,4 +15,3 @@ from . import test_shopinvader_partner_binding from . import test_res_partner from . import test_invoice - diff --git a/shopinvader/tests/test_invoice.py b/shopinvader/tests/test_invoice.py index 7be37caef7..19b537704b 100644 --- a/shopinvader/tests/test_invoice.py +++ b/shopinvader/tests/test_invoice.py @@ -2,6 +2,7 @@ # Copyright 2019 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import mock +from odoo import fields from odoo.exceptions import MissingError from .common import CommonCase @@ -10,13 +11,38 @@ class TestInvoice(CommonCase): def setUp(self, *args, **kwargs): super(TestInvoice, self).setUp(*args, **kwargs) + self.register_payments_obj = self.env["account.register.payments"] + self.journal_obj = self.env["account.journal"] self.sale = self.env.ref("shopinvader.sale_order_2") self.partner = self.env.ref("shopinvader.partner_1") + self.payment_method_manual_in = self.env.ref( + "account.account_payment_method_manual_in" + ) + self.bank_journal_euro = self.journal_obj.create( + {"name": "Bank", "type": "bank", "code": "BNK627"} + ) with self.work_on_services(partner=self.partner) as work: self.sale_service = work.component(usage="sales") self.invoice_service = work.component(usage="invoice") self.invoice = self._confirm_and_invoice_sale(self.sale) + def _make_payment(self, invoice): + """ + Make the invoice payment + :param invoice: account.invoice recordset + :return: bool + """ + ctx = {"active_model": invoice._name, "active_ids": invoice.ids} + wizard_obj = self.register_payments_obj.with_context(ctx) + register_payments = wizard_obj.create( + { + "payment_date": fields.Date.today(), + "journal_id": self.bank_journal_euro.id, + "payment_method_id": self.payment_method_manual_in.id, + } + ) + register_payments.create_payment() + def _confirm_and_invoice_sale(self, sale): sale.action_confirm() for line in sale.order_line: @@ -48,7 +74,7 @@ def test_02(self): Expected result: * An http response with the file to download """ - self.invoice.action_invoice_paid() + self._make_payment(self.invoice) with mock.patch( "openerp.addons.shopinvader.services.invoice.content_disposition" ) as mocked_cd, mock.patch( @@ -79,6 +105,6 @@ def test_03(self): sale.shopinvader_backend_id = self.backend self.assertNotEqual(sale.partner_id, self.partner) invoice = self._confirm_and_invoice_sale(sale) - invoice.action_invoice_paid() + self._make_payment(invoice) with self.assertRaises(MissingError): self.invoice_service.download(invoice.id) diff --git a/shopinvader/tests/test_sale.py b/shopinvader/tests/test_sale.py index 5789af36be..ea0497bbe3 100644 --- a/shopinvader/tests/test_sale.py +++ b/shopinvader/tests/test_sale.py @@ -14,6 +14,14 @@ def setUp(self, *args, **kwargs): super(SaleCase, self).setUp(*args, **kwargs) self.sale = self.env.ref("shopinvader.sale_order_2") self.partner = self.env.ref("shopinvader.partner_1") + self.register_payments_obj = self.env["account.register.payments"] + self.journal_obj = self.env["account.journal"] + self.payment_method_manual_in = self.env.ref( + "account.account_payment_method_manual_in" + ) + self.bank_journal_euro = self.journal_obj.create( + {"name": "Bank", "type": "bank", "code": "BNK6278"} + ) with self.work_on_services(partner=self.partner) as work: self.service = work.component(usage="sales") @@ -85,6 +93,23 @@ def test_ask_email_invoice(self): self.service.dispatch("ask_email_invoice", _id=self.sale.id) self.assertEquals(self.env["queue.job"].search_count(domain), 1) + def _make_payment(self, invoice): + """ + Make the invoice payment + :param invoice: account.invoice recordset + :return: bool + """ + ctx = {"active_model": invoice._name, "active_ids": invoice.ids} + wizard_obj = self.register_payments_obj.with_context(ctx) + register_payments = wizard_obj.create( + { + "payment_date": fields.Date.today(), + "journal_id": self.bank_journal_euro.id, + "payment_method_id": self.payment_method_manual_in.id, + } + ) + register_payments.create_payment() + def test_invoice_01(self): """ Data @@ -109,7 +134,7 @@ def test_invoice_02(self): * Invoice information must be filled """ self._confirm_and_invoice_sale() - self.invoice.action_invoice_paid() + self._make_payment(self.invoice) self.assertEqual(self.invoice.state, "paid") res = self.service.get(self.sale.id) self.assertTrue(res)