Compare commits

..

No commits in common. "f773cf169bb4733b358c8a876bd0b18d2c30bab8" and "1302dcce445eed5bf78f7cfdd0003777a392465d" have entirely different histories.

32 changed files with 130 additions and 627 deletions

View file

@ -9,14 +9,10 @@ dependencies = [
"cryptography>=44.0.2",
"django==5.2b1",
"django-allauth>=65.5.0",
"django-scopes>=2.0.0",
"django-template-partials>=24.4",
"pillow>=11.1.0",
"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,28 +0,0 @@
from django.shortcuts import get_object_or_404
from django.urls import resolve
from django_scopes import scope, scopes_disabled
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)
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]
request.organization = get_object_or_404(Organization, pk=pk)
with scope(organization=request.organization):
return self.get_response(request)
request.organization = None
return self.get_response(request)

View file

@ -1,25 +0,0 @@
# 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",
),
),
]

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,8 @@
import rules
import urlman
from django.conf import settings
from django.db import models
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):
@ -19,7 +13,6 @@ 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",
@ -35,17 +28,6 @@ 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
def set_owner(self, user):
OrganizationMembership.objects.filter(user=user, organization=self).delete()
OrganizationMembership.objects.create(
@ -67,12 +49,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
@ -147,8 +123,6 @@ class OrganizationMembership(ServalaModelMixin, models.Model):
verbose_name=_("Role"),
)
objects = ScopedManager(organization="organization")
class Meta:
verbose_name = _("Organization membership")
verbose_name_plural = _("Organization memberships")

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

@ -2,4 +2,4 @@ def add_organizations(request):
if not request.user.is_authenticated:
return {"user_organizations": []}
return {"user_organizations": request.user.organizations.all().order_by("name")}
return {"user_organizations": request.user.organizations.all()}

View file

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

View file

