From 1ed383ea10410c6ecaab1d059d09f5e755e48e74 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 07:09:06 +0200 Subject: [PATCH 01/49] 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 02/49] 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 03/49] 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 04/49] 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 05/49] 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 06/49] 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 07/49] 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 08/49] 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] From eb73b35a5ce4c9d945f8f3a2202e1aa9864a3d37 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 11:11:40 +0200 Subject: [PATCH 09/49] add objectstorage to deployment --- deployment/kustomize/base/portal/kustomization.yaml | 1 + deployment/kustomize/base/portal/objectstorage.yaml | 9 +++++++++ .../kustomize/overlays/production/kustomization.yaml | 1 + .../kustomize/overlays/production/objectstorage.yaml | 7 +++++++ deployment/kustomize/overlays/staging/kustomization.yaml | 1 + deployment/kustomize/overlays/staging/objectstorage.yaml | 7 +++++++ 6 files changed, 26 insertions(+) create mode 100644 deployment/kustomize/base/portal/objectstorage.yaml create mode 100644 deployment/kustomize/overlays/production/objectstorage.yaml create mode 100644 deployment/kustomize/overlays/staging/objectstorage.yaml diff --git a/deployment/kustomize/base/portal/kustomization.yaml b/deployment/kustomize/base/portal/kustomization.yaml index 140ec08..8818218 100644 --- a/deployment/kustomize/base/portal/kustomization.yaml +++ b/deployment/kustomize/base/portal/kustomization.yaml @@ -2,3 +2,4 @@ resources: - deployment.yaml - service.yaml - cronjob.yaml + - objectstorage.yaml diff --git a/deployment/kustomize/base/portal/objectstorage.yaml b/deployment/kustomize/base/portal/objectstorage.yaml new file mode 100644 index 0000000..6c6ad65 --- /dev/null +++ b/deployment/kustomize/base/portal/objectstorage.yaml @@ -0,0 +1,9 @@ +apiVersion: appcat.vshn.io/v1 +kind: ObjectBucket +metadata: + name: portal-storage +spec: + parameters: + region: lpg + writeConnectionSecretToRef: + name: portal-storage-creds diff --git a/deployment/kustomize/overlays/production/kustomization.yaml b/deployment/kustomize/overlays/production/kustomization.yaml index d746bb1..eb0f847 100644 --- a/deployment/kustomize/overlays/production/kustomization.yaml +++ b/deployment/kustomize/overlays/production/kustomization.yaml @@ -11,3 +11,4 @@ resources: - ingress.yaml patches: - path: portal-deployment.yaml + - path: objectstorage.yaml diff --git a/deployment/kustomize/overlays/production/objectstorage.yaml b/deployment/kustomize/overlays/production/objectstorage.yaml new file mode 100644 index 0000000..af056e9 --- /dev/null +++ b/deployment/kustomize/overlays/production/objectstorage.yaml @@ -0,0 +1,7 @@ +apiVersion: appcat.vshn.io/v1 +kind: ObjectBucket +metadata: + name: portal-storage +spec: + parameters: + bucketName: servala-portal-storage-production diff --git a/deployment/kustomize/overlays/staging/kustomization.yaml b/deployment/kustomize/overlays/staging/kustomization.yaml index d746bb1..eb0f847 100644 --- a/deployment/kustomize/overlays/staging/kustomization.yaml +++ b/deployment/kustomize/overlays/staging/kustomization.yaml @@ -11,3 +11,4 @@ resources: - ingress.yaml patches: - path: portal-deployment.yaml + - path: objectstorage.yaml diff --git a/deployment/kustomize/overlays/staging/objectstorage.yaml b/deployment/kustomize/overlays/staging/objectstorage.yaml new file mode 100644 index 0000000..e2d3443 --- /dev/null +++ b/deployment/kustomize/overlays/staging/objectstorage.yaml @@ -0,0 +1,7 @@ +apiVersion: appcat.vshn.io/v1 +kind: ObjectBucket +metadata: + name: portal-storage +spec: + parameters: + bucketName: servala-portal-storage-staging From 5f38856dd962a1caa1597bfe6a23b9522327e855 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 11:18:56 +0200 Subject: [PATCH 10/49] configure django storages with env vars --- .../kustomize/base/portal/deployment.yaml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/deployment/kustomize/base/portal/deployment.yaml b/deployment/kustomize/base/portal/deployment.yaml index 03deb98..b6755fa 100644 --- a/deployment/kustomize/base/portal/deployment.yaml +++ b/deployment/kustomize/base/portal/deployment.yaml @@ -54,5 +54,25 @@ spec: secretKeyRef: name: database-creds key: POSTGRESQL_DB + - name: SERVALA_STORAGE_BUCKET_NAME + valueFrom: + secretKeyRef: + name: portal-storage-creds + key: BUCKET_NAME + - name: SERVALA_S3_ENDPOINT_URL + valueFrom: + secretKeyRef: + name: portal-storage-creds + key: ENDPOINT_URL + - name: SERVALA_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: portal-storage-creds + key: AWS_ACCESS_KEY_ID + - name: SERVALA_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: portal-storage-creds + key: AWS_SECRET_ACCESS_KEY - name: UV_CACHE_DIR value: /app/.uvcache From 1dda974e110083ad30745ec11e16b31027f2f908 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 11:25:39 +0200 Subject: [PATCH 11/49] Revert "configure django storages with env vars" This reverts commit 5f38856dd962a1caa1597bfe6a23b9522327e855. --- .../kustomize/base/portal/deployment.yaml | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/deployment/kustomize/base/portal/deployment.yaml b/deployment/kustomize/base/portal/deployment.yaml index b6755fa..03deb98 100644 --- a/deployment/kustomize/base/portal/deployment.yaml +++ b/deployment/kustomize/base/portal/deployment.yaml @@ -54,25 +54,5 @@ spec: secretKeyRef: name: database-creds key: POSTGRESQL_DB - - name: SERVALA_STORAGE_BUCKET_NAME - valueFrom: - secretKeyRef: - name: portal-storage-creds - key: BUCKET_NAME - - name: SERVALA_S3_ENDPOINT_URL - valueFrom: - secretKeyRef: - name: portal-storage-creds - key: ENDPOINT_URL - - name: SERVALA_ACCESS_KEY_ID - valueFrom: - secretKeyRef: - name: portal-storage-creds - key: AWS_ACCESS_KEY_ID - - name: SERVALA_SECRET_ACCESS_KEY - valueFrom: - secretKeyRef: - name: portal-storage-creds - key: AWS_SECRET_ACCESS_KEY - name: UV_CACHE_DIR value: /app/.uvcache From 126ff350658d6a4afd5f3772b6e1e3a5285eb069 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 10:56:41 +0200 Subject: [PATCH 12/49] configure renovate with a recurring action --- .forgejo/workflows/renovate.yaml | 27 +++++++++++++++++++++++++++ renovate.json | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .forgejo/workflows/renovate.yaml create mode 100644 renovate.json diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml new file mode 100644 index 0000000..da1da1b --- /dev/null +++ b/.forgejo/workflows/renovate.yaml @@ -0,0 +1,27 @@ +name: Renovate Dependency Bot + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +jobs: + renovate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Renovate + uses: github.com/renovatebot/github-action@v42.0.4 + with: + token: ${{ secrets.RENOVATE_TOKEN }} + env: + LOG_LEVEL: info + RENOVATE_CONFIG_FILE: renovate.json + RENOVATE_ENDPOINT: ${{ vars.RENOVATE_ENDPOINT }} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..a55a455 --- /dev/null +++ b/renovate.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "pip_requirements": { + "fileMatch": [ + "^pyproject\\.toml$" + ] + }, + "dockerfile": { + "fileMatch": [ + "^Dockerfile$" + ] + }, + "github-actions": { + "fileMatch": [ + "^\\.forgejo\\/workflows\\/.*\\.yml$" + ] + }, + "labels": [ + "dependencies" + ], + "lockFileMaintenance": { + "enabled": true + } +} \ No newline at end of file From 4ef2f9a31b169edcf4b968f029a8ebad0617e059 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 13:27:29 +0200 Subject: [PATCH 13/49] use full url for action location --- .forgejo/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index da1da1b..46ffbd8 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -18,7 +18,7 @@ jobs: node-version: "18" - name: Renovate - uses: github.com/renovatebot/github-action@v42.0.4 + uses: https://github.com/renovatebot/github-action@v42.0.4 with: token: ${{ secrets.RENOVATE_TOKEN }} env: From 8edb0598319df02d5866057f204b13e5b668d361 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 13:34:16 +0200 Subject: [PATCH 14/49] specify storage for static files --- src/servala/settings.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/servala/settings.py b/src/servala/settings.py index c5905c2..046c468 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -117,7 +117,10 @@ if all( "addressing_style": SERVALA_S3_ADDRESSING_STYLE, "signature_version": SERVALA_S3_SIGNATURE_VERSION, }, - } + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, } ####################################### From 313a8a5492b660ff8ddc704de45fc7ddbfd03ced Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 13:37:43 +0200 Subject: [PATCH 15/49] use better container for renovate job --- .forgejo/workflows/renovate.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 46ffbd8..f5a9c78 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -8,6 +8,7 @@ on: jobs: renovate: runs-on: ubuntu-latest + container: catthehacker/ubuntu:act-latest steps: - name: Checkout uses: actions/checkout@v4 From 378f10c9926e00225ae6ada37bd537366109e134 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 13:44:38 +0200 Subject: [PATCH 16/49] enhanced renovate config --- renovate.json | 56 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/renovate.json b/renovate.json index a55a455..765dc82 100644 --- a/renovate.json +++ b/renovate.json @@ -1,27 +1,43 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base" + "config:recommended" + ], + "platform": "gitea", + "packageRules": [ + { + "matchManagers": [ + "pip_requirements" + ], + "matchFileNames": [ + "pyproject.toml", + "requirements.txt", + "requirements/*.txt" + ] + }, + { + "matchManagers": [ + "dockerfile" + ], + "matchFileNames": [ + "Dockerfile", + "**/*.dockerfile", + "**/Dockerfile.*" + ] + }, + { + "matchManagers": [ + "github-actions" + ], + "matchFileNames": [ + ".forgejo/workflows/*.yml", + ".forgejo/workflows/*.yaml", + ".github/workflows/*.yml", + ".github/workflows/*.yaml" + ] + } ], - "pip_requirements": { - "fileMatch": [ - "^pyproject\\.toml$" - ] - }, - "dockerfile": { - "fileMatch": [ - "^Dockerfile$" - ] - }, - "github-actions": { - "fileMatch": [ - "^\\.forgejo\\/workflows\\/.*\\.yml$" - ] - }, "labels": [ "dependencies" - ], - "lockFileMaintenance": { - "enabled": true - } + ] } \ No newline at end of file From 52553796d3066724e01ef32b059cb575edbaed55 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 13:52:24 +0200 Subject: [PATCH 17/49] restrict workflows to certain paths --- .forgejo/workflows/build-deploy-prod.yaml | 7 +++++++ .forgejo/workflows/build-deploy-staging.yaml | 7 +++++++ .forgejo/workflows/renovate.yaml | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/build-deploy-prod.yaml b/.forgejo/workflows/build-deploy-prod.yaml index 369272d..36404fd 100644 --- a/.forgejo/workflows/build-deploy-prod.yaml +++ b/.forgejo/workflows/build-deploy-prod.yaml @@ -4,6 +4,13 @@ on: push: tags: - "*" + paths: + - "deployment/**" + - "docker/**" + - "src/**" + - "Dockerfile" + - "pyproject.toml" + - "uv.lock" workflow_dispatch: jobs: diff --git a/.forgejo/workflows/build-deploy-staging.yaml b/.forgejo/workflows/build-deploy-staging.yaml index 92fa6d2..f73edae 100644 --- a/.forgejo/workflows/build-deploy-staging.yaml +++ b/.forgejo/workflows/build-deploy-staging.yaml @@ -3,6 +3,13 @@ name: Build and Deploy Staging on: push: branches: [main] + paths: + - "deployment/**" + - "docker/**" + - "src/**" + - "Dockerfile" + - "pyproject.toml" + - "uv.lock" workflow_dispatch: jobs: diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index f5a9c78..f601935 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -23,6 +23,6 @@ jobs: with: token: ${{ secrets.RENOVATE_TOKEN }} env: - LOG_LEVEL: info + LOG_LEVEL: debug RENOVATE_CONFIG_FILE: renovate.json RENOVATE_ENDPOINT: ${{ vars.RENOVATE_ENDPOINT }} From 06efd09f6033b38af50da5c86322434f20f4c353 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 13:53:38 +0200 Subject: [PATCH 18/49] only execute python tests on src changes --- .forgejo/workflows/tests.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.forgejo/workflows/tests.yaml b/.forgejo/workflows/tests.yaml index b86ba55..d766820 100644 --- a/.forgejo/workflows/tests.yaml +++ b/.forgejo/workflows/tests.yaml @@ -2,6 +2,10 @@ name: Tests on: push: + paths: + - "src/**" + - "pyproject.toml" + - "uv.lock" workflow_dispatch: jobs: From 45a1825b704acaa9c8a233bc189477ceb43ee5f4 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 14:02:30 +0200 Subject: [PATCH 19/49] remove config file location definition --- .forgejo/workflows/renovate.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index f601935..b39bbef 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -24,5 +24,4 @@ jobs: token: ${{ secrets.RENOVATE_TOKEN }} env: LOG_LEVEL: debug - RENOVATE_CONFIG_FILE: renovate.json RENOVATE_ENDPOINT: ${{ vars.RENOVATE_ENDPOINT }} From 995ace7d972170a7ebbf0e931a44cbce77928683 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 14:09:02 +0200 Subject: [PATCH 20/49] set the platform in renovate config --- .forgejo/workflows/renovate.yaml | 1 + renovate.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index b39bbef..2c9604b 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -25,3 +25,4 @@ jobs: env: LOG_LEVEL: debug RENOVATE_ENDPOINT: ${{ vars.RENOVATE_ENDPOINT }} + RENOVATE_PLATFORM: gitea diff --git a/renovate.json b/renovate.json index 765dc82..3dbec6b 100644 --- a/renovate.json +++ b/renovate.json @@ -3,7 +3,6 @@ "extends": [ "config:recommended" ], - "platform": "gitea", "packageRules": [ { "matchManagers": [ From cdd8838b3df8dad03d54ac1d9713a7036ab816d1 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 27 May 2025 14:17:20 +0200 Subject: [PATCH 21/49] Implement template/form/view for org create --- src/servala/core/models/organization.py | 13 +++ src/servala/core/odoo.py | 1 + src/servala/frontend/forms/organization.py | 102 ++++++++++++++++++ .../templates/frontend/forms/errors.html | 18 ++++ .../templates/frontend/forms/form.html | 19 +--- .../frontend/organizations/create.html | 64 ++++++++++- src/servala/frontend/views/organization.py | 41 ++++++- 7 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 src/servala/frontend/templates/frontend/forms/errors.html diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 201a8b9..c113f78 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -124,6 +124,19 @@ class BillingEntity(ServalaModelMixin, models.Model): def __str__(self): return self.name + @classmethod + def create_from_data(cls, odoo_data): + instance = BillingEntity.objects.create(name=odoo_data.get("name")) + # TODO implement odoo creation from data + return instance + + @classmethod + def create_from_id(cls, odoo_id): + # TODO implement odoo creation from ID + # instance = BillingEntity.objects.create(name=odoo_data.get("name")) + # return instance + pass + class OrganizationOrigin(ServalaModelMixin, models.Model): """ diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py index 81a24bc..85f0b35 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -97,6 +97,7 @@ def get_invoice_addresses(user): # addresses / organizations created by the user # - if the user is associated with an odoo contact, return all billing # addresses with the same parent_id + email = user.email or_conditions = [("email", "ilike", email)] email = user if isinstance(user, str) else user.email diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 41cc26c..115e574 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,6 +1,9 @@ +from django import forms from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ from servala.core.models import Organization +from servala.core.odoo import get_invoice_addresses from servala.frontend.forms.mixins import HtmxMixin @@ -8,3 +11,102 @@ class OrganizationForm(HtmxMixin, ModelForm): class Meta: model = Organization fields = ("name",) + + +class OrganizationCreateForm(OrganizationForm): + billing_processing_choice = forms.ChoiceField( + choices=[ + ("existing", _("Use an existing billing address")), + ("new", _("Create a new billing address")), + ], + widget=forms.RadioSelect, + label=_("Billing Address"), + initial="new", # Will change to 'existing' if options are found + ) + existing_odoo_address_id = forms.ChoiceField( + label=_("Existing Billing Address"), + required=False, + ) + + # Fields for creating a new billing address in Odoo, prefixed with 'ba_' + ba_name = forms.CharField( + label=_("Contact Person / Company Name"), required=False, max_length=100 + ) + ba_street = forms.CharField(label=_("Street"), required=False, max_length=100) + ba_street2 = forms.CharField( + label=_("Street 2 (Optional)"), required=False, max_length=100 + ) + ba_city = forms.CharField(label=_("City"), required=False, max_length=100) + ba_zip = forms.CharField(label=_("ZIP Code"), required=False, max_length=20) + # For state & country, Odoo uses structured data. For now, text input. + # These will need mapping logic when actual Odoo creation is implemented. + ba_state_name = forms.CharField( + label=_("State / Province"), required=False, max_length=100 + ) + ba_country_name = forms.CharField( + label=_("Country"), required=False, max_length=100 + ) + ba_email = forms.EmailField(label=_("Billing Email"), required=False) + ba_phone = forms.CharField(label=_("Billing Phone"), required=False, max_length=30) + ba_vat = forms.CharField(label=_("VAT ID"), required=False, max_length=50) + + class Meta(OrganizationForm.Meta): + pass + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.odoo_addresses = get_invoice_addresses(self.user) + + if self.odoo_addresses: + address_choices = [("", _("---------"))] + for addr in self.odoo_addresses: + display_parts = [ + addr.get("name"), + addr.get("street"), + addr.get("city"), + addr.get("zip"), + ] + display_name = ", ".join(filter(None, display_parts)) + address_choices.append((str(addr["id"]), display_name)) + + self.fields["existing_odoo_address_id"].choices = address_choices + if not self.is_bound and "billing_processing_choice" not in self.initial: + self.fields["billing_processing_choice"].initial = "existing" + else: + # No existing Odoo addresses. Force 'new' choice. + self.fields["billing_processing_choice"].choices = [ + ("new", _("Create a new billing address")), + ] + self.fields["billing_processing_choice"].initial = "new" + self.fields["billing_processing_choice"].widget = forms.HiddenInput() + self.fields["existing_odoo_address_id"].widget = forms.HiddenInput() + + def clean(self): + cleaned_data = super().clean() + choice = cleaned_data.get("billing_processing_choice") + if choice == "new": + required_fields = [ + "ba_name", + "ba_street", + "ba_city", + "ba_zip", + "ba_state_name", + "ba_country_name", + "ba_email", + ] + for field_name in required_fields: + if not cleaned_data.get(field_name): + self.add_error( + field_name, + _( + "This field is required when creating a new billing address." + ), + ) + else: + existing_id_str = cleaned_data.get("existing_odoo_address_id") + if not existing_id_str: + self.add_error( + "existing_odoo_address_id", _("Please select an existing address.") + ) + return cleaned_data diff --git a/src/servala/frontend/templates/frontend/forms/errors.html b/src/servala/frontend/templates/frontend/forms/errors.html new file mode 100644 index 0000000..964687d --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/errors.html @@ -0,0 +1,18 @@ +{% load i18n %} +{% if form.non_field_errors or form.errors %} + +{% endif %} diff --git a/src/servala/frontend/templates/frontend/forms/form.html b/src/servala/frontend/templates/frontend/forms/form.html index 8ab90d1..361b161 100644 --- a/src/servala/frontend/templates/frontend/forms/form.html +++ b/src/servala/frontend/templates/frontend/forms/form.html @@ -1,21 +1,4 @@ -{% load i18n %} -{% if form.non_field_errors or form.errors %} - -{% endif %} +{% include "frontend/forms/errors.html" %}
{% for field, errors in fields %}{{ field.as_field_group }}{% endfor %} diff --git a/src/servala/frontend/templates/frontend/organizations/create.html b/src/servala/frontend/templates/frontend/organizations/create.html index 2d7cfa4..fc0fe58 100644 --- a/src/servala/frontend/templates/frontend/organizations/create.html +++ b/src/servala/frontend/templates/frontend/organizations/create.html @@ -6,5 +6,67 @@ {% endblock page_title %} {% endblock html_title %} {% block card_content %} - {% include "includes/form.html" %} +
+ {% include "frontend/forms/errors.html" %} + {% csrf_token %} +
+
+ {{ form.name.as_field_group }} +
+

