From 8484fef8f2386c58ac26896051ade84510ac62a9 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 15:37:23 +0100 Subject: [PATCH 01/17] Build raw service list view, refactor mixins --- .../frontend/organizations/services.html | 39 +++++++++++++++++++ src/servala/frontend/urls.py | 5 +++ src/servala/frontend/views/__init__.py | 2 + src/servala/frontend/views/mixins.py | 26 +++++++++++++ src/servala/frontend/views/organization.py | 19 +-------- src/servala/static/js/autosubmit.js | 25 ++++++++++++ 6 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 src/servala/frontend/templates/frontend/organizations/services.html create mode 100644 src/servala/static/js/autosubmit.js diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html new file mode 100644 index 0000000..6c6bba9 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -0,0 +1,39 @@ +{% extends "frontend/base.html" %} +{% load i18n static %} +{% block html_title %} + {% block page_title %} + {% translate "Services" %} + {% endblock page_title %} +{% endblock html_title %} +{% block card_content %} +
+ {{ filter_form }} +
+ {% for service in services %} +
+
+
+
+
+ {% if service.logo %} + {{ service.name }} + {% endif %} +
{{ service.name }}
+
+ {% if service.description %}

{{ service.description }}

{% endif %} +
+ +
+
+
+ {% empty %} +

{% translate "No services found." %}

