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 %} -
- Servala +
+ Servala
{% endblock card_header %} - {% block card_content %} {% if SOCIALACCOUNT_ENABLED %} @@ -24,9 +25,10 @@
{% translate "Ready to get started?" %}
-

{% translate "Sign in to access your managed service instances and the Servala service catalog" %}

+

+ {% translate "Sign in to access your managed service instances and the Servala service catalog" %} +

- {% for provider in socialaccount_providers %} {% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
@@ -35,7 +37,9 @@
@@ -43,7 +47,6 @@
{% endif %} {% endif %} -
@@ -72,8 +75,8 @@
- {% translate "Learn more" %} @@ -82,7 +85,6 @@
-
@@ -96,7 +98,6 @@
-
diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index c2d9285..990dc17 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -5,13 +5,18 @@ - + - {% block html_title %}Dashboard{% endblock html_title %} – Servala + + {% block html_title %} + Dashboard + {% endblock html_title %} + – Servala diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html index d03489e..d9ca433 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -26,6 +26,24 @@

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

+ {% if service.external_links %} +
+
+
{% translate "External Links" %}
+
+ {% for link in service.external_links %} + + {{ link.title }} + + + {% endfor %} +
+
+
+ {% endif %}
diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 184ff49..56dc7e2 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -40,6 +40,13 @@
+ {% 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" %}
+
+ {% for link in service.external_links %} + + {{ link.title }} + + + {% endfor %} +
+
+
+ {% 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.logo %} {{ service.category }}
-
+
{% 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" },