{% translate "Billing Information" %}

+ {{ form.billing_processing_choice.as_field_group }} +
{{ form.existing_odoo_address_id.as_field_group }}
+
+ {{ form.ba_name.as_field_group }} + {{ form.ba_street.as_field_group }} + {{ form.ba_street2.as_field_group }} + {{ form.ba_city.as_field_group }} + {{ form.ba_zip.as_field_group }} + {{ form.ba_state_name.as_field_group }} + {{ form.ba_country_name.as_field_group }} + {{ form.ba_email.as_field_group }} + {{ form.ba_phone.as_field_group }} + {{ form.ba_vat.as_field_group }} +
+
+ +
+
+
+
+ {% endblock card_content %} diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 7bfdee5..fe0bbb2 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,18 +1,53 @@ from django.shortcuts import redirect +from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, DetailView from rules.contrib.views import AutoPermissionRequiredMixin -from servala.core.models import Organization -from servala.frontend.forms import OrganizationForm +from servala.core.models import BillingEntity, Organization +from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): - form_class = OrganizationForm + form_class = OrganizationCreateForm model = Organization template_name = "frontend/organizations/create.html" + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + def form_valid(self, form): + billing_choice = form.cleaned_data.get("billing_processing_choice") + billing_entity = None + + if billing_choice == "new": + billing_entity = BillingEntity.create_from_data( + { + key[3:]: value + for key, value in form.cleaned_data.items() + if key.startswith("ba_") + } + ) + elif odoo_id := form.cleaned_data.get("existing_odoo_address_id"): + billing_entity = BillingEntity.objects.filter( + odoo_invoice_id=odoo_id + ).first() + + if not billing_entity: + billing_entity = BillingEntity.create_from_id(odoo_id) + + if not billing_entity: + form.add_error( + None, + _( + "Could not determine or create the billing entity. Please check your input." + ), + ) + return self.form_invalid(form) + + form.instance.billing_entity = billing_entity instance = form.instance.create_organization( form.instance, owner=self.request.user ) From 67a76e7f4cc7f7b1834cfe2b6c789c3139934a73 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 14:48:40 +0200 Subject: [PATCH 22/49] specify which repo to renovate --- .forgejo/workflows/renovate.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 2c9604b..39328b3 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -23,6 +23,7 @@ jobs: with: token: ${{ secrets.RENOVATE_TOKEN }} env: - LOG_LEVEL: debug + LOG_LEVEL: info RENOVATE_ENDPOINT: ${{ vars.RENOVATE_ENDPOINT }} RENOVATE_PLATFORM: gitea + RENOVATE_REPOSITORIES: ${{ github.repository }} From 3976d2905bcfa5c002c18e27688953d87487a46b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 14:58:00 +0200 Subject: [PATCH 23/49] define the github token for renovate --- .forgejo/workflows/renovate.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 39328b3..1d198f5 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -27,3 +27,4 @@ jobs: RENOVATE_ENDPOINT: ${{ vars.RENOVATE_ENDPOINT }} RENOVATE_PLATFORM: gitea RENOVATE_REPOSITORIES: ${{ github.repository }} + RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }} From e0a1197a70151b064f714597cb7dd39316177c7c Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 15:03:12 +0200 Subject: [PATCH 24/49] adapt renovate config to recommendations --- .forgejo/workflows/renovate.yaml | 3 ++ renovate.json | 58 +++++++++++++++++++------------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 1d198f5..2f9d7d5 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -28,3 +28,6 @@ jobs: RENOVATE_PLATFORM: gitea RENOVATE_REPOSITORIES: ${{ github.repository }} RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }} + RENOVATE_GIT_AUTHOR: "Renovate Bot " + RENOVATE_USERNAME: renovate + RENOVATE_ENABLE_PYTHON_TOOL_VERSIONS: true diff --git a/renovate.json b/renovate.json index 3dbec6b..93dadaa 100644 --- a/renovate.json +++ b/renovate.json @@ -3,40 +3,50 @@ "extends": [ "config:recommended" ], + "hostRules": [ + { + "matchHost": "github.com", + "token": "{{ secrets.RENOVATE_GITHUB_TOKEN }}" + } + ], "packageRules": [ - { - "matchManagers": [ - "pip_requirements" - ], - "matchFileNames": [ - "pyproject.toml", - "requirements.txt", - "requirements/*.txt" - ] - }, - { - "matchManagers": [ - "dockerfile" - ], - "matchFileNames": [ - "Dockerfile", - "**/*.dockerfile", - "**/Dockerfile.*" - ] - }, { "matchManagers": [ "github-actions" ], "matchFileNames": [ ".forgejo/workflows/*.yml", - ".forgejo/workflows/*.yaml", - ".github/workflows/*.yml", - ".github/workflows/*.yaml" + ".forgejo/workflows/*.yaml" ] + }, + { + "matchManagers": [ + "pep621" + ], + "rangeStrategy": "bump" + }, + { + "matchPackageNames": [ + "python" + ], + "matchManagers": [ + "dockerfile" + ], + "versioning": "docker" } ], + "python": { + "installToolchain": true + }, "labels": [ "dependencies" - ] + ], + "lockFileMaintenance": { + "enabled": true, + "schedule": [ + "before 5am on monday" + ] + }, + "prConcurrentLimit": 5, + "branchConcurrentLimit": 10 } \ No newline at end of file From 4d5c8e3784bd649b9a9363b15a2f7b5d9c6fba23 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 15:08:05 +0200 Subject: [PATCH 25/49] remove invalid setting from renovate config --- renovate.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/renovate.json b/renovate.json index 93dadaa..464948f 100644 --- a/renovate.json +++ b/renovate.json @@ -35,9 +35,6 @@ "versioning": "docker" } ], - "python": { - "installToolchain": true - }, "labels": [ "dependencies" ], From 880b38ce3f4d3d029e1054532b31568b7008255f Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Tue, 27 May 2025 15:11:51 +0200 Subject: [PATCH 26/49] remove wrong secret ref This becomes annoying - Claude really didn't help with Renovate --- renovate.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/renovate.json b/renovate.json index 464948f..727bdf6 100644 --- a/renovate.json +++ b/renovate.json @@ -3,12 +3,6 @@ "extends": [ "config:recommended" ], - "hostRules": [ - { - "matchHost": "github.com", - "token": "{{ secrets.RENOVATE_GITHUB_TOKEN }}" - } - ], "packageRules": [ { "matchManagers": [ From 87838a38c3906b6dfeac9593e4a0e5988d032b3f Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 27 May 2025 13:15:42 +0000 Subject: [PATCH 27/49] Update dependency django to v5.2.1 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 740bb70..7cb0977 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.12" dependencies = [ "argon2-cffi>=23.1.0", "cryptography>=44.0.2", - "django==5.2", + "django==5.2.1", "django-allauth>=65.5.0", "django-fernet-encrypted-fields>=0.3.0", "django-scopes>=2.0.0", diff --git a/uv.lock b/uv.lock index f2e6662..7feca76 100644 --- a/uv.lock +++ b/uv.lock @@ -289,16 +289,16 @@ wheels = [ [[package]] name = "django" -version = "5.2" +version = "5.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/1b/c6da718c65228eb3a7ff7ba6a32d8e80fa840ca9057490504e099e4dd1ef/Django-5.2.tar.gz", hash = "sha256:1a47f7a7a3d43ce64570d350e008d2949abe8c7e21737b351b6a1611277c6d89", size = 10824891, upload-time = "2025-04-02T13:08:06.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/10/0d546258772b8f31398e67c85e52c66ebc2b13a647193c3eef8ee433f1a8/django-5.2.1.tar.gz", hash = "sha256:57fe1f1b59462caed092c80b3dd324fd92161b620d59a9ba9181c34746c97284", size = 10818735, upload-time = "2025-05-07T14:06:17.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/e0/6a5b5ea350c5bd63fe94b05e4c146c18facb51229d9dee42aa39f9fc2214/Django-5.2-py3-none-any.whl", hash = "sha256:91ceed4e3a6db5aedced65e3c8f963118ea9ba753fc620831c77074e620e7d83", size = 8301361, upload-time = "2025-04-02T13:08:01.465Z" }, + { url = "https://files.pythonhosted.org/packages/90/92/7448697b5838b3a1c6e1d2d6a673e908d0398e84dc4f803a2ce11e7ffc0f/django-5.2.1-py3-none-any.whl", hash = "sha256:a9b680e84f9a0e71da83e399f1e922e1ab37b2173ced046b541c72e1589a5961", size = 8301833, upload-time = "2025-05-07T14:06:10.955Z" }, ] [[package]] @@ -1022,7 +1022,7 @@ dev = [ requires-dist = [ { name = "argon2-cffi", specifier = ">=23.1.0" }, { name = "cryptography", specifier = ">=44.0.2" }, - { name = "django", specifier = "==5.2" }, + { name = "django", specifier = "==5.2.1" }, { name = "django-allauth", specifier = ">=65.5.0" }, { name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" }, { name = "django-scopes", specifier = ">=2.0.0" }, From fd8c21895ecc322cb0bbd634d00e558246c7609e Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 27 May 2025 13:16:47 +0000 Subject: [PATCH 28/49] Update quay.io/appuio/oc Docker tag to v4.18 --- .forgejo/workflows/build-deploy-prod.yaml | 4 ++-- .forgejo/workflows/build-deploy-staging.yaml | 2 +- .forgejo/workflows/docs.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/build-deploy-prod.yaml b/.forgejo/workflows/build-deploy-prod.yaml index 36404fd..fa40c35 100644 --- a/.forgejo/workflows/build-deploy-prod.yaml +++ b/.forgejo/workflows/build-deploy-prod.yaml @@ -87,7 +87,7 @@ jobs: esac - name: Deploy to OpenShift - uses: docker://quay.io/appuio/oc:v4.16 + uses: docker://quay.io/appuio/oc:v4.18 with: entrypoint: /bin/bash args: | @@ -104,7 +104,7 @@ jobs: OPENSHIFT_URL: ${{ secrets.OPENSHIFT_URL }} - name: Verify deployment - uses: docker://quay.io/appuio/oc:v4.16 + uses: docker://quay.io/appuio/oc:v4.18 with: entrypoint: /bin/bash args: | diff --git a/.forgejo/workflows/build-deploy-staging.yaml b/.forgejo/workflows/build-deploy-staging.yaml index f73edae..f39866f 100644 --- a/.forgejo/workflows/build-deploy-staging.yaml +++ b/.forgejo/workflows/build-deploy-staging.yaml @@ -56,7 +56,7 @@ jobs: uses: actions/checkout@v4 - name: Deploy to OpenShift - uses: docker://quay.io/appuio/oc:v4.16 + uses: docker://quay.io/appuio/oc:v4.18 with: entrypoint: /bin/bash args: | diff --git a/.forgejo/workflows/docs.yaml b/.forgejo/workflows/docs.yaml index 418bc92..780b5b9 100644 --- a/.forgejo/workflows/docs.yaml +++ b/.forgejo/workflows/docs.yaml @@ -52,7 +52,7 @@ jobs: uses: actions/checkout@v4 - name: Deploy to OpenShift - uses: docker://quay.io/appuio/oc:v4.16 + uses: docker://quay.io/appuio/oc:v4.18 with: entrypoint: /bin/bash args: | From 891519e97b3aa81640e3152ea8923adfaa63b005 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 27 May 2025 13:16:58 +0000 Subject: [PATCH 29/49] Update dependency node to v22 --- .forgejo/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 2f9d7d5..2bb35e8 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -16,7 +16,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "18" + node-version: "22" - name: Renovate uses: https://github.com/renovatebot/github-action@v42.0.4 From 9ee826eb5075f42ffa7150f366d6a574ee95846d Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 27 May 2025 13:17:06 +0000 Subject: [PATCH 30/49] Update docker/build-push-action action to v6 --- .forgejo/workflows/build-deploy-prod.yaml | 2 +- .forgejo/workflows/build-deploy-staging.yaml | 2 +- .forgejo/workflows/docs.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/build-deploy-prod.yaml b/.forgejo/workflows/build-deploy-prod.yaml index 36404fd..d86ad4d 100644 --- a/.forgejo/workflows/build-deploy-prod.yaml +++ b/.forgejo/workflows/build-deploy-prod.yaml @@ -51,7 +51,7 @@ jobs: esac - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: true diff --git a/.forgejo/workflows/build-deploy-staging.yaml b/.forgejo/workflows/build-deploy-staging.yaml index f73edae..10868a6 100644 --- a/.forgejo/workflows/build-deploy-staging.yaml +++ b/.forgejo/workflows/build-deploy-staging.yaml @@ -35,7 +35,7 @@ jobs: password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: true diff --git a/.forgejo/workflows/docs.yaml b/.forgejo/workflows/docs.yaml index 418bc92..94c0d2c 100644 --- a/.forgejo/workflows/docs.yaml +++ b/.forgejo/workflows/docs.yaml @@ -30,7 +30,7 @@ jobs: password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: docs/Dockerfile From 79a1c4dc4509dbc3c76847b8dfe2393c645baec9 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 27 May 2025 13:17:11 +0000 Subject: [PATCH 31/49] Update https://github.com/astral-sh/setup-uv action to v6 --- .forgejo/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/tests.yaml b/.forgejo/workflows/tests.yaml index d766820..79afa42 100644 --- a/.forgejo/workflows/tests.yaml +++ b/.forgejo/workflows/tests.yaml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: https://github.com/astral-sh/setup-uv@v5 + uses: https://github.com/astral-sh/setup-uv@v6 - name: Run tests run: uv run --env-file=.env.example pytest From c3d8fd9f56ae65c558babe127825883395c67417 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 2 Jun 2025 10:31:07 +0200 Subject: [PATCH 32/49] Display fix: place services in rows --- .../frontend/organizations/services.html | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html index 3286952..b3707e4 100644 --- a/src/servala/frontend/templates/frontend/organizations/services.html +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -16,36 +16,40 @@
- {% for service in services %} -
-
- {% if service.logo %} - {{ service.name }} - {% endif %} -
-

