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"]