Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/staff/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import django_tables2 as tables
from django.db import models
from django.db.models.query import QuerySet
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.safestring import mark_safe
from django.views.generic import View

from genlab_bestilling.models import (
AnalysisOrder,
Expand Down Expand Up @@ -156,3 +158,43 @@ def order_priority(
sorted_by_priority = queryset.order_by(f"{prefix}priority_order")

return (sorted_by_priority, True)


class SafeRedirectMixin(View):
"""Mixin to provide safe redirection after a successful form submission.
This mixin checks for a 'next' parameter in the request and validates it
to ensure it is a safe URL before redirecting. If no valid 'next' URL is found,
it falls back to a method that must be implemented in the view to define
a default redirect URL.
"""

next_param = "next"

def get_fallback_url(self) -> str:
msg = "You must override get_fallback_url()"
raise NotImplementedError(msg)

def has_next_url(self) -> bool:
next_url = self.request.POST.get(self.next_param) or self.request.GET.get(
self.next_param
)
return bool(
next_url
and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={self.request.get_host()},
require_https=self.request.is_secure(),
)
)

def get_next_url(self) -> str:
next_url = self.request.POST.get(self.next_param) or self.request.GET.get(
self.next_param
)
if next_url and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={self.request.get_host()},
require_https=self.request.is_secure(),
):
return next_url
return self.get_fallback_url()
1 change: 1 addition & 0 deletions src/staff/templates/staff/components/next_url_input.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<input type="hidden" name="next" value="{{ next_url }}">
4 changes: 3 additions & 1 deletion src/staff/templates/staff/components/priority_column.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
{% load next_input %}

{% if record.is_urgent %}
<i class="fa-solid fa-exclamation fa-lg text-red-500" title="Urgent"></i>
{% else %}
<form method="post" action="{% url 'staff:order-priority' pk=record.pk %}">
{% csrf_token %}

<input type="hidden" name="next" value="{{ request.get_full_path }}" />
{% next_url_input %}

{% comment %}Outlined flag icon does not work using the <i></i> tag, so also using the SVG for the filled flag icon for consistency. If it can be fixed in the future it should.{% endcomment %}

Expand Down
4 changes: 3 additions & 1 deletion src/staff/templates/staff/sample_filter.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{% extends "staff/base_filter.html" %}
{% load crispy_forms_tags static %}
{% load render_table from django_tables2 %}
{% load next_input %}

{% block page-title %}
<div class="flex justify-between items-center w-full">
Expand Down Expand Up @@ -33,6 +34,7 @@
<form method="post" action="{% url 'staff:generate-genlab-ids' pk=order.pk %}">
{% csrf_token %}
<input type="hidden" name="sort" value="{{ request.GET.sort|default:'' }}">
{% next_url_input %}
<div class="flex gap-5 mb-5">
<button class="btn custom_order_button_green" type="submit" name="generate_genlab_ids">
<i class="fa-solid fa-id-badge"></i> Generate genlab IDs
Expand Down Expand Up @@ -68,7 +70,7 @@
<form id="prioritise-form" method="post" style="display:none;">
{% csrf_token %}
<input type="hidden" name="sample_id" id="prioritise-sample-id">
<input type="hidden" name="next" id="prioritise-next-url" value="{{ request.get_full_path }}">
{% next_url_input %}
</form>

<script>
Expand Down
2 changes: 2 additions & 0 deletions src/staff/templates/staff/sample_lab.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{% extends "staff/base.html" %}
{% load render_table from django_tables2 %}
{% load crispy_forms_tags static %}
{% load next_input %}

{% block content %}
<h3 class="text-4xl mb-5">{% block page-title %}{% if order %}{{ order }} - Samples{% else %}Samples{% endif %}{% endblock page-title %}</h3>
Expand All @@ -21,6 +22,7 @@ <h3 class="text-4xl mb-5">{% block page-title %}{% if order %}{{ order }} - Samp

<form method="post" action="{% url 'staff:order-extraction-samples-lab' order.pk %}">
{% csrf_token %}
{% next_url_input %}
{% for status in statuses %}
<button class="btn custom_order_button_green" type="submit" name="status" value="{{ status }}">
{{ status|capfirst }}
Expand Down
2 changes: 2 additions & 0 deletions src/staff/templates/staff/samplemarkeranalysis_filter.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{% extends "staff/base_filter.html" %}
{% load crispy_forms_tags static %}
{% load render_table from django_tables2 %}
{% load next_input %}

{% block page-title %}
{% if order %}{{ order }} - Samples{% else %}Samples{% endif %}
Expand All @@ -23,6 +24,7 @@

<form method="post" action="{% url 'staff:order-analysis-samples' order.pk %}">
{% csrf_token %}
{% next_url_input %}
{% for status in statuses %}
<button class="btn custom_order_button_green" type="submit" name="status" value="{{ status }}">
{% if status == "pcr" %}
Expand Down
9 changes: 9 additions & 0 deletions src/staff/templatetags/next_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django import template

register = template.Library()


@register.inclusion_tag("staff/components/next_url_input.html", takes_context=True)
def next_url_input(context: template.Context) -> dict:
request = context["request"]
return {"next_url": request.get_full_path()}
75 changes: 34 additions & 41 deletions src/staff/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.views.generic import CreateView, DetailView, TemplateView, View
from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.detail import SingleObjectMixin
from django_filters.views import FilterView
from django_tables2.views import SingleTableMixin
Expand All @@ -33,6 +32,7 @@
from nina.models import Project
from shared.sentry import report_errors
from shared.views import ActionView
from staff.mixins import SafeRedirectMixin

from .filters import (
AnalysisOrderFilter,
Expand Down Expand Up @@ -309,7 +309,9 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
return context


class OrderExtractionSamplesListView(StaffMixin, SingleTableMixin, FilterView):
class OrderExtractionSamplesListView(
StaffMixin, SingleTableMixin, SafeRedirectMixin, FilterView
):
table_pagination = False

model = Sample
Expand All @@ -318,7 +320,6 @@ class OrderExtractionSamplesListView(StaffMixin, SingleTableMixin, FilterView):

class Params:
sample_id = "sample_id"
next = "next"

def get_queryset(self) -> QuerySet[Sample]:
queryset = (
Expand Down Expand Up @@ -354,26 +355,29 @@ def get_context_data(self, **kwargs) -> dict[str, Any]:
)
return context

def get_fallback_url(self) -> str:
return reverse(
"staff:order-extraction-samples", kwargs={"pk": self.kwargs["pk"]}
)

def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
sample_id = request.POST.get(self.Params.sample_id)

if sample_id:
sample = get_object_or_404(Sample, pk=sample_id)
sample.is_prioritised = not sample.is_prioritised
sample.save()

next_url = request.POST.get(self.Params.next)
if next_url and url_has_allowed_host_and_scheme(
next_url, allowed_hosts=request.get_host()
):
return redirect(next_url)
if self.has_next_url():
return HttpResponseRedirect(self.get_next_url())

return self.get(
request, *args, **kwargs
) # Re-render the view with updated data


class OrderAnalysisSamplesListView(StaffMixin, SingleTableMixin, FilterView):
class OrderAnalysisSamplesListView(
StaffMixin, SingleTableMixin, SafeRedirectMixin, FilterView
):
PCR = "pcr"
ANALYSED = "analysed"
OUTPUT = "output"
Expand Down Expand Up @@ -411,7 +415,7 @@ def get_context_data(self, **kwargs) -> dict[str, Any]:
)
return context

def get_success_url(self) -> str:
def get_fallback_url(self) -> str:
return reverse(
"staff:order-analysis-samples", kwargs={"pk": self.get_order().pk}
)
Expand All @@ -422,7 +426,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:

if not selected_ids:
messages.error(request, "No samples selected.")
return HttpResponseRedirect(self.get_success_url())
return HttpResponseRedirect(self.get_next_url())

order = self.get_order()