{{ service.name }}

- {{ service.category }} +
+ {% for service in services %} +
+
+
+ {% if service.logo %} + {{ service.name }} + {% endif %} +
+

{{ service.name }}

+ {{ service.category }} +
+
+
+ {% if service.description %}

{{ service.description }}

{% endif %} +
+
-
- {% if service.description %}

{{ service.description }}

{% endif %} + {% empty %} +
+
+

{% translate "No services found." %}

+
- -
- {% empty %} -
-
-

{% translate "No services found." %}

-
-
- {% endfor %} + {% endfor %} +
{% endblock content %} From 50a7f628e4f5c503a768df77d6c3f5188c013836 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 2 Jun 2025 10:32:12 +0200 Subject: [PATCH 33/49] Display fix: Place service offerings in rows --- .../organizations/service_detail.html | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html index c3962eb..a118539 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -28,38 +28,42 @@
- {% for offering in service.offerings.all %} -
-
- {% if offering.provider.logo %} - {{ offering.provider.name }} - {% endif %} -
-

{{ offering.provider.name }}

+
+ {% for offering in service.offerings.all %} +
+
+
+ {% if offering.provider.logo %} + {{ offering.provider.name }} + {% endif %} +
+

{{ offering.provider.name }}

+
+
+
+ {% if offering.description %} +

