From 1d4162582a2f8e30f6324eb4770b3b332e69ba08 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Mar 2025 10:51:56 +0100 Subject: [PATCH 01/12] Add auto-active links to sidebar --- .../frontend/templates/includes/sidebar.html | 3 ++- src/servala/static/js/sidebar.js | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/servala/static/js/sidebar.js diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index ae08b51..4b7bc77 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -1,4 +1,4 @@ -{% load i18n %} +{% load i18n static %} + diff --git a/src/servala/static/js/sidebar.js b/src/servala/static/js/sidebar.js new file mode 100644 index 0000000..1f79795 --- /dev/null +++ b/src/servala/static/js/sidebar.js @@ -0,0 +1,22 @@ +/** + * This script marks the current path as active in the sidebar. + */ + +document.addEventListener('DOMContentLoaded', () => { + const currentPath = window.location.pathname; + const sidebarLinks = document.querySelectorAll('.sidebar-link'); + + sidebarLinks.forEach(link => { + // Skip links that are inside buttons (like logout) + if (link.tagName === 'BUTTON') return; + + if (link.getAttribute('href') === currentPath) { + const parentItem = link.closest('.sidebar-item'); + if (parentItem) { + parentItem.classList.add('active'); + } else { + link.classList.add('active'); + } + } + }) +}) From 8be1c86deb52e56cb02a1a5d632f75516e775850 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Mar 2025 10:57:17 +0100 Subject: [PATCH 02/12] Move org account switcher to sidebar header --- .../frontend/templates/includes/sidebar.html | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index 4b7bc77..45da541 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -53,6 +53,43 @@ + {% if request.user.is_authenticated %} + {# request.user.is_authenticated #} + {% if user_organizations.count %} + + + {% else %} + + + {% translate "Create organization" %} + + {% endif %} + {% endif %} {% else %} - + {% translate "Create organization" %} From 21ff6fe7d05821b71373ce5eaca77602262a9cd1 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Mar 2025 16:50:56 +0100 Subject: [PATCH 09/12] Implement organization rules --- src/servala/core/models/mixins.py | 3 ++- src/servala/core/models/organization.py | 10 ++++++- src/servala/core/models/user.py | 36 +++++-------------------- src/servala/core/rules.py | 23 ++++++++++++++++ src/servala/settings.py | 2 +- 5 files changed, 41 insertions(+), 33 deletions(-) create mode 100644 src/servala/core/rules.py diff --git a/src/servala/core/models/mixins.py b/src/servala/core/models/mixins.py index ac8c3b0..5a2ed94 100644 --- a/src/servala/core/models/mixins.py +++ b/src/servala/core/models/mixins.py @@ -1,8 +1,9 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModelBase, RulesModelMixin -class ServalaModelMixin(models.Model): +class ServalaModelMixin(RulesModelMixin, models.Model, metaclass=RulesModelBase): created_at = models.DateTimeField( auto_now_add=True, editable=False, verbose_name=_("Created") ) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index bcba9a2..d59057b 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,3 +1,4 @@ +import rules import urlman from django.conf import settings from django.db import models @@ -6,7 +7,8 @@ from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django_scopes import ScopedManager -from .mixins import ServalaModelMixin +from servala.core import rules as perms +from servala.core.models.mixins import ServalaModelMixin class Organization(ServalaModelMixin, models.Model): @@ -65,6 +67,12 @@ class Organization(ServalaModelMixin, models.Model): class Meta: verbose_name = _("Organization") verbose_name_plural = _("Organizations") + rules_permissions = { + "view": rules.is_staff | perms.is_organization_member, + "change": rules.is_staff | perms.is_organization_admin, + "delete": rules.is_staff | perms.is_organization_owner, + "add": rules.is_authenticated, + } def __str__(self): return self.name diff --git a/src/servala/core/models/user.py b/src/servala/core/models/user.py index 12545a8..8fa3b88 100644 --- a/src/servala/core/models/user.py +++ b/src/servala/core/models/user.py @@ -1,4 +1,8 @@ -from django.contrib.auth.models import AbstractBaseUser, BaseUserManager +from django.contrib.auth.models import ( + AbstractBaseUser, + BaseUserManager, + PermissionsMixin, +) from django.db import models from django.utils.translation import gettext_lazy as _ @@ -32,7 +36,7 @@ class UserManager(BaseUserManager): return self.create_user(email, password, **extra_fields) -class User(ServalaModelMixin, AbstractBaseUser): +class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser): """The Django model provides a password and last_login field.""" objects = UserManager() @@ -71,31 +75,3 @@ class User(ServalaModelMixin, AbstractBaseUser): def normalize_username(self, username): return super().normalize_username(username).strip().lower() - - def has_perm(self, perm, obj=None): - """ - Return True if the user has the specified permission. - Superusers automatically have all permissions. - """ - return self.is_superuser - - def has_module_perms(self, app_label): - """ - Return True if the user has any permissions in the given app label. - Superusers automatically have all permissions. - """ - return self.is_superuser - - def get_all_permissions(self, obj=None): - """ - Return a set of permission strings that the user has. - Superusers have all permissions. - """ - if self.is_superuser: - from django.contrib.auth.models import Permission - - return { - f"{perm.content_type.app_label}.{perm.codename}" - for perm in Permission.objects.all() - } - return set() diff --git a/src/servala/core/rules.py b/src/servala/core/rules.py new file mode 100644 index 0000000..2d51145 --- /dev/null +++ b/src/servala/core/rules.py @@ -0,0 +1,23 @@ +import rules + + +def has_organization_role(user, org, roles): + memberships = org.memberships.all().filter(user=user) + if roles: + memberships = memberships.filter(role__in=roles) + return memberships.exists() + + +@rules.predicate +def is_organization_owner(user, org): + return has_organization_role(user, org, ["owner"]) + + +@rules.predicate +def is_organization_admin(user, org): + return has_organization_role(user, org, ["owner", "admin"]) + + +@rules.predicate +def is_organization_member(user, org): + return has_organization_role(user, org, None) diff --git a/src/servala/settings.py b/src/servala/settings.py index c1682a7..ef9932d 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -97,7 +97,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "django.forms", "template_partials", - "rules", + "rules.apps.AutodiscoverRulesConfig", # The frontend app is loaded early in order to supersede some allauth views/behaviour "servala.frontend", "allauth", From 31003c5c76967efd90c76dc56968a47ba253cd65 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Mar 2025 16:57:15 +0100 Subject: [PATCH 10/12] Use rules permissions in views and HTMX forms --- .../frontend/organizations/update.html | 2 + src/servala/frontend/views/generic.py | 6 +-- src/servala/frontend/views/mixins.py | 24 ++++++++++- src/servala/frontend/views/organization.py | 41 +++++++++++-------- 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 234622a..0a0fb8b 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -9,10 +9,12 @@ {% partialdef org-name %} {{ form.instance.name }} + {% if has_change_permission %} + {% endif %} {% endpartialdef org-name %} {% partialdef org-name-edit %} diff --git a/src/servala/frontend/views/generic.py b/src/servala/frontend/views/generic.py index 12300fd..a6846e7 100644 --- a/src/servala/frontend/views/generic.py +++ b/src/servala/frontend/views/generic.py @@ -1,18 +1,18 @@ from django.conf import settings from django.urls import reverse_lazy from django.utils.functional import cached_property -from django.views.generic import TemplateView, UpdateView +from django.views.generic import TemplateView from servala.core.models import User from servala.frontend.forms.profile import UserProfileForm -from servala.frontend.views.mixins import HtmxMixin +from servala.frontend.views.mixins import HtmxUpdateView class IndexView(TemplateView): template_name = "frontend/index.html" -class ProfileView(HtmxMixin, UpdateView): +class ProfileView(HtmxUpdateView): template_name = "frontend/profile.html" form_class = UserProfileForm success_url = reverse_lazy("frontend:profile") diff --git a/src/servala/frontend/views/mixins.py b/src/servala/frontend/views/mixins.py index 6ab8f0f..055366f 100644 --- a/src/servala/frontend/views/mixins.py +++ b/src/servala/frontend/views/mixins.py @@ -1,14 +1,34 @@ from django.utils.functional import cached_property -from django.views.generic import TemplateView +from django.views.generic import UpdateView +from rules.contrib.views import AutoPermissionRequiredMixin -class HtmxMixin(TemplateView): +class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView): fragments = [] @cached_property def is_htmx(self): return self.request.headers.get("HX-Request") + @property + def permission_type(self): + if self.request.method == "POST" or getattr( + self, "_test_write_permission", False + ): + return "change" + return "view" + + def has_change_permission(self): + self._test_write_permission = True + permission = self.get_permission_required()[0] + self._test_write_permission = False + return self.request.user.has_perm(permission, self.get_permission_object()) + + def get_context_data(self, **kwargs): + result = super().get_context_data(**kwargs) + result["has_change_permission"] = self.has_change_permission() + return result + def _get_fragment(self): if self.request.method == "POST": fragment = self.request.POST.get("fragment") diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 91fcb2a..c91a251 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,14 +1,16 @@ from django.shortcuts import redirect from django.utils.functional import cached_property -from django.views.generic import FormView, TemplateView, UpdateView +from django.views.generic import CreateView, DetailView +from rules.contrib.views import AutoPermissionRequiredMixin from servala.core.models import Organization from servala.frontend.forms import OrganizationForm -from servala.frontend.views.mixins import HtmxMixin +from servala.frontend.views.mixins import HtmxUpdateView -class OrganizationCreateView(FormView): +class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): form_class = OrganizationForm + model = Organization template_name = "frontend/organizations/create.html" def form_valid(self, form): @@ -18,27 +20,32 @@ class OrganizationCreateView(FormView): return redirect(instance.urls.base) -class OrganizationDashboardView(TemplateView): - template_name = "frontend/organizations/dashboard.html" - - -class OrganizationUpdateView(HtmxMixin, UpdateView): - template_name = "frontend/organizations/update.html" - form_class = OrganizationForm - fragments = ("org-name", "org-name-edit") +class OrganizationViewMixin: model = Organization context_object_name = "organization" - def get_success_url(self): - return self.request.path + @cached_property + def organization(self): + return self.request.organization def get_object(self): - return self.request.organization + return self.organization @cached_property def object(self): return self.get_object() - def form_valid(self, form): - form.save() - return super().form_valid(form) + +class OrganizationDashboardView( + AutoPermissionRequiredMixin, OrganizationViewMixin, DetailView +): + template_name = "frontend/organizations/dashboard.html" + + +class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): + template_name = "frontend/organizations/update.html" + form_class = OrganizationForm + fragments = ("org-name", "org-name-edit") + + def get_success_url(self): + return self.request.path From 832d85b7fcf93e8d410828ae49937677b507d765 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Mar 2025 16:57:52 +0100 Subject: [PATCH 11/12] Add sidebar icon --- src/servala/frontend/templates/includes/sidebar.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index 924c464..13d9d5c 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -112,7 +112,7 @@ From d10ba90b4dea3ac8f21b0014c88b97c529bb0726 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 20 Mar 2025 17:01:47 +0100 Subject: [PATCH 12/12] Profile view permissions --- .../frontend/templates/frontend/organizations/update.html | 8 ++++---- src/servala/frontend/views/generic.py | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 0a0fb8b..01c919a 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -10,10 +10,10 @@ {{ form.instance.name }} {% if has_change_permission %} - + {% endif %} {% endpartialdef org-name %} diff --git a/src/servala/frontend/views/generic.py b/src/servala/frontend/views/generic.py index a6846e7..a315851 100644 --- a/src/servala/frontend/views/generic.py +++ b/src/servala/frontend/views/generic.py @@ -19,6 +19,9 @@ class ProfileView(HtmxUpdateView): fragments = ("user-email", "user-email-edit", "user-company", "user-company-edit") model = User + def has_permission(self): + return True + def get_object(self): return self.request.user