diff --git a/pyproject.toml b/pyproject.toml
index ef119ef..a26e2ef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,7 @@ dependencies = [
"django==5.2.4",
"django-allauth>=65.10.0",
"django-fernet-encrypted-fields>=0.3.0",
+ "django-jsonform>=2.22.0",
"django-scopes>=2.0.0",
"django-storages[s3]>=1.14.6",
"django-template-partials>=24.4",
diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py
index 740b7bf..ed07574 100644
--- a/src/servala/core/admin.py
+++ b/src/servala/core/admin.py
@@ -1,5 +1,6 @@
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
+from django_jsonform.widgets import JSONFormWidget
from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
from servala.core.models import (
@@ -111,12 +112,60 @@ class ServiceAdmin(admin.ModelAdmin):
autocomplete_fields = ("category",)
prepopulated_fields = {"slug": ["name"]}
+ def get_form(self, request, obj=None, **kwargs):
+ form = super().get_form(request, obj, **kwargs)
+ # JSON schema for external_links field
+ external_links_schema = {
+ "type": "array",
+ "title": "External Links",
+ "items": {
+ "type": "object",
+ "title": "Link",
+ "properties": {
+ "url": {"type": "string", "format": "uri", "title": "URL"},
+ "title": {"type": "string", "title": "Title"},
+ "featured": {
+ "type": "boolean",
+ "title": "Featured",
+ "default": False,
+ "description": "Featured links will be shown on the service list page",
+ },
+ },
+ "required": ["url", "title"],
+ },
+ }
+ form.base_fields["external_links"].widget = JSONFormWidget(
+ schema=external_links_schema
+ )
+ return form
+
@admin.register(CloudProvider)
class CloudProviderAdmin(admin.ModelAdmin):
list_display = ("name",)
search_fields = ("name", "description")
+ def get_form(self, request, obj=None, **kwargs):
+ form = super().get_form(request, obj, **kwargs)
+ # JSON schema for external_links field
+ external_links_schema = {
+ "type": "array",
+ "title": "External Links",
+ "items": {
+ "type": "object",
+ "title": "Link",
+ "properties": {
+ "url": {"type": "string", "format": "uri", "title": "URL"},
+ "title": {"type": "string", "title": "Title"},
+ },
+ "required": ["url", "title"],
+ },
+ }
+ form.base_fields["external_links"].widget = JSONFormWidget(
+ schema=external_links_schema
+ )
+ return form
+
@admin.register(ControlPlane)
class ControlPlaneAdmin(admin.ModelAdmin):
diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py
index e517c83..997b0a2 100644
--- a/src/servala/core/models/organization.py
+++ b/src/servala/core/models/organization.py
@@ -3,8 +3,8 @@ import urlman
from django.conf import settings
from django.db import models, transaction
from django.utils.functional import cached_property
-from django.utils.text import slugify
from django.utils.safestring import mark_safe
+from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py
index ba1871d..a06db4e 100644
--- a/src/servala/core/models/service.py
+++ b/src/servala/core/models/service.py
@@ -71,9 +71,14 @@ class Service(ServalaModelMixin, models.Model):
logo = models.ImageField(
upload_to="public/services", blank=True, null=True, verbose_name=_("Logo")
)
- # TODO schema
external_links = models.JSONField(
- null=True, blank=True, verbose_name=_("External links")
+ null=True,
+ blank=True,
+ verbose_name=_("External links"),
+ help_text=(
+ 'JSON array of link objects: {"url": "…", "title": "…", "featured": false}. '
+ "Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages."
+ ),
)
class Meta:
@@ -83,6 +88,13 @@ class Service(ServalaModelMixin, models.Model):
def __str__(self):
return self.name
+ @property
+ def featured_links(self):
+ """Return external links marked as featured."""
+ if not self.external_links:
+ return []
+ return [link for link in self.external_links if link.get("featured")]
+
def validate_dict(data, required_fields=None, allow_empty=True):
if not data:
@@ -263,7 +275,10 @@ class CloudProvider(ServalaModelMixin, models.Model):
verbose_name=_("Logo"),
)
external_links = models.JSONField(
- null=True, blank=True, verbose_name=_("External links")
+ null=True,
+ blank=True,
+ verbose_name=_("External links"),
+ help_text=('JSON array of link objects: {"url": "…", "title": "…"}. '),
)
class Meta:
diff --git a/src/servala/frontend/templates/account/login.html b/src/servala/frontend/templates/account/login.html
index 233118b..58cd49b 100644
--- a/src/servala/frontend/templates/account/login.html
+++ b/src/servala/frontend/templates/account/login.html
@@ -1,21 +1,22 @@
{% extends "frontend/base.html" %}
{% load static i18n %}
{% load allauth account socialaccount %}
-
{% block html_title %}
{% translate "Sign in" %}
{% endblock html_title %}
-
{% block page_title %}
{% translate "Welcome to Servala" %}
{% endblock page_title %}
-
{% block card_header %}
-
-
@@ -96,7 +98,6 @@
-
+ {% if offering.description %}
+
+
+
{{ offering.description|urlize }}
+
+
+ {% endif %}
{% if not has_control_planes %}
{% translate "We currently cannot offer this service, sorry!" %}
{% else %}
@@ -49,6 +56,24 @@
{{ select_form }}
{% endif %}
+ {% if service.external_links %}
+
+
+
{% translate "External Links" %}
+
+
+
+ {% endif %}
{% partial service-form %}
diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html
index 022b6e5..2227e86 100644
--- a/src/servala/frontend/templates/frontend/organizations/services.html
+++ b/src/servala/frontend/templates/frontend/organizations/services.html
@@ -16,10 +16,10 @@
-
+
{% for service in services %}
-
+
-
+
{% if service.description %}
{{ service.description|urlize }}
{% endif %}
-
diff --git a/src/servala/frontend/templates/frontend/profile.html b/src/servala/frontend/templates/frontend/profile.html
index dc12100..b9c1799 100644
--- a/src/servala/frontend/templates/frontend/profile.html
+++ b/src/servala/frontend/templates/frontend/profile.html
@@ -116,7 +116,8 @@
{% endblocktranslate %}
-
{% translate "VSHN Account Console" %}
diff --git a/src/servala/frontend/templates/includes/k8s_error.html b/src/servala/frontend/templates/includes/k8s_error.html
index f97474d..5707dcc 100644
--- a/src/servala/frontend/templates/includes/k8s_error.html
+++ b/src/servala/frontend/templates/includes/k8s_error.html
@@ -1,14 +1,12 @@
{% if show_error %}
-
- {% if has_list %}
- {% if message %}{{ message }}{% endif %}
-
- {% for error in errors %}
- - {{ error }}
- {% endfor %}
-
- {% else %}
- {{ message }}
- {% endif %}
-
+
+ {% if has_list %}
+ {% if message %}{{ message }}{% endif %}
+
+ {% for error in errors %}- {{ error }}
{% endfor %}
+
+ {% else %}
+ {{ message }}
+ {% endif %}
+
{% endif %}
diff --git a/src/servala/frontend/templates/includes/message.html b/src/servala/frontend/templates/includes/message.html
index d5debc9..278c009 100644
--- a/src/servala/frontend/templates/includes/message.html
+++ b/src/servala/frontend/templates/includes/message.html
@@ -1,28 +1,29 @@
-
+
{{ message }}
-
\ No newline at end of file
+ document.addEventListener('DOMContentLoaded', function() {
+ const alert = document.getElementById('auto-dismiss-alert-{{ forloop.counter0|default:'
+ 0 ' }}');
+ if (alert && !alert.classList.contains('alert-danger')) {
+ setTimeout(function() {
+ let opacity = 1;
+ const fadeOutInterval = setInterval(function() {
+ if (opacity > 0.05) {
+ opacity -= 0.05;
+ alert.style.opacity = opacity;
+ } else {
+ clearInterval(fadeOutInterval);
+ const bsAlert = new bootstrap.Alert(alert);
+ bsAlert.close();
+ }
+ }, 25);
+ }, 5000);
+ }
+ });
+
diff --git a/src/servala/frontend/templatetags/error_filters.py b/src/servala/frontend/templatetags/error_filters.py
index 18c4c4d..410d49a 100644
--- a/src/servala/frontend/templatetags/error_filters.py
+++ b/src/servala/frontend/templatetags/error_filters.py
@@ -3,6 +3,7 @@ Template filters for safe error formatting.
"""
import html
+
from django import template
from django.utils.safestring import mark_safe
diff --git a/src/servala/frontend/views/support.py b/src/servala/frontend/views/support.py
index a5de757..6f4c4aa 100644
--- a/src/servala/frontend/views/support.py
+++ b/src/servala/frontend/views/support.py
@@ -51,7 +51,8 @@ class SupportView(OrganizationViewMixin, FormView):
self.request,
mark_safe(
_(
- 'There was an error submitting your support request. Please try again or contact us directly at
servala-support@vshn.ch.'
+ "There was an error submitting your support request. "
+ 'Please try again or contact us directly at
servala-support@vshn.ch.'
)
),
)
diff --git a/src/servala/settings.py b/src/servala/settings.py
index ed28324..63f7895 100644
--- a/src/servala/settings.py
+++ b/src/servala/settings.py
@@ -150,6 +150,7 @@ INSTALLED_APPS = [
# The frontend app is loaded early in order to supersede some allauth views/behaviour
"servala.frontend",
"django.forms",
+ "django_jsonform",
"template_partials",
"rules.apps.AutodiscoverRulesConfig",
"allauth",
diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css
index db9052a..ee4c080 100644
--- a/src/servala/static/css/servala.css
+++ b/src/servala/static/css/servala.css
@@ -174,3 +174,21 @@ a.btn-keycloak {
margin-top: -16px;
padding-right: 28px;
}
+
+/* Service cards equal height styling */
+.service-cards-container .card {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.service-cards-container .card-body {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+}
+
+.service-cards-container .card-footer {
+ margin-top: auto;
+}
diff --git a/uv.lock b/uv.lock
index 68b4f94..df4202a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -338,6 +338,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/8a/2c5d88cd540d83ceaa1cb3191ed35dfed0caacc6fe2ff5fe74c9ecc7776f/django_fernet_encrypted_fields-0.3.0-py3-none-any.whl", hash = "sha256:a17cca5bf3638ee44674e64f30792d5960b1d4d4b291ec478c27515fc4860612", size = 5400, upload-time = "2025-02-21T02:58:40.832Z" },
]
+[[package]]
+name = "django-jsonform"
+version = "2.23.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/a8/83c57acbc153b86615be279cee5a194ce1163b578f29a9f6d658f267785e/django_jsonform-2.23.2.tar.gz", hash = "sha256:6fa2ba7c082be51d738e6c66e35075a3cb9ebc2f941e3a477c988900a7fe3269", size = 108182, upload-time = "2025-01-29T22:31:34.093Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/8e/8766f4bc535917ccbc6b3dcb57caf82210a6bacd613c3d9dbaec81018935/django_jsonform-2.23.2-py3-none-any.whl", hash = "sha256:1b7f94c5a2bd22c844e035a9940a9c8586f7b8fc3346ef2a6a13ba608e0059d7", size = 109148, upload-time = "2025-01-29T22:31:31.186Z" },
+]
+
[[package]]
name = "django-scopes"
version = "2.0.0"
@@ -1050,6 +1062,7 @@ dependencies = [
{ name = "django" },
{ name = "django-allauth" },
{ name = "django-fernet-encrypted-fields" },
+ { name = "django-jsonform" },
{ name = "django-scopes" },
{ name = "django-storages", extra = ["s3"] },
{ name = "django-template-partials" },
@@ -1086,6 +1099,7 @@ requires-dist = [
{ name = "django", specifier = "==5.2.4" },
{ name = "django-allauth", specifier = ">=65.10.0" },
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
+ { name = "django-jsonform", specifier = ">=2.22.0" },
{ name = "django-scopes", specifier = ">=2.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
{ name = "django-template-partials", specifier = ">=24.4" },