Implement Exoscale onboarding API endpoint #199

Merged
rixx merged 14 commits from 37-exoscale-onboarding into main 2025-10-03 07:04:44 +00:00
23 changed files with 926 additions and 55 deletions

View file

@ -40,6 +40,7 @@ SERVALA_EMAIL_PASSWORD=''
# At most one of the following settings may be set to True # At most one of the following settings may be set to True
SERVALA_EMAIL_TLS='False' SERVALA_EMAIL_TLS='False'
SERVALA_EMAIL_SSL='False' SERVALA_EMAIL_SSL='False'
SERVALA_EMAIL_DEFAULT_FROM='noreply@servala.com'
# If the default OrganizationOrigin is **not** the one with the database ID 1, set it here. # If the default OrganizationOrigin is **not** the one with the database ID 1, set it here.
SERVALA_DEFAULT_ORIGIN='1' SERVALA_DEFAULT_ORIGIN='1'
@ -68,3 +69,7 @@ SERVALA_ODOO_USERNAME=''
SERVALA_ODOO_PASSWORD='' SERVALA_ODOO_PASSWORD=''
# Helpdesk team ID for support tickets in Odoo. Defaults to 5. # Helpdesk team ID for support tickets in Odoo. Defaults to 5.
SERVALA_ODOO_HELPDESK_TEAM_ID='5' SERVALA_ODOO_HELPDESK_TEAM_ID='5'
# OSB API authentication settings
SERVALA_OSB_USERNAME=''
SERVALA_OSB_PASSWORD=''

View file

@ -38,6 +38,12 @@ uv run --env-file=.env src/manage.py runserver
This will start the development server on http://localhost:8000. This will start the development server on http://localhost:8000.
For testing mail sending, `smtp4dev` can be used:
```
docker run --rm -it -p 5000:80 -p 2525:25 docker.io/rnwood/smtp4dev
```
## Configuration ## Configuration
Configuration happens using environment variables. Configuration happens using environment variables.

View file

