From 1ed383ea10410c6ecaab1d059d09f5e755e48e74 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 07:09:06 +0200 Subject: [PATCH 1/8] Add odoo fields --- .../0004_billingentity_odoo_fields.py | 23 +++++++++++++++++++ src/servala/core/models/organization.py | 7 ++++++ 2 files changed, 30 insertions(+) create mode 100644 src/servala/core/migrations/0004_billingentity_odoo_fields.py diff --git a/src/servala/core/migrations/0004_billingentity_odoo_fields.py b/src/servala/core/migrations/0004_billingentity_odoo_fields.py new file mode 100644 index 0000000..bcfee2d --- /dev/null +++ b/src/servala/core/migrations/0004_billingentity_odoo_fields.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2 on 2025-05-26 05:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0003_alter_organization_namespace"), + ] + + operations = [ + migrations.AddField( + model_name="billingentity", + name="odoo_company_id", + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name="billingentity", + name="odoo_invoice_id", + field=models.IntegerField(null=True), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 7ebda11..201a8b9 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -110,6 +110,13 @@ class BillingEntity(ServalaModelMixin, models.Model): max_length=100, blank=True, verbose_name=_("ERP reference") ) + # Odoo IDs are nullable for creation, should never be null in practice + # The company ID points at a record of type res.partner with company_type=company + # The invoice ID points at a record of type res.partner with company_type=person, + # type=invoic, parent_id=company_id (the invoice address). + odoo_company_id = models.IntegerField(null=True) + odoo_invoice_id = models.IntegerField(null=True) + class Meta: verbose_name = _("Billing entity") verbose_name_plural = _("Billing entities") From 97fc0453753eda770eedfba42388635e24627ade Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 09:14:07 +0200 Subject: [PATCH 2/8] First stab at Odoo filter logic --- src/servala/core/odoo.py | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/servala/core/odoo.py diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py new file mode 100644 index 0000000..9157d4c --- /dev/null +++ b/src/servala/core/odoo.py @@ -0,0 +1,67 @@ +ADDRESS_FIELDS = [ + "id", + "name", + "street", + "street2", + "city", + "zip", + "state_id", + "country_id", + "email", + "phone", + "vat", + "company_type", +] + + +def odoo_request(*args, **kwargs): + raise NotImplementedError + + +def get_invoice_addresses(user): + """Used during organization creation: retrieves all invoice + addresses the user owns or is connected to from the Odoo API.""" + or_conditions = [ + ("email", "ilike", user.email), + ("child_ids.email", "ilike", user.email), + ] + + # Attempt to find the Odoo user ID and add condition for records created by this user + try: + odoo_users = odoo_request( + model="res.users", + method="search_read", + domain=[("login", "=", user.email)], + fields=["id"], + limit=1, # Expecting at most one user + ) + if odoo_users and (uid := odoo_users[0].get("id")): + or_conditions.append(("create_uid", "=", uid)) + except Exception: + pass + + if len(or_conditions) == 1: + user_conditions = or_conditions[0] + else: + # Start with the last condition and progressively prepend OR clauses with previous conditions. + user_conditions = or_conditions[-1] + for i in range(len(or_conditions) - 2, -1, -1): + user_conditions = ["|", or_conditions[i], user_conditions] + + # The domain requires the partner to be an invoice address, that is: + # Of the company_type=person, and type=invoice. + # If we were searching for an existing organization, we would also have to + # filter for parent_id=odoo_company_id + static_conditions = ("&", ("company_type", "=", "person"), ("type", "=", "invoice")) + domain = ("&", static_conditions, user_conditions) + + try: + invoice_addresses = odoo_request( + model="res.partner", + method="search_read", + domain=domain, + fields=ADDRESS_FIELDS, + ) + return invoice_addresses or [] + except Exception: + return [] From 549e1fa19a48b572961f371968003de7c25b5574 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 09:14:25 +0200 Subject: [PATCH 3/8] Add Odoo settings --- .env.example | 5 +++++ src/servala/settings.py | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 13c1e64..1bbba50 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,8 @@ SERVALA_KEYCLOAK_SERVER_URL='' # SERVALA_S3_REGION_NAME='eu-central-1' # SERVALA_S3_ADDRESSING_STYLE='virtual' # SERVALA_S3_SIGNATURE_VERSION='s3v4' + +SERVALA_ODOO_DB='' +SERVALA_ODOO_URL='' +SERVALA_ODOO_USERNAME='' +SERVALA_ODOO_PASSWORD='' diff --git a/src/servala/settings.py b/src/servala/settings.py index c5905c2..9db4b4f 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -87,7 +87,6 @@ SOCIALACCOUNT_PROVIDERS = { } } - SERVALA_STORAGE_BUCKET_NAME = os.environ.get("SERVALA_STORAGE_BUCKET_NAME") SERVALA_S3_ENDPOINT_URL = os.environ.get("SERVALA_S3_ENDPOINT_URL") SERVALA_ACCESS_KEY_ID = os.environ.get("SERVALA_ACCESS_KEY_ID") @@ -120,6 +119,13 @@ if all( } } +ODOO = { + "URL": os.environ.get("SERVALA_ODOO_URL"), + "DB": os.environ.get("SERVALA_ODOO_DB"), + "USERNAME": os.environ.get("SERVALA_ODOO_USERNAME"), + "PASSWORD": os.environ.get("SERVALA_ODOO_PASSWORD"), +} + ####################################### # Non-configurable settings below # ####################################### From 0bd620d68ed8e1efbf1b2a9cdcad5402d7794735 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 09:15:53 +0200 Subject: [PATCH 4/8] Add system checks --- README.md | 1 + src/servala/core/apps.py | 3 ++ src/servala/core/checks.py | 86 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/servala/core/checks.py diff --git a/README.md b/README.md index df8d74c..7241854 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ uv run --env-file=.env src/manage.py COMMAND Useful commands: - ``migrate``: Make sure database migrations are applied. +- ``check --deploy``: Runs checks, e.g. for missing or mismatched configuration, including custom servala configuration. - ``showmigrations``: Show current database migrations status. Good for debugging. - ``runserver``: Run development server - ``clearsessions``: Clear away expired user sessions. Recommended to run regularly, e.g. weekly or monthly (doesn’t diff --git a/src/servala/core/apps.py b/src/servala/core/apps.py index ad73de8..f7cbde2 100644 --- a/src/servala/core/apps.py +++ b/src/servala/core/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class CoreConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "servala.core" + + def ready(self): + import servala.core.checks # noqa diff --git a/src/servala/core/checks.py b/src/servala/core/checks.py new file mode 100644 index 0000000..d1bb708 --- /dev/null +++ b/src/servala/core/checks.py @@ -0,0 +1,86 @@ +from django.conf import settings +from django.core.checks import ERROR, WARNING, CheckMessage, register + + +@register() +def check_servala_settings(app_configs, **kwargs): + """Checks all settings that should be present in all environments.""" + if app_configs: + # Don’t run if we’re meant to only test individual apps + return [] + + errors = [] + required_fields = ("URL", "DB", "USERNAME", "PASSWORD") + missing_fields = [ + field for field in required_fields if not settings.ODOO.get(field) + ] + if missing_fields: + fields = ", ".join(missing_fields) + errors.append( + CheckMessage( + level=WARNING if settings.DEBUG else ERROR, + msg=f"Missing Odoo config: {fields}", + hint="Make sure you set the required SERVALA_ODOO_* settings.", + id="servala.E001", + ) + ) + oidc_config = settings.SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"][0] + missing_fields = [ + field for field in ("client_id", "secret") if not oidc_config.get(field) + ] + if not oidc_config["settings"]["server_url"]: + missing_fields.append("server_url") + if missing_fields: + fields = ", ".join( + [f"SERVALA_KEYCLOAK_{field.upper()}" for field in missing_fields] + ) + errors.append( + CheckMessage( + level=WARNING if settings.DEBUG else ERROR, + msg=f"Missing Keycloak config: {fields}", + id="servala.E002", + ) + ) + + if settings.SERVALA_ENVIRONMENT not in ("development", "staging", "production"): + errors.append( + CheckMessage( + level=ERROR, + msg=f"Invalid environment {settings.SERVALA_ENVIRONMENT}", + hint="Must be one of development, staging, production.", + id="servala.E003", + ) + ) + + return errors + + +@register(deploy=True) +def check_servala_production_settings(app_configs, **kwargs): + if app_configs: + # Don’t run if we’re meant to only test individual apps + return [] + + errors = [] + if settings.SERVALA_ENVIRONMENT == "development": + errors.append( + CheckMessage( + level=ERROR, + msg="Environment is set to 'development'.", + id="servala.E004", + ) + ) + if "insecure" in settings.SECRET_KEY: + errors.append( + CheckMessage( + level=ERROR, msg="Secret key contains 'insecure'.", id="servala.E005" + ) + ) + if settings.EMAIL_USE_SSL and settings.EMAIL_USE_TLS: + errors.append( + CheckMessage( + level=WARNING, + msg="Use either SSL or TLS in email config, not both!", + id="servala.W001", + ) + ) From 6ce13126d5b54510d3d927243058aa730d570ca8 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 09:51:29 +0200 Subject: [PATCH 5/8] Implement user search in odoo --- src/servala/core/odoo.py | 81 ++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py index 9157d4c..59635bd 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -1,3 +1,7 @@ +import xmlrpc.client + +from django.conf import settings + ADDRESS_FIELDS = [ "id", "name", @@ -14,8 +18,66 @@ ADDRESS_FIELDS = [ ] -def odoo_request(*args, **kwargs): - raise NotImplementedError +def odoo_request(model, method, **kwargs): + url = settings.ODOO["URL"] + db = settings.ODOO["DB"] + username = settings.ODOO["USERNAME"] + password = settings.ODOO["PASSWORD"] + + try: + common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common") + uid = common.authenticate(db, username, password, {}) + + if not uid: + raise Exception("Authentication failed with Odoo.") + + models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object") + + # Prepare arguments for execute_kw + # Odoo's execute_kw expects: db, uid, password, model, method, args_list, kwargs_dict + # For 'search_read', args_list typically contains [domain, fields] + # and kwargs_dict contains {'limit': ..., 'offset': ..., 'order': ...} + + args_list = [] + kwargs_dict = {} + + if method == "search_read": + # Extract domain and fields for positional arguments if present + domain = kwargs.pop("domain", []) + fields = kwargs.pop("fields", []) + args_list = [domain, fields] + # Remaining kwargs are passed as the options dictionary + kwargs_dict = kwargs + else: + # For other methods, we might need a more generic way or specific handling. + # For now, assume kwargs can be passed directly if method is not 'search_read', + # or that they are passed as a list of arguments. + # This part might need refinement based on other Odoo methods used. + # A common pattern is to pass a list of IDs as the first arg for methods like 'read', 'write'. + # If 'args' is explicitly passed in kwargs, use it. + if "args" in kwargs: + args_list = kwargs.pop("args") + # Remaining kwargs are passed as the options dictionary + kwargs_dict = kwargs + + breakpoint() + result = models.execute_kw( + db, uid, password, model, method, args_list, kwargs_dict + ) + return result + + except xmlrpc.client.Fault as e: + # Handle XML-RPC specific errors (e.g., Odoo operational errors) + raise Exception(f"Odoo XML-RPC Fault: {e.faultString}") from e + except ConnectionRefusedError as e: + raise Exception( + f"Could not connect to Odoo at {url}. Connection refused." + ) from e + except Exception as e: + # General exception handling + raise Exception( + f"An error occurred while communicating with Odoo: {str(e)}" + ) from e def get_invoice_addresses(user): @@ -40,20 +102,15 @@ def get_invoice_addresses(user): except Exception: pass - if len(or_conditions) == 1: - user_conditions = or_conditions[0] - else: - # Start with the last condition and progressively prepend OR clauses with previous conditions. - user_conditions = or_conditions[-1] - for i in range(len(or_conditions) - 2, -1, -1): - user_conditions = ["|", or_conditions[i], user_conditions] - + user_conditions = ["|"] * (len(or_conditions) - 1) + or_conditions # The domain requires the partner to be an invoice address, that is: # Of the company_type=person, and type=invoice. # If we were searching for an existing organization, we would also have to # filter for parent_id=odoo_company_id - static_conditions = ("&", ("company_type", "=", "person"), ("type", "=", "invoice")) - domain = ("&", static_conditions, user_conditions) + domain = [ + ("company_type", "=", "person"), + ("type", "=", "invoice"), + ] + user_conditions try: invoice_addresses = odoo_request( From 8f75db5325dc74d0979bd390f11aee2e7686a61f Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 13:06:31 +0200 Subject: [PATCH 6/8] Use class-based odoo client for connection reuse --- src/servala/core/odoo.py | 112 +++++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 52 deletions(-) diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py index 59635bd..d5a0ad6 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -15,69 +15,77 @@ ADDRESS_FIELDS = [ "phone", "vat", "company_type", + "type", ] -def odoo_request(model, method, **kwargs): - url = settings.ODOO["URL"] - db = settings.ODOO["DB"] - username = settings.ODOO["USERNAME"] - password = settings.ODOO["PASSWORD"] +class OdooClient: + def __init__(self): + self.url = settings.ODOO["URL"] + self.db = settings.ODOO["DB"] + self.username = settings.ODOO["USERNAME"] + self.password = settings.ODOO["PASSWORD"] - try: - common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common") - uid = common.authenticate(db, username, password, {}) + self.common_proxy = None + self.models_proxy = None + self.uid = None - if not uid: - raise Exception("Authentication failed with Odoo.") + def _connect(self): + """This method is called on the first client request, not on instantiation, + so that we can instantiate the client on startup and reuse it across the entire + application.""" + try: + self.common_proxy = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common") + self.uid = self.common_proxy.authenticate( + self.db, self.username, self.password, {} + ) - models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object") + if not self.uid: + raise Exception("Authentication failed with Odoo: No UID returned.") - # Prepare arguments for execute_kw - # Odoo's execute_kw expects: db, uid, password, model, method, args_list, kwargs_dict - # For 'search_read', args_list typically contains [domain, fields] - # and kwargs_dict contains {'limit': ..., 'offset': ..., 'order': ...} + self.models_proxy = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object") - args_list = [] - kwargs_dict = {} + except xmlrpc.client.Fault as e: + raise Exception( + f"Odoo XML-RPC Fault during connection: {e.faultString}" + ) from e + except ConnectionRefusedError as e: + raise Exception( + f"Could not connect to Odoo at {self.url}. Connection refused." + ) from e + except Exception as e: + raise Exception( + f"An error occurred while connecting to Odoo: {str(e)}" + ) from e - if method == "search_read": - # Extract domain and fields for positional arguments if present - domain = kwargs.pop("domain", []) - fields = kwargs.pop("fields", []) - args_list = [domain, fields] - # Remaining kwargs are passed as the options dictionary - kwargs_dict = kwargs - else: - # For other methods, we might need a more generic way or specific handling. - # For now, assume kwargs can be passed directly if method is not 'search_read', - # or that they are passed as a list of arguments. - # This part might need refinement based on other Odoo methods used. - # A common pattern is to pass a list of IDs as the first arg for methods like 'read', 'write'. - # If 'args' is explicitly passed in kwargs, use it. - if "args" in kwargs: - args_list = kwargs.pop("args") - # Remaining kwargs are passed as the options dictionary - kwargs_dict = kwargs + def execute(self, model, method, args_list, **kwargs): + if not self.uid or not self.models_proxy: + self._connect() - breakpoint() - result = models.execute_kw( - db, uid, password, model, method, args_list, kwargs_dict - ) - return result + try: + result = self.models_proxy.execute_kw( + self.db, self.uid, self.password, model, method, args_list, kwargs + ) + return result - except xmlrpc.client.Fault as e: - # Handle XML-RPC specific errors (e.g., Odoo operational errors) - raise Exception(f"Odoo XML-RPC Fault: {e.faultString}") from e - except ConnectionRefusedError as e: - raise Exception( - f"Could not connect to Odoo at {url}. Connection refused." - ) from e - except Exception as e: - # General exception handling - raise Exception( - f"An error occurred while communicating with Odoo: {str(e)}" - ) from e + except xmlrpc.client.Fault as e: + print(f"Fault! {e}") + raise Exception(f"Odoo XML-RPC Fault: {e.faultString}") from e + except ConnectionRefusedError as e: + raise Exception( + f"Connection to Odoo at {self.url} lost or refused during operation." + ) from e + except Exception as e: + print(e) + raise Exception( + f"An error occurred while communicating with Odoo: {str(e)}" + ) from e + + def search_read(self, model, domain, fields, **kwargs): + return self.execute(model, "search_read", args_list=[domain, fields], **kwargs) + + +CLIENT = OdooClient() def get_invoice_addresses(user): From 22ff769b2cb881501815614617ba315864a699e7 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 13:07:07 +0200 Subject: [PATCH 7/8] Fully implement odoo user search --- src/servala/core/odoo.py | 55 +++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py index d5a0ad6..81a24bc 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -91,26 +91,40 @@ CLIENT = OdooClient() def get_invoice_addresses(user): """Used during organization creation: retrieves all invoice addresses the user owns or is connected to from the Odoo API.""" - or_conditions = [ - ("email", "ilike", user.email), - ("child_ids.email", "ilike", user.email), - ] + # We’re building our conditions in order: + # - in exceptions, users may be using a billing account’s email + # - if the user is associated with an odoo user, return all billing + # addresses / organizations created by the user + # - if the user is associated with an odoo contact, return all billing + # addresses with the same parent_id + or_conditions = [("email", "ilike", email)] - # Attempt to find the Odoo user ID and add condition for records created by this user - try: - odoo_users = odoo_request( - model="res.users", - method="search_read", - domain=[("login", "=", user.email)], - fields=["id"], - limit=1, # Expecting at most one user - ) - if odoo_users and (uid := odoo_users[0].get("id")): - or_conditions.append(("create_uid", "=", uid)) - except Exception: - pass + email = user if isinstance(user, str) else user.email + odoo_users = CLIENT.search_read( + model="res.users", + domain=[("login", "=", email)], + fields=["id"], + limit=1, + ) + if odoo_users and (uid := odoo_users[0].get("id")): + or_conditions.append(("create_uid", "=", uid)) + + odoo_contacts = CLIENT.search_read( + model="res.partner", + domain=[ + ("company_type", "=", "person"), + ("type", "=", "contact"), + ("email", "ilike", email), + ], + fields=["id", "parent_id"], + ) + if odoo_contacts: + for contact in odoo_contacts: + or_conditions.append(("parent_id", "=", contact["parent_id"][0])) + + if len(or_conditions) > 1: + or_conditions = ["|"] * (len(or_conditions) - 1) + or_conditions - user_conditions = ["|"] * (len(or_conditions) - 1) + or_conditions # The domain requires the partner to be an invoice address, that is: # Of the company_type=person, and type=invoice. # If we were searching for an existing organization, we would also have to @@ -118,12 +132,11 @@ def get_invoice_addresses(user): domain = [ ("company_type", "=", "person"), ("type", "=", "invoice"), - ] + user_conditions + ] + or_conditions try: - invoice_addresses = odoo_request( + invoice_addresses = CLIENT.search_read( model="res.partner", - method="search_read", domain=domain, fields=ADDRESS_FIELDS, ) From e18cafa813d5d94fdd067b7d510b079ee53a255a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 13:07:32 +0200 Subject: [PATCH 8/8] Implement user:odoo contact mapping ref #60 --- src/servala/core/models/user.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/servala/core/models/user.py b/src/servala/core/models/user.py index 0084593..5d513f4 100644 --- a/src/servala/core/models/user.py +++ b/src/servala/core/models/user.py @@ -6,7 +6,8 @@ from django.contrib.auth.models import ( from django.db import models from django.utils.translation import gettext_lazy as _ -from .mixins import ServalaModelMixin +from servala.core import odoo +from servala.core.models.mixins import ServalaModelMixin class UserManager(BaseUserManager): @@ -73,3 +74,22 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser): def normalize_username(self, username): return super().normalize_username(username).strip().lower() + + def get_odoo_contact(self, organization): + if ( + not organization.billing_entity + or not organization.billing_entity.odoo_company_id + ): + return + result = odoo.CLIENT.search_read( + model="res.partner", + domain=[ + ("company_type", "=", "person"), + ("type", "=", "contact"), + ("email", "ilike", self.email), + ("parent_id", "=", organization.billing_entity.odoo_company_id), + ], + fields=odoo.ADDRESS_FIELDS, + ) + if result: + return result[0]