Harden uploads and enforce language-prefixed routes
This commit is contained in:
@@ -22,7 +22,7 @@ Bygg ett webbaserat system för hantering av utlägg (”claims”) åt en organ
|
|||||||
10. Claims ska kopplas till ett projekt/evenemang; projekten hanteras via Django admin.
|
10. Claims ska kopplas till ett projekt/evenemang; projekten hanteras via Django admin.
|
||||||
11. Offentliga sidor ska använda Tailwind-baserade komponenter (CDN är okej) med minimalistisk layout. Claim-formuläret ska erbjuda klient-side kontroll för antal rader (plus/minus) utan sidladdning och återanvända formsetets tomma form som mall.
|
11. Offentliga sidor ska använda Tailwind-baserade komponenter (CDN är okej) med minimalistisk layout. Claim-formuläret ska erbjuda klient-side kontroll för antal rader (plus/minus) utan sidladdning och återanvända formsetets tomma form som mall.
|
||||||
12. Adminvyn för claims ska spegla samma designprinciper (kort per claim, statuschippar, loggtimeline och inlinebeslut).
|
12. Adminvyn för claims ska spegla samma designprinciper (kort per claim, statuschippar, loggtimeline och inlinebeslut).
|
||||||
13. Hantering av utbetalningar i UI är bakom flaggan `CLAIMS_ENABLE_INTERNAL_PAYMENTS` och kan även togglas via Django admin (modell `SystemSetting`). När den är på ska godkända claims få en summeringssektion med tydlig info (namn, belopp, kontonr) och en "Betala"-knapp som markerar posten som betald (med logg och markerad betalstatus). När flaggan är av saknas knappen och admins instrueras att hantera betalning externt.
|
13. Hantering av utbetalningar i UI är bakom flaggan `CLAIMS_ENABLE_INTERNAL_PAYMENTS` (styrd via miljövariabel). När den är på ska godkända claims få en summeringssektion med tydlig info (namn, belopp, kontonr) och en "Betala"-knapp som markerar posten som betald (med logg och markerad betalstatus). När flaggan är av saknas knappen och admins instrueras att hantera betalning externt.
|
||||||
14. När ett utlägg markerats som betalt ska beslut/status vara låst (ingen uppdatering av kommentar eller status i UI eller Django admin). Reset av betalstatus sker endast via admin-knappen.
|
14. När ett utlägg markerats som betalt ska beslut/status vara låst (ingen uppdatering av kommentar eller status i UI eller Django admin). Reset av betalstatus sker endast via admin-knappen.
|
||||||
15. Filuppladdningar ska alltid få säkra, unika namn (UUID i `receipts/`), och originalnamn ska inte exponeras.
|
15. Filuppladdningar ska alltid få säkra, unika namn (UUID i `receipts/`), och originalnamn ska inte exponeras.
|
||||||
16. För e-postaviseringar: använd `CLAIMS_EMAIL_ENABLED` (default false) och miljövariablerna `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `CLAIMS_EMAIL_FROM`, `CLAIMS_ADMIN_NOTIFICATION_EMAIL`. När flaggan är av ska koden inte försöka skicka mejl (men gärna logga att aviseringar är inaktiverade).
|
16. För e-postaviseringar: använd `CLAIMS_EMAIL_ENABLED` (default false) och miljövariablerna `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `CLAIMS_EMAIL_FROM`, `CLAIMS_ADMIN_NOTIFICATION_EMAIL`. När flaggan är av ska koden inte försöka skicka mejl (men gärna logga att aviseringar är inaktiverade).
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -10,14 +10,14 @@ Modern Django/Tailwind-baserad portal för att ta emot, granska och betala utlä
|
|||||||
3. `uv run python manage.py createsuperuser`
|
3. `uv run python manage.py createsuperuser`
|
||||||
4. `uv run python manage.py runserver`
|
4. `uv run python manage.py runserver`
|
||||||
|
|
||||||
Nyckel-URLer (default):
|
Nyckel-URLer (språkprefixed):
|
||||||
- Offentligt formulär `GET /claims/new/`
|
- Offentligt formulär `GET /sv/claims/new/` eller `/en/claims/new/`
|
||||||
- Bekräftelsesida `GET /claims/submitted/`
|
- Bekräftelsesida `GET /sv/claims/submitted/`
|
||||||
- Adminlista `GET /claims/admin/`
|
- Adminlista `GET /sv/claims/admin/`
|
||||||
- Mina utlägg `GET /claims/mine/`
|
- Mina utlägg `GET /sv/claims/mine/`
|
||||||
- Användarhantering `GET /claims/users/`
|
- Användarhantering `GET /sv/claims/users/`
|
||||||
- Export-placeholder `GET /claims/export/`
|
- Export-placeholder `GET /sv/claims/export/`
|
||||||
- Auth `GET /accounts/login|logout/`
|
- Auth `GET /sv/accounts/login|logout/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,16 +48,19 @@ Nyckel-URLer (default):
|
|||||||
### 4. Viktiga inställningar
|
### 4. Viktiga inställningar
|
||||||
| Variabel | Default | Beskrivning |
|
| Variabel | Default | Beskrivning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `CLAIMS_ENABLE_INTERNAL_PAYMENTS` | `true` | Styr “Betala”-flödet. Kan också togglas i Django admin > Systeminställningar. |
|
| `CLAIMS_ENABLE_INTERNAL_PAYMENTS` | `true` | Styr “Betala”-flödet. Aktiveras endast via miljövariabel. |
|
||||||
| `CLAIMS_EMAIL_ENABLED` | `false` | Slår på e-postaviseringar. Låt vara `false` i testläge. |
|
| `CLAIMS_EMAIL_ENABLED` | `false` | Slår på e-postaviseringar. Låt vara `false` i testläge. |
|
||||||
| `CLAIMS_EMAIL_FROM` | `no-reply@claims.local` | Avsändare för utskick. |
|
| `CLAIMS_EMAIL_FROM` | `no-reply@claims.local` | Avsändare för utskick. |
|
||||||
| `CLAIMS_ADMIN_NOTIFICATION_EMAIL` | tom | Om satt skickas notifiering vid nytt claim (när e-post är aktiverad). |
|
| `CLAIMS_ADMIN_NOTIFICATION_EMAIL` | tom | Om satt skickas notifiering vid nytt claim (när e-post är aktiverad). |
|
||||||
| `EMAIL_BACKEND` | `django.core.mail.backends.console.EmailBackend` | Byt till SMTP i prod (se nedan). |
|
| `EMAIL_BACKEND` | `django.core.mail.backends.console.EmailBackend` | Byt till SMTP i prod (se nedan). |
|
||||||
| `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD` | tom | Standard Django SMTP-inställningar. |
|
| `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD` | tom | Standard Django SMTP-inställningar. |
|
||||||
|
| `CLAIMS_MAX_RECEIPT_BYTES` | `10485760` | Maxstorlek per kvitto (i byte), default 10 MB. |
|
||||||
|
| `CLAIMS_ALLOWED_RECEIPT_EXTENSIONS` | `pdf,png,jpg,jpeg` | Tillåtna filändelser (kommaseparerade). |
|
||||||
|
| `CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES` | `application/pdf,image/png,image/jpeg` | Tillåtna MIME-typer (kommaseparerade). |
|
||||||
| `LANGUAGE_CODE` | `sv` | Standardspråk. |
|
| `LANGUAGE_CODE` | `sv` | Standardspråk. |
|
||||||
| `LOCALE_PATHS` | `BASE_DIR/locale` | Katalog för `.po/.mo`. |
|
| `LOCALE_PATHS` | `BASE_DIR/locale` | Katalog för `.po/.mo`. |
|
||||||
| `LOGIN_REDIRECT_URL` | `/claims/admin/` | Efter inloggning. |
|
| `LOGIN_REDIRECT_URL` | `claims:admin-list` | Lazy reverse till `/[lang]/claims/admin/` efter inloggning. |
|
||||||
| `LOGOUT_REDIRECT_URL` | `/accounts/login/` | Efter utloggning. |
|
| `LOGOUT_REDIRECT_URL` | `login` | Lazy reverse till `/[lang]/accounts/login/` efter utloggning. |
|
||||||
|
|
||||||
SMTP-exempel:
|
SMTP-exempel:
|
||||||
```env
|
```env
|
||||||
@@ -76,7 +79,7 @@ EMAIL_HOST_PASSWORD=secret
|
|||||||
|
|
||||||
### 5. Underhåll & verktyg
|
### 5. Underhåll & verktyg
|
||||||
- **Reset claims:** `uv run python manage.py reset_claims` (bekräfta med `ja` eller använd `--noinput`). Tar bort alla claims/loggar/kvitton men lämnar användarkonton.
|
- **Reset claims:** `uv run python manage.py reset_claims` (bekräfta med `ja` eller använd `--noinput`). Tar bort alla claims/loggar/kvitton men lämnar användarkonton.
|
||||||
- **Django admin:** `/admin/` används för projekt, grupper, superusers, SystemSetting mm. Staff-användare har automatiskt åtkomst.
|
- **Django admin:** `/admin/` används för projekt, grupper och superusers. Staff-användare har automatiskt åtkomst.
|
||||||
- **Testa konfiguration:** `uv run python manage.py check`.
|
- **Testa konfiguration:** `uv run python manage.py check`.
|
||||||
- **Språkreset:** Rensa cookies om språkväljaren inte byter språk (Django använder `django_language` cookien).
|
- **Språkreset:** Rensa cookies om språkväljaren inte byter språk (Django använder `django_language` cookien).
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from .models import Claim, ClaimLog, Project, SystemSetting
|
from .models import Claim, ClaimLog, Project
|
||||||
|
|
||||||
|
|
||||||
class ClaimLogInline(admin.TabularInline):
|
class ClaimLogInline(admin.TabularInline):
|
||||||
@@ -20,7 +19,7 @@ class ClaimAdmin(admin.ModelAdmin):
|
|||||||
list_display = ("full_name", "amount", "currency", "project", "status", "paid", "created_at", "submitted_by")
|
list_display = ("full_name", "amount", "currency", "project", "status", "paid", "created_at", "submitted_by")
|
||||||
list_filter = ("status", "created_at", "project", "paid_at")
|
list_filter = ("status", "created_at", "project", "paid_at")
|
||||||
search_fields = ("full_name", "email", "description")
|
search_fields = ("full_name", "email", "description")
|
||||||
base_readonly = ("created_at", "updated_at", "paid_at", "paid_by", "internal_payments_enabled", "reset_paid_button")
|
base_readonly = ("created_at", "updated_at", "paid_at", "paid_by", "reset_paid_button")
|
||||||
readonly_fields = base_readonly
|
readonly_fields = base_readonly
|
||||||
inlines = [ClaimLogInline]
|
inlines = [ClaimLogInline]
|
||||||
actions = ("mark_as_paid", "mark_as_unpaid")
|
actions = ("mark_as_paid", "mark_as_unpaid")
|
||||||
@@ -29,10 +28,6 @@ class ClaimAdmin(admin.ModelAdmin):
|
|||||||
def paid(self, obj):
|
def paid(self, obj):
|
||||||
return obj.is_paid
|
return obj.is_paid
|
||||||
|
|
||||||
@admin.display(description="Intern betalningshantering på?")
|
|
||||||
def internal_payments_enabled(self, obj):
|
|
||||||
return SystemSetting.internal_payments_active()
|
|
||||||
|
|
||||||
@admin.display(description="Återställ betalning")
|
@admin.display(description="Återställ betalning")
|
||||||
def reset_paid_button(self, obj):
|
def reset_paid_button(self, obj):
|
||||||
if not obj.is_paid:
|
if not obj.is_paid:
|
||||||
@@ -115,17 +110,6 @@ class ClaimAdmin(admin.ModelAdmin):
|
|||||||
return self.base_readonly
|
return self.base_readonly
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SystemSetting)
|
|
||||||
class SystemSettingAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("internal_payments_enabled", "updated_at")
|
|
||||||
list_display_links = ("updated_at",)
|
|
||||||
list_editable = ("internal_payments_enabled",)
|
|
||||||
actions = None
|
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
|
||||||
return not SystemSetting.objects.exists()
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ClaimLog)
|
@admin.register(ClaimLog)
|
||||||
class ClaimLogAdmin(admin.ModelAdmin):
|
class ClaimLogAdmin(admin.ModelAdmin):
|
||||||
list_display = ("claim", "action", "from_status", "to_status", "performed_by", "created_at")
|
list_display = ("claim", "action", "from_status", "to_status", "performed_by", "created_at")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-09 02:06
|
||||||
|
|
||||||
|
import claims.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('claims', '0006_systemsetting'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='SystemSetting',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='claim',
|
||||||
|
name='receipt',
|
||||||
|
field=models.FileField(blank=True, null=True, upload_to='receipts/', validators=[claims.validators.validate_receipt_file]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -5,6 +5,8 @@ from django.core.files.storage import default_storage
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from .validators import validate_receipt_file
|
||||||
|
|
||||||
|
|
||||||
class Project(models.Model):
|
class Project(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
@@ -51,7 +53,12 @@ class Claim(models.Model):
|
|||||||
)
|
)
|
||||||
description = models.TextField(help_text=_("Describe what the reimbursement is for"))
|
description = models.TextField(help_text=_("Describe what the reimbursement is for"))
|
||||||
account_number = models.CharField(max_length=50)
|
account_number = models.CharField(max_length=50)
|
||||||
receipt = models.FileField(upload_to="receipts/", blank=True, null=True)
|
receipt = models.FileField(
|
||||||
|
upload_to="receipts/",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[validate_receipt_file],
|
||||||
|
)
|
||||||
project = models.ForeignKey(
|
project = models.ForeignKey(
|
||||||
Project,
|
Project,
|
||||||
null=True,
|
null=True,
|
||||||
@@ -141,23 +148,3 @@ class ClaimLog(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.get_action_display()} ({self.created_at:%Y-%m-%d %H:%M})"
|
return f"{self.get_action_display()} ({self.created_at:%Y-%m-%d %H:%M})"
|
||||||
|
|
||||||
|
|
||||||
class SystemSetting(models.Model):
|
|
||||||
internal_payments_enabled = models.BooleanField(default=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Systeminställning"
|
|
||||||
verbose_name_plural = "Systeminställningar"
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "Systeminställningar"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_solo(cls):
|
|
||||||
obj, _ = cls.objects.get_or_create(pk=1)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def internal_payments_active(cls):
|
|
||||||
return cls.get_solo().internal_payments_enabled
|
|
||||||
|
|||||||
@@ -1,3 +1,67 @@
|
|||||||
from django.test import TestCase
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
# Create your tests here.
|
from .validators import validate_receipt_file
|
||||||
|
from .views import SubmitClaimView
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptValidatorTests(TestCase):
|
||||||
|
def test_accepts_valid_pdf(self):
|
||||||
|
file_obj = SimpleUploadedFile(
|
||||||
|
"receipt.pdf",
|
||||||
|
b"%PDF-1.4\nsample",
|
||||||
|
content_type="application/pdf",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
validate_receipt_file(file_obj)
|
||||||
|
except ValidationError as exc: # pragma: no cover - explicit failure message
|
||||||
|
self.fail(f"Valid PDF rejected: {exc}")
|
||||||
|
|
||||||
|
def test_rejects_disallowed_extension(self):
|
||||||
|
file_obj = SimpleUploadedFile(
|
||||||
|
"script.exe",
|
||||||
|
b"MZ fake exe",
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
validate_receipt_file(file_obj)
|
||||||
|
|
||||||
|
@override_settings(CLAIMS_MAX_RECEIPT_BYTES=1024)
|
||||||
|
def test_rejects_too_large_file(self):
|
||||||
|
big_payload = b"%PDF-1.4\n" + b"a" * 2048
|
||||||
|
file_obj = SimpleUploadedFile(
|
||||||
|
"large.pdf",
|
||||||
|
big_payload,
|
||||||
|
content_type="application/pdf",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
validate_receipt_file(file_obj)
|
||||||
|
|
||||||
|
def test_rejects_signature_mismatch(self):
|
||||||
|
file_obj = SimpleUploadedFile(
|
||||||
|
"fake.pdf",
|
||||||
|
b"\x89PNG\r\n\x1a\nnot a pdf",
|
||||||
|
content_type="application/pdf",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
validate_receipt_file(file_obj)
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimFormsetLimitTests(TestCase):
|
||||||
|
def test_default_formset_has_single_row(self):
|
||||||
|
view = SubmitClaimView()
|
||||||
|
formset = view.build_formset(extra=1)
|
||||||
|
self.assertEqual(formset.total_form_count(), 1)
|
||||||
|
|
||||||
|
def test_cannot_submit_more_than_max_forms(self):
|
||||||
|
view = SubmitClaimView()
|
||||||
|
data = {
|
||||||
|
"claim_lines-TOTAL_FORMS": "6",
|
||||||
|
"claim_lines-INITIAL_FORMS": "0",
|
||||||
|
"claim_lines-MIN_NUM_FORMS": "1",
|
||||||
|
"claim_lines-MAX_NUM_FORMS": "5",
|
||||||
|
}
|
||||||
|
formset = view.build_formset(data=data)
|
||||||
|
self.assertFalse(formset.is_valid())
|
||||||
|
self.assertTrue(formset.non_form_errors())
|
||||||
|
|||||||
115
claims/validators.py
Normal file
115
claims/validators.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
DEFAULT_ALLOWED_EXTENSIONS = ("pdf", "png", "jpg", "jpeg")
|
||||||
|
DEFAULT_ALLOWED_CONTENT_TYPES = ("application/pdf", "image/png", "image/jpeg")
|
||||||
|
|
||||||
|
PDF_SIGNATURE = b"%PDF-"
|
||||||
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||||
|
JPEG_PREFIX = b"\xff\xd8"
|
||||||
|
|
||||||
|
|
||||||
|
def _peek(file_obj, length=8):
|
||||||
|
"""Read the first bytes of a file without consuming the stream."""
|
||||||
|
stream = getattr(file_obj, "file", file_obj)
|
||||||
|
if isinstance(stream, io.BytesIO):
|
||||||
|
pos = stream.tell()
|
||||||
|
stream.seek(0)
|
||||||
|
data = stream.read(length)
|
||||||
|
stream.seek(pos)
|
||||||
|
return data
|
||||||
|
|
||||||
|
try:
|
||||||
|
pos = stream.tell()
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pos = None
|
||||||
|
try:
|
||||||
|
stream.seek(0)
|
||||||
|
data = stream.read(length)
|
||||||
|
finally:
|
||||||
|
if pos is not None:
|
||||||
|
stream.seek(pos)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
stream.seek(0)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_extensions():
|
||||||
|
exts = getattr(settings, "CLAIMS_ALLOWED_RECEIPT_EXTENSIONS", DEFAULT_ALLOWED_EXTENSIONS)
|
||||||
|
normalized = {ext.lower() for ext in exts}
|
||||||
|
return normalized or set(DEFAULT_ALLOWED_EXTENSIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_content_types():
|
||||||
|
cts = getattr(settings, "CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES", DEFAULT_ALLOWED_CONTENT_TYPES)
|
||||||
|
normalized = {ct.lower() for ct in cts}
|
||||||
|
return normalized or set(DEFAULT_ALLOWED_CONTENT_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
def _max_file_size():
|
||||||
|
return int(getattr(settings, "CLAIMS_MAX_RECEIPT_BYTES", 10 * 1024 * 1024))
|
||||||
|
|
||||||
|
|
||||||
|
def _extension(validated_file):
|
||||||
|
name = validated_file.name or ""
|
||||||
|
if "." not in name:
|
||||||
|
return ""
|
||||||
|
return name.rsplit(".", 1)[-1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _signature_matches(ext, header):
|
||||||
|
if not header:
|
||||||
|
return False
|
||||||
|
if ext == "pdf":
|
||||||
|
return header.startswith(PDF_SIGNATURE)
|
||||||
|
if ext == "png":
|
||||||
|
return header.startswith(PNG_SIGNATURE)
|
||||||
|
if ext in {"jpg", "jpeg"}:
|
||||||
|
return header.startswith(JPEG_PREFIX)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_receipt_file(uploaded_file):
|
||||||
|
"""Ensure uploaded receipts comply with size/format requirements."""
|
||||||
|
if not uploaded_file:
|
||||||
|
return
|
||||||
|
|
||||||
|
max_bytes = _max_file_size()
|
||||||
|
if uploaded_file.size > max_bytes:
|
||||||
|
max_mb = round(max_bytes / (1024 * 1024), 2)
|
||||||
|
raise ValidationError(
|
||||||
|
_("Kvitton får vara max %(size)s MB."),
|
||||||
|
code="file_too_large",
|
||||||
|
params={"size": max_mb},
|
||||||
|
)
|
||||||
|
|
||||||
|
ext = _extension(uploaded_file)
|
||||||
|
extensions = _allowed_extensions()
|
||||||
|
if ext not in extensions:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Otillåtet filformat. Tillåtna format är %(formats)s."),
|
||||||
|
code="invalid_extension",
|
||||||
|
params={"formats": ", ".join(sorted(extensions))},
|
||||||
|
)
|
||||||
|
|
||||||
|
content_type = getattr(uploaded_file, "content_type", "")
|
||||||
|
allowed_types = _allowed_content_types()
|
||||||
|
if content_type and content_type.lower() not in allowed_types:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Otillåten MIME-typ: %(type)s."),
|
||||||
|
code="invalid_mime",
|
||||||
|
params={"type": content_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
header = _peek(uploaded_file, length=8)
|
||||||
|
if not _signature_matches(ext, header):
|
||||||
|
raise ValidationError(
|
||||||
|
_("Filens innehåll matchar inte förväntat format."),
|
||||||
|
code="invalid_signature",
|
||||||
|
)
|
||||||
@@ -20,7 +20,7 @@ from .forms import (
|
|||||||
UserPermissionForm,
|
UserPermissionForm,
|
||||||
)
|
)
|
||||||
from .email_utils import notify_admin_of_claim, send_claimant_confirmation_email
|
from .email_utils import notify_admin_of_claim, send_claimant_confirmation_email
|
||||||
from .models import Claim, ClaimLog, SystemSetting
|
from .models import Claim, ClaimLog
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -31,17 +31,21 @@ class SubmitClaimView(View):
|
|||||||
|
|
||||||
def get_extra_forms(self):
|
def get_extra_forms(self):
|
||||||
try:
|
try:
|
||||||
count = int(self.request.GET.get("forms", 2))
|
count = int(self.request.GET.get("forms", 1))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
count = 2
|
count = 1
|
||||||
return max(1, min(count, self.max_extra_forms))
|
return max(1, min(count, self.max_extra_forms))
|
||||||
|
|
||||||
def build_formset(self, *, data=None, files=None, extra=0):
|
def build_formset(self, *, data=None, files=None, extra=0):
|
||||||
|
extra_forms = max(0, extra - 1)
|
||||||
FormSet = formset_factory(
|
FormSet = formset_factory(
|
||||||
ClaimLineForm,
|
ClaimLineForm,
|
||||||
extra=extra,
|
extra=extra_forms,
|
||||||
min_num=1,
|
min_num=1,
|
||||||
|
max_num=self.max_extra_forms,
|
||||||
|
absolute_max=self.max_extra_forms,
|
||||||
validate_min=True,
|
validate_min=True,
|
||||||
|
validate_max=True,
|
||||||
)
|
)
|
||||||
return FormSet(data=data, files=files, prefix="claim_lines")
|
return FormSet(data=data, files=files, prefix="claim_lines")
|
||||||
|
|
||||||
@@ -154,7 +158,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
|||||||
context["status_choices"] = Claim.Status.choices
|
context["status_choices"] = Claim.Status.choices
|
||||||
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
|
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
|
||||||
context["can_change"] = self.request.user.has_perm("claims.change_claim")
|
context["can_change"] = self.request.user.has_perm("claims.change_claim")
|
||||||
context["payments_enabled"] = SystemSetting.internal_payments_active()
|
context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
@@ -202,7 +206,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
|||||||
return redirect(request.get_full_path())
|
return redirect(request.get_full_path())
|
||||||
|
|
||||||
def _handle_payment(self, request):
|
def _handle_payment(self, request):
|
||||||
if not SystemSetting.internal_payments_active():
|
if not getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False):
|
||||||
messages.error(request, _("Betalningshantering är inte aktiverad."))
|
messages.error(request, _("Betalningshantering är inte aktiverad."))
|
||||||
return redirect(request.get_full_path())
|
return redirect(request.get_full_path())
|
||||||
if not request.user.has_perm("claims.change_claim"):
|
if not request.user.has_perm("claims.change_claim"):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -130,8 +131,8 @@ STATIC_URL = 'static/'
|
|||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = '/claims/admin/'
|
LOGIN_REDIRECT_URL = reverse_lazy('claims:admin-list')
|
||||||
LOGOUT_REDIRECT_URL = '/accounts/login/'
|
LOGOUT_REDIRECT_URL = reverse_lazy('login')
|
||||||
|
|
||||||
os.environ.setdefault("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true")
|
os.environ.setdefault("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true")
|
||||||
CLAIMS_ENABLE_INTERNAL_PAYMENTS = os.getenv("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true").lower() in {"1", "true", "yes"}
|
CLAIMS_ENABLE_INTERNAL_PAYMENTS = os.getenv("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true").lower() in {"1", "true", "yes"}
|
||||||
@@ -148,6 +149,18 @@ CLAIMS_EMAIL_ENABLED = os.getenv("CLAIMS_EMAIL_ENABLED", "false").lower() in {"1
|
|||||||
CLAIMS_EMAIL_FROM = os.getenv("CLAIMS_EMAIL_FROM", "no-reply@claims.local")
|
CLAIMS_EMAIL_FROM = os.getenv("CLAIMS_EMAIL_FROM", "no-reply@claims.local")
|
||||||
CLAIMS_ADMIN_NOTIFICATION_EMAIL = os.getenv("CLAIMS_ADMIN_NOTIFICATION_EMAIL", "")
|
CLAIMS_ADMIN_NOTIFICATION_EMAIL = os.getenv("CLAIMS_ADMIN_NOTIFICATION_EMAIL", "")
|
||||||
|
|
||||||
|
CLAIMS_MAX_RECEIPT_BYTES = int(os.getenv("CLAIMS_MAX_RECEIPT_BYTES", str(10 * 1024 * 1024)))
|
||||||
|
CLAIMS_ALLOWED_RECEIPT_EXTENSIONS = tuple(
|
||||||
|
ext.strip().lower()
|
||||||
|
for ext in os.getenv("CLAIMS_ALLOWED_RECEIPT_EXTENSIONS", "pdf,png,jpg,jpeg").split(",")
|
||||||
|
if ext.strip()
|
||||||
|
)
|
||||||
|
CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES = tuple(
|
||||||
|
ct.strip().lower()
|
||||||
|
for ct in os.getenv("CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES", "application/pdf,image/png,image/jpeg").split(",")
|
||||||
|
if ct.strip()
|
||||||
|
)
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
|||||||
@@ -16,17 +16,22 @@ Including another URLconf
|
|||||||
"""
|
"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
|
from django.conf.urls.i18n import i18n_patterns
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
|
||||||
path('claims/', include('claims.urls')),
|
|
||||||
path('accounts/', include('django.contrib.auth.urls')),
|
|
||||||
path('i18n/', include('django.conf.urls.i18n')),
|
path('i18n/', include('django.conf.urls.i18n')),
|
||||||
path('', RedirectView.as_view(pattern_name='claims:submit', permanent=False)),
|
path('', RedirectView.as_view(url=f'/{settings.LANGUAGE_CODE}/', permanent=False)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
urlpatterns += i18n_patterns(
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('accounts/', include('django.contrib.auth.urls')),
|
||||||
|
path('claims/', include('claims.urls')),
|
||||||
|
path('', RedirectView.as_view(pattern_name='claims:submit', permanent=False)),
|
||||||
|
)
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
Reference in New Issue
Block a user