diff --git a/pyproject.toml b/pyproject.toml index dc46064..aa12d13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "psycopg2-binary>=2.9.10", "pyjwt>=2.10.1", "requests>=2.32.3", + "rules>=3.5", + "urlman>=2.0.2", ] [dependency-groups] diff --git a/src/servala/core/middleware.py b/src/servala/core/middleware.py index 3046ff0..dd8f54c 100644 --- a/src/servala/core/middleware.py +++ b/src/servala/core/middleware.py @@ -1,6 +1,6 @@ from django.shortcuts import get_object_or_404 from django.urls import resolve -from django_scopes import scope +from django_scopes import scope, scopes_disabled from servala.core.models import Organization @@ -13,6 +13,10 @@ class OrganizationMiddleware: def __call__(self, request): url = resolve(request.path_info) + if "admin" in url.namespaces: + with scopes_disabled(): + return self.get_response(request) + organization_slug = url.kwargs.get("organization") if organization_slug: pk = organization_slug.rsplit("-", maxsplit=1)[-1] 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 4c0cdac..d59057b 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,12 +1,14 @@ +import rules +import urlman 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 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): @@ -33,14 +35,16 @@ class Organization(ServalaModelMixin, models.Model): verbose_name=_("Members"), ) + class urls(urlman.Urls): + base = "/org/{self.slug}/" + details = "{base}details/" + @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} - ) + return self.urls.base def set_owner(self, user): OrganizationMembership.objects.filter(user=user, organization=self).delete() @@ -63,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/frontend/forms/__init__.py b/src/servala/frontend/forms/__init__.py index fe0ffc1..01447a1 100644 --- a/src/servala/frontend/forms/__init__.py +++ b/src/servala/frontend/forms/__init__.py @@ -1,4 +1,4 @@ -from .organization import OrganizationCreateForm +from .organization import OrganizationForm from .profile import UserProfileForm -__all__ = ["OrganizationCreateForm", "UserProfileForm"] +__all__ = ["OrganizationForm", "UserProfileForm"] diff --git a/src/servala/frontend/forms/mixins.py b/src/servala/frontend/forms/mixins.py index 4ca1796..7fe6663 100644 --- a/src/servala/frontend/forms/mixins.py +++ b/src/servala/frontend/forms/mixins.py @@ -1,3 +1,5 @@ +from django.utils.functional import cached_property + from servala.frontend.forms.renderers import InlineFormRenderer @@ -7,8 +9,6 @@ class HtmxMixin: Useful when sending single fields with htmx. """ - default_renderer = InlineFormRenderer - def __init__(self, *args, **kwargs): self.single_field = kwargs.pop("single_field", None) @@ -18,3 +18,8 @@ class HtmxMixin: field = self.fields[self.single_field] self.fields.clear() self.fields[self.single_field] = field + + @cached_property + def default_renderer(self): + if self.single_field: + return InlineFormRenderer diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index b6a3391..41cc26c 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,9 +1,10 @@ from django.forms import ModelForm from servala.core.models import Organization +from servala.frontend.forms.mixins import HtmxMixin -class OrganizationCreateForm(ModelForm): +class OrganizationForm(HtmxMixin, ModelForm): class Meta: model = Organization fields = ("name",) diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html new file mode 100644 index 0000000..01c919a --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -0,0 +1,52 @@ +{% extends "frontend/base.html" %} +{% load i18n static %} +{% load partials %} +{% block html_title %} + {% block page_title %} + {% translate "Organization Details" %} + {% endblock page_title %} +{% endblock html_title %} +{% partialdef org-name %} + + {{ form.instance.name }} + {% if has_change_permission %} + + {% endif %} + +{% endpartialdef org-name %} +{% partialdef org-name-edit %} + +
+
+ {{ form.name.as_field_group }} + + + + +
+
+ +{% endpartialdef org-name-edit %} +{% block card_content %} +
+ + + + + {% partial org-name %} + + +
+ {% translate "Name" %} +
+
+{% endblock card_content %} diff --git a/src/servala/frontend/templates/frontend/profile.html b/src/servala/frontend/templates/frontend/profile.html index cfad0e9..2d6433a 100644 --- a/src/servala/frontend/templates/frontend/profile.html +++ b/src/servala/frontend/templates/frontend/profile.html @@ -15,7 +15,7 @@ {{ request.user.email }} @@ -24,7 +24,7 @@ {{ request.user.company|default:"–" }} diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index ae08b51..13d9d5c 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -1,11 +1,11 @@ -{% load i18n %} +{% load i18n static %} + diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index e947a7b..59a7ccc 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -12,9 +12,14 @@ urlpatterns = [ name="organization.create", ), path( - "/", + "org//", include( [ + path( + "details/", + views.OrganizationUpdateView.as_view(), + name="organization.details", + ), path( "", views.OrganizationDashboardView.as_view(), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 8dc1ee8..4ff574a 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -1,11 +1,16 @@ from .auth import LogoutView from .generic import IndexView, ProfileView -from .organization import OrganizationCreateView, OrganizationDashboardView +from .organization import ( + OrganizationCreateView, + OrganizationDashboardView, + OrganizationUpdateView, +) __all__ = [ "IndexView", "LogoutView", "OrganizationCreateView", "OrganizationDashboardView", + "OrganizationUpdateView", "ProfileView", ] diff --git a/src/servala/frontend/views/generic.py b/src/servala/frontend/views/generic.py index 12300fd..a315851 100644 --- a/src/servala/frontend/views/generic.py +++ b/src/servala/frontend/views/generic.py @@ -1,24 +1,27 @@ 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") 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 diff --git a/src/servala/frontend/views/mixins.py b/src/servala/frontend/views/mixins.py index 3184e86..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") @@ -25,8 +45,11 @@ class HtmxMixin(TemplateView): 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 + if self.is_htmx: + data = ( + self.request.POST if self.request.method == "POST" else self.request.GET + ) + result["single_field"] = data.get("hx-single-field") return result def form_valid(self, form): diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 9ef4e37..c91a251 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,19 +1,51 @@ from django.shortcuts import redirect -from django.views.generic import FormView, TemplateView +from django.utils.functional import cached_property +from django.views.generic import CreateView, DetailView +from rules.contrib.views import AutoPermissionRequiredMixin -from servala.frontend.forms import OrganizationCreateForm +from servala.core.models import Organization +from servala.frontend.forms import OrganizationForm +from servala.frontend.views.mixins import HtmxUpdateView -class OrganizationCreateView(FormView): - form_class = OrganizationCreateForm +class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): + form_class = OrganizationForm + model = Organization template_name = "frontend/organizations/create.html" def form_valid(self, form): instance = form.instance.create_organization( form.instance, owner=self.request.user ) - return redirect(instance.get_absolute_url()) + return redirect(instance.urls.base) -class OrganizationDashboardView(TemplateView): +class OrganizationViewMixin: + model = Organization + context_object_name = "organization" + + @cached_property + def organization(self): + return self.request.organization + + def get_object(self): + return self.organization + + @cached_property + def object(self): + return self.get_object() + + +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 diff --git a/src/servala/settings.py b/src/servala/settings.py index 7bc2f9f..ef9932d 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -97,6 +97,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "django.forms", "template_partials", + "rules.apps.AutodiscoverRulesConfig", # The frontend app is loaded early in order to supersede some allauth views/behaviour "servala.frontend", "allauth", @@ -170,6 +171,7 @@ ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"] ACCOUNT_SIGNUP_FORM_CLASS = "servala.frontend.forms.auth.ServalaSignupForm" AUTHENTICATION_BACKENDS = [ + "rules.permissions.ObjectPermissionBackend", # Needed to login by username in Django admin, regardless of `allauth` "django.contrib.auth.backends.ModelBackend", "allauth.account.auth_backends.AuthenticationBackend", 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'); + } + } + }) +}) diff --git a/uv.lock b/uv.lock index 7231712..6d6a585 100644 --- a/uv.lock +++ b/uv.lock @@ -661,6 +661,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "rules" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/36/918cf4cc9fd0e38bb9310b2d1a13ae6ebb2b5732d56e7de6feb4a992a6ed/rules-3.5.tar.gz", hash = "sha256:f01336218f4561bab95f53672d22418b4168baea271423d50d9e8490d64cb27a", size = 55504 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/33/16213dd62ca8ce8749985318a966ac1300ab55c977b2d66632a45b405c99/rules-3.5-py2.py3-none-any.whl", hash = "sha256:0f00fc9ee448b3f82e9aff9334ab0c56c76dce4dfa14f1598f57969f1022acc0", size = 25658 }, +] + [[package]] name = "servala" version = "0.0.0" @@ -676,6 +685,8 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "pyjwt" }, { name = "requests" }, + { name = "rules" }, + { name = "urlman" }, ] [package.dev-dependencies] @@ -702,6 +713,8 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "pyjwt", specifier = ">=2.10.1" }, { name = "requests", specifier = ">=2.32.3" }, + { name = "rules", specifier = ">=3.5" }, + { name = "urlman", specifier = ">=2.0.2" }, ] [package.metadata.requires-dev] @@ -763,3 +776,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf wheels = [ { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] + +[[package]] +name = "urlman" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/c3/cc163cadf40a03d23d522d050ffa147c0589ccd7992a2cc4dd2b02aa9886/urlman-2.0.2.tar.gz", hash = "sha256:231afe89d0d0db358fe7a2626eb39310e5bf5911f3796318955cbe77e1b39601", size = 7684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/0c/e8a418c9bc9349e7869e88a5b439cf39c4f6f8942da858000944c94a8f01/urlman-2.0.2-py2.py3-none-any.whl", hash = "sha256:2505bf310be424ffa6f4965a6f643ce32dc6194f61a3c5989f2f56453c614814", size = 8028 }, +]