diff --git a/src/servala/api/views.py b/src/servala/api/views.py
index 456f4b2..5fdb91a 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 create_helpdesk_ticket
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,169 @@ 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)
+ # 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:
+ 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=None, 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
+ filter_kwargs = {"name": instance_id}
+ if service_offering:
+ filter_kwargs["context__service_offering"] = service_offering
+
+ service_instance = (
+ ServiceInstance.objects.filter(**filter_kwargs)
+ .select_related("organization")
+ .first()
+ )
+
+ if service_instance:
+ 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}")
+
+ 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:")
+ 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 = "
".join(description_parts)
+
+ 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}"
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Error creating Exoscale {action} helpdesk ticket for instance {instance_id}: {e}"
+ )
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 6d10deb..19f8b93 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,290 @@ 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 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}"
+ f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
+ )
+
+ assert response.status_code == 200
+
+ # Verify the ticket was created with admin URL
+ mock_create_ticket.assert_called_once()
+ call_kwargs = mock_create_ticket.call_args[1]
+
+ # 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 (
+ call_kwargs["title"]
+ == 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 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,
+ "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
+
+ # 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 (
+ call_kwargs["title"]
+ == 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 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,
+ 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
+
+ # 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"]