diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 5fdb91a..456f4b2 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -6,7 +6,6 @@ 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 @@ -20,8 +19,7 @@ from servala.core.models import ( OrganizationRole, User, ) -from servala.core.models.service import Service, ServiceInstance, ServiceOffering -from servala.core.odoo import create_helpdesk_ticket +from servala.core.models.service import Service, ServiceOffering logger = logging.getLogger(__name__) @@ -30,7 +28,9 @@ logger = logging.getLogger(__name__) @method_decorator(login_not_required, name="dispatch") class OSBServiceInstanceView(OSBBasicAuthPermission, View): """ - OSB API endpoint for service instance management via Exoscale. + 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 """ def _error(self, error): @@ -177,169 +177,3 @@ 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 517829a..ba91dc7 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -207,19 +207,3 @@ 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 2cd4cf3..6f4c4aa 100644 --- a/src/servala/frontend/views/support.py +++ b/src/servala/frontend/views/support.py @@ -1,10 +1,11 @@ +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 create_helpdesk_ticket +from servala.core.odoo import CLIENT from servala.frontend.forms.support import SupportForm from servala.frontend.views.mixins import OrganizationViewMixin @@ -23,16 +24,21 @@ 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. - 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, - ) + if organization.odoo_sale_order_id: + ticket_data["sale_order_id"] = organization.odoo_sale_order_id + + CLIENT.execute("helpdesk.ticket", "create", [ticket_data]) messages.success( self.request, _( diff --git a/src/servala/settings_test.py b/src/servala/settings_test.py index fec1bfb..477ecb2 100644 --- a/src/servala/settings_test.py +++ b/src/servala/settings_test.py @@ -8,7 +8,6 @@ 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 19f8b93..6d10deb 100644 --- a/src/tests/test_api_exoscale.py +++ b/src/tests/test_api_exoscale.py @@ -5,12 +5,6 @@ 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 @@ -457,290 +451,3 @@ 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"]