Harden uploads and enforce language-prefixed routes

This commit is contained in:
Victor Andersson
2025-11-09 10:03:23 +01:00
parent 3835be3c17
commit 79f5cb8ff3
10 changed files with 263 additions and 66 deletions

View File

@@ -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).

View File

@@ -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).

View File

@@ -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")

View File

@@ -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]),
),
]

View 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

View File

@@ -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
View 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",
)

View File

@@ -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"):

View File

@@ -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

View File

@@ -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)