diff --git a/AGENTS.md b/AGENTS.md index 1926d2e..8b3edcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/README.md b/README.md index c94d580..b2d78a0 100644 --- a/README.md +++ b/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). diff --git a/claims/admin.py b/claims/admin.py index c65ac3b..2f89e4e 100644 --- a/claims/admin.py +++ b/claims/admin.py @@ -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") diff --git a/claims/migrations/0007_delete_systemsetting_alter_claim_receipt.py b/claims/migrations/0007_delete_systemsetting_alter_claim_receipt.py new file mode 100644 index 0000000..633747b --- /dev/null +++ b/claims/migrations/0007_delete_systemsetting_alter_claim_receipt.py @@ -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]), + ), + ] diff --git a/claims/models.py b/claims/models.py index b59bccf..f08af18 100644 --- a/claims/models.py +++ b/claims/models.py @@ -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 diff --git a/claims/tests.py b/claims/tests.py index 7ce503c..c492a05 100644 --- a/claims/tests.py +++ b/claims/tests.py @@ -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()) diff --git a/claims/validators.py b/claims/validators.py new file mode 100644 index 0000000..bb51cd0 --- /dev/null +++ b/claims/validators.py @@ -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", + ) diff --git a/claims/views.py b/claims/views.py index 58bc95a..0ecb745 100644 --- a/claims/views.py +++ b/claims/views.py @@ -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"): diff --git a/claims_system/settings.py b/claims_system/settings.py index 0aafdd0..043e9f0 100644 --- a/claims_system/settings.py +++ b/claims_system/settings.py @@ -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 diff --git a/claims_system/urls.py b/claims_system/urls.py index 9b90055..a2b4308 100644 --- a/claims_system/urls.py +++ b/claims_system/urls.py @@ -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)