diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 34df542..709b132 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -19,7 +19,7 @@ jobs: node-version: "22" - name: Renovate - uses: https://github.com/renovatebot/github-action@v43.0.2 + uses: https://github.com/renovatebot/github-action@v43.0.5 with: token: ${{ secrets.RENOVATE_TOKEN }} env: diff --git a/docs/modules/ROOT/pages/web-portal-billingentity.adoc b/docs/modules/ROOT/pages/web-portal-billingentity.adoc index cd7b662..0b485c8 100644 --- a/docs/modules/ROOT/pages/web-portal-billingentity.adoc +++ b/docs/modules/ROOT/pages/web-portal-billingentity.adoc @@ -17,9 +17,9 @@ Search is done this way: 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 field `is_company = False` * A record with the following field configuration: -** `company_type = person` +** `is_company = False` ** `type = invoice` ** `parent_id = company_id` diff --git a/pyproject.toml b/pyproject.toml index a2804e4..3ad42a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "argon2-cffi>=25.1.0", "cryptography>=45.0.5", "django==5.2.4", - "django-allauth>=65.9.0", + "django-allauth>=65.10.0", "django-fernet-encrypted-fields>=0.3.0", "django-scopes>=2.0.0", "django-storages[s3]>=1.14.6", @@ -20,7 +20,7 @@ dependencies = [ "pyjwt>=2.10.1", "requests>=2.32.4", "rules>=3.5", - "sentry-sdk[django]>=2.32.0", + "sentry-sdk[django]>=2.33.0", "urlman>=2.0.2", ] diff --git a/renovate.json b/renovate.json index 727bdf6..b72dd71 100644 --- a/renovate.json +++ b/renovate.json @@ -11,7 +11,8 @@ "matchFileNames": [ ".forgejo/workflows/*.yml", ".forgejo/workflows/*.yaml" - ] + ], + "automerge": true }, { "matchManagers": [ diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index a9dcd4e..57cd660 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db import models, transaction from django.utils.functional import cached_property from django.utils.text import slugify +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django_scopes import ScopedManager, scopes_disabled @@ -74,6 +75,14 @@ class Organization(ServalaModelMixin, models.Model): user=user, organization=self, role=OrganizationRole.OWNER ) + def add_support_message(self, message): + support_message = _( + "Need help? We're happy to help via the support form." + ).format(support_url=self.urls.support) + return mark_safe( + f'{message} {support_message}' + ) + @classmethod @transaction.atomic def create_organization(cls, instance, owner): @@ -147,8 +156,8 @@ class BillingEntity(ServalaModelMixin, models.Model): ) # 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, + # The company ID points at a record of type res.partner with is_company=True + # The invoice ID points at a record of type res.partner with is_company=False, # type=invoice, parent_id=company_id (the invoice address). odoo_company_id = models.IntegerField(null=True) odoo_invoice_id = models.IntegerField(null=True) @@ -166,8 +175,8 @@ class BillingEntity(ServalaModelMixin, models.Model): """ 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'` + This method creates a `res.partner` record in Odoo with `is_company=True` + for the main company, and another `res.partner` record with `is_company=False` 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. @@ -187,14 +196,14 @@ class BillingEntity(ServalaModelMixin, models.Model): instance = cls.objects.create(name=name) company_payload = { "name": odoo_data.get("company_name", name), - "company_type": "company", + "is_company": True, } company_id = CLIENT.execute("res.partner", "create", [company_payload]) instance.odoo_company_id = company_id invoice_address_payload = { "name": name, - "company_type": "person", + "is_company": False, "type": "invoice", "parent_id": company_id, } @@ -249,10 +258,10 @@ class BillingEntity(ServalaModelMixin, models.Model): "invoice_address": None, } - company_fields = ["name", "company_type"] + company_fields = ["name", "is_company"] invoice_address_fields = [ "name", - "company_type", + "is_company", "type", "parent_id", "street", diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 362661b..ba1871d 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,5 +1,7 @@ import copy +import html import json +import re import kubernetes import rules @@ -10,6 +12,7 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError, models, transaction from django.utils import timezone from django.utils.functional import cached_property +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from encrypted_fields.fields import EncryptedJSONField from kubernetes import client, config @@ -571,7 +574,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): unique_together = [("name", "organization", "context")] rules_permissions = { "view": rules.is_staff | perms.is_organization_member, - "change": rules.is_staff | perms.is_organization_member, + "change": rules.is_staff | perms.is_organization_admin, "delete": rules.is_staff | perms.is_organization_admin, "add": rules.is_authenticated, } @@ -603,6 +606,58 @@ class ServiceInstance(ServalaModelMixin, models.Model): spec_data = prune_empty_data(spec_data) return spec_data + @classmethod + def _format_kubernetes_error(cls, error_message): + if not error_message: + return {"message": "", "errors": None, "has_list": False} + + error_message = str(error_message).strip() + + # Pattern to match validation errors in brackets like [error1, error2, error3] + pattern = r"\[([^\]]+)\]" + match = re.search(pattern, error_message) + + if not match: + return {"message": error_message, "errors": None, "has_list": False} + + errors_text = match.group(1).strip() + + if "," not in errors_text: + return {"message": error_message, "errors": None, "has_list": False} + + errors = [error.strip().strip("\"'") for error in errors_text.split(",")] + errors = [error for error in errors if error] + + if len(errors) <= 1: + return {"message": error_message, "errors": None, "has_list": False} + + base_message = re.sub(pattern, "", error_message).strip() + base_message = base_message.rstrip(":").strip() + + return {"message": base_message, "errors": errors, "has_list": True} + + @classmethod + def _safe_format_error(cls, error_data): + if not isinstance(error_data, dict): + return html.escape(str(error_data)) + + if not error_data.get("has_list", False): + return html.escape(error_data.get("message", "")) + + message = html.escape(error_data.get("message", "")) + errors = error_data.get("errors", []) + + if not errors: + return message + + escaped_errors = [html.escape(str(error)) for error in errors] + error_items = "".join(f"
  • {error}
  • " for error in escaped_errors) + + if message: + return mark_safe(f"{message}") + else: + return mark_safe(f"") + @classmethod def create_instance(cls, name, organization, context, created_by, spec_data): # Ensure the namespace exists @@ -615,11 +670,10 @@ class ServiceInstance(ServalaModelMixin, models.Model): context=context, ) except IntegrityError: - raise ValidationError( - _( - "An instance with this name already exists in this organization. Please choose a different name." - ) + message = _( + "An instance with this name already exists in this organization. Please choose a different name." ) + raise ValidationError(organization.add_support_message(message)) try: spec_data = cls._prepare_spec_data(spec_data) @@ -657,10 +711,25 @@ class ServiceInstance(ServalaModelMixin, models.Model): try: error_body = json.loads(e.body) reason = error_body.get("message", str(e)) - raise ValidationError(_("Kubernetes API error: {}").format(reason)) + error_data = cls._format_kubernetes_error(reason) + formatted_reason = cls._safe_format_error(error_data) + message = _("Error reported by control plane: {reason}").format( + reason=formatted_reason + ) + raise ValidationError(organization.add_support_message(message)) except (ValueError, TypeError): - raise ValidationError(_("Kubernetes API error: {}").format(str(e))) - raise ValidationError(_("Error creating instance: {}").format(str(e))) + error_data = cls._format_kubernetes_error(str(e)) + formatted_error = cls._safe_format_error(error_data) + message = _("Error reported by control plane: {error}").format( + error=formatted_error + ) + raise ValidationError(organization.add_support_message(message)) + error_data = cls._format_kubernetes_error(str(e)) + formatted_error = cls._safe_format_error(error_data) + message = _("Error creating instance: {error}").format( + error=formatted_error + ) + raise ValidationError(organization.add_support_message(message)) return instance def update_spec(self, spec_data, updated_by): @@ -681,29 +750,33 @@ class ServiceInstance(ServalaModelMixin, models.Model): self.save() # Updates updated_at timestamp except ApiException as e: if e.status == 404: - raise ValidationError( - _( - "Service instance not found in Kubernetes. It may have been deleted externally." - ) + message = _( + "Service instance not found in control plane. It may have been deleted externally." ) + raise ValidationError(self.organization.add_support_message(message)) try: error_body = json.loads(e.body) reason = error_body.get("message", str(e)) - raise ValidationError( - _("Kubernetes API error updating instance: {error}").format( - error=reason - ) - ) + error_data = self._format_kubernetes_error(reason) + formatted_reason = self._safe_format_error(error_data) + message = _( + "Error reported by control plane while updating instance: {reason}" + ).format(reason=formatted_reason) + raise ValidationError(self.organization.add_support_message(message)) except (ValueError, TypeError): - raise ValidationError( - _("Kubernetes API error updating instance: {error}").format( - error=str(e) - ) - ) + error_data = self._format_kubernetes_error(str(e)) + formatted_error = self._safe_format_error(error_data) + message = _( + "Error reported by control plane while updating instance: {error}" + ).format(error=formatted_error) + raise ValidationError(self.organization.add_support_message(message)) except Exception as e: - raise ValidationError( - _("Error updating instance: {error}").format(error=str(e)) + error_data = self._format_kubernetes_error(str(e)) + formatted_error = self._safe_format_error(error_data) + message = _("Error updating instance: {error}").format( + error=formatted_error ) + raise ValidationError(self.organization.add_support_message(message)) @transaction.atomic def delete_instance(self, user): diff --git a/src/servala/core/models/user.py b/src/servala/core/models/user.py index 38cf80c..b8adfa4 100644 --- a/src/servala/core/models/user.py +++ b/src/servala/core/models/user.py @@ -84,7 +84,7 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser): result = odoo.CLIENT.search_read( model="res.partner", domain=[ - ("company_type", "=", "person"), + ("is_company", "=", False), ("type", "=", "contact"), ("email", "ilike", self.email), ("parent_id", "=", organization.billing_entity.odoo_company_id), @@ -107,7 +107,7 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser): partner_data = { "name": f"{self.first_name} {self.last_name}".strip() or self.email, "email": self.email, - "company_type": "person", + "is_company": False, "type": "contact", "parent_id": organization.billing_entity.odoo_company_id, } diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py index 3d98e18..ba91dc7 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -15,7 +15,7 @@ ADDRESS_FIELDS = [ "email", "phone", "vat", - "company_type", + "is_company", "type", "parent_id", ] @@ -118,8 +118,8 @@ def get_odoo_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 + # 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 @@ -153,7 +153,7 @@ def get_odoo_access_conditions(user): odoo_contacts = CLIENT.search_read( model="res.partner", domain=[ - ("company_type", "=", "person"), + ("is_company", "=", False), ("type", "=", "contact"), ("email", "ilike", email), ], @@ -186,7 +186,7 @@ def get_invoice_addresses(user): or_conditions = get_odoo_access_conditions(user) domain = [ - ("company_type", "=", "person"), + ("is_company", "=", False), ("type", "=", "invoice"), ] + or_conditions diff --git a/src/servala/core/rules.py b/src/servala/core/rules.py index 5ead2c3..cf4dc1c 100644 --- a/src/servala/core/rules.py +++ b/src/servala/core/rules.py @@ -13,15 +13,30 @@ def has_organization_role(user, org, roles): @rules.predicate -def is_organization_owner(user, org): +def is_organization_owner(user, obj): + if hasattr(obj, "organization"): + org = obj.organization + else: + org = obj return has_organization_role(user, org, ["owner"]) @rules.predicate -def is_organization_admin(user, org): +def is_organization_admin(user, obj): + if hasattr(obj, "organization"): + org = obj.organization + else: + org = obj return has_organization_role(user, org, ["owner", "admin"]) @rules.predicate -def is_organization_member(user, org): +def is_organization_member(user, obj): + if hasattr(obj, "organization"): + org = obj.organization + else: + org = obj return has_organization_role(user, org, None) + + +rules.add_perm("core", rules.is_staff) diff --git a/src/servala/frontend/templates/includes/k8s_error.html b/src/servala/frontend/templates/includes/k8s_error.html new file mode 100644 index 0000000..f97474d --- /dev/null +++ b/src/servala/frontend/templates/includes/k8s_error.html @@ -0,0 +1,14 @@ +{% if show_error %} +
    + {% if has_list %} + {% if message %}{{ message }}{% endif %} + + {% else %} + {{ message }} + {% endif %} +
    +{% endif %} diff --git a/src/servala/frontend/templates/includes/message.html b/src/servala/frontend/templates/includes/message.html index 59260d2..d5debc9 100644 --- a/src/servala/frontend/templates/includes/message.html +++ b/src/servala/frontend/templates/includes/message.html @@ -9,7 +9,7 @@