Initial claims system setup
This commit is contained in:
0
claims/__init__.py
Normal file
0
claims/__init__.py
Normal file
32
claims/admin.py
Normal file
32
claims/admin.py
Normal 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
6
claims/apps.py
Normal 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
107
claims/forms.py
Normal 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)
|
||||
33
claims/migrations/0001_initial.py
Normal file
33
claims/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
37
claims/migrations/0002_claim_submitted_by_claimlog.py
Normal file
37
claims/migrations/0002_claim_submitted_by_claimlog.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
claims/migrations/0003_claim_currency.py
Normal file
18
claims/migrations/0003_claim_currency.py
Normal 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),
|
||||
),
|
||||
]
|
||||
33
claims/migrations/0004_project_claim_project.py
Normal file
33
claims/migrations/0004_project_claim_project.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
claims/migrations/__init__.py
Normal file
0
claims/migrations/__init__.py
Normal file
110
claims/models.py
Normal file
110
claims/models.py
Normal 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})"
|
||||
113
claims/templates/claims/admin_list.html
Normal file
113
claims/templates/claims/admin_list.html
Normal 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 %}
|
||||
48
claims/templates/claims/base.html
Normal file
48
claims/templates/claims/base.html
Normal 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>
|
||||
20
claims/templates/claims/export_placeholder.html
Normal file
20
claims/templates/claims/export_placeholder.html
Normal 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 %}
|
||||
63
claims/templates/claims/my_claims.html
Normal file
63
claims/templates/claims/my_claims.html
Normal 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 %}
|
||||
54
claims/templates/claims/submit_claim.html
Normal file
54
claims/templates/claims/submit_claim.html
Normal 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 %}
|
||||
78
claims/templates/claims/user_management.html
Normal file
78
claims/templates/claims/user_management.html
Normal 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
3
claims/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
19
claims/urls.py
Normal file
19
claims/urls.py
Normal 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
308
claims/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user