Display service catalogs #22
21 changed files with 704 additions and 52 deletions
|
@ -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,7 +12,9 @@ from servala.core.models import (
|
|||
Plan,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDefinition,
|
||||
ServiceOffering,
|
||||
ServiceOfferingControlPlane,
|
||||
User,
|
||||
)
|
||||
|
||||
|
@ -101,6 +103,7 @@ class ServiceAdmin(admin.ModelAdmin):
|
|||
list_filter = ("category",)
|
||||
search_fields = ("name", "description")
|
||||
autocomplete_fields = ("category",)
|
||||
prepopulated_fields = {"slug": ["name"]}
|
||||
|
||||
|
||||
@admin.register(CloudProvider)
|
||||
|
@ -121,7 +124,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
|||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("name", "description", "cloud_provider")},
|
||||
{"fields": ("name", "description", "cloud_provider", "service_definition")},
|
||||
),
|
||||
(
|
||||
_("API Credentials"),
|
||||
|
@ -170,11 +173,54 @@ class PlanAdmin(admin.ModelAdmin):
|
|||
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)
|
||||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "service", "provider")
|
||||
list_filter = ("service", "provider", "control_plane")
|
||||
list_filter = ("service", "provider")
|
||||
search_fields = ("description",)
|
||||
autocomplete_fields = ("service", "provider")
|
||||
filter_horizontal = ("control_plane",)
|
||||
inlines = (PlanInline,)
|
||||
inlines = (
|
||||
ServiceOfferingControlPlaneInline,
|
||||
PlanInline,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
21
src/servala/core/migrations/0006_service_slug.py
Normal file
21
src/servala/core/migrations/0006_service_slug.py
Normal 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,
|
||||
),
|
||||
]
|
115
src/servala/core/migrations/0007_service_definition.py
Normal file
115
src/servala/core/migrations/0007_service_definition.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -11,7 +11,9 @@ from .service import (
|
|||
Plan,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDefinition,
|
||||
ServiceOffering,
|
||||
ServiceOfferingControlPlane,
|
||||
)
|
||||
from .user import User
|
||||
|
||||
|
@ -26,6 +28,8 @@ __all__ = [
|
|||
"Plan",
|
||||
"Service",
|
||||
"ServiceCategory",
|
||||
"ServiceDefinition",
|
||||
"ServiceOffering",
|
||||
"ServiceOfferingControlPlane",
|
||||
"User",
|
||||
]
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,
|
||||
|
@ -67,18 +68,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 +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):
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||
|
@ -228,6 +229,88 @@ 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,
|
||||
)
|
||||
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):
|
||||
"""
|
||||
A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP".
|
||||
|
@ -245,8 +328,9 @@ class ServiceOffering(models.Model):
|
|||
related_name="offerings",
|
||||
verbose_name=_("Provider"),
|
||||
)
|
||||
control_plane = models.ManyToManyField(
|
||||
control_planes = models.ManyToManyField(
|
||||
to="ControlPlane",
|
||||
through="ServiceOfferingControlPlane",
|
||||
related_name="offerings",
|
||||
verbose_name=_("Control planes"),
|
||||
)
|
||||
|
@ -255,3 +339,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
|
||||
)
|
||||
|
|
22
src/servala/frontend/forms/service.py
Normal file
22
src/servala/frontend/forms/service.py
Normal 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
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -73,7 +73,7 @@
|
|||
<div class="col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Profile" %}</h4>
|
||||
<h4 class="">{% translate "Profile" %}</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
|
@ -106,7 +106,7 @@
|
|||
<div class="col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Account" %}</h4>
|
||||
<h4 class="">{% translate "Account" %}</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
|
|
|
@ -116,6 +116,12 @@
|
|||
<span>{% translate 'Details' %}</span>
|
||||
</a>
|
||||
</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 %}
|
||||
<li class="sidebar-title">{% translate 'Account' %}</li>
|
||||
<li class="sidebar-item">
|
||||
|
|
|
@ -20,6 +20,21 @@ urlpatterns = [
|
|||
views.OrganizationUpdateView.as_view(),
|
||||
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(
|
||||
"",
|
||||
views.OrganizationDashboardView.as_view(),
|
||||
|
|
|
@ -5,6 +5,7 @@ from .organization import (
|
|||
OrganizationDashboardView,
|
||||
OrganizationUpdateView,
|
||||
)
|
||||
from .service import ServiceDetailView, ServiceListView, ServiceOfferingDetailView
|
||||
|
||||
__all__ = [
|
||||
"IndexView",
|
||||
|
@ -12,5 +13,8 @@ __all__ = [
|
|||
"OrganizationCreateView",
|
||||
"OrganizationDashboardView",
|
||||
"OrganizationUpdateView",
|
||||
"ServiceDetailView",
|
||||
"ServiceListView",
|
||||
"ServiceOfferingDetailView",
|
||||
"ProfileView",
|
||||
]
|
||||
|
|
|
@ -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):
|
||||
|
@ -57,3 +59,31 @@ 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):
|
||||
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()
|
||||
)
|
||||
|
|
|
@ -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
|
||||
):
|
||||
|
|
56
src/servala/frontend/views/service.py
Normal file
56
src/servala/frontend/views/service.py
Normal 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"
|
||||
)
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
25
src/servala/static/js/autosubmit.js
Normal file
25
src/servala/static/js/autosubmit.js
Normal 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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
|
@ -2,21 +2,30 @@
|
|||
* 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', () => {
|
||||
const currentPath = window.location.pathname;
|
||||
const sidebarLinks = document.querySelectorAll('.sidebar-link');
|
||||
const sidebarLinks = [...document.querySelectorAll('a.sidebar-link')]
|
||||
|
||||
sidebarLinks.forEach(link => {
|
||||
// Skip links that are inside buttons (like logout)
|
||||
if (link.tagName === 'BUTTON') return;
|
||||
|
||||
if (link.getAttribute('href') === currentPath) {
|
||||
const parentItem = link.closest('.sidebar-item');
|
||||
if (parentItem) {
|
||||
parentItem.classList.add('active');
|
||||
} else {
|
||||
link.classList.add('active');
|
||||
}
|
||||
const exactMatches = sidebarLinks.filter(link => link.getAttribute('href') === currentPath)
|
||||
if (exactMatches.length > 0) {
|
||||
markActive(exactMatches[0])
|
||||
} else {
|
||||
fuzzyMatches = sidebarLinks.filter(link => currentPath.startsWith(link.getAttribute('href')))
|
||||
if (fuzzyMatches.length > 0) {
|
||||
const longestMatch = fuzzyMatches.sort((a, b) => b.href.length - a.href.length)[0]
|
||||
markActive(longestMatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue