Initial claims system setup

This commit is contained in:
Victor Andersson
2025-11-08 16:54:46 +01:00
commit 9619dbedcb
31 changed files with 1440 additions and 0 deletions

0
claims/__init__.py Normal file
View File

32
claims/admin.py Normal file
View File

@@ -0,0 +1,32 @@
from django.contrib import admin
from .models import Claim, ClaimLog, Project
class ClaimLogInline(admin.TabularInline):
model = ClaimLog
extra = 0
readonly_fields = ("action", "from_status", "to_status", "note", "performed_by", "created_at")
can_delete = False
@admin.register(Claim)
class ClaimAdmin(admin.ModelAdmin):
list_display = ("full_name", "amount", "currency", "project", "status", "created_at", "submitted_by")
list_filter = ("status", "created_at", "project")
search_fields = ("full_name", "email", "description")
readonly_fields = ("created_at", "updated_at")
inlines = [ClaimLogInline]
@admin.register(ClaimLog)
class ClaimLogAdmin(admin.ModelAdmin):
list_display = ("claim", "action", "from_status", "to_status", "performed_by", "created_at")
list_filter = ("action", "to_status", "created_at")
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ("name", "code", "is_active", "updated_at")
list_filter = ("is_active",)
search_fields = ("name", "code")

6
claims/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ClaimsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'claims'

107
claims/forms.py Normal file
View File

@@ -0,0 +1,107 @@
from django import forms
from django.contrib.auth import get_user_model, password_validation
from django.core.exceptions import ValidationError
from .models import Claim, Project
User = get_user_model()
class ClaimantForm(forms.Form):
full_name = forms.CharField(max_length=255, label="Namn")
email = forms.EmailField(label="E-post")
account_number = forms.CharField(max_length=50, label="Kontonummer")
class ClaimLineForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.is_bound:
self.fields["currency"].initial = Claim.Currency.SEK
self.fields["project"].queryset = Project.objects.filter(is_active=True).order_by("name")
self.fields["project"].required = False
class Meta:
model = Claim
fields = ["description", "amount", "currency", "project", "receipt"]
labels = {
"description": "Beskrivning",
"amount": "Belopp",
"currency": "Valuta",
"project": "Evenemang/Projekt",
"receipt": "Kvitto",
}
widgets = {
"description": forms.Textarea(attrs={"rows": 3}),
}
class ClaimDecisionForm(forms.Form):
ACTION_APPROVE = "approve"
ACTION_REJECT = "reject"
ACTION_CHOICES = (
(ACTION_APPROVE, "Godkänn"),
(ACTION_REJECT, "Neka"),
)
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"}),
)
def clean(self):
cleaned = super().clean()
action = cleaned.get("action")
note = cleaned.get("decision_note", "").strip()
if action == self.ACTION_REJECT and not note:
self.add_error("decision_note", "Kommentar krävs när du nekar ett utlägg.")
return cleaned
class UserManagementForm(forms.Form):
username = forms.CharField(max_length=150, label="Användarnamn")
email = forms.EmailField(required=False, label="E-post")
first_name = forms.CharField(max_length=150, required=False, label="Förnamn")
last_name = forms.CharField(max_length=150, required=False, label="Efternamn")
password1 = forms.CharField(widget=forms.PasswordInput, label="Lösenord")
password2 = forms.CharField(widget=forms.PasswordInput, label="Bekräfta lösenord")
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")
def clean_username(self):
username = self.cleaned_data["username"]
if User.objects.filter(username=username).exists():
raise forms.ValidationError("Användarnamnet är upptaget.")
return username
def clean(self):
cleaned = super().clean()
if cleaned.get("password1") != cleaned.get("password2"):
self.add_error("password2", "Lösenorden matchar inte.")
password = cleaned.get("password1")
if password:
temp_user = User(
username=cleaned.get("username", ""),
email=cleaned.get("email", ""),
first_name=cleaned.get("first_name", ""),
last_name=cleaned.get("last_name", ""),
)
try:
password_validation.validate_password(password, temp_user)
except ValidationError as exc:
self.add_error("password1", exc)
return cleaned
class UserPermissionForm(forms.Form):
user_id = forms.IntegerField(widget=forms.HiddenInput)
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")
class DeleteUserForm(forms.Form):
user_id = forms.IntegerField(widget=forms.HiddenInput)

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-11-08 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Claim',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_name', models.CharField(max_length=255)),
('email', models.EmailField(max_length=254)),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('description', models.TextField(help_text='Describe what the reimbursement is for')),
('account_number', models.CharField(max_length=50)),
('receipt', models.FileField(blank=True, null=True, upload_to='receipts/')),
('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=20)),
('decision_note', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.8 on 2025-11-08 14:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='claim',
name='submitted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='claims_submitted', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='ClaimLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('created', 'Submitted'), ('status_changed', 'Status changed')], max_length=32)),
('from_status', models.CharField(blank=True, choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], max_length=20, null=True)),
('to_status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], max_length=20)),
('note', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('claim', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='claims.claim')),
('performed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='claim_logs', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-11-08 15:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0002_claim_submitted_by_claimlog'),
]
operations = [
migrations.AddField(
model_name='claim',
name='currency',
field=models.CharField(choices=[('SEK', 'Swedish krona (SEK)'), ('EUR', 'Euro (EUR)'), ('USD', 'US dollar (USD)'), ('GBP', 'British pound (GBP)')], default='SEK', max_length=3),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-11-08 15:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0003_claim_currency'),
]
operations = [
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('code', models.CharField(blank=True, max_length=50)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='claim',
name='project',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='claims', to='claims.project'),
),
]

