feat: harden dashboard editing and translations

This commit is contained in:
Victor Andersson
2025-11-11 20:27:41 +01:00
parent 559ed671f3
commit 2de32b2083
9 changed files with 1264 additions and 235 deletions

View File

@@ -58,30 +58,23 @@ class ClaimLineForm(forms.ModelForm):
class ClaimDecisionForm(forms.Form):
ACTION_PENDING = "pending"
ACTION_APPROVE = "approve"
ACTION_REJECT = "reject"
ACTION_CHOICES = (
(ACTION_APPROVE, _("Godkänn")),
(ACTION_REJECT, _("Neka")),
(ACTION_PENDING, _("Pending")),
)
claim_id = forms.IntegerField(widget=forms.HiddenInput)
action = forms.ChoiceField(choices=ACTION_CHOICES)
project = forms.ModelChoiceField(
queryset=Project.objects.none(),
required=False,
label=_("Evenemang/Projekt"),
)
decision_note = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 2, "placeholder": _("Kommentar")}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["project"].queryset = Project.objects.filter(is_active=True).order_by("name")
def clean(self):
cleaned = super().clean()
action = cleaned.get("action")

View File

@@ -91,5 +91,6 @@
{% endif %}
{% block content %}{% endblock %}
</main>
{% block modals %}{% endblock %}
</body>
</html>

View File

