Compare commits

..

No commits in common. "d10ba90b4dea3ac8f21b0014c88b97c529bb0726" and "6e6f2d099352e641d9e1c9620b41b30e9708c635" have entirely different histories.

20 changed files with 100 additions and 296 deletions

View file

@ -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]

View file

@ -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]

View file

@ -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")
)

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -1,4 +1,4 @@
from .organization import OrganizationForm
from .organization import OrganizationCreateForm
from .profile import UserProfileForm
__all__ = ["OrganizationForm", "UserProfileForm"]
__all__ = ["OrganizationCreateForm", "UserProfileForm"]

View file

@ -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

View file

@ -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",)

View file

@ -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 %}
<td>
{{ form.instance.name }}
{% if has_change_permission %}
<button class="btn btn-primary"
hx-get="{{ request.path }}?fragment=org-name-edit&hx-single-field=name"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Edit" %}</button>
{% endif %}
</td>
{% endpartialdef org-name %}
{% partialdef org-name-edit %}
<td>
<form hx-target="closest td"
hx-swap="outerHTML"
hx-post="{{ request.url }}">
<div class="d-flex align-items-baseline">
{{ form.name.as_field_group }}
<input type="hidden" name="hx-single-field" value="name">
<input type="hidden" name="fragment" value="org-name">
<button type="submit" class="btn btn-primary mx-1">{% translate "Save" %}</button>
<button type="button"
class="btn btn-secondary"
hx-get="{{ request.path }}?fragment=org-name"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Cancel" %}</button>
</div>
</form>
</td>
{% endpartialdef org-name-edit %}
{% block card_content %}
<div class="table-responsive">
<table class="table table-lg">
<tbody>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Name" %}</span>
</th>
{% partial org-name %}
</tr>
</tbody>
</table>
</div>
{% endblock card_content %}

View file

@ -15,7 +15,7 @@
<td>
{{ request.user.email }}
<button class="btn btn-primary"
hx-get="{% url 'frontend:profile' %}?fragment=user-email-edit&hx-single-field=email"
hx-get="{% url 'frontend:profile' %}?fragment=user-email-edit"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Edit" %}</button>
</td>
@ -24,7 +24,7 @@
<td>
{{ request.user.company|default:"" }}
<button class="btn btn-primary"
hx-get="{% url 'frontend:profile' %}?fragment=user-company-edit&hx-single-field=company"
hx-get="{% url 'frontend:profile' %}?fragment=user-company-edit"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Edit" %}</button>
</td>

View file

@ -1,11 +1,11 @@
{% load i18n static %}
{% load i18n %}
<div id="sidebar">
<div id="sidebar">
<div class="sidebar-wrapper active">
<div class="sidebar-header position-relative">
<div class="d-flex justify-content-between align-items-center">
<div class="logo">
<a href="{% if request.organization %}{{ request.organization.urls.base }}{% else %}/{% endif %}">
<a href="{% if request.organization %}{{ request.organization.get_absolute_url }}{% else %}/{% endif %}">
<img src="" alt="{% translate 'Logo' %}" srcset="">
</a>
</div>
@ -53,8 +53,19 @@
<a href="#" class="sidebar-hide d-xl-none d-block"><i class="bi bi-x bi-middle"></i></a>
</div>
</div>
{% if request.user.is_authenticated %}
</div>
<div class="sidebar-menu">
<ul class="menu">
{% if not request.user.is_authenticated %}
<li class="sidebar-item">
<a href="{% url 'account_login' %}" class="sidebar-link">
<i class="bi bi-person-badge-fill"></i>
<span>{% translate 'Login' %}</span>
</a>
</li>
{% else %}
{# request.user.is_authenticated #}
<li class="sidebar-item">
{% if user_organizations.count %}
<button class="btn btn-outline-primary dropdown-toggle w-100"
type="button"
@ -73,7 +84,7 @@
id="organization-dropdown">
{% for organization in user_organizations %}
<a class="dropdown-item{% if organization == request.organization %} active{% endif %}"
href="{{ organization.urls.base }}">
href="{{ organization.get_absolute_url }}">
<i class="bi bi-building-fill me-1"></i>
{{ organization.name }}
</a>
@ -84,38 +95,20 @@
</a>
</div>
{% else %}
<a href="{% url 'frontend:organization.create' %}"
class="btn btn-outline-primary w-100">
<a href="{% url 'frontend:organization.create' %}" class="sidebar-link">
<i class="bi bi-plus-square"></i>
<span>{% translate "Create organization" %}</span>
</a>
{% endif %}
{% endif %}
</div>
<div class="sidebar-menu">
<ul class="menu">
{% if not request.user.is_authenticated %}
<li class="sidebar-item">
<a href="{% url 'account_login' %}" class="sidebar-link">
<i class="bi bi-person-badge-fill"></i>
<span>{% translate 'Login' %}</span>
</a>
</li>
{% else %}
{% if request.organization %}
<li class="sidebar-item">
<a href="{{ request.organization.urls.base }}" class='sidebar-link'>
<a href="{{ request.organization.get_absolute_url }}"
class='sidebar-link'>
<i class="bi bi-grid-fill"></i>
<span>{% translate 'Dashboard' %}</span>
</a>
</li>
<li class="sidebar-title">{% translate 'Organization' %}</li>
<li class="sidebar-item">
<a href="{{ request.organization.urls.details }}" class='sidebar-link'>
<i class="bi bi-building-gear"></i>
<span>{% translate 'Details' %}</span>
</a>
</li>
{% endif %}
<li class="sidebar-title">{% translate 'Account' %}</li>
<li class="sidebar-item">
@ -139,4 +132,3 @@
</div>
</div>
</div>
<script src="{% static 'js/sidebar.js' %}" defer></script>

View file

@ -12,14 +12,9 @@ urlpatterns = [
name="organization.create",
),
path(
"org/<slug:organization>/",
"<slug:organization>/",
include(
[
path(
"details/",
views.OrganizationUpdateView.as_view(),
name="organization.details",
),
path(
"",
views.OrganizationDashboardView.as_view(),

View file

@ -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",
]

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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",

View file

@ -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');
}
}
})
})

22
uv.lock generated
View file

@ -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 },
]