diff --git a/.env.example b/.env.example index 58eb7b3..aa287ce 100644 --- a/.env.example +++ b/.env.example @@ -60,4 +60,9 @@ SERVALA_KEYCLOAK_SERVER_URL='' # SERVALA_S3_SIGNATURE_VERSION='s3v4' # Configuration for Sentry error reporting -SERVALA_SENTRY_DSN='' \ No newline at end of file +SERVALA_SENTRY_DSN='' + +SERVALA_ODOO_DB='' +SERVALA_ODOO_URL='' +SERVALA_ODOO_USERNAME='' +SERVALA_ODOO_PASSWORD='' 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/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 447852e..cff0b83 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -4,6 +4,7 @@ * xref:web-portal.adoc[] ** xref:web-portal-admin.adoc[Admin] ** xref:web-portal-controlplanes.adoc[Control-Planes] +** xref:web-portal-billingentity.adoc[Billing Entities] * xref:web-portal-planning.adoc[] ** xref:user-stories.adoc[] diff --git a/docs/modules/ROOT/pages/web-portal-billingentity.adoc b/docs/modules/ROOT/pages/web-portal-billingentity.adoc new file mode 100644 index 0000000..cd7b662 --- /dev/null +++ b/docs/modules/ROOT/pages/web-portal-billingentity.adoc @@ -0,0 +1,26 @@ += Web Portal Billing Entities + +Billing entities are used to connect an invoice address in Odoo to an organization in Servala. + +When creating a new organization, the billing information is required to be added. + +== Existing Billing Address + +With the email address of the currently logged-in user, Odoo is searched for existing `res.partner` records and presented in the dropdown. + +Search is done this way: + +* `res.partner` records created by a matching Odoo user. +* User email matches an invoice address or contact address + +== New Billing Address + +When choosing to add a new billing address, two new records are created in the Odoo `res.partner` model: + +* A record with the field `company_type = company` +* A record with the following field configuration: +** `company_type = person` +** `type = invoice` +** `parent_id = company_id` + +The resulting database IDs are stored in the Servala portal database for referencing the records in Odoo. 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", + ) + ) 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/migrations/0005_organization_sale_order_fields.py b/src/servala/core/migrations/0005_organization_sale_order_fields.py new file mode 100644 index 0000000..d5f6b4d --- /dev/null +++ b/src/servala/core/migrations/0005_organization_sale_order_fields.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.1 on 2025-06-15 16:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0004_billingentity_odoo_fields"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="odoo_sale_order_id", + field=models.IntegerField( + blank=True, null=True, verbose_name="Odoo Sale Order ID" + ), + ), + migrations.AddField( + model_name="organization", + name="odoo_sale_order_name", + field=models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Odoo Sale Order Name", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 7ebda11..1662dfa 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): @@ -45,6 +46,13 @@ class Organization(ServalaModelMixin, models.Model): verbose_name=_("Members"), ) + odoo_sale_order_id = models.IntegerField( + null=True, blank=True, verbose_name=_("Odoo Sale Order ID") + ) + odoo_sale_order_name = models.CharField( + max_length=100, null=True, blank=True, verbose_name=_("Odoo Sale Order Name") + ) + class urls(urlman.Urls): base = "/org/{self.slug}/" details = "{base}details/" @@ -66,6 +74,7 @@ class Organization(ServalaModelMixin, models.Model): ) @classmethod + @transaction.atomic def create_organization(cls, instance, owner): try: instance.origin @@ -75,6 +84,32 @@ class Organization(ServalaModelMixin, models.Model): ) instance.save() instance.set_owner(owner) + + if ( + instance.billing_entity.odoo_company_id + and instance.billing_entity.odoo_invoice_id + ): + payload = { + "partner_id": instance.billing_entity.odoo_company_id, + "partner_invoice_id": instance.billing_entity.odoo_invoice_id, + "state": "sale", + "client_order_ref": f"Servala (Organization: {instance.name})", + "internal_note": "auto-generated by Servala Portal", + } + sale_order_id = CLIENT.execute("sale.order", "create", [payload]) + + sale_order_data = CLIENT.search_read( + model="sale.order", + domain=[["id", "=", sale_order_id]], + fields=["name"], + limit=1, + ) + + instance.odoo_sale_order_id = sale_order_id + if sale_order_data: + instance.odoo_sale_order_name = sale_order_data[0]["name"] + instance.save(update_fields=["odoo_sale_order_id", "odoo_sale_order_name"]) + return instance class Meta: @@ -110,6 +145,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=invoice, 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") @@ -117,6 +159,131 @@ class BillingEntity(ServalaModelMixin, models.Model): def __str__(self): return self.name + @classmethod + @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", + } + 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 + @transaction.atomic + def create_from_id(cls, name, odoo_id): + parent_data = CLIENT.search_read( + model="res.partner", + domain=[["id", "=", odoo_id]], + fields=["parent_id"], + limit=1, + ) + # Data validation: If the data is not as expected, we just return None, + # rather than raising an exception, for now. + if not parent_data: + return + if not (parent_info := parent_data[0].get("parent_id")): + return + if not isinstance(parent_info, (list, tuple)) or not len(parent_info) > 0: + # parent_info is a tuple of the parent’s ID and name + return + + instance = cls.objects.create( + name=name, odoo_invoice_id=odoo_id, odoo_company_id=parent_info[0] + ) + return instance + + @cached_property + def odoo_data(self): + data = { + "company": None, + "invoice_address": None, + } + + company_fields = ["name", "company_type"] + 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/core/models/service.py b/src/servala/core/models/service.py index ba57c9c..6eb912e 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -210,15 +210,35 @@ class ControlPlane(ServalaModelMixin, models.Model): except Exception as e: return False, _("Connection error: {}").format(str(e)) - def get_or_create_namespace(self, name): + def get_or_create_namespace(self, organization): api_instance = kubernetes.client.CoreV1Api(self.get_kubernetes_client()) + name = organization.namespace try: api_instance.read_namespace(name=name) except kubernetes.client.ApiException as e: if e.status == 404: - # Namespace does not exist, create it + labels = { + "servala.com/organization_id": str(organization.id), + } + annotations = { + "servala.com/organization": organization.name, + "servala.com/origin": organization.origin.name, + "servala.com/billing": organization.billing_entity.name, + } + + for field in ("company_id", "invoice_id"): + if value := getattr(organization.billing_entity, f"odoo_{field}"): + labels[f"servala.com/erp_{field}"] = str(value) + + if organization.odoo_sale_order_id: + labels["servala.com/erp_sale_order_id"] = str( + organization.odoo_sale_order_id + ) + body = kubernetes.client.V1Namespace( - metadata=kubernetes.client.V1ObjectMeta(name=name) + metadata=kubernetes.client.V1ObjectMeta( + name=name, labels=labels, annotations=annotations + ) ) api_instance.create_namespace(body=body) else: @@ -551,7 +571,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): @classmethod def create_instance(cls, name, organization, context, created_by, spec_data): # Ensure the namespace exists - context.control_plane.get_or_create_namespace(organization.namespace) + context.control_plane.get_or_create_namespace(organization) try: instance = cls.objects.create( name=name, 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] diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py new file mode 100644 index 0000000..3d98e18 --- /dev/null +++ b/src/servala/core/odoo.py @@ -0,0 +1,209 @@ +import xmlrpc.client + +from django.conf import settings +from django_scopes import scopes_disabled + +ADDRESS_FIELDS = [ + "id", + "name", + "street", + "street2", + "city", + "zip", + "state_id", + "country_id", + "email", + "phone", + "vat", + "company_type", + "type", + "parent_id", +] + + +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"] + + self.common_proxy = None + self.models_proxy = None + self.uid = None + + 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, {} + ) + + if not self.uid: + raise Exception("Authentication failed with Odoo: No UID returned.") + + self.models_proxy = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object") + + 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 + + def execute(self, model, method, args_list, **kwargs): + if not self.uid or not self.models_proxy: + self._connect() + + 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: + 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() + +# 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_odoo_access_conditions(user): + # We’re building our conditions in order: + # - in exceptions, users may be using a billing account’s email + # - if the user is an admin or owner of a Servala organization + # - 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 + from servala.core.models.organization import ( + OrganizationMembership, + OrganizationRole, + ) + + email = user.email + or_conditions = [("email", "ilike", email)] + + odoo_users = CLIENT.search_read( + model="res.users", + domain=[("login", "=", email), ("active", "=", True)], + fields=["id", "share"], + limit=1, + ) + + if odoo_users: + odoo_user = odoo_users[0] + if odoo_user.get("share") is False: + # An Odoo internal user (share=False) should see all invoice addresses, + # so we short-circuit the entire search logic here + return [] + elif uid := odoo_user.get("id"): + # For portal users or users not in Odoo, apply standard filters. + 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])) + + with scopes_disabled(): + servala_invoice_ids = list( + OrganizationMembership.objects.filter( + user=user, role__in=[OrganizationRole.ADMIN, OrganizationRole.OWNER] + ) + .values_list("organization__billing_entity__odoo_invoice_id", flat=True) + .distinct() + ) + servala_invoice_ids = [pk for pk in servala_invoice_ids if pk] + if servala_invoice_ids: + or_conditions.append(("id", "in", servala_invoice_ids)) + + if len(or_conditions) > 1: + or_conditions = ["|"] * (len(or_conditions) - 1) + or_conditions + return or_conditions + + +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 = get_odoo_access_conditions(user) + domain = [ + ("company_type", "=", "person"), + ("type", "=", "invoice"), + ] + or_conditions + + try: + invoice_addresses = CLIENT.search_read( + model="res.partner", + domain=domain, + fields=ADDRESS_FIELDS, + ) + if invoice_addresses: + invoice_addresses.sort( + key=lambda addr: ( + addr["parent_id"][1] if addr.get("parent_id") else "", + addr["name"], + addr["id"], + ) + ) + return invoice_addresses or [] + except Exception: + return [] diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 41cc26c..915ad7b 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, get_odoo_countries from servala.frontend.forms.mixins import HtmxMixin @@ -8,3 +11,100 @@ 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 '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(), + ) + invoice_email = forms.EmailField(label=_("Billing Email"), required=False) + invoice_phone = forms.CharField(label=_("Phone"), required=False, max_length=30) + + 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) + + if self.odoo_addresses: + address_choices = [("", _("---------"))] + for addr in self.odoo_addresses: + parent_name = None + parent_info = addr.get("parent_id") + if ( + parent_info + and isinstance(parent_info, (list, tuple)) + and len(parent_info) > 1 + ): + parent_name = parent_info[1] + + display_parts = [ + parent_name, + addr.get("name"), + addr.get("street"), + addr.get("city"), + addr.get("zip"), + ] + display_name = ", ".join([part for part in display_parts if part]) + 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: + 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 not choice or choice == "new": + required_fields = [ + "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.")) + 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 invoice address.") + ) + return cleaned_data diff --git a/src/servala/frontend/forms/renderers.py b/src/servala/frontend/forms/renderers.py index b6a9995..eb50961 100644 --- a/src/servala/frontend/forms/renderers.py +++ b/src/servala/frontend/forms/renderers.py @@ -20,6 +20,8 @@ class VerticalFormRenderer(TemplatesSetting): def get_class_names(self, field): input_type = self.get_field_input_type(field) errors = "is-invalid " if field.errors else "" + if input_type == "radio": + return f"{errors}form-check-input" if input_type == "checkbox": return f"{errors}form-check-input" return f"{errors}form-control" diff --git a/src/servala/frontend/templates/django/forms/widgets/input_option.html b/src/servala/frontend/templates/django/forms/widgets/input_option.html new file mode 100644 index 0000000..9a7654d --- /dev/null +++ b/src/servala/frontend/templates/django/forms/widgets/input_option.html @@ -0,0 +1,6 @@ +{% include "django/forms/widgets/input.html" %} +{% if widget.wrap_label %} + +{% endif %} diff --git a/src/servala/frontend/templates/django/forms/widgets/radio.html b/src/servala/frontend/templates/django/forms/widgets/radio.html new file mode 100644 index 0000000..1f363b9 --- /dev/null +++ b/src/servala/frontend/templates/django/forms/widgets/radio.html @@ -0,0 +1,15 @@ +{# Change compared to Django: only render widget.attrs.class in actual option widget, not in wrapper #} +{% with id=widget.attrs.id %} +
- {% translate "Name" %} - | - {% partial org-name %} -|
---|---|
- {% translate "Namespace" %} - | -
- {{ form.instance.namespace }}
- {% translate "System-generated namespace for Kubernetes resources." %}
- |
-
+ {% translate "Name" %} + | + {% partial org-name %} +|
---|---|
+ {% translate "Namespace" %} + | +
+ {{ form.instance.namespace }}
+ {% translate "System-generated namespace for Kubernetes resources." %}
+ |
+
+ {% 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 "Invoice Email" %} + | +{{ odoo_data.invoice_address.email|default:"" }} | + + {% endif %} + +