@@ -85,82 +85,6 @@
{% endfor %}
</div>
</div>
{% if can_change %}
<div class="fixed inset-0 z-40 hidden items-center justify-center bg-slate-900/80 p-4"
data-edit-panel="{{ claim.id }}"
data-edit-backdrop="{{ claim.id }}"
aria-hidden="true"
role="dialog"
aria-modal="true">
<div class="w-full max-w-2xl rounded-3xl bg-white p-6 text-left shadow-2xl">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Redigera utlägg" %}</p>
<h3 class="text-xl font-semibold text-gray-900">{{ claim.full_name }}</h3>
</div>
<button type="button"
class="rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-600 transition hover:bg-gray-200"
onclick="claimsCloseEdit('{{ claim.id }}'); return false;">
{% trans "Stäng" %}
</button>
</div>
<form method="post" class="mt-4 space-y-4">
{% csrf_token %}
<input type="hidden" name="action_type" value="edit">
<input type="hidden" name="edit_claim_id" value="{{ claim.id }}">
<div class="grid gap-4 md:grid-cols-2">
<label class="text-sm font-medium text-gray-700">
{% trans "Namn" %}
<input type="text" name="full_name" value="{{ claim.full_name }}" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "E-post" %}
<input type="email" name="email" value="{{ claim.email }}" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Kontonummer" %}
<input type="text" name="account_number" value="{{ claim.account_number }}" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Belopp" %}
<input type="number" step="0.01" name="amount" value="{{ claim.amount }}" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Valuta" %}
<select name="currency" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">
{% for value, label in currency_choices %}
<option value="{{ value }}"{% if claim.currency == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Evenemang/Projekt" %}
<select name="project" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">
<option value="">{% trans "Ingen" %}</option>
{% for project in project_options %}
<option value="{{ project.id }}"{% if claim.project and project.id == claim.project.id %} selected{% endif %}>{{ project }}</option>
{% endfor %}
</select>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-700" for="edit-description-{{ claim.id }}">{% trans "Beskrivning" %}</label>
<textarea id="edit-description-{{ claim.id }}" name="description" rows="4" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">{{ claim.description }}</textarea>
</div>
<div class="flex items-center justify-end gap-3">
<button type="button"
class="rounded-full border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:bg-gray-100"
onclick="claimsCloseEdit('{{ claim.id }}'); return false;">
{% trans "Avbryt" %}
</button>
<button type="submit" class="rounded-full bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
{% trans "Spara ändringar" %}
</button>
</div>
</form>
</div>
</div>
{% endif %}
</section>
<div class="space-y-6" data-claim-list>
@@ -211,7 +135,7 @@
<span class="text-xs text-gray-500">{% trans "Ej markerad som betald" %}</span>
{% endif %}
{% endif %}
{% if can_change %}
{% if can_change 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">
@@ -344,15 +268,6 @@
<label class="block text-sm font-medium text-gray-700">{% trans "Kommentar" %}</label>
<textarea name="decision_note" rows="3" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">{{ claim.decision_note }}</textarea>
<label class="block text-sm font-medium text-gray-700">{% trans "Evenemang/Projekt" %}</label>
<select name="project" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">
<option value="">{% trans "Behåll nuvarande" %}</option>
{% for project in project_options %}
<option value="{{ project.id }}"{% if claim.project and project.id == claim.project.id %} selected{% endif %}>{{ project }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500">{% trans "Justera projekt om underlaget skickats in mot fel evenemang." %}</p>
<input type="hidden" name="action_type" value="decision">
<button type="submit" class="w-full rounded-full bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
{% trans "Uppdatera beslut" %}
@@ -432,18 +347,33 @@
</section>
<script>
(function () {
function lockBodyScroll() {
document.body.classList.add("overflow-hidden");
}
function unlockBodyScrollIfNeeded() {
const anyOpen = Array.from(document.querySelectorAll("[data-edit-panel]")).some(
(panel) => !panel.classList.contains("hidden")
);
if (!anyOpen) {
document.body.classList.remove("overflow-hidden");
}
}
function openPanel(id) {
const panel = document.querySelector(`[data-edit-panel="${id}"]`);
if (!panel) return;
panel.classList.remove("hidden");
panel.classList.add("flex");
panel.setAttribute("aria-hidden", "false");
lockBodyScroll();
}
function closePanelElement(panel) {
panel.classList.add("hidden");
panel.classList.remove("flex");
panel.setAttribute("aria-hidden", "true");
unlockBodyScrollIfNeeded();
}
document.addEventListener("click", (event) => {
@@ -553,3 +483,87 @@
})();
</script>
{% endblock %}
{% block modals %}
{{ block.super }}
{% if can_change %}
{% 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"
data-edit-panel="{{ claim.id }}"
data-edit-backdrop="{{ claim.id }}"
aria-hidden="true"
role="dialog"
aria-modal="true">
<div class="w-full max-w-2xl rounded-3xl bg-white p-6 text-left shadow-2xl">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Redigera utlägg" %}</p>
<h3 class="text-xl font-semibold text-gray-900">{{ claim.full_name }}</h3>
</div>
<button type="button"
data-close-edit
class="rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-600 transition hover:bg-gray-200">
{% trans "Stäng" %}
</button>
</div>
<form method="post" class="mt-4 space-y-4">
{% csrf_token %}
<input type="hidden" name="action_type" value="edit">
<input type="hidden" name="edit_claim_id" value="{{ claim.id }}">
<div class="grid gap-4 md:grid-cols-2">
<label class="text-sm font-medium text-gray-700">
{% trans "Namn" %}
<input type="text" name="full_name" value="{{ claim.full_name }}" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "E-post" %}
<input type="email" name="email" value="{{ claim.email }}" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Kontonummer" %}
<input type="text" name="account_number" value="{{ claim.account_number }}" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Belopp" %}
<input type="number" step="0.01" name="amount" value="{{ claim.amount }}" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Valuta" %}
<select name="currency" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">
{% for value, label in currency_choices %}
<option value="{{ value }}"{% if claim.currency == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Evenemang/Projekt" %}
<select name="project" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">
<option value="">{% trans "Ingen" %}</option>
{% for project in project_options %}
<option value="{{ project.id }}"{% if claim.project and project.id == claim.project.id %} selected{% endif %}>{{ project }}</option>
{% endfor %}
</select>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-700" for="edit-description-{{ claim.id }}">{% trans "Beskrivning" %}</label>
<textarea id="edit-description-{{ claim.id }}" name="description" rows="4" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">{{ claim.description }}</textarea>
</div>
<div class="flex items-center justify-end gap-3">
<button type="button"
data-close-edit
class="rounded-full border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:bg-gray-100">
{% trans "Avbryt" %}
</button>
<button type="submit" class="rounded-full bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
{% trans "Spara ändringar" %}
</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}

View File

@@ -125,26 +125,26 @@ class DashboardViewTests(TestCase):
response = self.client.get(reverse("claims:admin-list") + "?status=approved")
self.assertFalse(response.context["has_filtered_claims"])
def test_attester_can_update_project_when_deciding(self):
project_old = Project.objects.create(name="Original", is_active=True)
project_new = Project.objects.create(name="Corrected", is_active=True)
claim = self._create_claim(project=project_old)
def test_attester_can_reset_claim_to_pending(self):
claim = self._create_claim(status=Claim.Status.APPROVED)
response = self.client.post(
reverse("claims:admin-list"),
{
"action_type": "decision",
"claim_id": claim.id,
"action": ClaimDecisionForm.ACTION_APPROVE,
"decision_note": "Updated project",
"project": project_new.id,
"action": ClaimDecisionForm.ACTION_PENDING,
"decision_note": "Behöver komplettering",
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
self.assertEqual(claim.project, project_new)
self.assertTrue(claim.logs.filter(action=ClaimLog.Action.PROJECT_CHANGED).exists())
self.assertEqual(claim.status, Claim.Status.PENDING)
log = claim.logs.filter(action=ClaimLog.Action.STATUS_CHANGED).first()
self.assertIsNotNone(log)
self.assertEqual(log.from_status, Claim.Status.APPROVED)
self.assertEqual(log.to_status, Claim.Status.PENDING)
def test_attester_can_edit_details(self):
project = Project.objects.create(name="Event", is_active=True)
@@ -174,3 +174,26 @@ class DashboardViewTests(TestCase):
edit_log = claim.logs.filter(action=ClaimLog.Action.DETAILS_EDITED).first()
self.assertIsNotNone(edit_log)
self.assertIn("Namn", edit_log.note)
self.assertIn("Changed Name", edit_log.note)
def test_edit_blocked_for_non_pending_claims(self):
claim = self._create_claim(status=Claim.Status.APPROVED)
response = self.client.post(
reverse("claims:admin-list"),
{
"action_type": "edit",
"edit_claim_id": claim.id,
"full_name": "Blocked",
"email": "blocked@example.com",
"account_number": "456",
"amount": "200",
"currency": Claim.Currency.SEK,
"project": "",
"description": "Blocked edit",
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
self.assertNotEqual(claim.full_name, "Blocked")
self.assertFalse(claim.logs.filter(action=ClaimLog.Action.DETAILS_EDITED).exists())

View File

@@ -201,23 +201,26 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
return redirect(request.get_full_path())
previous_status = claim.status
claim.decision_note = decision_note
new_project = form.cleaned_data.get("project")
project_changed = False
if new_project is not None and new_project != claim.project:
claim.project = new_project
project_changed = True
if action == ClaimDecisionForm.ACTION_APPROVE:
claim.status = Claim.Status.APPROVED
messages.success(request, _("%(claim)s markerades som godkänd.") % {"claim": claim})
target_status = Claim.Status.APPROVED
feedback = messages.success
feedback_msg = _("%(claim)s markerades som godkänd.")
elif action == ClaimDecisionForm.ACTION_REJECT:
target_status = Claim.Status.REJECTED
feedback = messages.warning
feedback_msg = _("%(claim)s markerades som nekad.")
else:
claim.status = Claim.Status.REJECTED
messages.warning(request, _("%(claim)s markerades som nekad.") % {"claim": claim})
target_status = Claim.Status.PENDING
feedback = messages.info
feedback_msg = _("%(claim)s återställdes till väntande status.")
update_fields = ["status", "decision_note", "updated_at"]
if project_changed:
update_fields.append("project")
status_changed = previous_status != target_status
update_fields = ["decision_note", "updated_at"]
if status_changed:
claim.status = target_status
update_fields.append("status")
claim.save(update_fields=update_fields)
feedback(request, feedback_msg % {"claim": claim})
claim.add_log(
action=ClaimLog.Action.STATUS_CHANGED,
performed_by=request.user,
@@ -225,12 +228,6 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
to_status=claim.status,
note=decision_note,
)
if project_changed:
claim.add_log(
action=ClaimLog.Action.PROJECT_CHANGED,
performed_by=request.user,
note=_("Project updated during decision."),
)
return redirect(request.get_full_path())
def _handle_payment(self, request):
@@ -265,6 +262,12 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
messages.error(request, _("Du har inte behörighet att uppdatera 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:
messages.error(request, _("Endast väntande utlägg kan redigeras via panelen."))
return redirect(request.get_full_path())
original_values = {}
for field in ClaimEditForm.Meta.fields:
original_values[field] = getattr(claim, field)
form = ClaimEditForm(request.POST, instance=claim)
if not form.is_valid():
for error in form.errors.get("__all__", []):
@@ -277,12 +280,19 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
return redirect(request.get_full_path())
updated_claim = form.save()
changed_fields = []
def _format_value(value):
if value is None:
return "-"
return str(value)
change_notes = []
for field in form.changed_data:
label = form.fields[field].label or field
changed_fields.append(str(label))
if changed_fields:
note = _("Fields updated: %(fields)s") % {"fields": ", ".join(changed_fields)}
old_value = _format_value(original_values.get(field))
new_value = _format_value(getattr(updated_claim, field))
change_notes.append(f"{label}: {old_value}{new_value}")
if change_notes:
note = _("Följande fält uppdaterades: %(fields)s") % {"fields": "; ".join(change_notes)}
claim.add_log(
action=ClaimLog.Action.DETAILS_EDITED,
performed_by=request.user,