Expand All @@ -435,7 +439,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
self.check_all_output(SampleMarkerAnalysis.objects.filter(order=order))
else:
self.get_order().to_processing()
return HttpResponseRedirect(self.get_success_url())
return HttpResponseRedirect(self.get_next_url())

def statuses_with_lower_or_equal_priority(self, status_name: str) -> list[str]:
index = self.VALID_STATUSES.index(status_name)
Expand Down Expand Up @@ -536,7 +540,7 @@ class SampleDetailView(StaffMixin, DetailView):
model = Sample


class SampleLabView(StaffMixin, SingleTableMixin, FilterView):
class SampleLabView(StaffMixin, SingleTableMixin, SafeRedirectMixin, FilterView):
MARKED = "marked"
PLUCKED = "plucked"
ISOLATED = "isolated"
Expand Down Expand Up @@ -583,7 +587,7 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
)
return context

def get_success_url(self) -> str:
def get_fallback_url(self) -> str:
return reverse(
"staff:order-extraction-samples-lab", kwargs={"pk": self.get_order().pk}
)
Expand All @@ -595,7 +599,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:

if not selected_ids:
messages.error(request, "No samples selected.")
return HttpResponseRedirect(self.get_success_url())
return HttpResponseRedirect(self.get_next_url())

order = self.get_order()

Expand All @@ -610,7 +614,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
self.check_all_isolated(Sample.objects.filter(order=order))
if isolation_method:
self.update_isolation_methods(samples, isolation_method, request)
return HttpResponseRedirect(self.get_success_url())
return HttpResponseRedirect(self.get_next_url())

def statuses_with_lower_or_equal_priority(self, status_name: str) -> list[str]:
index = self.VALID_STATUSES.index(status_name)
Expand Down Expand Up @@ -847,12 +851,17 @@ def form_invalid(self, form: Form) -> HttpResponse:
return HttpResponseRedirect(self.get_success_url())


class GenerateGenlabIDsView(SingleObjectMixin, StaffMixin, View):
class GenerateGenlabIDsView(SingleObjectMixin, StaffMixin, SafeRedirectMixin):
model = ExtractionOrder

def get_object(self) -> ExtractionOrder:
return ExtractionOrder.objects.get(pk=self.kwargs["pk"])

def get_fallback_url(self) -> str:
return reverse_lazy(
"staff:order-extraction-samples", kwargs={"pk": self.object.pk}
)

def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
self.object = self.get_object()

Expand All @@ -861,13 +870,13 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:

if not selected_ids:
messages.error(request, "No samples were selected.")
return HttpResponseRedirect(self.get_return_url())
return HttpResponseRedirect(self.get_next_url())

if not self.object.confirmed_at:
messages.error(
request, "Order needs to be confirmed before generating genlab IDs"
)
return HttpResponseRedirect(self.get_return_url())
return HttpResponseRedirect(self.get_next_url())

try:
self.object.order_selected_checked(selected_samples=selected_ids)
Expand All @@ -884,12 +893,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
f"Error: {str(e)}",
)

return HttpResponseRedirect(self.get_return_url())

def get_return_url(self) -> str:
return reverse_lazy(
"staff:order-extraction-samples", kwargs={"pk": self.object.pk}
)
return HttpResponseRedirect(self.get_next_url())


class ExtractionPlateCreateView(StaffMixin, CreateView):
Expand Down Expand Up @@ -988,24 +992,13 @@ def form_invalid(self, form: Form) -> HttpResponse:
return HttpResponseRedirect(self.get_success_url())


class OrderPrioritizedAdminView(StaffMixin, ActionView):
class Params:
next = "next"

def get_success_url(self) -> str:
next_url = self.request.POST.get(self.Params.next)
if next_url and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={self.request.get_host()},
require_https=self.request.is_secure(),
):
return next_url

class OrderPrioritizedAdminView(StaffMixin, SafeRedirectMixin, ActionView):
def get_fallback_url(self) -> str:
return reverse("staff:dashboard")

def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
pk = kwargs.get("pk")
order = Order.objects.get(pk=pk)
order.toggle_prioritized()

return redirect(self.get_success_url())
return redirect(self.get_next_url())