Initial claims system setup
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.sqlite3
|
||||||
|
db.sqlite3
|
||||||
|
/claims_system/__pycache__/
|
||||||
|
/claims_system/*.pyc
|
||||||
|
/claims_system/migrations/__pycache__/
|
||||||
|
/claims_system/migrations/*.pyc
|
||||||
|
.DS_Store
|
||||||
|
.python-version
|
||||||
|
media/
|
||||||
36
AGENTS.md
Normal file
36
AGENTS.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# claims-system – Agentinstruktioner
|
||||||
|
|
||||||
|
## Syfte
|
||||||
|
Bygg ett webbaserat system för hantering av utlägg (”claims”) åt en organisation. Oinloggade användare ska kunna lämna in ett formulär för att begära ersättning. Administratörer loggar in, granskar listan med claims och tar beslut (godkänn/avslå). Systemet ska förberedas för integrationer så att data kan exporteras till bank- och redovisningssystem.
|
||||||
|
|
||||||
|
## Teknikval och verktyg
|
||||||
|
- **Ramverk:** Django.
|
||||||
|
- **Pakethantering & virtuella miljöer:** uv (kör `uv add`, `uv run`, `uv sync` osv).
|
||||||
|
- **Databas:** SQLite i utveckling. Förbered kodbasen för PostgreSQL i produktion (t.ex. via miljövariabler).
|
||||||
|
- **Versionshantering:** Git, använd befintligt repo (initierat i detta steg).
|
||||||
|
|
||||||
|
## Arkitektur och kodprinciper
|
||||||
|
1. Skapa en dedikerad Django-app (t.ex. `claims`) för domänlogiken.
|
||||||
|
2. Claims behöver modell med statusfält (t.ex. `PENDING`, `APPROVED`, `REJECTED`) och metadata om belopp, syfte, kostnadsställe samt fält för kvittounderlag (filuppladdning).
|
||||||
|
3. Exponera ett offentligt formulär (utan inloggning) där claimers anger kontaktuppgifter, kontonummer och laddar upp kvitto.
|
||||||
|
4. Använd Django admin + en enkel intern vy för att lista och uppdatera claims (godkänna/avslå).
|
||||||
|
5. Förbered ett integrationslager (t.ex. tjänst eller management command) som kan exportera claims till externa system. Det räcker initialt med en tydlig struktur + TODO.
|
||||||
|
6. Dokumentera konfiguration (miljövariabler, hur man startar servern, kör migreringar, exportflöden) i README.
|
||||||
|
7. Stöd flera rader i samma inskick, koppla dem till samma användare samt logga varje statusändring.
|
||||||
|
8. Tillåt val av valuta per claimrad (default SEK) men håll valet dolt/avancerat för enklare UX.
|
||||||
|
9. Tillhandahåll en intern vy som låter användare med rätt behörighet skapa/uppdatera/ta bort konton och toggla `claims.view_claim`/`claims.change_claim`.
|
||||||
|
10. Claims ska kopplas till ett projekt/evenemang; projekten hanteras via Django admin.
|
||||||
|
|
||||||
|
## Säkerhet och drift
|
||||||
|
- Skydda admin-flöden bakom inloggning.
|
||||||
|
- Håll känsliga värden (SECRET_KEY, databaskredentialer) i miljövariabler.
|
||||||
|
- Automatisk migrering ska fungera lokalt via `uv run python manage.py migrate`.
|
||||||
|
|
||||||
|
## Nästa steg (högnivå)
|
||||||
|
1. Skapa Django-appen `claims`, modellera databastabellen och registrera den i admin.
|
||||||
|
2. Implementera det offentliga claim-formuläret med validering och filuppladdning.
|
||||||
|
3. Lägg till vyer/templates för att lista och besluta claims i admin/UI.
|
||||||
|
4. Skapa exportyta (API-endpoint eller filgenerering) och dokumentera hur integrationer kopplas på.
|
||||||
|
5. Skriv tester för modell, formulär och beslutsflöden.
|
||||||
|
|
||||||
|
Följ ovanstående riktlinjer i fortsatt utveckling. Var konsekvent med uv-kommandon och håll koden modulär så att integrationer kan läggas till utan större omskrivningar.
|
||||||
21
README.md
Normal file
21
README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
## claims-system
|
||||||
|
|
||||||
|
### Kom igång
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run python manage.py migrate
|
||||||
|
uv run python manage.py createsuperuser
|
||||||
|
uv run python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
- Offentligt formulär: `http://localhost:8000/claims/new/`
|
||||||
|
- Sidan börjar med ett block där användaren skriver sina uppgifter (för inloggade fylls namn/e‑post + senaste kontonummer i automatiskt). Själva utläggsraderna kan fyllas i flera åt gången via formset (lägg till `?forms=n` för fler rader, max 5).
|
||||||
|
- Varje rad har en dold valuta-väljare. Standard är SEK men EUR/USD/GBP går att välja vid behov.
|
||||||
|
- Välj även vilket projekt/evenemang utlägget hör till (valen hämtas från Django admin > Projekt).
|
||||||
|
- Adminlista (kräver `claims.view_claim`, uppdateringar kräver `claims.change_claim`): `http://localhost:8000/claims/admin/`
|
||||||
|
- Adminlistan visar kvittolänk, vem som skickade in (och om det var en inloggad användare) samt en logg över alla statusändringar.
|
||||||
|
- Export-meny (placeholder för framtida integrationer): `http://localhost:8000/claims/export/`
|
||||||
|
- Inloggade användare kan följa sina egna claim via `http://localhost:8000/claims/mine/`.
|
||||||
|
- Behörighets- och kontohantering (visa kräver `auth.view_user`, skapa/uppdatera/ta bort kräver respektive `auth.add_user`/`auth.change_user`/`auth.delete_user`): `http://localhost:8000/claims/users/`
|
||||||
|
- Django auth-vyer (login/logout) exponeras under `/accounts/`.
|
||||||
|
- Använd Django admin (`/admin/`) för att skapa konton, lägga användare i grupper, lägga upp projekt/evenemang samt tilldela behörigheterna `claims.view_claim` och `claims.change_claim`. Superusers har full kontroll per default.
|
||||||
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)
|
||||||
0
claims_system/__init__.py
Normal file
0
claims_system/__init__.py
Normal file
16
claims_system/asgi.py
Normal file
16
claims_system/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for claims_system project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'claims_system.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
127
claims_system/settings.py
Normal file
127
claims_system/settings.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Django settings for claims_system project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.8.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = 'django-insecure-b^^9k#=p@8t$^a5s8k^)mtkkc3qv@8gz#l@@)-000ug-%jw(0j'
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'claims',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'claims_system.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'claims_system.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL = '/claims/admin/'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
31
claims_system/urls.py
Normal file
31
claims_system/urls.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for claims_system project.
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||||
|
Examples:
|
||||||
|
Function views
|
||||||
|
1. Add an import: from my_app import views
|
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||||
|
Class-based views
|
||||||
|
1. Add an import: from other_app.views import Home
|
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||||
|
Including another URLconf
|
||||||
|
1. Import the include() function: from django.urls import include, path
|
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('claims/', include('claims.urls')),
|
||||||
|
path('accounts/', include('django.contrib.auth.urls')),
|
||||||
|
path('', RedirectView.as_view(pattern_name='claims:submit', permanent=False)),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
16
claims_system/wsgi.py
Normal file
16
claims_system/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for claims_system project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'claims_system.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
22
manage.py
Executable file
22
manage.py
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'claims_system.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "claims-system"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"django>=5.2.8",
|
||||||
|
]
|
||||||
13
templates/registration/login.html
Normal file
13
templates/registration/login.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "claims/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Logga in{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Logga in</h1>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit">Logga in</button>
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
55
uv.lock
generated
Normal file
55
uv.lock
generated
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asgiref"
|
||||||
|
version = "3.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "claims-system"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [{ name = "django", specifier = ">=5.2.8" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django"
|
||||||
|
version = "5.2.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "sqlparse" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/a2/933dbbb3dd9990494960f6e64aca2af4c0745b63b7113f59a822df92329e/django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", size = 10849032, upload-time = "2025-11-05T14:07:32.778Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/3d/a035a4ee9b1d4d4beee2ae6e8e12fe6dee5514b21f62504e22efcbd9fb46/django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f", size = 8289692, upload-time = "2025-11-05T14:07:28.761Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparse"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2025.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user