View File

110
claims/models.py Normal file
View File

@@ -0,0 +1,110 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class Project(models.Model):
name = models.CharField(max_length=255)
code = models.CharField(max_length=50, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self):
if self.code:
return f"{self.code} {self.name}"
return self.name
class Claim(models.Model):
class Status(models.TextChoices):
PENDING = "pending", _("Pending")
APPROVED = "approved", _("Approved")
REJECTED = "rejected", _("Rejected")
class Currency(models.TextChoices):
SEK = "SEK", _("Swedish krona (SEK)")
EUR = "EUR", _("Euro (EUR)")
USD = "USD", _("US dollar (USD)")
GBP = "GBP", _("British pound (GBP)")
submitted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="claims_submitted",
)
full_name = models.CharField(max_length=255)
email = models.EmailField()
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(
max_length=3,
choices=Currency.choices,
default=Currency.SEK,
)
description = models.TextField(help_text=_("Describe what the reimbursement is for"))
account_number = models.CharField(max_length=50)
receipt = models.FileField(upload_to="receipts/", blank=True, null=True)
project = models.ForeignKey(
Project,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="claims",
)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
decision_note = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
project = f" [{self.project}]" if self.project else ""
return f"{self.full_name} {self.amount} {self.currency}{project} ({self.get_status_display()})"
def add_log(self, *, action, performed_by=None, from_status=None, to_status=None, note=""):
return ClaimLog.objects.create(
claim=self,
action=action,
from_status=from_status,
to_status=to_status or self.status,
note=note or "",
performed_by=performed_by,
)
class ClaimLog(models.Model):
class Action(models.TextChoices):
CREATED = "created", _("Submitted")
STATUS_CHANGED = "status_changed", _("Status changed")
claim = models.ForeignKey(Claim, related_name="logs", on_delete=models.CASCADE)
action = models.CharField(max_length=32, choices=Action.choices)
from_status = models.CharField(
max_length=20,
choices=Claim.Status.choices,
null=True,
blank=True,
)
to_status = models.CharField(max_length=20, choices=Claim.Status.choices)
note = models.TextField(blank=True)
performed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="claim_logs",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.get_action_display()} ({self.created_at:%Y-%m-%d %H:%M})"

View File

