Files
claims-system/claims/views.py
2025-11-11 20:27:41 +01:00

473 lines
20 KiB
Python

from datetime import timedelta
from decimal import Decimal
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.db.models import Sum
from django.forms import formset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from django.views import View
from django.views.generic import ListView, TemplateView
from .forms import (
ClaimDecisionForm,
ClaimEditForm,
ClaimLineForm,
ClaimantForm,
DeleteUserForm,
UserManagementForm,
UserPermissionForm,
)
from .email_utils import notify_admin_of_claim, send_claimant_confirmation_email
from .models import Claim, ClaimLog, Project
User = get_user_model()
class SubmitClaimView(View):
template_name = "claims/submit_claim.html"
max_extra_forms = 5
def get_extra_forms(self):
try:
count = int(self.request.GET.get("forms", 1))
except (TypeError, ValueError):
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_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")
def get_claimant_initial(self):
initial = {}
user = self.request.user
if user.is_authenticated:
initial["full_name"] = user.get_full_name() or user.get_username()
initial["email"] = user.email
last_claim = user.claims_submitted.order_by("-created_at").first()
if last_claim:
initial["account_number"] = last_claim.account_number
return initial
def build_context(self, formset, claimant_form):
current_forms = formset.total_form_count()
return {
"formset": formset,
"claimant_form": claimant_form,
"current_forms": current_forms,
"max_extra_forms": self.max_extra_forms,
"can_add_forms": current_forms < self.max_extra_forms,
"can_remove_forms": current_forms > 1,
"add_forms_value": min(self.max_extra_forms, current_forms + 1),
"remove_forms_value": max(1, current_forms - 1),
"form_fragment": "claim-formset",
}
def get(self, request):
extra = self.get_extra_forms()
formset = self.build_formset(extra=extra)
claimant_form = ClaimantForm(initial=self.get_claimant_initial())
context = self.build_context(formset, claimant_form)
if self._wants_fragment(request):
return render(request, "claims/includes/claim_formset.html", context)
return render(request, self.template_name, context)
def post(self, request):
formset = self.build_formset(data=request.POST, files=request.FILES)
claimant_form = ClaimantForm(request.POST)
if formset.is_valid() and claimant_form.is_valid():
claimant_data = claimant_form.cleaned_data
created = 0
for form in formset:
if not form.cleaned_data:
continue
description = form.cleaned_data.get("description")
amount = form.cleaned_data.get("amount")
if not description or amount is None:
continue
claim = Claim(
full_name=claimant_data["full_name"],
email=claimant_data["email"],
account_number=claimant_data["account_number"],
amount=amount,
currency=form.cleaned_data.get("currency") or Claim.Currency.SEK,
description=description,
receipt=form.cleaned_data.get("receipt"),
project=form.cleaned_data.get("project"),
submitted_by=request.user if request.user.is_authenticated else None,
)
claim.save()
claim.add_log(
action=ClaimLog.Action.CREATED,
performed_by=claim.submitted_by,
to_status=Claim.Status.PENDING,
)
send_claimant_confirmation_email(claim)
notify_admin_of_claim(claim)
created += 1
if created:
messages.success(request, _("{} utlägg skickade in.").format(created))
return redirect(reverse("claims:submit-success"))
messages.error(request, _("Inga utlägg kunde sparas. Fyll i minst en rad."))
else:
messages.error(request, _("Kunde inte spara utläggen. Kontrollera formuläret."))
return render(request, self.template_name, self.build_context(formset, claimant_form))
@staticmethod
def _wants_fragment(request):
return (
request.headers.get("x-requested-with") == "XMLHttpRequest"
or request.GET.get("fragment") == "claim-formset"
)
class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
template_name = "claims/dashboard.html"
context_object_name = "claims"
permission_required = "claims.view_claim"
def get_queryset(self):
queryset = (
Claim.objects.select_related("submitted_by", "project", "paid_by")
.prefetch_related("logs__performed_by")
.order_by("-created_at")
)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["status_filter"] = self.request.GET.get("status", "all")
context["status_choices"] = Claim.Status.choices
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
context["can_change"] = self.request.user.has_perm("claims.change_claim")
context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False)
context["summary"] = self._build_summary()
context["project_options"] = Project.objects.filter(is_active=True).order_by("name")
context["currency_choices"] = Claim.Currency.choices
context["has_any_claims"] = context["summary"]["total_claims"] > 0
context["has_filtered_claims"] = self._has_filtered_claims(context["status_filter"], context["summary"])
context["recent_claims"] = (
Claim.objects.select_related("project")
.prefetch_related("logs__performed_by")
.order_by("-created_at")[:5]
)
return context
def post(self, request, *args, **kwargs):
action_type = request.POST.get("action_type", "decision")
if action_type == "payment":
return self._handle_payment(request)
if action_type == "edit":
return self._handle_edit(request)
return self._handle_decision(request)
def _handle_decision(self, request):
if not request.user.has_perm("claims.change_claim"):
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
return redirect(request.get_full_path())
form = ClaimDecisionForm(request.POST)
if not form.is_valid():
for field_errors in form.errors.values():
for error in field_errors:
messages.error(request, error)
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=form.cleaned_data["claim_id"])
action = form.cleaned_data["action"]
decision_note = form.cleaned_data.get("decision_note", "")
if claim.is_paid:
messages.error(request, _("Utlägget är redan markerat som betalt och kan inte ändras."))
return redirect(request.get_full_path())
previous_status = claim.status
claim.decision_note = decision_note
if action == ClaimDecisionForm.ACTION_APPROVE:
target_status = Claim.Status.APPROVED
feedback = messages.success
feedback_msg = _("%(claim)s markerades som godkänd.")
elif action == ClaimDecisionForm.ACTION_REJECT:
target_status = Claim.Status.REJECTED
feedback = messages.warning
feedback_msg = _("%(claim)s markerades som nekad.")
else:
target_status = Claim.Status.PENDING
feedback = messages.info
feedback_msg = _("%(claim)s återställdes till väntande status.")
status_changed = previous_status != target_status
update_fields = ["decision_note", "updated_at"]
if status_changed:
claim.status = target_status
update_fields.append("status")
claim.save(update_fields=update_fields)
feedback(request, feedback_msg % {"claim": claim})
claim.add_log(
action=ClaimLog.Action.STATUS_CHANGED,
performed_by=request.user,
from_status=previous_status,
to_status=claim.status,
note=decision_note,
)
return redirect(request.get_full_path())
def _handle_payment(self, request):
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"):
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=request.POST.get("payment_claim_id"))
if claim.status != Claim.Status.APPROVED:
messages.error(request, _("Endast godkända utlägg kan markeras som betalda."))
return redirect(request.get_full_path())
if claim.is_paid:
messages.info(request, _("Detta utlägg är redan markerat som betalt."))
return redirect(request.get_full_path())
claim.paid_by = request.user
claim.paid_at = timezone.now()
claim.save(update_fields=["paid_by", "paid_at"])
claim.add_log(
action=ClaimLog.Action.MARKED_PAID,
performed_by=request.user,
note="Markerad som betald via systemet.",
)
messages.success(request, _("%(claim)s markerades som betald.") % {"claim": claim})
return redirect(request.get_full_path())
def _handle_edit(self, request):
if not request.user.has_perm("claims.change_claim"):
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=request.POST.get("edit_claim_id"))
if claim.status != Claim.Status.PENDING:
messages.error(request, _("Endast väntande utlägg kan redigeras via panelen."))
return redirect(request.get_full_path())
original_values = {}
for field in ClaimEditForm.Meta.fields:
original_values[field] = getattr(claim, field)
form = ClaimEditForm(request.POST, instance=claim)
if not form.is_valid():
for error in form.errors.get("__all__", []):
messages.error(request, error)
for field, field_errors in form.errors.items():
if field == "__all__":
continue
for error in field_errors:
messages.error(request, f"{form.fields[field].label}: {error}")
return redirect(request.get_full_path())
updated_claim = form.save()
def _format_value(value):
if value is None:
return "-"
return str(value)
change_notes = []
for field in form.changed_data:
label = form.fields[field].label or field
old_value = _format_value(original_values.get(field))
new_value = _format_value(getattr(updated_claim, field))
change_notes.append(f"{label}: {old_value}{new_value}")
if change_notes:
note = _("Följande fält uppdaterades: %(fields)s") % {"fields": "; ".join(change_notes)}
claim.add_log(
action=ClaimLog.Action.DETAILS_EDITED,
performed_by=request.user,
note=note,
)
messages.success(request, _("Informationen uppdaterades."))
else:
messages.info(request, _("Inga förändringar att spara."))
return redirect(request.get_full_path())
def _build_summary(self):
now = timezone.now()
last_week = now - timedelta(days=7)
pending_qs = Claim.objects.filter(status=Claim.Status.PENDING)
approved_qs = Claim.objects.filter(status=Claim.Status.APPROVED)
rejected_qs = Claim.objects.filter(status=Claim.Status.REJECTED)
ready_to_pay_qs = approved_qs.filter(paid_at__isnull=True)
def _sum(qs):
return qs.aggregate(total=Sum("amount"))["total"] or Decimal("0")
return {
"total_claims": Claim.objects.count(),
"last_week_claims": Claim.objects.filter(created_at__gte=last_week).count(),
"pending_count": pending_qs.count(),
"approved_count": approved_qs.count(),
"rejected_count": rejected_qs.count(),
"ready_to_pay": ready_to_pay_qs.count(),
"pending_amount": _sum(pending_qs),
"approved_amount": _sum(approved_qs),
"paid_amount": _sum(approved_qs.filter(paid_at__isnull=False)),
}
@staticmethod
def _has_filtered_claims(active_filter, summary):
if active_filter == "all":
return summary["total_claims"] > 0
key_map = {
Claim.Status.PENDING: "pending_count",
Claim.Status.APPROVED: "approved_count",
Claim.Status.REJECTED: "rejected_count",
}
key = key_map.get(active_filter)
if not key:
return summary["total_claims"] > 0
return summary.get(key, 0) > 0
class ClaimExportMenuView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView):
template_name = "claims/export_placeholder.html"
permission_required = "claims.view_claim"
class MyClaimsView(LoginRequiredMixin, ListView):
template_name = "claims/my_claims.html"
context_object_name = "claims"
def get_queryset(self):
return (
Claim.objects.filter(submitted_by=self.request.user)
.select_related("project")
.prefetch_related("logs__performed_by")
.order_by("-created_at")
)
class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView):
template_name = "claims/user_management.html"
permission_required = "auth.view_user"
def _ensure_perm(self, perm_codename):
perm = f"auth.{perm_codename}"
if not self.request.user.has_perm(perm):
messages.error(self.request, _("Du saknar behörighet för åtgärden."))
return False
return True
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
users = User.objects.order_by("username")
rows = []
for user in users:
rows.append(
{
"user": user,
"permission_form": UserPermissionForm(
initial={
"user_id": user.id,
"is_staff": user.is_staff,
"grant_view": user.has_perm("claims.view_claim"),
"grant_change": user.has_perm("claims.change_claim"),
}
),
"delete_form": None
if user == self.request.user or user.is_superuser
else DeleteUserForm(initial={"user_id": user.id}),
}
)
context["user_rows"] = rows
context["create_form"] = kwargs.get("create_form") or UserManagementForm()
return context
def post(self, request, *args, **kwargs):
action = request.POST.get("action")
if action == "create":
if not self._ensure_perm("add_user"):
return redirect(request.path)
form = UserManagementForm(request.POST)
if form.is_valid():
user = User.objects.create_user(
username=form.cleaned_data["username"],
password=form.cleaned_data["password1"],
email=form.cleaned_data.get("email", ""),
first_name=form.cleaned_data.get("first_name", ""),
last_name=form.cleaned_data.get("last_name", ""),
is_staff=form.cleaned_data.get("is_staff", False),
)
self._set_perm(user, "claims.view_claim", form.cleaned_data.get("grant_view", False))
self._set_perm(user, "claims.change_claim", form.cleaned_data.get("grant_change", False))
messages.success(request, _("Användaren %(user)s skapades.") % {"user": user.username})
return redirect(request.path)
return self.render_to_response(self.get_context_data(create_form=form))
elif action == "update":
if not self._ensure_perm("change_user"):
return redirect(request.path)
form = UserPermissionForm(request.POST)
if form.is_valid():
user = get_object_or_404(User, pk=form.cleaned_data["user_id"])
if user == request.user and not form.cleaned_data["is_staff"]:
messages.error(request, _("Du kan inte ta bort din egen staff-status."))
return redirect(request.path)
user.is_staff = form.cleaned_data["is_staff"]
user.save(update_fields=["is_staff"])
self._set_perm(user, "claims.view_claim", form.cleaned_data["grant_view"])
self._set_perm(user, "claims.change_claim", form.cleaned_data["grant_change"])
messages.success(request, _("Behörigheter uppdaterades för %(user)s.") % {"user": user.username})
else:
messages.error(request, _("Kunde inte uppdatera behörigheter."))
return redirect(request.path)
elif action == "delete":
if not self._ensure_perm("delete_user"):
return redirect(request.path)
form = DeleteUserForm(request.POST)
if form.is_valid():
user = get_object_or_404(User, pk=form.cleaned_data["user_id"])
if user == request.user:
messages.error(request, _("Du kan inte ta bort ditt eget konto."))
elif user.is_superuser:
messages.error(request, _("Du kan inte ta bort en superuser via detta gränssnitt."))
else:
user.delete()
messages.warning(request, _("Användaren togs bort."))
return redirect(request.path)
messages.error(request, _("Okänd åtgärd."))
return redirect(request.path)
@staticmethod
def _set_perm(user, perm_label, should_have):
app_label, codename = perm_label.split(".")
perm = Permission.objects.filter(
content_type__app_label=app_label,
codename=codename,
).first()
if not perm:
return
if should_have:
user.user_permissions.add(perm)
else:
user.user_permissions.remove(perm)
class SubmitClaimSuccessView(TemplateView):
template_name = "claims/submit_success.html"