From 81e55a2134ba48db36d5f568138199b9aead60ee Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 31 Jan 2025 15:34:10 +0100 Subject: [PATCH] initial work --- hub/broker/__init__.py | 0 hub/broker/admin.py | 13 +++ hub/broker/apps.py | 6 ++ hub/broker/authentication.py | 22 +++++ hub/broker/migrations/0001_initial.py | 86 +++++++++++++++++++ hub/broker/migrations/__init__.py | 0 hub/broker/models.py | 28 +++++++ hub/broker/serializers.py | 49 +++++++++++ hub/broker/tests.py | 3 + hub/broker/urls.py | 12 +++ hub/broker/views.py | 114 ++++++++++++++++++++++++++ hub/settings.py | 1 + hub/urls.py | 1 + 13 files changed, 335 insertions(+) create mode 100644 hub/broker/__init__.py create mode 100644 hub/broker/admin.py create mode 100644 hub/broker/apps.py create mode 100644 hub/broker/authentication.py create mode 100644 hub/broker/migrations/0001_initial.py create mode 100644 hub/broker/migrations/__init__.py create mode 100644 hub/broker/models.py create mode 100644 hub/broker/serializers.py create mode 100644 hub/broker/tests.py create mode 100644 hub/broker/urls.py create mode 100644 hub/broker/views.py diff --git a/hub/broker/__init__.py b/hub/broker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hub/broker/admin.py b/hub/broker/admin.py new file mode 100644 index 0000000..a34027b --- /dev/null +++ b/hub/broker/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from .models import ServiceBrokerUser, ServiceInstance + + +@admin.register(ServiceBrokerUser) +class ServiceBrokerUserAdmin(admin.ModelAdmin): + list_display = ["user"] + search_fields = ["user"] + + +@admin.register(ServiceInstance) +class ServiceInstanceAdmin(admin.ModelAdmin): + list_display = ("instance_id", "offering") diff --git a/hub/broker/apps.py b/hub/broker/apps.py new file mode 100644 index 0000000..4e69c28 --- /dev/null +++ b/hub/broker/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BrokerConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "hub.broker" diff --git a/hub/broker/authentication.py b/hub/broker/authentication.py new file mode 100644 index 0000000..1885fad --- /dev/null +++ b/hub/broker/authentication.py @@ -0,0 +1,22 @@ +from rest_framework.authentication import BasicAuthentication +from rest_framework.exceptions import AuthenticationFailed +from django.contrib.auth.models import User +from .models import ServiceBrokerUser + + +class ServiceBrokerAuthentication(BasicAuthentication): + def authenticate_credentials(self, userid, password, request=None): + try: + user = User.objects.get(username=userid) + if not user.check_password(password): + raise AuthenticationFailed("Invalid password") + + # Ensure user has broker permissions + try: + broker_user = ServiceBrokerUser.objects.get(user=user) + except ServiceBrokerUser.DoesNotExist: + raise AuthenticationFailed("User is not authorized for broker access") + + return (user, None) + except User.DoesNotExist: + raise AuthenticationFailed("Invalid username") diff --git a/hub/broker/migrations/0001_initial.py b/hub/broker/migrations/0001_initial.py new file mode 100644 index 0000000..9e9b13b --- /dev/null +++ b/hub/broker/migrations/0001_initial.py @@ -0,0 +1,86 @@ +# Generated by Django 5.1.5 on 2025-01-31 14:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("services", "0002_lead_offering"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ServiceBrokerUser", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "allowed_offerings", + models.ManyToManyField(blank=True, to="services.serviceoffering"), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="ServiceInstance", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("instance_id", models.CharField(max_length=100, unique=True)), + ("organization_guid", models.CharField(max_length=100)), + ("space_guid", models.CharField(max_length=100)), + ("organization_name", models.CharField(max_length=200)), + ("parameters", models.JSONField(default=dict)), + ("context", models.JSONField(default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "lead", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="services.lead", + ), + ), + ( + "offering", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="services.serviceoffering", + ), + ), + ( + "plan", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="services.plan" + ), + ), + ], + ), + ] diff --git a/hub/broker/migrations/__init__.py b/hub/broker/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hub/broker/models.py b/hub/broker/models.py new file mode 100644 index 0000000..7d3c103 --- /dev/null +++ b/hub/broker/models.py @@ -0,0 +1,28 @@ +from django.db import models +from django.contrib.auth.models import User +from hub.services.models import ServiceOffering + + +class ServiceBrokerUser(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + allowed_offerings = models.ManyToManyField(ServiceOffering, blank=True) + + def __str__(self): + return f"Broker User: {self.user.username}" + + +class ServiceInstance(models.Model): + instance_id = models.CharField(max_length=100, unique=True) + offering = models.ForeignKey("services.ServiceOffering", on_delete=models.PROTECT) + plan = models.ForeignKey("services.Plan", on_delete=models.PROTECT) + organization_guid = models.CharField(max_length=100) + space_guid = models.CharField(max_length=100) + organization_name = models.CharField(max_length=200) + parameters = models.JSONField(default=dict) + context = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + lead = models.ForeignKey("services.Lead", on_delete=models.SET_NULL, null=True) + + def __str__(self): + return f"{self.offering.service.name} on {self.offering.cloud_provider.name} - {self.instance_id}" diff --git a/hub/broker/serializers.py b/hub/broker/serializers.py new file mode 100644 index 0000000..6ac63fd --- /dev/null +++ b/hub/broker/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers +from hub.services.models import ServiceOffering, Plan + + +class ServiceUserSerializer(serializers.Serializer): + email = serializers.EmailField() + full_name = serializers.CharField() + role = serializers.CharField() + + +class ProvisioningContextSerializer(serializers.Serializer): + platform = serializers.CharField() + organization_guid = serializers.CharField() + space_guid = serializers.CharField() + organization_name = serializers.CharField() + organization_display_name = serializers.CharField() + + +class ProvisioningParametersSerializer(serializers.Serializer): + users = ServiceUserSerializer(many=True) + + +class ServiceInstanceProvisioningSerializer(serializers.Serializer): + service_id = serializers.CharField() # This will now represent the offering_id + plan_id = serializers.CharField() + organization_guid = serializers.CharField() + space_guid = serializers.CharField() + parameters = ProvisioningParametersSerializer() + context = ProvisioningContextSerializer() + + def validate_service_id(self, value): + """Validate the service_id which now represents the offering_id""" + try: + return ServiceOffering.objects.get(pk=value) + except ServiceOffering.DoesNotExist: + raise serializers.ValidationError("Invalid offering_id") + + def validate_plan_id(self, value): + try: + # Get the plan and ensure it belongs to the selected offering + plan = Plan.objects.get(pk=value) + offering = self.initial_data.get("service_id") + if str(plan.offering.id) != offering: + raise serializers.ValidationError( + "Plan does not belong to the selected offering" + ) + return plan + except Plan.DoesNotExist: + raise serializers.ValidationError("Invalid plan_id") diff --git a/hub/broker/tests.py b/hub/broker/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/hub/broker/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/hub/broker/urls.py b/hub/broker/urls.py new file mode 100644 index 0000000..7c4615a --- /dev/null +++ b/hub/broker/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ServiceBrokerViewSet + +app_name = "broker" + +router = DefaultRouter(trailing_slash=False) +router.register("v2", ServiceBrokerViewSet, basename="broker") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/hub/broker/views.py b/hub/broker/views.py new file mode 100644 index 0000000..718d0bb --- /dev/null +++ b/hub/broker/views.py @@ -0,0 +1,114 @@ +import logging +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.decorators import action +from django.shortcuts import get_object_or_404 +from hub.services.models import ServiceOffering, Lead +from hub.services.odoo import OdooAPI +from .models import ServiceInstance, ServiceBrokerUser +from .serializers import ServiceInstanceProvisioningSerializer +from .authentication import ServiceBrokerAuthentication + +logger = logging.getLogger(__name__) + + +class ServiceBrokerViewSet(viewsets.ViewSet): + authentication_classes = [ServiceBrokerAuthentication] + + def _create_lead_from_provision_data(self, offering, plan, parameters, context): + """Create a lead in Odoo from provisioning data""" + # Get the first user from parameters as the main contact + user_data = parameters["users"][0] + + lead = Lead( + service=offering.service, + offering=offering, + plan=plan, + name=user_data["full_name"], + company=context["organization_display_name"], + email=user_data["email"], + phone="", # Not provided in broker API + ) + + try: + odoo = OdooAPI() + lead.odoo_lead_id = odoo.create_lead(lead) + lead.save() + return lead + except Exception as e: + logger.error(f"Failed to create lead in Odoo: {str(e)}") + raise + + @action( + detail=False, + methods=["put"], + url_path="service_instances/(?P[^/.]+)", + ) + def provision_instance(self, request, instance_id): + # Validate request data + serializer = ServiceInstanceProvisioningSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Get broker user and check offering access + broker_user = get_object_or_404(ServiceBrokerUser, user=request.user) + offering = serializer.validated_data["service_id"] # This is now the offering + + if not broker_user.allowed_offerings.filter(id=offering.id).exists(): + return Response( + {"error": "Service offering not available for this user"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Check if instance already exists + if ServiceInstance.objects.filter(instance_id=instance_id).exists(): + return Response(status=status.HTTP_200_OK) + + try: + # Create lead in Odoo + lead = self._create_lead_from_provision_data( + offering=offering, + plan=serializer.validated_data["plan_id"], + parameters=serializer.validated_data["parameters"], + context=serializer.validated_data["context"], + ) + + # Create service instance + instance = ServiceInstance.objects.create( + instance_id=instance_id, + offering=offering, + plan=serializer.validated_data["plan_id"], + organization_guid=serializer.validated_data["organization_guid"], + space_guid=serializer.validated_data["space_guid"], + organization_name=serializer.validated_data["context"][ + "organization_name" + ], + parameters=serializer.validated_data["parameters"], + context=serializer.validated_data["context"], + lead=lead, + ) + + return Response( + { + "dashboard_url": f"/services/{offering.service.slug}", + "operation": "provision", + }, + status=status.HTTP_201_CREATED, + ) + + except Exception as e: + logger.error(f"Error provisioning instance: {str(e)}") + return Response( + {"error": "Failed to provision service instance"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + @action( + detail=False, + methods=["delete"], + url_path="service_instances/(?P[^/.]+)", + ) + def deprovision_instance(self, request, instance_id): + instance = get_object_or_404(ServiceInstance, instance_id=instance_id) + instance.delete() + return Response(status=status.HTTP_200_OK) diff --git a/hub/settings.py b/hub/settings.py index 9e73b30..76c4830 100644 --- a/hub/settings.py +++ b/hub/settings.py @@ -60,6 +60,7 @@ INSTALLED_APPS = [ "schema_viewer", # local "hub.services", + "hub.broker", ] if DEBUG: INSTALLED_APPS += ["django_browser_reload"] diff --git a/hub/urls.py b/hub/urls.py index 37dced0..36261aa 100644 --- a/hub/urls.py +++ b/hub/urls.py @@ -6,6 +6,7 @@ from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), path("", include("hub.services.urls")), + path("broker/", include("hub.broker.urls", namespace="broker")), ] if settings.DEBUG: urlpatterns += [