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.
|
||||
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).
|
||||
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.
|
||||
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).
|
||||
|
||||
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`
|
||||
4. `uv run python manage.py runserver`
|
||||
|
||||
Nyckel-URLer (default):
|
||||
- Offentligt formulär `GET /claims/new/`
|
||||
- Bekräftelsesida `GET /claims/submitted/`
|
||||
- Adminlista `GET /claims/admin/`
|
||||
- Mina utlägg `GET /claims/mine/`
|
||||
- Användarhantering `GET /claims/users/`
|
||||
- Export-placeholder `GET /claims/export/`
|
||||
- Auth `GET /accounts/login|logout/`
|
||||
Nyckel-URLer (språkprefixed):
|
||||
- Offentligt formulär `GET /sv/claims/new/` eller `/en/claims/new/`
|
||||
- Bekräftelsesida `GET /sv/claims/submitted/`
|
||||
- Adminlista `GET /sv/claims/admin/`
|
||||
- Mina utlägg `GET /sv/claims/mine/`
|
||||
- Användarhantering `GET /sv/claims/users/`
|
||||
- Export-placeholder `GET /sv/claims/export/`
|
||||
- Auth `GET /sv/accounts/login|logout/`
|
||||
|
||||
---
|
||||
|
||||
@@ -48,16 +48,19 @@ Nyckel-URLer (default):
|
||||
### 4. Viktiga inställningar
|
||||
| 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_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). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `LOCALE_PATHS` | `BASE_DIR/locale` | Katalog för `.po/.mo`. |
|
||||
| `LOGIN_REDIRECT_URL` | `/claims/admin/` | Efter inloggning. |
|
||||
| `LOGOUT_REDIRECT_URL` | `/accounts/login/` | Efter utloggning. |
|
||||
| `LOGIN_REDIRECT_URL` | `claims:admin-list` | Lazy reverse till `/[lang]/claims/admin/` efter inloggning. |
|
||||
| `LOGOUT_REDIRECT_URL` | `login` | Lazy reverse till `/[lang]/accounts/login/` efter utloggning. |
|
||||
|
||||
SMTP-exempel:
|
||||
```env
|
||||
@@ -76,7 +79,7 @@ EMAIL_HOST_PASSWORD=secret
|
||||
|
||||
### 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.
|
||||
- **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`.
|
||||
- **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.shortcuts import redirect
|
||||
from django.urls import path, reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .models import Claim, ClaimLog, Project, SystemSetting
|
||||
from .models import Claim, ClaimLog, Project
|
||||
|
||||
|
||||
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_filter = ("status", "created_at", "project", "paid_at")
|
||||
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
|
||||
inlines = [ClaimLogInline]
|
||||
actions = ("mark_as_paid", "mark_as_unpaid")
|
||||
@@ -29,10 +28,6 @@ class ClaimAdmin(admin.ModelAdmin):
|
||||
def paid(self, obj):
|
||||
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")
|
||||
def reset_paid_button(self, obj):
|
||||
if not obj.is_paid:
|
||||
@@ -115,17 +110,6 @@ class ClaimAdmin(admin.ModelAdmin):
|
||||
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)
|
||||
class ClaimLogAdmin(admin.ModelAdmin):
|
||||
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.utils.translation import gettext_lazy as _
|
||||
|
||||
from .validators import validate_receipt_file
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
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"))
|
||||
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,
|
||||
null=True,
|
||||
@@ -141,23 +148,3 @@ class ClaimLog(models.Model):
|
||||
def __str__(self):
|
||||
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,
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -31,17 +31,21 @@ class SubmitClaimView(View):
|
||||
|
||||
def get_extra_forms(self):
|
||||
try:
|
||||
count = int(self.request.GET.get("forms", 2))
|
||||
count = int(self.request.GET.get("forms", 1))
|
||||
except (TypeError, ValueError):
|
||||
count = 2
|
||||
count = 1
|
||||
return max(1, min(count, self.max_extra_forms))
|
||||
|
||||
def build_formset(self, *, data=None, files=None, extra=0):
|
||||
extra_forms = max(0, extra - 1)
|
||||
FormSet = formset_factory(
|
||||
ClaimLineForm,
|
||||
extra=extra,
|
||||
extra=extra_forms,
|
||||
min_num=1,
|
||||
max_num=self.max_extra_forms,
|
||||
absolute_max=self.max_extra_forms,
|
||||
validate_min=True,
|
||||
validate_max=True,
|
||||
)
|
||||
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["decision_choices"] = ClaimDecisionForm().fields["action"].choices
|
||||
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
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@@ -202,7 +206,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
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."))
|
||||
return redirect(request.get_full_path())
|
||||
if not request.user.has_perm("claims.change_claim"):
|
||||
|
||||
@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -130,8 +131,8 @@ STATIC_URL = 'static/'
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
LOGIN_REDIRECT_URL = '/claims/admin/'
|
||||
LOGOUT_REDIRECT_URL = '/accounts/login/'
|
||||
LOGIN_REDIRECT_URL = reverse_lazy('claims:admin-list')
|
||||
LOGOUT_REDIRECT_URL = reverse_lazy('login')
|
||||
|
||||
os.environ.setdefault("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true")
|
||||
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_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
|
||||
# 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.urls.static import static
|
||||
from django.conf.urls.i18n import i18n_patterns
|
||||
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('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:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
Reference in New Issue
Block a user