feat: add granular permissions for editing and payments

This commit is contained in:
Victor Andersson
2025-11-11 20:38:53 +01:00
parent 2de32b2083
commit cbada0794f
10 changed files with 395 additions and 211 deletions

View File

@@ -117,6 +117,16 @@ class UserManagementForm(forms.Form):
is_staff = forms.BooleanField(required=False, initial=True, label=_("Administratör (staff)"))
grant_view = forms.BooleanField(required=False, initial=True, label=_("Ge behörighet att se utlägg"))
grant_change = forms.BooleanField(required=False, initial=True, label=_("Ge behörighet att besluta utlägg"))
grant_edit = forms.BooleanField(
required=False,
initial=False,
label=_("Ge behörighet att redigera utläggsdetaljer"),
)
grant_pay = forms.BooleanField(
required=False,
initial=False,
label=_("Ge behörighet att markera betalningar"),
)
def clean_username(self):
username = self.cleaned_data["username"]
@@ -148,6 +158,8 @@ class UserPermissionForm(forms.Form):
is_staff = forms.BooleanField(required=False, label=_("Admin/staff"))
grant_view = forms.BooleanField(required=False, label=_("Får se utlägg"))
grant_change = forms.BooleanField(required=False, label=_("Får besluta utlägg"))
grant_edit = forms.BooleanField(required=False, label=_("Får redigera utlägg"))
grant_pay = forms.BooleanField(required=False, label=_("Får markera betalningar"))
class DeleteUserForm(forms.Form):

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.8 on 2025-11-11 19:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0007_delete_systemsetting_alter_claim_receipt'),
]
operations = [
migrations.AlterModelOptions(
name='claim',
options={'ordering': ['-created_at'], 'permissions': [('mark_claim_paid', 'Can mark claims as paid'), ('edit_claim_details', 'Can edit claim details')]},
),
migrations.AlterField(
model_name='claimlog',
name='action',
field=models.CharField(choices=[('created', 'Submitted'), ('status_changed', 'Status changed'), ('marked_paid', 'Marked as paid'), ('project_changed', 'Project changed'), ('details_edited', 'Details edited')], max_length=32),
),
]

View File

@@ -81,6 +81,10 @@ class Claim(models.Model):
class Meta:
ordering = ["-created_at"]
permissions = [
("mark_claim_paid", _("Can mark claims as paid")),
("edit_claim_details", _("Can edit claim details")),
]
def __str__(self):
project = f" [{self.project}]" if self.project else ""

View File

@@ -135,7 +135,7 @@
<span class="text-xs text-gray-500">{% trans "Ej markerad som betald" %}</span>
{% endif %}
{% endif %}
{% if can_change and claim.status == 'pending' %}
{% if can_edit_claim and claim.status == 'pending' %}
<button type="button"
data-open-edit="{{ claim.id }}"
class="rounded-full border border-gray-300 px-3 py-1 text-xs font-semibold text-gray-700 transition hover:bg-gray-100">
@@ -186,15 +186,21 @@
</details>
{% if payments_enabled and not claim.is_paid %}
<div class="flex flex-col items-start gap-3 md:items-end">
<form method="post" class="w-full max-w-xs" onsubmit="return confirm('{% trans "Är du säker att du har lagt upp betalningen? Markera endast som betald om beloppet skickas till banken." %}');">
{% csrf_token %}
<input type="hidden" name="action_type" value="payment">
<input type="hidden" name="payment_claim_id" value="{{ claim.id }}">
<button type="submit" class="flex w-full items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-4 py-3 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-emerald-700">
{% trans "Markera som betald" %}
</button>
</form>
<p class="text-[11px] text-green-700">{% trans "Dubbelkolla belopp och kontonummer i panelen innan du bekräftar." %}</p>
{% if can_mark_paid %}
<form method="post" class="w-full max-w-xs" onsubmit="return confirm('{% trans "Är du säker att du har lagt upp betalningen? Markera endast som betald om beloppet skickas till banken." %}');">
{% csrf_token %}
<input type="hidden" name="action_type" value="payment">
<input type="hidden" name="payment_claim_id" value="{{ claim.id }}">
<button type="submit" class="flex w-full items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-4 py-3 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-emerald-700">
{% trans "Markera som betald" %}
</button>
</form>
<p class="text-[11px] text-green-700">{% trans "Dubbelkolla belopp och kontonummer i panelen innan du bekräftar." %}</p>
{% else %}
<p class="rounded-2xl bg-white/80 px-4 py-3 text-xs text-green-800">
{% trans "Du saknar behörighet att markera betalningar. Kontakta en administratör." %}
</p>
{% endif %}
</div>
{% elif not payments_enabled %}
<div class="flex flex-col items-start gap-3 md:items-end">
@@ -486,7 +492,7 @@
{% block modals %}
{{ block.super }}
{% if can_change %}
{% if can_edit_claim %}
{% for claim in claims %}
{% if claim.status == 'pending' %}
<div class="fixed inset-0 z-40 hidden items-center justify-center bg-slate-900/80 p-4"

View File

