Compare commits

...

16 Commits

Author SHA1 Message Date
Victor Andersson
65b249f2f8 fix: show edit button and translations 2025-11-11 21:09:59 +01:00
Victor Andersson
3d8e5ed410 feat: allow editing user profile info via modal 2025-11-11 21:04:33 +01:00
Victor Andersson
73ff0a9d45 fix: use precomputed permission flags in template 2025-11-11 20:48:12 +01:00
Victor Andersson
9fe70ac13b feat: add permission edit modal 2025-11-11 20:44:56 +01:00
Victor Andersson
cbada0794f feat: add granular permissions for editing and payments 2025-11-11 20:38:53 +01:00
Victor Andersson
2de32b2083 feat: harden dashboard editing and translations 2025-11-11 20:27:41 +01:00
Victor Andersson
559ed671f3 Attach edit button handler with JS listeners 2025-11-10 23:20:59 +01:00
Victor Andersson
9499eb6395 Render edit overlay per claim 2025-11-09 22:18:22 +01:00
Victor Andersson
968150b074 Fix edit button for text-node targets 2025-11-09 22:05:01 +01:00
Victor Andersson
c189fd053c Expose edit panel opener globally 2025-11-09 22:03:29 +01:00
Victor Andersson
3323ffd82e Use delegated handlers for edit panel 2025-11-09 22:00:14 +01:00
Victor Andersson
f42381a9a0 Make edit panel work without dialog support 2025-11-09 21:58:04 +01:00
Victor Andersson
78377a7ae9 Switch edit overlay to modal dialog 2025-11-09 21:53:22 +01:00
Victor Andersson
caf3df24cf Add inline edit panel for claims 2025-11-09 21:49:44 +01:00
Victor Andersson
0d68c75fef Log project changes during approvals 2025-11-09 14:05:28 +01:00
Victor Andersson
70aeca6187 Allow approvers to adjust project before approving 2025-11-09 13:57:54 +01:00
13 changed files with 2279 additions and 313 deletions

View File

@@ -26,7 +26,7 @@ Nyckel-URLer (språkprefixed):
- **Auto-prefill:** Inloggade användare får namn, e-post och senaste kontonummer förifyllt.
- **Valuta & projekt:** Varje rad har dold valutaväljare (SEK default) och projektreferens. Projekt listas från Django admin > Projekt.
- **Kvitton:** Filuppladdningar sparas med slumpat UUID-baserat namn under `receipts/` för säkerhet och unika namn.
- **Dashboard:** KPI-kort med totalsiffror, senaste aktivitet, statusfördelning och samma inline-flöde för beslut/utbetalningar.
- **Dashboard:** KPI-kort med totalsiffror, senaste aktivitet, statusfördelning och samma inline-flöde för beslut/utbetalningar. Attestanter kan öppna en redigeringspanel för att justera namn, belopp, valuta, kontonummer och projekt innan beslut.
- **Betalspårning:** När intern betalning är på får godkända claims en "Betala"-knapp. När ett claim markeras som betalt låses status/kommentar tills reset görs.
- **Mina utlägg:** Inloggade ser sina egna claims i samma Tailwind-layout med kvitto-länk och logg.
- **Användarhantering:** Tailwind-sida där personal kan skapa konton, tilldela `claims.view_claim`/`claims.change_claim`, markera staff och ta bort användare.

View File

@@ -58,15 +58,18 @@ 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)
decision_note = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 2, "placeholder": _("Kommentar")}),
@@ -81,6 +84,29 @@ class ClaimDecisionForm(forms.Form):
return cleaned
class ClaimEditForm(forms.ModelForm):
class Meta:
model = Claim
fields = [
"full_name",
"email",
"account_number",
"amount",
"currency",
"project",
"description",
]
labels = {
"full_name": _("Namn"),
"email": _("E-post"),
"account_number": _("Kontonummer"),
"amount": _("Belopp"),
"currency": _("Valuta"),
"project": _("Evenemang/Projekt"),
"description": _("Beskrivning"),
}
class UserManagementForm(forms.Form):
username = forms.CharField(max_length=150, label=_("Användarnamn"))
email = forms.EmailField(required=False, label=_("E-post"))
@@ -91,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"]
@@ -122,6 +158,44 @@ 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"))
first_name = forms.CharField(
max_length=150,
required=False,
label=_("Förnamn"),
widget=forms.TextInput(attrs={"class": INPUT_CLASSES}),
)
last_name = forms.CharField(
max_length=150,
required=False,
label=_("Efternamn"),
widget=forms.TextInput(attrs={"class": INPUT_CLASSES}),
)
email = forms.EmailField(
required=False,
label=_("E-post"),
widget=forms.EmailInput(attrs={"class": INPUT_CLASSES}),
)
new_password1 = forms.CharField(
required=False,
label=_("Nytt lösenord"),
widget=forms.PasswordInput(attrs={"class": INPUT_CLASSES}),
)
new_password2 = forms.CharField(
required=False,
label=_("Bekräfta nytt lösenord"),
widget=forms.PasswordInput(attrs={"class": INPUT_CLASSES}),
)
def clean(self):
cleaned = super().clean()
pwd1 = cleaned.get("new_password1")
pwd2 = cleaned.get("new_password2")
if pwd1 or pwd2:
if pwd1 != pwd2:
self.add_error("new_password2", _("Lösenorden matchar inte."))
return cleaned
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 ""
@@ -122,6 +126,8 @@ class ClaimLog(models.Model):
CREATED = "created", _("Submitted")
STATUS_CHANGED = "status_changed", _("Status changed")
MARKED_PAID = "marked_paid", _("Marked as paid")
PROJECT_CHANGED = "project_changed", _("Project changed")
DETAILS_EDITED = "details_edited", _("Details edited")
claim = models.ForeignKey(Claim, related_name="logs", on_delete=models.CASCADE)
action = models.CharField(max_length=32, choices=Action.choices)
@@ -147,4 +153,3 @@ class ClaimLog(models.Model):
def __str__(self):
return f"{self.get_action_display()} ({self.created_at:%Y-%m-%d %H:%M})"

View File

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

View File

