Merge pull request 'Implement Exoscale onboarding API endpoint' (#199) from 37-exoscale-onboarding into main
Reviewed-on: #199
This commit is contained in:
commit
cc23730d33
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
|
||||
SERVALA_EMAIL_TLS='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.
|
||||
SERVALA_DEFAULT_ORIGIN='1'
|
||||
|
|
@ -68,3 +69,7 @@ SERVALA_ODOO_USERNAME=''
|
|||
SERVALA_ODOO_PASSWORD=''
|
||||
# Helpdesk team ID for support tickets in Odoo. Defaults to 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.
|
||||
|
||||
For testing mail sending, `smtp4dev` can be used:
|
||||
|
||||
```
|
||||
docker run --rm -it -p 5000:80 -p 2525:25 docker.io/rnwood/smtp4dev
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration happens using environment variables.
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ dev = [
|
|||
"pytest>=8.4.2",
|
||||
"pytest-cov>=6.3.0",
|
||||
"pytest-django>=4.11.1",
|
||||
"pytest-mock>=3.15.1",
|
||||
]
|
||||
|
||||
[tool.isort]
|
||||
|
|
@ -54,7 +55,7 @@ ignore = "E203,W503"
|
|||
extend_exclude = "src/servala/static/mazer"
|
||||
|
||||
[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"
|
||||
testpaths = "src/tests"
|
||||
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,
|
||||
OrganizationMembership,
|
||||
OrganizationOrigin,
|
||||
Plan,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDefinition,
|
||||
|
|
@ -99,11 +98,6 @@ class ServiceCategoryAdmin(admin.ModelAdmin):
|
|||
autocomplete_fields = ("parent",)
|
||||
|
||||
|
||||
class PlanInline(admin.TabularInline):
|
||||
model = Plan
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Service)
|
||||
class ServiceAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "category")
|
||||
|
|
@ -230,14 +224,6 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
|||
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)
|
||||
class ServiceDefinitionAdmin(admin.ModelAdmin):
|
||||
form = ServiceDefinitionAdminForm
|
||||
|
|
@ -317,7 +303,4 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
|
|||
list_filter = ("service", "provider")
|
||||
search_fields = ("description",)
|
||||
autocomplete_fields = ("service", "provider")
|
||||
inlines = (
|
||||
ControlPlaneCRDInline,
|
||||
PlanInline,
|
||||
)
|
||||
inlines = (ControlPlaneCRDInline,)
|
||||
|
|
|
|||
|
|
@ -84,3 +84,5 @@ def check_servala_production_settings(app_configs, **kwargs):
|
|||
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",
|
||||
field=models.JSONField(
|
||||
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,
|
||||
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,
|
||||
ControlPlane,
|
||||
ControlPlaneCRD,
|
||||
Plan,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDefinition,
|
||||
|
|
@ -27,7 +26,6 @@ __all__ = [
|
|||
"OrganizationMembership",
|
||||
"OrganizationOrigin",
|
||||
"OrganizationRole",
|
||||
"Plan",
|
||||
"Service",
|
||||
"ServiceCategory",
|
||||
"ServiceInstance",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,15 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
odoo_sale_order_name = models.CharField(
|
||||
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):
|
||||
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."
|
||||
),
|
||||
)
|
||||
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:
|
||||
verbose_name = _("Service")
|
||||
|
|
@ -297,35 +304,6 @@ class CloudProvider(ServalaModelMixin, models.Model):
|
|||
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):
|
||||
required_fields = ("group", "version", "kind")
|
||||
return validate_dict(value, required_fields)
|
||||
|
|
@ -533,6 +511,13 @@ class ServiceOffering(ServalaModelMixin, models.Model):
|
|||
verbose_name=_("Provider"),
|
||||
)
|
||||
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:
|
||||
verbose_name = _("Service offering")
|
||||
|
|
|
|||
|
|
@ -21,10 +21,7 @@
|
|||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-header card-header-with-logo">
|
||||
{% if service.logo %}
|
||||
<img src="{{ service.logo.url }}"
|
||||
alt="{{ service.name }}">
|
||||
{% endif %}
|
||||
{% if service.logo %}<img src="{{ service.logo.url }}" alt="{{ service.name }}">{% endif %}
|
||||
<div class="card-header-content">
|
||||
<h4>{{ service.name }}</h4>
|
||||
<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_USE_TLS = os.environ.get("SERVALA_EMAIL_TLS", "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"))
|
||||
|
||||
OSB_USERNAME = os.environ.get("SERVALA_OSB_USERNAME")
|
||||
OSB_PASSWORD = os.environ.get("SERVALA_OSB_PASSWORD")
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"openid_connect": {
|
||||
"APPS": [
|
||||
|
|
@ -159,6 +163,7 @@ INSTALLED_APPS = [
|
|||
"allauth.socialaccount.providers.openid_connect",
|
||||
"auditlog",
|
||||
"servala.core",
|
||||
"servala.api",
|
||||
]
|
||||
|
||||
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/
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/", include("servala.api.urls")),
|
||||
]
|
||||
|
||||
# Serve static and media files in development
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from servala.core.models import (
|
||||
|
|
@ -6,6 +8,12 @@ from servala.core.models import (
|
|||
OrganizationOrigin,
|
||||
User,
|
||||
)
|
||||
from servala.core.models.service import (
|
||||
CloudProvider,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceOffering,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -30,3 +38,76 @@ def org_owner(organization):
|
|||
organization=organization, user=user, role="owner"
|
||||
)
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
|
|
@ -1234,6 +1246,7 @@ dev = [
|
|||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "pytest-mock" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
|
@ -1272,6 +1285,7 @@ dev = [
|
|||
{ name = "pytest", specifier = ">=8.4.2" },
|
||||
{ name = "pytest-cov", specifier = ">=6.3.0" },
|
||||
{ name = "pytest-django", specifier = ">=4.11.1" },
|
||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue