diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 5d67754..456f4b2 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -12,7 +12,13 @@ from django.views.decorators.csrf import csrf_exempt from servala.api.permissions import OSBBasicAuthPermission from servala.core.exoscale import get_exoscale_origin -from servala.core.models import BillingEntity, Organization, User +from servala.core.models import ( + BillingEntity, + Organization, + OrganizationInvitation, + OrganizationRole, + User, +) from servala.core.models.service import Service, ServiceOffering logger = logging.getLogger(__name__) @@ -127,8 +133,13 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): origin=exoscale_origin, osb_guid=organization_guid, ) - organization = Organization.create_organization(organization, user) - self._send_invitation_email(request, organization, user) + organization = Organization.create_organization(organization) + invitation = OrganizationInvitation.objects.create( + organization=organization, + email=user.email.lower(), + role=OrganizationRole.OWNER, + ) + invitation.send_invitation_email(request) except Exception: return JsonResponse({"error": "Internal server error"}, status=500) @@ -138,28 +149,6 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): ) return JsonResponse({"message": "Successfully enabled service"}, status=201) - def _send_invitation_email(self, request, organization, user): - subject = f"Welcome to Servala - {organization.name}" - url = request.build_absolute_uri(organization.urls.base) - message = f"""Hello {user.first_name or user.email}, - -You have been invited to join the organization "{organization.name}" on Servala Portal. - -You can access your organization at: {url} - -Please use this email address ({user.email}) when prompted to log in. - -Best regards, -The Servala Team""" - - send_mail( - subject=subject, - message=message, - from_email=settings.EMAIL_DEFAULT_FROM, - recipient_list=[user.email], - fail_silently=False, - ) - def _send_service_welcome_email( self, request, organization, user, service, service_offering ): diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index f966f38..1aec22a 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -120,6 +120,7 @@ class OrganizationInvitationAdmin(admin.ModelAdmin): "updated_at", ) date_hierarchy = "created_at" + actions = ["send_invitation_emails"] def is_accepted(self, obj): return obj.is_accepted @@ -127,6 +128,35 @@ class OrganizationInvitationAdmin(admin.ModelAdmin): is_accepted.boolean = True is_accepted.short_description = _("Accepted") + def send_invitation_emails(self, request, queryset): + pending_invitations = queryset.filter(accepted_by__isnull=True) + sent_count = 0 + failed_count = 0 + + for invitation in pending_invitations: + try: + invitation.send_invitation_email(request) + sent_count += 1 + except Exception as e: + failed_count += 1 + messages.error( + request, + _(f"Failed to send invitation to {invitation.email}: {str(e)}"), + ) + + if sent_count > 0: + messages.success( + request, + _(f"Successfully sent {sent_count} invitation email(s)."), + ) + + if failed_count > 0: + messages.warning( + request, _(f"Failed to send {failed_count} invitation email(s).") + ) + + send_invitation_emails.short_description = _("Send invitation emails") + @admin.register(ServiceCategory) class ServiceCategoryAdmin(admin.ModelAdmin): @@ -146,7 +176,6 @@ class ServiceAdmin(admin.ModelAdmin): 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", @@ -179,7 +208,6 @@ class CloudProviderAdmin(admin.ModelAdmin): 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", @@ -212,7 +240,15 @@ class ControlPlaneAdmin(admin.ModelAdmin): fieldsets = ( ( None, - {"fields": ("name", "description", "cloud_provider", "required_label")}, + { + "fields": ( + "name", + "description", + "cloud_provider", + "required_label", + "wildcard_dns", + ) + }, ), ( _("API Credentials"), @@ -282,8 +318,35 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): "description": _("API definition for the Kubernetes Custom Resource"), }, ), + ( + _("Form Configuration"), + { + "fields": ("advanced_fields",), + "description": _( + "Configure which fields should be hidden behind an 'Advanced' toggle in the form" + ), + }, + ), ) + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + # JSON schema for advanced_fields field + advanced_fields_schema = { + "type": "array", + "title": "Advanced Fields", + "items": { + "type": "string", + "title": "Field Name", + "description": "Field name in dot notation (e.g., spec.parameters.monitoring.enabled)", + }, + } + if "advanced_fields" in form.base_fields: + form.base_fields["advanced_fields"].widget = JSONFormWidget( + schema=advanced_fields_schema + ) + return form + def get_exclude(self, request, obj=None): # Exclude the original api_definition field as we're using our custom fields return ["api_definition"] @@ -342,3 +405,23 @@ class ServiceOfferingAdmin(admin.ModelAdmin): search_fields = ("description",) autocomplete_fields = ("service", "provider") inlines = (ControlPlaneCRDInline,) + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + 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 diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 44c809b..276e9c2 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -86,10 +86,117 @@ def build_object_fields(schema, name, verbose_name_prefix=None, parent_required= def deslugify(title): + """ + Convert camelCase, PascalCase, or snake_case to human-readable title. + Handles known acronyms (e.g., postgreSQLParameters -> PostgreSQL Parameters). + """ + ACRONYMS = { + # Database systems + "SQL": "SQL", + "MYSQL": "MySQL", + "POSTGRESQL": "PostgreSQL", + "MARIADB": "MariaDB", + "MSSQL": "MSSQL", + "MONGODB": "MongoDB", + "REDIS": "Redis", + # Protocols + "HTTP": "HTTP", + "HTTPS": "HTTPS", + "FTP": "FTP", + "SFTP": "SFTP", + "SSH": "SSH", + "TLS": "TLS", + "SSL": "SSL", + # APIs + "API": "API", + "REST": "REST", + "GRPC": "gRPC", + "GRAPHQL": "GraphQL", + # Networking + "URL": "URL", + "URI": "URI", + "FQDN": "FQDN", + "DNS": "DNS", + "IP": "IP", + "TCP": "TCP", + "UDP": "UDP", + # Data formats + "JSON": "JSON", + "XML": "XML", + "YAML": "YAML", + "CSV": "CSV", + "HTML": "HTML", + "CSS": "CSS", + # Hardware + "CPU": "CPU", + "RAM": "RAM", + "GPU": "GPU", + "SSD": "SSD", + "HDD": "HDD", + # Identifiers + "ID": "ID", + "UUID": "UUID", + "GUID": "GUID", + "ARN": "ARN", + # Cloud providers + "AWS": "AWS", + "GCP": "GCP", + "AZURE": "Azure", + "IBM": "IBM", + # Kubernetes/Cloud + "DB": "DB", + "PVC": "PVC", + "PV": "PV", + "VPN": "VPN", + # Auth + "OS": "OS", + "LDAP": "LDAP", + "SAML": "SAML", + "OAUTH": "OAuth", + "JWT": "JWT", + # AWS Services + "S3": "S3", + "EC2": "EC2", + "RDS": "RDS", + "EBS": "EBS", + "IAM": "IAM", + } + if "_" in title: - title.replace("_", " ") - return title.title() - return re.sub(r"(? SQL Parameters) + words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[a-z]+|[0-9]+", title) + + # Merge adjacent words if they form a known compound acronym (e.g., postgre + SQL = PostgreSQL) + merged_words = [] + i = 0 + while i < len(words): + if i < len(words) - 1: + # Check if current word + next word form a known acronym + combined = (words[i] + words[i + 1]).upper() + if combined in ACRONYMS: + merged_words.append(combined) + i += 2 + continue + merged_words.append(words[i]) + i += 1 + + # Capitalize each word, using proper casing for known acronyms + result = [] + for word in merged_words: + word_upper = word.upper() + if word_upper in ACRONYMS: + result.append(ACRONYMS[word_upper]) + else: + result.append(word.capitalize()) + + return " ".join(result) def get_django_field( @@ -220,6 +327,19 @@ class CrdModelFormMixin: field.widget = forms.HiddenInput() field.required = False + # Mark advanced fields with a CSS class and data attribute + advanced_fields = getattr(self, "ADVANCED_FIELDS", []) + for name, field in self.fields.items(): + if name in advanced_fields: + field.widget.attrs.update( + { + "class": ( + field.widget.attrs.get("class", "") + " advanced-field" + ).strip(), + "data-advanced": "true", + } + ) + if self.instance and self.instance.pk: self.fields["name"].disabled = True self.fields["name"].help_text = _("Name cannot be changed after creation.") @@ -406,7 +526,7 @@ class CrdModelFormMixin: pass -def generate_model_form_class(model): +def generate_model_form_class(model, advanced_fields=None): meta_attrs = { "model": model, "fields": "__all__", @@ -414,6 +534,7 @@ def generate_model_form_class(model): fields = { "Meta": type("Meta", (object,), meta_attrs), "__module__": "crd_models", + "ADVANCED_FIELDS": advanced_fields or [], } class_name = f"{model.__name__}ModelForm" return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields) diff --git a/src/servala/core/migrations/0012_serviceoffering_external_links.py b/src/servala/core/migrations/0012_serviceoffering_external_links.py new file mode 100644 index 0000000..d8c2ac7 --- /dev/null +++ b/src/servala/core/migrations/0012_serviceoffering_external_links.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-10-17 02:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0011_organizationinvitation"), + ] + + operations = [ + migrations.AddField( + model_name="serviceoffering", + name="external_links", + field=models.JSONField( + blank=True, + help_text='JSON array of link objects: {"url": "…", "title": "…"}. ', + null=True, + verbose_name="External links", + ), + ), + ] diff --git a/src/servala/core/migrations/0013_controlplane_wildcard_dns.py b/src/servala/core/migrations/0013_controlplane_wildcard_dns.py new file mode 100644 index 0000000..26dbaaf --- /dev/null +++ b/src/servala/core/migrations/0013_controlplane_wildcard_dns.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.7 on 2025-10-17 02:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0012_serviceoffering_external_links"), + ] + + operations = [ + migrations.AddField( + model_name="controlplane", + name="wildcard_dns", + field=models.CharField( + blank=True, + help_text="Wildcard DNS domain for auto-generating FQDNs (e.g., apps.exoscale-ch-gva-2-prod2.services.servala.com)", + max_length=255, + null=True, + verbose_name="Wildcard DNS", + ), + ), + ] diff --git a/src/servala/core/migrations/0014_servicedefinition_advanced_fields.py b/src/servala/core/migrations/0014_servicedefinition_advanced_fields.py new file mode 100644 index 0000000..20632b9 --- /dev/null +++ b/src/servala/core/migrations/0014_servicedefinition_advanced_fields.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.7 on 2025-10-17 03:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0013_controlplane_wildcard_dns"), + ] + + operations = [ + migrations.AddField( + model_name="servicedefinition", + name="advanced_fields", + field=models.JSONField( + blank=True, + default=list, + help_text=( + "Array of field names that should be hidden behind an 'Advanced' toggle. " + "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])" + ), + null=True, + verbose_name="Advanced fields", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index b6d7e9f..d308bfa 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -3,7 +3,10 @@ import secrets import rules import urlman from django.conf import settings +from django.contrib.sites.shortcuts import get_current_site +from django.core.mail import send_mail from django.db import models, transaction +from django.http import HttpRequest from django.utils.functional import cached_property from django.utils.safestring import mark_safe from django.utils.text import slugify @@ -112,7 +115,7 @@ class Organization(ServalaModelMixin, models.Model): @classmethod @transaction.atomic - def create_organization(cls, instance, owner): + def create_organization(cls, instance, owner=None): try: instance.origin except Exception: @@ -120,7 +123,8 @@ class Organization(ServalaModelMixin, models.Model): pk=settings.SERVALA_DEFAULT_ORIGIN ) instance.save() - instance.set_owner(owner) + if owner: + instance.set_owner(owner) if ( instance.billing_entity.odoo_company_id @@ -486,3 +490,49 @@ class OrganizationInvitation(ServalaModelMixin, models.Model): @property def can_be_accepted(self): return not self.is_accepted + + def send_invitation_email(self, request=None): + subject = _("You're invited to join {organization} on Servala").format( + organization=self.organization.name + ) + + if request: + invitation_url = request.build_absolute_uri(self.urls.accept) + organization_url = request.build_absolute_uri(self.organization.urls.base) + else: + fake_request = HttpRequest() + fake_request.META["SERVER_NAME"] = get_current_site(None).domain + fake_request.META["SERVER_PORT"] = "443" + fake_request.META["wsgi.url_scheme"] = "https" + invitation_url = fake_request.build_absolute_uri(self.urls.accept) + organization_url = fake_request.build_absolute_uri( + self.organization.urls.base + ) + + message = _( + """Hello, + +You have been invited to join the organization "{organization}" on Servala Portal as a {role}. + +To accept this invitation, please click the link below: +{invitation_url} + +Once you accept, you'll be able to access the organization at: +{organization_url} + +Best regards, +The Servala Team""" + ).format( + organization=self.organization.name, + role=self.get_role_display(), + invitation_url=invitation_url, + organization_url=organization_url, + ) + + send_mail( + subject=subject, + message=message, + from_email=settings.EMAIL_DEFAULT_FROM, + recipient_list=[self.email], + fail_silently=False, + ) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 846b24e..42fc500 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -159,6 +159,15 @@ class ControlPlane(ServalaModelMixin, models.Model): "Key-value information displayed to users when selecting this control plane" ), ) + wildcard_dns = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name=_("Wildcard DNS"), + help_text=_( + "Wildcard DNS domain for auto-generating FQDNs (e.g., apps.exoscale-ch-gva-2-prod2.services.servala.com)" + ), + ) class Meta: verbose_name = _("Control plane") @@ -350,6 +359,16 @@ class ServiceDefinition(ServalaModelMixin, models.Model): null=True, blank=True, ) + advanced_fields = models.JSONField( + verbose_name=_("Advanced fields"), + help_text=_( + "Array of field names that should be hidden behind an 'Advanced' toggle. " + "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])" + ), + null=True, + blank=True, + default=list, + ) service = models.ForeignKey( to="Service", on_delete=models.CASCADE, @@ -490,7 +509,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): if not self.django_model: return - return generate_model_form_class(self.django_model) + advanced_fields = self.service_definition.advanced_fields or [] + return generate_model_form_class( + self.django_model, advanced_fields=advanced_fields + ) class ServiceOffering(ServalaModelMixin, models.Model): @@ -511,6 +533,12 @@ class ServiceOffering(ServalaModelMixin, models.Model): verbose_name=_("Provider"), ) description = models.TextField(blank=True, verbose_name=_("Description")) + external_links = models.JSONField( + null=True, + blank=True, + verbose_name=_("External links"), + help_text=('JSON array of link objects: {"url": "…", "title": "…"}. '), + ) osb_plan_id = models.CharField( max_length=100, null=True, @@ -657,6 +685,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): return mark_safe(f"") @classmethod + @transaction.atomic def create_instance(cls, name, organization, context, created_by, spec_data): # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) @@ -704,7 +733,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): body=create_data, ) except Exception as e: - instance.delete() + # Transaction will automatically roll back the instance creation if isinstance(e, ApiException): try: error_body = json.loads(e.body) @@ -905,6 +934,9 @@ class ServiceInstance(ServalaModelMixin, models.Model): import base64 for key, value in secret.data.items(): + # Skip keys ending with _HOST as they're only useful for dedicated OpenShift clusters + if key.endswith("_HOST"): + continue try: credentials[key] = base64.b64decode(value).decode("utf-8") except Exception: diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 6fd04b4..27e6a09 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,8 +1,9 @@ from django import forms +from django.core.exceptions import ValidationError from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ -from servala.core.models import Organization +from servala.core.models import Organization, OrganizationInvitation, OrganizationRole from servala.core.odoo import get_invoice_addresses, get_odoo_countries from servala.frontend.forms.mixins import HtmxMixin @@ -111,3 +112,68 @@ class OrganizationCreateForm(OrganizationForm): "existing_odoo_address_id", _("Please select an invoice address.") ) return cleaned_data + + +class OrganizationInvitationForm(forms.ModelForm): + + def __init__(self, *args, organization=None, user_role=None, **kwargs): + super().__init__(*args, **kwargs) + self.organization = organization + self.user_role = user_role + + if user_role: + allowed_roles = self._get_allowed_roles(user_role) + self.fields["role"].choices = [ + (value, label) + for value, label in OrganizationRole.choices + if value in allowed_roles + ] + + def _get_allowed_roles(self, user_role): + role_hierarchy = { + OrganizationRole.OWNER: [ + OrganizationRole.OWNER, + OrganizationRole.ADMIN, + OrganizationRole.MEMBER, + ], + OrganizationRole.ADMIN: [ + OrganizationRole.ADMIN, + OrganizationRole.MEMBER, + ], + OrganizationRole.MEMBER: [], + } + return role_hierarchy.get(user_role, []) + + def clean_email(self): + email = self.cleaned_data["email"].lower() + + if self.organization.members.filter(email__iexact=email).exists(): + raise ValidationError( + _("A user with this email is already a member of this organization.") + ) + + if OrganizationInvitation.objects.filter( + organization=self.organization, + email__iexact=email, + accepted_by__isnull=True, + ).exists(): + raise ValidationError( + _("An invitation has already been sent to this email address.") + ) + + return email + + def save(self, commit=True): + invitation = super().save(commit=False) + invitation.organization = self.organization + if commit: + invitation.save() + return invitation + + class Meta: + model = OrganizationInvitation + fields = ("email", "role") + widgets = { + "email": forms.EmailInput(attrs={"placeholder": _("user@example.com")}), + "role": forms.RadioSelect(), + } diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 1301bec..7c6bc54 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -93,5 +93,7 @@ })(); + {% block extra_js %} + {% endblock extra_js %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html index c72fbef..a101ed1 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -32,13 +32,7 @@
{% translate "External Links" %}
{% for link in service.external_links %} - - {{ link.title }} - - + {% include "includes/external_link.html" %} {% endfor %}
diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index c17dca0..d375344 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -173,34 +173,36 @@ {% endif %} {% if instance.connection_credentials %} -
-
-

