Initial claims system setup

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

12
.gitignore vendored Normal file
View 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
View 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
View 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/epost + 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
View File

32
claims/admin.py Normal file
View File

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

6
claims/apps.py Normal file
View File

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

107
claims/forms.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

110
claims/models.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3
claims/tests.py Normal file
View File

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

19
claims/urls.py Normal file
View File

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

308
claims/views.py Normal file
View File

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

View File

16
claims_system/asgi.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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",
]

View 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
View 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" },
]