@ -1,25 +0,0 @@
from django.utils.functional import cached_property
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.
"""
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
@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,11 +0,0 @@
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")

View file

@ -1,5 +1,4 @@
from django.forms.renderers import TemplatesSetting
from django.forms.widgets import Textarea
def inject_class(f, class_name):
@ -17,31 +16,13 @@ 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"):
input_type = self.get_field_input_type(field)
if field.field.widget.input_type == "checkbox":
class_name = "form-check-input"
else:
class_name = "form-control"
field.build_widget_attrs = inject_class(
field.build_widget_attrs, self.get_class_names(input_type)
field.build_widget_attrs, class_name
)
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"

View file

@ -8,8 +8,6 @@
<link rel="stylesheet"
href="{% static 'mazer/compiled/css/app-dark.css' %}">
<link rel="stylesheet" href="{% static 'mazer/compiled/css/iconly.css' %}">
<link rel="stylesheet" href="{% static 'css/servala.css' %}">
<script src="{% static "js/htmx.min.js" %}" defer></script>
</head>
<title>
{% block html_title %}
@ -17,7 +15,7 @@
{% endblock html_title %}
Servala</title>
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<body>
<script src="{% static 'mazer/static/js/initTheme.js' %}"></script>
<div id="app">
{% include 'includes/sidebar.html' %}

View file

@ -1,21 +0,0 @@
{% load i18n %}
<div class="form-group{% if field.field.required %} mandatory{% endif %}{% if errors %} is-invalid{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}">
{% if not hide_label %}
{% if field.field.widget.input_type != "checkbox" or field.field.widget.allow_multiple_selected %}
<label for="{{ field.auto_id }}" class="form-label">{{ field.label }}</label>
{% endif %}
{% endif %}
{% if field.use_fieldset %}
<fieldset {% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}>
{% endif %}
{{ field }}
{% if field.field.widget.input_type == "checkbox" and not field.field.widget.allow_multiple_selected %}
<label for="{{ field.auto_id }}" class="form-check-label form-label">{{ field.label }}</label>
{% endif %}
{% if field.use_fieldset %}</fieldset>{% endif %}
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
{% if field.help_text %}
<small class="form-text text-muted"
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
{% endif %}
</div>

View file

@ -1 +0,0 @@
{% include "frontend/forms/field.html" with extra_class="d-inline" hide_label=True %}

View file

@ -1 +1,21 @@
<div class="col-12">{% include "frontend/forms/field.html" %}</div>
{% load i18n %}
<div class="col-12">
<div class="form-group{% if field.field.required %} mandatory{% endif %}{% if errors %} is-invalid{% endif %}">
{% if field.field.widget.input_type != "checkbox" or field.field.widget.allow_multiple_selected %}
<label for="{{ field.auto_id }}" class="form-label">{{ field.label }}</label>
{% endif %}
{% if field.use_fieldset %}
<fieldset {% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}>
{% endif %}
{{ field }}
{% if field.field.widget.input_type == "checkbox" and not field.field.widget.allow_multiple_selected %}
<label for="{{ field.auto_id }}" class="form-check-label form-label">{{ field.label }}</label>
{% endif %}
{% if field.use_fieldset %}</fieldset>{% endif %}
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
{% if field.help_text %}
<small class="form-text text-muted"
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
{% endif %}
</div>
</div>

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

@ -1,6 +1,5 @@
{% extends "frontend/base.html" %}
{% load i18n static %}
{% load partials %}
{% block html_title %}
{% block page_title %}
{% translate "Profile" %}
@ -11,62 +10,6 @@
<h4 class="card-title">{% translate "Account" %}</h4>
</div>
{% endblock %}
{% partialdef user-email %}
<td>
{{ request.user.email }}
<button class="btn btn-primary"
hx-get="{% url 'frontend:profile' %}?fragment=user-email-edit&hx-single-field=email"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Edit" %}</button>
</td>
{% endpartialdef user-email %}
{% partialdef user-company %}
<td>
{{ request.user.company|default:"" }}
<button class="btn btn-primary"
hx-get="{% url 'frontend:profile' %}?fragment=user-company-edit&hx-single-field=company"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Edit" %}</button>
</td>
{% endpartialdef user-company %}
{% partialdef user-email-edit %}
<td>
<form hx-target="closest td"
hx-swap="outerHTML"
hx-post="{{ request.url }}">
<div class="d-flex align-items-baseline">
{{ form.email.as_field_group }}
<input type="hidden" name="hx-single-field" value="email">
<input type="hidden" name="fragment" value="user-email">
<button type="submit" class="btn btn-primary mx-1">{% translate "Save" %}</button>
<button type="button"
class="btn btn-secondary"
hx-get="{{ request.path }}?fragment=user-email"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Cancel" %}</button>
</div>
</form>
</td>
{% endpartialdef %}
{% partialdef user-company-edit %}
<td>
<form hx-target="closest td"
hx-swap="outerHTML"
hx-post="{{ request.url }}">
<div class="d-flex align-items-baseline">
{{ form.company.as_field_group }}
<input type="hidden" name="hx-single-field" value="company">
<input type="hidden" name="fragment" value="user-company">
<button type="submit" class="btn mx-1 btn-primary">{% translate "Save" %}</button>
<button type="button"
class="btn btn-secondary"
hx-get="{{ request.path }}?fragment=user-company"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Cancel" %}</button>
</div>
</form>
</td>
{% endpartialdef %}
{% block content %}
<section>
<div class="row match-height">
@ -81,20 +24,20 @@
<table class="table table-lg">
<tbody>
<tr>
<th class="w-25">{% translate "Name" %}</th>
<td>{{ request.user.first_name }} {{ request.user.last_name }}</td>
<th>{% translate "E-mail" %}</th>
<td>{{ request.user.email }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "E-mail" %}</span>
</th>
{% partial user-email %}
<th>{% translate "First name" %}</th>
<td>{{ request.user.first_name }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Company" %}</span>
</th>
{% partial user-company %}
<th>{% translate "Last name" %}</th>
<td>{{ request.user.last_name }}</td>
</tr>
<tr>
<th>{% translate "Company" %}</th>
<td>{{ request.user.company }}</td>
</tr>
</tbody>
</table>

View file

@ -5,13 +5,11 @@
{% include "includes/form_errors.html" %}
{% csrf_token %}
{{ form }}
<div class="col-sm-12 d-flex justify-content-end">
<button class="btn btn-primary me-1 mb-1" type="submit">
{% if form_submit_label %}
{{ form_submit_label }}
{% else %}
{% translate "Save" %}
{% endif %}
</button>
</div>
<button class="btn btn-primary" type="submit">
{% if form_submit_label %}
{{ form_submit_label }}
{% else %}
{% translate "Save" %}
{% endif %}
</button>
</form>

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="index.html">
<img src="" alt="{% translate 'Logo' %}" srcset="">
</a>
</div>
@ -53,44 +53,6 @@
<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 %}
{# request.user.is_authenticated #}
{% if user_organizations.count %}
<button class="btn btn-outline-primary dropdown-toggle w-100"
type="button"
id="organizationDropdown"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
{% if request.organization %}
{{ request.organization.name }}
{% else %}
{% translate "Organizations" %}
{% endif %}
</button>
<div class="dropdown-menu shadow"
aria-labelledby="organizationDropdown"
id="organization-dropdown">
{% for organization in user_organizations %}
<a class="dropdown-item{% if organization == request.organization %} active{% endif %}"
href="{{ organization.urls.base }}">
<i class="bi bi-building-fill me-1"></i>
{{ organization.name }}
</a>
{% endfor %}
<a href="{% url 'frontend:organization.create' %}" class="dropdown-item">
<i class="bi bi-building-add me-1"></i>
<span>{% translate "Create organization" %}</span>
</a>
</div>
{% else %}
<a href="{% url 'frontend:organization.create' %}"
class="btn btn-outline-primary w-100">
<i class="bi bi-plus-square"></i>
<span>{% translate "Create organization" %}</span>
</a>
{% endif %}
{% endif %}
</div>
<div class="sidebar-menu">
<ul class="menu">
@ -102,21 +64,41 @@
</a>
</li>
{% else %}
{% if request.organization %}
<li class="sidebar-item">
<a href="{{ request.organization.urls.base }}" class='sidebar-link'>
<i class="bi bi-grid-fill"></i>
<span>{% translate 'Dashboard' %}</span>
{# request.user.is_authenticated #}
<li class="sidebar-item">
{% if user_organizations.count > 1 %}
<button class="btn btn-primary dropdown-toggle me-1"
type="button"
id="organizationDropdown"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
{% if current_organization %}
{{ current_organization.name }}
{% else %}
{% translate "Organizations" %}
{% endif %}
</button>
<div class="dropdown-menu" aria-labelledby="organizationDropdown">
{% for organization in user_organizations %}
<a class="dropdown-item" href="#TODO">{{ organization.name }}</a>
{% endfor %}
</div>
{% elif current_organization %}
{% translate "Organization" %}: {{ current_organization.name }}
{% else %}
<a href="{% url 'frontend:organization.create' %}" class="sidebar-link">
<i class="bi bi-plus-square"></i>
<span>{% translate "Create organization" %}</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 %}
{% endif %}
</li>
<li class="sidebar-item">
<a href="index.html" class='sidebar-link'>
<i class="bi bi-grid-fill"></i>
<span>{% translate 'Dashboard' %}</span>
</a>
</li>
<li class="sidebar-title">{% translate 'Account' %}</li>
<li class="sidebar-item">
<a href="{% url 'frontend:profile' %}" class='sidebar-link'>
@ -139,4 +121,3 @@
</div>
</div>
</div>
<script src="{% static 'js/sidebar.js' %}" defer></script>

View file

@ -1,5 +1,4 @@
from django.urls import include, path
from django.views.generic import RedirectView
from django.urls import path
from servala.frontend import views
@ -11,22 +10,5 @@ urlpatterns = [
views.OrganizationCreateView.as_view(),
name="organization.create",
),
path(
"org/<slug:organization>/",
include(
[
path(
"details/",
views.OrganizationUpdateView.as_view(),
name="organization.details",
),
path(
"",
views.OrganizationDashboardView.as_view(),
name="organization.dashboard",
),
]
),
),
path("", RedirectView.as_view(pattern_name="frontend:profile"), name="index"),
path("", views.IndexView.as_view(), name="index"),
]

View file

@ -1,16 +1,10 @@
from .auth import LogoutView
from .generic import IndexView, ProfileView
from .organization import (
OrganizationCreateView,
OrganizationDashboardView,
OrganizationUpdateView,
)
from .organization import OrganizationCreateView
__all__ = [
"IndexView",
"LogoutView",
"OrganizationCreateView",
"OrganizationDashboardView",
"OrganizationUpdateView",
"ProfileView",
]

View file

@ -1,44 +1,21 @@
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 servala.core.models import User
from servala.frontend.forms.profile import UserProfileForm
from servala.frontend.views.mixins import HtmxUpdateView
class IndexView(TemplateView):
template_name = "frontend/index.html"
class ProfileView(HtmxUpdateView):
class ProfileView(TemplateView):
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
@cached_property
def object(self):
return self.get_object()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
keycloak_settings = settings.SOCIALACCOUNT_PROVIDERS["openid_connect"]
keycloak_server_url = keycloak_settings["APPS"][0]["settings"]["server_url"]
keycloak_server_url = settings.SOCIALACCOUNT_PROVIDERS["openid_connect"][
"APPS"
][0]["settings"]["server_url"]
account_url = keycloak_server_url.replace(
"/.well-known/openid-configuration", "/account"
)
context["account_href"] = account_url
return context
def form_valid(self, form):
form.save()
return super().form_valid(form)

View file

@ -1,59 +0,0 @@
from django.utils.functional import cached_property
from django.views.generic import UpdateView
from rules.contrib.views import AutoPermissionRequiredMixin
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")
else:
fragment = self.request.GET.get("fragment")
if fragment and fragment in self.fragments:
return fragment
def get_template_names(self):
template_names = super().get_template_names()
if self.is_htmx and (fragment := self._get_fragment()):
return [f"{template_names[0]}#{fragment}"]
return template_names
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")
return result
def form_valid(self, form):
result = super().form_valid(form)
if self.is_htmx and self._get_fragment():
return self.get(self.request, *self.args, **self.kwargs)
return result

View file

@ -1,51 +1,15 @@
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
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)
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")
form.instance.create_organization(form.instance, owner=self.request.user)
return super().form_valid(form)
def get_success_url(self):
return self.request.path
return "/"

View file

@ -96,9 +96,6 @@ INSTALLED_APPS = [
"django.contrib.messages",
"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",
"allauth.account",
@ -117,7 +114,6 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
"django.contrib.auth.middleware.LoginRequiredMiddleware",
"servala.core.middleware.OrganizationMiddleware",
]
LOGIN_URL = "account_login"
@ -171,7 +167,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,3 +0,0 @@
.form-group.d-inline {
margin-bottom: 0;
}

File diff suppressed because one or more lines are too long

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

50
uv.lock generated
View file

@ -248,30 +248,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/66/f8/b58f84c29bcbca3798939279a98e2423e6e53a38c29e3fed7700ff3d6984/django_allauth-65.5.0.tar.gz", hash = "sha256:1a564fd2f5413054559078c2b7146796b517c1e7a38c6312e9de7c9bb708325d", size = 1624216 }
[[package]]
name = "django-scopes"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/d7/a26ccb685b64e8e0b21f107b01ea16636a899a380175fe29d7c01d3d8395/django-scopes-2.0.0.tar.gz", hash = "sha256:d190d9a2462bce812bc6fdd254e47ba031f6fba3279c8ac7397c671df0a4e54f", size = 15118 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/3d/94d82839c111a36145b5ec1fb407a85f9a460af5974a07f4c6d3cc414358/django_scopes-2.0.0-py3-none-any.whl", hash = "sha256:9cf521b4d543ffa2ff6369fb5a1dda03567e862ba89626c01405f3d93ca04724", size = 16660 },
]
[[package]]
name = "django-template-partials"
version = "24.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/ff/2a7ddae12ca8e5fea1a41af05924c04f1bb4aec7157b04a88b829dd93d4a/django_template_partials-24.4.tar.gz", hash = "sha256:25b67301470fc274ecc419e5e5fd4686a5020b1c038fd241a70eb087809034b6", size = 14538 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/72/d8eea70683b25230e0d2647b5cf6f2db4a7e7d35cb6170506d9618196374/django_template_partials-24.4-py2.py3-none-any.whl", hash = "sha256:ee59d3839385d7f648907c3fa8d5923fcd66cd8090f141fe2a1c338b917984e2", size = 8439 },
]
[[package]]
name = "djlint"
version = "1.36.4"
@ -661,15 +637,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"
@ -679,14 +646,10 @@ dependencies = [
{ name = "cryptography" },
{ name = "django" },
{ name = "django-allauth" },
{ name = "django-scopes" },
{ name = "django-template-partials" },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "pyjwt" },
{ name = "requests" },
{ name = "rules" },
{ name = "urlman" },
]
[package.dev-dependencies]
@ -707,14 +670,10 @@ requires-dist = [
{ name = "cryptography", specifier = ">=44.0.2" },
{ name = "django", specifier = "==5.2b1" },
{ name = "django-allauth", specifier = ">=65.5.0" },
{ name = "django-scopes", specifier = ">=2.0.0" },
{ name = "django-template-partials", specifier = ">=24.4" },
{ name = "pillow", specifier = ">=11.1.0" },
{ 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 +735,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 },
]