@ -39,6 +39,7 @@ dev = [
"pytest>=8.4.2", "pytest>=8.4.2",
"pytest-cov>=6.3.0", "pytest-cov>=6.3.0",
"pytest-django>=4.11.1", "pytest-django>=4.11.1",
"pytest-mock>=3.15.1",
] ]
[tool.isort] [tool.isort]
@ -54,7 +55,7 @@ ignore = "E203,W503"
extend_exclude = "src/servala/static/mazer" extend_exclude = "src/servala/static/mazer"
[tool.pytest.ini_options] [tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "servala.settings" DJANGO_SETTINGS_MODULE = "servala.settings_test"
addopts = "-p no:doctest -p no:pastebin -p no:nose --cov=./ --cov-report=term-missing:skip-covered" addopts = "-p no:doctest -p no:pastebin -p no:nose --cov=./ --cov-report=term-missing:skip-covered"
testpaths = "src/tests" testpaths = "src/tests"
pythonpath = "src" pythonpath = "src"

View file

6
src/servala/api/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "servala.api"

View file

@ -0,0 +1,50 @@
import base64
from contextlib import suppress
from django.conf import settings
from django.http import HttpResponseForbidden
def get_username_and_password(request): # pragma: no cover
# This method is vendored from assorted DRF bits, so we
# skip it in our test coverage report
auth = request.META.get("HTTP_AUTHORIZATION", b"")
if isinstance(auth, str):
# Work around django test client oddness
auth = auth.encode("iso-8859-1")
auth = auth.split()
if not auth or auth[0].lower() != b"basic":
return False, False
if len(auth) != 2:
return False, False
with suppress(TypeError, ValueError):
try:
auth_decoded = base64.b64decode(auth[1]).decode("utf-8")
except UnicodeDecodeError:
auth_decoded = base64.b64decode(auth[1]).decode("latin-1")
return auth_decoded.split(":", 1)
return False, False
class OSBBasicAuthPermission:
"""
Basic auth for OSB is implemented as a permission class rather than as
an authentication class, because authentication is expected to associate
the request with a Django user. However, the OSB/Exoscale requests do not
relate to a user account, so we treat the auth result as a permission instead.
"""
def dispatch(self, request, *args, **kwargs):
osb_username = getattr(settings, "OSB_USERNAME", None)
osb_password = getattr(settings, "OSB_PASSWORD", None)
if not osb_username or not osb_password:
return False # pragma: no cover
username, password = get_username_and_password(request)
if username == osb_username and password == osb_password:
return super().dispatch(request, *args, **kwargs)
return HttpResponseForbidden()

13
src/servala/api/urls.py Normal file
View file

@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = "api"
urlpatterns = [
path(
"osb/v2/service_instances/<str:instance_id>",
rixx marked this conversation as resolved Outdated

Let's move this to api/osb/v2/service_instances/<str:instance_id>

Let's move this to `api/osb/v2/service_instances/<str:instance_id>`
views.OSBServiceInstanceView.as_view(),
name="osb_service_instance",
),
]

193
src/servala/api/views.py Normal file
View file

@ -0,0 +1,193 @@
import json
import logging
from contextlib import suppress
from django.conf import settings
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.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from servala.api.permissions import OSBBasicAuthPermission
from servala.core.exoscale import get_exoscale_origin
from servala.core.models import BillingEntity, Organization, User
from servala.core.models.service import Service, ServiceOffering
logger = logging.getLogger(__name__)
@method_decorator(csrf_exempt, name="dispatch")
@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
"""
def _error(self, error):
return JsonResponse({"error": error}, status=400)
def _get_user(self, data):
email = data.get("email", "").strip().lower()
if not email:
raise ValueError("Email address is required but missing or empty")
full_name = data.get("full_name") or ""
name_parts = full_name.split(" ", 1)
first_name = name_parts[0] if name_parts else ""
last_name = name_parts[1] if len(name_parts) > 1 else ""
user, _ = User.objects.get_or_create(
email=email,
defaults={"first_name": first_name, "last_name": last_name},
)
return user
def put(self, request, instance_id):
"""
This implements the Exoscale onboarding flow.
https://docs.servala.com/exoscale-osb.html#_onboarding
https://community.exoscale.com/vendor/marketplace-managed-svc-prov/#provisioning
"""
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON in request body"}, status=400)
context = data.get("context", {})
parameters = data.get("parameters", {})
organization_guid = context.get("organization_guid") or data.get(
"organization_guid"
)
organization_name = context.get("organization_name")
organization_display_name = context.get(
"organization_display_name", organization_name
)
users = parameters.get("users", [])
service_id = data.get("service_id")
plan_id = data.get("plan_id")
if not organization_guid:
return self._error("organization_guid is required but missing.")
if not organization_name:
return self._error("organization_name is required but missing.")
if not users:
return self._error("users array is required but missing.")
if len(users) != 1:
return self._error("users array is expected to contain a single user.")
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:
user = self._get_user(users[0])
except Exception as e:
return self._error(f"Unable to create user: {e}")
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}"
)
exoscale_origin = get_exoscale_origin()
with suppress(Organization.DoesNotExist):
organization = Organization.objects.get(
osb_guid=organization_guid, origin=exoscale_origin
)
self._send_service_welcome_email(
request, organization, user, service, service_offering
)
return JsonResponse({"message": "Service already enabled"}, status=200)
odoo_data = {
"company_name": organization_display_name,
"invoice_email": user.email,
}
try:
with transaction.atomic():
billing_entity = BillingEntity.create_from_data(
name=f"{organization_display_name} (Exoscale)", odoo_data=odoo_data
)
organization = Organization(
name=organization_display_name,
billing_entity=billing_entity,
origin=exoscale_origin,
osb_guid=organization_guid,
)
organization = Organization.create_organization(organization, user)
self._send_invitation_email(request, organization, user)
self._send_service_welcome_email(
request, organization, user, service, service_offering
)
return JsonResponse(
{"message": "Successfully enabled service"}, status=201
)
except Exception as e:
logger.error(f"Error creating organization for Exoscale: {str(e)}")
return JsonResponse({"error": "Internal server error"}, status=500)
def _send_invitation_email(self, request, organization, user):
subject = f"Welcome to Servala - {organization.name}"
url = request.build_absolute_uri(organization.urls.base)
message = f"""Hello {user.first_name or user.email},
You have been invited to join the organization "{organization.name}" on Servala Portal.
You can access your organization at: {url}
Please use this email address ({user.email}) when prompted to log in.
Best regards,
The Servala Team"""
send_mail(
subject=subject,
message=message,
from_email=settings.EMAIL_DEFAULT_FROM,
recipient_list=[user.email],
fail_silently=False,
)
def _send_service_welcome_email(
self, request, organization, user, service, service_offering
):
service_path = f"{organization.urls.services}{service.slug}/offering/{service_offering.id}/"
service_url = request.build_absolute_uri(service_path)
subject = f"Get started with {service.name} - {organization.name}"
message = f"""Hello {user.first_name or user.email},
Your organization "{organization.name}" is now ready on Servala Portal!
You can create your {service.name} service directly here:
{service_url}
Or browse all available services at: {request.build_absolute_uri(organization.urls.services)}
Need help? Contact our support team through the portal.
Best regards,
The Servala Team"""
send_mail(
subject=subject,
message=message,
from_email=settings.EMAIL_DEFAULT_FROM,
recipient_list=[user.email],
fail_silently=False,
)

View file

@ -11,7 +11,6 @@ from servala.core.models import (
Organization, Organization,
OrganizationMembership, OrganizationMembership,
OrganizationOrigin, OrganizationOrigin,
Plan,
Service, Service,
ServiceCategory, ServiceCategory,
ServiceDefinition, ServiceDefinition,
@ -99,11 +98,6 @@ class ServiceCategoryAdmin(admin.ModelAdmin):
autocomplete_fields = ("parent",) autocomplete_fields = ("parent",)
class PlanInline(admin.TabularInline):
model = Plan
extra = 1
@admin.register(Service) @admin.register(Service)
class ServiceAdmin(admin.ModelAdmin): class ServiceAdmin(admin.ModelAdmin):
list_display = ("name", "category") list_display = ("name", "category")
@ -230,14 +224,6 @@ class ControlPlaneAdmin(admin.ModelAdmin):
test_kubernetes_connection.short_description = _("Test Kubernetes connection") test_kubernetes_connection.short_description = _("Test Kubernetes connection")
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
list_display = ("name", "service_offering", "term")
list_filter = ("service_offering", "term")
search_fields = ("name", "description")
autocomplete_fields = ("service_offering",)
@admin.register(ServiceDefinition) @admin.register(ServiceDefinition)
class ServiceDefinitionAdmin(admin.ModelAdmin): class ServiceDefinitionAdmin(admin.ModelAdmin):
form = ServiceDefinitionAdminForm form = ServiceDefinitionAdminForm
@ -317,7 +303,4 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
list_filter = ("service", "provider") list_filter = ("service", "provider")
search_fields = ("description",) search_fields = ("description",)
autocomplete_fields = ("service", "provider") autocomplete_fields = ("service", "provider")
inlines = ( inlines = (ControlPlaneCRDInline,)
ControlPlaneCRDInline,
PlanInline,
)

View file

@ -84,3 +84,5 @@ def check_servala_production_settings(app_configs, **kwargs):
id="servala.W001", id="servala.W001",
) )
) )
return errors

View file

@ -0,0 +1,11 @@
from servala.core.models import OrganizationOrigin
def get_exoscale_origin():
origin, _ = OrganizationOrigin.objects.get_or_create(
name="exoscale-marketplace",
defaults={
"description": "Organizations created via Exoscale marketplace onboarding"
},
)
return origin

View file

@ -35,7 +35,10 @@ class Migration(migrations.Migration):
name="external_links", name="external_links",
field=models.JSONField( field=models.JSONField(
blank=True, blank=True,
help_text='JSON array of link objects: {"url": "", "title": "", "featured": false}. Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages.', help_text=(
'JSON array of link objects: {"url": "", "title": "", "featured": false}. '
"Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages."
),
null=True, null=True,
verbose_name="External links", verbose_name="External links",
), ),

View file

@ -0,0 +1,49 @@
# Generated by Django 5.2.6 on 2025-10-02 07:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0007_controlplane_user_info_and_more"),
]
operations = [
migrations.AddField(
model_name="organization",
name="osb_guid",
field=models.CharField(
blank=True,
help_text="Open Service Broker GUID, used for organizations created via OSB API",
max_length=100,
null=True,
verbose_name="OSB GUID",
),
),
migrations.AddField(
model_name="service",
name="osb_service_id",
field=models.CharField(
blank=True,
help_text="Open Service Broker service ID for API matching",
max_length=100,
null=True,
verbose_name="OSB Service ID",
),
),
migrations.AddField(
model_name="serviceoffering",
name="osb_plan_id",
field=models.CharField(
blank=True,
help_text="Open Service Broker plan ID for API matching",
max_length=100,
null=True,
verbose_name="OSB Plan ID",
),
),
migrations.DeleteModel(
name="Plan",
),
]

View file

@ -9,7 +9,6 @@ from .service import (
CloudProvider, CloudProvider,
ControlPlane, ControlPlane,
ControlPlaneCRD, ControlPlaneCRD,
Plan,
Service, Service,
ServiceCategory, ServiceCategory,
ServiceDefinition, ServiceDefinition,
@ -27,7 +26,6 @@ __all__ = [
"OrganizationMembership", "OrganizationMembership",
"OrganizationOrigin", "OrganizationOrigin",
"OrganizationRole", "OrganizationRole",
"Plan",
"Service", "Service",
"ServiceCategory", "ServiceCategory",
"ServiceInstance", "ServiceInstance",

View file

@ -53,6 +53,15 @@ class Organization(ServalaModelMixin, models.Model):
odoo_sale_order_name = models.CharField( odoo_sale_order_name = models.CharField(
max_length=100, null=True, blank=True, verbose_name=_("Odoo Sale Order Name") max_length=100, null=True, blank=True, verbose_name=_("Odoo Sale Order Name")
) )
osb_guid = models.CharField(
max_length=100,
null=True,
blank=True,
verbose_name=_("OSB GUID"),
help_text=_(
"Open Service Broker GUID, used for organizations created via OSB API"
),
)
class urls(urlman.Urls): class urls(urlman.Urls):
base = "/org/{self.slug}/" base = "/org/{self.slug}/"

View file

@ -80,6 +80,13 @@ class Service(ServalaModelMixin, models.Model):
"Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages." "Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages."
), ),
) )
osb_service_id = models.CharField(
max_length=100,
null=True,
blank=True,
verbose_name=_("OSB Service ID"),
help_text=_("Open Service Broker service ID for API matching"),
)
class Meta: class Meta:
verbose_name = _("Service") verbose_name = _("Service")
@ -297,35 +304,6 @@ class CloudProvider(ServalaModelMixin, models.Model):
return self.name return self.name
class Plan(ServalaModelMixin, models.Model):
"""
Each service offering can have multiple plans, e.g. for different tiers.
"""
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description"))
# TODO schema
features = models.JSONField(verbose_name=_("Features"), null=True, blank=True)
# TODO schema
pricing = models.JSONField(verbose_name=_("Pricing"), null=True, blank=True)
term = models.PositiveIntegerField(
verbose_name=_("Term"), help_text=_("Term in months")
)
service_offering = models.ForeignKey(
to="ServiceOffering",
on_delete=models.PROTECT,
related_name="plans",
verbose_name=_("Service offering"),
)
class Meta:
verbose_name = _("Plan")
verbose_name_plural = _("Plans")
def __str__(self):
return self.name
def validate_api_definition(value): def validate_api_definition(value):
required_fields = ("group", "version", "kind") required_fields = ("group", "version", "kind")
return validate_dict(value, required_fields) return validate_dict(value, required_fields)
@ -533,6 +511,13 @@ class ServiceOffering(ServalaModelMixin, models.Model):
verbose_name=_("Provider"), verbose_name=_("Provider"),
) )
description = models.TextField(blank=True, verbose_name=_("Description")) description = models.TextField(blank=True, verbose_name=_("Description"))
osb_plan_id = models.CharField(
max_length=100,
null=True,
blank=True,
verbose_name=_("OSB Plan ID"),
help_text=_("Open Service Broker plan ID for API matching"),
)
class Meta: class Meta:
verbose_name = _("Service offering") verbose_name = _("Service offering")

View file

@ -21,10 +21,7 @@
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card"> <div class="card">
<div class="card-header card-header-with-logo"> <div class="card-header card-header-with-logo">
{% if service.logo %} {% if service.logo %}<img src="{{ service.logo.url }}" alt="{{ service.name }}">{% endif %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}">
{% endif %}
<div class="card-header-content"> <div class="card-header-content">
<h4>{{ service.name }}</h4> <h4>{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small> <small class="text-muted">{{ service.category }}</small>

View file

@ -72,9 +72,13 @@ EMAIL_HOST_USER = os.environ.get("SERVALA_EMAIL_USER", "")
EMAIL_HOST_PASSWORD = os.environ.get("SERVALA_EMAIL_PASSWORD", "") EMAIL_HOST_PASSWORD = os.environ.get("SERVALA_EMAIL_PASSWORD", "")
EMAIL_USE_TLS = os.environ.get("SERVALA_EMAIL_TLS", "False") == "True" EMAIL_USE_TLS = os.environ.get("SERVALA_EMAIL_TLS", "False") == "True"
EMAIL_USE_SSL = os.environ.get("SERVALA_EMAIL_SSL", "False") == "True" EMAIL_USE_SSL = os.environ.get("SERVALA_EMAIL_SSL", "False") == "True"
EMAIL_DEFAULT_FROM = os.environ.get("SERVALA_EMAIL_DEFAULT_FROM", "noreply@servala.com")
SERVALA_DEFAULT_ORIGIN = int(os.environ.get("SERVALA_DEFAULT_ORIGIN", "1")) SERVALA_DEFAULT_ORIGIN = int(os.environ.get("SERVALA_DEFAULT_ORIGIN", "1"))
OSB_USERNAME = os.environ.get("SERVALA_OSB_USERNAME")
OSB_PASSWORD = os.environ.get("SERVALA_OSB_PASSWORD")
SOCIALACCOUNT_PROVIDERS = { SOCIALACCOUNT_PROVIDERS = {
"openid_connect": { "openid_connect": {
"APPS": [ "APPS": [
@ -159,6 +163,7 @@ INSTALLED_APPS = [
"allauth.socialaccount.providers.openid_connect", "allauth.socialaccount.providers.openid_connect",
"auditlog", "auditlog",
"servala.core", "servala.core",
"servala.api",
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View file

@ -0,0 +1,37 @@
"""
Django test settings that extend the main settings.
This file imports all settings from the main settings module and
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"
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
"OPTIONS": {"timeout": 10},
}
}
OSB_USERNAME = "testuser"
OSB_PASSWORD = "testpass"
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