{{ offering.description }}

+ {% elif offering.provider.description %} +

{{ offering.provider.description }}

+ {% endif %} +
+
-
- {% if offering.description %} -

{{ offering.description }}

- {% elif offering.provider.description %} -

{{ offering.provider.description }}

- {% endif %} + {% empty %} +
+
+

{% translate "No offerings found." %}

+
- -
- {% empty %} -
-
-

{% translate "No offerings found." %}

-
-
- {% endfor %} + {% endfor %} +
{% endblock content %} From cb7332f4e972c5cebf38b9ce6ff8faf11fdf58ff Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 2 Jun 2025 10:35:22 +0200 Subject: [PATCH 34/49] Fix display of search field --- src/servala/frontend/forms/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 04fe2df..28e5c8e 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -18,7 +18,7 @@ class ServiceFilterForm(forms.Form): cloud_provider = forms.ModelChoiceField( queryset=CloudProvider.objects.all(), required=False ) - q = forms.CharField(required=False) + q = forms.CharField(label=_("Search"), required=False) def filter_queryset(self, queryset): if category := self.cleaned_data.get("category"): From fa6ac5334efc42012dc14014d316e8eaee12c43e Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 2 Jun 2025 10:35:27 +0200 Subject: [PATCH 35/49] Fix search field functionality --- src/servala/frontend/forms/service.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 28e5c8e..189f9d5 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -1,4 +1,5 @@ from django import forms +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from servala.core.models import ( @@ -25,6 +26,10 @@ class ServiceFilterForm(forms.Form): queryset = queryset.filter(category=category) if cloud_provider := self.cleaned_data.get("cloud_provider"): queryset = queryset.filter(offerings__provider=cloud_provider) + if search := self.cleaned_data.get("q"): + queryset = queryset.filter( + Q(name__icontains=search) | Q(category__name__icontains=search) + ) return queryset From 12b88330d11044df7f3c6a9908342fb9c4fd6982 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 07:09:06 +0200 Subject: [PATCH 36/49] 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 1551900fa4179dbc079b5f0b505fc41c3b35ae23 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 09:14:07 +0200 Subject: [PATCH 37/49] 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 cedf9b43424e9b31fcfabe28f685547c312d9422 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 09:14:25 +0200 Subject: [PATCH 38/49] 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 046c468..654b039 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") @@ -123,6 +122,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 ed60ea5491ba3b6e8c068bf5fc00c4047ea11137 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 09:15:53 +0200 Subject: [PATCH 39/49] 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 fdaac15b476727b67eacc406b1c7122b3489643b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 09:51:29 +0200 Subject: [PATCH 40/49] 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 284d716571cb8c2abd74448f3201455b8aa84787 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 13:06:31 +0200 Subject: [PATCH 41/49] 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 611172afdb3a64cf541725e47b8a1585cd89031b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 13:07:07 +0200 Subject: [PATCH 42/49] 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 7c4dcb7df4b61c33176e0cf5fccea974d7415943 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 26 May 2025 13:07:32 +0200 Subject: [PATCH 43/49] 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] From c751f9d71063c18da625cfc3accecb5e8564fa77 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 27 May 2025 14:17:20 +0200 Subject: [PATCH 44/49] Implement template/form/view for org create --- src/servala/core/models/organization.py | 13 +++ src/servala/core/odoo.py | 1 + src/servala/frontend/forms/organization.py | 102 ++++++++++++++++++ .../templates/frontend/forms/errors.html | 18 ++++ .../templates/frontend/forms/form.html | 19 +--- .../frontend/organizations/create.html | 64 ++++++++++- src/servala/frontend/views/organization.py | 41 ++++++- 7 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 src/servala/frontend/templates/frontend/forms/errors.html diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 201a8b9..c113f78 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -124,6 +124,19 @@ class BillingEntity(ServalaModelMixin, models.Model): def __str__(self): return self.name + @classmethod + def create_from_data(cls, odoo_data): + instance = BillingEntity.objects.create(name=odoo_data.get("name")) + # TODO implement odoo creation from data + return instance + + @classmethod + def create_from_id(cls, odoo_id): + # TODO implement odoo creation from ID + # instance = BillingEntity.objects.create(name=odoo_data.get("name")) + # return instance + pass + class OrganizationOrigin(ServalaModelMixin, models.Model): """ diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py index 81a24bc..85f0b35 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -97,6 +97,7 @@ def get_invoice_addresses(user): # addresses / organizations created by the user # - if the user is associated with an odoo contact, return all billing # addresses with the same parent_id + email = user.email or_conditions = [("email", "ilike", email)] email = user if isinstance(user, str) else user.email diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 41cc26c..115e574 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,6 +1,9 @@ +from django import forms from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ from servala.core.models import Organization +from servala.core.odoo import get_invoice_addresses from servala.frontend.forms.mixins import HtmxMixin @@ -8,3 +11,102 @@ class OrganizationForm(HtmxMixin, ModelForm): class Meta: model = Organization fields = ("name",) + + +class OrganizationCreateForm(OrganizationForm): + billing_processing_choice = forms.ChoiceField( + choices=[ + ("existing", _("Use an existing billing address")), + ("new", _("Create a new billing address")), + ], + widget=forms.RadioSelect, + label=_("Billing Address"), + initial="new", # Will change to 'existing' if options are found + ) + existing_odoo_address_id = forms.ChoiceField( + label=_("Existing Billing Address"), + required=False, + ) + + # Fields for creating a new billing address in Odoo, prefixed with 'ba_' + ba_name = forms.CharField( + label=_("Contact Person / Company Name"), required=False, max_length=100 + ) + ba_street = forms.CharField(label=_("Street"), required=False, max_length=100) + ba_street2 = forms.CharField( + label=_("Street 2 (Optional)"), required=False, max_length=100 + ) + ba_city = forms.CharField(label=_("City"), required=False, max_length=100) + ba_zip = forms.CharField(label=_("ZIP Code"), required=False, max_length=20) + # For state & country, Odoo uses structured data. For now, text input. + # These will need mapping logic when actual Odoo creation is implemented. + ba_state_name = forms.CharField( + label=_("State / Province"), required=False, max_length=100 + ) + ba_country_name = forms.CharField( + label=_("Country"), required=False, max_length=100 + ) + ba_email = forms.EmailField(label=_("Billing Email"), required=False) + ba_phone = forms.CharField(label=_("Billing Phone"), required=False, max_length=30) + ba_vat = forms.CharField(label=_("VAT ID"), required=False, max_length=50) + + class Meta(OrganizationForm.Meta): + pass + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.odoo_addresses = get_invoice_addresses(self.user) + + if self.odoo_addresses: + address_choices = [("", _("---------"))] + for addr in self.odoo_addresses: + display_parts = [ + addr.get("name"), + addr.get("street"), + addr.get("city"), + addr.get("zip"), + ] + display_name = ", ".join(filter(None, display_parts)) + address_choices.append((str(addr["id"]), display_name)) + + self.fields["existing_odoo_address_id"].choices = address_choices + if not self.is_bound and "billing_processing_choice" not in self.initial: + self.fields["billing_processing_choice"].initial = "existing" + else: + # No existing Odoo addresses. Force 'new' choice. + self.fields["billing_processing_choice"].choices = [ + ("new", _("Create a new billing address")), + ] + self.fields["billing_processing_choice"].initial = "new" + self.fields["billing_processing_choice"].widget = forms.HiddenInput() + self.fields["existing_odoo_address_id"].widget = forms.HiddenInput() + + def clean(self): + cleaned_data = super().clean() + choice = cleaned_data.get("billing_processing_choice") + if choice == "new": + required_fields = [ + "ba_name", + "ba_street", + "ba_city", + "ba_zip", + "ba_state_name", + "ba_country_name", + "ba_email", + ] + for field_name in required_fields: + if not cleaned_data.get(field_name): + self.add_error( + field_name, + _( + "This field is required when creating a new billing address." + ), + ) + else: + existing_id_str = cleaned_data.get("existing_odoo_address_id") + if not existing_id_str: + self.add_error( + "existing_odoo_address_id", _("Please select an existing address.") + ) + return cleaned_data diff --git a/src/servala/frontend/templates/frontend/forms/errors.html b/src/servala/frontend/templates/frontend/forms/errors.html new file mode 100644 index 0000000..964687d --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/errors.html @@ -0,0 +1,18 @@ +{% load i18n %} +{% if form.non_field_errors or form.errors %} + +{% endif %} diff --git a/src/servala/frontend/templates/frontend/forms/form.html b/src/servala/frontend/templates/frontend/forms/form.html index 8ab90d1..361b161 100644 --- a/src/servala/frontend/templates/frontend/forms/form.html +++ b/src/servala/frontend/templates/frontend/forms/form.html @@ -1,21 +1,4 @@ -{% load i18n %} -{% if form.non_field_errors or form.errors %} - -{% endif %} +{% include "frontend/forms/errors.html" %}
{% for field, errors in fields %}{{ field.as_field_group }}{% endfor %} diff --git a/src/servala/frontend/templates/frontend/organizations/create.html b/src/servala/frontend/templates/frontend/organizations/create.html index 2d7cfa4..fc0fe58 100644 --- a/src/servala/frontend/templates/frontend/organizations/create.html +++ b/src/servala/frontend/templates/frontend/organizations/create.html @@ -6,5 +6,67 @@ {% endblock page_title %} {% endblock html_title %} {% block card_content %} - {% include "includes/form.html" %} +
+ {% include "frontend/forms/errors.html" %} + {% csrf_token %} +
+
+ {{ form.name.as_field_group }} +
+

{% translate "Billing Information" %}

+ {{ form.billing_processing_choice.as_field_group }} +
{{ form.existing_odoo_address_id.as_field_group }}
+
+ {{ form.ba_name.as_field_group }} + {{ form.ba_street.as_field_group }} + {{ form.ba_street2.as_field_group }} + {{ form.ba_city.as_field_group }} + {{ form.ba_zip.as_field_group }} + {{ form.ba_state_name.as_field_group }} + {{ form.ba_country_name.as_field_group }} + {{ form.ba_email.as_field_group }} + {{ form.ba_phone.as_field_group }} + {{ form.ba_vat.as_field_group }} +
+
+ +
+
+
+
+ {% endblock card_content %} diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 7bfdee5..fe0bbb2 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,18 +1,53 @@ from django.shortcuts import redirect +from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, DetailView from rules.contrib.views import AutoPermissionRequiredMixin -from servala.core.models import Organization -from servala.frontend.forms import OrganizationForm +from servala.core.models import BillingEntity, Organization +from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): - form_class = OrganizationForm + form_class = OrganizationCreateForm model = Organization template_name = "frontend/organizations/create.html" + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + def form_valid(self, form): + billing_choice = form.cleaned_data.get("billing_processing_choice") + billing_entity = None + + if billing_choice == "new": + billing_entity = BillingEntity.create_from_data( + { + key[3:]: value + for key, value in form.cleaned_data.items() + if key.startswith("ba_") + } + ) + elif odoo_id := form.cleaned_data.get("existing_odoo_address_id"): + billing_entity = BillingEntity.objects.filter( + odoo_invoice_id=odoo_id + ).first() + + if not billing_entity: + billing_entity = BillingEntity.create_from_id(odoo_id) + + if not billing_entity: + form.add_error( + None, + _( + "Could not determine or create the billing entity. Please check your input." + ), + ) + return self.form_invalid(form) + + form.instance.billing_entity = billing_entity instance = form.instance.create_organization( form.instance, owner=self.request.user ) From 4c0cd2a4fe5b70b0efebbee63214b946a230b8fd Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 3 Jun 2025 10:40:10 +0200 Subject: [PATCH 45/49] Retrieve country list from odoo --- src/servala/core/odoo.py | 27 ++++++++++++++ src/servala/frontend/forms/organization.py | 43 ++++++++++++---------- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py index 85f0b35..22d45ec 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -87,6 +87,33 @@ class OdooClient: CLIENT = OdooClient() +# Odoo countries do not change, so they are fetched once per process +COUNTRIES = [] + + +def get_odoo_countries(): + global COUNTRIES + if COUNTRIES: + return COUNTRIES + + try: + odoo_countries_data = CLIENT.search_read( + model="res.country", domain=[], fields=["id", "name"] + ) + # Format as Django choices: [(value, label), ...] + COUNTRIES = [ + (country["id"], country["name"]) for country in odoo_countries_data + ] + # Sort by country name for better UX in dropdowns + COUNTRIES.sort(key=lambda x: x[1]) + except Exception as e: + # Log the error or handle it as appropriate for your application + # For now, return an empty list or a default if Odoo is unavailable + print(f"Error fetching Odoo countries: {e}") + return [("", "Error fetching countries")] # Or just [] + + return COUNTRIES + def get_invoice_addresses(user): """Used during organization creation: retrieves all invoice diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 115e574..d37ce91 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -3,7 +3,7 @@ from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from servala.core.models import Organization -from servala.core.odoo import get_invoice_addresses +from servala.core.odoo import get_invoice_addresses, get_odoo_countries from servala.frontend.forms.mixins import HtmxMixin @@ -28,33 +28,36 @@ class OrganizationCreateForm(OrganizationForm): required=False, ) - # Fields for creating a new billing address in Odoo, prefixed with 'ba_' - ba_name = forms.CharField( - label=_("Contact Person / Company Name"), required=False, max_length=100 + # Fields for creating a new billing address in Odoo, prefixed with 'invoice_' + invoice_street = forms.CharField(label=_("Line 1"), required=False, max_length=100) + invoice_street2 = forms.CharField(label=_("Line 2"), required=False, max_length=100) + invoice_city = forms.CharField(label=_("City"), required=False, max_length=100) + invoice_zip = forms.CharField(label=_("Postal Code"), required=False, max_length=20) + invoice_country = forms.ChoiceField( + label=_("Country"), + required=False, + choices=get_odoo_countries(), ) - ba_street = forms.CharField(label=_("Street"), required=False, max_length=100) - ba_street2 = forms.CharField( - label=_("Street 2 (Optional)"), required=False, max_length=100 + invoice_email = forms.EmailField(label=_("Billing Email"), required=False) + invoice_phone = forms.CharField( + label=_("Billing Phone"), required=False, max_length=30 ) - ba_city = forms.CharField(label=_("City"), required=False, max_length=100) - ba_zip = forms.CharField(label=_("ZIP Code"), required=False, max_length=20) - # For state & country, Odoo uses structured data. For now, text input. - # These will need mapping logic when actual Odoo creation is implemented. - ba_state_name = forms.CharField( - label=_("State / Province"), required=False, max_length=100 - ) - ba_country_name = forms.CharField( - label=_("Country"), required=False, max_length=100 - ) - ba_email = forms.EmailField(label=_("Billing Email"), required=False) - ba_phone = forms.CharField(label=_("Billing Phone"), required=False, max_length=30) - ba_vat = forms.CharField(label=_("VAT ID"), required=False, max_length=50) + invoice_vat = forms.CharField(label=_("VAT ID"), required=False, max_length=50) class Meta(OrganizationForm.Meta): pass def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) + + if not self.initial.get("invoice_country"): + default_country_name = "Switzerland" + country_choices = self.fields["invoice_country"].choices + for country_id, country_name_label in country_choices: + if country_name_label == default_country_name: + self.initial["invoice_country"] = country_id + break + self.user = user self.odoo_addresses = get_invoice_addresses(self.user) From 87439c62d07546dd8c00b57780151d89bfa554d5 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 3 Jun 2025 10:42:12 +0200 Subject: [PATCH 46/49] Rename form fields --- src/servala/frontend/forms/organization.py | 21 +++++++------------ .../frontend/organizations/create.html | 20 +++++++++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index d37ce91..a0b3800 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -90,26 +90,19 @@ class OrganizationCreateForm(OrganizationForm): choice = cleaned_data.get("billing_processing_choice") if choice == "new": required_fields = [ - "ba_name", - "ba_street", - "ba_city", - "ba_zip", - "ba_state_name", - "ba_country_name", - "ba_email", + "invoice_street", + "invoice_city", + "invoice_zip", + "invoice_country", + "invoice_email", ] for field_name in required_fields: if not cleaned_data.get(field_name): - self.add_error( - field_name, - _( - "This field is required when creating a new billing address." - ), - ) + self.add_error(field_name, _("This field is required.")) else: existing_id_str = cleaned_data.get("existing_odoo_address_id") if not existing_id_str: self.add_error( - "existing_odoo_address_id", _("Please select an existing address.") + "existing_odoo_address_id", _("Please select an invoice address.") ) return cleaned_data diff --git a/src/servala/frontend/templates/frontend/organizations/create.html b/src/servala/frontend/templates/frontend/organizations/create.html index fc0fe58..29ac934 100644 --- a/src/servala/frontend/templates/frontend/organizations/create.html +++ b/src/servala/frontend/templates/frontend/organizations/create.html @@ -17,16 +17,16 @@ {{ form.billing_processing_choice.as_field_group }}
{{ form.existing_odoo_address_id.as_field_group }}
- {{ form.ba_name.as_field_group }} - {{ form.ba_street.as_field_group }} - {{ form.ba_street2.as_field_group }} - {{ form.ba_city.as_field_group }} - {{ form.ba_zip.as_field_group }} - {{ form.ba_state_name.as_field_group }} - {{ form.ba_country_name.as_field_group }} - {{ form.ba_email.as_field_group }} - {{ form.ba_phone.as_field_group }} - {{ form.ba_vat.as_field_group }} + {{ form.invoice_name.as_field_group }} + {{ form.invoice_street.as_field_group }} + {{ form.invoice_street2.as_field_group }} + {{ form.invoice_city.as_field_group }} + {{ form.invoice_zip.as_field_group }} + {{ form.invoice_state_name.as_field_group }} + {{ form.invoice_country.as_field_group }} + {{ form.invoice_email.as_field_group }} + {{ form.invoice_phone.as_field_group }} + {{ form.invoice_vat.as_field_group }}
From 81932c4da05f9d725f8d3b3042d8853ffda95dff Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 3 Jun 2025 11:04:53 +0200 Subject: [PATCH 47/49] Improve look of invoice address form --- .../frontend/organizations/create.html | 96 ++++++++++++++----- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/create.html b/src/servala/frontend/templates/frontend/organizations/create.html index 29ac934..4fcdc7e 100644 --- a/src/servala/frontend/templates/frontend/organizations/create.html +++ b/src/servala/frontend/templates/frontend/organizations/create.html @@ -5,31 +5,75 @@ {% translate "Create a new organization" %} {% endblock page_title %} {% endblock html_title %} -{% block card_content %} -
- {% include "frontend/forms/errors.html" %} - {% csrf_token %} -
-
- {{ form.name.as_field_group }} -
-

