From 9619dbedcbf6000fc090d4ac86079bb07dad1ce1 Mon Sep 17 00:00:00 2001 From: Victor Andersson Date: Sat, 8 Nov 2025 16:54:46 +0100 Subject: [PATCH] Initial claims system setup --- .gitignore | 12 + AGENTS.md | 36 ++ README.md | 21 ++ claims/__init__.py | 0 claims/admin.py | 32 ++ claims/apps.py | 6 + claims/forms.py | 107 ++++++ claims/migrations/0001_initial.py | 33 ++ .../0002_claim_submitted_by_claimlog.py | 37 +++ claims/migrations/0003_claim_currency.py | 18 + .../migrations/0004_project_claim_project.py | 33 ++ claims/migrations/__init__.py | 0 claims/models.py | 110 +++++++ claims/templates/claims/admin_list.html | 113 +++++++ claims/templates/claims/base.html | 48 +++ .../templates/claims/export_placeholder.html | 20 ++ claims/templates/claims/my_claims.html | 63 ++++ claims/templates/claims/submit_claim.html | 54 +++ claims/templates/claims/user_management.html | 78 +++++ claims/tests.py | 3 + claims/urls.py | 19 ++ claims/views.py | 308 ++++++++++++++++++ claims_system/__init__.py | 0 claims_system/asgi.py | 16 + claims_system/settings.py | 127 ++++++++ claims_system/urls.py | 31 ++ claims_system/wsgi.py | 16 + manage.py | 22 ++ pyproject.toml | 9 + templates/registration/login.html | 13 + uv.lock | 55 ++++ 31 files changed, 1440 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 claims/__init__.py create mode 100644 claims/admin.py create mode 100644 claims/apps.py create mode 100644 claims/forms.py create mode 100644 claims/migrations/0001_initial.py create mode 100644 claims/migrations/0002_claim_submitted_by_claimlog.py create mode 100644 claims/migrations/0003_claim_currency.py create mode 100644 claims/migrations/0004_project_claim_project.py create mode 100644 claims/migrations/__init__.py create mode 100644 claims/models.py create mode 100644 claims/templates/claims/admin_list.html create mode 100644 claims/templates/claims/base.html create mode 100644 claims/templates/claims/export_placeholder.html create mode 100644 claims/templates/claims/my_claims.html create mode 100644 claims/templates/claims/submit_claim.html create mode 100644 claims/templates/claims/user_management.html create mode 100644 claims/tests.py create mode 100644 claims/urls.py create mode 100644 claims/views.py create mode 100644 claims_system/__init__.py create mode 100644 claims_system/asgi.py create mode 100644 claims_system/settings.py create mode 100644 claims_system/urls.py create mode 100644 claims_system/wsgi.py create mode 100755 manage.py create mode 100644 pyproject.toml create mode 100644 templates/registration/login.html create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..874f1fb --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a522c4 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..89202ac --- /dev/null +++ b/README.md @@ -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. diff --git a/claims/__init__.py b/claims/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/claims/admin.py b/claims/admin.py new file mode 100644 index 0000000..175404b --- /dev/null +++ b/claims/admin.py @@ -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") diff --git a/claims/apps.py b/claims/apps.py new file mode 100644 index 0000000..3b372c8 --- /dev/null +++ b/claims/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ClaimsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'claims' diff --git a/claims/forms.py b/claims/forms.py new file mode 100644 index 0000000..a668a70 --- /dev/null +++ b/claims/forms.py @@ -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) diff --git a/claims/migrations/0001_initial.py b/claims/migrations/0001_initial.py new file mode 100644 index 0000000..64cf8e5 --- /dev/null +++ b/claims/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/claims/migrations/0002_claim_submitted_by_claimlog.py b/claims/migrations/0002_claim_submitted_by_claimlog.py new file mode 100644 index 0000000..9ef5447 --- /dev/null +++ b/claims/migrations/0002_claim_submitted_by_claimlog.py @@ -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'], + }, + ), + ] diff --git a/claims/migrations/0003_claim_currency.py b/claims/migrations/0003_claim_currency.py new file mode 100644 index 0000000..da4d611 --- /dev/null +++ b/claims/migrations/0003_claim_currency.py @@ -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), + ), + ] diff --git a/claims/migrations/0004_project_claim_project.py b/claims/migrations/0004_project_claim_project.py new file mode 100644 index 0000000..eaff523 --- /dev/null +++ b/claims/migrations/0004_project_claim_project.py @@ -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'), + ), + ] diff --git a/claims/migrations/__init__.py b/claims/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/claims/models.py b/claims/models.py new file mode 100644 index 0000000..fed983e --- /dev/null +++ b/claims/models.py @@ -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})" diff --git a/claims/templates/claims/admin_list.html b/claims/templates/claims/admin_list.html new file mode 100644 index 0000000..955329a --- /dev/null +++ b/claims/templates/claims/admin_list.html @@ -0,0 +1,113 @@ +{% extends "claims/base.html" %} + +{% block title %}Admin – Utlägg{% endblock %} + +{% block content %} +

