diff --git a/pyproject.toml b/pyproject.toml index aa12d13..dc46064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,6 @@ 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 dd8f54c..3046ff0 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, scopes_disabled +from django_scopes import scope from servala.core.models import Organization @@ -13,10 +13,6 @@ 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 5a2ed94..ac8c3b0 100644 --- a/src/servala/core/models/mixins.py +++ b/src/servala/core/models/mixins.py @@ -1,9 +1,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from rules.contrib.models import RulesModelBase, RulesModelMixin -class ServalaModelMixin(RulesModelMixin, models.Model, metaclass=RulesModelBase): +class ServalaModelMixin(models.Model): 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 d59057b..4c0cdac 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,14 +1,12 @@ -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 servala.core import rules as perms -from servala.core.models.mixins import ServalaModelMixin +from .mixins import ServalaModelMixin class Organization(ServalaModelMixin, models.Model): @@ -35,16 +33,14 @@ 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 self.urls.base + return reverse( + "frontend:organization.dashboard", kwargs={"organization": self.slug} + ) def set_owner(self, user): OrganizationMembership.objects.filter(user=user, organization=self).delete() @@ -67,12 +63,6 @@ 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 8fa3b88..12545a8 100644 --- a/src/servala/core/models/user.py +++ b/src/servala/core/models/user.py @@ -1,8 +1,4 @@ -from django.contrib.auth.models import ( - AbstractBaseUser, - BaseUserManager, - PermissionsMixin, -) +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager from django.db import models from django.utils.translation import gettext_lazy as _ @@ -36,7 +32,7 @@ class UserManager(BaseUserManager): return self.create_user(email, password, **extra_fields) -class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser): +class User(ServalaModelMixin, AbstractBaseUser): """The Django model provides a password and last_login field.""" objects = UserManager() @@ -75,3 +71,31 @@ class User(ServalaModelMixin, PermissionsMixin, 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 deleted file mode 100644 index 2d51145..0000000 --- a/src/servala/core/rules.py +++ /dev/null @@ -1,23 +0,0 @@ -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 01447a1..fe0ffc1 100644 --- a/src/servala/frontend/forms/__init__.py +++ b/src/servala/frontend/forms/__init__.py @@ -1,4 +1,4 @@ -from .organization import OrganizationForm +from .organization import OrganizationCreateForm from .profile import UserProfileForm -__all__ = ["OrganizationForm", "UserProfileForm"] +__all__ = ["OrganizationCreateForm", "UserProfileForm"] diff --git a/src/servala/frontend/forms/mixins.py b/src/servala/frontend/forms/mixins.py index 7fe6663..4ca1796 100644 --- a/src/servala/frontend/forms/mixins.py +++ b/src/servala/frontend/forms/mixins.py @@ -1,5 +1,3 @@ -from django.utils.functional import cached_property - from servala.frontend.forms.renderers import InlineFormRenderer @@ -9,6 +7,8 @@ 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,8 +18,3 @@ 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 41cc26c..b6a3391 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,10 +1,9 @@ from django.forms import ModelForm from servala.core.models import Organization -from servala.frontend.forms.mixins import HtmxMixin -class OrganizationForm(HtmxMixin, ModelForm): +class OrganizationCreateForm(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 deleted file mode 100644 index 01c919a..0000000 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ /dev/null @@ -1,52 +0,0 @@ -{% 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 2d6433a..cfad0e9 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 13d9d5c..ae08b51 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -1,11 +1,11 @@ -{% load i18n static %} +{% load i18n %} - diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 59a7ccc..e947a7b 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -12,14 +12,9 @@ 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 4ff574a..8dc1ee8 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -1,16 +1,11 @@ from .auth import LogoutView from .generic import IndexView, ProfileView -from .organization import ( - OrganizationCreateView, - OrganizationDashboardView, - OrganizationUpdateView, -) +from .organization import OrganizationCreateView, OrganizationDashboardView __all__ = [ "IndexView", "LogoutView", "OrganizationCreateView", "OrganizationDashboardView", - "OrganizationUpdateView", "ProfileView", ] diff --git a/src/servala/frontend/views/generic.py b/src/servala/frontend/views/generic.py index a315851..12300fd 100644 --- a/src/servala/frontend/views/generic.py +++ b/src/servala/frontend/views/generic.py @@ -1,27 +1,24 @@ from django.conf import settings from django.urls import reverse_lazy from django.utils.functional import cached_property -from django.views.generic import TemplateView +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 HtmxUpdateView +from servala.frontend.views.mixins import HtmxMixin class IndexView(TemplateView): template_name = "frontend/index.html" -class ProfileView(HtmxUpdateView): +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 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 055366f..3184e86 100644 --- a/src/servala/frontend/views/mixins.py +++ b/src/servala/frontend/views/mixins.py @@ -1,34 +1,14 @@ from django.utils.functional import cached_property -from django.views.generic import UpdateView -from rules.contrib.views import AutoPermissionRequiredMixin +from django.views.generic import TemplateView -class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView): +class HtmxMixin(TemplateView): 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") @@ -45,11 +25,8 @@ class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView): def get_form_kwargs(self): result = super().get_form_kwargs() - 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") + 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): diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index c91a251..9ef4e37 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,51 +1,19 @@ from django.shortcuts import redirect -from django.utils.functional import cached_property -from django.views.generic import CreateView, DetailView -from rules.contrib.views import AutoPermissionRequiredMixin +from django.views.generic import FormView, TemplateView -from servala.core.models import Organization -from servala.frontend.forms import OrganizationForm -from servala.frontend.views.mixins import HtmxUpdateView +from servala.frontend.forms import OrganizationCreateForm -class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): - form_class = OrganizationForm - model = Organization +class OrganizationCreateView(FormView): + form_class = OrganizationCreateForm 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.urls.base) + return redirect(instance.get_absolute_url()) -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 -): +class OrganizationDashboardView(TemplateView): 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 ef9932d..7bc2f9f 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -97,7 +97,6 @@ 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", @@ -171,7 +170,6 @@ 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 deleted file mode 100644 index 1f79795..0000000 --- a/src/servala/static/js/sidebar.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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 6d6a585..7231712 100644 --- a/uv.lock +++ b/uv.lock @@ -661,15 +661,6 @@ 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" @@ -685,8 +676,6 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "pyjwt" }, { name = "requests" }, - { name = "rules" }, - { name = "urlman" }, ] [package.dev-dependencies] @@ -713,8 +702,6 @@ 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] @@ -776,12 +763,3 @@ 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 }, -]