View file

@ -20,6 +20,7 @@ urlpatterns = [
# - accounts/keycloak/login/callback/ # - accounts/keycloak/login/callback/
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/", include("servala.api.urls")),
] ]
# Serve static and media files in development # Serve static and media files in development

View file

@ -1,3 +1,5 @@
import base64
import pytest import pytest
from servala.core.models import ( from servala.core.models import (
@ -6,6 +8,12 @@ from servala.core.models import (
OrganizationOrigin, OrganizationOrigin,
User, User,
) )
from servala.core.models.service import (
CloudProvider,
Service,
ServiceCategory,
ServiceOffering,
)
@pytest.fixture @pytest.fixture
@ -30,3 +38,76 @@ def org_owner(organization):
organization=organization, user=user, role="owner" organization=organization, user=user, role="owner"
) )
return user return user
@pytest.fixture
def test_service_category():
return ServiceCategory.objects.create(
name="Databases",
description="Database services",
)
@pytest.fixture
def test_service(test_service_category):
return Service.objects.create(
name="Redis",
slug="redis",
category=test_service_category,
description="Redis database service",
osb_service_id="test-service-123",
)
@pytest.fixture
def test_cloud_provider():
return CloudProvider.objects.create(
name="Exoscale",
description="Exoscale cloud provider",
)
@pytest.fixture
def test_service_offering(test_service, test_cloud_provider):
return ServiceOffering.objects.create(
service=test_service,
provider=test_cloud_provider,
description="Redis on Exoscale",
osb_plan_id="test-plan-123",
)
@pytest.fixture
def osb_client(client):
credentials = base64.b64encode(b"testuser:testpass").decode("ascii")
client.defaults = {"HTTP_AUTHORIZATION": f"Basic {credentials}"}
return client
@pytest.fixture
def mock_odoo_success(mocker):
"""
Mock Odoo client with successful responses for organization creation.
Returns the mock object for further customization if needed.
"""
mock_client = mocker.patch("servala.core.models.organization.CLIENT")
# Default successful responses for organization creation
mock_client.execute.side_effect = [
123, # company_id
456, # invoice_address_id
789, # sale_order_id
]
mock_client.search_read.return_value = [{"name": "SO001"}]
return mock_client
@pytest.fixture
def mock_odoo_failure(mocker):
"""
Mock Odoo client that raises an exception to simulate failure.
"""
mock_client = mocker.patch("servala.core.models.organization.CLIENT")
mock_client.execute.side_effect = Exception("Odoo connection failed")
return mock_client

