diff --git a/.env.example b/.env.example index 998150c..ab361d7 100644 --- a/.env.example +++ b/.env.example @@ -40,7 +40,6 @@ 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' @@ -69,7 +68,3 @@ 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='' diff --git a/README.md b/README.md index 1192060..89d0526 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,6 @@ 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. diff --git a/pyproject.toml b/pyproject.toml index 273101a..a4061d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ dev = [ "pytest>=8.4.2", "pytest-cov>=6.3.0", "pytest-django>=4.11.1", - "pytest-mock>=3.15.1", ] [tool.isort] @@ -55,7 +54,7 @@ ignore = "E203,W503" extend_exclude = "src/servala/static/mazer" [tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "servala.settings_test" +DJANGO_SETTINGS_MODULE = "servala.settings" addopts = "-p no:doctest -p no:pastebin -p no:nose --cov=./ --cov-report=term-missing:skip-covered" testpaths = "src/tests" pythonpath = "src" diff --git a/src/servala/api/__init__.py b/src/servala/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/servala/api/apps.py b/src/servala/api/apps.py deleted file mode 100644 index 33c94f2..0000000 --- a/src/servala/api/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ApiConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "servala.api" diff --git a/src/servala/api/permissions.py b/src/servala/api/permissions.py deleted file mode 100644 index 25a015d..0000000 --- a/src/servala/api/permissions.py +++ /dev/null @@ -1,50 +0,0 @@ -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() diff --git a/src/servala/api/urls.py b/src/servala/api/urls.py deleted file mode 100644 index 244d1f4..0000000 --- a/src/servala/api/urls.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.urls import path - -from . import views - -app_name = "api" - -urlpatterns = [ - path( - "osb/v2/service_instances/", - views.OSBServiceInstanceView.as_view(), - name="osb_service_instance", - ), -] diff --git a/src/servala/api/views.py b/src/servala/api/views.py deleted file mode 100644 index 48845d0..0000000 --- a/src/servala/api/views.py +++ /dev/null @@ -1,193 +0,0 @@ -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, - ) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 073d444..0b1b980 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -11,6 +11,7 @@ from servala.core.models import ( Organization, OrganizationMembership, OrganizationOrigin, + Plan, Service, ServiceCategory, ServiceDefinition, @@ -98,6 +99,11 @@ 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") @@ -224,6 +230,14 @@ 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 @@ -303,4 +317,7 @@ class ServiceOfferingAdmin(admin.ModelAdmin): list_filter = ("service", "provider") search_fields = ("description",) autocomplete_fields = ("service", "provider") - inlines = (ControlPlaneCRDInline,) + inlines = ( + ControlPlaneCRDInline, + PlanInline, + ) diff --git a/src/servala/core/checks.py b/src/servala/core/checks.py index b8f5f74..d1bb708 100644 --- a/src/servala/core/checks.py +++ b/src/servala/core/checks.py @@ -84,5 +84,3 @@ def check_servala_production_settings(app_configs, **kwargs): id="servala.W001", ) ) - - return errors diff --git a/src/servala/core/exoscale.py b/src/servala/core/exoscale.py deleted file mode 100644 index 49b3f60..0000000 --- a/src/servala/core/exoscale.py +++ /dev/null @@ -1,11 +0,0 @@ -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 diff --git a/src/servala/core/migrations/0007_controlplane_user_info_and_more.py b/src/servala/core/migrations/0007_controlplane_user_info_and_more.py index 25c78a9..5dda2ed 100644 --- a/src/servala/core/migrations/0007_controlplane_user_info_and_more.py +++ b/src/servala/core/migrations/0007_controlplane_user_info_and_more.py @@ -35,10 +35,7 @@ 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", ), diff --git a/src/servala/core/migrations/0008_organization_osb_guid_service_osb_service_id_and_more.py b/src/servala/core/migrations/0008_organization_osb_guid_service_osb_service_id_and_more.py deleted file mode 100644 index 66d20a3..0000000 --- a/src/servala/core/migrations/0008_organization_osb_guid_service_osb_service_id_and_more.py +++ /dev/null @@ -1,49 +0,0 @@ -# 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", - ), - ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 22e8e8a..3fb0663 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -9,6 +9,7 @@ from .service import ( CloudProvider, ControlPlane, ControlPlaneCRD, + Plan, Service, ServiceCategory, ServiceDefinition, @@ -26,6 +27,7 @@ __all__ = [ "OrganizationMembership", "OrganizationOrigin", "OrganizationRole", + "Plan", "Service", "ServiceCategory", "ServiceInstance", diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 083bc50..997b0a2 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -53,15 +53,6 @@ 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}/" diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 43c9023..3677cca 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -80,13 +80,6 @@ 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") @@ -304,6 +297,35 @@ 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) @@ -511,13 +533,6 @@ 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") diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html index 3a48ff9..766b5bc 100644 --- a/src/servala/frontend/templates/frontend/organizations/services.html +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -21,7 +21,10 @@