@@ -118,25 +118,32 @@
</p>
</div>
</div>
<div class="flex flex-col items-start gap-2 text-sm lg:items-end">
<span class="rounded-full px-4 py-2 text-sm font-semibold {% if claim.status == 'approved' %}bg-green-50 text-green-700 border border-green-200{% elif claim.status == 'rejected' %}bg-rose-50 text-rose-700 border border-rose-200{% else %}bg-amber-50 text-amber-800 border border-amber-200{% endif %}">
{{ claim.get_status_display }}
</span>
{% if claim.decision_note %}
<p class="text-xs text-gray-500">{% trans "Kommentar" %}: {{ claim.decision_note }}</p>
<div class="flex flex-col items-start gap-2 text-sm lg:items-end">
<span class="rounded-full px-4 py-2 text-sm font-semibold {% if claim.status == 'approved' %}bg-green-50 text-green-700 border border-green-200{% elif claim.status == 'rejected' %}bg-rose-50 text-rose-700 border border-rose-200{% else %}bg-amber-50 text-amber-800 border border-amber-200{% endif %}">
{{ claim.get_status_display }}
</span>
{% if claim.decision_note %}
<p class="text-xs text-gray-500">{% trans "Kommentar" %}: {{ claim.decision_note }}</p>
{% endif %}
{% if payments_enabled and claim.status == 'approved' %}
{% if claim.is_paid %}
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800">
{% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d H:i" }}
{% if claim.paid_by %}{% trans "av" %} {{ claim.paid_by.get_username }}{% endif %}
</span>
{% else %}
<span class="text-xs text-gray-500">{% trans "Ej markerad som betald" %}</span>
{% endif %}
{% endif %}
</div>
</div>
{% if claim.is_paid %}
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800">
{% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d H:i" }}
{% if claim.paid_by %}{% trans "av" %} {{ claim.paid_by.get_username }}{% endif %}
</span>
{% else %}
<span class="text-xs text-gray-500">{% trans "Ej markerad som betald" %}</span>
{% endif %}
{% endif %}
{% 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">
{% trans "Redigera" %}
</button>
{% endif %}
</div>
</div>
{% if claim.status == 'approved' %}
<div class="mx-6 mt-4 grid gap-4 rounded-3xl border border-green-100 bg-green-50 px-6 py-4 text-sm text-green-900 {% if payments_enabled and claim.is_paid %}md:grid-cols-1{% else %}md:grid-cols-[2fr,1fr]{% endif %}">
@@ -179,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">
@@ -258,11 +271,11 @@
{% endfor %}
</select>
<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 "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>
<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">
<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" %}
</button>
</form>
@@ -339,73 +352,224 @@
</div>
</section>
<script>
document.addEventListener("DOMContentLoaded", () => {
const filterButtons = Array.from(document.querySelectorAll("[data-filter-button]"));
const cards = Array.from(document.querySelectorAll("[data-claim-card]"));
const emptyState = document.querySelector("[data-claim-empty]");
if (!filterButtons.length || !cards.length) {
return;
(function () {
function lockBodyScroll() {
document.body.classList.add("overflow-hidden");
}
const activeClasses = ["bg-brand-600", "text-white", "hover:bg-brand-700"];
const inactiveClasses = ["bg-slate-100", "text-gray-700", "hover:bg-slate-200"];
const setButtonState = (activeValue) => {
filterButtons.forEach((btn) => {
const value = btn.dataset.filterValue || "all";
const isActive = value === activeValue;
btn.setAttribute("aria-pressed", String(isActive));
const classList = btn.classList;
if (isActive) {
inactiveClasses.forEach((cls) => classList.remove(cls));
activeClasses.forEach((cls) => classList.add(cls));
} else {
activeClasses.forEach((cls) => classList.remove(cls));
inactiveClasses.forEach((cls) => classList.add(cls));
}
});
};
const applyFilter = (filterValue) => {
const value = filterValue || "all";
let visibleCount = 0;
cards.forEach((card) => {
const matches = value === "all" || card.dataset.status === value;
card.classList.toggle("hidden", !matches);
if (matches) {
visibleCount += 1;
}
});
if (emptyState) {
emptyState.classList.toggle("hidden", visibleCount > 0);
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");
}
}
setButtonState(value);
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();
}
try {
const url = new URL(window.location.href);
if (value === "all") {
url.searchParams.delete("status");
} else {
url.searchParams.set("status", value);
}
window.history.replaceState({}, "", url);
} catch (error) {
// ignore history errors
function closePanelElement(panel) {
panel.classList.add("hidden");
panel.classList.remove("flex");
panel.setAttribute("aria-hidden", "true");
unlockBodyScrollIfNeeded();
}
document.addEventListener("click", (event) => {
const backdrop = event.target.closest("[data-edit-backdrop]");
if (backdrop && event.target === backdrop) {
closePanelElement(backdrop);
}
};
filterButtons.forEach((btn) => {
btn.addEventListener("click", (event) => {
event.preventDefault();
applyFilter(btn.dataset.filterValue || "all");
});
});
const initialFilter = new URLSearchParams(window.location.search).get("status") || "all";
applyFilter(initialFilter);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
document.querySelectorAll("[data-edit-panel]").forEach((panel) => {
if (!panel.classList.contains("hidden")) {
closePanelElement(panel);
}
});
}
});
document.addEventListener("DOMContentLoaded", () => {
const editButtons = Array.from(document.querySelectorAll("[data-open-edit]"));
editButtons.forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
openPanel(button.dataset.openEdit);
});
});
document.querySelectorAll("[data-close-edit]").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
const panel = button.closest("[data-edit-panel]");
if (panel) {
closePanelElement(panel);
}
});
});
const filterButtons = Array.from(document.querySelectorAll("[data-filter-button]"));
const cards = Array.from(document.querySelectorAll("[data-claim-card]"));
const emptyState = document.querySelector("[data-claim-empty]");
if (!filterButtons.length || !cards.length) {
return;
}
const activeClasses = ["bg-brand-600", "text-white", "hover:bg-brand-700"];
const inactiveClasses = ["bg-slate-100", "text-gray-700", "hover:bg-slate-200"];
const setButtonState = (activeValue) => {
filterButtons.forEach((btn) => {
const value = btn.dataset.filterValue || "all";
const isActive = value === activeValue;
btn.setAttribute("aria-pressed", String(isActive));
const classList = btn.classList;
if (isActive) {
inactiveClasses.forEach((cls) => classList.remove(cls));
activeClasses.forEach((cls) => classList.add(cls));
} else {
activeClasses.forEach((cls) => classList.remove(cls));
inactiveClasses.forEach((cls) => classList.add(cls));
}
});
};
const applyFilter = (filterValue) => {
const value = filterValue || "all";
let visibleCount = 0;
cards.forEach((card) => {
const matches = value === "all" || card.dataset.status === value;
card.classList.toggle("hidden", !matches);
if (matches) {
visibleCount += 1;
}
});
if (emptyState) {
emptyState.classList.toggle("hidden", visibleCount > 0);
}
setButtonState(value);
try {
const url = new URL(window.location.href);
if (value === "all") {
url.searchParams.delete("status");
} else {
url.searchParams.set("status", value);
}
window.history.replaceState({}, "", url);
} catch (error) {
// ignore history errors
}
};
filterButtons.forEach((btn) => {
btn.addEventListener("click", (event) => {
event.preventDefault();
applyFilter(btn.dataset.filterValue || "all");
});
});
const initialFilter = new URLSearchParams(window.location.search).get("status") || "all";
applyFilter(initialFilter);
});
})();
</script>
{% endblock %}
{% block modals %}
{{ block.super }}
{% 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"
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

@@ -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">
@@ -103,31 +105,48 @@
{{ user.get_full_name|default:_("Saknar namn") }} · {{ user.email|default:_("Ingen e-post") }}
</p>
</div>
<p class="text-xs uppercase tracking-wide text-gray-400">ID: {{ user.id }}</p>
<div class="flex items-center gap-3">
<p class="text-xs uppercase tracking-wide text-gray-400">ID: {{ user.id }}</p>
{% if can_change_users %}
<button type="button"
data-open-permission-edit="{{ user.id }}"
class="inline-flex items-center gap-2 rounded-full border border-gray-200 px-3 py-1 text-xs font-semibold text-gray-700 transition hover:bg-gray-100">
{% trans "Redigera användare" %}
</button>
{% endif %}
</div>
</div>
<div class="mt-4 grid gap-6 lg:grid-cols-[2fr,1fr]">
<form method="post" class="space-y-4 rounded-2xl bg-slate-50 p-4">
{% csrf_token %}
<input type="hidden" name="action" value="update">
{{ form.user_id }}
<div class="space-y-3 text-sm text-gray-700">
<label class="flex items-center gap-2" for="{{ form.is_staff.id_for_label }}">
{{ form.is_staff }}
<span>{% trans "Admin/staff" %}</span>
</label>
<label class="flex items-center gap-2" for="{{ form.grant_view.id_for_label }}">
{{ form.grant_view }}
<span>{% trans "Får se utlägg" %}</span>
</label>
<label class="flex items-center gap-2" for="{{ form.grant_change.id_for_label }}">
{{ form.grant_change }}
<span>{% trans "Får besluta utlägg" %}</span>
</label>
<div class="rounded-2xl bg-slate-50 p-4">
<p class="text-sm font-semibold text-gray-700">{% trans "Behörigheter" %}</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
{% if row.permission_flags.is_staff %}
<span class="rounded-full px-3 py-1 bg-emerald-100 text-emerald-800">{% trans "Admin/staff" %}</span>
{% endif %}
{% if row.permission_flags.view %}
<span class="rounded-full px-3 py-1 bg-blue-100 text-blue-800">{% trans "Får se utlägg" %}</span>
{% endif %}
{% if row.permission_flags.change %}
<span class="rounded-full px-3 py-1 bg-indigo-100 text-indigo-800">{% trans "Får besluta utlägg" %}</span>
{% endif %}
{% if row.permission_flags.edit %}
<span class="rounded-full px-3 py-1 bg-purple-100 text-purple-800">{% trans "Får redigera utlägg" %}</span>
{% endif %}
{% if row.permission_flags.pay %}
<span class="rounded-full px-3 py-1 bg-amber-100 text-amber-800">{% trans "Får markera betalningar" %}</span>
{% endif %}
{% if not row.permission_flags.is_staff and not row.permission_flags.view and not row.permission_flags.change and not row.permission_flags.edit and not row.permission_flags.pay %}
<span class="rounded-full bg-slate-200 px-3 py-1 text-slate-600">{% trans "Inga behörigheter tilldelade" %}</span>
{% endif %}
</div>
<button type="submit" class="w-full rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
{% trans "Spara behörigheter" %}
</button>
</form>
{% if can_change_users %}
<button type="button"
data-open-permission-edit="{{ user.id }}"
class="mt-4 inline-flex items-center gap-2 rounded-full bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
{% trans "Redigera behörigheter" %}
</button>
{% endif %}
</div>
<div class="rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-800">
<p class="font-semibold">{% trans "Ta bort konto" %}</p>
{% if delete_form %}
@@ -157,3 +176,191 @@
</section>
</section>
{% endblock %}
{% block modals %}
{{ block.super }}
{% if can_change_users %}
{% for row in user_rows %}
{% with user=row.user form=row.permission_form %}
<div class="fixed inset-0 z-40 hidden items-center justify-center bg-slate-900/80 p-4"
data-permission-modal="{{ user.id }}"
aria-hidden="true"
role="dialog"
aria-modal="true">
<div class="w-full max-w-xl rounded-3xl bg-white p-6 shadow-2xl">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Redigera behörigheter" %}</p>
<h3 class="text-xl font-semibold text-gray-900">{{ user.username }}</h3>
</div>
<button type="button"
data-close-permission-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" value="update">
{{ form.user_id }}
<div class="space-y-5 text-sm text-gray-800">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Kontaktuppgifter" %}</p>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<div>
<label class="text-xs font-semibold text-gray-600" for="{{ form.first_name.id_for_label }}">{{ form.first_name.label }}</label>
{{ form.first_name }}
{% for error in form.first_name.errors %}
<p class="text-xs text-rose-600">{{ error }}</p>
{% endfor %}
</div>
<div>
<label class="text-xs font-semibold text-gray-600" for="{{ form.last_name.id_for_label }}">{{ form.last_name.label }}</label>
{{ form.last_name }}
{% for error in form.last_name.errors %}
<p class="text-xs text-rose-600">{{ error }}</p>
{% endfor %}
</div>
<div class="md:col-span-2">
<label class="text-xs font-semibold text-gray-600" for="{{ form.email.id_for_label }}">{{ form.email.label }}</label>
{{ form.email }}
{% for error in form.email.errors %}
<p class="text-xs text-rose-600">{{ error }}</p>
{% endfor %}
</div>
</div>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Lösenord" %}</p>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<div>
<label class="text-xs font-semibold text-gray-600" for="{{ form.new_password1.id_for_label }}">{{ form.new_password1.label }}</label>
{{ form.new_password1 }}
{% for error in form.new_password1.errors %}
<p class="text-xs text-rose-600">{{ error }}</p>
{% endfor %}
</div>
<div>
<label class="text-xs font-semibold text-gray-600" for="{{ form.new_password2.id_for_label }}">{{ form.new_password2.label }}</label>
{{ form.new_password2 }}
{% for error in form.new_password2.errors %}
<p class="text-xs text-rose-600">{{ error }}</p>
{% endfor %}
</div>
</div>
<p class="mt-1 text-xs text-gray-500">{% trans "Lämna fälten tomma för att behålla nuvarande lösenord." %}</p>
</div>
<div class="space-y-3">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Behörigheter" %}</p>
<label class="flex items-center gap-3" for="{{ form.is_staff.id_for_label }}">
{{ form.is_staff }}
<span>{% trans "Admin/staff" %}</span>
</label>
<label class="flex items-center gap-3" for="{{ form.grant_view.id_for_label }}">
{{ form.grant_view }}
<span>{% trans "Får se utlägg" %}</span>
</label>
<label class="flex items-center gap-3" for="{{ form.grant_change.id_for_label }}">
{{ form.grant_change }}
<span>{% trans "Får besluta utlägg" %}</span>
</label>
<label class="flex items-center gap-3" for="{{ form.grant_edit.id_for_label }}">
{{ form.grant_edit }}
<span>{% trans "Får redigera utlägg" %}</span>
</label>
<label class="flex items-center gap-3" for="{{ form.grant_pay.id_for_label }}">
{{ form.grant_pay }}
<span>{% trans "Får markera betalningar" %}</span>
</label>
</div>
</div>
<div class="flex items-center justify-end gap-3">
<button type="button"
data-close-permission-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 behörigheter" %}
</button>
</div>
</form>
</div>
</div>
{% endwith %}
{% endfor %}
{% endif %}
{% endblock %}
{% block extra_js %}
{{ block.super }}
{% if can_change_users %}
<script>
(function () {
function lockScroll() {
document.body.classList.add("overflow-hidden");
}
function unlockScrollIfNeeded() {
const anyOpen = Array.from(document.querySelectorAll("[data-permission-modal]")).some(
(modal) => !modal.classList.contains("hidden")
);
if (!anyOpen) {
document.body.classList.remove("overflow-hidden");
}
}
function openModal(id) {
const modal = document.querySelector(`[data-permission-modal="${id}"]`);
if (!modal) return;
modal.classList.remove("hidden");
modal.classList.add("flex");
modal.setAttribute("aria-hidden", "false");
lockScroll();
}
function closeModal(modal) {
modal.classList.add("hidden");
modal.classList.remove("flex");
modal.setAttribute("aria-hidden", "true");
unlockScrollIfNeeded();
}
document.addEventListener("click", (event) => {
const backdrop = event.target.closest("[data-permission-modal]");
if (backdrop && event.target === backdrop) {
closeModal(backdrop);
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
document.querySelectorAll("[data-permission-modal]").forEach((modal) => {
if (!modal.classList.contains("hidden")) {
closeModal(modal);
}
});
}
});
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-open-permission-edit]").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
openModal(button.dataset.openPermissionEdit);
});
});
document.querySelectorAll("[data-close-permission-edit]").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
const modal = button.closest("[data-permission-modal]");
if (modal) {
closeModal(modal);
}
});
});
});
})();
</script>
{% endif %}
{% endblock %}