View file

@ -0,0 +1,422 @@
import json
import pytest
from django.core import mail
from django_scopes import scopes_disabled
from servala.core.models import Organization, OrganizationOrigin, User
@pytest.fixture
def exoscale_origin():
origin, _ = OrganizationOrigin.objects.get_or_create(
name="exoscale-marketplace",
defaults={
"description": "Organizations created via Exoscale marketplace onboarding"
},
)
return origin
@pytest.fixture
def instance_id():
return "test-instance-123"
@pytest.fixture
def valid_osb_payload():
return {
"service_id": None,
"plan_id": None,
"context": {
"organization_guid": "test-org-guid-123",
"organization_name": "Test Organization",
"organization_display_name": "Test Organization Display",
},
"parameters": {
"users": [
{
"email": "test@example.com",
"full_name": "Test User",
"role": "owner",
}
]
},
}
@pytest.mark.django_db
def test_successful_onboarding_new_organization(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 201
response_data = json.loads(response.content)
assert response_data["message"] == "Successfully enabled service"
org = Organization.objects.get(osb_guid="test-org-guid-123")
assert org.name == "Test Organization Display"
assert org.origin == exoscale_origin
assert org.namespace.startswith("org-")
user = User.objects.get(email="test@example.com")
assert user.first_name == "Test"
assert user.last_name == "User"
with scopes_disabled():
membership = org.memberships.get(user=user)
assert membership.role == "owner"
billing_entity = org.billing_entity
assert billing_entity.name == "Test Organization Display (Exoscale)"
assert billing_entity.odoo_company_id == 123
assert billing_entity.odoo_invoice_id == 456
assert org.odoo_sale_order_id == 789
assert org.odoo_sale_order_name == "SO001"
assert len(mail.outbox) == 2
invitation_email = mail.outbox[0]
assert invitation_email.subject == "Welcome to Servala - Test Organization Display"
assert "test@example.com" in invitation_email.to
welcome_email = mail.outbox[1]
assert welcome_email.subject == "Get started with Redis - Test Organization Display"
assert "redis/offering/" in welcome_email.body
@pytest.mark.django_db
def test_duplicate_organization_returns_existing(
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
Organization.objects.create(
name="Existing Org",
osb_guid="test-org-guid-123",
origin=exoscale_origin,
)
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 200
response_data = json.loads(response.content)
assert response_data["message"] == "Service already enabled"
assert Organization.objects.filter(osb_guid="test-org-guid-123").count() == 1
assert len(mail.outbox) == 1 # Only one email was sent
@pytest.mark.django_db
def test_unauthenticated_osb_api_request_fails(
client,
test_service,
test_service_offering,
valid_osb_payload,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
response = client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 403
@pytest.mark.django_db
@pytest.mark.parametrize(
"field_to_remove,expected_error",
[
(
("context", "organization_guid"),
"organization_guid is required but missing",
),
(
("context", "organization_name"),
"organization_name is required but missing",
),
("service_id", "service_id is required but missing"),
("plan_id", "plan_id is required but missing"),
(("parameters", "users"), "users array is required but missing"),
],
)
def test_missing_required_fields_error(
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
field_to_remove,
expected_error,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
if isinstance(field_to_remove, tuple):
if field_to_remove[0] == "context":
del valid_osb_payload["context"][field_to_remove[1]]
elif field_to_remove[0] == "parameters":
del valid_osb_payload["parameters"][field_to_remove[1]]
else:
if field_to_remove in valid_osb_payload:
del valid_osb_payload[field_to_remove]
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert expected_error in response_data["error"]
@pytest.mark.django_db
def test_invalid_service_id_error(osb_client, valid_osb_payload, instance_id):
valid_osb_payload["service_id"] = 99999
valid_osb_payload["plan_id"] = 1
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Unknown service_id: 99999" in response_data["error"]
@pytest.mark.django_db
def test_invalid_plan_id_error(
osb_client, test_service, valid_osb_payload, instance_id
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = 99999
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert (
f"Unknown plan_id: 99999 for service_id: {test_service.osb_service_id}"
in response_data["error"]
)
@pytest.mark.django_db
def test_empty_users_array_error(
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"] = []
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "users array is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_multiple_users_error(
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"] = [
{"email": "user1@example.com", "full_name": "User One"},
{"email": "user2@example.com", "full_name": "User Two"},
]
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "users array is expected to contain a single user" in response_data["error"]
@pytest.mark.django_db
def test_empty_email_address_error(
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"] = [
{"email": "", "full_name": "User With No Email"},
]
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Unable to create user:" in response_data["error"]
@pytest.mark.django_db
def test_invalid_json_error(osb_client, instance_id):
response = osb_client.put(
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_user_creation_with_name_parsing(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"][0]["full_name"] = "John Doe Smith"
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 201
user = User.objects.get(email="test@example.com")
assert user.first_name == "John"
assert user.last_name == "Doe Smith"
@pytest.mark.django_db
def test_email_normalization(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM "
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 201
user = User.objects.get(email="test@example.com")
assert user.email == "test@example.com"
@pytest.mark.django_db
def test_odoo_integration_failure_handling(
mock_odoo_failure,
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 500
response_data = json.loads(response.content)
assert response_data["error"] == "Internal server error"
@pytest.mark.django_db
def test_organization_creation_with_context_only(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
exoscale_origin,
instance_id,
):
payload = {
"service_id": test_service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id,
"context": {
"organization_guid": "fallback-org-guid",
"organization_name": "Fallback Organization",
},
"parameters": {
"users": [
{
"email": "fallback@example.com",
"full_name": "Fallback User",
}
]
},
}
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 201
org = Organization.objects.get(osb_guid="fallback-org-guid")
assert org is not None

14
uv.lock generated
View file

@ -963,6 +963,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" },
] ]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@ -1234,6 +1246,7 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "pytest-django" }, { name = "pytest-django" },
{ name = "pytest-mock" },
] ]
[package.metadata] [package.metadata]
@ -1272,6 +1285,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4.2" }, { name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-cov", specifier = ">=6.3.0" }, { name = "pytest-cov", specifier = ">=6.3.0" },
{ name = "pytest-django", specifier = ">=4.11.1" }, { name = "pytest-django", specifier = ">=4.11.1" },
{ name = "pytest-mock", specifier = ">=3.15.1" },
] ]
[[package]] [[package]]