From 61f1065bc6fbc748f4569246ee293d6cfce38365 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 12 Nov 2025 11:42:10 +0100 Subject: [PATCH 1/4] Start work on implementing Exoscale offboarding rel #262 --- src/servala/api/views.py | 173 ++++++++++++++++++++- src/tests/test_api_exoscale.py | 276 +++++++++++++++++++++++++++++++++ 2 files changed, 445 insertions(+), 4 deletions(-) diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 456f4b2..c4857e5 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_not_required from django.core.mail import send_mail from django.db import transaction from django.http import JsonResponse +from django.urls import reverse from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt @@ -19,7 +20,8 @@ from servala.core.models import ( OrganizationRole, User, ) -from servala.core.models.service import Service, ServiceOffering +from servala.core.models.service import Service, ServiceInstance, ServiceOffering +from servala.core.odoo import CLIENT logger = logging.getLogger(__name__) @@ -28,9 +30,7 @@ logger = logging.getLogger(__name__) @method_decorator(login_not_required, name="dispatch") class OSBServiceInstanceView(OSBBasicAuthPermission, View): """ - OSB API endpoint for service instance provisioning (onboarding). - Implements the PUT /v2/service_instances/:instance_id endpoint. - https://docs.servala.com/exoscale-osb.html#_onboarding + OSB API endpoint for service instance management via Exoscale. """ def _error(self, error): @@ -177,3 +177,168 @@ The Servala Team""" recipient_list=[user.email], fail_silently=False, ) + + def delete(self, request, instance_id): + """ + This implements the Exoscale offboarding flow MVP. + https://docs.servala.com/exoscale-osb.html#_offboarding + """ + service_id = request.GET.get("service_id") + plan_id = request.GET.get("plan_id") + + if not service_id: + return self._error("service_id is required but missing.") + if not plan_id: + return self._error("plan_id is required but missing.") + + try: + service = Service.objects.get(osb_service_id=service_id) + service_offering = ServiceOffering.objects.get( + osb_plan_id=plan_id, service=service + ) + except Service.DoesNotExist: + return self._error(f"Unknown service_id: {service_id}") + except ServiceOffering.DoesNotExist: + return self._error( + f"Unknown plan_id: {plan_id} for service_id: {service_id}" + ) + + self._create_action_helpdesk_ticket( + request=request, + action="Offboard", + instance_id=instance_id, + service=service, + service_offering=service_offering, + ) + + return JsonResponse({}, status=200) + + def patch(self, request, instance_id): + """ + This implements the Exoscale suspension flow MVP. + https://docs.servala.com/exoscale-osb.html#_suspension + """ + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON in request body"}, status=400) + + service_id = data.get("service_id") + plan_id = data.get("plan_id") + + if not service_id: + return self._error("service_id is required but missing.") + if not plan_id: + return self._error("plan_id is required but missing.") + + try: + service = Service.objects.get(osb_service_id=service_id) + service_offering = ServiceOffering.objects.get( + osb_plan_id=plan_id, service=service + ) + except Service.DoesNotExist: + return self._error(f"Unknown service_id: {service_id}") + except ServiceOffering.DoesNotExist: + return self._error( + f"Unknown plan_id: {plan_id} for service_id: {service_id}" + ) + + self._create_action_helpdesk_ticket( + request=request, + action="Suspend", + instance_id=instance_id, + service=service, + service_offering=service_offering, + users=data.get("parameters", {}).get("users"), + ) + return JsonResponse({}, status=200) + + def _get_admin_url(self, model_name, pk): + admin_path = reverse(f"admin:{model_name}", args=[pk]) + return self.request.build_absolute_uri(admin_path) + + def _create_action_helpdesk_ticket( + self, request, action, instance_id, service, service_offering, users=None + ): + """ + Create an Odoo helpdesk ticket for offboarding or suspension actions. + This is an MVP implementation that creates a ticket for manual handling. + """ + try: + service_instance = None + organization = None + try: + # Look for instances with this name in the service offering's context + # TODO: we do not currently match instance IDs from exoscale yet, this + # will likely not work at all yet + instances = ( + ServiceInstance.objects.filter( + name=instance_id, + context__service_offering=service_offering, + ) + .select_related("organization") + .first() + ) + + if instances: + organization = service_instance.organization + except Exception: + pass + + description_parts = [f"Action: {action}", f"Service: {service.name}"] + if organization: + org_url = self._get_admin_url( + "core_organization_change", organization.pk + ) + description_parts.append( + f"Organization: {organization.name} - {org_url}" + ) + + if service_instance: + instance_url = self._get_admin_url( + "core_serviceinstance_change", service_instance.pk + ) + description_parts.append( + f"Instance: {service_instance.name} - {instance_url}" + ) + else: + description_parts.append(f"Instance: {instance_id}") + + offering_url = self._get_admin_url( + "core_serviceoffering_change", service_offering.pk + ) + description_parts.append(f"Service Offering: {offering_url}") + + if users: + description_parts.append("\nUsers:") + for user_data in users: + email = user_data.get("email", "N/A") + full_name = user_data.get("full_name", "N/A") + role = user_data.get("role", "N/A") + + user_link = email + if email and email != "N/A": + try: + user = User.objects.get(email=email.strip().lower()) + user_link = self._get_admin_url("core_user_change", user.pk) + except User.DoesNotExist: + pass + + description_parts.append(f" - {full_name} ({user_link}) - {role}") + + description = "\n".join(description_parts) + ticket_data = { + "name": f"Exoscale OSB {action} - {service.name} - {instance_id}", + "team_id": settings.ODOO["HELPDESK_TEAM_ID"], + "description": description, + } + + CLIENT.execute("helpdesk.ticket", "create", [ticket_data]) + logger.info( + f"Created {action} helpdesk ticket for instance {instance_id}, service {service.name}" + ) + + except Exception as e: + logger.error( + f"Error creating Exoscale {action} helpdesk ticket for instance {instance_id}: {e}" + ) diff --git a/src/tests/test_api_exoscale.py b/src/tests/test_api_exoscale.py index 6d10deb..283e604 100644 --- a/src/tests/test_api_exoscale.py +++ b/src/tests/test_api_exoscale.py @@ -5,6 +5,12 @@ from django.core import mail from django_scopes import scopes_disabled from servala.core.models import Organization, OrganizationOrigin, User +from servala.core.models.service import ( + ControlPlane, + ControlPlaneCRD, + ServiceDefinition, + ServiceInstance, +) @pytest.fixture @@ -451,3 +457,273 @@ def test_organization_creation_with_context_only( assert response.status_code == 201 org = Organization.objects.get(osb_guid="fallback-org-guid") assert org is not None + + +@pytest.mark.django_db +def test_delete_offboarding_success( + mock_odoo_success, + osb_client, + test_service, + test_service_offering, + instance_id, +): + response = osb_client.delete( + f"/api/osb/v2/service_instances/{instance_id}" + f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}" + ) + + assert response.status_code == 200 + assert response.content == b"{}" + + +@pytest.mark.django_db +def test_delete_missing_service_id(osb_client, test_service_offering, instance_id): + response = osb_client.delete( + f"/api/osb/v2/service_instances/{instance_id}?plan_id={test_service_offering.osb_plan_id}" + ) + + assert response.status_code == 400 + response_data = json.loads(response.content) + assert "service_id is required but missing" in response_data["error"] + + +@pytest.mark.django_db +def test_delete_missing_plan_id(osb_client, test_service, instance_id): + response = osb_client.delete( + f"/api/osb/v2/service_instances/{instance_id}?service_id={test_service.osb_service_id}" + ) + + assert response.status_code == 400 + response_data = json.loads(response.content) + assert "plan_id is required but missing" in response_data["error"] + + +@pytest.mark.django_db +def test_delete_invalid_service_id(osb_client, instance_id): + response = osb_client.delete( + f"/api/osb/v2/service_instances/{instance_id}?service_id=invalid&plan_id=invalid" + ) + + assert response.status_code == 400 + response_data = json.loads(response.content) + assert "Unknown service_id: invalid" in response_data["error"] + + +@pytest.mark.django_db +def test_delete_invalid_plan_id(osb_client, test_service, instance_id): + response = osb_client.delete( + f"/api/osb/v2/service_instances/{instance_id}" + f"?service_id={test_service.osb_service_id}&plan_id=invalid" + ) + + assert response.status_code == 400 + response_data = json.loads(response.content) + assert ( + f"Unknown plan_id: invalid for service_id: {test_service.osb_service_id}" + in response_data["error"] + ) + + +@pytest.mark.django_db +def test_patch_suspension_success( + mock_odoo_success, + osb_client, + test_service, + test_service_offering, + instance_id, +): + payload = { + "service_id": test_service.osb_service_id, + "plan_id": test_service_offering.osb_plan_id, + "parameters": { + "users": [ + { + "email": "user@example.com", + "full_name": "Test User", + "role": "owner", + } + ] + }, + } + + response = osb_client.patch( + f"/api/osb/v2/service_instances/{instance_id}", + data=json.dumps(payload), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.content == b"{}" + + +@pytest.mark.django_db +def test_patch_missing_service_id(osb_client, test_service_offering, instance_id): + payload = { + "plan_id": test_service_offering.osb_plan_id, + "parameters": {"users": []}, + } + + response = osb_client.patch( + f"/api/osb/v2/service_instances/{instance_id}", + data=json.dumps(payload), + content_type="application/json", + ) + + assert response.status_code == 400 + response_data = json.loads(response.content) + assert "service_id is required but missing" in response_data["error"] + + +@pytest.mark.django_db +def test_patch_missing_plan_id(osb_client, test_service, instance_id): + payload = { + "service_id": test_service.osb_service_id, + "parameters": {"users": []}, + } + + response = osb_client.patch( + f"/api/osb/v2/service_instances/{instance_id}", + data=json.dumps(payload), + content_type="application/json", + ) + + assert response.status_code == 400 + response_data = json.loads(response.content) + assert "plan_id is required but missing" in response_data["error"] + + +@pytest.mark.django_db +def test_patch_invalid_json(osb_client, instance_id): + response = osb_client.patch( + f"/api/osb/v2/service_instances/{instance_id}", + data="invalid json{", + content_type="application/json", + ) + + assert response.status_code == 400 + response_data = json.loads(response.content) + assert "Invalid JSON in request body" in response_data["error"] + + +@pytest.mark.django_db +def test_delete_creates_ticket_with_admin_links( + mocker, + mock_odoo_success, + osb_client, + test_service, + test_service_offering, + instance_id, +): + mock_api_client = mocker.patch("servala.api.views.CLIENT") + + response = osb_client.delete( + f"/api/osb/v2/service_instances/{instance_id}" + f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}" + ) + + assert response.status_code == 200 + + ticket_call = mock_api_client.execute.call_args_list[-1] + ticket_data = ticket_call[0][2][0] + + assert "admin/core/serviceoffering" in ticket_data["description"] + assert f"/{test_service_offering.pk}/" in ticket_data["description"] + assert ( + ticket_data["name"] + == f"Exoscale OSB Offboard - {test_service.name} - {instance_id}" + ) + + +@pytest.mark.django_db +def test_patch_creates_ticket_with_user_admin_links( + mocker, + mock_odoo_success, + osb_client, + test_service, + test_service_offering, + instance_id, + org_owner, +): + mock_api_client = mocker.patch("servala.api.views.CLIENT") + payload = { + "service_id": test_service.osb_service_id, + "plan_id": test_service_offering.osb_plan_id, + "parameters": { + "users": [ + { + "email": org_owner.email, + "full_name": "Test User", + "role": "owner", + } + ] + }, + } + + response = osb_client.patch( + f"/api/osb/v2/service_instances/{instance_id}", + data=json.dumps(payload), + content_type="application/json", + ) + + assert response.status_code == 200 + ticket_call = mock_api_client.execute.call_args_list[-1] + ticket_data = ticket_call[0][2][0] + assert "admin/core/serviceoffering" in ticket_data["description"] + assert "admin/core/user" in ticket_data["description"] + assert f"/{org_owner.pk}/" in ticket_data["description"] + assert ( + ticket_data["name"] + == f"Exoscale OSB Suspend - {test_service.name} - {instance_id}" + ) + + +@pytest.mark.django_db +def test_ticket_includes_organization_and_instance_when_found( + mocker, + mock_odoo_success, + osb_client, + test_service, + test_service_offering, + organization, +): + mock_api_client = mocker.patch("servala.api.views.CLIENT") + service_definition = ServiceDefinition.objects.create( + name="Test Definition", + service=test_service, + api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"}, + ) + control_plane = ControlPlane.objects.create( + name="Test Control Plane", + cloud_provider=test_service_offering.provider, + api_credentials={ + "certificate-authority-data": "test", + "server": "https://test", + "token": "test", + }, + ) + crd = ControlPlaneCRD.objects.create( + service_offering=test_service_offering, + control_plane=control_plane, + service_definition=service_definition, + ) + instance_name = "test-instance-123" + service_instance = ServiceInstance.objects.create( + name=instance_name, + organization=organization, + context=crd, + ) + + response = osb_client.delete( + f"/api/osb/v2/service_instances/{instance_name}" + f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}" + ) + + assert response.status_code == 200 + ticket_call = mock_api_client.execute.call_args_list[-1] + ticket_data = ticket_call[0][2][0] + assert f"Organization: {organization.name}" in ticket_data["description"] + assert "admin/core/organization" in ticket_data["description"] + assert f"/{organization.pk}/" in ticket_data["description"] + assert f"Instance: {service_instance.name}" in ticket_data["description"] + assert "admin/core/serviceinstance" in ticket_data["description"] + assert f"/{service_instance.pk}/" in ticket_data["description"] -- 2.49.1 From e4c64c4a1777c6211b5b65c204dd6af655617827 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 12 Nov 2025 11:52:54 +0100 Subject: [PATCH 2/4] Extract odoo helpdesk ticket creation --- src/servala/api/views.py | 18 +++----- src/servala/core/odoo.py | 16 +++++++ src/servala/frontend/views/support.py | 20 +++------ src/servala/settings_test.py | 1 + src/tests/test_api_exoscale.py | 61 +++++++++++++++++---------- 5 files changed, 70 insertions(+), 46 deletions(-) diff --git a/src/servala/api/views.py b/src/servala/api/views.py index c4857e5..7d8ebfa 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -21,7 +21,7 @@ from servala.core.models import ( User, ) from servala.core.models.service import Service, ServiceInstance, ServiceOffering -from servala.core.odoo import CLIENT +from servala.core.odoo import create_helpdesk_ticket logger = logging.getLogger(__name__) @@ -269,9 +269,7 @@ The Servala Team""" organization = None try: # Look for instances with this name in the service offering's context - # TODO: we do not currently match instance IDs from exoscale yet, this - # will likely not work at all yet - instances = ( + service_instance = ( ServiceInstance.objects.filter( name=instance_id, context__service_offering=service_offering, @@ -280,7 +278,7 @@ The Servala Team""" .first() ) - if instances: + if service_instance: organization = service_instance.organization except Exception: pass @@ -327,13 +325,11 @@ The Servala Team""" description_parts.append(f" - {full_name} ({user_link}) - {role}") description = "\n".join(description_parts) - ticket_data = { - "name": f"Exoscale OSB {action} - {service.name} - {instance_id}", - "team_id": settings.ODOO["HELPDESK_TEAM_ID"], - "description": description, - } - CLIENT.execute("helpdesk.ticket", "create", [ticket_data]) + create_helpdesk_ticket( + title=f"Exoscale OSB {action} - {service.name} - {instance_id}", + description=description, + ) logger.info( f"Created {action} helpdesk ticket for instance {instance_id}, service {service.name}" ) diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py index ba91dc7..517829a 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -207,3 +207,19 @@ def get_invoice_addresses(user): return invoice_addresses or [] except Exception: return [] + + +def create_helpdesk_ticket(title, description, partner_id=None, sale_order_id=None): + ticket_data = { + "name": title, + "team_id": settings.ODOO["HELPDESK_TEAM_ID"], + "description": description, + } + + if partner_id: + ticket_data["partner_id"] = partner_id + + if sale_order_id: + ticket_data["sale_order_id"] = sale_order_id + + return CLIENT.execute("helpdesk.ticket", "create", [ticket_data]) diff --git a/src/servala/frontend/views/support.py b/src/servala/frontend/views/support.py index 6f4c4aa..2cd4cf3 100644 --- a/src/servala/frontend/views/support.py +++ b/src/servala/frontend/views/support.py @@ -1,11 +1,10 @@ -from django.conf import settings from django.contrib import messages from django.shortcuts import redirect from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView -from servala.core.odoo import CLIENT +from servala.core.odoo import create_helpdesk_ticket from servala.frontend.forms.support import SupportForm from servala.frontend.views.mixins import OrganizationViewMixin @@ -24,21 +23,16 @@ class SupportView(OrganizationViewMixin, FormView): if not partner_id: raise Exception("Could not get or create Odoo contact for user") - ticket_data = { - "name": f"Servala Support - Organization {organization.name}", - "team_id": settings.ODOO["HELPDESK_TEAM_ID"], - "partner_id": partner_id, - "description": message, - } - # All orgs should have a sale order ID, but legacy ones might not have it. # Also, we want to be very sure that support requests work, especially for # organizations where something in the creation process may have gone wrong, # so if the ID does not exist, we omit it entirely. - if organization.odoo_sale_order_id: - ticket_data["sale_order_id"] = organization.odoo_sale_order_id - - CLIENT.execute("helpdesk.ticket", "create", [ticket_data]) + create_helpdesk_ticket( + title=f"Servala Support - Organization {organization.name}", + description=message, + partner_id=partner_id, + sale_order_id=organization.odoo_sale_order_id or None, + ) messages.success( self.request, _( diff --git a/src/servala/settings_test.py b/src/servala/settings_test.py index 477ecb2..fec1bfb 100644 --- a/src/servala/settings_test.py +++ b/src/servala/settings_test.py @@ -8,6 +8,7 @@ overrides/adds settings specific to testing. from servala.settings import * # noqa: F403, F401 SECRET_KEY = "test-secret-key-for-testing-only-do-not-use-in-production" +SALT_KEY = SECRET_KEY PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", ] diff --git a/src/tests/test_api_exoscale.py b/src/tests/test_api_exoscale.py index 283e604..19f8b93 100644 --- a/src/tests/test_api_exoscale.py +++ b/src/tests/test_api_exoscale.py @@ -614,7 +614,8 @@ def test_delete_creates_ticket_with_admin_links( test_service_offering, instance_id, ): - mock_api_client = mocker.patch("servala.api.views.CLIENT") + # Mock the create_helpdesk_ticket function + mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket") response = osb_client.delete( f"/api/osb/v2/service_instances/{instance_id}" @@ -623,13 +624,15 @@ def test_delete_creates_ticket_with_admin_links( assert response.status_code == 200 - ticket_call = mock_api_client.execute.call_args_list[-1] - ticket_data = ticket_call[0][2][0] + # Verify the ticket was created with admin URL + mock_create_ticket.assert_called_once() + call_kwargs = mock_create_ticket.call_args[1] - assert "admin/core/serviceoffering" in ticket_data["description"] - assert f"/{test_service_offering.pk}/" in ticket_data["description"] + # Check that the description contains an admin URL + assert "admin/core/serviceoffering" in call_kwargs["description"] + assert f"/{test_service_offering.pk}/" in call_kwargs["description"] assert ( - ticket_data["name"] + call_kwargs["title"] == f"Exoscale OSB Offboard - {test_service.name} - {instance_id}" ) @@ -644,7 +647,9 @@ def test_patch_creates_ticket_with_user_admin_links( instance_id, org_owner, ): - mock_api_client = mocker.patch("servala.api.views.CLIENT") + # Mock the create_helpdesk_ticket function + mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket") + payload = { "service_id": test_service.osb_service_id, "plan_id": test_service_offering.osb_plan_id, @@ -666,13 +671,17 @@ def test_patch_creates_ticket_with_user_admin_links( ) assert response.status_code == 200 - ticket_call = mock_api_client.execute.call_args_list[-1] - ticket_data = ticket_call[0][2][0] - assert "admin/core/serviceoffering" in ticket_data["description"] - assert "admin/core/user" in ticket_data["description"] - assert f"/{org_owner.pk}/" in ticket_data["description"] + + # Verify the ticket was created with admin URLs + mock_create_ticket.assert_called_once() + call_kwargs = mock_create_ticket.call_args[1] + + # Check that the description contains admin URLs + assert "admin/core/serviceoffering" in call_kwargs["description"] + assert "admin/core/user" in call_kwargs["description"] + assert f"/{org_owner.pk}/" in call_kwargs["description"] assert ( - ticket_data["name"] + call_kwargs["title"] == f"Exoscale OSB Suspend - {test_service.name} - {instance_id}" ) @@ -686,7 +695,9 @@ def test_ticket_includes_organization_and_instance_when_found( test_service_offering, organization, ): - mock_api_client = mocker.patch("servala.api.views.CLIENT") + # Mock the create_helpdesk_ticket function + mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket") + service_definition = ServiceDefinition.objects.create( name="Test Definition", service=test_service, @@ -719,11 +730,17 @@ def test_ticket_includes_organization_and_instance_when_found( ) assert response.status_code == 200 - ticket_call = mock_api_client.execute.call_args_list[-1] - ticket_data = ticket_call[0][2][0] - assert f"Organization: {organization.name}" in ticket_data["description"] - assert "admin/core/organization" in ticket_data["description"] - assert f"/{organization.pk}/" in ticket_data["description"] - assert f"Instance: {service_instance.name}" in ticket_data["description"] - assert "admin/core/serviceinstance" in ticket_data["description"] - assert f"/{service_instance.pk}/" in ticket_data["description"] + + # Verify the ticket was created with all admin URLs + mock_create_ticket.assert_called_once() + call_kwargs = mock_create_ticket.call_args[1] + + # Check organization is included + assert f"Organization: {organization.name}" in call_kwargs["description"] + assert "admin/core/organization" in call_kwargs["description"] + assert f"/{organization.pk}/" in call_kwargs["description"] + + # Check instance is included + assert f"Instance: {service_instance.name}" in call_kwargs["description"] + assert "admin/core/serviceinstance" in call_kwargs["description"] + assert f"/{service_instance.pk}/" in call_kwargs["description"] -- 2.49.1 From 208f3c357d1a956ceb981a98c6e4e00e816be0a5 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 17 Nov 2025 11:38:02 +0100 Subject: [PATCH 3/4] odoo wants html for linebreaks --- src/servala/api/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 7d8ebfa..b2991aa 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -308,7 +308,7 @@ The Servala Team""" description_parts.append(f"Service Offering: {offering_url}") if users: - description_parts.append("\nUsers:") + description_parts.append("
Users:") for user_data in users: email = user_data.get("email", "N/A") full_name = user_data.get("full_name", "N/A") @@ -324,7 +324,7 @@ The Servala Team""" description_parts.append(f" - {full_name} ({user_link}) - {role}") - description = "\n".join(description_parts) + description = "
".join(description_parts) create_helpdesk_ticket( title=f"Exoscale OSB {action} - {service.name} - {instance_id}", -- 2.49.1 From 3e17e03da9f290aeed89a68343d2ca98fa2c266b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 17 Nov 2025 11:48:19 +0100 Subject: [PATCH 4/4] support hardcoded suspend plan for exoscale --- src/servala/api/views.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/servala/api/views.py b/src/servala/api/views.py index b2991aa..5fdb91a 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -233,9 +233,12 @@ The Servala Team""" try: service = Service.objects.get(osb_service_id=service_id) - service_offering = ServiceOffering.objects.get( - osb_plan_id=plan_id, service=service - ) + # Special handling: when plan_id is "suspend", don't lookup service_offering + service_offering = None + if plan_id != "suspend": + service_offering = ServiceOffering.objects.get( + osb_plan_id=plan_id, service=service + ) except Service.DoesNotExist: return self._error(f"Unknown service_id: {service_id}") except ServiceOffering.DoesNotExist: @@ -258,7 +261,7 @@ The Servala Team""" return self.request.build_absolute_uri(admin_path) def _create_action_helpdesk_ticket( - self, request, action, instance_id, service, service_offering, users=None + self, request, action, instance_id, service, service_offering=None, users=None ): """ Create an Odoo helpdesk ticket for offboarding or suspension actions. @@ -269,11 +272,12 @@ The Servala Team""" organization = None try: # Look for instances with this name in the service offering's context + filter_kwargs = {"name": instance_id} + if service_offering: + filter_kwargs["context__service_offering"] = service_offering + service_instance = ( - ServiceInstance.objects.filter( - name=instance_id, - context__service_offering=service_offering, - ) + ServiceInstance.objects.filter(**filter_kwargs) .select_related("organization") .first() ) @@ -302,10 +306,11 @@ The Servala Team""" else: description_parts.append(f"Instance: {instance_id}") - offering_url = self._get_admin_url( - "core_serviceoffering_change", service_offering.pk - ) - description_parts.append(f"Service Offering: {offering_url}") + if service_offering: + offering_url = self._get_admin_url( + "core_serviceoffering_change", service_offering.pk + ) + description_parts.append(f"Service Offering: {offering_url}") if users: description_parts.append("
Users:") -- 2.49.1