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 @@ +
{% translate "E-mail" %} | -{{ request.user.email }} | +{% translate "Name" %} | +{{ request.user.first_name }} {{ request.user.last_name }} |
---|---|---|---|
{% translate "First name" %} | -{{ request.user.first_name }} | ++ {% translate "E-mail" %} + | + {% partial user-email %}|
{% translate "Last name" %} | -{{ request.user.last_name }} | -||
{% translate "Company" %} | -{{ request.user.company }} | ++ {% translate "Company" %} + | + {% partial user-company %}