Implement Exoscale onboarding API endpoint #199
23 changed files with 926 additions and 55 deletions
|
|
@ -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=''
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
0
src/servala/api/__init__.py
Normal file
0
src/servala/api/__init__.py
Normal file
6
src/servala/api/apps.py
Normal file
6
src/servala/api/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "servala.api"
|
||||||
50
src/servala/api/permissions.py
Normal file
50
src/servala/api/permissions.py
Normal 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
13
src/servala/api/urls.py
Normal 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>",
|
||||||
|
views.OSBServiceInstanceView.as_view(),
|
||||||
|
name="osb_service_instance",
|
||||||
|
),
|
||||||
|
]
|
||||||
193
src/servala/api/views.py
Normal file
193
src/servala/api/views.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -84,3 +84,5 @@ def check_servala_production_settings(app_configs, **kwargs):
|
||||||
id="servala.W001",
|
id="servala.W001",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
|
||||||
11
src/servala/core/exoscale.py
Normal file
11
src/servala/core/exoscale.py
Normal 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
|
||||||
|
|
@ -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",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}/"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
37
src/servala/settings_test.py
Normal file
37
src/servala/settings_test.py
Normal 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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
422
src/tests/test_api_exoscale.py
Normal file
422
src/tests/test_api_exoscale.py
Normal 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
14
uv.lock
generated
|
|
@ -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]]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue