Merge pull request 'Display service catalogs' (#22) from 13-service-catalog into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 52s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 7s

Reviewed-on: https://servala-2nkgm.app.codey.ch/servala/servala-portal/pulls/22
This commit is contained in:
Tobias Kunze 2025-03-25 11:18:19 +00:00
commit a520fdeb4a
21 changed files with 704 additions and 52 deletions

View file

@ -1,7 +1,7 @@
from django.contrib import admin, messages from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _ 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 ( from servala.core.models import (
BillingEntity, BillingEntity,
CloudProvider, CloudProvider,
@ -12,7 +12,9 @@ from servala.core.models import (
Plan, Plan,
Service, Service,
ServiceCategory, ServiceCategory,
ServiceDefinition,
ServiceOffering, ServiceOffering,
ServiceOfferingControlPlane,
User, User,
) )
@ -101,6 +103,7 @@ class ServiceAdmin(admin.ModelAdmin):
list_filter = ("category",) list_filter = ("category",)
search_fields = ("name", "description") search_fields = ("name", "description")
autocomplete_fields = ("category",) autocomplete_fields = ("category",)
prepopulated_fields = {"slug": ["name"]}
@admin.register(CloudProvider) @admin.register(CloudProvider)
@ -121,7 +124,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
( (
None, None,
{"fields": ("name", "description", "cloud_provider")}, {"fields": ("name", "description", "cloud_provider", "service_definition")},
), ),
( (
_("API Credentials"), _("API Credentials"),
@ -170,11 +173,54 @@ class PlanAdmin(admin.ModelAdmin):
autocomplete_fields = ("service_offering",) autocomplete_fields = ("service_offering",)
@admin.register(ServiceDefinition)
class ServiceDefinitionAdmin(admin.ModelAdmin):
form = ServiceDefinitionAdminForm
list_display = ("name", "service")
list_filter = ("service",)
search_fields = ("name", "description")
autocomplete_fields = ("service",)
fieldsets = (
(
None,
{"fields": ("name", "description", "service")},
),
(
_("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"]
class ServiceOfferingControlPlaneInline(admin.TabularInline):
model = ServiceOfferingControlPlane
extra = 1
autocomplete_fields = ("control_plane", "service_definition")
@admin.register(ServiceOfferingControlPlane)
class ServiceOfferingControlPlaneAdmin(admin.ModelAdmin):
list_display = ("service_offering", "control_plane", "service_definition")
list_filter = ("service_offering", "control_plane", "service_definition")
search_fields = ("service_offering__service__name", "control_plane__name")
autocomplete_fields = ("service_offering", "control_plane", "service_definition")
@admin.register(ServiceOffering) @admin.register(ServiceOffering)
class ServiceOfferingAdmin(admin.ModelAdmin): class ServiceOfferingAdmin(admin.ModelAdmin):
list_display = ("id", "service", "provider") list_display = ("id", "service", "provider")
list_filter = ("service", "provider", "control_plane") list_filter = ("service", "provider")
search_fields = ("description",) search_fields = ("description",)
autocomplete_fields = ("service", "provider") autocomplete_fields = ("service", "provider")
filter_horizontal = ("control_plane",) inlines = (
inlines = (PlanInline,) ServiceOfferingControlPlaneInline,
PlanInline,
)

View file

@ -1,7 +1,7 @@
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ 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): class ControlPlaneAdminForm(forms.ModelForm):
@ -70,3 +70,65 @@ class ControlPlaneAdminForm(forms.ModelForm):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.instance.api_credentials = self.cleaned_data["api_credentials"] self.instance.api_credentials = self.cleaned_data["api_credentials"]
return super().save(*args, **kwargs) 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)

View file

@ -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", "0005_remove_controlplane_k8s_api_endpoint"),
]
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,
),
]

View file

@ -0,0 +1,115 @@
# Generated by Django 5.2b1 on 2025-03-25 11:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0006_service_slug"),
]
operations = [
migrations.RemoveField(
model_name="serviceoffering",
name="control_plane",
),
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",
),
),
(
"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.CreateModel(
name="ServiceOfferingControlPlane",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"control_plane",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="offering_connections",
to="core.controlplane",
verbose_name="Control plane",
),
),
(
"service_definition",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="offering_control_planes",
to="core.servicedefinition",
verbose_name="Service definition",
),
),
(
"service_offering",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="control_plane_connections",
to="core.serviceoffering",
verbose_name="Service offering",
),
),
],
options={
"verbose_name": "Service offering control plane connection",
"verbose_name_plural": "Service offering control planes connections",
"unique_together": {("service_offering", "control_plane")},
},
),
migrations.AddField(
model_name="serviceoffering",
name="control_planes",
field=models.ManyToManyField(
related_name="offerings",
through="core.ServiceOfferingControlPlane",
to="core.controlplane",
verbose_name="Control planes",
),
),
]

View file

@ -11,7 +11,9 @@ from .service import (
Plan, Plan,
Service, Service,
ServiceCategory, ServiceCategory,
ServiceDefinition,
ServiceOffering, ServiceOffering,
ServiceOfferingControlPlane,
) )
from .user import User from .user import User
@ -26,6 +28,8 @@ __all__ = [
"Plan", "Plan",
"Service", "Service",
"ServiceCategory", "ServiceCategory",
"ServiceDefinition",
"ServiceOffering", "ServiceOffering",
"ServiceOfferingControlPlane",
"User", "User",
] ]

View file

@ -38,6 +38,7 @@ class Organization(ServalaModelMixin, models.Model):
class urls(urlman.Urls): class urls(urlman.Urls):
base = "/org/{self.slug}/" base = "/org/{self.slug}/"
details = "{base}details/" details = "{base}details/"
services = "{base}services/"
@cached_property @cached_property
def slug(self): def slug(self):

View file

@ -44,6 +44,7 @@ class Service(models.Model):
""" """
name = models.CharField(max_length=100, verbose_name=_("Name")) name = models.CharField(max_length=100, verbose_name=_("Name"))
slug = models.SlugField(max_length=100, verbose_name=_("URL slug"), unique=True)
category = models.ForeignKey( category = models.ForeignKey(
to="ServiceCategory", to="ServiceCategory",
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -67,18 +68,13 @@ class Service(models.Model):
return self.name return self.name
def validate_api_credentials(value): def validate_dict(data, required_fields=None, allow_empty=True):
""" if not data:
Validates that api_credentials either contains all required fields or is empty. if allow_empty:
""" return
# If empty dict, that's valid raise ValidationError(_("Data may not be empty!"))
if not value:
return
# Check for required fields
required_fields = ("certificate-authority-data", "server", "token")
missing_fields = required_fields - set(value)
missing_fields = required_fields - set(data)
if missing_fields: if missing_fields:
raise ValidationError( raise ValidationError(
_("Missing required fields in API credentials: %(fields)s"), _("Missing required fields in API credentials: %(fields)s"),
@ -86,6 +82,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): class ControlPlane(models.Model):
name = models.CharField(max_length=100, verbose_name=_("Name")) name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description")) description = models.TextField(blank=True, verbose_name=_("Description"))
@ -228,6 +229,88 @@ class Plan(models.Model):
return self.name 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,
)
service = models.ForeignKey(
to="Service",
on_delete=models.CASCADE,
related_name="service_definitions",
verbose_name=_("Service"),
)
@property
def control_planes(self):
return ControlPlane.objects.filter(
offering_connections__service_definition=self
).distinct()
@property
def service_offerings(self):
return ServiceOffering.objects.filter(
control_plane_connections__service_definition=self
).distinct()
class Meta:
verbose_name = _("Service definition")
verbose_name_plural = _("Service definitions")
def __str__(self):
return self.name
class ServiceOfferingControlPlane(models.Model):
"""
Each combination of ServiceOffering and ControlPlane can have a different
ServiceDefinition, which is here modeled as the "through" model.
"""
service_offering = models.ForeignKey(
to="ServiceOffering",
on_delete=models.CASCADE,
related_name="control_plane_connections",
verbose_name=_("Service offering"),
)
control_plane = models.ForeignKey(
to="ControlPlane",
on_delete=models.CASCADE,
related_name="offering_connections",
verbose_name=_("Control plane"),
)
service_definition = models.ForeignKey(
to="ServiceDefinition",
on_delete=models.PROTECT,
related_name="offering_control_planes",
verbose_name=_("Service definition"),
)
class Meta:
verbose_name = _("Service offering control plane connection")
verbose_name_plural = _("Service offering control planes connections")
unique_together = [("service_offering", "control_plane")]
def __str__(self):
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
class ServiceOffering(models.Model): class ServiceOffering(models.Model):
""" """
A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP". A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP".
@ -245,8 +328,9 @@ class ServiceOffering(models.Model):
related_name="offerings", related_name="offerings",
verbose_name=_("Provider"), verbose_name=_("Provider"),
) )
control_plane = models.ManyToManyField( control_planes = models.ManyToManyField(
to="ControlPlane", to="ControlPlane",
through="ServiceOfferingControlPlane",
related_name="offerings", related_name="offerings",
verbose_name=_("Control planes"), verbose_name=_("Control planes"),
) )
@ -255,3 +339,8 @@ class ServiceOffering(models.Model):
class Meta: class Meta:
verbose_name = _("Service offering") verbose_name = _("Service offering")
verbose_name_plural = _("Service offerings") 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
)

View file

@ -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(
offerings__control_planes__cloud_provider=cloud_provider
)
return queryset

View file

@ -0,0 +1,65 @@
{% extends "frontend/base.html" %}
{% load i18n %}
{% load static %}
{% block html_title %}
{% block page_title %}
{{ service.name }}
{% endblock page_title %}
{% endblock html_title %}
{% block content %}
<section class="section">
<div class="card">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-body">
<div class="row">
<p>{{ service.description|default:"No description available." }}</p>
</div>
</div>
</div>
{% for offering in service.offerings.all %}
<div class="card col-6 col-lg-3 col-md-4">
<div class="card-header d-flex align-items-center">
{% if offering.provider.logo %}
<img src="{{ offering.provider.logo.url }}"
alt="{{ offering.provider.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ offering.provider.name }}</h4>
</div>
</div>
<div class="card-body">
{% if offering.description %}
<p class="card-text">{{ offering.description }}</p>
{% elif offering.provider.description %}
<p class="card-text">{{ offering.provider.description }}</p>
{% endif %}
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No offerings found." %}</p>
</div>
</div>
{% endfor %}
</section>
{% endblock content %}

View file

@ -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 %}
<section class="section">
<div class="card">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ offering }}</h4>
<small class="text-muted">{{ offering.service.category }}</small>
</div>
</div>
<div class="card-body">
<div class="row">
{% if offering.control_planes.all.count > 1 %}
<p>{% translate "Please choose your zone." %}</p>
{% else %}
<p>
{% blocktranslate trimmed with zone=offering.control_planes.all.first.name %}
Your zone will be <strong>{{ zone }}</strong>.
{% endblocktranslate %}
</p>
{% endif %}
</div>
</div>
</div>
</section>
{% endblock content %}

View file

@ -0,0 +1,51 @@
{% extends "frontend/base.html" %}
{% load i18n static %}
{% block html_title %}
{% block page_title %}
{% translate "Services" %}
{% endblock page_title %}
{% endblock html_title %}
{% block content %}
<section class="section">
<div class="card">
<div class="card-content">
<div class="card-body">
<form class="search-form" auto-submit>
{{ filter_form }}
</form>
</div>
</div>
</div>
{% for service in services %}
<div class="card col-6 col-lg-3 col-md-4">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-body">
{% if service.description %}<p class="card-text">{{ service.description }}</p>{% endif %}
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No services found." %}</p>
</div>
</div>
{% endfor %}
</section>
<script src="{% static "js/autosubmit.js" %}" defer></script>
{% endblock content %}

View file

@ -73,7 +73,7 @@
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h4 class="card-title">{% translate "Profile" %}</h4> <h4 class="">{% translate "Profile" %}</h4>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="card-body"> <div class="card-body">
@ -106,7 +106,7 @@
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h4 class="card-title">{% translate "Account" %}</h4> <h4 class="">{% translate "Account" %}</h4>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="card-body"> <div class="card-body">

View file

@ -116,6 +116,12 @@
<span>{% translate 'Details' %}</span> <span>{% translate 'Details' %}</span>
</a> </a>
</li> </li>
<li class="sidebar-item">
<a href="{{ request.organization.urls.services }}" class='sidebar-link'>
<i class="bi bi-card-list"></i>
<span>{% translate 'Services' %}</span>
</a>
</li>
{% endif %} {% endif %}
<li class="sidebar-title">{% translate 'Account' %}</li> <li class="sidebar-title">{% translate 'Account' %}</li>
<li class="sidebar-item"> <li class="sidebar-item">

View file

@ -20,6 +20,21 @@ urlpatterns = [
views.OrganizationUpdateView.as_view(), views.OrganizationUpdateView.as_view(),
name="organization.details", name="organization.details",
), ),
path(
"services/",
views.ServiceListView.as_view(),
name="organization.services",
),
path(
"services/<slug:slug>/",
views.ServiceDetailView.as_view(),
name="organization.service",
),
path(
"services/<slug:slug>/offering/<int:pk>/",
views.ServiceOfferingDetailView.as_view(),
name="organization.offering",
),
path( path(
"", "",
views.OrganizationDashboardView.as_view(), views.OrganizationDashboardView.as_view(),

View file

@ -5,6 +5,7 @@ from .organization import (
OrganizationDashboardView, OrganizationDashboardView,
OrganizationUpdateView, OrganizationUpdateView,
) )
from .service import ServiceDetailView, ServiceListView, ServiceOfferingDetailView
__all__ = [ __all__ = [
"IndexView", "IndexView",
@ -12,5 +13,8 @@ __all__ = [
"OrganizationCreateView", "OrganizationCreateView",
"OrganizationDashboardView", "OrganizationDashboardView",
"OrganizationUpdateView", "OrganizationUpdateView",
"ServiceDetailView",
"ServiceListView",
"ServiceOfferingDetailView",
"ProfileView", "ProfileView",
] ]

View file

@ -1,6 +1,8 @@
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.views.generic import UpdateView 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): class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
@ -57,3 +59,31 @@ class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
if self.is_htmx and self._get_fragment(): if self.is_htmx and self._get_fragment():
return self.get(self.request, *self.args, **self.kwargs) return self.get(self.request, *self.args, **self.kwargs)
return result 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):
if self.model == Organization:
return self.organization
return super().get_object()
@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()
)

View file

@ -1,11 +1,10 @@
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.functional import cached_property
from django.views.generic import CreateView, DetailView from django.views.generic import CreateView, DetailView
from rules.contrib.views import AutoPermissionRequiredMixin from rules.contrib.views import AutoPermissionRequiredMixin
from servala.core.models import Organization from servala.core.models import Organization
from servala.frontend.forms import OrganizationForm 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): class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
@ -20,22 +19,6 @@ class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
return redirect(instance.urls.base) 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( class OrganizationDashboardView(
AutoPermissionRequiredMixin, OrganizationViewMixin, DetailView AutoPermissionRequiredMixin, OrganizationViewMixin, DetailView
): ):

View file

@ -0,0 +1,56 @@
from django.utils.functional import cached_property
from django.views.generic import DetailView, ListView
from servala.core.models import Service, ServiceOffering
from servala.frontend.forms.service import ServiceFilterForm
from servala.frontend.views.mixins import OrganizationViewMixin
class ServiceListView(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
class ServiceDetailView(OrganizationViewMixin, DetailView):
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",
)
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"
)

View file

@ -1,3 +1,11 @@
.form-group.d-inline { .form-group.d-inline {
margin-bottom: 0; margin-bottom: 0;
} }
.search-form .form-body>.row {
display: flex;
&>.col-12 {
width: auto;
flex-grow: 1;
}
}

View file

@ -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()
})
}
})
})
})

View file

@ -2,21 +2,30 @@
* This script marks the current path as active in the sidebar. * This script marks the current path as active in the sidebar.
*/ */
const markActive = (link) => {
const parentItem = link.closest('.sidebar-item');
if (parentItem) {
parentItem.classList.add('active');
} else {
link.classList.add('active');
}
}
const checkLink = (fuzzy) => {
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const sidebarLinks = document.querySelectorAll('.sidebar-link'); const sidebarLinks = [...document.querySelectorAll('a.sidebar-link')]
sidebarLinks.forEach(link => { const exactMatches = sidebarLinks.filter(link => link.getAttribute('href') === currentPath)
// Skip links that are inside buttons (like logout) if (exactMatches.length > 0) {
if (link.tagName === 'BUTTON') return; markActive(exactMatches[0])
} else {
if (link.getAttribute('href') === currentPath) { fuzzyMatches = sidebarLinks.filter(link => currentPath.startsWith(link.getAttribute('href')))
const parentItem = link.closest('.sidebar-item'); if (fuzzyMatches.length > 0) {
if (parentItem) { const longestMatch = fuzzyMatches.sort((a, b) => b.href.length - a.href.length)[0]
parentItem.classList.add('active'); markActive(longestMatch)
} else {
link.classList.add('active');
}
} }
}) }
}) })