+ {% endfor %} + +{% endblock card_content %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 59a7ccc..43a4a51 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -20,6 +20,11 @@ urlpatterns = [ views.OrganizationUpdateView.as_view(), name="organization.details", ), + path( + "services/", + views.OrganizationServicesView.as_view(), + name="organization.services", + ), path( "", views.OrganizationDashboardView.as_view(), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 4ff574a..a9f323a 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -5,6 +5,7 @@ from .organization import ( OrganizationDashboardView, OrganizationUpdateView, ) +from .service import OrganizationServicesView __all__ = [ "IndexView", @@ -12,5 +13,6 @@ __all__ = [ "OrganizationCreateView", "OrganizationDashboardView", "OrganizationUpdateView", + "OrganizationServicesView", "ProfileView", ] diff --git a/src/servala/frontend/views/mixins.py b/src/servala/frontend/views/mixins.py index 055366f..e27c28d 100644 --- a/src/servala/frontend/views/mixins.py +++ b/src/servala/frontend/views/mixins.py @@ -57,3 +57,29 @@ class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView): if self.is_htmx and self._get_fragment(): return self.get(self.request, *self.args, **self.kwargs) return result + + +class OrganizationViewMixin(PermissionRequiredMixin): + model = Organization + context_object_name = "organization" + permission_required = "core.view_organization" + + @cached_property + def organization(self): + return self.request.organization + + def get_object(self): + return self.organization + + @cached_property + def object(self): + return self.get_object() + + def get_permission_object(self): + return self.organization + + def has_permission(self): + return ( + self.request.user.has_perm("core.view_organization", self.organization) + and super().has_permission() + ) diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index c91a251..7bfdee5 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,11 +1,10 @@ from django.shortcuts import redirect -from django.utils.functional import cached_property from django.views.generic import CreateView, DetailView from rules.contrib.views import AutoPermissionRequiredMixin from servala.core.models import Organization from servala.frontend.forms import OrganizationForm -from servala.frontend.views.mixins import HtmxUpdateView +from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): @@ -20,22 +19,6 @@ class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): return redirect(instance.urls.base) -class OrganizationViewMixin: - model = Organization - context_object_name = "organization" - - @cached_property - def organization(self): - return self.request.organization - - def get_object(self): - return self.organization - - @cached_property - def object(self): - return self.get_object() - - class OrganizationDashboardView( AutoPermissionRequiredMixin, OrganizationViewMixin, DetailView ): diff --git a/src/servala/static/js/autosubmit.js b/src/servala/static/js/autosubmit.js new file mode 100644 index 0000000..c8e54cb --- /dev/null +++ b/src/servala/static/js/autosubmit.js @@ -0,0 +1,25 @@ +/** + * Auto-submit functionality for forms + * + * This script looks for forms with the 'auto-submit' attribute + * and automatically submits them when any input, select, or textarea + * within the form changes, useful for search/filter forms. + */ +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('form[auto-submit]').forEach(form => { + const formElements = form.querySelectorAll('input, select, textarea') + + formElements.forEach(element => { + if (element.type === 'checkbox' || element.type === 'radio') { + element.addEventListener('change', () => { + form.submit() + }) + } + else if (element.tagName.toLowerCase() === 'select') { + element.addEventListener('change', () => { + form.submit() + }) + } + }) + }) +}) From aadeb58ec56d3da84243f1f16fc6c5e8c9b3092b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 15:49:31 +0100 Subject: [PATCH 02/17] Add filter form to service list --- src/servala/core/models/organization.py | 1 + src/servala/frontend/forms/service.py | 22 ++++++++++++++++++ src/servala/frontend/views/mixins.py | 4 +++- src/servala/frontend/views/service.py | 31 +++++++++++++++++++++++++ src/servala/static/css/servala.css | 8 +++++++ 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/servala/frontend/forms/service.py create mode 100644 src/servala/frontend/views/service.py diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index be6737e..e842c36 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -38,6 +38,7 @@ class Organization(ServalaModelMixin, models.Model): class urls(urlman.Urls): base = "/org/{self.slug}/" details = "{base}details/" + services = "{base}services/" @cached_property def slug(self): diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py new file mode 100644 index 0000000..6047293 --- /dev/null +++ b/src/servala/frontend/forms/service.py @@ -0,0 +1,22 @@ +from django import forms + +from servala.core.models import CloudProvider, ServiceCategory + + +class ServiceFilterForm(forms.Form): + category = forms.ModelChoiceField( + queryset=ServiceCategory.objects.all(), required=False + ) + cloud_provider = forms.ModelChoiceField( + queryset=CloudProvider.objects.all(), required=False + ) + q = forms.CharField(required=False) + + def filter_queryset(self, queryset): + if category := self.cleaned_data.get("category"): + queryset = queryset.filter(category=category) + if cloud_provider := self.cleaned_data.get("cloud_provider"): + queryset = queryset.filter( + service_offerings__control_planes__cloud_provider=cloud_provider + ) + return queryset diff --git a/src/servala/frontend/views/mixins.py b/src/servala/frontend/views/mixins.py index e27c28d..502a26a 100644 --- a/src/servala/frontend/views/mixins.py +++ b/src/servala/frontend/views/mixins.py @@ -1,6 +1,8 @@ from django.utils.functional import cached_property from django.views.generic import UpdateView -from rules.contrib.views import AutoPermissionRequiredMixin +from rules.contrib.views import AutoPermissionRequiredMixin, PermissionRequiredMixin + +from servala.core.models import Organization class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView): diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py new file mode 100644 index 0000000..708205e --- /dev/null +++ b/src/servala/frontend/views/service.py @@ -0,0 +1,31 @@ +from django.utils.functional import cached_property +from django.views.generic import ListView + +from servala.core.models import Service +from servala.frontend.forms.service import ServiceFilterForm +from servala.frontend.views.mixins import OrganizationViewMixin + + +class OrganizationServicesView(OrganizationViewMixin, ListView): + """View to display all available services for an organization.""" + + template_name = "frontend/organizations/services.html" + context_object_name = "services" + model = Service + permission_type = "view" + + def get_queryset(self): + """Return all services.""" + services = Service.objects.all().select_related("category") + if self.filter_form.is_valid(): + services = self.filter_form.filter_queryset(services) + return services + + @cached_property + def filter_form(self): + return ServiceFilterForm(data=self.request.GET or None) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["filter_form"] = self.filter_form + return context diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index 826a458..97d103e 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -1,3 +1,11 @@ .form-group.d-inline { margin-bottom: 0; } + +.search-form .form-body>.row { + display: flex; + &>.col-12 { + width: auto; + flex-grow: 1; + } +} From 86fe4ec3ec29d2c263630a3536fbc301fd4217b6 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 16:36:34 +0100 Subject: [PATCH 03/17] Fix name of related field --- src/servala/core/admin.py | 4 ++-- src/servala/core/models/service.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index d718fe0..c1e65c6 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -173,8 +173,8 @@ class PlanAdmin(admin.ModelAdmin): @admin.register(ServiceOffering) class ServiceOfferingAdmin(admin.ModelAdmin): list_display = ("id", "service", "provider") - list_filter = ("service", "provider", "control_plane") + list_filter = ("service", "provider", "control_planes") search_fields = ("description",) autocomplete_fields = ("service", "provider") - filter_horizontal = ("control_plane",) + filter_horizontal = ("control_planes",) inlines = (PlanInline,) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 78815da..64e0741 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -245,7 +245,7 @@ class ServiceOffering(models.Model): related_name="offerings", verbose_name=_("Provider"), ) - control_plane = models.ManyToManyField( + control_planes = models.ManyToManyField( to="ControlPlane", related_name="offerings", verbose_name=_("Control planes"), From 4fcc9154b6972b7fdabf1eea84ccbd0337761a04 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 16:55:49 +0100 Subject: [PATCH 04/17] Format service list --- src/servala/frontend/forms/service.py | 2 +- .../frontend/organizations/services.html | 65 +++++++++++-------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 6047293..d7ea60f 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -17,6 +17,6 @@ class ServiceFilterForm(forms.Form): queryset = queryset.filter(category=category) if cloud_provider := self.cleaned_data.get("cloud_provider"): queryset = queryset.filter( - service_offerings__control_planes__cloud_provider=cloud_provider + offerings__control_planes__cloud_provider=cloud_provider ) return queryset diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html index 6c6bba9..0e8fd16 100644 --- a/src/servala/frontend/templates/frontend/organizations/services.html +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -5,35 +5,44 @@ {% translate "Services" %} {% endblock page_title %} {% endblock html_title %} -{% block card_content %} -
- {{ filter_form }} -
- {% for service in services %} -
-
-
-
-
- {% if service.logo %} - {{ service.name }} - {% endif %} -
{{ service.name }}
-
- {% if service.description %}

{{ service.description }}

{% endif %} -
- +{% block content %} +
+
+
+
+
+ {{ filter_form }} +
- {% empty %} -

{% translate "No services found." %}

- {% endfor %} + {% for service in services %} +
+
+
+ {% if service.logo %} + {{ service.name }} + {% endif %} +
+

{{ service.name }}

+ {{ service.category }} +
+
+
+ {% if service.description %}

{{ service.description }}

{% endif %} +
+ +
+
+ {% empty %} +

{% translate "No services found." %}

+ {% endfor %} +
-{% endblock card_content %} +{% endblock content %} From 58790c3b1604ae1eb27853d4ea34b2a8865af141 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 17:23:51 +0100 Subject: [PATCH 05/17] Add skeleton service detail page --- .../organizations/service_detail.html | 126 ++++++++++++++++++ src/servala/frontend/urls.py | 7 +- src/servala/frontend/views/__init__.py | 5 +- src/servala/frontend/views/mixins.py | 4 +- src/servala/frontend/views/service.py | 23 +++- 5 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 src/servala/frontend/templates/frontend/organizations/service_detail.html diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html new file mode 100644 index 0000000..bdff6b9 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -0,0 +1,126 @@ +{% extends "frontend/base.html" %} +{% load i18n %} +{% load static %} +{% block html_title %} + {% block page_title %} + {{ service.name }} + {% endblock page_title %} +{% endblock html_title %} +{% block content %} +
+
+
+ {% if service.logo %} + {{ service.name }} + {% endif %} +
+

{{ service.name }}

+ {{ service.category }} +
+
+
+
+

{{ service.description|default:"No description available." }}

+
+
+
+
+
+

{% trans "Available Offerings" %}

+
+
+ {% for offering in service.offerings.all %} +
+
+
+ {% if offering.provider.logo %} + {{ offering.provider.name }} + {% endif %} +
{{ service.name }} on {{ offering.provider.name }}
+
+
+
+
+
+

{{ offering.description|default:"No description available." }}

+
+
+
{% trans "Control Planes" %}
+
    + {% for control_plane in offering.control_planes.all %} +
  • {{ control_plane.name }}
  • + {% empty %} +
  • {% trans "No control planes available" %}
  • + {% endfor %} +
+
+
+
{% trans "Available Plans" %}
+
+ + + + + + + + + + + + {% for plan in plans %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Plan" %}{% trans "Term" %}{% trans "Features" %}{% trans "Pricing" %}{% trans "Actions" %}
+ {{ plan.name }} + {% if plan.description %} +
+ {{ plan.description }} + {% endif %} +
{{ plan.term }} {% trans "months" %} + {% if plan.features %} +
    + {% for feature, value in plan.features.items %}
  • {{ feature }}: {{ value }}
  • {% endfor %} +
+ {% else %} + {% trans "No features specified" %} + {% endif %} +
+ {% if plan.pricing %} +
    + {% for price_type, price in plan.pricing.items %}
  • {{ price_type }}: {{ price }}
  • {% endfor %} +
+ {% else %} + {% trans "No pricing specified" %} + {% endif %} +
+ +
{% trans "No plans available for this offering" %}
+
+
+
+ {% empty %} +
{% trans "No offerings available for this service yet." %}
+ {% endfor %} +
+
+
+{% endblock content %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 43a4a51..75deb43 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -22,7 +22,12 @@ urlpatterns = [ ), path( "services/", - views.OrganizationServicesView.as_view(), + views.ServiceListView.as_view(), + name="organization.services", + ), + path( + "services//", + views.ServiceDetailView.as_view(), name="organization.services", ), path( diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index a9f323a..dcd8d9e 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -5,7 +5,7 @@ from .organization import ( OrganizationDashboardView, OrganizationUpdateView, ) -from .service import OrganizationServicesView +from .service import ServiceDetailView, ServiceListView __all__ = [ "IndexView", @@ -13,6 +13,7 @@ __all__ = [ "OrganizationCreateView", "OrganizationDashboardView", "OrganizationUpdateView", - "OrganizationServicesView", + "ServiceDetailView", + "ServiceListView", "ProfileView", ] diff --git a/src/servala/frontend/views/mixins.py b/src/servala/frontend/views/mixins.py index 502a26a..f304e42 100644 --- a/src/servala/frontend/views/mixins.py +++ b/src/servala/frontend/views/mixins.py @@ -71,7 +71,9 @@ class OrganizationViewMixin(PermissionRequiredMixin): return self.request.organization def get_object(self): - return self.organization + if self.model == Organization: + return self.organization + return super().get_object() @cached_property def object(self): diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 708205e..f407250 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,12 +1,12 @@ from django.utils.functional import cached_property -from django.views.generic import ListView +from django.views.generic import DetailView, ListView -from servala.core.models import Service +from servala.core.models import Plan, Service, ServiceOffering from servala.frontend.forms.service import ServiceFilterForm from servala.frontend.views.mixins import OrganizationViewMixin -class OrganizationServicesView(OrganizationViewMixin, ListView): +class ServiceListView(OrganizationViewMixin, ListView): """View to display all available services for an organization.""" template_name = "frontend/organizations/services.html" @@ -29,3 +29,20 @@ class OrganizationServicesView(OrganizationViewMixin, ListView): context = super().get_context_data(**kwargs) context["filter_form"] = self.filter_form return context + + +class ServiceDetailView(OrganizationViewMixin, DetailView): + """View to display details of a specific service and its offerings.""" + + template_name = "frontend/organizations/service_detail.html" + context_object_name = "service" + model = Service + permission_type = "view" + + def get_queryset(self): + return Service.objects.select_related("category").prefetch_related( + "offerings", + "offerings__provider", + "offerings__control_planes", + "offerings__plans", + ) From fb11aa44074f730236530ebfe19b62fced3c5605 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 24 Mar 2025 10:50:48 +0100 Subject: [PATCH 06/17] Improve service detail template --- .../organizations/service_detail.html | 125 +++++------------- .../frontend/organizations/services.html | 43 +++--- .../frontend/templates/frontend/profile.html | 4 +- src/servala/frontend/views/service.py | 8 +- 4 files changed, 62 insertions(+), 118 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html index bdff6b9..674a714 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -18,7 +18,7 @@ max-height: 48px"> {% endif %}
-

{{ service.name }}

+

{{ service.name }}

{{ service.category }}
@@ -28,99 +28,38 @@
-
-
-

{% trans "Available Offerings" %}

-
-
- {% for offering in service.offerings.all %} -
-
-
- {% if offering.provider.logo %} - {{ offering.provider.name }} - {% endif %} -
{{ service.name }} on {{ offering.provider.name }}
-
-
-
-
-
-

{{ offering.description|default:"No description available." }}

-
-
-
{% trans "Control Planes" %}
-
    - {% for control_plane in offering.control_planes.all %} -
  • {{ control_plane.name }}
  • - {% empty %} -
  • {% trans "No control planes available" %}
  • - {% endfor %} -
-
-
-
{% trans "Available Plans" %}
-
- - - - - - - - - - - - {% for plan in plans %} - - - - - - - - {% empty %} - - - - {% endfor %} - -
{% trans "Plan" %}{% trans "Term" %}{% trans "Features" %}{% trans "Pricing" %}{% trans "Actions" %}
- {{ plan.name }} - {% if plan.description %} -
- {{ plan.description }} - {% endif %} -
{{ plan.term }} {% trans "months" %} - {% if plan.features %} -
    - {% for feature, value in plan.features.items %}
  • {{ feature }}: {{ value }}
  • {% endfor %} -
- {% else %} - {% trans "No features specified" %} - {% endif %} -
- {% if plan.pricing %} -
    - {% for price_type, price in plan.pricing.items %}
  • {{ price_type }}: {{ price }}
  • {% endfor %} -
- {% else %} - {% trans "No pricing specified" %} - {% endif %} -
- -
{% trans "No plans available for this offering" %}
-
-
+ {% for offering in service.offerings.all %} +
+
+ {% if offering.provider.logo %} + {{ offering.provider.name }} + {% endif %} +
+

{{ offering.provider.name }}

- {% empty %} -
{% trans "No offerings available for this service yet." %}
- {% endfor %} +
+
+ {% if offering.description %} +

{{ offering.description }}

+ {% elif offering.provider.description %} +

{{ offering.provider.description }}

+ {% endif %} +
+
-
+ {% empty %} +
+
+

{% translate "No offerings found." %}

+
+
+ {% endfor %} {% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html index 0e8fd16..56ddab0 100644 --- a/src/servala/frontend/templates/frontend/organizations/services.html +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -18,30 +18,33 @@
{% for service in services %}
-
-
- {% if service.logo %} - {{ service.name }} - {% endif %} -
-

{{ service.name }}

- {{ service.category }} -
-
-
- {% if service.description %}

{{ service.description }}

{% endif %} -
- {% empty %} -

{% translate "No services found." %}

+
+
+

{% translate "No services found." %}

+
+
{% endfor %} diff --git a/src/servala/frontend/templates/frontend/profile.html b/src/servala/frontend/templates/frontend/profile.html index 2d6433a..db6eda4 100644 --- a/src/servala/frontend/templates/frontend/profile.html +++ b/src/servala/frontend/templates/frontend/profile.html @@ -73,7 +73,7 @@
-

{% translate "Profile" %}

+

{% translate "Profile" %}

@@ -106,7 +106,7 @@
-

{% translate "Account" %}

+

{% translate "Account" %}

diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index f407250..5eac7e2 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,7 +1,7 @@ from django.utils.functional import cached_property from django.views.generic import DetailView, ListView -from servala.core.models import Plan, Service, ServiceOffering +from servala.core.models import Service from servala.frontend.forms.service import ServiceFilterForm from servala.frontend.views.mixins import OrganizationViewMixin @@ -43,6 +43,8 @@ class ServiceDetailView(OrganizationViewMixin, DetailView): return Service.objects.select_related("category").prefetch_related( "offerings", "offerings__provider", - "offerings__control_planes", - "offerings__plans", ) + + +class ServiceOfferingDetailView: + pass From 008edd78feaf1dd599201eafcce5f3dfd6048eae Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 24 Mar 2025 11:03:17 +0100 Subject: [PATCH 07/17] wip: gvk --- src/servala/core/models/service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 64e0741..5ab1e02 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -251,6 +251,7 @@ class ServiceOffering(models.Model): verbose_name=_("Control planes"), ) description = models.TextField(blank=True, verbose_name=_("Description")) + gvk # group, version, kind = jsonfeld; property => gvk kombiniert, kubernetes-ding class Meta: verbose_name = _("Service offering") From 48b5a1e3e42f9680f4ee78f277abdd565602ead9 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 24 Mar 2025 15:21:06 +0100 Subject: [PATCH 08/17] Add ServiceDefinition model --- ...serviceoffering_control_planes_and_more.py | 81 +++++++++++++++++++ src/servala/core/models/__init__.py | 2 + src/servala/core/models/service.py | 71 +++++++++++++--- 3 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 src/servala/core/migrations/0006_rename_control_plane_serviceoffering_control_planes_and_more.py diff --git a/src/servala/core/migrations/0006_rename_control_plane_serviceoffering_control_planes_and_more.py b/src/servala/core/migrations/0006_rename_control_plane_serviceoffering_control_planes_and_more.py new file mode 100644 index 0000000..a5f9340 --- /dev/null +++ b/src/servala/core/migrations/0006_rename_control_plane_serviceoffering_control_planes_and_more.py @@ -0,0 +1,81 @@ +# Generated by Django 5.2b1 on 2025-03-24 14:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0005_remove_controlplane_k8s_api_endpoint"), + ] + + operations = [ + migrations.RenameField( + model_name="serviceoffering", + old_name="control_plane", + new_name="control_planes", + ), + migrations.CreateModel( + name="ServiceDefinition", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="Name")), + ( + "description", + models.TextField(blank=True, verbose_name="Description"), + ), + ( + "api_definition", + models.JSONField( + blank=True, + help_text="Contains group, version, and kind information", + null=True, + verbose_name="API Definition", + ), + ), + ( + "control_plane", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="service_definitions", + to="core.controlplane", + verbose_name="Control Plane", + ), + ), + ( + "service", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="service_definitions", + to="core.service", + verbose_name="Service", + ), + ), + ], + options={ + "verbose_name": "Service definition", + "verbose_name_plural": "Service definitions", + }, + ), + migrations.AddField( + model_name="serviceoffering", + name="service_definition", + field=models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.PROTECT, + related_name="offerings", + to="core.servicedefinition", + verbose_name="Service definition", + ), + preserve_default=False, + ), + ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index f637b57..b4ddf21 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -11,6 +11,7 @@ from .service import ( Plan, Service, ServiceCategory, + ServiceDefinition, ServiceOffering, ) from .user import User @@ -26,6 +27,7 @@ __all__ = [ "Plan", "Service", "ServiceCategory", + "ServiceDefinition", "ServiceOffering", "User", ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 5ab1e02..770ac40 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -67,18 +67,13 @@ class Service(models.Model): return self.name -def validate_api_credentials(value): - """ - Validates that api_credentials either contains all required fields or is empty. - """ - # If empty dict, that's valid - if not value: - return - - # Check for required fields - required_fields = ("certificate-authority-data", "server", "token") - missing_fields = required_fields - set(value) +def validate_dict(data, required_fields=None, allow_empty=True): + if not data: + if allow_empty: + return + raise ValidationError(_("Data may not be empty!")) + missing_fields = required_fields - set(data) if missing_fields: raise ValidationError( _("Missing required fields in API credentials: %(fields)s"), @@ -86,6 +81,11 @@ def validate_api_credentials(value): ) +def validate_api_credentials(value): + required_fields = ("certificate-authority-data", "server", "token") + return validate_dict(value, required_fields) + + class ControlPlane(models.Model): name = models.CharField(max_length=100, verbose_name=_("Name")) description = models.TextField(blank=True, verbose_name=_("Description")) @@ -228,6 +228,48 @@ class Plan(models.Model): return self.name +def validate_api_definition(value): + required_fields = ("group", "version", "kind") + return validate_dict(value, required_fields) + + +class ServiceDefinition(models.Model): + """ + Configuration/service implementation: contains information on which + CompositeResourceDefinition (aka XRD) implements a service on a ControlPlane. + + Is required in order to query the OpenAPI spec for dynamic form generation. + """ + + name = models.CharField(max_length=100, verbose_name=_("Name")) + description = models.TextField(blank=True, verbose_name=_("Description")) + api_definition = models.JSONField( + verbose_name=_("API Definition"), + help_text=_("Contains group, version, and kind information"), + null=True, + blank=True, + ) + control_plane = models.ForeignKey( + to="ControlPlane", + on_delete=models.CASCADE, + related_name="service_definitions", + verbose_name=_("Control Plane"), + ) + service = models.ForeignKey( + to="Service", + on_delete=models.CASCADE, + related_name="service_definitions", + verbose_name=_("Service"), + ) + + class Meta: + verbose_name = _("Service definition") + verbose_name_plural = _("Service definitions") + + def __str__(self): + return self.name + + class ServiceOffering(models.Model): """ A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP". @@ -250,8 +292,13 @@ class ServiceOffering(models.Model): related_name="offerings", verbose_name=_("Control planes"), ) + service_definition = models.ForeignKey( + to="ServiceDefinition", + related_name="offerings", + verbose_name=_("Service definition"), + on_delete=models.PROTECT, + ) description = models.TextField(blank=True, verbose_name=_("Description")) - gvk # group, version, kind = jsonfeld; property => gvk kombiniert, kubernetes-ding class Meta: verbose_name = _("Service offering") From 332724fbdef0a1e7876198e526a3ec8f2297d9b2 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 24 Mar 2025 15:21:13 +0100 Subject: [PATCH 09/17] Add ServiceDefinition admin integration --- src/servala/core/admin.py | 30 +++++++++++++++++- src/servala/core/forms.py | 64 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index c1e65c6..c521cb6 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin, messages from django.utils.translation import gettext_lazy as _ -from servala.core.forms import ControlPlaneAdminForm +from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm from servala.core.models import ( BillingEntity, CloudProvider, @@ -12,6 +12,7 @@ from servala.core.models import ( Plan, Service, ServiceCategory, + ServiceDefinition, ServiceOffering, User, ) @@ -170,6 +171,33 @@ class PlanAdmin(admin.ModelAdmin): autocomplete_fields = ("service_offering",) +@admin.register(ServiceDefinition) +class ServiceDefinitionAdmin(admin.ModelAdmin): + form = ServiceDefinitionAdminForm + list_display = ("name", "service", "control_plane") + list_filter = ("service", "control_plane") + search_fields = ("name", "description") + autocomplete_fields = ("service", "control_plane") + + fieldsets = ( + ( + None, + {"fields": ("name", "description", "service", "control_plane")}, + ), + ( + _("API Definition"), + { + "fields": ("api_group", "api_version", "api_kind"), + "description": _("API definition for the Kubernetes Custom Resource"), + }, + ), + ) + + def get_exclude(self, request, obj=None): + # Exclude the original api_definition field as we're using our custom fields + return ["api_definition"] + + @admin.register(ServiceOffering) class ServiceOfferingAdmin(admin.ModelAdmin): list_display = ("id", "service", "provider") diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py index d7740ae..1fece41 100644 --- a/src/servala/core/forms.py +++ b/src/servala/core/forms.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from servala.core.models import ControlPlane +from servala.core.models import ControlPlane, ServiceDefinition class ControlPlaneAdminForm(forms.ModelForm): @@ -70,3 +70,65 @@ class ControlPlaneAdminForm(forms.ModelForm): def save(self, *args, **kwargs): self.instance.api_credentials = self.cleaned_data["api_credentials"] return super().save(*args, **kwargs) + + +class ServiceDefinitionAdminForm(forms.ModelForm): + api_group = forms.CharField( + required=False, + help_text=_("API Group"), + ) + api_version = forms.CharField( + required=False, + help_text=_("API Version"), + ) + api_kind = forms.CharField( + required=False, + help_text=_("API Kind"), + ) + + class Meta: + model = ServiceDefinition + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # If we have existing api_definition, populate the individual fields + if self.instance.pk and self.instance.api_definition: + api_def = self.instance.api_definition + self.fields["api_group"].initial = api_def.get("group", "") + self.fields["api_version"].initial = api_def.get("version", "") + self.fields["api_kind"].initial = api_def.get("kind", "") + + def clean(self): + cleaned_data = super().clean() + + api_group = cleaned_data.get("api_group") + api_version = cleaned_data.get("api_version") + api_kind = cleaned_data.get("api_kind") + + if api_group and api_version and api_kind: + cleaned_data["api_definition"] = { + "group": api_group, + "version": api_version, + "kind": api_kind, + } + else: + if not (api_group or api_version or api_kind): + cleaned_data["api_definition"] = {} + else: + # Some fields are filled but not all – validation will fail at model level. + api_def = {} + if api_group: + api_def["group"] = api_group + if api_version: + api_def["version"] = api_version + if api_kind: + api_def["kind"] = api_kind + cleaned_data["api_definition"] = api_def + + return cleaned_data + + def save(self, *args, **kwargs): + self.instance.api_definition = self.cleaned_data["api_definition"] + return super().save(*args, **kwargs) From 5a36ced8f976cff48aaa542fb27f5bbbf0957cc3 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 24 Mar 2025 15:25:28 +0100 Subject: [PATCH 10/17] Attach ServiceDefinition to ControlPlane --- ...lanes_and_more.py => 0006_service_definitions.py} | 6 +++--- src/servala/core/models/service.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) rename src/servala/core/migrations/{0006_rename_control_plane_serviceoffering_control_planes_and_more.py => 0006_service_definitions.py} (95%) diff --git a/src/servala/core/migrations/0006_rename_control_plane_serviceoffering_control_planes_and_more.py b/src/servala/core/migrations/0006_service_definitions.py similarity index 95% rename from src/servala/core/migrations/0006_rename_control_plane_serviceoffering_control_planes_and_more.py rename to src/servala/core/migrations/0006_service_definitions.py index a5f9340..9426209 100644 --- a/src/servala/core/migrations/0006_rename_control_plane_serviceoffering_control_planes_and_more.py +++ b/src/servala/core/migrations/0006_service_definitions.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2b1 on 2025-03-24 14:20 +# Generated by Django 5.2b1 on 2025-03-24 14:24 import django.db.models.deletion from django.db import migrations, models @@ -67,12 +67,12 @@ class Migration(migrations.Migration): }, ), migrations.AddField( - model_name="serviceoffering", + model_name="controlplane", name="service_definition", field=models.ForeignKey( default=1, on_delete=django.db.models.deletion.PROTECT, - related_name="offerings", + related_name="control_planes", to="core.servicedefinition", verbose_name="Service definition", ), diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 770ac40..f2fdd1f 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -102,6 +102,12 @@ class ControlPlane(models.Model): related_name="control_planes", verbose_name=_("Cloud provider"), ) + service_definition = models.ForeignKey( + to="ServiceDefinition", + related_name="control_planes", + verbose_name=_("Service definition"), + on_delete=models.PROTECT, + ) class Meta: verbose_name = _("Control plane") @@ -292,12 +298,6 @@ class ServiceOffering(models.Model): related_name="offerings", verbose_name=_("Control planes"), ) - service_definition = models.ForeignKey( - to="ServiceDefinition", - related_name="offerings", - verbose_name=_("Service definition"), - on_delete=models.PROTECT, - ) description = models.TextField(blank=True, verbose_name=_("Description")) class Meta: From 74112880197c23e53431ca2fc102547939b23d4c Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 24 Mar 2025 15:29:53 +0100 Subject: [PATCH 11/17] Add and auto-populate slug field --- src/servala/core/admin.py | 1 + .../core/migrations/0007_service_slug.py | 21 +++++++++++++++++++ src/servala/core/models/service.py | 1 + 3 files changed, 23 insertions(+) create mode 100644 src/servala/core/migrations/0007_service_slug.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index c521cb6..c7dbb0d 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -102,6 +102,7 @@ class ServiceAdmin(admin.ModelAdmin): list_filter = ("category",) search_fields = ("name", "description") autocomplete_fields = ("category",) + prepopulated_fields = {"slug": ["name"]} @admin.register(CloudProvider) diff --git a/src/servala/core/migrations/0007_service_slug.py b/src/servala/core/migrations/0007_service_slug.py new file mode 100644 index 0000000..e4e6e48 --- /dev/null +++ b/src/servala/core/migrations/0007_service_slug.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2b1 on 2025-03-24 14:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0006_service_definitions"), + ] + + operations = [ + migrations.AddField( + model_name="service", + name="slug", + field=models.SlugField( + default="slug", max_length=100, unique=True, verbose_name="URL slug" + ), + preserve_default=False, + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index f2fdd1f..986f9dd 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -44,6 +44,7 @@ class Service(models.Model): """ name = models.CharField(max_length=100, verbose_name=_("Name")) + slug = models.SlugField(max_length=100, verbose_name=_("URL slug"), unique=True) category = models.ForeignKey( to="ServiceCategory", on_delete=models.PROTECT, From 51dcc46f62ffb8417452753d818d3e275133cf7c Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 24 Mar 2025 15:33:33 +0100 Subject: [PATCH 12/17] Use service slug in routing --- .../frontend/templates/frontend/organizations/services.html | 2 +- src/servala/frontend/urls.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html index 56ddab0..3286952 100644 --- a/src/servala/frontend/templates/frontend/organizations/services.html +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -36,7 +36,7 @@
{% empty %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 75deb43..96d33dc 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -26,7 +26,7 @@ urlpatterns = [ name="organization.services", ), path( - "services//", + "services//", views.ServiceDetailView.as_view(), name="organization.services", ), From ccdc7bd42518104c160fccd4db0a01aad7f49490 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 24 Mar 2025 15:44:00 +0100 Subject: [PATCH 13/17] Drop ServiceDefinition.control_plane --- src/servala/core/admin.py | 10 +++++----- .../{0007_service_slug.py => 0006_service_slug.py} | 2 +- ...e_definitions.py => 0007_service_definitions.py} | 13 ++----------- src/servala/core/models/service.py | 6 ------ 4 files changed, 8 insertions(+), 23 deletions(-) rename src/servala/core/migrations/{0007_service_slug.py => 0006_service_slug.py} (87%) rename src/servala/core/migrations/{0006_service_definitions.py => 0007_service_definitions.py} (82%) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index c7dbb0d..665c291 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -123,7 +123,7 @@ class ControlPlaneAdmin(admin.ModelAdmin): fieldsets = ( ( None, - {"fields": ("name", "description", "cloud_provider")}, + {"fields": ("name", "description", "cloud_provider", "service_definition")}, ), ( _("API Credentials"), @@ -175,15 +175,15 @@ class PlanAdmin(admin.ModelAdmin): @admin.register(ServiceDefinition) class ServiceDefinitionAdmin(admin.ModelAdmin): form = ServiceDefinitionAdminForm - list_display = ("name", "service", "control_plane") - list_filter = ("service", "control_plane") + list_display = ("name", "service") + list_filter = ("service", "control_planes") search_fields = ("name", "description") - autocomplete_fields = ("service", "control_plane") + autocomplete_fields = ("service",) fieldsets = ( ( None, - {"fields": ("name", "description", "service", "control_plane")}, + {"fields": ("name", "description", "service")}, ), ( _("API Definition"), diff --git a/src/servala/core/migrations/0007_service_slug.py b/src/servala/core/migrations/0006_service_slug.py similarity index 87% rename from src/servala/core/migrations/0007_service_slug.py rename to src/servala/core/migrations/0006_service_slug.py index e4e6e48..f15e796 100644 --- a/src/servala/core/migrations/0007_service_slug.py +++ b/src/servala/core/migrations/0006_service_slug.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("core", "0006_service_definitions"), + ("core", "0005_remove_controlplane_k8s_api_endpoint"), ] operations = [ diff --git a/src/servala/core/migrations/0006_service_definitions.py b/src/servala/core/migrations/0007_service_definitions.py similarity index 82% rename from src/servala/core/migrations/0006_service_definitions.py rename to src/servala/core/migrations/0007_service_definitions.py index 9426209..bdc11e1 100644 --- a/src/servala/core/migrations/0006_service_definitions.py +++ b/src/servala/core/migrations/0007_service_definitions.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2b1 on 2025-03-24 14:24 +# Generated by Django 5.2b1 on 2025-03-24 14:41 import django.db.models.deletion from django.db import migrations, models @@ -7,7 +7,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("core", "0005_remove_controlplane_k8s_api_endpoint"), + ("core", "0006_service_slug"), ] operations = [ @@ -42,15 +42,6 @@ class Migration(migrations.Migration): verbose_name="API Definition", ), ), - ( - "control_plane", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="service_definitions", - to="core.controlplane", - verbose_name="Control Plane", - ), - ), ( "service", models.ForeignKey( diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 986f9dd..9648ba4 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -256,12 +256,6 @@ class ServiceDefinition(models.Model): null=True, blank=True, ) - control_plane = models.ForeignKey( - to="ControlPlane", - on_delete=models.CASCADE, - related_name="service_definitions", - verbose_name=_("Control Plane"), - ) service = models.ForeignKey( to="Service", on_delete=models.CASCADE, From b7b5e30e7acc1e0f305fdcf1c33eb60b67482180 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 24 Mar 2025 16:16:23 +0100 Subject: [PATCH 14/17] Add service offering detail view --- src/servala/core/models/service.py | 5 +++ .../organizations/service_detail.html | 2 +- .../service_offering_detail.html | 40 +++++++++++++++++++ src/servala/frontend/urls.py | 7 +++- src/servala/frontend/views/__init__.py | 3 +- src/servala/frontend/views/service.py | 16 +++++--- 6 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 src/servala/frontend/templates/frontend/organizations/service_offering_detail.html diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 9648ba4..330e077 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -298,3 +298,8 @@ class ServiceOffering(models.Model): class Meta: verbose_name = _("Service offering") verbose_name_plural = _("Service offerings") + + def __str__(self): + return _("{service_name} at {provider_name}").format( + service_name=self.service.name, provider_name=self.provider.name + ) diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html index 674a714..c3962eb 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -51,7 +51,7 @@
{% empty %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html new file mode 100644 index 0000000..1ed9a12 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -0,0 +1,40 @@ +{% extends "frontend/base.html" %} +{% load i18n %} +{% load static %} +{% block html_title %} + {% block page_title %} + {{ offering }} + {% endblock page_title %} +{% endblock html_title %} +{% block content %} +
+
+
+ {% if service.logo %} + {{ service.name }} + {% endif %} +
+

{{ offering }}

+ {{ offering.service.category }} +
+
+
+
+ {% if offering.control_planes.all.count > 1 %} +

{% translate "Please choose your zone." %}

+ {% else %} +

+ {% blocktranslate trimmed with zone=offering.control_planes.all.first.name %} + Your zone will be {{ zone }}. + {% endblocktranslate %} +

+ {% endif %} +
+
+
+
+{% endblock content %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 96d33dc..d6c7c3a 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -28,7 +28,12 @@ urlpatterns = [ path( "services//", views.ServiceDetailView.as_view(), - name="organization.services", + name="organization.service", + ), + path( + "services//offering//", + views.ServiceOfferingDetailView.as_view(), + name="organization.offering", ), path( "", diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index dcd8d9e..1a180a1 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -5,7 +5,7 @@ from .organization import ( OrganizationDashboardView, OrganizationUpdateView, ) -from .service import ServiceDetailView, ServiceListView +from .service import ServiceDetailView, ServiceListView, ServiceOfferingDetailView __all__ = [ "IndexView", @@ -15,5 +15,6 @@ __all__ = [ "OrganizationUpdateView", "ServiceDetailView", "ServiceListView", + "ServiceOfferingDetailView", "ProfileView", ] diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 5eac7e2..f5c33f0 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,7 +1,7 @@ from django.utils.functional import cached_property from django.views.generic import DetailView, ListView -from servala.core.models import Service +from servala.core.models import Service, ServiceOffering from servala.frontend.forms.service import ServiceFilterForm from servala.frontend.views.mixins import OrganizationViewMixin @@ -32,8 +32,6 @@ class ServiceListView(OrganizationViewMixin, ListView): class ServiceDetailView(OrganizationViewMixin, DetailView): - """View to display details of a specific service and its offerings.""" - template_name = "frontend/organizations/service_detail.html" context_object_name = "service" model = Service @@ -46,5 +44,13 @@ class ServiceDetailView(OrganizationViewMixin, DetailView): ) -class ServiceOfferingDetailView: - pass +class ServiceOfferingDetailView(OrganizationViewMixin, DetailView): + template_name = "frontend/organizations/service_offering_detail.html" + context_object_name = "offering" + model = ServiceOffering + permission_type = "view" + + def get_queryset(self): + return ServiceOffering.objects.all().select_related( + "service", "service__category", "provider" + ) From 267dc56f3279ed00f2bd56cedb6fb188f5e7db61 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 24 Mar 2025 17:36:55 +0100 Subject: [PATCH 15/17] add navigation link for services --- src/servala/frontend/templates/includes/sidebar.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index 13d9d5c..e2a98a2 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -116,6 +116,12 @@ {% translate 'Details' %} + {% endif %}