diff --git a/.isort.cfg b/.isort.cfg index 1517121..d0c6c75 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -9,4 +9,4 @@ line_length=79 known_odoo=odoo known_odoo_addons=odoo.addons sections=FUTURE,STDLIB,THIRDPARTY,ODOO,ODOO_ADDONS,FIRSTPARTY,LOCALFOLDER -known_third_party=addons,cStringIO,lxml,openerp,requests,setuptools,werkzeug,xlsxwriter +known_third_party=addons,cStringIO,dateutil,lxml,openerp,psycopg2,requests,setuptools,werkzeug,xlsxwriter diff --git a/easy_my_coop_api/__manifest__.py b/easy_my_coop_api/__manifest__.py index e9751bd..b24affb 100644 --- a/easy_my_coop_api/__manifest__.py +++ b/easy_my_coop_api/__manifest__.py @@ -17,7 +17,7 @@ "summary": """ Open Easy My Coop to the world: RESTful API. """, - "data": [], + "data": ["views/external_id_mixin_views.xml"], "demo": ["demo/demo.xml"], "installable": True, "application": False, diff --git a/easy_my_coop_api/controllers/controllers.py b/easy_my_coop_api/controllers/controllers.py index 8badeb2..e8d5f89 100644 --- a/easy_my_coop_api/controllers/controllers.py +++ b/easy_my_coop_api/controllers/controllers.py @@ -3,11 +3,30 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import re + +from odoo import http from odoo.http import route from odoo.addons.base_rest.controllers import main +def patch_for_json(path_re): + # this is to avoid Odoo, which assumes json always means json+rpc, + # complaining about "function declared as capable of handling request + # of type 'http' but called with a request of type 'json'" + # cf rest-framework/graphql_base/controllers/main.py + path_re = re.compile(path_re) + orig_get_request = http.Root.get_request + + def get_request(self, httprequest): + if path_re.match(httprequest.path): + return http.HttpRequest(httprequest) + return orig_get_request(self, httprequest) + + http.Root.get_request = get_request + + class UserController(main.RestController): _root_path = "/api/" _collection_name = "emc.services" @@ -23,3 +42,15 @@ class UserController(main.RestController): return self._process_method( _service_name, "test", _id=None, params=None ) + + @route( + _root_path + "//validate", + methods=["POST"], + csrf=False, + ) + def validate(self, _service_name, _id, **params): + return self._process_method( + _service_name, "validate", _id=_id, params=params + ) + + patch_for_json("^/api/subscription-request/[0-9]*/validate$") diff --git a/easy_my_coop_api/demo/demo.xml b/easy_my_coop_api/demo/demo.xml index 72d2102..1e305fc 100644 --- a/easy_my_coop_api/demo/demo.xml +++ b/easy_my_coop_api/demo/demo.xml @@ -11,9 +11,22 @@ 1 + 2 + + + + 1 + + + + + 2 + + + diff --git a/easy_my_coop_api/models/__init__.py b/easy_my_coop_api/models/__init__.py index 6740752..3e72c0b 100644 --- a/easy_my_coop_api/models/__init__.py +++ b/easy_my_coop_api/models/__init__.py @@ -1,2 +1,3 @@ from . import auth_api_key from . import external_id_mixin +from . import subscription_request diff --git a/easy_my_coop_api/models/external_id_mixin.py b/easy_my_coop_api/models/external_id_mixin.py index cd9193e..1b56e78 100644 --- a/easy_my_coop_api/models/external_id_mixin.py +++ b/easy_my_coop_api/models/external_id_mixin.py @@ -2,6 +2,8 @@ # Robin Keunen # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from psycopg2 import IntegrityError + from odoo import api, fields, models @@ -9,14 +11,37 @@ class ExternalIdMixin(models.AbstractModel): _name = "external.id.mixin" _description = "External ID Mixin" + _sql_constraints = [ + ( + "_api_external_id_uniq", + "unique(_api_external_id)", + "API External ID must be unique!", + ) + ] + # do not access directly, always use get_api_external_id method _api_external_id = fields.Integer( - string="External ID", index=True, required=False + string="External ID", index=True, required=False, copy=False ) external_id_sequence_id = fields.Many2one( comodel_name="ir.sequence", string="External ID Sequence", required=False, + copy=False, + ) + first_api_export_date = fields.Datetime( + string="First API Export Date", required=False, copy=False + ) + last_api_export_date = fields.Datetime( + string="Last API Export Date", required=False, copy=False + ) + + # only used to display and hide "Generate external ID" button + external_id_generated = fields.Boolean( + string="External ID Generated", + default=False, + required=False, + copy=False, ) @api.multi @@ -28,7 +53,7 @@ class ExternalIdMixin(models.AbstractModel): sequence = Sequence.search([("code", "=", code)]) if not sequence: sequence = Sequence.sudo().create( - {"name": code, "code": code, "number_next": 1} + {"name": code, "code": code, "number_next": 100} ) self.sudo().write({"external_id_sequence_id": sequence.id}) @@ -40,9 +65,23 @@ class ExternalIdMixin(models.AbstractModel): if not self.external_id_sequence_id: self.set_external_sequence() if not self._api_external_id: - self.sudo().write( - {"_api_external_id": self.external_id_sequence_id._next()} - ) + # pass already allocated ids + n = 100 + while True: + try: + next_id = self.external_id_sequence_id._next() + self.sudo().write( + { + "_api_external_id": next_id, + "external_id_generated": True, + } + ) + break + except IntegrityError as e: + if n > 0: + continue + else: + raise e return self._api_external_id @@ -51,11 +90,6 @@ class ResPartner(models.Model): _inherit = ["res.partner", "external.id.mixin"] -class SubscriptionRequest(models.Model): - _name = "subscription.request" - _inherit = ["subscription.request", "external.id.mixin"] - - class AccountAccount(models.Model): _name = "account.account" _inherit = ["account.account", "external.id.mixin"] diff --git a/easy_my_coop_api/models/subscription_request.py b/easy_my_coop_api/models/subscription_request.py new file mode 100644 index 0000000..97b6c76 --- /dev/null +++ b/easy_my_coop_api/models/subscription_request.py @@ -0,0 +1,18 @@ +# Copyright 2020 Coop IT Easy SCRL fs +# Robin Keunen +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, models +from odoo.fields import Datetime + + +class SubscriptionRequest(models.Model): + _name = "subscription.request" + _inherit = ["subscription.request", "external.id.mixin"] + + @api.multi + def _timestamp_export(self): + self.write({"last_api_export_date": Datetime.now()}) + self.filtered(lambda sr: not sr.first_api_export_date).write( + {"first_api_export_date": Datetime.now()} + ) diff --git a/easy_my_coop_api/services/account_invoice_service.py b/easy_my_coop_api/services/account_invoice_service.py index 29216e5..6a39ecf 100644 --- a/easy_my_coop_api/services/account_invoice_service.py +++ b/easy_my_coop_api/services/account_invoice_service.py @@ -27,11 +27,11 @@ class AccountInvoiceService(Component): """ def get(self, _id): - sr = self.env["account.invoice"].search( + ai = self.env["account.invoice"].search( [("_api_external_id", "=", _id)] ) - if sr: - return self._to_dict(sr) + if ai: + return self._to_dict(ai) else: raise wrapJsonException( NotFound(_("No invoice found for id %s") % _id) @@ -42,7 +42,7 @@ class AccountInvoiceService(Component): data = { "id": invoice.get_api_external_id(), - "name": invoice.name, + "number": invoice.number, "state": invoice.state, "type": invoice.type, "date": Date.to_string(invoice.date), diff --git a/easy_my_coop_api/services/account_payment_service.py b/easy_my_coop_api/services/account_payment_service.py index d8094e3..391068b 100644 --- a/easy_my_coop_api/services/account_payment_service.py +++ b/easy_my_coop_api/services/account_payment_service.py @@ -76,10 +76,14 @@ class AccountPaymentService(Component): } def _to_dict(self, payment): + invoice = { + "id": payment.invoice_ids.get_api_external_id(), + "name": payment.invoice_ids.number, + } return { "id": payment.get_api_external_id(), "journal": self._one_to_many_to_dict(payment.journal_id), - "invoice": self._one_to_many_to_dict(payment.invoice_ids), + "invoice": invoice, "payment_date": Date.to_string(payment.payment_date), "amount": payment.amount, "communication": payment.communication, diff --git a/easy_my_coop_api/services/schemas.py b/easy_my_coop_api/services/schemas.py index 542c9f0..9f6166d 100644 --- a/easy_my_coop_api/services/schemas.py +++ b/easy_my_coop_api/services/schemas.py @@ -124,7 +124,7 @@ S_INVOICE_LINE_RETURN_GET = { S_INVOICE_RETURN_GET = { "id": {"type": "integer", "required": True}, - "name": {"type": "string", "required": True, "empty": False}, + "number": {"type": "string", "required": True, "empty": False}, "state": {"type": "string", "required": True, "empty": False}, "type": {"type": "string", "required": True, "empty": False}, "date": {"type": "string", "required": True, "empty": False}, diff --git a/easy_my_coop_api/services/subscription_request_service.py b/easy_my_coop_api/services/subscription_request_service.py index 66e3a2e..c966043 100644 --- a/easy_my_coop_api/services/subscription_request_service.py +++ b/easy_my_coop_api/services/subscription_request_service.py @@ -31,6 +31,7 @@ class SubscriptionRequestService(Component): [("_api_external_id", "=", _id)] ) if sr: + sr._timestamp_export() return self._to_dict(sr) else: raise wrapJsonException( @@ -49,6 +50,7 @@ class SubscriptionRequestService(Component): domain.append(("date", "<=", date_to)) requests = self.env["subscription.request"].search(domain) + requests._timestamp_export() response = { "count": len(requests), @@ -88,8 +90,9 @@ class SubscriptionRequestService(Component): _("Subscription request %s is not in draft state") % _id ) ) - sr.validate_subscription_request() - return self._to_dict(sr) + invoice = sr.validate_subscription_request() + invoice_service = self.work.component(usage="invoice") + return invoice_service.get(invoice.get_api_external_id()) def _to_dict(self, sr): sr.ensure_one() @@ -219,4 +222,4 @@ class SubscriptionRequestService(Component): return schemas.S_SUBSCRIPTION_REQUEST_VALIDATE def _validator_return_validate(self): - return schemas.S_SUBSCRIPTION_REQUEST_RETURN_GET + return schemas.S_INVOICE_RETURN_GET diff --git a/easy_my_coop_api/tests/test_account_invoice.py b/easy_my_coop_api/tests/test_account_invoice.py index 2eaf902..e6117ca 100644 --- a/easy_my_coop_api/tests/test_account_invoice.py +++ b/easy_my_coop_api/tests/test_account_invoice.py @@ -30,11 +30,20 @@ class TestAccountInvoiceController(BaseEMCRestCase): today = Date.to_string(Date.today()) self.demo_invoice_dict = { - "id": 1, - "name": "Capital Release Example", - "partner": {"id": 1, "name": "Catherine des Champs"}, - "account": {"id": 1, "name": "Cooperators"}, - "journal": {"id": 1, "name": "Subscription Journal"}, + "id": self.capital_release.get_api_external_id(), + "number": "xxx", # can't guess it + "partner": { + "id": self.coop_candidate.get_api_external_id(), + "name": self.coop_candidate.name, + }, + "account": { + "id": self.cooperator_account.get_api_external_id(), + "name": self.cooperator_account.name, + }, + "journal": { + "id": self.subscription_journal.get_api_external_id(), + "name": self.subscription_journal.name, + }, "subscription_request": {}, "state": "open", "date": today, @@ -47,7 +56,10 @@ class TestAccountInvoiceController(BaseEMCRestCase): "product": {"id": 1, "name": "Part A - Founder"}, "price_unit": 100.0, "quantity": 2.0, - "account": {"id": 2, "name": "Equity"}, + "account": { + "id": self.equity_account.get_api_external_id(), + "name": self.equity_account.name, + }, } ], } @@ -79,7 +91,7 @@ class TestAccountInvoiceController(BaseEMCRestCase): self.capital_release = self.env["account.invoice"].create( { - "name": "Capital Release Example", + "number": "Capital Release Example", "partner_id": self.coop_candidate.id, "type": "out_invoice", "invoice_line_ids": capital_release_line, @@ -92,10 +104,14 @@ class TestAccountInvoiceController(BaseEMCRestCase): def test_service_get(self): external_id = self.capital_release.get_api_external_id() result = self.ai_service.get(external_id) - self.assertEquals(self.demo_invoice_dict, result) + expected = self.demo_invoice_dict.copy() + expected["number"] = result["number"] + self.assertEquals(expected, result) def test_route_get(self): external_id = self.capital_release.get_api_external_id() route = "/api/invoice/%s" % external_id content = self.http_get_content(route) - self.assertEquals(self.demo_invoice_dict, content) + expected = self.demo_invoice_dict.copy() + expected["number"] = content["number"] + self.assertEquals(expected, content) diff --git a/easy_my_coop_api/tests/test_account_payment.py b/easy_my_coop_api/tests/test_account_payment.py index d428a75..766b67c 100644 --- a/easy_my_coop_api/tests/test_account_payment.py +++ b/easy_my_coop_api/tests/test_account_payment.py @@ -48,7 +48,7 @@ class TestAccountPaymentController(BaseEMCRestCase): "communication": invoice.reference, "invoice": { "id": invoice.get_api_external_id(), - "name": invoice.name, + "name": invoice.number, }, "amount": self.demo_request_1.subscription_amount, "payment_date": Date.to_string(Date.today()), diff --git a/easy_my_coop_api/tests/test_external_id_mixin.py b/easy_my_coop_api/tests/test_external_id_mixin.py index 0f7f0a3..242e7a3 100644 --- a/easy_my_coop_api/tests/test_external_id_mixin.py +++ b/easy_my_coop_api/tests/test_external_id_mixin.py @@ -2,6 +2,9 @@ # Robin Keunen # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from psycopg2 import IntegrityError + +import odoo from odoo.fields import Date from odoo.tests import TransactionCase @@ -81,3 +84,17 @@ class TestExternalIdMixin(TransactionCase): self.assertTrue(bool(invoice.external_id_sequence_id)) self.assertEquals(external_id, invoice.get_api_external_id()) + + @odoo.tools.mute_logger("odoo.sql_db") + def test_duplicate_api_external_id_raises(self): + invoice_1 = self.env["account.invoice"].create( + {"name": "create passes"} + ) + external_id = invoice_1.get_api_external_id() + self.assertTrue(bool(invoice_1._api_external_id)) + + invoice_2 = self.env["account.invoice"].create( + {"name": "create passes"} + ) + with self.assertRaises(IntegrityError): + invoice_2._api_external_id = external_id diff --git a/easy_my_coop_api/tests/test_subscription_requests.py b/easy_my_coop_api/tests/test_subscription_requests.py index be1102b..824166d 100644 --- a/easy_my_coop_api/tests/test_subscription_requests.py +++ b/easy_my_coop_api/tests/test_subscription_requests.py @@ -76,6 +76,8 @@ class TestSRController(BaseEMCRestCase): date_sr = self.sr_service.search(date_from=date_from, date_to=date_to) self.assertTrue(date_sr) + self.assertTrue(self.demo_request_1.first_api_export_date) + self.assertTrue(self.demo_request_1.last_api_export_date) def test_route_get(self): external_id = self.demo_request_1.get_api_external_id() @@ -193,7 +195,7 @@ class TestSRController(BaseEMCRestCase): content = json.loads(response.content.decode("utf-8")) state = content.get("state") - self.assertEquals(state, "done") + self.assertEquals(state, "open") def test_service_validate_draft_request(self): self.sr_service.validate(self.demo_request_1.get_api_external_id()) diff --git a/easy_my_coop_api/views/external_id_mixin_views.xml b/easy_my_coop_api/views/external_id_mixin_views.xml new file mode 100644 index 0000000..4a8cc8b --- /dev/null +++ b/easy_my_coop_api/views/external_id_mixin_views.xml @@ -0,0 +1,178 @@ + + + + + view_partner_form + res.partner + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + emc_backend_view_tree + emc.backend + + + + + + + + +
diff --git a/easy_my_coop_connector/views/emc_bindings.xml b/easy_my_coop_connector/views/emc_bindings.xml new file mode 100644 index 0000000..838e283 --- /dev/null +++ b/easy_my_coop_connector/views/emc_bindings.xml @@ -0,0 +1,174 @@ + + + + + emc_binding_subscription_request_view_form + emc.binding.subscription.request + +
+ + + + + + + +
+
+
+ + + emc_binding_subscription_request_view_tree + emc.binding.subscription.request + + + + + + + + + + + emc_binding_product_template_view_form + emc.binding.product.template + +
+ + + + + + + +
+
+
+ + + emc_binding_product_template_view_tree + emc.binding.product.template + + + + + + + + + + + emc_binding_account_invoice_view_form + emc.binding.account.invoice + +
+ + + + + + + +
+
+
+ + + emc_binding_account_invoice_view_tree + emc.binding.account.invoice + + + + + + + + + + + emc_binding_account_payment_view_form + emc.binding.account.payment + +
+ + + + + + + +
+
+
+ + + emc_binding_account_payment_view_tree + emc.binding.account.payment + + + + + + + + + + + emc_binding_account_journal_view_form + emc.binding.account.journal + +
+ + + + + + + +
+
+
+ + + emc_binding_account_journal_view_tree + emc.binding.account.journal + + + + + + + + + + + emc_binding_account_account_view_form + emc.binding.account.account + +
+ + + + + + + +
+
+
+ + + emc_binding_account_account_view_tree + emc.binding.account.account + + + + + + + + +
diff --git a/easy_my_coop_connector/views/menus.xml b/easy_my_coop_connector/views/menus.xml new file mode 100644 index 0000000..3710880 --- /dev/null +++ b/easy_my_coop_connector/views/menus.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/easy_my_coop_connector/wizards/__init__.py b/easy_my_coop_connector/wizards/__init__.py new file mode 100644 index 0000000..f22bdbc --- /dev/null +++ b/easy_my_coop_connector/wizards/__init__.py @@ -0,0 +1 @@ +from . import emc_history_import_sr diff --git a/easy_my_coop_connector/wizards/emc_history_import_sr.py b/easy_my_coop_connector/wizards/emc_history_import_sr.py new file mode 100644 index 0000000..cb7626f --- /dev/null +++ b/easy_my_coop_connector/wizards/emc_history_import_sr.py @@ -0,0 +1,37 @@ +# Copyright 2020 Coop IT Easy SCRL fs +# Robin Keunen +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import date + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class EMCHistoryImportSR(models.TransientModel): + _name = "emc.history.import.sr" + _description = "emc.history.import.sr" + + def first_day_of_month(self): + return date.today() - relativedelta(day=1) + + def last_day_of_month(self): + return date.today() + relativedelta(day=31) + + name = fields.Char("Name", default="Import History") + date_from = fields.Date( + string="Date From", required=True, default=first_day_of_month + ) + date_to = fields.Date( + string="Date To", required=True, default=last_day_of_month + ) + + @api.multi + def import_subscription_button(self): + self.env["subscription.request"].fetch_subscription_requests( + date_from=self.date_from, date_to=self.date_to + ) + + action = self.env.ref("easy_my_coop.subscription_request_action") + return action.read()[0] diff --git a/easy_my_coop_connector/wizards/emc_history_import_sr.xml b/easy_my_coop_connector/wizards/emc_history_import_sr.xml new file mode 100644 index 0000000..8035124 --- /dev/null +++ b/easy_my_coop_connector/wizards/emc_history_import_sr.xml @@ -0,0 +1,42 @@ + + + + + emc_history_import_sr_view_form + emc.history.import.sr + +
+ + + + + +
+
+
+
+
+
+ + + Import Subscription Request History + ir.actions.act_window + emc.history.import.sr + form + + + + + + + + + +
+