{% translate "Billing Information" %}

- {{ form.billing_processing_choice.as_field_group }} -
{{ form.existing_odoo_address_id.as_field_group }}
-
- {{ form.invoice_name.as_field_group }} - {{ form.invoice_street.as_field_group }} - {{ form.invoice_street2.as_field_group }} - {{ form.invoice_city.as_field_group }} - {{ form.invoice_zip.as_field_group }} - {{ form.invoice_state_name.as_field_group }} - {{ form.invoice_country.as_field_group }} - {{ form.invoice_email.as_field_group }} - {{ form.invoice_phone.as_field_group }} - {{ form.invoice_vat.as_field_group }} +{% block content %} +
+ +
+
+
+
+ {% include "frontend/forms/errors.html" %} + {% csrf_token %} + {{ form.name.as_field_group }} +
+
-
- +
+ {% if form.existing_odoo_address_id and form.existing_odoo_address_id.choices %} +
+
+
+

{% translate "Billing Information" %}

+
+
+
+ {{ form.billing_processing_choice.as_field_group }} +
{{ form.existing_odoo_address_id.as_field_group }}
+
+
+
+
+ {% endif %} +
+
+
+
+

{% translate "Invoice Address" %}

+
+
+
+ {{ form.invoice_vat.as_field_group }} +
+ {{ form.invoice_street.as_field_group }} + {{ form.invoice_street2.as_field_group }} +
{{ form.invoice_zip.as_field_group }}
+
{{ form.invoice_city.as_field_group }}
+ {{ form.invoice_country.as_field_group }} +
+
+
+
+
+
+
+

