From ecf4052819b5b1d923e7ea09fa0f2d23a160ca7a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Mar 2025 08:26:13 +0100 Subject: [PATCH 01/10] Form and View mixins for HTMX/partials --- src/servala/frontend/forms/mixins.py | 15 ++++++++++++ src/servala/frontend/views/mixins.py | 36 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/servala/frontend/forms/mixins.py create mode 100644 src/servala/frontend/views/mixins.py diff --git a/src/servala/frontend/forms/mixins.py b/src/servala/frontend/forms/mixins.py new file mode 100644 index 0000000..a2f61a6 --- /dev/null +++ b/src/servala/frontend/forms/mixins.py @@ -0,0 +1,15 @@ +class HtmxMixin: + """ + Form mixin that retains only a single field when specified. + Useful when sending single fields with htmx. + """ + + def __init__(self, *args, **kwargs): + self.single_field = kwargs.pop("single_field", None) + + super().__init__(*args, **kwargs) + + if self.single_field and self.single_field in self.fields: + field = self.fields[self.single_field] + self.fields.clear() + self.fields[self.single_field] = field diff --git a/src/servala/frontend/views/mixins.py b/src/servala/frontend/views/mixins.py new file mode 100644 index 0000000..3184e86 --- /dev/null +++ b/src/servala/frontend/views/mixins.py @@ -0,0 +1,36 @@ +from django.utils.functional import cached_property +from django.views.generic import TemplateView + + +class HtmxMixin(TemplateView): + fragments = [] + + @cached_property + def is_htmx(self): + return self.request.headers.get("HX-Request") + + def _get_fragment(self): + if self.request.method == "POST": + fragment = self.request.POST.get("fragment") + else: + fragment = self.request.GET.get("fragment") + if fragment and fragment in self.fragments: + return fragment + + def get_template_names(self): + template_names = super().get_template_names() + if self.is_htmx and (fragment := self._get_fragment()): + return [f"{template_names[0]}#{fragment}"] + return template_names + + def get_form_kwargs(self): + result = super().get_form_kwargs() + if self.is_htmx and (field_name := self.request.POST.get("hx-single-field")): + result["single_field"] = field_name + return result + + def form_valid(self, form): + result = super().form_valid(form) + if self.is_htmx and self._get_fragment(): + return self.get(self.request, *self.args, **self.kwargs) + return result From 72bd7388d6f8d874d4de9089c5e5361d3816a561 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Mar 2025 08:28:09 +0100 Subject: [PATCH 02/10] Inline-edit user profile with HTMX --- src/servala/frontend/forms/__init__.py | 3 +- src/servala/frontend/forms/profile.py | 11 +++ .../frontend/templates/frontend/profile.html | 77 +++++++++++++++++-- src/servala/frontend/views/generic.py | 30 ++++++-- 4 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 src/servala/frontend/forms/profile.py diff --git a/src/servala/frontend/forms/__init__.py b/src/servala/frontend/forms/__init__.py index 8169b61..fe0ffc1 100644 --- a/src/servala/frontend/forms/__init__.py +++ b/src/servala/frontend/forms/__init__.py @@ -1,3 +1,4 @@ from .organization import OrganizationCreateForm +from .profile import UserProfileForm -__all__ = ["OrganizationCreateForm"] +__all__ = ["OrganizationCreateForm", "UserProfileForm"] diff --git a/src/servala/frontend/forms/profile.py b/src/servala/frontend/forms/profile.py new file mode 100644 index 0000000..23f1df6 --- /dev/null +++ b/src/servala/frontend/forms/profile.py @@ -0,0 +1,11 @@ +from django import forms + +from servala.core.models import User +from servala.frontend.forms.mixins import HtmxMixin + + +class UserProfileForm(HtmxMixin, forms.ModelForm): + + class Meta: + model = User + fields = ("email", "company") diff --git a/src/servala/frontend/templates/frontend/profile.html b/src/servala/frontend/templates/frontend/profile.html index 9532e96..7b37956 100644 --- a/src/servala/frontend/templates/frontend/profile.html +++ b/src/servala/frontend/templates/frontend/profile.html @@ -1,5 +1,6 @@ {% extends "frontend/base.html" %} {% load i18n static %} +{% load partials %} {% block html_title %} {% block page_title %} {% translate "Profile" %} @@ -10,6 +11,70 @@

{% translate "Account" %}