@@ -65,9 +65,11 @@
<li class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-brand-400"></span>
<span class="min-w-0">
{% blocktrans %}Behörigheterna <code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.view_claim</code>
och <code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.change_claim</code>
styr åtkomst till adminvyn respektive beslutsflödet.{% endblocktrans %}
{% blocktrans %}Behörigheterna <code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.view_claim</code>,
<code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.change_claim</code>,
<code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.edit_claim_details</code>
och <code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.mark_claim_paid</code>
styr åtkomst till adminvyn, beslutsflödet, redigering samt betalningspanelen.{% endblocktrans %}
</span>
</li>
<li class="flex items-start gap-3">

View File

@@ -81,7 +81,9 @@ class DashboardViewTests(TestCase):
self.user = User.objects.create_user(username="admin", password="test123", email="admin@example.com")
view_perm = Permission.objects.get(codename="view_claim")
change_perm = Permission.objects.get(codename="change_claim")
self.user.user_permissions.add(view_perm, change_perm)
edit_perm = Permission.objects.get(codename="edit_claim_details")
pay_perm = Permission.objects.get(codename="mark_claim_paid")
self.user.user_permissions.add(view_perm, change_perm, edit_perm, pay_perm)
self.client.force_login(self.user)
def _create_claim(self, **kwargs):
@@ -197,3 +199,43 @@ class DashboardViewTests(TestCase):
claim.refresh_from_db()
self.assertNotEqual(claim.full_name, "Blocked")
self.assertFalse(claim.logs.filter(action=ClaimLog.Action.DETAILS_EDITED).exists())
def test_edit_requires_permission(self):
self.user.user_permissions.remove(Permission.objects.get(codename="edit_claim_details"))
claim = self._create_claim()
response = self.client.post(
reverse("claims:admin-list"),
{
"action_type": "edit",
"edit_claim_id": claim.id,
"full_name": "Nope",
"email": "nope@example.com",
"account_number": "456",
"amount": "200",
"currency": Claim.Currency.SEK,
"project": "",
"description": "Should fail",
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
self.assertNotEqual(claim.full_name, "Nope")
self.assertFalse(claim.logs.filter(action=ClaimLog.Action.DETAILS_EDITED).exists())
@override_settings(CLAIMS_ENABLE_INTERNAL_PAYMENTS=True)
def test_mark_paid_requires_permission(self):
claim = self._create_claim(status=Claim.Status.APPROVED)
self.user.user_permissions.remove(Permission.objects.get(codename="mark_claim_paid"))
response = self.client.post(
reverse("claims:admin-list"),
{
"action_type": "payment",
"payment_claim_id": claim.id,
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
self.assertIsNone(claim.paid_at)
self.assertFalse(claim.logs.filter(action=ClaimLog.Action.MARKED_PAID).exists())

View File

@@ -160,6 +160,8 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
context["status_choices"] = Claim.Status.choices
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
context["can_change"] = self.request.user.has_perm("claims.change_claim")
context["can_edit_claim"] = self.request.user.has_perm("claims.edit_claim_details")
context["can_mark_paid"] = self.request.user.has_perm("claims.mark_claim_paid")
context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False)
context["summary"] = self._build_summary()
context["project_options"] = Project.objects.filter(is_active=True).order_by("name")
@@ -234,8 +236,8 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
if not getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False):
messages.error(request, _("Betalningshantering är inte aktiverad."))
return redirect(request.get_full_path())
if not request.user.has_perm("claims.change_claim"):
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
if not request.user.has_perm("claims.mark_claim_paid"):
messages.error(request, _("Du har inte behörighet att markera betalningar i systemet."))
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=request.POST.get("payment_claim_id"))
@@ -258,8 +260,8 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
return redirect(request.get_full_path())
def _handle_edit(self, request):
if not request.user.has_perm("claims.change_claim"):
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
if not request.user.has_perm("claims.edit_claim_details"):
messages.error(request, _("Du har inte behörighet att redigera utlägg."))
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=request.POST.get("edit_claim_id"))
if claim.status != Claim.Status.PENDING:
@@ -384,6 +386,8 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
"is_staff": user.is_staff,
"grant_view": user.has_perm("claims.view_claim"),
"grant_change": user.has_perm("claims.change_claim"),
"grant_edit": user.has_perm("claims.edit_claim_details"),
"grant_pay": user.has_perm("claims.mark_claim_paid"),
}
),
"delete_form": None
@@ -413,6 +417,8 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
)
self._set_perm(user, "claims.view_claim", form.cleaned_data.get("grant_view", False))
self._set_perm(user, "claims.change_claim", form.cleaned_data.get("grant_change", False))
self._set_perm(user, "claims.edit_claim_details", form.cleaned_data.get("grant_edit", False))
self._set_perm(user, "claims.mark_claim_paid", form.cleaned_data.get("grant_pay", False))
messages.success(request, _("Användaren %(user)s skapades.") % {"user": user.username})
return redirect(request.path)
return self.render_to_response(self.get_context_data(create_form=form))
@@ -430,6 +436,8 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
user.save(update_fields=["is_staff"])
self._set_perm(user, "claims.view_claim", form.cleaned_data["grant_view"])
self._set_perm(user, "claims.change_claim", form.cleaned_data["grant_change"])
self._set_perm(user, "claims.edit_claim_details", form.cleaned_data["grant_edit"])
self._set_perm(user, "claims.mark_claim_paid", form.cleaned_data["grant_pay"])
messages.success(request, _("Behörigheter uppdaterades för %(user)s.") % {"user": user.username})
else:
messages.error(request, _("Kunde inte uppdatera behörigheter."))