{% translate "Invoice Contact" %}

+
+
+
+ {{ form.invoice_email.as_field_group }} + {{ form.invoice_phone.as_field_group }} +
+
+
+
+
+
+
+
+
+
+ +
@@ -65,8 +109,8 @@ } else { // No existing addresses found, a new address has to be entered. if (existingSection) existingSection.style.display = 'none' - newSection.style.display = '' + if (newSection) newSection.style.display = '' // Ensure newSection is not null } }); -{% endblock card_content %} +{% endblock content %} From e646bae158404fccfd915a3fa050dcbaa9dc02bd Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 3 Jun 2025 11:19:23 +0200 Subject: [PATCH 48/49] Implement billing address creation via odoo --- src/servala/core/models/organization.py | 63 ++++++++++++++++++++-- src/servala/frontend/forms/organization.py | 13 ++--- src/servala/frontend/views/organization.py | 9 ++-- 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index c113f78..fe22623 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,7 +1,7 @@ import rules import urlman from django.conf import settings -from django.db import models +from django.db import models, transaction from django.utils.functional import cached_property from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -9,6 +9,7 @@ from django_scopes import ScopedManager, scopes_disabled from servala.core import rules as perms from servala.core.models.mixins import ServalaModelMixin +from servala.core.odoo import CLIENT class Organization(ServalaModelMixin, models.Model): @@ -125,9 +126,63 @@ class BillingEntity(ServalaModelMixin, models.Model): return self.name @classmethod - def create_from_data(cls, odoo_data): - instance = BillingEntity.objects.create(name=odoo_data.get("name")) - # TODO implement odoo creation from data + @transaction.atomic + def create_from_data(cls, name, odoo_data): + """ + Creates a BillingEntity and corresponding Odoo records. + + This method creates a `res.partner` record in Odoo with `company_type='company'` + for the main company, and another `res.partner` record with `company_type='person'` + and `type='invoice'` (linked via `parent_id` to the first record) for the + invoice address. The IDs of these Odoo records are stored in the BillingEntity. + + Args: + odoo_data (dict): A dictionary containing the data for creating + the BillingEntity and Odoo records. + + Expected keys in `odoo_data`: + - `invoice_street` (str): Street for the invoice address. + - `invoice_street2` (str): Second line of street address for the invoice address. + - `invoice_city` (str): City for the invoice address. + - `invoice_zip` (str): ZIP/Postal code for the invoice address. + - `invoice_country` (int): Odoo `res.country` ID for the invoice address country. + - `invoice_email` (str): Email address for the invoice contact. + - `invoice_phone` (str): Phone number for the invoice contact. + """ + instance = cls.objects.create(name=name) + company_payload = { + "name": odoo_data.get("company_name", name), + "company_type": "company", + } + if vat := odoo_data.get("invoice_vat"): + company_payload["vat"] = vat + company_id = CLIENT.execute("res.partner", "create", [company_payload]) + instance.odoo_company_id = company_id + + invoice_address_payload = { + "name": name, + "company_type": "person", + "type": "invoice", + "parent_id": company_id, + } + invoice_optional_fields = { + "street": odoo_data.get("invoice_street"), + "street2": odoo_data.get("invoice_street2"), + "city": odoo_data.get("invoice_city"), + "zip": odoo_data.get("invoice_zip"), + "country_id": odoo_data.get("invoice_country"), + "email": odoo_data.get("invoice_email"), + } + invoice_address_payload.update( + {k: v for k, v in invoice_optional_fields.items() if v is not None} + ) + + invoice_address_id = CLIENT.execute( + "res.partner", "create", [invoice_address_payload] + ) + instance.odoo_invoice_id = invoice_address_id + + instance.save(update_fields=["odoo_company_id", "odoo_invoice_id"]) return instance @classmethod diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index a0b3800..b08c2f7 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -39,9 +39,7 @@ class OrganizationCreateForm(OrganizationForm): choices=get_odoo_countries(), ) invoice_email = forms.EmailField(label=_("Billing Email"), required=False) - invoice_phone = forms.CharField( - label=_("Billing Phone"), required=False, max_length=30 - ) + invoice_phone = forms.CharField(label=_("Phone"), required=False, max_length=30) invoice_vat = forms.CharField(label=_("VAT ID"), required=False, max_length=50) class Meta(OrganizationForm.Meta): @@ -77,18 +75,13 @@ class OrganizationCreateForm(OrganizationForm): if not self.is_bound and "billing_processing_choice" not in self.initial: self.fields["billing_processing_choice"].initial = "existing" else: - # No existing Odoo addresses. Force 'new' choice. - self.fields["billing_processing_choice"].choices = [ - ("new", _("Create a new billing address")), - ] - self.fields["billing_processing_choice"].initial = "new" - self.fields["billing_processing_choice"].widget = forms.HiddenInput() + self.fields.pop("billing_processing_choice") self.fields["existing_odoo_address_id"].widget = forms.HiddenInput() def clean(self): cleaned_data = super().clean() choice = cleaned_data.get("billing_processing_choice") - if choice == "new": + if not choice or choice == "new": required_fields = [ "invoice_street", "invoice_city", diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index fe0bbb2..834dede 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -22,13 +22,14 @@ class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): billing_choice = form.cleaned_data.get("billing_processing_choice") billing_entity = None - if billing_choice == "new": + if not billing_choice or billing_choice == "new": billing_entity = BillingEntity.create_from_data( + form.cleaned_data["name"], { - key[3:]: value + key: value for key, value in form.cleaned_data.items() - if key.startswith("ba_") - } + if key.startswith("invoice_") + }, ) elif odoo_id := form.cleaned_data.get("existing_odoo_address_id"): billing_entity = BillingEntity.objects.filter( From 4b3ddec4bb29a0e3eaf9349debb1f1c8d8b1f79a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 3 Jun 2025 11:33:32 +0200 Subject: [PATCH 49/49] Show invoice address on organization detail page --- src/servala/core/models/organization.py | 43 ++++++ .../frontend/organizations/update.html | 125 ++++++++++++++---- 2 files changed, 145 insertions(+), 23 deletions(-) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index fe22623..2a90464 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -192,6 +192,49 @@ class BillingEntity(ServalaModelMixin, models.Model): # return instance pass + @cached_property + def odoo_data(self): + data = { + "company": None, + "invoice_address": None, + } + + company_fields = ["name", "company_type", "vat"] + invoice_address_fields = [ + "name", + "company_type", + "type", + "parent_id", + "street", + "street2", + "city", + "zip", + "country_id", + "email", + ] + + if self.odoo_company_id: + company_records = CLIENT.search_read( + model="res.partner", + domain=[["id", "=", self.odoo_company_id]], + fields=company_fields, + limit=1, + ) + if company_records: + data["company"] = company_records[0] + + if self.odoo_invoice_id: + invoice_address_records = CLIENT.search_read( + model="res.partner", + domain=[["id", "=", self.odoo_invoice_id]], + fields=invoice_address_fields, + limit=1, + ) + if invoice_address_records: + data["invoice_address"] = invoice_address_records[0] + + return data + class OrganizationOrigin(ServalaModelMixin, models.Model): """ diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 0d55f22..384d5a5 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -36,26 +36,105 @@ {% endpartialdef org-name-edit %} -{% block card_content %} -
- - - - - {% partial org-name %} - - - - - - -
- {% translate "Name" %} -
- {% translate "Namespace" %} - -
{{ form.instance.namespace }}
- {% translate "System-generated namespace for Kubernetes resources." %} -
-
-{% endblock card_content %} +{% block content %} +
+
+
+
+
+ + + + + {% partial org-name %} + + + + + + +
+ {% translate "Name" %} +
+ {% translate "Namespace" %} + +
{{ form.instance.namespace }}
+ {% translate "System-generated namespace for Kubernetes resources." %} +
+
+
+
+
+ {% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %} +
+
+

{% translate "Billing Address" %}

+
+
+
+ {% with odoo_data=form.instance.billing_entity.odoo_data %} +
+ + + {% if odoo_data.invoice_address %} + + + + + + + + + + {% if odoo_data.invoice_address.street2 %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + {% endif %} + +
+ {% translate "Invoice Contact Name" %} + {{ odoo_data.invoice_address.name|default:"" }}
+ {% translate "Street" %} + {{ odoo_data.invoice_address.street|default:"" }}
+ {% translate "Street 2" %} + {{ odoo_data.invoice_address.street2 }}
+ {% translate "City" %} + {{ odoo_data.invoice_address.city|default:"" }}
+ {% translate "ZIP Code" %} + {{ odoo_data.invoice_address.zip|default:"" }}
+ {% translate "Country" %} + {{ odoo_data.invoice_address.country_id.1|default:"" }}
+ {% translate "VAT ID" %} + {{ odoo_data.company.vat|default:"" }}
+ {% translate "Invoice Email" %} + {{ odoo_data.invoice_address.email|default:"" }}
+
+ {% endwith %} +
+
+
+ {% endif %} +
+{% endblock content %}