@@ -0,0 +1,113 @@
{% extends "claims/base.html" %}
{% block title %}Admin Utlägg{% endblock %}
{% block content %}
<h1>Inkomna utlägg</h1>
<p>Endast användare med behörighet att se utlägg kommer åt den här sidan.</p>
<div>
<strong>Filtrera:</strong>
<a href="?status=all"{% if status_filter == "all" %} aria-current="page"{% endif %}>Alla</a>
{% for value, label in status_choices %}
|
<a href="?status={{ value }}"{% if status_filter == value %} aria-current="page"{% endif %}>
{{ label }}
</a>
{% endfor %}
</div>
<table>
<thead>
<tr>
<th>Person & kontakt</th>
<th>Belopp</th>
<th>Projekt</th>
<th>Status</th>
<th>Kvittens</th>
<th>Logg</th>
<th>Senast uppdaterad</th>
{% if can_change %}<th>Åtgärd</th>{% endif %}
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td>
<strong>{{ claim.full_name }}</strong><br>
{{ claim.email }}<br>
Konto: {{ claim.account_number }}<br>
<em>{{ claim.description|linebreaksbr }}</em>
<div>
{% if claim.submitted_by %}
<small>Inskickad av inloggad användare: {{ claim.submitted_by.get_username }}</small>
{% else %}
<small>Inskickad av gäst</small>
{% endif %}
</div>
</td>
<td>{{ claim.amount }} {{ claim.currency }}</td>
<td>{{ claim.project|default:"-" }}</td>
<td>
{{ claim.get_status_display }}<br>
{% if claim.decision_note %}<small>Kommentar: {{ claim.decision_note }}</small>{% endif %}
</td>
<td>
{% if claim.receipt %}
<a href="{{ claim.receipt.url }}" target="_blank" rel="noopener">Visa fil</a>
{% else %}
{% endif %}
</td>
<td>
<details>
<summary>Visa logg</summary>
<ul>
{% for log in claim.logs.all %}
<li>
{{ log.created_at|date:"Y-m-d H:i" }}
{{ log.get_action_display }}:
{% if log.from_status %}{{ log.get_from_status_display }} → {% endif %}
{{ log.get_to_status_display }}
{% if log.performed_by %}
(av {{ log.performed_by.get_username }})
{% endif %}
{% if log.note %}
"{{ log.note }}"
{% endif %}
</li>
{% empty %}
<li>Ingen logg än.</li>
{% endfor %}
</ul>
</details>
</td>
<td>{{ claim.updated_at|date:"Y-m-d H:i" }}</td>
{% if can_change %}
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="claim_id" value="{{ claim.id }}">
<label>
Åtgärd
<select name="action">
{% for value, label in decision_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
Kommentar
<textarea name="decision_note" rows="2">{{ claim.decision_note }}</textarea>
</label>
<button type="submit">Uppdatera</button>
</form>
</td>
{% endif %}
</tr>
{% empty %}
<tr><td colspan="{% if can_change %}8{% else %}7{% endif %}">Inga utlägg än.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Claims{% endblock %}</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
form { max-width: 480px; display: grid; gap: 1rem; }
label { font-weight: 600; }
input, textarea { padding: 0.5rem; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; }
</style>
</head>
<body>
<nav>
<a href="{% url 'claims:submit' %}">Skicka utlägg</a> |
<a href="{% url 'claims:admin-list' %}">Admin</a> |
<a href="{% url 'claims:export' %}">Export</a> |
{% if user.is_authenticated %}
<a href="{% url 'claims:my-claims' %}">Mina utlägg</a> |
{% if perms.auth.view_user %}
<a href="{% url 'claims:user-manage' %}">Användare</a> |
{% endif %}
{% if user.is_staff %}
<a href="{% url 'admin:index' %}">Kontohantering</a> |
{% endif %}
Inloggad som {{ user.get_username }}
<form action="{% url 'logout' %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit">Logga ut</button>
</form>
{% else %}
<a href="{% url 'login' %}">Logga in</a>
{% endif %}
</nav>
<hr>
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% block content %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,20 @@
{% extends "claims/base.html" %}
{% block title %}Export{% endblock %}
{% block content %}
<h1>Export till redovisningssystem</h1>
<p>Detta är ett framtida steg. Här kommer du att kunna:</p>
<ul>
<li>Välja tidsperiod eller status</li>
<li>Exportera till t.ex. bankfil eller SIE</li>
<li>Skicka data via API till externa system</li>
</ul>
<p>Planerade åtgärder:</p>
<ol>
<li>Definiera format</li>
<li>Implementera exportkommando/API</li>
<li>Bygga integrationsinställningar</li>
</ol>
<p>Tills vidare kan du ladda ner data via Django admin eller med ett enkelt SQL-utdrag.</p>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "claims/base.html" %}
{% block title %}Mina utlägg{% endblock %}
{% block content %}
<h1>Mina utlägg</h1>
<p>Här ser du status för de utlägg du skickat in när du varit inloggad.</p>
{% if claims %}
<table>
<thead>
<tr>
<th>Skickad</th>
<th>Beskrivning</th>
<th>Belopp</th>
<th>Projekt</th>
<th>Status</th>
<th>Kvitto</th>
<th>Logg</th>
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td>{{ claim.created_at|date:"Y-m-d H:i" }}</td>
<td>{{ claim.description|linebreaksbr }}</td>
<td>{{ claim.amount }} {{ claim.currency }}</td>
<td>{{ claim.project|default:"-" }}</td>
<td>{{ claim.get_status_display }}</td>
<td>
{% if claim.receipt %}
<a href="{{ claim.receipt.url }}" target="_blank" rel="noopener">Visa fil</a>
{% else %}
{% endif %}
</td>
<td>
<details>
<summary>Visa logg</summary>
<ul>
{% for log in claim.logs.all %}
<li>
{{ log.created_at|date:"Y-m-d H:i" }}
{{ log.get_action_display }}
{% if log.from_status %} ({{ log.get_from_status_display }} → {{ log.get_to_status_display }}){% endif %}
{% if log.note %}
"{{ log.note }}"
{% endif %}
</li>
{% empty %}
<li>Ingen logg än.</li>
{% endfor %}
</ul>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Du har inte skickat in några utlägg ännu eller så gjordes de utan inloggning.</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "claims/base.html" %}
{% block title %}Skicka utlägg{% endblock %}
{% block content %}
<h1>Skicka in utlägg</h1>
<p>Formuläret är öppet för alla. Du kan fylla i flera rader innan du skickar in.</p>
<p>Behöver du fler rader än som visas? Lägg till <code>?forms=n</code> i URL:en (max {{ max_extra_forms }}).</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<legend>Dina uppgifter</legend>
{{ claimant_form.as_p }}
</fieldset>
{{ formset.management_form }}
{% for form in formset %}
<fieldset>
<legend>Utlägg {{ forloop.counter }}</legend>
{{ form.non_field_errors }}
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
<p>
{{ form.description.label_tag }}
{{ form.description }}
{{ form.description.errors }}
</p>
<p>
{{ form.amount.label_tag }}
{{ form.amount }}
{{ form.amount.errors }}
</p>
<details>
<summary>Avancerat: ändra valuta (standard SEK)</summary>
<p>
{{ form.currency.label_tag }}
{{ form.currency }}
{{ form.currency.errors }}
</p>
<p>
{{ form.project.label_tag }}
{{ form.project }}
{{ form.project.errors }}
</p>
</details>
<p>
{{ form.receipt.label_tag }}
{{ form.receipt }}
{{ form.receipt.errors }}
</p>
</fieldset>
{% endfor %}
<button type="submit">Skicka in utlägg</button>
</form>
<p>När du skickar formuläret lotsas du till adminvyn. Saknar du inloggning får du möjlighet att logga in.</p>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "claims/base.html" %}
{% block title %}Användarhantering{% endblock %}
{% block content %}
<h1>Användare & behörigheter</h1>
<p>Skapa nya konton, underhåll behörigheter och ta bort användare kopplat till utläggssystemet.</p>
<p><small>Notis: sidan hanterar direkta behörigheter. Behörigheter via grupper eller superuser-status gäller även om kryssrutorna avmarkeras.</small></p>
<section>
<h2>Skapa ny användare</h2>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="create">
{{ create_form.as_p }}
<button type="submit">Skapa användare</button>
</form>
</section>
<hr>
<section>
<h2>Befintliga användare</h2>
<table>
<thead>
<tr>
<th>Användare</th>
<th>Namn</th>
<th>E-post</th>
<th>Behörigheter</th>
<th>Ta bort</th>
</tr>
</thead>
<tbody>
{% for row in user_rows %}
{% with user=row.user %}
<tr>
<td>
{{ user.username }}
{% if user.is_superuser %}<span title="Superuser"></span>{% endif %}
</td>
<td>{{ user.get_full_name|default:"-" }}</td>
<td>{{ user.email|default:"-" }}</td>
<td>
{% with form=row.permission_form %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="update">
{{ form.user_id }}
<label>{{ form.is_staff }} {{ form.is_staff.label }}</label><br>
<label>{{ form.grant_view }} {{ form.grant_view.label }}</label><br>
<label>{{ form.grant_change }} {{ form.grant_change.label }}</label><br>
<button type="submit">Spara</button>
</form>
{% endwith %}
</td>
<td>
{% if row.delete_form %}
<form method="post" onsubmit="return confirm('Ta bort {{ user.username }}?');">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
{{ row.delete_form.user_id }}
<button type="submit">Ta bort</button>
</form>
{% else %}
<em></em>
{% endif %}
</td>
</tr>
{% endwith %}
{% empty %}
<tr><td colspan="5">Inga användare upplagda.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

3
claims/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

19
claims/urls.py Normal file
View File

@@ -0,0 +1,19 @@
from django.urls import path
from .views import (
ClaimAdminListView,
ClaimExportMenuView,
MyClaimsView,
SubmitClaimView,
UserManagementView,
)
app_name = "claims"
urlpatterns = [
path("new/", SubmitClaimView.as_view(), name="submit"),
path("admin/", ClaimAdminListView.as_view(), name="admin-list"),
path("export/", ClaimExportMenuView.as_view(), name="export"),
path("mine/", MyClaimsView.as_view(), name="my-claims"),
path("users/", UserManagementView.as_view(), name="user-manage"),
]

308
claims/views.py Normal file
View File

@@ -0,0 +1,308 @@
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.forms import formset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views import View
from django.views.generic import ListView, TemplateView
from .forms import (
ClaimDecisionForm,
ClaimLineForm,
ClaimantForm,
DeleteUserForm,
UserManagementForm,
UserPermissionForm,
)
from .models import Claim, ClaimLog
User = get_user_model()
class SubmitClaimView(View):
template_name = "claims/submit_claim.html"
max_extra_forms = 5
def get_extra_forms(self):
try:
count = int(self.request.GET.get("forms", 2))
except (TypeError, ValueError):
count = 2
return max(1, min(count, self.max_extra_forms))
def build_formset(self, *, data=None, files=None, extra=0):
FormSet = formset_factory(
ClaimLineForm,
extra=extra,
min_num=1,
validate_min=True,
)
return FormSet(data=data, files=files, prefix="claim_lines")
def get_claimant_initial(self):
initial = {}
user = self.request.user
if user.is_authenticated:
initial["full_name"] = user.get_full_name() or user.get_username()
initial["email"] = user.email
last_claim = user.claims_submitted.order_by("-created_at").first()
if last_claim:
initial["account_number"] = last_claim.account_number
return initial
def get(self, request):
extra = self.get_extra_forms()
formset = self.build_formset(extra=extra)
claimant_form = ClaimantForm(initial=self.get_claimant_initial())
return render(
request,
self.template_name,
{
"formset": formset,
"claimant_form": claimant_form,
"extra_forms": extra,
"max_extra_forms": self.max_extra_forms,
},
)
def post(self, request):
formset = self.build_formset(data=request.POST, files=request.FILES)
claimant_form = ClaimantForm(request.POST)
if formset.is_valid() and claimant_form.is_valid():
claimant_data = claimant_form.cleaned_data
created = 0
for form in formset:
if not form.cleaned_data:
continue
description = form.cleaned_data.get("description")
amount = form.cleaned_data.get("amount")
if not description or amount is None:
continue
claim = Claim(
full_name=claimant_data["full_name"],
email=claimant_data["email"],
account_number=claimant_data["account_number"],
amount=amount,
currency=form.cleaned_data.get("currency") or Claim.Currency.SEK,
description=description,
receipt=form.cleaned_data.get("receipt"),
project=form.cleaned_data.get("project"),
submitted_by=request.user if request.user.is_authenticated else None,
)
claim.save()
claim.add_log(
action=ClaimLog.Action.CREATED,
performed_by=claim.submitted_by,
to_status=Claim.Status.PENDING,
)
created += 1
if created:
messages.success(request, f"{created} utlägg skickade in.")
return redirect(reverse("claims:admin-list"))
messages.error(request, "Inga utlägg kunde sparas. Fyll i minst en rad.")
else:
messages.error(request, "Kunde inte spara utläggen. Kontrollera formuläret.")
extra = min(formset.total_form_count(), self.max_extra_forms)
return render(
request,
self.template_name,
{
"formset": formset,
"claimant_form": claimant_form,
"extra_forms": extra,
"max_extra_forms": self.max_extra_forms,
},
)
class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
template_name = "claims/admin_list.html"
context_object_name = "claims"
permission_required = "claims.view_claim"
def get_queryset(self):
queryset = (
Claim.objects.select_related("submitted_by", "project")
.prefetch_related("logs__performed_by")
.all()
)
status = self.request.GET.get("status")
if status in {choice[0] for choice in Claim.Status.choices}:
queryset = queryset.filter(status=status)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["status_filter"] = self.request.GET.get("status", "all")
context["status_choices"] = Claim.Status.choices
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
context["can_change"] = self.request.user.has_perm("claims.change_claim")
return context
def post(self, request, *args, **kwargs):
if not request.user.has_perm("claims.change_claim"):
messages.error(request, "Du har inte behörighet att uppdatera utlägg.")
return redirect(request.get_full_path())
form = ClaimDecisionForm(request.POST)
if not form.is_valid():
for field_errors in form.errors.values():
for error in field_errors:
messages.error(request, error)
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=form.cleaned_data["claim_id"])
action = form.cleaned_data["action"]
decision_note = form.cleaned_data.get("decision_note", "")
previous_status = claim.status
claim.decision_note = decision_note
if action == ClaimDecisionForm.ACTION_APPROVE:
claim.status = Claim.Status.APPROVED
messages.success(request, f"{claim} markerades som godkänd.")
else:
claim.status = Claim.Status.REJECTED
messages.warning(request, f"{claim} markerades som nekad.")
claim.save(update_fields=["status", "decision_note", "updated_at"])
claim.add_log(
action=ClaimLog.Action.STATUS_CHANGED,
performed_by=request.user,
from_status=previous_status,
to_status=claim.status,
note=decision_note,
)
return redirect(request.get_full_path())
class ClaimExportMenuView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView):
template_name = "claims/export_placeholder.html"
permission_required = "claims.view_claim"
class MyClaimsView(LoginRequiredMixin, ListView):
template_name = "claims/my_claims.html"
context_object_name = "claims"
def get_queryset(self):
return (
Claim.objects.filter(submitted_by=self.request.user)
.select_related("project")
.prefetch_related("logs__performed_by")
.order_by("-created_at")
)
class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView):
template_name = "claims/user_management.html"
permission_required = "auth.view_user"
def _ensure_perm(self, perm_codename):
perm = f"auth.{perm_codename}"
if not self.request.user.has_perm(perm):
messages.error(self.request, "Du saknar behörighet för åtgärden.")
return False
return True
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
users = User.objects.order_by("username")
rows = []
for user in users:
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"),
}
),
"delete_form": None
if user == self.request.user or user.is_superuser
else DeleteUserForm(initial={"user_id": user.id}),
}
)
context["user_rows"] = rows
context["create_form"] = kwargs.get("create_form") or UserManagementForm()
return context
def post(self, request, *args, **kwargs):
action = request.POST.get("action")
if action == "create":
if not self._ensure_perm("add_user"):
return redirect(request.path)
form = UserManagementForm(request.POST)
if form.is_valid():
user = User.objects.create_user(
username=form.cleaned_data["username"],
password=form.cleaned_data["password1"],
email=form.cleaned_data.get("email", ""),
first_name=form.cleaned_data.get("first_name", ""),
last_name=form.cleaned_data.get("last_name", ""),
is_staff=form.cleaned_data.get("is_staff", False),
)
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))
messages.success(request, f"Användaren {user.username} skapades.")
return redirect(request.path)
return self.render_to_response(self.get_context_data(create_form=form))
elif action == "update":
if not self._ensure_perm("change_user"):
return redirect(request.path)
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"]:
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"])
self._set_perm(user, "claims.view_claim", form.cleaned_data["grant_view"])
self._set_perm(user, "claims.change_claim", form.cleaned_data["grant_change"])
messages.success(request, f"Behörigheter uppdaterades för {user.username}.")
else:
messages.error(request, "Kunde inte uppdatera behörigheter.")
return redirect(request.path)
elif action == "delete":
if not self._ensure_perm("delete_user"):
return redirect(request.path)
form = DeleteUserForm(request.POST)
if form.is_valid():
user = get_object_or_404(User, pk=form.cleaned_data["user_id"])
if user == request.user:
messages.error(request, "Du kan inte ta bort ditt eget konto.")
elif user.is_superuser:
messages.error(request, "Du kan inte ta bort en superuser via detta gränssnitt.")
else:
user.delete()
messages.warning(request, "Användaren togs bort.")
return redirect(request.path)
messages.error(request, "Okänd åtgärd.")
return redirect(request.path)
@staticmethod
def _set_perm(user, perm_label, should_have):
app_label, codename = perm_label.split(".")
perm = Permission.objects.filter(
content_type__app_label=app_label,
codename=codename,
).first()
if not perm:
return
if should_have:
user.user_permissions.add(perm)
else:
user.user_permissions.remove(perm)