{% endblock %} +{% partialdef user-email %} + + {% translate "E-mail" %} + {{ request.user.email }} + + + + +{% endpartialdef user-email %} +{% partialdef user-company %} + + {% translate "Company" %} + {{ request.user.company|default_if_none:"" }} + + + + +{% endpartialdef user-company %} +{% partialdef user-email-edit %} + + {% translate "E-mail" %} + +
+ {{ form.email }} + + + + +
+ + +{% endpartialdef %} +{% partialdef user-company-edit %} + + {% translate "Company" %} + +
+ {{ form.company }} + + + + +
+ + +{% endpartialdef %} {% block content %}
@@ -23,22 +88,18 @@
- - - - + {% partial user-email %} + + - - - - + {% partial user-company %}
{% translate "E-mail" %}{{ request.user.email }}
{% translate "First name" %} {{ request.user.first_name }}
{% translate "Last name" %} {{ request.user.last_name }}
{% translate "Company" %}{{ request.user.company }}
diff --git a/src/servala/frontend/views/generic.py b/src/servala/frontend/views/generic.py index 3b2196b..12300fd 100644 --- a/src/servala/frontend/views/generic.py +++ b/src/servala/frontend/views/generic.py @@ -1,21 +1,41 @@ from django.conf import settings -from django.views.generic import TemplateView +from django.urls import reverse_lazy +from django.utils.functional import cached_property +from django.views.generic import TemplateView, UpdateView + +from servala.core.models import User +from servala.frontend.forms.profile import UserProfileForm +from servala.frontend.views.mixins import HtmxMixin class IndexView(TemplateView): template_name = "frontend/index.html" -class ProfileView(TemplateView): +class ProfileView(HtmxMixin, UpdateView): template_name = "frontend/profile.html" + form_class = UserProfileForm + success_url = reverse_lazy("frontend:profile") + fragments = ("user-email", "user-email-edit", "user-company", "user-company-edit") + model = User + + def get_object(self): + return self.request.user + + @cached_property + def object(self): + return self.get_object() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - keycloak_server_url = settings.SOCIALACCOUNT_PROVIDERS["openid_connect"][ - "APPS" - ][0]["settings"]["server_url"] + keycloak_settings = settings.SOCIALACCOUNT_PROVIDERS["openid_connect"] + keycloak_server_url = keycloak_settings["APPS"][0]["settings"]["server_url"] account_url = keycloak_server_url.replace( "/.well-known/openid-configuration", "/account" ) context["account_href"] = account_url return context + + def form_valid(self, form): + form.save() + return super().form_valid(form) From b36ebcf5ff396325fbcb2fc5ced3074ea31c27d9 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Mar 2025 08:57:24 +0100 Subject: [PATCH 03/10] Implement inline form renderer --- src/servala/frontend/forms/renderers.py | 17 +++++++++----- .../frontend/templates/frontend/base.html | 1 + .../templates/frontend/forms/field.html | 16 ++++++++++++++ .../frontend/forms/inline_field.html | 1 + .../frontend/forms/vertical_field.html | 22 +------------------ src/servala/static/css/servala.css | 3 +++ 6 files changed, 34 insertions(+), 26 deletions(-) create mode 100644 src/servala/frontend/templates/frontend/forms/field.html create mode 100644 src/servala/frontend/templates/frontend/forms/inline_field.html create mode 100644 src/servala/static/css/servala.css diff --git a/src/servala/frontend/forms/renderers.py b/src/servala/frontend/forms/renderers.py index f8cdb5d..90ce926 100644 --- a/src/servala/frontend/forms/renderers.py +++ b/src/servala/frontend/forms/renderers.py @@ -16,13 +16,20 @@ class VerticalFormRenderer(TemplatesSetting): form_template_name = "frontend/forms/form.html" field_template_name = "frontend/forms/vertical_field.html" + def get_class_names(self, input_type): + if input_type == "checkbox": + return "form-check-input" + return "form-control" + def render(self, template_name, context, request=None): if field := context.get("field"): - if field.field.widget.input_type == "checkbox": - class_name = "form-check-input" - else: - class_name = "form-control" field.build_widget_attrs = inject_class( - field.build_widget_attrs, class_name + field.build_widget_attrs, + self.get_class_names(field.field.widget.input_type), ) return super().render(template_name, context, request) + + +class InlineFormRenderer(VerticalFormRenderer): + form_template_name = "frontend/forms/form.html" + field_template_name = "frontend/forms/inline_field.html" diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 62c6b33..87c8a1c 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -8,6 +8,7 @@ + diff --git a/src/servala/frontend/templates/frontend/forms/field.html b/src/servala/frontend/templates/frontend/forms/field.html new file mode 100644 index 0000000..f4b16d1 --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/field.html @@ -0,0 +1,16 @@ +{% load i18n %} +<div class="form-group d-inline{% if field.field.required %} mandatory{% endif %}{% if errors %} is-invalid{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}"> + {% if field.use_fieldset %} + <fieldset {% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}> + {% endif %} + {{ field }} + {% if field.field.widget.input_type == "checkbox" and not field.field.widget.allow_multiple_selected %} + <label for="{{ field.auto_id }}" class="form-check-label form-label">{{ field.label }}</label> + {% endif %} + {% if field.use_fieldset %}</fieldset>{% endif %} + {% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %} + {% if field.help_text %} + <small class="form-text text-muted" + {% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small> + {% endif %} +</div> diff --git a/src/servala/frontend/templates/frontend/forms/inline_field.html b/src/servala/frontend/templates/frontend/forms/inline_field.html new file mode 100644 index 0000000..c229641 --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/inline_field.html @@ -0,0 +1 @@ +{% include "frontend/forms/field.html" with extra_class="d-inline" %} diff --git a/src/servala/frontend/templates/frontend/forms/vertical_field.html b/src/servala/frontend/templates/frontend/forms/vertical_field.html index a2457d9..544d07d 100644 --- a/src/servala/frontend/templates/frontend/forms/vertical_field.html +++ b/src/servala/frontend/templates/frontend/forms/vertical_field.html @@ -1,21 +1 @@ -{% load i18n %} -<div class="col-12"> - <div class="form-group{% if field.field.required %} mandatory{% endif %}{% if errors %} is-invalid{% endif %}"> - {% if field.field.widget.input_type != "checkbox" or field.field.widget.allow_multiple_selected %} - <label for="{{ field.auto_id }}" class="form-label">{{ field.label }}</label> - {% endif %} - {% if field.use_fieldset %} - <fieldset {% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}> - {% endif %} - {{ field }} - {% if field.field.widget.input_type == "checkbox" and not field.field.widget.allow_multiple_selected %} - <label for="{{ field.auto_id }}" class="form-check-label form-label">{{ field.label }}</label> - {% endif %} - {% if field.use_fieldset %}</fieldset>{% endif %} - {% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %} - {% if field.help_text %} - <small class="form-text text-muted" - {% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small> - {% endif %} - </div> -</div> +<div class="col-12">{% include "frontend/forms/field.html" %}</div> diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css new file mode 100644 index 0000000..826a458 --- /dev/null +++ b/src/servala/static/css/servala.css @@ -0,0 +1,3 @@ +.form-group.d-inline { + margin-bottom: 0; +} From eae01c74c745bde60bd6dca8952f84848abd1530 Mon Sep 17 00:00:00 2001 From: Tobias Kunze <r@rixx.de> Date: Thu, 20 Mar 2025 08:58:05 +0100 Subject: [PATCH 04/10] More code reuse + inline form rendering --- src/servala/frontend/forms/mixins.py | 5 + .../frontend/templates/frontend/profile.html | 96 +++++++++---------- 2 files changed, 49 insertions(+), 52 deletions(-) diff --git a/src/servala/frontend/forms/mixins.py b/src/servala/frontend/forms/mixins.py index a2f61a6..4ca1796 100644 --- a/src/servala/frontend/forms/mixins.py +++ b/src/servala/frontend/forms/mixins.py @@ -1,9 +1,14 @@ +from servala.frontend.forms.renderers import InlineFormRenderer + + class HtmxMixin: """ Form mixin that retains only a single field when specified. Useful when sending single fields with htmx. """ + default_renderer = InlineFormRenderer + def __init__(self, *args, **kwargs): self.single_field = kwargs.pop("single_field", None) diff --git a/src/servala/frontend/templates/frontend/profile.html b/src/servala/frontend/templates/frontend/profile.html index 7b37956..afc9fd3 100644 --- a/src/servala/frontend/templates/frontend/profile.html +++ b/src/servala/frontend/templates/frontend/profile.html @@ -12,68 +12,60 @@ </div> {% endblock %} {% partialdef user-email %} -<tr> - <th>{% translate "E-mail" %}</th> - <td>{{ request.user.email }}</td> - <td> - <button class="btn btn-sm btn-primary" - hx-get="{% url 'frontend:profile' %}?fragment=user-email-edit" - hx-target="closest tr" - hx-swap="outerHTML">{% translate "Edit" %}</button> - </td> -</tr> +<td> + {{ request.user.email }} + <button class="btn btn-primary" + hx-get="{% url 'frontend:profile' %}?fragment=user-email-edit" + hx-target="closest td" + hx-swap="outerHTML">{% translate "Edit" %}</button> +</td> {% endpartialdef user-email %} {% partialdef user-company %} -<tr> - <th>{% translate "Company" %}</th> - <td>{{ request.user.company|default_if_none:"" }}</td> - <td> - <button class="btn btn-sm btn-primary" - hx-get="{% url 'frontend:profile' %}?fragment=user-company-edit" - hx-target="closest tr" - hx-swap="outerHTML">{% translate "Edit" %}</button> - </td> -</tr> +<td> + {{ request.user.company|default_if_none:"" }} + <button class="btn btn-primary" + hx-get="{% url 'frontend:profile' %}?fragment=user-company-edit" + hx-target="closest td" + hx-swap="outerHTML">{% translate "Edit" %}</button> +</td> {% endpartialdef user-company %} {% partialdef user-email-edit %} -<tr> - <th>{% translate "E-mail" %}</th> - <td colspan="2"> - <form hx-target="closest tr" - hx-swap="outerHTML" - hx-post="{{ request.url }}"> - {{ form.email }} +<td> + <form hx-target="closest td" + hx-swap="outerHTML" + hx-post="{{ request.url }}"> + <div class="d-flex align-items-baseline"> + {{ form.email.as_field_group }} <input type="hidden" name="hx-single-field" value="email"> <input type="hidden" name="fragment" value="user-email"> - <button type="submit" class="btn btn-primary">{% translate "Save" %}</button> + <button type="submit" class="btn btn-primary mx-1">{% translate "Save" %}</button> <button type="button" class="btn btn-secondary" hx-get="{{ request.path }}?fragment=user-email" - hx-target="closest tr" + hx-target="closest td" hx-swap="outerHTML">{% translate "Cancel" %}</button> - </form> - </td> -</tr> + </div> + </form> +</td> {% endpartialdef %} {% partialdef user-company-edit %} -<tr> - <th>{% translate "Company" %}</th> - <td colspan="2"> - <form hx-target="closest tr" - hx-swap="outerHTML" - hx-post="{{ request.url }}"> - {{ form.company }} +<td> + <form hx-target="closest td" + hx-swap="outerHTML" + hx-post="{{ request.url }}"> + <div class="d-flex align-items-baseline"> + {{ form.company.as_field_group }} <input type="hidden" name="hx-single-field" value="company"> <input type="hidden" name="fragment" value="user-company"> - <button type="submit" class="btn btn-primary">{% translate "Save" %}</button> + <button type="submit" class="btn mx-1 btn-primary">{% translate "Save" %}</button> <button type="button" class="btn btn-secondary" hx-get="{{ request.path }}?fragment=user-company" - hx-target="closest tr" + hx-target="closest td" hx-swap="outerHTML">{% translate "Cancel" %}</button> - </form> - </td> -</tr> + </div> + </form> +</td> {% endpartialdef %} {% block content %} <section> @@ -88,18 +80,18 @@ <div class="table-responsive"> <table class="table table-lg"> <tbody> - {% partial user-email %} <tr> - <th>{% translate "First name" %}</th> - <td>{{ request.user.first_name }}</td> - <td></td> + <th class="w-25">{% translate "Name" %}</th> + <td>{{ request.user.first_name }} {{ request.user.last_name }}</td> </tr> <tr> - <th>{% translate "Last name" %}</th> - <td>{{ request.user.last_name }}</td> - <td></td> + <th class="w-25">{% translate "E-mail" %}</th> + {% partial user-email %} + </tr> + <tr> + <th class="w-25">{% translate "Company" %}</th> + {% partial user-company %} </tr> - {% partial user-company %} </tbody> </table> </div> From f17e1a07823c870e19eb3631ae4b7e922157f9f0 Mon Sep 17 00:00:00 2001 From: Tobias Kunze <r@rixx.de> Date: Thu, 20 Mar 2025 09:03:24 +0100 Subject: [PATCH 05/10] Fix alignment --- src/servala/frontend/templates/frontend/profile.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/servala/frontend/templates/frontend/profile.html b/src/servala/frontend/templates/frontend/profile.html index afc9fd3..16bbd3e 100644 --- a/src/servala/frontend/templates/frontend/profile.html +++ b/src/servala/frontend/templates/frontend/profile.html @@ -85,11 +85,15 @@ <td>{{ request.user.first_name }} {{ request.user.last_name }}</td> </tr> <tr> - <th class="w-25">{% translate "E-mail" %}</th> + <th class="w-25"> + <span class="d-flex mt-2">{% translate "E-mail" %}</span> + </th> {% partial user-email %} </tr> <tr> - <th class="w-25">{% translate "Company" %}</th> + <th class="w-25"> + <span class="d-flex mt-2">{% translate "Company" %}</span> + </th> {% partial user-company %} </tr> </tbody> From 6ed19e90a360fd987ce3a0462b5b35558fcac8d2 Mon Sep 17 00:00:00 2001 From: Tobias Kunze <r@rixx.de> Date: Thu, 20 Mar 2025 09:11:15 +0100 Subject: [PATCH 06/10] Form rendering improvements --- .../frontend/templates/frontend/forms/field.html | 7 ++++++- .../templates/frontend/forms/inline_field.html | 2 +- .../frontend/templates/frontend/profile.html | 2 +- .../frontend/templates/includes/form.html | 16 +++++++++------- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/servala/frontend/templates/frontend/forms/field.html b/src/servala/frontend/templates/frontend/forms/field.html index f4b16d1..fe56619 100644 --- a/src/servala/frontend/templates/frontend/forms/field.html +++ b/src/servala/frontend/templates/frontend/forms/field.html @@ -1,5 +1,10 @@ {% load i18n %} -<div class="form-group d-inline{% if field.field.required %} mandatory{% endif %}{% if errors %} is-invalid{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}"> +<div class="form-group{% if field.field.required %} mandatory{% endif %}{% if errors %} is-invalid{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}"> + {% if not hide_label %} + {% if field.field.widget.input_type != "checkbox" or field.field.widget.allow_multiple_selected %} + <label for="{{ field.auto_id }}" class="form-label">{{ field.label }}</label> + {% endif %} + {% endif %} {% if field.use_fieldset %} <fieldset {% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}> {% endif %} diff --git a/src/servala/frontend/templates/frontend/forms/inline_field.html b/src/servala/frontend/templates/frontend/forms/inline_field.html index c229641..ab8edba 100644 --- a/src/servala/frontend/templates/frontend/forms/inline_field.html +++ b/src/servala/frontend/templates/frontend/forms/inline_field.html @@ -1 +1 @@ -{% include "frontend/forms/field.html" with extra_class="d-inline" %} +{% include "frontend/forms/field.html" with extra_class="d-inline" hide_label=True %} diff --git a/src/servala/frontend/templates/frontend/profile.html b/src/servala/frontend/templates/frontend/profile.html index 16bbd3e..cfad0e9 100644 --- a/src/servala/frontend/templates/frontend/profile.html +++ b/src/servala/frontend/templates/frontend/profile.html @@ -22,7 +22,7 @@ {% endpartialdef user-email %} {% partialdef user-company %} <td> - {{ request.user.company|default_if_none:"" }} + {{ request.user.company|default:"–" }} <button class="btn btn-primary" hx-get="{% url 'frontend:profile' %}?fragment=user-company-edit" hx-target="closest td" diff --git a/src/servala/frontend/templates/includes/form.html b/src/servala/frontend/templates/includes/form.html index fec4356..d998fc1 100644 --- a/src/servala/frontend/templates/includes/form.html +++ b/src/servala/frontend/templates/includes/form.html @@ -5,11 +5,13 @@ {% include "includes/form_errors.html" %} {% csrf_token %} {{ form }} - <button class="btn btn-primary" type="submit"> - {% if form_submit_label %} - {{ form_submit_label }} - {% else %} - {% translate "Save" %} - {% endif %} - </button> + <div class="col-sm-12 d-flex justify-content-end"> + <button class="btn btn-primary me-1 mb-1" type="submit"> + {% if form_submit_label %} + {{ form_submit_label }} + {% else %} + {% translate "Save" %} + {% endif %} + </button> + </div> </form> From 314ecc9c0d247b9291e6e57a356e7e657e2dd46d Mon Sep 17 00:00:00 2001 From: Tobias Kunze <r@rixx.de> Date: Thu, 20 Mar 2025 09:12:56 +0100 Subject: [PATCH 07/10] Make Organization.billing_entity nullable --- .../0003_billing_entity_nullable.py | 25 +++++++++++++++++++ src/servala/core/models/organization.py | 1 + 2 files changed, 26 insertions(+) create mode 100644 src/servala/core/migrations/0003_billing_entity_nullable.py diff --git a/src/servala/core/migrations/0003_billing_entity_nullable.py b/src/servala/core/migrations/0003_billing_entity_nullable.py new file mode 100644 index 0000000..bae2ae2 --- /dev/null +++ b/src/servala/core/migrations/0003_billing_entity_nullable.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2b1 on 2025-03-20 08:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_billingentity_created_at_billingentity_updated_at_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="organization", + name="billing_entity", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="organizations", + to="core.billingentity", + verbose_name="Billing entity", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 0419175..38bc699 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -13,6 +13,7 @@ class Organization(ServalaModelMixin, models.Model): on_delete=models.PROTECT, related_name="organizations", verbose_name=_("Billing entity"), + null=True, # TODO: billing entity should be required ) origin = models.ForeignKey( to="OrganizationOrigin", From 1b69310c778584c75956dbb90a3bb9d97b2a9607 Mon Sep 17 00:00:00 2001 From: Tobias Kunze <r@rixx.de> Date: Thu, 20 Mar 2025 09:19:10 +0100 Subject: [PATCH 08/10] Fix admin form rendering --- src/servala/frontend/forms/renderers.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/servala/frontend/forms/renderers.py b/src/servala/frontend/forms/renderers.py index 90ce926..24e412b 100644 --- a/src/servala/frontend/forms/renderers.py +++ b/src/servala/frontend/forms/renderers.py @@ -1,4 +1,5 @@ from django.forms.renderers import TemplatesSetting +from django.forms.widgets import Textarea def inject_class(f, class_name): @@ -21,11 +22,22 @@ class VerticalFormRenderer(TemplatesSetting): return "form-check-input" return "form-control" + def get_widget_input_type(self, widget): + if isinstance(widget, Textarea): + return "textarea" + return widget.input_type + + def get_field_input_type(self, field): + widget = field.field.widget + if inner_widget := getattr(widget, "widget", None): + widget = inner_widget + return self.get_widget_input_type(widget) + def render(self, template_name, context, request=None): if field := context.get("field"): + input_type = self.get_field_input_type(field) field.build_widget_attrs = inject_class( - field.build_widget_attrs, - self.get_class_names(field.field.widget.input_type), + field.build_widget_attrs, self.get_class_names(input_type) ) return super().render(template_name, context, request) From 7f389434a43bb2bce4161d8a028636c45a59dc86 Mon Sep 17 00:00:00 2001 From: Tobias Kunze <r@rixx.de> Date: Thu, 20 Mar 2025 09:46:59 +0100 Subject: [PATCH 09/10] Add organization dashboard and redirects --- src/servala/core/models/organization.py | 12 +++++++ .../dashboard.html} | 0 .../frontend/templates/includes/sidebar.html | 31 ++++++++++++------- src/servala/frontend/urls.py | 17 ++++++++-- src/servala/frontend/views/__init__.py | 3 +- src/servala/frontend/views/organization.py | 6 +++- 6 files changed, 54 insertions(+), 15 deletions(-) rename src/servala/frontend/templates/frontend/{index.html => organizations/dashboard.html} (100%) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 38bc699..8a1c552 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,5 +1,8 @@ from django.conf import settings from django.db import models +from django.urls import reverse +from django.utils.functional import cached_property +from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from .mixins import ServalaModelMixin @@ -29,6 +32,15 @@ class Organization(ServalaModelMixin, models.Model): verbose_name=_("Members"), ) + @cached_property + def slug(self): + return f"{slugify(self.name)}-{self.id}" + + def get_absolute_url(self): + return reverse( + "frontend:organization.dashboard", kwargs={"organization": self.slug} + ) + def set_owner(self, user): OrganizationMembership.objects.filter(user=user, organization=self).delete() OrganizationMembership.objects.create( diff --git a/src/servala/frontend/templates/frontend/index.html b/src/servala/frontend/templates/frontend/organizations/dashboard.html similarity index 100% rename from src/servala/frontend/templates/frontend/index.html rename to src/servala/frontend/templates/frontend/organizations/dashboard.html diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index 8c47293..1380052 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -5,7 +5,7 @@ <div class="sidebar-header position-relative"> <div class="d-flex justify-content-between align-items-center"> <div class="logo"> - <a href="index.html"> + <a href="{% if current_organization %}{{ current_organization.get_absolute_url }}{% else %}/{% endif %}"> <img src="" alt="{% translate 'Logo' %}" srcset=""> </a> </div> @@ -66,7 +66,7 @@ {% else %} {# request.user.is_authenticated #} <li class="sidebar-item"> - {% if user_organizations.count > 1 %} + {% if user_organizations.count %} <button class="btn btn-primary dropdown-toggle me-1" type="button" id="organizationDropdown" @@ -81,11 +81,17 @@ </button> <div class="dropdown-menu" aria-labelledby="organizationDropdown"> {% for organization in user_organizations %} - <a class="dropdown-item" href="#TODO">{{ organization.name }}</a> + <a class="dropdown-item{% if organization == current_organization %} active{% endif %}" + href="{{ organization.get_absolute_url }}"> + <i class="bi bi-building-fill me-1"></i> + {{ organization.name }} + </a> {% endfor %} + <a href="{% url 'frontend:organization.create' %}" class="dropdown-item"> + <i class="bi bi-building-add me-1"></i> + <span>{% translate "Create organization" %}</span> + </a> </div> - {% elif current_organization %} - {% translate "Organization" %}: {{ current_organization.name }} {% else %} <a href="{% url 'frontend:organization.create' %}" class="sidebar-link"> <i class="bi bi-plus-square"></i> @@ -93,12 +99,15 @@ </a> {% endif %} </li> - <li class="sidebar-item"> - <a href="index.html" class='sidebar-link'> - <i class="bi bi-grid-fill"></i> - <span>{% translate 'Dashboard' %}</span> - </a> - </li> + {% if current_organization %} + <li class="sidebar-item"> + <a href="{{ current_organization.get_absolute_url }}" + class='sidebar-link'> + <i class="bi bi-grid-fill"></i> + <span>{% translate 'Dashboard' %}</span> + </a> + </li> + {% endif %} <li class="sidebar-title">{% translate 'Account' %}</li> <li class="sidebar-item"> <a href="{% url 'frontend:profile' %}" class='sidebar-link'> diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index fbaaa08..e947a7b 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -1,4 +1,5 @@ -from django.urls import path +from django.urls import include, path +from django.views.generic import RedirectView from servala.frontend import views @@ -10,5 +11,17 @@ urlpatterns = [ views.OrganizationCreateView.as_view(), name="organization.create", ), - path("", views.IndexView.as_view(), name="index"), + path( + "<slug:organization>/", + include( + [ + path( + "", + views.OrganizationDashboardView.as_view(), + name="organization.dashboard", + ), + ] + ), + ), + path("", RedirectView.as_view(pattern_name="frontend:profile"), name="index"), ] diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 20b4398..8dc1ee8 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -1,10 +1,11 @@ from .auth import LogoutView from .generic import IndexView, ProfileView -from .organization import OrganizationCreateView +from .organization import OrganizationCreateView, OrganizationDashboardView __all__ = [ "IndexView", "LogoutView", "OrganizationCreateView", + "OrganizationDashboardView", "ProfileView", ] diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 020d28b..0091df1 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,4 +1,4 @@ -from django.views.generic import FormView +from django.views.generic import FormView, TemplateView from servala.frontend.forms import OrganizationCreateForm @@ -13,3 +13,7 @@ class OrganizationCreateView(FormView): def get_success_url(self): return "/" + + +class OrganizationDashboardView(TemplateView): + template_name = "frontend/organizations/dashboard.html" From 2dcc5650a99a4400d9bbbe68e52bdab532e2b816 Mon Sep 17 00:00:00 2001 From: Tobias Kunze <r@rixx.de> Date: Thu, 20 Mar 2025 10:04:48 +0100 Subject: [PATCH 10/10] Add organization context to every request --- src/servala/core/middleware.py | 22 +++++++++++++++++++ src/servala/frontend/context_processors.py | 2 +- .../frontend/templates/includes/sidebar.html | 18 ++++++++------- src/servala/frontend/views/organization.py | 10 ++++----- src/servala/settings.py | 1 + 5 files changed, 39 insertions(+), 14 deletions(-) create mode 100644 src/servala/core/middleware.py diff --git a/src/servala/core/middleware.py b/src/servala/core/middleware.py new file mode 100644 index 0000000..07ebe4a --- /dev/null +++ b/src/servala/core/middleware.py @@ -0,0 +1,22 @@ +from django.shortcuts import get_object_or_404 +from django.urls import resolve + +from servala.core.models import Organization + + +class OrganizationMiddleware: + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + url = resolve(request.path_info) + + organization_slug = url.kwargs.get("organization") + if organization_slug: + pk = organization_slug.rsplit("-", maxsplit=1)[-1] + request.organization = get_object_or_404(Organization, pk=pk) + else: + request.organization = None + + return self.get_response(request) diff --git a/src/servala/frontend/context_processors.py b/src/servala/frontend/context_processors.py index 1a180d8..1ff2a14 100644 --- a/src/servala/frontend/context_processors.py +++ b/src/servala/frontend/context_processors.py @@ -2,4 +2,4 @@ def add_organizations(request): if not request.user.is_authenticated: return {"user_organizations": []} - return {"user_organizations": request.user.organizations.all()} + return {"user_organizations": request.user.organizations.all().order_by("name")} diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index 1380052..ae08b51 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -5,7 +5,7 @@ <div class="sidebar-header position-relative"> <div class="d-flex justify-content-between align-items-center"> <div class="logo"> - <a href="{% if current_organization %}{{ current_organization.get_absolute_url }}{% else %}/{% endif %}"> + <a href="{% if request.organization %}{{ request.organization.get_absolute_url }}{% else %}/{% endif %}"> <img src="" alt="{% translate 'Logo' %}" srcset=""> </a> </div> @@ -67,21 +67,23 @@ {# request.user.is_authenticated #} <li class="sidebar-item"> {% if user_organizations.count %} - <button class="btn btn-primary dropdown-toggle me-1" + <button class="btn btn-outline-primary dropdown-toggle w-100" type="button" id="organizationDropdown" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> - {% if current_organization %} - {{ current_organization.name }} + {% if request.organization %} + {{ request.organization.name }} {% else %} {% translate "Organizations" %} {% endif %} </button> - <div class="dropdown-menu" aria-labelledby="organizationDropdown"> + <div class="dropdown-menu shadow" + aria-labelledby="organizationDropdown" + id="organization-dropdown"> {% for organization in user_organizations %} - <a class="dropdown-item{% if organization == current_organization %} active{% endif %}" + <a class="dropdown-item{% if organization == request.organization %} active{% endif %}" href="{{ organization.get_absolute_url }}"> <i class="bi bi-building-fill me-1"></i> {{ organization.name }} @@ -99,9 +101,9 @@ </a> {% endif %} </li> - {% if current_organization %} + {% if request.organization %} <li class="sidebar-item"> - <a href="{{ current_organization.get_absolute_url }}" + <a href="{{ request.organization.get_absolute_url }}" class='sidebar-link'> <i class="bi bi-grid-fill"></i> <span>{% translate 'Dashboard' %}</span> diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 0091df1..9ef4e37 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,3 +1,4 @@ +from django.shortcuts import redirect from django.views.generic import FormView, TemplateView from servala.frontend.forms import OrganizationCreateForm @@ -8,11 +9,10 @@ class OrganizationCreateView(FormView): template_name = "frontend/organizations/create.html" def form_valid(self, form): - form.instance.create_organization(form.instance, owner=self.request.user) - return super().form_valid(form) - - def get_success_url(self): - return "/" + instance = form.instance.create_organization( + form.instance, owner=self.request.user + ) + return redirect(instance.get_absolute_url()) class OrganizationDashboardView(TemplateView): diff --git a/src/servala/settings.py b/src/servala/settings.py index 9260988..7bc2f9f 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -116,6 +116,7 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", "allauth.account.middleware.AccountMiddleware", "django.contrib.auth.middleware.LoginRequiredMiddleware", + "servala.core.middleware.OrganizationMiddleware", ] LOGIN_URL = "account_login"