Inkomna utlägg

+

Endast användare med behörighet att se utlägg kommer åt den här sidan.

+ +
+ Filtrera: + Alla + {% for value, label in status_choices %} + | + + {{ label }} + + {% endfor %} +
+ + + + + + + + + + + + {% if can_change %}{% endif %} + + + + {% for claim in claims %} + + + + + + + + + {% if can_change %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
Person & kontaktBeloppProjektStatusKvittensLoggSenast uppdateradÅtgärd
+ {{ claim.full_name }}
+ {{ claim.email }}
+ Konto: {{ claim.account_number }}
+ {{ claim.description|linebreaksbr }} +
+ {% if claim.submitted_by %} + Inskickad av inloggad användare: {{ claim.submitted_by.get_username }} + {% else %} + Inskickad av gäst + {% endif %} +
+
{{ claim.amount }} {{ claim.currency }}{{ claim.project|default:"-" }} + {{ claim.get_status_display }}
+ {% if claim.decision_note %}Kommentar: {{ claim.decision_note }}{% endif %} +
+ {% if claim.receipt %} + Visa fil + {% else %} + – + {% endif %} + +
+ Visa logg +
    + {% for log in claim.logs.all %} +
  • + {{ 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 %} +
  • + {% empty %} +
  • Ingen logg än.
  • + {% endfor %} +
+
+
{{ claim.updated_at|date:"Y-m-d H:i" }} +
+ {% csrf_token %} + + + + +
+
Inga utlägg än.
+{% endblock %} diff --git a/claims/templates/claims/base.html b/claims/templates/claims/base.html new file mode 100644 index 0000000..b2dbfb0 --- /dev/null +++ b/claims/templates/claims/base.html @@ -0,0 +1,48 @@ + + + + + + {% block title %}Claims{% endblock %} + + + + +
+ {% if messages %} + + {% endif %} + {% block content %}{% endblock %} + + diff --git a/claims/templates/claims/export_placeholder.html b/claims/templates/claims/export_placeholder.html new file mode 100644 index 0000000..c1da067 --- /dev/null +++ b/claims/templates/claims/export_placeholder.html @@ -0,0 +1,20 @@ +{% extends "claims/base.html" %} + +{% block title %}Export{% endblock %} + +{% block content %} +

Export till redovisningssystem

+

Detta är ett framtida steg. Här kommer du att kunna:

+ +

Planerade åtgärder:

+
    +
  1. Definiera format
  2. +
  3. Implementera exportkommando/API
  4. +
  5. Bygga integrationsinställningar
  6. +
+

Tills vidare kan du ladda ner data via Django admin eller med ett enkelt SQL-utdrag.

+{% endblock %} diff --git a/claims/templates/claims/my_claims.html b/claims/templates/claims/my_claims.html new file mode 100644 index 0000000..379b74a --- /dev/null +++ b/claims/templates/claims/my_claims.html @@ -0,0 +1,63 @@ +{% extends "claims/base.html" %} + +{% block title %}Mina utlägg{% endblock %} + +{% block content %} +

Mina utlägg

+

Här ser du status för de utlägg du skickat in när du varit inloggad.

+ + {% if claims %} + + + + + + + + + + + + + + {% for claim in claims %} + + + + + + + + + + {% endfor %} + +
SkickadBeskrivningBeloppProjektStatusKvittoLogg
{{ claim.created_at|date:"Y-m-d H:i" }}{{ claim.description|linebreaksbr }}{{ claim.amount }} {{ claim.currency }}{{ claim.project|default:"-" }}{{ claim.get_status_display }} + {% if claim.receipt %} + Visa fil + {% else %} + – + {% endif %} + +
+ Visa logg +
    + {% for log in claim.logs.all %} +
  • + {{ 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 %} +
  • + {% empty %} +
  • Ingen logg än.
  • + {% endfor %} +
+
+
+ {% else %} +

Du har inte skickat in några utlägg ännu eller så gjordes de utan inloggning.

+ {% endif %} +{% endblock %} diff --git a/claims/templates/claims/submit_claim.html b/claims/templates/claims/submit_claim.html new file mode 100644 index 0000000..ce3adac --- /dev/null +++ b/claims/templates/claims/submit_claim.html @@ -0,0 +1,54 @@ +{% extends "claims/base.html" %} + +{% block title %}Skicka utlägg{% endblock %} + +{% block content %} +

Skicka in utlägg

+

Formuläret är öppet för alla. Du kan fylla i flera rader innan du skickar in.

+

Behöver du fler rader än som visas? Lägg till ?forms=n i URL:en (max {{ max_extra_forms }}).

+
+ {% csrf_token %} +
+ Dina uppgifter + {{ claimant_form.as_p }} +
+ {{ formset.management_form }} + {% for form in formset %} +
+ Utlägg {{ forloop.counter }} + {{ form.non_field_errors }} + {% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %} +

+ {{ form.description.label_tag }} + {{ form.description }} + {{ form.description.errors }} +

+

+ {{ form.amount.label_tag }} + {{ form.amount }} + {{ form.amount.errors }} +

+
+ Avancerat: ändra valuta (standard SEK) +

+ {{ form.currency.label_tag }} + {{ form.currency }} + {{ form.currency.errors }} +

+

+ {{ form.project.label_tag }} + {{ form.project }} + {{ form.project.errors }} +

+
+

+ {{ form.receipt.label_tag }} + {{ form.receipt }} + {{ form.receipt.errors }} +

+
+ {% endfor %} + +
+

När du skickar formuläret lotsas du till adminvyn. Saknar du inloggning får du möjlighet att logga in.

+{% endblock %} diff --git a/claims/templates/claims/user_management.html b/claims/templates/claims/user_management.html new file mode 100644 index 0000000..72272f0 --- /dev/null +++ b/claims/templates/claims/user_management.html @@ -0,0 +1,78 @@ +{% extends "claims/base.html" %} + +{% block title %}Användarhantering{% endblock %} + +{% block content %} +

Användare & behörigheter

+

Skapa nya konton, underhåll behörigheter och ta bort användare kopplat till utläggssystemet.

+ +

Notis: sidan hanterar direkta behörigheter. Behörigheter via grupper eller superuser-status gäller även om kryssrutorna avmarkeras.

+ +
+

Skapa ny användare

+
+ {% csrf_token %} + + {{ create_form.as_p }} + +
+
+ +
+ +
+

Befintliga användare

+ + + + + + + + + + + + {% for row in user_rows %} + {% with user=row.user %} + + + + + + + + {% endwith %} + {% empty %} + + {% endfor %} + +
AnvändareNamnE-postBehörigheterTa bort
+ {{ user.username }} + {% if user.is_superuser %}{% endif %} + {{ user.get_full_name|default:"-" }}{{ user.email|default:"-" }} + {% with form=row.permission_form %} +
+ {% csrf_token %} + + {{ form.user_id }} +
+
+
+ +
+ {% endwith %} +
+ {% if row.delete_form %} +
+ {% csrf_token %} + + {{ row.delete_form.user_id }} + +
+ {% else %} + + {% endif %} +
Inga användare upplagda.
+
+{% endblock %} diff --git a/claims/tests.py b/claims/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/claims/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/claims/urls.py b/claims/urls.py new file mode 100644 index 0000000..5dfa971 --- /dev/null +++ b/claims/urls.py @@ -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"), +] diff --git a/claims/views.py b/claims/views.py new file mode 100644 index 0000000..d5c80be --- /dev/null +++ b/claims/views.py @@ -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) diff --git a/claims_system/__init__.py b/claims_system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/claims_system/asgi.py b/claims_system/asgi.py new file mode 100644 index 0000000..063f642 --- /dev/null +++ b/claims_system/asgi.py @@ -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() diff --git a/claims_system/settings.py b/claims_system/settings.py new file mode 100644 index 0000000..96c7151 --- /dev/null +++ b/claims_system/settings.py @@ -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' diff --git a/claims_system/urls.py b/claims_system/urls.py new file mode 100644 index 0000000..74e88ba --- /dev/null +++ b/claims_system/urls.py @@ -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) diff --git a/claims_system/wsgi.py b/claims_system/wsgi.py new file mode 100644 index 0000000..a1f2647 --- /dev/null +++ b/claims_system/wsgi.py @@ -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() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..ec00ccf --- /dev/null +++ b/manage.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5bdd5bd --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..dc5bda4 --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,13 @@ +{% extends "claims/base.html" %} + +{% block title %}Logga in{% endblock %} + +{% block content %} +

Logga in

+
+ {% csrf_token %} + {{ form.as_p }} + + +
+{% endblock %} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ea0f36c --- /dev/null +++ b/uv.lock @@ -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" }, +]