View File

@@ -8,7 +8,8 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from .models import Claim
from .forms import ClaimDecisionForm
from .models import Claim, ClaimLog, Project
from .validators import validate_receipt_file
from .views import SubmitClaimView
@@ -79,7 +80,10 @@ class DashboardViewTests(TestCase):
User = get_user_model()
self.user = User.objects.create_user(username="admin", password="test123", email="admin@example.com")
view_perm = Permission.objects.get(codename="view_claim")
self.user.user_permissions.add(view_perm)
change_perm = Permission.objects.get(codename="change_claim")
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):
@@ -122,3 +126,153 @@ class DashboardViewTests(TestCase):
self._create_claim(status=Claim.Status.PENDING)
response = self.client.get(reverse("claims:admin-list") + "?status=approved")
self.assertFalse(response.context["has_filtered_claims"])
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_PENDING,
"decision_note": "Behöver komplettering",
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
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)
claim = self._create_claim(project=project, amount=100)
response = self.client.post(
reverse("claims:admin-list"),
{
"action_type": "edit",
"edit_claim_id": claim.id,
"full_name": "Changed Name",
"email": "changed@example.com",
"account_number": "789-000",
"amount": "555.55",
"currency": Claim.Currency.EUR,
"project": "",
"description": "Updated description",
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
self.assertEqual(claim.full_name, "Changed Name")
self.assertEqual(claim.email, "changed@example.com")
self.assertEqual(claim.currency, Claim.Currency.EUR)
self.assertIsNone(claim.project)
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())
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())
class UserManagementViewTests(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(username="manager", password="test123", email="manager@example.com")
perms = Permission.objects.filter(codename__in=["view_user", "change_user"])
self.admin.user_permissions.add(*perms)
self.client.force_login(self.admin)
self.target = User.objects.create_user(username="editor", password="oldpass123", email="old@example.com")
def test_admin_can_update_profile_and_password(self):
response = self.client.post(
reverse("claims:user-manage"),
{
"action": "update",
"user_id": self.target.id,
"is_staff": "on",
"grant_view": "on",
"grant_change": "",
"grant_edit": "on",
"grant_pay": "on",
"first_name": "New",
"last_name": "Name",
"email": "new@example.com",
"new_password1": "StrongPass123!",
"new_password2": "StrongPass123!",
},
follow=True,
)
self.assertEqual(response.status_code, 200)
target = get_user_model().objects.get(pk=self.target.pk)
self.assertEqual(target.first_name, "New")
self.assertEqual(target.last_name, "Name")
self.assertEqual(target.email, "new@example.com")
self.assertTrue(target.is_staff)
self.assertTrue(target.check_password("StrongPass123!"))

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth import get_user_model, password_validation, update_session_auth_hash
from django.contrib.auth.models import Permission
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.db.models import Sum
@@ -14,9 +14,11 @@ from django.utils import timezone
from django.utils.translation import gettext as _
from django.views import View
from django.views.generic import ListView, TemplateView
from django.core.exceptions import ValidationError
from .forms import (
ClaimDecisionForm,
ClaimEditForm,
ClaimLineForm,
ClaimantForm,
DeleteUserForm,
@@ -24,7 +26,7 @@ from .forms import (
UserPermissionForm,
)
from .email_utils import notify_admin_of_claim, send_claimant_confirmation_email
from .models import Claim, ClaimLog
from .models import Claim, ClaimLog, Project
User = get_user_model()
@@ -159,8 +161,12 @@ 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")
context["currency_choices"] = Claim.Currency.choices
context["has_any_claims"] = context["summary"]["total_claims"] > 0
context["has_filtered_claims"] = self._has_filtered_claims(context["status_filter"], context["summary"])
context["recent_claims"] = (
@@ -174,6 +180,8 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
action_type = request.POST.get("action_type", "decision")
if action_type == "payment":
return self._handle_payment(request)
if action_type == "edit":
return self._handle_edit(request)
return self._handle_decision(request)
def _handle_decision(self, request):
@@ -196,15 +204,26 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
return redirect(request.get_full_path())
previous_status = claim.status
claim.decision_note = decision_note
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.")
claim.save(update_fields=["status", "decision_note", "updated_at"])
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,
@@ -218,8 +237,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"))
@@ -241,6 +260,52 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
messages.success(request, _("%(claim)s markerades som betald.") % {"claim": claim})
return redirect(request.get_full_path())
def _handle_edit(self, request):
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:
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__", []):
messages.error(request, error)
for field, field_errors in form.errors.items():
if field == "__all__":
continue
for error in field_errors:
messages.error(request, f"{form.fields[field].label}: {error}")
return redirect(request.get_full_path())
updated_claim = form.save()
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
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,
note=note,
)
messages.success(request, _("Informationen uppdaterades."))
else:
messages.info(request, _("Inga förändringar att spara."))
return redirect(request.get_full_path())
def _build_summary(self):
now = timezone.now()
last_week = now - timedelta(days=7)
@@ -313,17 +378,30 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
users = User.objects.order_by("username")
rows = []
for user in users:
perms = {
"is_staff": user.is_staff,
"view": user.has_perm("claims.view_claim"),
"change": user.has_perm("claims.change_claim"),
"edit": user.has_perm("claims.edit_claim_details"),
"pay": user.has_perm("claims.mark_claim_paid"),
}
rows.append(
{
"user": user,
"permission_form": UserPermissionForm(
initial={
"user_id": user.id,
"is_staff": user.is_staff,
"grant_view": user.has_perm("claims.view_claim"),
"grant_change": user.has_perm("claims.change_claim"),
"is_staff": perms["is_staff"],
"grant_view": perms["view"],
"grant_change": perms["change"],
"grant_edit": perms["edit"],
"grant_pay": perms["pay"],
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
}
),
"permission_flags": perms,
"delete_form": None
if user == self.request.user or user.is_superuser
else DeleteUserForm(initial={"user_id": user.id}),
@@ -331,6 +409,7 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
)
context["user_rows"] = rows
context["create_form"] = kwargs.get("create_form") or UserManagementForm()
context["can_change_users"] = self.request.user.has_perm("auth.change_user")
return context
def post(self, request, *args, **kwargs):
@@ -351,6 +430,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))
@@ -361,13 +442,39 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
form = UserPermissionForm(request.POST)
if form.is_valid():
user = get_object_or_404(User, pk=form.cleaned_data["user_id"])
if user == request.user and not form.cleaned_data["is_staff"]:
new_is_staff = form.cleaned_data["is_staff"]
if user == request.user and not new_is_staff:
messages.error(request, _("Du kan inte ta bort din egen staff-status."))
return redirect(request.path)
user.is_staff = form.cleaned_data["is_staff"]
user.save(update_fields=["is_staff"])
update_fields = set()
if user.is_staff != new_is_staff:
user.is_staff = new_is_staff
update_fields.add("is_staff")
for attr in ("first_name", "last_name", "email"):
new_value = form.cleaned_data.get(attr)
if new_value is None:
continue
if getattr(user, attr) != new_value:
setattr(user, attr, new_value)
update_fields.add(attr)
new_password = form.cleaned_data.get("new_password1")
if new_password:
try:
password_validation.validate_password(new_password, user)
except ValidationError as exc:
for error in exc:
messages.error(request, error)
return redirect(request.path)
user.set_password(new_password)
update_fields.add("password")
if update_fields:
user.save(update_fields=list(update_fields))
if new_password and user == request.user:
update_session_auth_hash(request, user)
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."))

Binary file not shown.

View File

@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: claims-system 0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-09 13:13+0100\n"
"POT-Creation-Date: 2025-11-11 20:06+0000\n"
"PO-Revision-Date: 2025-11-08 23:40+0100\n"
"Last-Translator: ChatGPT <noreply@example.com>\n"
"Language-Team: English\n"
@@ -12,33 +12,43 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: claims/forms.py:19
#: claims/forms.py:19 claims/forms.py:100
#: claims/templates/claims/dashboard.html:522
msgid "Namn"
msgstr "Name"
#: claims/forms.py:23 claims/forms.py:86
#: claims/templates/claims/dashboard.html:169
#: claims/forms.py:23 claims/forms.py:101 claims/forms.py:112
#: claims/forms.py:177 claims/templates/claims/dashboard.html:176
#: claims/templates/claims/dashboard.html:526
msgid "E-post"
msgstr "Email"
#: claims/forms.py:28 claims/templates/claims/dashboard.html:157
#: claims/forms.py:28 claims/forms.py:102
#: claims/templates/claims/dashboard.html:164
#: claims/templates/claims/dashboard.html:530
msgid "Kontonummer"
msgstr "Account number"
#: claims/forms.py:49 claims/templates/claims/dashboard.html:204
#: claims/forms.py:49 claims/forms.py:106
#: claims/templates/claims/dashboard.html:217
#: claims/templates/claims/dashboard.html:556
msgid "Beskrivning"
msgstr "Description"
#: claims/forms.py:50 claims/templates/claims/dashboard.html:153
#: claims/forms.py:50 claims/forms.py:103
#: claims/templates/claims/dashboard.html:160
#: claims/templates/claims/dashboard.html:534
#: claims/templates/claims/my_claims.html:23
msgid "Belopp"
msgstr "Amount"
#: claims/forms.py:51
#: claims/forms.py:51 claims/forms.py:104
#: claims/templates/claims/dashboard.html:538
msgid "Valuta"
msgstr "Currency"
#: claims/forms.py:52
#: claims/forms.py:52 claims/forms.py:105
#: claims/templates/claims/dashboard.html:546
msgid "Evenemang/Projekt"
msgstr "Project"
@@ -46,84 +56,118 @@ msgstr "Project"
msgid "Kvitto"
msgstr "Receipt"
#: claims/forms.py:64
#: claims/forms.py:65
msgid "Godkänn"
msgstr "Approve"
#: claims/forms.py:65
#: claims/forms.py:66
msgid "Neka"
msgstr "Reject"
#: claims/forms.py:72 claims/templates/claims/dashboard.html:126
#: claims/templates/claims/dashboard.html:261
msgid "Kommentar"
msgstr "Comment"
#: claims/forms.py:80
msgid "Kommentar krävs när du nekar ett utlägg."
msgstr "A comment is required when you reject an expense."
#: claims/forms.py:85
msgid "Användarnamn"
msgstr "Username"
#: claims/forms.py:87
msgid "Förnamn"
msgstr "First name"
#: claims/forms.py:88
msgid "Efternamn"
msgstr "Last name"
#: claims/forms.py:89
msgid "Lösenord"
msgstr "Password"
#: claims/forms.py:90
msgid "Bekräfta lösenord"
msgstr "Confirm password"
#: claims/forms.py:91
msgid "Administratör (staff)"
msgstr "Administrator (staff)"
#: claims/forms.py:92
msgid "Ge behörighet att se utlägg"
msgstr "Allow viewing claims"
#: claims/forms.py:93
msgid "Ge behörighet att besluta utlägg"
msgstr "Allow deciding claims"
#: claims/forms.py:98
msgid "Användarnamnet är upptaget."
msgstr "That username is already taken."
#: claims/forms.py:104
msgid "Lösenorden matchar inte."
msgstr "Passwords do not match."
#: claims/forms.py:122 claims/templates/claims/user_management.html:116
msgid "Admin/staff"
msgstr "Admin/staff"
#: claims/forms.py:123 claims/templates/claims/user_management.html:120
msgid "Får se utlägg"
msgstr "May view claims"
#: claims/forms.py:124 claims/templates/claims/user_management.html:124
msgid "Får besluta utlägg"
msgstr "May decide claims"
#: claims/models.py:29 claims/templates/claims/dashboard.html:325
#: claims/forms.py:67 claims/models.py:29
#: claims/templates/claims/dashboard.html:338
msgid "Pending"
msgstr "Pending"
#: claims/models.py:30 claims/templates/claims/dashboard.html:329
#: claims/forms.py:75 claims/templates/claims/dashboard.html:126
#: claims/templates/claims/dashboard.html:274
msgid "Kommentar"
msgstr "Comment"
#: claims/forms.py:83
msgid "Kommentar krävs när du nekar ett utlägg."
msgstr "A comment is required when you reject an expense."
#: claims/forms.py:111
msgid "Användarnamn"
msgstr "Username"
#: claims/forms.py:113 claims/forms.py:166
msgid "Förnamn"
msgstr "First name"
#: claims/forms.py:114 claims/forms.py:172
msgid "Efternamn"
msgstr "Last name"
#: claims/forms.py:115 claims/templates/claims/user_management.html:234
msgid "Lösenord"
msgstr "Password"
#: claims/forms.py:116
msgid "Bekräfta lösenord"
msgstr "Confirm password"
#: claims/forms.py:117
msgid "Administratör (staff)"
msgstr "Administrator (staff)"
#: claims/forms.py:118
msgid "Ge behörighet att se utlägg"
msgstr "Allow viewing claims"
#: claims/forms.py:119
msgid "Ge behörighet att besluta utlägg"
msgstr "Allow deciding claims"
#: claims/forms.py:123
msgid "Ge behörighet att redigera utläggsdetaljer"
msgstr "Allow editing claim details"
#: claims/forms.py:128
msgid "Ge behörighet att markera betalningar"
msgstr "Allow marking payments"
#: claims/forms.py:134
msgid "Användarnamnet är upptaget."
msgstr "That username is already taken."
#: claims/forms.py:140 claims/forms.py:197
msgid "Lösenorden matchar inte."
msgstr "Passwords do not match."
#: claims/forms.py:158 claims/templates/claims/user_management.html:124
#: claims/templates/claims/user_management.html:257
msgid "Admin/staff"
msgstr "Admin/staff"
#: claims/forms.py:159 claims/templates/claims/user_management.html:127
#: claims/templates/claims/user_management.html:261
msgid "Får se utlägg"
msgstr "May view claims"
#: claims/forms.py:160 claims/templates/claims/user_management.html:130
#: claims/templates/claims/user_management.html:265
msgid "Får besluta utlägg"
msgstr "May decide claims"
#: claims/forms.py:161 claims/templates/claims/user_management.html:133
#: claims/templates/claims/user_management.html:269
msgid "Får redigera utlägg"
msgstr "May edit claims"
#: claims/forms.py:162 claims/templates/claims/user_management.html:136
#: claims/templates/claims/user_management.html:273
msgid "Får markera betalningar"
msgstr "May mark payments"
#: claims/forms.py:182
#, fuzzy
#| msgid "Lösenord"
msgid "Nytt lösenord"
msgstr "Password"
#: claims/forms.py:187
#, fuzzy
#| msgid "Bekräfta lösenord"
msgid "Bekräfta nytt lösenord"
msgstr "Confirm password"
#: claims/models.py:30 claims/templates/claims/dashboard.html:342
msgid "Approved"
msgstr "Approved"
#: claims/models.py:31 claims/templates/claims/dashboard.html:333
#: claims/models.py:31 claims/templates/claims/dashboard.html:346
msgid "Rejected"
msgstr "Rejected"
@@ -147,18 +191,34 @@ msgstr "British pound (GBP)"
msgid "Describe what the reimbursement is for"
msgstr "Describe what the reimbursement is for"
#: claims/models.py:122
#: claims/models.py:85
msgid "Can mark claims as paid"
msgstr ""
#: claims/models.py:86
msgid "Can edit claim details"
msgstr ""
#: claims/models.py:126
msgid "Submitted"
msgstr "Submitted"
#: claims/models.py:123
#: claims/models.py:127
msgid "Status changed"
msgstr "Status changed"
#: claims/models.py:124
#: claims/models.py:128
msgid "Marked as paid"
msgstr "Marked as paid"
#: claims/models.py:129
msgid "Project changed"
msgstr "Project changed"
#: claims/models.py:130
msgid "Details edited"
msgstr ""
#: claims/templates/claims/base.html:8
msgid "Claims"
msgstr "Claims"
@@ -241,7 +301,9 @@ msgstr "Track inflow, decisions, and payouts and act on claims immediately."
msgid ""
"Tips: använd filtren för att fokusera på specifika statusar eller projekt. "
"Dashboarden uppdateras i realtid när data ändras."
msgstr "Tip: use the filters to focus on statuses or projects. The dashboard updates in real time."
msgstr ""
"Tip: use the filters to focus on statuses or projects. The dashboard updates "
"in real time."
#: claims/templates/claims/dashboard.html:21
msgid "Totalt antal utlägg"
@@ -316,7 +378,7 @@ msgid "Alla"
msgstr "All"
#: claims/templates/claims/dashboard.html:106
#: claims/templates/claims/dashboard.html:165
#: claims/templates/claims/dashboard.html:172
msgid "Skapad"
msgstr "Created"
@@ -349,26 +411,32 @@ msgstr "by"
msgid "Ej markerad som betald"
msgstr "Not marked as paid"
#: claims/templates/claims/dashboard.html:145
#: claims/templates/claims/dashboard.html:142
msgid "Redigera"
msgstr "Edit"
#: claims/templates/claims/dashboard.html:152
msgid "Utbetalningsdetaljer"
msgstr "Payout details"
#: claims/templates/claims/dashboard.html:161
#: claims/templates/claims/dashboard.html:168
msgid "Referens (Claim ID)"
msgstr "Reference (Claim ID)"
#: claims/templates/claims/dashboard.html:173
#: claims/templates/claims/dashboard.html:180
#: claims/templates/claims/my_claims.html:24
msgid "Projekt"
msgstr "Project"
#: claims/templates/claims/dashboard.html:177
#: claims/templates/claims/dashboard.html:184
msgid ""
"Använd referensen och beloppet när du lägger upp betalningen hjälper att "
"undvika dubbletter."
msgstr "Use the reference and amount when entering the payment it helps avoid duplicates."
msgstr ""
"Use the reference and amount when entering the payment it helps avoid "
"duplicates."
#: claims/templates/claims/dashboard.html:182
#: claims/templates/claims/dashboard.html:190
msgid ""
"Är du säker på att du har lagt upp betalningen? Markera endast som betald om "
"beloppet skickas till banken."
@@ -376,17 +444,20 @@ msgstr ""
"Are you sure the payment has been scheduled? Only mark as paid if the amount "
"has been sent to the bank."
#: claims/templates/claims/dashboard.html:187
#, fuzzy
#| msgid "Markerad som betald"
#: claims/templates/claims/dashboard.html:195
msgid "Markera som betald"
msgstr "Marked as paid"
msgstr "Mark as paid"
#: claims/templates/claims/dashboard.html:190
#: claims/templates/claims/dashboard.html:198
msgid "Dubbelkolla belopp och kontonummer i panelen innan du bekräftar."
msgstr "Double-check the amount and account number before confirming."
#: claims/templates/claims/dashboard.html:195
#: claims/templates/claims/dashboard.html:201
msgid ""
"Du saknar behörighet att markera betalningar. Kontakta en administratör."
msgstr "You do not have permission to mark payments. Contact an administrator."
#: claims/templates/claims/dashboard.html:208
msgid ""
"Intern betalningshantering är av markera betalning i ekonomisystemet och "
"resetta status vid behov."
@@ -394,86 +465,108 @@ msgstr ""
"Internal payment handling is off register the payment in the finance "
"system and reset the status if needed."
#: claims/templates/claims/dashboard.html:212
#: claims/templates/claims/dashboard.html:225
msgid "Visa kvitto"
msgstr "View receipt"
#: claims/templates/claims/dashboard.html:215
#: claims/templates/claims/dashboard.html:228
msgid "Inget kvitto bifogat"
msgstr "No receipt attached"
#: claims/templates/claims/dashboard.html:217
#: claims/templates/claims/dashboard.html:230
msgid "Senast uppdaterad"
msgstr "Last updated"
#: claims/templates/claims/dashboard.html:222
#: claims/templates/claims/dashboard.html:235
msgid "Logg"
msgstr "Log"
#: claims/templates/claims/dashboard.html:229
#: claims/templates/claims/dashboard.html:242
#: claims/templates/claims/my_claims.html:62
msgid "Status"
msgstr "Status"
#: claims/templates/claims/dashboard.html:235
#: claims/templates/claims/dashboard.html:248
msgid "Av"
msgstr "By"
#: claims/templates/claims/dashboard.html:239
#: claims/templates/claims/dashboard.html:252
#: claims/templates/claims/my_claims.html:69
msgid "Ingen logg än."
msgstr "No log entries yet."
#: claims/templates/claims/dashboard.html:247
#: claims/templates/claims/dashboard.html:260
msgid ""
"Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta."
msgstr "The claim is marked as paid. Decision/comments are locked."
#: claims/templates/claims/dashboard.html:254
#: claims/templates/claims/dashboard.html:267
msgid "Åtgärd"
msgstr "Action"
#: claims/templates/claims/dashboard.html:266
#: claims/templates/claims/dashboard.html:279
msgid "Uppdatera beslut"
msgstr "Update decision"
#: claims/templates/claims/dashboard.html:277
#: claims/templates/claims/dashboard.html:290
#: claims/templates/claims/my_claims.html:78
msgid "Inga utlägg ännu"
msgstr "No claims yet"
#: claims/templates/claims/dashboard.html:278
#: claims/templates/claims/dashboard.html:291
msgid "När formuläret tas emot visas posterna automatiskt här."
msgstr "As soon as submissions arrive they will appear here."
#: claims/templates/claims/dashboard.html:284
#: claims/templates/claims/dashboard.html:297
msgid "Inga utlägg matchar filtret"
msgstr "No claims match the filter"
#: claims/templates/claims/dashboard.html:285
#: claims/templates/claims/dashboard.html:298
msgid "Välj en annan status för att se fler poster."
msgstr "Choose another status to see more entries."
#: claims/templates/claims/dashboard.html:293
#: claims/templates/claims/dashboard.html:306
msgid "Senaste inskick"
msgstr "Latest submissions"
#: claims/templates/claims/dashboard.html:294
#: claims/templates/claims/dashboard.html:307
msgid "Aktivitet"
msgstr "Activity"
#: claims/templates/claims/dashboard.html:312
#: claims/templates/claims/dashboard.html:325
msgid "Inga aktiviteter än."
msgstr "No activity yet."
#: claims/templates/claims/dashboard.html:320
#: claims/templates/claims/dashboard.html:333
msgid "Statusfördelning"
msgstr "Status breakdown"
#: claims/templates/claims/dashboard.html:321
#: claims/templates/claims/dashboard.html:334
msgid "Snabbstatistik"
msgstr "Quick stats"
#: claims/templates/claims/dashboard.html:507
msgid "Redigera utlägg"
msgstr "Edit claim"
#: claims/templates/claims/dashboard.html:513
#: claims/templates/claims/user_management.html:199
msgid "Stäng"
msgstr "Close"
#: claims/templates/claims/dashboard.html:548
msgid "Ingen"
msgstr "None"
#: claims/templates/claims/dashboard.html:563
#: claims/templates/claims/user_management.html:281
msgid "Avbryt"
msgstr "Cancel"
#: claims/templates/claims/dashboard.html:566
msgid "Spara ändringar"
msgstr "Save changes"
#: claims/templates/claims/export_placeholder.html:8
msgid "Export till redovisningssystem"
msgstr "Export to bookkeeping system"
@@ -715,17 +808,26 @@ msgstr "Use Django admin groups when multiple people share a role."
#: claims/templates/claims/user_management.html:68
msgid ""
"Behörigheterna <code class=\"break-normal rounded bg-slate-800 px-2 py-1 "
"text-xs\">claims.view_claim</code>\n"
"text-xs\">claims.view_claim</code>,\n"
" <code class=\"break-normal rounded bg-slate-800 px-2 "
"py-1 text-xs\">claims.change_claim</code>,\n"
" <code class=\"break-normal rounded bg-slate-800 px-2 "
"py-1 text-xs\">claims.edit_claim_details</code>\n"
" och <code class=\"break-normal rounded bg-slate-800 "
"px-2 py-1 text-xs\">claims.change_claim</code>\n"
" styr åtkomst till adminvyn respektive beslutsflödet."
"px-2 py-1 text-xs\">claims.mark_claim_paid</code>\n"
" styr åtkomst till adminvyn, beslutsflödet, "
"redigering samt betalningspanelen."
msgstr ""
"The permissions <code class=\"break-normal rounded bg-slate-800 px-2 py-1 "
"text-xs\">claims.view_claim</code> and <code class=\"break-normal rounded bg-"
"slate-800 px-2 py-1 text-xs\">claims.change_claim</code> control access to "
"the list and decision flows."
"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>, and <code class=\"break-normal "
"rounded bg-slate-800 px-2 py-1 text-xs\">claims.mark_claim_paid</code> "
"control access to the dashboard, decision flow, edit dialog, and payment "
"panel."
#: claims/templates/claims/user_management.html:75
#: claims/templates/claims/user_management.html:77
msgid ""
"En markerad Admin/staff-användare kan nå Django admin och skapa projekt, "
"exportflöden m.m."
@@ -733,7 +835,7 @@ msgstr ""
"Users flagged as Admin/staff may access Django admin to create projects, "
"exports, etc."
#: claims/templates/claims/user_management.html:79
#: claims/templates/claims/user_management.html:81
msgid ""
"Ta bara bort konton du är säker på historik försvinner inte, men personen "
"tappar all åtkomst."
@@ -741,60 +843,85 @@ msgstr ""
"Only delete accounts you are sure about history stays, but the person "
"loses access."
#: claims/templates/claims/user_management.html:87
#: claims/templates/claims/user_management.html:89
msgid "Befintliga användare"
msgstr "Existing users"
#: claims/templates/claims/user_management.html:88
#: claims/templates/claims/user_management.html:90
msgid "Justera behörigheter"
msgstr "Adjust permissions"
#: claims/templates/claims/user_management.html:99
#: claims/templates/claims/user_management.html:101
msgid "Superuser"
msgstr "Superuser"
#: claims/templates/claims/user_management.html:103
#: claims/templates/claims/user_management.html:105
msgid "Saknar namn"
msgstr "No name"
#: claims/templates/claims/user_management.html:103
#: claims/templates/claims/user_management.html:105
msgid "Ingen e-post"
msgstr "No email"
#: claims/templates/claims/user_management.html:128
msgid "Spara behörigheter"
msgstr "Save permissions"
#: claims/templates/claims/user_management.html:114
msgid "Redigera användare"
msgstr "Edit user"
#: claims/templates/claims/user_management.html:132
#: claims/templates/claims/user_management.html:121
#: claims/templates/claims/user_management.html:254
msgid "Behörigheter"
msgstr "Permissions"
#: claims/templates/claims/user_management.html:139
msgid "Inga behörigheter tilldelade"
msgstr "No permissions assigned"
#: claims/templates/claims/user_management.html:146
#: claims/templates/claims/user_management.html:193
msgid "Redigera behörigheter"
msgstr "Edit permissions"
#: claims/templates/claims/user_management.html:151
msgid "Ta bort konto"
msgstr "Remove account"
#: claims/templates/claims/user_management.html:134
#: claims/templates/claims/user_management.html:153
msgid "Åtgärden går inte att ångra. Användaren förlorar omedelbart åtkomst."
msgstr "This action cannot be undone. The user loses access immediately."
#: claims/templates/claims/user_management.html:135
#, fuzzy, python-format
#| msgid "Ta bort {{ user.username }}?"
#: claims/templates/claims/user_management.html:154
#, python-format
msgid "Ta bort %(user.username)s?"
msgstr "Remove {{ user.username }}?"
msgstr "Delete %(user.username)s?"
#: claims/templates/claims/user_management.html:140
#: claims/templates/claims/user_management.html:159
msgid "Ta bort användare"
msgstr "Delete user"
#: claims/templates/claims/user_management.html:144
#: claims/templates/claims/user_management.html:163
msgid "Kan inte tas bort (antingen du själv eller superuser)."
msgstr "Cannot be removed (either yourself or a superuser)."
#: claims/templates/claims/user_management.html:152
#: claims/templates/claims/user_management.html:171
msgid "Inga användare upplagda."
msgstr "No users yet."
#: claims/templates/claims/user_management.html:153
#: claims/templates/claims/user_management.html:172
msgid "Skapa det första kontot via formuläret ovan."
msgstr "Create the first account using the form above."
#: claims/templates/claims/user_management.html:208
msgid "Kontaktuppgifter"
msgstr "Contact details"
#: claims/templates/claims/user_management.html:251
msgid "Lämna fälten tomma för att behålla nuvarande lösenord."
msgstr "Leave the fields blank to keep the current password."
#: claims/templates/claims/user_management.html:284
msgid "Spara behörigheter"
msgstr "Save permissions"
#: claims/validators.py:87
#, python-format
msgid "Kvitton får vara max %(size)s MB."
@@ -814,100 +941,123 @@ msgstr ""
msgid "Filens innehåll matchar inte förväntat format."
msgstr ""
#: claims/views.py:126
#: claims/views.py:128
#, python-brace-format
msgid "{} utlägg skickade in."
msgstr ""
#: claims/views.py:129
#: claims/views.py:131
msgid "Inga utlägg kunde sparas. Fyll i minst en rad."
msgstr ""
#: claims/views.py:131
#: claims/views.py:133
msgid "Kunde inte spara utläggen. Kontrollera formuläret."
msgstr ""
#: claims/views.py:181 claims/views.py:222
#, fuzzy
#| msgid "Ge behörighet att besluta utlägg"
#: claims/views.py:189
msgid "Du har inte behörighet att uppdatera utlägg."
msgstr "Allow deciding claims"
msgstr "You do not have permission to update claims."
#: claims/views.py:195
#, fuzzy
#| msgid ""
#| "Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta."
#: claims/views.py:203
msgid "Utlägget är redan markerat som betalt och kan inte ändras."
msgstr "The claim is marked as paid. Decision/comments are locked."
msgstr "This claim is already marked as paid and cannot be changed."
#: claims/views.py:202
#: claims/views.py:210
#, python-format
msgid "%(claim)s markerades som godkänd."
msgstr ""
msgstr "%(claim)s was marked as approved."
#: claims/views.py:205
#, fuzzy, python-format
#| msgid "Ej markerad som betald"
#: claims/views.py:214
#, python-format
msgid "%(claim)s markerades som nekad."
msgstr "Not marked as paid"
msgstr "%(claim)s was marked as rejected."
#: claims/views.py:219
#: claims/views.py:218
#, python-format
msgid "%(claim)s återställdes till väntande status."
msgstr "%(claim)s was reset to pending status."
#: claims/views.py:238
msgid "Betalningshantering är inte aktiverad."
msgstr ""
#: claims/views.py:227
msgid "Endast godkända utlägg kan markeras som betalda."
msgstr ""
#: claims/views.py:230
msgid "Detta utlägg är redan markerat som betalt."
msgstr ""
msgstr "Payment handling is not enabled."
#: claims/views.py:241
#, fuzzy, python-format
#| msgid "Ej markerad som betald"
msgid "Du har inte behörighet att markera betalningar i systemet."
msgstr "You do not have permission to mark payments in the system."
#: claims/views.py:246
msgid "Endast godkända utlägg kan markeras som betalda."
msgstr "Only approved claims can be marked as paid."
#: claims/views.py:249
msgid "Detta utlägg är redan markerat som betalt."
msgstr "This claim is already marked as paid."
#: claims/views.py:260
#, python-format
msgid "%(claim)s markerades som betald."
msgstr "Not marked as paid"
msgstr "%(claim)s was marked as paid."
#: claims/views.py:307
#: claims/views.py:265
msgid "Du har inte behörighet att redigera utlägg."
msgstr "You do not have permission to edit claims."
#: claims/views.py:269
msgid "Endast väntande utlägg kan redigeras via panelen."
msgstr "Only pending claims can be edited via the panel."
#: claims/views.py:298
#, python-format
msgid "Följande fält uppdaterades: %(fields)s"
msgstr "The following fields were updated: %(fields)s"
#: claims/views.py:304
msgid "Informationen uppdaterades."
msgstr "Information updated."
#: claims/views.py:306
msgid "Inga förändringar att spara."
msgstr "No changes to save."
#: claims/views.py:372
msgid "Du saknar behörighet för åtgärden."
msgstr ""
msgstr "You do not have permission to perform this action."
#: claims/views.py:354
#: claims/views.py:435
#, python-format
msgid "Användaren %(user)s skapades."
msgstr ""
#: claims/views.py:365
#: claims/views.py:447
msgid "Du kan inte ta bort din egen staff-status."
msgstr ""
#: claims/views.py:371
#: claims/views.py:478
#, python-format
msgid "Behörigheter uppdaterades för %(user)s."
msgstr ""
#: claims/views.py:373
#: claims/views.py:480
#, fuzzy
#| msgid "Justera behörigheter"
msgid "Kunde inte uppdatera behörigheter."
msgstr "Adjust permissions"
#: claims/views.py:383
#: claims/views.py:490
msgid "Du kan inte ta bort ditt eget konto."
msgstr ""
#: claims/views.py:385
#: claims/views.py:492
msgid "Du kan inte ta bort en superuser via detta gränssnitt."
msgstr ""
#: claims/views.py:388
#: claims/views.py:495
#, fuzzy
#| msgid "Användare"
msgid "Användaren togs bort."
msgstr "Users"
#: claims/views.py:391
#: claims/views.py:498
msgid "Okänd åtgärd."
msgstr ""
@@ -957,6 +1107,19 @@ msgstr "Use your admin credentials to manage claims."
msgid "Behöver du ett konto? Kontakta en superuser i organisationen."
msgstr "Need an account? Contact a superuser in your organization."
#~ msgid "Behåll nuvarande"
#~ msgstr "Keep current"
#~ msgid "Justera projekt om underlaget skickats in mot fel evenemang."
#~ msgstr ""
#~ "Adjust the project if the submission was sent against the wrong event."
#~ msgid "Aktivera JavaScript för att kunna redigera uppgifter direkt här."
#~ msgstr "Enable JavaScript to edit the information directly here."
#~ msgid "Project updated during decision."
#~ msgstr "Project updated during decision."
#~ msgid "Admin Utlägg"
#~ msgstr "Admin Claims"

Binary file not shown.

File diff suppressed because it is too large Load Diff