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/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..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 @@ -13,6 +16,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", @@ -28,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/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/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/mixins.py b/src/servala/frontend/forms/mixins.py new file mode 100644 index 0000000..4ca1796 --- /dev/null +++ b/src/servala/frontend/forms/mixins.py @@ -0,0 +1,20 @@ +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) + + 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/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/forms/renderers.py b/src/servala/frontend/forms/renderers.py index f8cdb5d..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): @@ -16,13 +17,31 @@ 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 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"): - if field.field.widget.input_type == "checkbox": - class_name = "form-check-input" - else: - class_name = "form-control" + input_type = self.get_field_input_type(field) field.build_widget_attrs = inject_class( - field.build_widget_attrs, class_name + field.build_widget_attrs, self.get_class_names(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..fe56619 --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/field.html @@ -0,0 +1,21 @@ +{% load i18n %} +<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 %} + {{ 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..ab8edba --- /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" hide_label=True %} 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/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/frontend/profile.html b/src/servala/frontend/templates/frontend/profile.html index 9532e96..cfad0e9 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,62 @@ <h4 class="card-title">{% translate "Account" %}</h4> </div> {% endblock %} +{% partialdef user-email %} +<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 %} +<td> + {{ request.user.company|default:"–" }} + <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 %} +<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 mx-1">{% translate "Save" %}</button> + <button type="button" + class="btn btn-secondary" + hx-get="{{ request.path }}?fragment=user-email" + hx-target="closest td" + hx-swap="outerHTML">{% translate "Cancel" %}</button> + </div> + </form> +</td> +{% endpartialdef %} +{% partialdef user-company-edit %} +<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 mx-1 btn-primary">{% translate "Save" %}</button> + <button type="button" + class="btn btn-secondary" + hx-get="{{ request.path }}?fragment=user-company" + hx-target="closest td" + hx-swap="outerHTML">{% translate "Cancel" %}</button> + </div> + </form> +</td> +{% endpartialdef %} {% block content %} <section> <div class="row match-height"> @@ -24,20 +81,20 @@ <table class="table table-lg"> <tbody> <tr> - <th>{% translate "E-mail" %}</th> - <td>{{ request.user.email }}</td> + <th class="w-25">{% translate "Name" %}</th> + <td>{{ request.user.first_name }} {{ request.user.last_name }}</td> </tr> <tr> - <th>{% translate "First name" %}</th> - <td>{{ request.user.first_name }}</td> + <th class="w-25"> + <span class="d-flex mt-2">{% translate "E-mail" %}</span> + </th> + {% partial user-email %} </tr> <tr> - <th>{% translate "Last name" %}</th> - <td>{{ request.user.last_name }}</td> - </tr> - <tr> - <th>{% translate "Company" %}</th> - <td>{{ request.user.company }}</td> + <th class="w-25"> + <span class="d-flex mt-2">{% translate "Company" %}</span> + </th> + {% partial user-company %} </tr> </tbody> </table> 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> diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index 8c47293..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="index.html"> + <a href="{% if request.organization %}{{ request.organization.get_absolute_url }}{% else %}/{% endif %}"> <img src="" alt="{% translate 'Logo' %}" srcset=""> </a> </div> @@ -66,26 +66,34 @@ {% else %} {# request.user.is_authenticated #} <li class="sidebar-item"> - {% if user_organizations.count > 1 %} - <button class="btn btn-primary dropdown-toggle me-1" + {% if user_organizations.count %} + <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" href="#TODO">{{ organization.name }}</a> + <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 }} + </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 +101,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 request.organization %} + <li class="sidebar-item"> + <a href="{{ request.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/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) 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 diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 020d28b..9ef4e37 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,4 +1,5 @@ -from django.views.generic import FormView +from django.shortcuts import redirect +from django.views.generic import FormView, TemplateView from servala.frontend.forms import OrganizationCreateForm @@ -8,8 +9,11 @@ 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) + instance = form.instance.create_organization( + form.instance, owner=self.request.user + ) + return redirect(instance.get_absolute_url()) - def get_success_url(self): - return "/" + +class OrganizationDashboardView(TemplateView): + template_name = "frontend/organizations/dashboard.html" 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" 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; +}