{% translate "Connection Credentials" %}

-
-
-
- - - - - - - - - {% for key, value in instance.connection_credentials.items %} +
+
+
+

{% translate "Connection Credentials" %}

+
+
+
+
{% translate "Name" %}{% translate "Value" %}
+ - - + + - {% endfor %} - -
{{ key }} - {% if key == "error" %} - {{ value }} - {% else %} - {{ value }} - {% endif %} - {% translate "Name" %}{% translate "Value" %}
+ + + {% for key, value in instance.connection_credentials.items %} + + {{ key }} + + {% if key == "error" %} + {{ value }} + {% else %} + {{ value }} + {% endif %} + + + {% endfor %} + + +
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 7f3863e..842e610 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -64,19 +64,16 @@ {{ select_form }} {% endif %} - {% if service.external_links %} + {% if service.external_links or offering.external_links %}
{% translate "External Links" %}
{% for link in service.external_links %} - - {{ link.title }} - - + {% include "includes/external_link.html" %} + {% endfor %} + {% for link in offering.external_links %} + {% include "includes/external_link.html" %} {% endfor %}
@@ -96,3 +93,14 @@
{% endblock content %} +{% block extra_js %} + {% if wildcard_dns and organization_namespace %} + + + {% endif %} +{% endblock extra_js %} diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 2e1b9b0..d55dc56 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -36,6 +36,74 @@ {% endpartialdef org-name-edit %} +{% partialdef members-list %} +
+ + + + + + + + + + + {% for membership in memberships %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Name" %}{% translate "Email" %}{% translate "Role" %}{% translate "Joined" %}
{{ membership.user }}{{ membership.user.email }} + + {{ membership.get_role_display }} + + {{ membership.date_joined|date:"Y-m-d" }}
{% translate "No members yet" %}
+
+{% if pending_invitations %} +
+ {% translate "Pending Invitations" %} +
+
+ + + + + + + + + + + {% for invitation in pending_invitations %} + + + + + + + {% endfor %} + +
{% translate "Email" %}{% translate "Role" %}{% translate "Sent" %}{% translate "Link" %}
{{ invitation.email }} + + {{ invitation.get_role_display }} + + {{ invitation.created_at|date:"Y-m-d H:i" }} + +
+
+{% endif %} +{% endpartialdef members-list %} {% block content %}
@@ -135,5 +203,39 @@
{% endif %} + {% if can_manage_members %} +
+
+

+ {% translate "Members" %} +

+
+
+
{% partial members-list %}
+
+
+
+
+

+ {% translate "Invite New Member" %} +

+
+
+
+
+ {% csrf_token %} +
{{ invitation_form }}
+
+
+ +
+
+
+
+
+
+ {% endif %}
{% endblock content %} diff --git a/src/servala/frontend/templates/includes/external_link.html b/src/servala/frontend/templates/includes/external_link.html new file mode 100644 index 0000000..e8319bf --- /dev/null +++ b/src/servala/frontend/templates/includes/external_link.html @@ -0,0 +1,7 @@ + + {{ link.title }} + + diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index c9d947a..5857bdf 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -1,10 +1,23 @@ {% load i18n %} {% load get_field %} +{% load static %}
{% csrf_token %} {% include "frontend/forms/errors.html" %} + {% if form.ADVANCED_FIELDS %} +
+ +
+ {% endif %}