From 88f7607963d116460296b813aec744c510f0f919 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Thu, 24 Jul 2025 10:25:15 +0200 Subject: [PATCH 01/42] Improve IsolationMethodAdmin. (#404) --- src/genlab_bestilling/admin.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index d6700401..fab4b5ab 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -540,4 +540,22 @@ class AnalysisResultAdmin(ModelAdmin): @admin.register(IsolationMethod) -class IsolationMethodAdmin(ModelAdmin): ... +class IsolationMethodAdmin(ModelAdmin): + M = IsolationMethod + list_display = [ + M.name.field.name, + M.type.field.name, + ] + + search_help_text = "Search for isolation method name or species name" + search_fields = [ + M.name.field.name, + f"{M.type.field.name}__{Species.name.field.name}", + ] + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.type.field.name, unfold_filters.AutocompleteSelectFilter), + ] + autocomplete_fields = [M.type.field.name] + list_filter_submit = True + list_filter_sheet = False From cab080101ea7bfcfc11e407fd037858ec56498cc Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Thu, 24 Jul 2025 17:43:01 +0200 Subject: [PATCH 02/42] Fix order by sample status (#421) --- src/staff/mixins.py | 61 ++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/src/staff/mixins.py b/src/staff/mixins.py index 41163f50..745b0d18 100644 --- a/src/staff/mixins.py +++ b/src/staff/mixins.py @@ -1,8 +1,7 @@ -from collections.abc import Sequence from typing import Any import django_tables2 as tables -from django.db import models +from django.db.models import Case, IntegerField, Value, When 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 @@ -63,11 +62,13 @@ def order_status( ) -> tuple[QuerySet[Order], bool]: prefix = "-" if is_descending else "" sorted_by_status = queryset.annotate( - status_order=models.Case( - models.When(status=Order.OrderStatus.DELIVERED, then=0), - models.When(status=Order.OrderStatus.DRAFT, then=1), - models.When(status=Order.OrderStatus.PROCESSING, then=2), - models.When(status=Order.OrderStatus.COMPLETED, then=3), + status_order=Case( + When(status=Order.OrderStatus.DELIVERED, then=0), + When(status=Order.OrderStatus.DRAFT, then=1), + When(status=Order.OrderStatus.PROCESSING, then=2), + When(status=Order.OrderStatus.COMPLETED, then=3), + default=Value(4), + output_field=IntegerField(), ) ).order_by(f"{prefix}status_order") @@ -84,9 +85,9 @@ class SampleStatusMixinTable(tables.Table): def render_sample_status(self, value: Any, record: Sample) -> str: order = record.order + status = "Unknown" - # Determine status label - if isinstance(order, ExtractionOrder): + if order: if record.is_isolated: status = "Isolated" elif record.is_plucked: @@ -95,8 +96,6 @@ def render_sample_status(self, value: Any, record: Sample) -> str: status = "Marked" else: status = "Not started" - else: - status = getattr(order, "sample_status", "Unknown") # Define color map status_colors = { @@ -115,25 +114,19 @@ def render_sample_status(self, value: Any, record: Sample) -> str: ) def order_sample_status( - self, records: Sequence[Any], is_descending: bool - ) -> tuple[list[Any], bool]: - def get_status_value(record: Any) -> int: - if isinstance(record.order, ExtractionOrder): - if record.is_isolated: - return self.STATUS_PRIORITY["Isolated"] - if record.is_plucked: - return self.STATUS_PRIORITY["Plucked"] - if record.is_marked: - return self.STATUS_PRIORITY["Marked"] - return self.STATUS_PRIORITY["Not started"] - - # fallback for other types of orders - return self.STATUS_PRIORITY.get( - getattr(record.order, "sample_status", ""), -1 - ) - - sorted_records = sorted(records, key=get_status_value, reverse=is_descending) - return (sorted_records, True) + self, queryset: QuerySet[Sample], is_descending: bool + ) -> tuple[QuerySet[Sample], bool]: + prefix = "-" if is_descending else "" + status_order = Case( + When(is_isolated=True, then=Value(3)), + When(is_plucked=True, then=Value(2)), + When(is_marked=True, then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ) + annotated_queryset = queryset.annotate(sample_status_order=status_order) + sorted_queryset = annotated_queryset.order_by(f"{prefix}sample_status_order") + return (sorted_queryset, True) class PriorityMixinTable(tables.Table): @@ -148,11 +141,11 @@ def order_priority( ) -> tuple[QuerySet[Order], bool]: prefix = "-" if is_descending else "" queryset = queryset.annotate( - priority_order=models.Case( - models.When(is_urgent=True, then=2), - models.When(is_prioritized=True, then=1), + priority_order=Case( + When(is_urgent=True, then=2), + When(is_prioritized=True, then=1), default=0, - output_field=models.IntegerField(), + output_field=IntegerField(), ) ) sorted_by_priority = queryset.order_by(f"{prefix}priority_order") From f55f57b33383e33112e0b4ad1701050867fb939a Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Fri, 25 Jul 2025 07:35:52 +0200 Subject: [PATCH 03/42] Improve dashboard overflow (#429) --- .../templates/core/partials/navigation.html | 2 +- src/staff/templates/staff/base.html | 20 +++++++++---------- src/templates/core/partials/navigation.html | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/capps/core/templates/core/partials/navigation.html b/src/capps/core/templates/core/partials/navigation.html index 70f8f3ce..b60c8a9a 100755 --- a/src/capps/core/templates/core/partials/navigation.html +++ b/src/capps/core/templates/core/partials/navigation.html @@ -1,7 +1,7 @@ {% load i18n %}
-
+
+ -
+
{% if messages %} {% for message in messages %}
diff --git a/src/templates/core/partials/navigation.html b/src/templates/core/partials/navigation.html index c7bd16f2..d436729b 100644 --- a/src/templates/core/partials/navigation.html +++ b/src/templates/core/partials/navigation.html @@ -1,7 +1,7 @@ {% load i18n static %}
Date: Fri, 25 Jul 2025 08:23:27 +0200 Subject: [PATCH 04/42] Refactor SafeRedirectMixin to remove repetitive code (#419) Co-authored-by: Morten Madsen Lyngstad --- src/staff/mixins.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/staff/mixins.py b/src/staff/mixins.py index 745b0d18..db148ad7 100644 --- a/src/staff/mixins.py +++ b/src/staff/mixins.py @@ -167,10 +167,13 @@ 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( + def get_next_url_from_request(self) -> str | None: + return self.request.POST.get(self.next_param) or self.request.GET.get( self.next_param ) + + def has_next_url(self) -> bool: + next_url = self.get_next_url_from_request() return bool( next_url and url_has_allowed_host_and_scheme( @@ -181,13 +184,7 @@ def has_next_url(self) -> bool: ) 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(), - ): + next_url = self.get_next_url_from_request() + if next_url and self.has_next_url(): return next_url return self.get_fallback_url() From 55f6603380076c67d6ade8e66da3e50d8726de43 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Fri, 25 Jul 2025 08:27:27 +0200 Subject: [PATCH 05/42] Add filtering for sample status (#425) Co-authored-by: Morten Madsen Lyngstad --- src/staff/filters.py | 82 ++++++++++++++++++++------------------------ 1 file changed, 38 insertions(+), 44 deletions(-) diff --git a/src/staff/filters.py b/src/staff/filters.py index d3720407..0b54252a 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -5,7 +5,7 @@ from django import forms from django.db.models import QuerySet from django.http import HttpRequest -from django_filters import BooleanFilter, CharFilter, ChoiceFilter +from django_filters import CharFilter, ChoiceFilter from capps.users.models import User from genlab_bestilling.models import ( @@ -250,7 +250,27 @@ class Meta: ] +class SampleStatusWidget(forms.Select): + def __init__(self, attrs: dict[str, Any] | None = None): + choices = ( + ("", "---------"), + ("true", "Yes"), + ("false", "No"), + ) + super().__init__(choices=choices, attrs=attrs) + + class SampleFilter(filters.FilterSet): + is_marked = filters.BooleanFilter( + label="Marked", method="filter_boolean", widget=SampleStatusWidget + ) + is_plucked = filters.BooleanFilter( + label="Plucked", method="filter_boolean", widget=SampleStatusWidget + ) + is_isolated = filters.BooleanFilter( + label="Isolated", method="filter_boolean", widget=SampleStatusWidget + ) + def __init__( self, data: dict[str, Any] | None = None, @@ -280,9 +300,19 @@ class Meta: "year", "location", "pop_id", - "type", + "is_marked", + "is_plucked", + "is_isolated", ] + def filter_boolean(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: + val = self.data.get(name) + if str(val) == "true": + return queryset.filter(**{name: True}) + if str(val) == "false": + return queryset.filter(**{name: False}) + return queryset + class ExtractionPlateFilter(filters.FilterSet): class Meta: @@ -293,51 +323,15 @@ class Meta: class SampleLabFilter(filters.FilterSet): - is_marked = BooleanFilter( - label="Marked", - method="filter_boolean", - widget=forms.Select( - choices=( - ("", "---------"), - ("true", "Yes"), - ("false", "No"), - ), - attrs={ - "class": "w-full border border-gray-300 rounded-lg py-2 px-4 text-gray-700", # noqa: E501 - }, - ), + is_marked = filters.BooleanFilter( + label="Marked", method="filter_boolean", widget=SampleStatusWidget ) - - is_plucked = BooleanFilter( - label="Plucked", - method="filter_boolean", - widget=forms.Select( - choices=( - ("", "---------"), - ("true", "Yes"), - ("false", "No"), - ), - attrs={ - "class": "w-full border border-gray-300 rounded-lg py-2 px-4 text-gray-700", # noqa: E501 - }, - ), + is_plucked = filters.BooleanFilter( + label="Plucked", method="filter_boolean", widget=SampleStatusWidget ) - - is_isolated = BooleanFilter( - label="Isolated", - method="filter_boolean", - widget=forms.Select( - choices=( - ("", "---------"), - ("true", "Yes"), - ("false", "No"), - ), - attrs={ - "class": "w-full border border-gray-300 rounded-lg py-2 px-4 text-gray-700", # noqa: E501 - }, - ), + is_isolated = filters.BooleanFilter( + label="Isolated", method="filter_boolean", widget=SampleStatusWidget ) - genlab_id_min = ChoiceFilter( label="Genlab ID (From)", method="filter_genlab_id_range", From a7ea30f05e3d48d8e54595ecda6bcf744982db43 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Fri, 25 Jul 2025 09:59:04 +0200 Subject: [PATCH 06/42] 423 remove is seen and is prioritized from table in a order (#432) * Restrict fields shown in placing an order * Keep is_urgent as a visible field --- src/capps/core/templatetags/core.py | 22 ++++++++++++++++++- .../staff/equipmentorder_detail.html | 2 +- .../staff/extractionplate_detail.html | 2 +- src/staff/templates/staff/project_detail.html | 2 +- src/staff/templates/staff/sample_detail.html | 2 +- src/templates/components.yaml | 1 + .../components/object-detail-staff.html | 20 +++++++++++++++++ src/templates/components/object-detail.html | 2 +- 8 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 src/templates/components/object-detail-staff.html diff --git a/src/capps/core/templatetags/core.py b/src/capps/core/templatetags/core.py index f3063e5a..719b5657 100644 --- a/src/capps/core/templatetags/core.py +++ b/src/capps/core/templatetags/core.py @@ -30,7 +30,13 @@ def render(field: Any, instance: Model) -> tuple: return None, None -IGNORED_FIELDS = ["tagged_items"] +IGNORED_FIELDS = [ + "tagged_items", + "is_seen", + "is_prioritized", + "responsible_staff", +] +IGNORED_FIELDS_STAFF = ["tagged_items"] @register.filter @@ -45,3 +51,17 @@ def get_fields(instance: Model, fields: str | None = None) -> Any: and field.name not in IGNORED_FIELDS ), ) + + +@register.filter +def get_fields_staff(instance: Model, fields: str | None = None) -> Any: + return filter( + lambda x: x[0], + ( + render(field, instance) + for field in instance._meta.get_fields() + if (not fields or field.name in fields.split(" ")) + and not isinstance(field, TaggableManager) + and field.name not in IGNORED_FIELDS_STAFF + ), + ) diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index 061dee90..fc6a68ac 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -14,7 +14,7 @@

Order {{ object }}

Back
- {% object-detail object=object %} + {% object-detail-staff object=object %}
Requested Equipment
diff --git a/src/staff/templates/staff/extractionplate_detail.html b/src/staff/templates/staff/extractionplate_detail.html index e688e864..38f6fb67 100644 --- a/src/staff/templates/staff/extractionplate_detail.html +++ b/src/staff/templates/staff/extractionplate_detail.html @@ -7,7 +7,7 @@

Plate {{ object }}

- {% object-detail object=object %} + {% object-detail-staff object=object %}
Back diff --git a/src/staff/templates/staff/project_detail.html b/src/staff/templates/staff/project_detail.html index 4aa8eb2a..535fc839 100644 --- a/src/staff/templates/staff/project_detail.html +++ b/src/staff/templates/staff/project_detail.html @@ -14,6 +14,6 @@

Project {{ object }}

{% endif %}
- {% object-detail object=object %} + {% object-detail-staff object=object %} {% endblock %} diff --git a/src/staff/templates/staff/sample_detail.html b/src/staff/templates/staff/sample_detail.html index 477ba47d..6eb55d3f 100644 --- a/src/staff/templates/staff/sample_detail.html +++ b/src/staff/templates/staff/sample_detail.html @@ -7,7 +7,7 @@

Sample {{ object }}

- {% object-detail object=object %} + {% object-detail-staff object=object %}
Back diff --git a/src/templates/components.yaml b/src/templates/components.yaml index c7e7ae87..91fb90ad 100644 --- a/src/templates/components.yaml +++ b/src/templates/components.yaml @@ -3,4 +3,5 @@ components: table-cell: "components/table-cell.html" formset: "components/formset.html" object-detail: "components/object-detail.html" + object-detail-staff: "components/object-detail-staff.html" action-button: "components/action-button.html" diff --git a/src/templates/components/object-detail-staff.html b/src/templates/components/object-detail-staff.html new file mode 100644 index 00000000..ec98a596 --- /dev/null +++ b/src/templates/components/object-detail-staff.html @@ -0,0 +1,20 @@ +{% load core %} + +{% fragment as headers %} + {% #table-cell header=True %}Key{% /table-cell %} + {% #table-cell header=True %}Value{% /table-cell %} +{% endfragment %} + +{% var fields=fields %} +{% var object=object %} + +
+ {% #table headers=headers %} + {% for f, v in object|get_fields_staff:fields %} + + {% #table-cell %}{{ f|capfirst }}{% /table-cell %} + {% #table-cell %}{{ v }}{% /table-cell %} + + {% endfor %} + {% /table %} +
diff --git a/src/templates/components/object-detail.html b/src/templates/components/object-detail.html index f1b75f46..05600816 100644 --- a/src/templates/components/object-detail.html +++ b/src/templates/components/object-detail.html @@ -12,7 +12,7 @@ {% #table headers=headers %} {% for f, v in object|get_fields:fields %} - {% #table-cell %}{{ f }}{% /table-cell %} + {% #table-cell %}{{ f|capfirst }}{% /table-cell %} {% #table-cell %}{{ v }}{% /table-cell %} {% endfor %} From 0eef7dc874b3adbaf1e924edadc45043f109fe49 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:01:43 +0200 Subject: [PATCH 07/42] If an order is started (has genlab ids) it cannot be deleted by researchers (#434) --- .../analysisorder_detail.html | 2 ++ .../extractionorder_detail.html | 3 ++- src/genlab_bestilling/views.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html index 11812109..982a072d 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html @@ -44,7 +44,9 @@
Samples to analyze
{% url 'genrequest-order-clone' genrequest_id=object.genrequest_id pk=object.id as clone_order_url %} {% action-button action=confirm_order_url class="btn custom_order_button" submit_text="Deliver order" csrf_token=csrf_token %} {% action-button action=clone_order_url class="btn custom_order_button" submit_text="Clone Order" csrf_token=csrf_token %} + {% if all_samples_have_no_genlab_id %} Delete + {% endif %} {% elif object.status == object.OrderStatus.DELIVERED %} Samples {% endif %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html index 0342d567..044bbcde 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html @@ -24,8 +24,9 @@

Order {{ object }}

{% url 'genrequest-order-confirm' genrequest_id=object.genrequest_id pk=object.id as confirm_order_url %} {% action-button action=confirm_order_url class="btn custom_order_button" submit_text="Deliver order" csrf_token=csrf_token %} - + {% if all_samples_have_no_genlab_id %} Delete + {% endif %} {% endif %} Samples diff --git a/src/genlab_bestilling/views.py b/src/genlab_bestilling/views.py index ed8e464a..19b6874b 100644 --- a/src/genlab_bestilling/views.py +++ b/src/genlab_bestilling/views.py @@ -475,6 +475,15 @@ def get_queryset(self) -> QuerySet: .prefetch_related("sample_markers", "markers") ) + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + order = self.object + all_samples_have_no_genlab_id = not order.samples.exclude( + genlab_id__isnull=True + ).exists() + context["all_samples_have_no_genlab_id"] = all_samples_have_no_genlab_id + return context + class ExtractionOrderDetailView(GenrequestNestedMixin, DetailView): model = ExtractionOrder @@ -499,6 +508,15 @@ def gen_crumbs(self) -> list[tuple]: (str(self.object), ""), ] + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + order = self.object + all_samples_have_no_genlab_id = not order.samples.exclude( + genlab_id__isnull=True + ).exists() + context["all_samples_have_no_genlab_id"] = all_samples_have_no_genlab_id + return context + class GenrequestOrderDeleteView(GenrequestNestedMixin, DeleteView): model = Order From dc940d7b44ba4f3e812770a2ea5fc774aa0fa498 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:12:56 +0200 Subject: [PATCH 08/42] When all samples are isolated/analysed, the order is set to completed (#439) --- src/genlab_bestilling/models.py | 4 ++++ src/staff/views.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 14a06c0a..783e3607 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -332,6 +332,10 @@ def to_processing(self) -> None: self.status = Order.OrderStatus.PROCESSING self.save() + def to_completed(self) -> None: + self.status = Order.OrderStatus.COMPLETED + self.save() + def toggle_seen(self) -> None: self.is_seen = not self.is_seen self.save() diff --git a/src/staff/views.py b/src/staff/views.py index 982a10d7..85cb3a77 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -483,7 +483,7 @@ def assign_status_to_samples( # If they are, it updates the order status to completed def check_all_output(self, analyses: models.QuerySet) -> None: if not analyses.filter(is_outputted=False).exists(): - self.get_order().to_next_status() + self.get_order().to_completed() messages.success( self.request, "All samples have an output. The order status is updated to completed.", @@ -641,7 +641,7 @@ def assign_status_to_samples( # If they are, it updates the order status to completed def check_all_isolated(self, samples: QuerySet) -> None: if not samples.filter(is_isolated=False).exists(): - self.get_order().to_next_status() + self.get_order().to_completed() messages.success( self.request, "All samples are isolated. The order status is updated to completed.", From cbd2e39950451ff531d3c8797e52e9f281323281 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:19:55 +0200 Subject: [PATCH 09/42] Use tuples. (#418) --- src/capps/users/forms.py | 2 +- src/genlab_bestilling/api/serializers.py | 44 ++++++------- src/genlab_bestilling/forms.py | 26 ++++---- src/genlab_bestilling/tables.py | 38 +++++------ src/nina/forms.py | 6 +- src/nina/tables.py | 4 +- src/staff/filters.py | 28 ++++---- src/staff/forms.py | 2 +- src/staff/tables.py | 84 ++++++++++++------------ 9 files changed, 116 insertions(+), 118 deletions(-) diff --git a/src/capps/users/forms.py b/src/capps/users/forms.py index 43daad0b..75f21e86 100644 --- a/src/capps/users/forms.py +++ b/src/capps/users/forms.py @@ -20,7 +20,7 @@ class UserAdminCreationForm(admin_forms.UserCreationForm): class Meta(admin_forms.UserCreationForm.Meta): # type: ignore[name-defined] model = User - fields = ["email"] + fields = ("email",) field_classes = {"email": EmailField} error_messages = { "email": {"unique": _("This email has already been taken.")}, diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index 01de72ea..45740ab4 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -31,19 +31,19 @@ class KoncivSerializer(EnumSerializer): class MarkerSerializer(serializers.ModelSerializer): class Meta: model = Marker - fields = ["name"] + fields = ("name",) class SampleTypeSerializer(serializers.ModelSerializer): class Meta: model = SampleType - fields = ["id", "name"] + fields = ("id", "name") class SpeciesSerializer(serializers.ModelSerializer): class Meta: model = Species - fields = ["id", "name"] + fields = ("id", "name") class LocationSerializer(serializers.ModelSerializer): @@ -51,7 +51,7 @@ class LocationSerializer(serializers.ModelSerializer): class Meta: model = Location - fields = ["id", "name"] + fields = ("id", "name") def get_name(self, obj: Location) -> str: return str(obj) @@ -60,7 +60,7 @@ def get_name(self, obj: Location) -> str: class LocationCreateSerializer(serializers.ModelSerializer): class Meta: model = Location - fields = ["id", "name"] + fields = ("id", "name") class SampleSerializer(serializers.ModelSerializer): @@ -77,7 +77,7 @@ def get_has_error(self, obj: Sample) -> bool: class Meta: model = Sample - fields = [ + fields = ( "id", "order", "guid", @@ -91,7 +91,7 @@ class Meta: "type", "has_error", "genlab_id", - ] + ) class SampleCSVSerializer(serializers.ModelSerializer): @@ -109,7 +109,7 @@ class SampleCSVSerializer(serializers.ModelSerializer): class Meta: model = Sample - fields = [ + fields = ( "order", "guid", "name", @@ -128,7 +128,7 @@ class Meta: "is_plucked", "is_isolated", "internal_note", - ] + ) def get_fish_id(self, obj: Sample) -> str: return obj.fish_id or "-" @@ -168,12 +168,12 @@ def get_internal_note(self, obj: Sample) -> str: class LabelCSVSerializer(serializers.ModelSerializer): class Meta: model = Sample - fields = [ + fields = ( "genlab_id", "guid", "name", "fish_id", - ] + ) def get_fish_id(self, obj: Sample) -> str: return obj.fish_id or "-" @@ -190,7 +190,7 @@ def get_has_error(self, obj: Sample) -> bool: class Meta: model = Sample - fields = [ + fields = ( "id", "order", "guid", @@ -202,7 +202,7 @@ class Meta: "location", "type", "has_error", - ] + ) class SampleBulkSerializer(serializers.ModelSerializer): @@ -219,7 +219,7 @@ class SampleBulkSerializer(serializers.ModelSerializer): class Meta: model = Sample - fields = [ + fields = ( "order", "species", "year", @@ -229,23 +229,23 @@ class Meta: "type", "location", "quantity", - ] + ) class SampleDeleteBulkSerializer(serializers.ModelSerializer): class Meta: model = Sample - fields = ["order"] + fields = ("order",) class GenrequestSerializer(serializers.ModelSerializer): class Meta: model = Genrequest - fields = [ + fields = ( "id", "project", "area", - ] + ) class ExtractionSerializer(serializers.ModelSerializer): @@ -255,7 +255,7 @@ class ExtractionSerializer(serializers.ModelSerializer): class Meta: model = ExtractionOrder - fields = ["id", "genrequest", "species", "sample_types", "needs_guid"] + fields = ("id", "genrequest", "species", "sample_types", "needs_guid") class AnalysisSerializer(serializers.ModelSerializer): @@ -264,7 +264,7 @@ class AnalysisSerializer(serializers.ModelSerializer): class Meta: model = AnalysisOrder - fields = ["id", "genrequest", "markers"] + fields = ("id", "genrequest", "markers") class SampleMarkerAnalysisSerializer(serializers.ModelSerializer): @@ -272,7 +272,7 @@ class SampleMarkerAnalysisSerializer(serializers.ModelSerializer): class Meta: model = SampleMarkerAnalysis - fields = ["id", "order", "sample", "marker"] + fields = ("id", "order", "sample", "marker") class SampleMarkerAnalysisBulkSerializer(serializers.ModelSerializer): @@ -285,7 +285,7 @@ class SampleMarkerAnalysisBulkSerializer(serializers.ModelSerializer): class Meta: model = SampleMarkerAnalysis - fields = ["order", "samples", "markers"] + fields = ("order", "samples", "markers") class SampleMarkerAnalysisBulkDeleteSerializer(serializers.Serializer): diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index 4c61ac5d..dcb382c7 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -58,7 +58,7 @@ def save(self, commit: bool = True) -> Genrequest: class Meta: model = Genrequest - fields = [ + fields = ( "project", "name", "area", @@ -69,7 +69,7 @@ class Meta: "expected_total_samples", "expected_samples_delivery_date", "expected_analysis_delivery_date", - ] + ) widgets = { "area": Selectize(search_lookup="name_icontains"), "samples_owner": Selectize(search_lookup="name_icontains"), @@ -101,7 +101,7 @@ def __init__(self, *args, **kwargs): ) class Meta(GenrequestForm.Meta): - fields = [ + fields = ( "area", "name", "species", @@ -110,7 +110,7 @@ class Meta(GenrequestForm.Meta): "expected_samples_delivery_date", "expected_analysis_delivery_date", "expected_total_samples", - ] + ) # type: ignore[assignment] class EquipmentOrderForm(FormMixin, forms.ModelForm): @@ -133,7 +133,7 @@ def save(self, commit: bool = True) -> EquipmentOrder: class Meta: model = EquipmentOrder - fields = [ + fields = ( "name", "needs_guid", # "species", @@ -143,7 +143,7 @@ class Meta: "is_urgent", "contact_person", "contact_email", - ] + ) widgets = { # "species": DualSortableSelector( # search_lookup="name_icontains", @@ -175,7 +175,7 @@ def clean(self) -> None: class Meta: model = EquimentOrderQuantity - fields = ["id", "equipment", "buffer", "buffer_quantity", "quantity"] + fields = ("id", "equipment", "buffer", "buffer_quantity", "quantity") widgets = { "equipment": Selectize(search_lookup="name_icontains"), "buffer": Selectize(search_lookup="name_icontains"), @@ -272,7 +272,7 @@ def save(self, commit: bool = True) -> ExtractionOrder: class Meta: model = ExtractionOrder - fields = [ + fields = ( "name", "needs_guid", "species", @@ -284,7 +284,7 @@ class Meta: "is_urgent", "contact_person", "contact_email", - ] + ) widgets = { "species": DualSortableSelector( search_lookup="name_icontains", @@ -365,7 +365,7 @@ def clean(self) -> None: class Meta: model = AnalysisOrder - fields = [ + fields = ( "name", "from_order", "markers", @@ -375,7 +375,7 @@ class Meta: "is_urgent", "contact_person", "contact_email", - ] + ) widgets = { "name": TextInput( attrs={"df-show": ".from_order==''||.use_all_samples=='False'"} @@ -393,7 +393,7 @@ class Meta: class AnalysisOrderUpdateForm(AnalysisOrderForm): class Meta(AnalysisOrderForm.Meta): - fields = [ + fields = ( "name", "markers", # "from_order", @@ -403,7 +403,7 @@ class Meta(AnalysisOrderForm.Meta): "is_urgent", "contact_person", "contact_email", - ] + ) # type: ignore[assignment] def __init__(self, *args, genrequest: Genrequest, **kwargs): super().__init__(*args, genrequest=genrequest, **kwargs) diff --git a/src/genlab_bestilling/tables.py b/src/genlab_bestilling/tables.py index 11a68dc8..adfb540a 100644 --- a/src/genlab_bestilling/tables.py +++ b/src/genlab_bestilling/tables.py @@ -17,17 +17,17 @@ class BaseOrderTable(tables.Table): class Meta: model = Order - fields = [ + fields = ( "name", "status", "created_at", "last_modified_at", - ] - sequence = [ + ) + sequence = ( "id", "name", "status", - ] + ) empty_text = "No Orders" def render_id(self, record: Any) -> str: @@ -39,7 +39,7 @@ class OrderTable(BaseOrderTable): class Meta: model = Order - fields = [ + fields = ( "name", "status", "polymorphic_ctype", @@ -47,13 +47,13 @@ class Meta: "genrequest__project", "created_at", "last_modified_at", - ] - sequence = [ + ) + sequence = ( "id", "name", "status", "polymorphic_ctype", - ] + ) empty_text = "No Orders" def render_polymorphic_ctype(self, value: Any) -> str: @@ -68,7 +68,7 @@ class GenrequestTable(tables.Table): class Meta: model = Genrequest - fields = [ + fields = ( "project_id", "name", "area", @@ -77,7 +77,7 @@ class Meta: "expected_total_samples", "expected_samples_delivery_date", "expected_analysis_delivery_date", - ] + ) empty_text = "No projects" @@ -92,7 +92,7 @@ class SampleTable(tables.Table): class Meta: model = Sample - fields = [ + fields = ( "guid", "name", "species", @@ -103,7 +103,7 @@ class Meta: "notes", "genlab_id", "plate_positions", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Samples" @@ -120,7 +120,7 @@ class AnalysisSampleTable(tables.Table): class Meta: model = Sample - fields = [ + fields = ( "sample__genlab_id", "markers_names", "sample__guid", @@ -130,7 +130,7 @@ class Meta: "sample__year", "sample__pop_id", "sample__location__name", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Samples" @@ -145,11 +145,11 @@ class AnalysisOrderTable(BaseOrderTable): class Meta(BaseOrderTable.Meta): model = AnalysisOrder - fields = BaseOrderTable.Meta.fields + [ + fields = BaseOrderTable.Meta.fields + ( "genrequest", "genrequest__project", "return_samples", - ] + ) # type: ignore[assignment] class ExtractionOrderTable(BaseOrderTable): @@ -161,7 +161,7 @@ class ExtractionOrderTable(BaseOrderTable): class Meta(BaseOrderTable.Meta): model = ExtractionOrder - fields = BaseOrderTable.Meta.fields + [ + fields = BaseOrderTable.Meta.fields + ( "species", "sample_types", "internal_status", @@ -170,7 +170,7 @@ class Meta(BaseOrderTable.Meta): "pre_isolated", "genrequest", "genrequest__project", - ] + ) # type: ignore[assignment] class EquipmentOrderTable(BaseOrderTable): @@ -182,4 +182,4 @@ class EquipmentOrderTable(BaseOrderTable): class Meta(BaseOrderTable.Meta): model = EquipmentOrder - fields = BaseOrderTable.Meta.fields + ["needs_guid", "sample_types"] + fields = BaseOrderTable.Meta.fields + ("needs_guid", "sample_types") # type: ignore[assignment] diff --git a/src/nina/forms.py b/src/nina/forms.py index fd6733d8..12028098 100644 --- a/src/nina/forms.py +++ b/src/nina/forms.py @@ -27,7 +27,7 @@ def save(self, commit: bool = True) -> Model: class Meta: model = ProjectMembership - fields = ["id", "user", "role"] + fields = ("id", "user", "role") class ProjectMembershipCollection(ContextFormCollection): @@ -90,7 +90,7 @@ def clean_number(self) -> int: class Meta: model = Project - fields = ["number", "name"] + fields = ("number", "name") class ProjectUpdateForm(forms.ModelForm): @@ -98,4 +98,4 @@ class ProjectUpdateForm(forms.ModelForm): class Meta: model = Project - fields = ["name"] + fields = ("name",) diff --git a/src/nina/tables.py b/src/nina/tables.py index fd37d8a4..711b3013 100644 --- a/src/nina/tables.py +++ b/src/nina/tables.py @@ -9,10 +9,10 @@ class MyProjectsTable(tables.Table): class Meta: model = ProjectMembership - fields = ["project", "role", "project__active", "project__verified_at"] + fields = ("project", "role", "project__active", "project__verified_at") class MembersTable(tables.Table): class Meta: model = ProjectMembership - fields = ["user", "role"] + fields = ("user", "role") diff --git a/src/staff/filters.py b/src/staff/filters.py index 0b54252a..f37b5b46 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -76,13 +76,13 @@ class AnalysisOrderFilter(filters.FilterSet): class Meta: model = AnalysisOrder - fields = [ + fields = ( "id", "status", "genrequest__area", "responsible_staff", "genrequest__species", - ] + ) class ExtractionOrderFilter(filters.FilterSet): @@ -141,13 +141,13 @@ class ExtractionOrderFilter(filters.FilterSet): class Meta: model = ExtractionOrder - fields = [ + fields = ( "id", "status", "genrequest__area", "responsible_staff", "genrequest__species", - ] + ) class OrderSampleFilter(filters.FilterSet): @@ -187,12 +187,12 @@ def __init__( class Meta: model = Sample - fields = [ + fields = ( "genlab_id", "name", "species", "type", - ] + ) class SampleMarkerOrderFilter(filters.FilterSet): @@ -239,7 +239,7 @@ def __init__( class Meta: model = SampleMarkerAnalysis - fields = [ + fields = ( "sample__genlab_id", "sample__type", "sample__extractions", @@ -247,7 +247,7 @@ class Meta: # "PCR", # "fluidigm", # "output", - ] + ) class SampleStatusWidget(forms.Select): @@ -292,7 +292,7 @@ def __init__( class Meta: model = Sample - fields = [ + fields = ( "name", "genlab_id", "species", @@ -303,7 +303,7 @@ class Meta: "is_marked", "is_plucked", "is_isolated", - ] + ) def filter_boolean(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: val = self.data.get(name) @@ -317,9 +317,7 @@ def filter_boolean(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: class ExtractionPlateFilter(filters.FilterSet): class Meta: model = ExtractionPlate - fields = [ - "id", - ] + fields = ("id",) class SampleLabFilter(filters.FilterSet): @@ -393,7 +391,7 @@ def __init__( class Meta: model = Sample - fields = [ + fields = ( "genlab_id_min", "genlab_id_max", "is_marked", @@ -403,7 +401,7 @@ class Meta: "isolation_method", # "fluidigm", # "output", - ] + ) def filter_genlab_id_range( self, queryset: QuerySet, name: str, value: Any diff --git a/src/staff/forms.py b/src/staff/forms.py index 62a6b6a7..01e09d6f 100644 --- a/src/staff/forms.py +++ b/src/staff/forms.py @@ -9,7 +9,7 @@ class ExtractionPlateForm(ModelForm): class Meta: model = ExtractionPlate - fields = ["name"] + fields = ("name",) class OrderStaffForm(forms.Form): diff --git a/src/staff/tables.py b/src/staff/tables.py index 0d2e4b80..318d7910 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -35,7 +35,7 @@ class ProjectTable(tables.Table): class Meta: model = Project - fields = ["number", "name", "active", "verified_at"] + fields = ("number", "name", "active", "verified_at") class OrderTable(OrderStatusMixinTable, PriorityMixinTable): @@ -72,7 +72,7 @@ def render_id(self, record: Order) -> str: ) class Meta: - fields = [ + fields = ( "priority", "id", "status", @@ -81,9 +81,9 @@ class Meta: "species", "total_samples", "responsible_staff", - ] + ) empty_text = "No Orders" - order_by = ["-priority", "status"] + order_by = ("-priority", "status") class AnalysisOrderTable(OrderTable): @@ -110,8 +110,8 @@ class AnalysisOrderTable(OrderTable): class Meta(OrderTable.Meta): model = AnalysisOrder - fields = OrderTable.Meta.fields + ["markers", "expected_delivery_date"] - sequence = [ + fields = OrderTable.Meta.fields + ("markers", "expected_delivery_date") # type: ignore[assignment] + sequence = ( "priority", "id", "status", @@ -122,7 +122,7 @@ class Meta(OrderTable.Meta): "markers", "responsible_staff", "expected_delivery_date", - ] + ) class ExtractionOrderTable(OrderTable): @@ -148,11 +148,11 @@ def render_total_samples_isolated(self, record: ExtractionOrder) -> str: class Meta(OrderTable.Meta): model = ExtractionOrder - fields = OrderTable.Meta.fields + [ + fields = OrderTable.Meta.fields + ( "total_samples_isolated", "confirmed_at", - ] - sequence = [ + ) # type: ignore[assignment] + sequence = ( "priority", "id", "status", @@ -163,7 +163,7 @@ class Meta(OrderTable.Meta): "total_samples_isolated", "responsible_staff", "confirmed_at", - ] + ) class EquipmentOrderTable(tables.Table): @@ -192,7 +192,7 @@ class EquipmentOrderTable(tables.Table): class Meta(OrderTable.Meta): model = EquipmentOrder - fields = [ + fields = ( "name", "status", "genrequest", @@ -206,10 +206,10 @@ class Meta(OrderTable.Meta): "is_seen", "needs_guid", "sample_types", - ] - sequence = ["is_seen", "is_urgent", "status", "id", "name"] + ) # type: ignore[assignment] + sequence = ("is_seen", "is_urgent", "status", "id", "name") empty_text = "No Orders" - order_by = ["-is_urgent", "last_modified_at", "created_at"] + order_by = ("-is_urgent", "last_modified_at", "created_at") # type: ignore[assignment] def render_id(self, record: Any) -> str: return str(record) @@ -255,7 +255,7 @@ class SampleBaseTable(tables.Table): class Meta: model = Sample - fields = [ + fields = ( "genlab_id", "guid", "name", @@ -266,9 +266,9 @@ class Meta: "location", "notes", "plate_positions", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} - sequence = [ + sequence = ( "checked", "is_prioritised", "genlab_id", @@ -276,8 +276,8 @@ class Meta: "name", "species", "type", - ] - order_by = ["-is_prioritised", "species", "genlab_id"] + ) + order_by = ("-is_prioritised", "species", "genlab_id") empty_text = "No Samples" @@ -364,7 +364,7 @@ class SampleStatusTable(tables.Table): class Meta: model = Sample - fields = [ + fields = ( "checked", "genlab_id", "marked", @@ -373,8 +373,8 @@ class Meta: "internal_note", "isolation_method", "type", - ] - sequence = [ + ) + sequence = ( "checked", "genlab_id", "type", @@ -383,8 +383,8 @@ class Meta: "isolated", "internal_note", "isolation_method", - ] - order_by = ["genlab_id"] + ) + order_by = ("genlab_id",) def render_checked(self, record: Any) -> str: return mark_safe( # noqa: S308 @@ -394,7 +394,7 @@ def render_checked(self, record: Any) -> str: class OrderExtractionSampleTable(SampleBaseTable): class Meta(SampleBaseTable.Meta): - exclude = ["pop_id", "guid", "plate_positions"] + exclude = ("pop_id", "guid", "plate_positions") class OrderAnalysisSampleTable(tables.Table): @@ -437,7 +437,7 @@ class OrderAnalysisSampleTable(tables.Table): class Meta: model = SampleMarkerAnalysis - fields = [ + fields = ( "checked", "sample__genlab_id", "sample__type", @@ -447,7 +447,7 @@ class Meta: "is_outputted", "sample__internal_note", "sample__order", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Samples" @@ -466,13 +466,13 @@ class PlateTable(tables.Table): class Meta: model = ExtractionPlate - fields = [ + fields = ( "id", "name", "created_at", "last_updated_at", "samples_count", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Plates" @@ -528,18 +528,18 @@ def render_order__id(self, value: int, record: Sample) -> str: return str(record.order) class Meta(SampleBaseTable.Meta): - fields = SampleBaseTable.Meta.fields + [ + fields = SampleBaseTable.Meta.fields + ( "order__id", "order__status", "order__genrequest__project", - ] - sequence = SampleBaseTable.Meta.sequence + [ + ) # type: ignore[assignment] + sequence = SampleBaseTable.Meta.sequence + ( "sample_status", "order__id", "order__status", "notes", - ] - exclude = ["plate_positions", "checked", "is_prioritised"] + ) # type: ignore[assignment] + exclude = ("plate_positions", "checked", "is_prioritised") class UrgentOrderTable(StaffIDMixinTable, OrderStatusMixinTable): @@ -564,7 +564,7 @@ class UrgentOrderTable(StaffIDMixinTable, OrderStatusMixinTable): class Meta: model = Order - fields = ["priority", "id", "description", "delivery_date", "status"] + fields = ("priority", "id", "description", "delivery_date", "status") empty_text = "No urgent orders" template_name = "django_tables2/tailwind_inner.html" @@ -609,7 +609,7 @@ def render_samples(self, value: int) -> str: class Meta: model = Order - fields = ["id", "description", "delivery_date", "samples", "markers", "seen"] + fields = ("id", "description", "delivery_date", "samples", "markers", "seen") empty_text = "No new unseen orders" template_name = "django_tables2/tailwind_inner.html" @@ -653,14 +653,14 @@ def render_samples(self, value: int) -> str: class Meta: model = Order - fields = [ + fields = ( "priority", "id", "description", "delivery_date", "markers", "samples", - ] + ) empty_text = "No new seen orders" template_name = "django_tables2/tailwind_inner.html" @@ -687,7 +687,7 @@ def render_samples_completed(self, value: int, record: Order) -> str: class Meta: model = Order - fields = ["priority", "id", "description", "samples_completed", "status"] + fields = ("priority", "id", "description", "samples_completed", "status") empty_text = "No assigned orders" order_by = ["-priority", "status"] template_name = "django_tables2/tailwind_inner.html" @@ -716,11 +716,11 @@ class DraftOrderTable(StaffIDMixinTable): class Meta: model = Order - fields = [ + fields = ( "id", "description", "contact_person", "contact_email", - ] + ) empty_text = "No draft orders" template_name = "django_tables2/tailwind_inner.html" From c94e62c3192a3527090fa13d65a1e9c329a949e9 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:30:25 +0200 Subject: [PATCH 10/42] Equipment buttons moved up. Mark as seen button has consistent styling. (#438) * Equipment buttons moved up. Mark as seen button has consistent styling. * Mark as seen button follows correct styling --- .../templates/staff/analysisorder_detail.html | 4 +++- .../templates/staff/components/seen_column.html | 4 +++- .../templates/staff/equipmentorder_detail.html | 15 +-------------- .../templates/staff/extractionorder_detail.html | 4 +++- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 91f4c877..cfded782 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -19,7 +19,9 @@

Order {{ object }}

{% if not object.is_seen %}
{% csrf_token %} - +
{% endif %} diff --git a/src/staff/templates/staff/components/seen_column.html b/src/staff/templates/staff/components/seen_column.html index 8b8dd9c3..7424cb32 100644 --- a/src/staff/templates/staff/components/seen_column.html +++ b/src/staff/templates/staff/components/seen_column.html @@ -3,5 +3,7 @@
{% csrf_token %} - +
diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index fc6a68ac..8bd24687 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -12,6 +12,7 @@

Order {{ object }}

{% object-detail-staff object=object %} @@ -31,18 +32,4 @@
Requested Equipment
{% endfor %} {% /table %} - -
- Back - Assign staff - {% comment %} - {% if object.status == 'draft' %} - Edit - Edit requested equipment - {% url 'genrequest-order-confirm' genrequest_id=object.genrequest_id pk=object.id as confirm_order_url %} - {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} - Delete - {% endif %} - {% endcomment %} -
{% endblock %} diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index f132f1fa..006de46d 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -19,7 +19,9 @@

Order {{ object }}

{% if not object.is_seen %}
{% csrf_token %} - +
{% endif %} From 56123df7fc5f407047c7ee625d89f583f3342878 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:45:22 +0200 Subject: [PATCH 11/42] 406 assign responsible scientist to project (#411) * Added results contact info for analysis * Added migrations * Added migrations * Safe fields for name and email for analysis results * Mypy fix * Safer (?) fix * Fixed comments * Removed mark_safe() * Removed duplicate import * Changed names of columns (analysis results contact person) * Make field mandatory --- src/genlab_bestilling/forms.py | 82 ++++++++++++++++++- ...033_alter_order_contact_person_and_more.py | 59 +++++++++++++ src/genlab_bestilling/models.py | 23 +++++- .../analysisorder_detail.html | 23 +++++- src/genlab_bestilling/views.py | 30 +++++-- src/staff/tables.py | 4 +- src/staff/templatetags/order_tags.py | 31 ++++++- 7 files changed, 233 insertions(+), 19 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index dcb382c7..d28798dd 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -3,8 +3,10 @@ from django import forms from django.contrib.auth.models import User from django.core.exceptions import ValidationError +from django.core.validators import validate_email from django.db import transaction from django.db.models import Model +from django.utils.html import strip_tags from formset.renderers.tailwind import FormRenderer from formset.utils import FormMixin from formset.widgets import DualSortableSelector, Selectize, TextInput @@ -14,6 +16,7 @@ from .libs.formset import ContextFormCollection from .models import ( AnalysisOrder, + AnalysisOrderResultsCommunication, EquimentOrderQuantity, EquipmentOrder, ExtractionOrder, @@ -122,6 +125,12 @@ def __init__(self, *args, genrequest: Genrequest, **kwargs): # self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() # type: ignore[attr-defined] + self.fields[ + "contact_person" + ].help_text = "Person to contact with questions about this order" + self.fields[ + "contact_email" + ].help_text = "Email to contact with questions about this order" def save(self, commit: bool = True) -> EquipmentOrder: obj = super().save(commit=False) @@ -258,9 +267,8 @@ def __init__(self, *args, genrequest: Genrequest, **kwargs): self.fields["species"].queryset = genrequest.species.all() # type: ignore[attr-defined] self.fields["sample_types"].queryset = genrequest.sample_types.all() # type: ignore[attr-defined] - # self.fields["markers"].queryset = Marker.objects.filter( - # species__genrequests__id=genrequest.id - # ).distinct() + self.fields["contact_person"].label = "Responsible genetic researcher" + self.fields["contact_email"].label = "Responsible genetic researcher email" def save(self, commit: bool = True) -> ExtractionOrder: obj = super().save(commit=False) @@ -329,10 +337,37 @@ def __init__(self, *args, genrequest: Genrequest, **kwargs): " with the sample selection by pressing Submit" ) + self.fields["contact_person"].label = "Responsible genetic researcher" + self.fields["contact_email"].label = "Responsible genetic researcher email" + + def clean_contact_email_results(self) -> str: + emails_raw = self.cleaned_data.get("contact_email_results", "") + emails = [e.strip() for e in emails_raw.split(",") if e.strip()] + + try: + for email in emails: + validate_email(email) + except ValidationError: + msg = f"Invalid email: {email}" + raise forms.ValidationError(msg) from ValidationError(msg) + return ", ".join(emails) + + def clean_contact_person_results(self) -> str: + names_raw = self.cleaned_data.get("contact_person_results", "") + names = [n.strip() for n in names_raw.split(",") if n.strip()] + + for name in names: + # Optionally allow hyphens and apostrophes in names + if not all(c.isalpha() or c.isspace() or c in "-'" for c in name): + msg = f"Invalid name: {name}" + raise forms.ValidationError(msg) from ValidationError(msg) + return ", ".join(names) + def save(self, commit: bool = True) -> Model: if not commit: msg = "This form is always committed" raise NotImplementedError(msg) + with transaction.atomic(): obj = super().save(commit=False) obj.genrequest = self.genrequest @@ -342,9 +377,38 @@ def save(self, commit: bool = True) -> Model: if obj.from_order and not obj.name and obj.from_order.name: obj.name = obj.from_order.name + " - Analysis" + obj.save() self.save_m2m() obj.populate_from_order() + + # Save AnalysisOrderResultsCommunication objects + # Delete old entries first (in case of resubmission) + obj.results_contacts.all().delete() + + names = [ + strip_tags(n.strip()) + for n in self.cleaned_data["contact_person_results"].split(",") + if n.strip() + ] + emails = [ + strip_tags(e.strip()) + for e in self.cleaned_data["contact_email_results"].split(",") + if e.strip() + ] + + if names and emails: + if len(names) != len(emails): + msg = "The number of names must match the number of emails." + raise ValidationError(msg) + + for name, email in zip(names, emails, strict=False): + AnalysisOrderResultsCommunication.objects.create( + analysis_order=obj, + contact_person_results=name, + contact_email_results=email, + ) + return obj def clean(self) -> None: @@ -354,6 +418,18 @@ def clean(self) -> None: msg = "An extraction order must be selected" raise ValidationError(msg) + contact_person_results = forms.CharField( + label="Contact person(s) for results", + help_text="Comma-separated list of names to contact with results", + required=True, + ) + + contact_email_results = forms.CharField( + label="Contact email(s) for results", + help_text="Comma-separated list of emails to contact with results (must match order of names)", # noqa: E501 + required=True, + ) + field_order = [ "name", "use_all_samples", diff --git a/src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py b/src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py new file mode 100644 index 00000000..051aa388 --- /dev/null +++ b/src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.3 on 2025-07-24 08:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "genlab_bestilling", + "0032_alter_isolationmethod_remove_species_add_type_20250722_1226", + ), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="contact_person", + field=models.CharField( + help_text="Responsible for genetic bioinformatics analysis", null=True + ), + ), + migrations.CreateModel( + name="AnalysisOrderResultsCommunication", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "contact_person_results", + models.CharField( + help_text="Person to contact for analysis resuls", null=True + ), + ), + ( + "contact_email_results", + models.EmailField( + help_text="Email to send analysis results", + max_length=254, + null=True, + ), + ), + ( + "analysis_order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="results_contacts", + to="genlab_bestilling.analysisorder", + ), + ), + ], + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 783e3607..c83f9f12 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -284,7 +284,7 @@ class OrderPriority: contact_person = models.CharField( null=True, blank=False, - help_text="Person to contact with questions about this order", + help_text="Responsible for genetic bioinformatics analysis", ) contact_email = models.EmailField( null=True, @@ -532,6 +532,27 @@ def order_selected_checked( ) +class AnalysisOrderResultsCommunication(models.Model): + analysis_order = models.ForeignKey( + f"{an}.AnalysisOrder", + on_delete=models.CASCADE, + related_name="results_contacts", + ) + contact_person_results = models.CharField( + null=True, + blank=False, + help_text="Person to contact for analysis resuls", + ) + contact_email_results = models.EmailField( + null=True, + blank=False, + help_text="Email to send analysis results", + ) + + def __str__(self): + return f"{str(self.analysis_order)} {str(self.contact_person_results)} {str(self.contact_email_results)}" # noqa: E501 + + class AnalysisOrder(Order): samples = models.ManyToManyField( f"{an}.Sample", blank=True, through="SampleMarkerAnalysis" diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html index 982a072d..9e264540 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html @@ -26,6 +26,27 @@

Order {{ object }}

{% object-detail object=object %} + {% if results_contacts %} +
Contacts for Analysis Results
+
+ +
+ {% else %} +
Contacts for Analysis Results
+
+

No contacts provided for analysis results.

+
+ {% endif %} +
Samples to analyze
@@ -48,7 +69,7 @@
Samples to analyze
Delete {% endif %} {% elif object.status == object.OrderStatus.DELIVERED %} - Samples + Samples {% endif %}
{% endblock %} diff --git a/src/genlab_bestilling/views.py b/src/genlab_bestilling/views.py index 19b6874b..e7c98fc5 100644 --- a/src/genlab_bestilling/views.py +++ b/src/genlab_bestilling/views.py @@ -467,6 +467,15 @@ def gen_crumbs(self) -> list[tuple]: (str(self.object), ""), ] + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + all_samples_have_no_genlab_id = not self.object.samples.exclude( + genlab_id__isnull=True + ).exists() + context["all_samples_have_no_genlab_id"] = all_samples_have_no_genlab_id + context["results_contacts"] = self.object.results_contacts.all() + return context + def get_queryset(self) -> QuerySet: return ( super() @@ -475,15 +484,6 @@ def get_queryset(self) -> QuerySet: .prefetch_related("sample_markers", "markers") ) - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: - context = super().get_context_data(**kwargs) - order = self.object - all_samples_have_no_genlab_id = not order.samples.exclude( - genlab_id__isnull=True - ).exists() - context["all_samples_have_no_genlab_id"] = all_samples_have_no_genlab_id - return context - class ExtractionOrderDetailView(GenrequestNestedMixin, DetailView): model = ExtractionOrder @@ -826,6 +826,9 @@ def gen_crumbs(self) -> list[tuple]: ] def get_success_url(self) -> str: + # Clear any leftover error messages before redirect + list(messages.get_messages(self.request)) + obj: AnalysisOrder = self.object # type: ignore[assignment] # Possibly None if obj.from_order: return reverse( @@ -843,6 +846,15 @@ def get_success_url(self) -> str: }, ) + # Override form_invalid to show errors in the form + def form_invalid(self, form: Form) -> HttpResponse: + for field, errors in form.errors.items(): + field_obj = form.fields.get(field) + label = field_obj.label if field_obj is not None else field + for error in errors: + messages.error(self.request, f"{label}: {error}") + return super().form_invalid(form) + class ExtractionOrderCreateView( GenrequestNestedMixin, diff --git a/src/staff/tables.py b/src/staff/tables.py index 318d7910..aac10d18 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -704,13 +704,13 @@ class DraftOrderTable(StaffIDMixinTable): contact_person = tables.Column( accessor="contact_person", - verbose_name="Contact Person", + verbose_name="Responsible genetic researcher", orderable=False, ) contact_email = tables.Column( accessor="contact_email", - verbose_name="Contact Email", + verbose_name="Responsible genetic researcher email", orderable=False, ) diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index d09fff73..640d5655 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -2,9 +2,15 @@ from django import template from django.db import models +from django.utils.html import format_html_join from django.utils.safestring import mark_safe -from genlab_bestilling.models import Area, Order +from genlab_bestilling.models import ( + AnalysisOrder, + AnalysisOrderResultsCommunication, + Area, + Order, +) from ..tables import ( AssignedOrderTable, @@ -339,11 +345,30 @@ def analysis_order_samples_detail_table(order: Order, extraction_orders: dict) - @register.inclusion_tag("../templates/components/order-detail.html") def contact_detail_table(order: Order) -> dict: + # Default values + result_contacts_html = "—" + + # Only fetch contacts if it's an AnalysisOrder instance + if isinstance(order, AnalysisOrder): + result_contacts = ( + AnalysisOrderResultsCommunication.objects.filter(analysis_order=order) + .values_list("contact_person_results", "contact_email_results") + .distinct() + ) + if result_contacts: + result_contacts_html = format_html_join( + "\n", + '
{} — {}
', # noqa: E501 + [(name, email, email) for name, email in result_contacts], + ) + fields = { "Samples owner of genetic project": order.genrequest.samples_owner, - "Contact person": order.contact_person, - "Contact Email": order.contact_email, + "Responsible genetic researcher": order.contact_person, + "Responsible genetic researcher email": order.contact_email, + "Contact name and email for analysis results": result_contacts_html, } + return { "fields": fields, "header": "Contact", From e236cb54cbbe345997c5d015896becc4080cff70 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:53:16 +0200 Subject: [PATCH 12/42] Fix blank species field (#447) Co-authored-by: Morten Madsen Lyngstad --- src/staff/tables.py | 14 +++++++++++++- src/staff/templatetags/order_tags.py | 2 +- src/staff/views.py | 5 +++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index aac10d18..ebcdd3f2 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -89,7 +89,7 @@ class Meta: class AnalysisOrderTable(OrderTable): id = tables.Column( linkify=("staff:order-analysis-detail", {"pk": tables.A("id")}), - orderable=False, + orderable=True, empty_values=(), ) @@ -108,6 +108,13 @@ class AnalysisOrderTable(OrderTable): empty_values=(), ) + species = tables.Column( + verbose_name="Species", + accessor="samples", + orderable=False, + empty_values=(), + ) + class Meta(OrderTable.Meta): model = AnalysisOrder fields = OrderTable.Meta.fields + ("markers", "expected_delivery_date") # type: ignore[assignment] @@ -124,6 +131,11 @@ class Meta(OrderTable.Meta): "expected_delivery_date", ) + def render_species(self, value: Any) -> str: + return ", ".join( + sorted({sample.species.name for sample in value.all() if sample.species}) + ) + class ExtractionOrderTable(OrderTable): id = tables.Column( diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index 640d5655..d3c501a0 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -273,7 +273,7 @@ def extraction_order_detail_table(order: Order) -> dict: fields = { "Order ID": order.id, "Genetic Project": order.genrequest, - "Species": order.species.name, + "Species": ", ".join(str(s) for s in order.species.all()), "Status": render_status_helper(order.status), "Name": order.name, "Notes": "-" if order.notes == "" else order.notes, diff --git a/src/staff/views.py b/src/staff/views.py index 85cb3a77..c7a9ab8c 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -113,6 +113,7 @@ def get_queryset(self) -> QuerySet[AnalysisOrder]: "genrequest__project", "genrequest__area", ) + .prefetch_related("samples__species") .annotate(total_samples=Count("samples")) ) @@ -269,6 +270,10 @@ def get_return_url(self, return_to: str | None) -> str: class ExtractionOrderDetailView(StaffMixin, DetailView): model = ExtractionOrder + # Prefetch species to avoid N+1 queries when accessing species in the template + def get_queryset(self) -> QuerySet[ExtractionOrder]: + return super().get_queryset().prefetch_related("species") + def get_analysis_orders_for_samples( self, samples: QuerySet[Sample] ) -> QuerySet[AnalysisOrder]: From f2d1175f17393c1a6bfc32e9322f80f69752ba35 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Fri, 25 Jul 2025 16:13:52 +0200 Subject: [PATCH 13/42] Minor visual changes for analysis order (#446) * Minor visual changes for analysis order * Linter fix * Linter fix #2 * Linter fix #3 --- src/staff/tables.py | 14 ++++++++++++-- .../templates/staff/note_input_column.html | 17 +++++++++-------- .../staff/samplemarkeranalysis_filter.html | 12 +++++++++++- src/staff/templatetags/order_tags.py | 2 +- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index ebcdd3f2..d7c41f48 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -425,7 +425,7 @@ class OrderAnalysisSampleTable(tables.Table): ) has_pcr = tables.BooleanColumn( - verbose_name="Has PCR", + verbose_name="PCR", orderable=True, yesno="✔,-", default=False, @@ -440,13 +440,23 @@ class OrderAnalysisSampleTable(tables.Table): accessor="is_analysed", ) is_outputted = tables.BooleanColumn( - verbose_name="Is Outputted", + verbose_name="Output", orderable=True, yesno="✔,-", default=False, accessor="is_outputted", ) + sample__internal_note = tables.TemplateColumn( + template_name="staff/note_input_column.html", + orderable=False, + attrs={ + "td": { + "class": "relative", + }, + }, + ) + class Meta: model = SampleMarkerAnalysis fields = ( diff --git a/src/staff/templates/staff/note_input_column.html b/src/staff/templates/staff/note_input_column.html index 63255bff..4f50f727 100644 --- a/src/staff/templates/staff/note_input_column.html +++ b/src/staff/templates/staff/note_input_column.html @@ -1,19 +1,20 @@ +
- +>{{ record.internal_note|default:'' }} + - + +
diff --git a/src/staff/templates/staff/samplemarkeranalysis_filter.html b/src/staff/templates/staff/samplemarkeranalysis_filter.html index 1572383c..a3ee0d30 100644 --- a/src/staff/templates/staff/samplemarkeranalysis_filter.html +++ b/src/staff/templates/staff/samplemarkeranalysis_filter.html @@ -61,13 +61,23 @@ formData.append("field_value", value); formData.append("csrfmiddlewaretoken", "{{ csrf_token }}"); + const spinner = document.getElementById(`internal_note-spinner-${sampleId}`); + const checkIcon = document.getElementById(`internal_note-check-${sampleId}`); + checkIcon.style.visibility = "hidden"; + spinner.style.visibility = "visible"; + clearTimeout(debounceTimeout); debounceTimeout = setTimeout(function () { fetch("{% url 'staff:update-internal-note' %}", { method: "POST", body: formData }); - }, 500); + spinner.style.visibility = "hidden"; + checkIcon.style.visibility = "visible"; + setTimeout(function () { + checkIcon.style.visibility = "hidden"; + }, 5000); + }, 1500); }); }); }); diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index d3c501a0..b4cfae82 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -313,7 +313,7 @@ def analysis_order_detail_table(order: Order) -> dict: "Confirmed at": order.confirmed_at.strftime("%d.%m.%Y") if order.confirmed_at else "Not confirmed", - "Expected delivery date": order.expected_delivery_date.strftime("%d.%m.%Y") + "Deadline": order.expected_delivery_date.strftime("%d.%m.%Y") if order.expected_delivery_date else "Not specified", } From d92aac8b7d2a748a1fd0f46fab91ba6809021f5f Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 28 Jul 2025 08:58:04 +0200 Subject: [PATCH 14/42] Processing and complete status are always seen. Added buttons to equipment order. (#459) --- src/genlab_bestilling/models.py | 4 ++++ .../staff/equipmentorder_detail.html | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index c83f9f12..a932e36d 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -330,10 +330,14 @@ def to_draft(self) -> None: def to_processing(self) -> None: self.status = Order.OrderStatus.PROCESSING + if not self.is_seen: + self.is_seen = True self.save() def to_completed(self) -> None: self.status = Order.OrderStatus.COMPLETED + if not self.is_seen: + self.is_seen = True self.save() def toggle_seen(self) -> None: diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index 8bd24687..365a7aee 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -13,6 +13,29 @@

Order {{ object }}

Back Assign staff +
+ + {% if not object.is_seen %} +
+ {% csrf_token %} + +
+ {% endif %} + + {% if object.status == object.OrderStatus.DELIVERED %} + {% url 'staff:order-to-next-status' pk=object.id as to_next_status_url %} + {% with " Set as "|add:object.next_status as btn_name %} + {% action-button action=to_next_status_url class="bg-yellow-200 text-yellow-800 border border-yellow-700 hover:bg-yellow-300" submit_text=btn_name csrf_token=csrf_token %} + {% endwith %} + {% endif %} + {% if object.status == object.OrderStatus.PROCESSING %} + {% url 'staff:order-to-next-status' pk=object.id as to_next_status_url %} + {% with " Set as "|add:object.next_status as btn_name %} + {% action-button action=to_next_status_url class="custom_order_button_green" submit_text=btn_name csrf_token=csrf_token %} + {% endwith %} + {% endif %}
{% object-detail-staff object=object %} From 38eee24b4f008d3d39d78eadef2bc2f8dc8a3212 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Mon, 28 Jul 2025 09:00:05 +0200 Subject: [PATCH 15/42] Fix dashboard horizontal scroll (#460) --- src/staff/templates/staff/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/staff/templates/staff/base.html b/src/staff/templates/staff/base.html index 3e01589e..d25107b2 100644 --- a/src/staff/templates/staff/base.html +++ b/src/staff/templates/staff/base.html @@ -14,7 +14,7 @@
Menu
  • Plates
  • -
    +
    {% if messages %} {% for message in messages %}
    From c86ed2fc341f9b8c20f6064a3f8c3ed5dc3d64db Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Mon, 28 Jul 2025 09:06:51 +0200 Subject: [PATCH 16/42] Add isolation method to multiple sample types (#427) --- src/genlab_bestilling/admin.py | 23 ++++++++----- ...lete_all_isolationmethods_20250724_1745.py | 32 +++++++++++++++++++ ..._types_to_isolationmethod_20250724_1749.py | 25 +++++++++++++++ src/genlab_bestilling/models.py | 9 +++--- src/staff/views.py | 2 +- 5 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0034_delete_all_isolationmethods_20250724_1745.py create mode 100644 src/genlab_bestilling/migrations/0035_add_sample_types_to_isolationmethod_20250724_1749.py diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index fab4b5ab..413984a4 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.db.models import QuerySet +from django.http import HttpRequest from unfold.admin import ModelAdmin from unfold.contrib.filters import admin as unfold_filters @@ -542,20 +544,25 @@ class AnalysisResultAdmin(ModelAdmin): @admin.register(IsolationMethod) class IsolationMethodAdmin(ModelAdmin): M = IsolationMethod + list_display = [ M.name.field.name, - M.type.field.name, + "get_sample_types", ] - search_help_text = "Search for isolation method name or species name" - search_fields = [ - M.name.field.name, - f"{M.type.field.name}__{Species.name.field.name}", - ] + search_help_text = "Search for isolation method name" + search_fields = [M.name.field.name] list_filter = [ (M.name.field.name, unfold_filters.FieldTextFilter), - (M.type.field.name, unfold_filters.AutocompleteSelectFilter), + (M.sample_types.field.name, unfold_filters.AutocompleteSelectMultipleFilter), ] - autocomplete_fields = [M.type.field.name] + autocomplete_fields = [M.sample_types.field.name] + filter_horizontal = [M.sample_types.field.name] list_filter_submit = True list_filter_sheet = False + + def get_queryset(self, request: HttpRequest) -> QuerySet[IsolationMethod]: + return super().get_queryset(request).prefetch_related("sample_types") + + def get_sample_types(self, obj: IsolationMethod) -> str: + return ", ".join([sample_type.name for sample_type in obj.sample_types.all()]) diff --git a/src/genlab_bestilling/migrations/0034_delete_all_isolationmethods_20250724_1745.py b/src/genlab_bestilling/migrations/0034_delete_all_isolationmethods_20250724_1745.py new file mode 100644 index 00000000..cdea6216 --- /dev/null +++ b/src/genlab_bestilling/migrations/0034_delete_all_isolationmethods_20250724_1745.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.3 on 2025-07-24 15:45 + +from django.db import migrations +from django.db.migrations.state import StateApps +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def delete_isolation_methods(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor): + IsolationMethod = apps.get_model("genlab_bestilling", "IsolationMethod") + IsolationMethod.objects.all().delete() + + +def reverse_delete_isolation_methods( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ( + "genlab_bestilling", + "0033_alter_order_contact_person_and_more", + ), + ] + + operations = [ + migrations.RunPython( + delete_isolation_methods, + reverse_delete_isolation_methods, + ), + ] diff --git a/src/genlab_bestilling/migrations/0035_add_sample_types_to_isolationmethod_20250724_1749.py b/src/genlab_bestilling/migrations/0035_add_sample_types_to_isolationmethod_20250724_1749.py new file mode 100644 index 00000000..41bf6d5a --- /dev/null +++ b/src/genlab_bestilling/migrations/0035_add_sample_types_to_isolationmethod_20250724_1749.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-07-24 15:49 + +from django.db import migrations +from django.db.models import ManyToManyField + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0034_delete_all_isolationmethods_20250724_1745"), + ] + + operations = [ + migrations.RemoveField(model_name="isolationmethod", name="type"), + migrations.AddField( + model_name="isolationmethod", + name="sample_types", + field=ManyToManyField( + to="genlab_bestilling.SampleType", + related_name="isolation_methods", + verbose_name="Sample types", + blank=True, + help_text="Sample types that this isolation method can be used for", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index a932e36d..d8cd27cc 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -846,11 +846,12 @@ def __str__(self) -> str: class IsolationMethod(models.Model): name = models.CharField(max_length=255) - type = models.ForeignKey( + sample_types = models.ManyToManyField( f"{an}.SampleType", - on_delete=models.CASCADE, - related_name="type_isolation_methods", - help_text="The sample type this isolation method is related to.", + related_name="isolation_methods", + verbose_name="Sample types", + blank=True, + help_text="Sample types that this isolation method can be used for", ) def __str__(self) -> str: diff --git a/src/staff/views.py b/src/staff/views.py index c7a9ab8c..d40d62a8 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -559,7 +559,7 @@ def get_queryset(self) -> QuerySet[Sample]: def get_isolation_methods(self) -> QuerySet[IsolationMethod, str]: types = self.get_queryset().values_list("type", flat=True).distinct() return ( - IsolationMethod.objects.filter(type__in=types) + IsolationMethod.objects.filter(sample_types__in=types) .values_list("name", flat=True) .distinct() ) From a74cbdfd71c6f0efec4c54cac573d8d1519a38d2 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:28:30 +0200 Subject: [PATCH 17/42] Status logic (#450) * Order will be set to processing or completed after being a draft if it has already been in those states * Made the status update to an Order method --- src/genlab_bestilling/models.py | 13 +++++++++++++ src/staff/views.py | 1 + 2 files changed, 14 insertions(+) diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index d8cd27cc..0753b8ab 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -351,6 +351,19 @@ def toggle_prioritized(self) -> None: def get_type(self) -> str: return "order" + def update_status(self) -> None: + """ + Checks if the order should be set to processing or completed + """ + if isinstance(self, ExtractionOrder | AnalysisOrder): + if not self.samples.filter(is_isolated=False).exists(): + self.to_completed() + return + if not self.samples.filter(genlab_id=None).exists(): + self.to_processing() + return + return + @property def filled_genlab_count(self) -> int: return self.samples.filter(genlab_id__isnull=False).count() diff --git a/src/staff/views.py b/src/staff/views.py index d40d62a8..3370b08b 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -252,6 +252,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: try: order = self.get_object() order.toggle_seen() + order.update_status() messages.success(request, _("Order is marked as seen")) except Exception as e: From bfa57cbc1dc6f1999e5b50b3bc8cb8bb73093ebc Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:31:58 +0200 Subject: [PATCH 18/42] Update SafeRedirectMixin (#440) Co-authored-by: Morten Madsen Lyngstad --- src/staff/mixins.py | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/staff/mixins.py b/src/staff/mixins.py index db148ad7..3bf73a46 100644 --- a/src/staff/mixins.py +++ b/src/staff/mixins.py @@ -168,23 +168,39 @@ def get_fallback_url(self) -> str: raise NotImplementedError(msg) def get_next_url_from_request(self) -> str | None: - return self.request.POST.get(self.next_param) or self.request.GET.get( - self.next_param - ) - - def has_next_url(self) -> bool: - next_url = self.get_next_url_from_request() - 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(), - ) + """ + Safely extract the next URL from POST and GET data. + Returns a stripped string if present and non-empty, else None. + """ + next_url = self.request.POST.get(self.next_param) + if not next_url: + next_url = self.request.GET.get(self.next_param) + if isinstance(next_url, str): + next_url = next_url.strip() + if next_url: + return next_url + return None + + def has_next_url(self, next_url: str | None = None) -> bool: + """ + Check if a valid and safe next URL is present in the request. + Optionally accepts a next_url to avoid duplicate extraction. + """ + if next_url is None: + next_url = self.get_next_url_from_request() + if not next_url: + return False + return url_has_allowed_host_and_scheme( + url=next_url, + allowed_hosts={self.request.get_host()}, + require_https=self.request.is_secure(), ) def get_next_url(self) -> str: + """ + Return a safe next URL if available, otherwise fallback URL. + """ next_url = self.get_next_url_from_request() - if next_url and self.has_next_url(): + if next_url and self.has_next_url(next_url): return next_url return self.get_fallback_url() From 384dcf8e4d936aa5f44c607f431d41d48e66641a Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:37:32 +0200 Subject: [PATCH 19/42] Refactor logic to choose when analysis_orders are shown in the CSV and fix N + 1 query. (#430) Co-authored-by: Morten Madsen Lyngstad --- src/genlab_bestilling/api/constants.py | 2 ++ src/genlab_bestilling/api/serializers.py | 11 +++++++++-- src/genlab_bestilling/api/views.py | 9 ++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/genlab_bestilling/api/constants.py b/src/genlab_bestilling/api/constants.py index a8189a3a..4b62197f 100644 --- a/src/genlab_bestilling/api/constants.py +++ b/src/genlab_bestilling/api/constants.py @@ -101,6 +101,8 @@ "genlab_id", "fish_id", "guid", + "order", + "analysis_orders", "river", "location.name", "under_locality", diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index 45740ab4..f5026e02 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -134,8 +134,15 @@ def get_fish_id(self, obj: Sample) -> str: return obj.fish_id or "-" def get_analysis_orders(self, obj: Sample) -> list[str]: - if obj.order and obj.order.analysis_orders.exists(): - return [str(anl.id) for anl in obj.order.analysis_orders.all()] + if not obj.order: + return [] + + analysis_orders = obj.order.analysis_orders.all() + # Return all analysis order IDs as strings + # only if there is exactly one analysis order, else return empty list. + # This is to ensure no duplicate rows in staffs common sheet + if analysis_orders.count() == 1: + return [str(analysis_orders.first().id)] return [] def get_project(self, obj: Sample) -> str: diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index e6b4965a..867b94dc 100644 --- a/src/genlab_bestilling/api/views.py +++ b/src/genlab_bestilling/api/views.py @@ -4,7 +4,7 @@ from typing import Any from django.db import transaction -from django.db.models import QuerySet +from django.db.models import Prefetch, QuerySet from django.http import HttpResponse from django.views import View from drf_spectacular.utils import extend_schema @@ -36,6 +36,7 @@ SpeciesFilter, ) from ..models import ( + AnalysisOrder, AnalysisType, ExtractionOrder, Location, @@ -190,6 +191,12 @@ def get_queryset(self) -> QuerySet: "order__genrequest__area", "location", ) + .prefetch_related( + Prefetch( + "order__analysis_orders", + queryset=AnalysisOrder.objects.only("id"), + ) + ) .order_by("genlab_id", "type") ) From 285ccaf11dcf3595848f6cb40b13a1f3b59b77fc Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:04:42 +0200 Subject: [PATCH 20/42] Remove status transition buttons for delivered and processing orders (#454) Co-authored-by: Morten Madsen Lyngstad --- .../templates/staff/analysisorder_detail.html | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index cfded782..82abb258 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -29,20 +29,6 @@

    Order {{ object }}

    {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} {% action-button action=to_draft_url class="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} {% endif %} - - {% if object.status == object.OrderStatus.DELIVERED %} - {% url 'staff:order-to-next-status' pk=object.id as to_next_status_url %} - {% with " Set as "|add:object.next_status as btn_name %} - {% action-button action=to_next_status_url class="bg-yellow-200 text-yellow-800 border border-yellow-700 hover:bg-yellow-300" submit_text=btn_name csrf_token=csrf_token %} - {% endwith %} - {% endif %} - - {% if object.status == object.OrderStatus.PROCESSING %} - {% url 'staff:order-to-next-status' pk=object.id as to_next_status_url %} - {% with " Set as "|add:object.next_status as btn_name %} - {% action-button action=to_next_status_url class="custom_order_button_green" submit_text=btn_name csrf_token=csrf_token %} - {% endwith %} - {% endif %}
    Assign staff From 1d6bbf66611489e1d7e4fdf2a6f92331c85aea06 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:09:42 +0200 Subject: [PATCH 21/42] Add FlagField serializer for boolean representation of sample status to keep code DRY (#455) Co-authored-by: Morten Madsen Lyngstad --- src/genlab_bestilling/api/serializers.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index f5026e02..1bdf9ce8 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -94,6 +94,11 @@ class Meta: ) +class FlagField(serializers.Field): + def to_representation(self, value: bool) -> str: + return "x" if value else "" + + class SampleCSVSerializer(serializers.ModelSerializer): type = SampleTypeSerializer() species = SpeciesSerializer() @@ -102,9 +107,9 @@ class SampleCSVSerializer(serializers.ModelSerializer): analysis_orders = serializers.SerializerMethodField() project = serializers.SerializerMethodField() isolation_method = serializers.SerializerMethodField() - is_marked = serializers.SerializerMethodField() - is_plucked = serializers.SerializerMethodField() - is_isolated = serializers.SerializerMethodField() + is_marked = FlagField() + is_plucked = FlagField() + is_isolated = FlagField() internal_note = serializers.SerializerMethodField() class Meta: @@ -154,18 +159,6 @@ def get_isolation_method(self, obj: Sample) -> str: method = obj.isolation_method.first() return method.name if method else "" - def _flag(self, value: bool) -> str: - return "x" if value else "" - - def get_is_marked(self, obj: Sample) -> str: - return self._flag(obj.is_marked) - - def get_is_plucked(self, obj: Sample) -> str: - return self._flag(obj.is_plucked) - - def get_is_isolated(self, obj: Sample) -> str: - return self._flag(obj.is_isolated) - def get_internal_note(self, obj: Sample) -> str: if obj.internal_note: return obj.internal_note From cafdce444176992dd74c3840d104829db8cad025 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:30:00 +0200 Subject: [PATCH 22/42] Changed to "Clear all" as filter text. Samples page also can clear filters. (#467) --- .../templates/genlab_bestilling/base_filter.html | 2 +- src/staff/templates/staff/analysisorder_filter.html | 2 +- src/staff/templates/staff/equipmentorder_filter.html | 2 +- src/staff/templates/staff/extractionorder_filter.html | 2 +- src/staff/templates/staff/extractionplate_filter.html | 2 +- src/staff/templates/staff/project_filter.html | 2 +- src/staff/templates/staff/sample_filter.html | 3 ++- src/staff/templates/staff/sample_lab.html | 2 +- src/staff/templates/staff/samplemarkeranalysis_filter.html | 2 +- 9 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html b/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html index 7b7be329..ceee53de 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html @@ -10,7 +10,7 @@

    {% block page-title %}{% endblock page-title %}

    {{ filter.form | crispy }} - Clear Filters + Clear all
    diff --git a/src/staff/templates/staff/analysisorder_filter.html b/src/staff/templates/staff/analysisorder_filter.html index 4db95d15..0d1cc6c3 100644 --- a/src/staff/templates/staff/analysisorder_filter.html +++ b/src/staff/templates/staff/analysisorder_filter.html @@ -11,7 +11,7 @@
    {{ filter.form | crispy }} - Clear Filters + Clear all
    diff --git a/src/staff/templates/staff/equipmentorder_filter.html b/src/staff/templates/staff/equipmentorder_filter.html index 372f35b9..ceb9fb38 100644 --- a/src/staff/templates/staff/equipmentorder_filter.html +++ b/src/staff/templates/staff/equipmentorder_filter.html @@ -11,7 +11,7 @@
    {{ filter.form | crispy }} - Clear Filters + Clear all
    diff --git a/src/staff/templates/staff/extractionorder_filter.html b/src/staff/templates/staff/extractionorder_filter.html index d6849d29..8b9830b3 100644 --- a/src/staff/templates/staff/extractionorder_filter.html +++ b/src/staff/templates/staff/extractionorder_filter.html @@ -11,7 +11,7 @@
    {{ filter.form | crispy }} - Clear Filters + Clear all
    diff --git a/src/staff/templates/staff/extractionplate_filter.html b/src/staff/templates/staff/extractionplate_filter.html index c8ed54ab..9e431c71 100644 --- a/src/staff/templates/staff/extractionplate_filter.html +++ b/src/staff/templates/staff/extractionplate_filter.html @@ -16,7 +16,7 @@ {{ filter.form | crispy }}
    - Clear Filters + Clear all {% render_table table %} diff --git a/src/staff/templates/staff/project_filter.html b/src/staff/templates/staff/project_filter.html index afc3319e..1041e9cf 100644 --- a/src/staff/templates/staff/project_filter.html +++ b/src/staff/templates/staff/project_filter.html @@ -11,7 +11,7 @@
    {{ filter.form | crispy }} - Clear Filters + Clear all
    diff --git a/src/staff/templates/staff/sample_filter.html b/src/staff/templates/staff/sample_filter.html index b2b94506..f356280f 100644 --- a/src/staff/templates/staff/sample_filter.html +++ b/src/staff/templates/staff/sample_filter.html @@ -27,7 +27,7 @@
    {{ filter.form | crispy }} - Clear Filters + Clear all
    @@ -60,6 +60,7 @@
    {{ filter.form | crispy }} + Clear all
    diff --git a/src/staff/templates/staff/sample_lab.html b/src/staff/templates/staff/sample_lab.html index 781e6054..05acdbe9 100644 --- a/src/staff/templates/staff/sample_lab.html +++ b/src/staff/templates/staff/sample_lab.html @@ -16,7 +16,7 @@

    {% block page-title %}{% if order %}{{ order }} - Samp
    {{ filter.form | crispy }} - Clear Filters + Clear all
    diff --git a/src/staff/templates/staff/samplemarkeranalysis_filter.html b/src/staff/templates/staff/samplemarkeranalysis_filter.html index a3ee0d30..c0f1f8db 100644 --- a/src/staff/templates/staff/samplemarkeranalysis_filter.html +++ b/src/staff/templates/staff/samplemarkeranalysis_filter.html @@ -18,7 +18,7 @@
    {{ filter.form | crispy }} - Clear Filters + Clear all
    From abed102033dffc062b55dbde3b31147f86c18841 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:32:31 +0200 Subject: [PATCH 23/42] Change name on downloaded csv file (#457) * Improve IsolationMethodAdmin. (#404) * Fix order by sample status (#421) * Improve dashboard overflow (#429) * Refactor SafeRedirectMixin to remove repetitive code (#419) Co-authored-by: Morten Madsen Lyngstad * Add filtering for sample status (#425) Co-authored-by: Morten Madsen Lyngstad * 423 remove is seen and is prioritized from table in a order (#432) * Restrict fields shown in placing an order * Keep is_urgent as a visible field * If an order is started (has genlab ids) it cannot be deleted by researchers (#434) * When all samples are isolated/analysed, the order is set to completed (#439) * Use tuples. (#418) * Equipment buttons moved up. Mark as seen button has consistent styling. (#438) * Equipment buttons moved up. Mark as seen button has consistent styling. * Mark as seen button follows correct styling * 406 assign responsible scientist to project (#411) * Added results contact info for analysis * Added migrations * Added migrations * Safe fields for name and email for analysis results * Mypy fix * Safer (?) fix * Fixed comments * Removed mark_safe() * Removed duplicate import * Changed names of columns (analysis results contact person) * Make field mandatory * Fix blank species field (#447) Co-authored-by: Morten Madsen Lyngstad * Minor visual changes for analysis order (#446) * Minor visual changes for analysis order * Linter fix * Linter fix #2 * Linter fix #3 * Processing and complete status are always seen. Added buttons to equipment order. (#459) * Fix dashboard horizontal scroll (#460) * Add isolation method to multiple sample types (#427) * Status logic (#450) * Order will be set to processing or completed after being a draft if it has already been in those states * Made the status update to an Order method * Update SafeRedirectMixin (#440) Co-authored-by: Morten Madsen Lyngstad * Refactor logic to choose when analysis_orders are shown in the CSV and fix N + 1 query. (#430) Co-authored-by: Morten Madsen Lyngstad * Remove status transition buttons for delivered and processing orders (#454) Co-authored-by: Morten Madsen Lyngstad * Add FlagField serializer for boolean representation of sample status to keep code DRY (#455) Co-authored-by: Morten Madsen Lyngstad * Make specified CSV filenames with order id --------- Co-authored-by: Emil Telstad <22004178+emilte@users.noreply.github.com> Co-authored-by: Ole Magnus Co-authored-by: Morten Madsen Lyngstad Co-authored-by: Bertine <112892518+aastabk@users.noreply.github.com> --- src/genlab_bestilling/api/views.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index 867b94dc..4b78ec2a 100644 --- a/src/genlab_bestilling/api/views.py +++ b/src/genlab_bestilling/api/views.py @@ -209,6 +209,14 @@ def get_serializer_class(self) -> type[BaseSerializer]: return SampleUpdateSerializer return super().get_serializer_class() + def get_order_id(self, queryset: QuerySet) -> str: + order_id = "unknown_order_id" + first_sample = queryset.first() + if first_sample and first_sample.order and first_sample.order.id: + order_id = str(first_sample.order.id) + + return order_id + @action( methods=["GET"], url_path="csv", @@ -217,11 +225,14 @@ def get_serializer_class(self) -> type[BaseSerializer]: ) def csv(self, request: Request) -> HttpResponse: queryset = self.filter_queryset(self.get_queryset()) + + filename = f"Complete_sheet_EXT_{self.get_order_id(queryset)}.csv" + return self.render_csv_response( queryset, serializer_class=SampleCSVSerializer, fields_by_area=SAMPLE_CSV_FIELDS_BY_AREA, - filename="samples.csv", + filename=filename, ) @action( @@ -232,11 +243,14 @@ def csv(self, request: Request) -> HttpResponse: ) def labels_csv(self, request: Request) -> HttpResponse: queryset = self.filter_queryset(self.get_queryset()) + + filename = f"EXT_{self.get_order_id(queryset)}.csv" + return self.render_csv_response( queryset, serializer_class=LabelCSVSerializer, fields_by_area=LABEL_CSV_FIELDS_BY_AREA, - filename="sample_labels.csv", + filename=filename, ) @extend_schema( From d8206986284c566b52b0ed31c7cd7493d122b371 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:41:19 +0200 Subject: [PATCH 24/42] Add AnalysisMarkerAutocomplete and filter for markers in AnalysisOrders page (#461) Co-authored-by: Morten Madsen Lyngstad --- src/config/autocomplete.py | 6 ++++++ src/genlab_bestilling/autocomplete.py | 4 ++++ src/staff/filters.py | 12 ++++++++++++ 3 files changed, 22 insertions(+) diff --git a/src/config/autocomplete.py b/src/config/autocomplete.py index 30d2f15e..3850e0bc 100644 --- a/src/config/autocomplete.py +++ b/src/config/autocomplete.py @@ -2,6 +2,7 @@ from capps.users.autocomplete import StaffUserAutocomplete, UserAutocomplete from genlab_bestilling.autocomplete import ( + AnalysisMarkerAutocomplete, AnalysisOrderAutocomplete, AreaAutocomplete, EquipmentAutocomplete, @@ -42,4 +43,9 @@ IsolationMethodAutocomplete.as_view(), name="isolation-method", ), + path( + "analysis-marker/", + AnalysisMarkerAutocomplete.as_view(), + name="analysis-marker", + ), ] diff --git a/src/genlab_bestilling/autocomplete.py b/src/genlab_bestilling/autocomplete.py index 04514b0f..fbb9be40 100644 --- a/src/genlab_bestilling/autocomplete.py +++ b/src/genlab_bestilling/autocomplete.py @@ -72,3 +72,7 @@ class ExtractionOrderAutocomplete(autocomplete.Select2QuerySetView): class IsolationMethodAutocomplete(autocomplete.Select2QuerySetView): model = IsolationMethod + + +class AnalysisMarkerAutocomplete(autocomplete.Select2QuerySetView): + model = Marker diff --git a/src/staff/filters.py b/src/staff/filters.py index f37b5b46..2ea3ce8c 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -13,6 +13,7 @@ Area, ExtractionOrder, ExtractionPlate, + Marker, Order, Sample, SampleMarkerAnalysis, @@ -74,6 +75,16 @@ class AnalysisOrderFilter(filters.FilterSet): ), ) + markers = filters.ModelMultipleChoiceFilter( + field_name="markers", + label="Markers", + queryset=Marker.objects.all(), + widget=autocomplete.ModelSelect2Multiple( + url="autocomplete:analysis-marker", + attrs={"class": "w-full"}, + ), + ) + class Meta: model = AnalysisOrder fields = ( @@ -82,6 +93,7 @@ class Meta: "genrequest__area", "responsible_staff", "genrequest__species", + "markers", ) From c09790a8ad2da07562e73804f894afe4e28c2a58 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:01:10 +0200 Subject: [PATCH 25/42] 462 refactor sample status filtering 2 (#468) * Filter on marked, plucked and isolated * Remove prints * Reuse filter method --- src/staff/filters.py | 67 ++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/src/staff/filters.py b/src/staff/filters.py index 2ea3ce8c..631e9ce8 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -266,21 +266,35 @@ class SampleStatusWidget(forms.Select): def __init__(self, attrs: dict[str, Any] | None = None): choices = ( ("", "---------"), - ("true", "Yes"), - ("false", "No"), + ("marked", "Marked"), + ("plucked", "Plucked"), + ("isolated", "Isolated"), ) super().__init__(choices=choices, attrs=attrs) +def filter_sample_status( + filter_set: Any, queryset: QuerySet, name: Any, value: str +) -> QuerySet: + if value == "marked": + # Only marked, not plucked or isolated + return queryset.filter(is_marked=True, is_plucked=False, is_isolated=False) + if value == "plucked": + # Plucked but not isolated + return queryset.filter(is_plucked=True, is_isolated=False) + if value == "isolated": + # All isolated samples, regardless of others + return queryset.filter(is_isolated=True) + return queryset + + class SampleFilter(filters.FilterSet): - is_marked = filters.BooleanFilter( - label="Marked", method="filter_boolean", widget=SampleStatusWidget - ) - is_plucked = filters.BooleanFilter( - label="Plucked", method="filter_boolean", widget=SampleStatusWidget - ) - is_isolated = filters.BooleanFilter( - label="Isolated", method="filter_boolean", widget=SampleStatusWidget + filter_sample_status = filter_sample_status + + sample_status = filters.CharFilter( + label="Sample Status", + method="filter_sample_status", + widget=SampleStatusWidget, ) def __init__( @@ -312,19 +326,9 @@ class Meta: "year", "location", "pop_id", - "is_marked", - "is_plucked", - "is_isolated", + "sample_status", ) - def filter_boolean(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: - val = self.data.get(name) - if str(val) == "true": - return queryset.filter(**{name: True}) - if str(val) == "false": - return queryset.filter(**{name: False}) - return queryset - class ExtractionPlateFilter(filters.FilterSet): class Meta: @@ -333,15 +337,8 @@ class Meta: class SampleLabFilter(filters.FilterSet): - is_marked = filters.BooleanFilter( - label="Marked", method="filter_boolean", widget=SampleStatusWidget - ) - is_plucked = filters.BooleanFilter( - label="Plucked", method="filter_boolean", widget=SampleStatusWidget - ) - is_isolated = filters.BooleanFilter( - label="Isolated", method="filter_boolean", widget=SampleStatusWidget - ) + filter_sample_status = filter_sample_status + genlab_id_min = ChoiceFilter( label="Genlab ID (From)", method="filter_genlab_id_range", @@ -354,6 +351,12 @@ class SampleLabFilter(filters.FilterSet): empty_label="Select upper bound", ) + sample_status = filters.CharFilter( + label="Sample Status", + method="filter_sample_status", + widget=SampleStatusWidget, + ) + def __init__( self, data: dict[str, Any] | None = None, @@ -406,9 +409,7 @@ class Meta: fields = ( "genlab_id_min", "genlab_id_max", - "is_marked", - "is_plucked", - "is_isolated", + "sample_status", "extractions", "isolation_method", # "fluidigm", From c8990f2745ae56ab32200232996f9f47cdbd2a83 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:17:52 +0200 Subject: [PATCH 26/42] Remove prioritization feature on samples (#471) Co-authored-by: Morten Madsen Lyngstad --- src/staff/tables.py | 9 +------- src/staff/templates/staff/sample_filter.html | 23 -------------------- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index d7c41f48..2704333c 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -248,12 +248,6 @@ class SampleBaseTable(tables.Table): empty_values=(), orderable=False, verbose_name="Extraction position" ) - is_prioritised = tables.TemplateColumn( - template_name="staff/prioritise_flag.html", - orderable=True, - verbose_name="", - ) - checked = tables.CheckBoxColumn( attrs={ "th__input": {"type": "checkbox", "id": "select-all-checkbox"}, @@ -282,14 +276,13 @@ class Meta: attrs = {"class": "w-full table-auto tailwind-table table-sm"} sequence = ( "checked", - "is_prioritised", "genlab_id", "guid", "name", "species", "type", ) - order_by = ("-is_prioritised", "species", "genlab_id") + order_by = ("species", "genlab_id") empty_text = "No Samples" diff --git a/src/staff/templates/staff/sample_filter.html b/src/staff/templates/staff/sample_filter.html index f356280f..c597f819 100644 --- a/src/staff/templates/staff/sample_filter.html +++ b/src/staff/templates/staff/sample_filter.html @@ -67,27 +67,4 @@ {% render_table table %} {% endif %} - - - - {% endblock %} From 6232fbc9c339602eb97c1f931d646a7e6beb4bff Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Mon, 28 Jul 2025 12:51:03 +0200 Subject: [PATCH 27/42] Add markers to staff samples view (#428) Co-authored-by: Morten Madsen Lyngstad --- .../migrations/0036_sample_markers.py | 23 +++++++++++++++++++ src/genlab_bestilling/models.py | 8 +++++++ src/staff/tables.py | 7 ++++++ src/staff/views.py | 6 ++++- 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/genlab_bestilling/migrations/0036_sample_markers.py diff --git a/src/genlab_bestilling/migrations/0036_sample_markers.py b/src/genlab_bestilling/migrations/0036_sample_markers.py new file mode 100644 index 00000000..7ea7b2f3 --- /dev/null +++ b/src/genlab_bestilling/migrations/0036_sample_markers.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.3 on 2025-07-28 08:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0035_add_sample_types_to_isolationmethod_20250724_1749"), + ] + + operations = [ + migrations.AddField( + model_name="sample", + name="markers", + field=models.ManyToManyField( + blank=True, + help_text="Markers that are relevant for this sample", + related_name="samples", + through="genlab_bestilling.SampleMarkerAnalysis", + to="genlab_bestilling.marker", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 0753b8ab..ac7d211f 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -718,6 +718,14 @@ class Sample(models.Model): help_text="The isolation method used for this sample", ) + markers = models.ManyToManyField( + f"{an}.Marker", + through="SampleMarkerAnalysis", + related_name="samples", + blank=True, + help_text="Markers that are relevant for this sample", + ) + is_prioritised = models.BooleanField( default=False, help_text="Check this box if the sample is prioritised for processing", diff --git a/src/staff/tables.py b/src/staff/tables.py index 2704333c..6b1f9f8a 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -542,6 +542,12 @@ def get_order_id_link(record: Sample) -> str: def render_order__id(self, value: int, record: Sample) -> str: return str(record.order) + markers = tables.ManyToManyColumn( + accessor="markers", + verbose_name="Markers", + orderable=False, + ) + class Meta(SampleBaseTable.Meta): fields = SampleBaseTable.Meta.fields + ( "order__id", @@ -550,6 +556,7 @@ class Meta(SampleBaseTable.Meta): ) # type: ignore[assignment] sequence = SampleBaseTable.Meta.sequence + ( "sample_status", + "markers", "order__id", "order__status", "notes", diff --git a/src/staff/views.py b/src/staff/views.py index 3370b08b..9aaac3e5 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -3,7 +3,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db import models -from django.db.models import Count, QuerySet +from django.db.models import Count, Prefetch, QuerySet from django.forms import Form from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, redirect @@ -23,6 +23,7 @@ ExtractionPlate, Genrequest, IsolationMethod, + Marker, Order, Sample, SampleIsolationMethod, @@ -524,6 +525,9 @@ def get_queryset(self) -> QuerySet[Sample]: .prefetch_related( "plate_positions", "order__responsible_staff", + Prefetch( + "markers", queryset=Marker.objects.order_by("name").distinct() + ), ) .exclude(order__status=Order.OrderStatus.DRAFT) .order_by("species__name", "year", "location__name", "name") From a87bdf0d7be26912eff9f5ad40d507cc04bba6ed Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:10:26 +0200 Subject: [PATCH 28/42] Update logic for analysisorder (#472) --- src/genlab_bestilling/models.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index ac7d211f..c6cbcccf 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -355,14 +355,24 @@ def update_status(self) -> None: """ Checks if the order should be set to processing or completed """ - if isinstance(self, ExtractionOrder | AnalysisOrder): + if isinstance(self, ExtractionOrder): if not self.samples.filter(is_isolated=False).exists(): self.to_completed() return if not self.samples.filter(genlab_id=None).exists(): self.to_processing() return - return + if isinstance(self, AnalysisOrder): + if not self.samples.filter(is_outputted=False).exists(): + self.to_completed() + return + if ( + self.samples.filter(has_pcr=True).exists() + or self.samples.filter(is_analysed=True).exists() + ): + self.to_processing() + return + return @property def filled_genlab_count(self) -> int: From 581caf023924dab2c8d405d877cdc50d841adf45 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Mon, 28 Jul 2025 13:55:38 +0200 Subject: [PATCH 29/42] Remove custom order on dashboard (#475) --- src/staff/tables.py | 14 ++++++++++++++ src/staff/templatetags/order_tags.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index 6b1f9f8a..88e2d786 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -584,6 +584,10 @@ class UrgentOrderTable(StaffIDMixinTable, OrderStatusMixinTable): empty_values=(), ) + status = tables.Column( + orderable=False, + ) + class Meta: model = Order fields = ("priority", "id", "description", "delivery_date", "status") @@ -690,6 +694,12 @@ class Meta: class AssignedOrderTable(OrderStatusMixinTable, PriorityMixinTable, StaffIDMixinTable): sticky_header = True + priority = tables.TemplateColumn( + orderable=False, + verbose_name="Priority", + template_name="staff/components/priority_column.html", + ) + description = tables.Column( accessor="genrequest__name", verbose_name="Description", @@ -707,6 +717,10 @@ def render_samples_completed(self, value: int, record: Order) -> str: return str(record.isolated_sample_count) + " / " + str(value) return "-" + status = tables.Column( + orderable=False, + ) + class Meta: model = Order fields = ("priority", "id", "description", "samples_completed", "status") diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index b4cfae82..b83d1fe8 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -215,6 +215,7 @@ def assigned_orders_table(context: dict) -> dict: ), ) .order_by( + "-priority", models.Case( models.When(status=Order.OrderStatus.PROCESSING, then=0), models.When(status=Order.OrderStatus.DELIVERED, then=1), @@ -222,7 +223,6 @@ def assigned_orders_table(context: dict) -> dict: default=3, output_field=models.IntegerField(), ), - "-priority", "-created_at", ) ) From 98491f258e2ac9e2af77100b4c61dc7fcfe47848 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Tue, 29 Jul 2025 08:45:21 +0200 Subject: [PATCH 30/42] Fixed error in mark as seen. Adding svg for exclaimation mark. (#481) * Fixed error in mark as seen. Adding svg for exclaimation mark. * Cleanup --- src/genlab_bestilling/models.py | 49 +++++++++++++++---------- src/static/images/exclaimation_mark.svg | 4 ++ 2 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 src/static/images/exclaimation_mark.svg diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index c6cbcccf..f59d7de6 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -5,6 +5,7 @@ from django.conf import settings from django.db import models, transaction +from django.db.models import Q from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -353,26 +354,10 @@ def get_type(self) -> str: def update_status(self) -> None: """ - Checks if the order should be set to processing or completed + AnalysisOrder and ExtractionOrder should implement this method """ - if isinstance(self, ExtractionOrder): - if not self.samples.filter(is_isolated=False).exists(): - self.to_completed() - return - if not self.samples.filter(genlab_id=None).exists(): - self.to_processing() - return - if isinstance(self, AnalysisOrder): - if not self.samples.filter(is_outputted=False).exists(): - self.to_completed() - return - if ( - self.samples.filter(has_pcr=True).exists() - or self.samples.filter(is_analysed=True).exists() - ): - self.to_processing() - return - return + msg = "Subclasses must implement update_status()" + raise NotImplementedError(msg) @property def filled_genlab_count(self) -> int: @@ -470,6 +455,9 @@ def confirm_order(self) -> Any: raise Order.CannotConfirm(_("No equipments found")) return super().confirm_order() + def update_status(self) -> None: + pass + def get_type(self) -> str: return "equipment" @@ -537,6 +525,14 @@ def confirm_order(self, persist: bool = True) -> None: if persist: super().confirm_order() + def update_status(self) -> None: + if not self.samples.filter(is_isolated=False).exists(): + super().to_completed() + return + if not self.samples.filter(genlab_id__isnull=True).exists(): + super().to_processing() + return + @transaction.atomic def order_selected_checked( self, @@ -631,6 +627,21 @@ def confirm_order(self, persist: bool = True) -> None: if persist: super().confirm_order() + def update_status(self) -> None: + if not SampleMarkerAnalysis.objects.filter( + order=self, is_outputted=False + ).exists(): + super().to_completed() + return + + if ( + SampleMarkerAnalysis.objects.filter(order=self) + .filter(Q(has_pcr=True) | Q(is_analysed=True)) + .exists() + ): + super().to_processing() + return + def populate_from_order(self) -> None: """ Create the list of markers per sample to analyze diff --git a/src/static/images/exclaimation_mark.svg b/src/static/images/exclaimation_mark.svg new file mode 100644 index 00000000..25b1688f --- /dev/null +++ b/src/static/images/exclaimation_mark.svg @@ -0,0 +1,4 @@ + + + + From 2089bd88c0c6d9f32bb2a69c308c6a47a91b7250 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 29 Jul 2025 09:20:01 +0200 Subject: [PATCH 31/42] Assign staff multiselect (#300) --- src/staff/api.py | 48 ++++++++ src/staff/forms.py | 27 +++++ src/staff/tables.py | 24 +++- .../templates/staff/analysisorder_detail.html | 2 +- .../staff/components/order_table.html | 4 +- .../components/responsible_staff_column.html | 3 + .../responsible_staff_multiselect.html | 12 ++ .../staff/equipmentorder_detail.html | 5 +- .../staff/extractionorder_detail.html | 2 +- src/staff/templatetags/order_tags.py | 14 +++ src/staff/urls.py | 7 ++ src/static/staff/js/responsible-staff-form.js | 112 ++++++++++++++++++ src/theme/static_src/src/styles.css | 73 +++++++++--- 13 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 src/staff/api.py create mode 100644 src/staff/templates/staff/components/responsible_staff_column.html create mode 100644 src/staff/templates/staff/components/responsible_staff_multiselect.html create mode 100644 src/static/staff/js/responsible-staff-form.js diff --git a/src/staff/api.py b/src/staff/api.py new file mode 100644 index 00000000..f0ca9d3f --- /dev/null +++ b/src/staff/api.py @@ -0,0 +1,48 @@ +from django.contrib import messages +from django.db.models.query import QuerySet +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from capps.users.models import User +from genlab_bestilling.models import Order + + +class OrderAPIView(APIView): + class RequestJson: + user_ids = "user_ids" + + def get_object(self) -> Order: + return Order.objects.get(pk=self.kwargs["pk"]) + + def get_genlab_staff(self) -> QuerySet[User]: + return User.objects.filter(groups__name="genlab") + + def post(self, request: Request, *args, **kwargs) -> Response: + order = self.get_object() + + if not order.is_seen: + msg = "Order must be seen before assigning staff." + + messages.error(request, msg) + return Response( + status=400, + data={"detail": msg}, + ) + + try: + users = self.get_genlab_staff().filter( + id__in=request.data.get(self.RequestJson.user_ids, []) + ) + order.responsible_staff.clear() + order.responsible_staff.set(users) + order.save() + + return Response( + status=200, + ) + except Exception: + # TODO Report to Sentry + return Response( + status=500, + ) diff --git a/src/staff/forms.py b/src/staff/forms.py index 01e09d6f..745c880c 100644 --- a/src/staff/forms.py +++ b/src/staff/forms.py @@ -1,3 +1,4 @@ +from dal import autocomplete from django import forms from django.forms import ModelForm from formset.renderers.tailwind import FormRenderer @@ -40,3 +41,29 @@ def get_all_staff(self) -> list[tuple[int, str]]: (user.id, f"{user}") for user in User.objects.filter(groups__name="genlab").all() ] + + +class ResponsibleStaffForm(forms.ModelForm): + id = forms.IntegerField(widget=forms.HiddenInput(), required=True) + + responsible_staff = forms.ModelMultipleChoiceField( + label="", + queryset=User.objects.filter(groups__name="genlab"), + widget=autocomplete.ModelSelect2Multiple( + url="autocomplete:staff-user", + attrs={"data-placeholder": "Assign staff"}, + ), + ) + + def __init__(self, *args, order: Order | None = None, **kwargs): + super().__init__(*args, **kwargs) + if order: + self.fields["id"].initial = order.id + self.fields["responsible_staff"].initial = order.responsible_staff.all() + + class Meta: + model = Order + fields = ( + "id", + "responsible_staff", + ) diff --git a/src/staff/tables.py b/src/staff/tables.py index 88e2d786..faeba5b5 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -588,9 +588,23 @@ class UrgentOrderTable(StaffIDMixinTable, OrderStatusMixinTable): orderable=False, ) + assigned_staff = tables.TemplateColumn( + template_name="staff/components/responsible_staff_column.html", + verbose_name="Assigned staff", + orderable=False, + empty_values=(), + ) + class Meta: model = Order - fields = ("priority", "id", "description", "delivery_date", "status") + fields = ( + "priority", + "id", + "description", + "delivery_date", + "status", + "assigned_staff", + ) empty_text = "No urgent orders" template_name = "django_tables2/tailwind_inner.html" @@ -677,6 +691,13 @@ def render_samples(self, value: int) -> str: transform=lambda x: x.name, ) + assigned_staff = tables.TemplateColumn( + template_name="staff/components/responsible_staff_column.html", + verbose_name="Assigned staff", + orderable=False, + empty_values=(), + ) + class Meta: model = Order fields = ( @@ -686,6 +707,7 @@ class Meta: "delivery_date", "markers", "samples", + "assigned_staff", ) empty_text = "No new seen orders" template_name = "django_tables2/tailwind_inner.html" diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 82abb258..54fa3f2b 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -31,7 +31,7 @@

    Order {{ object }}

    {% endif %}

    - Assign staff + {% responsible_staff_multiselect order=object %}
    diff --git a/src/staff/templates/staff/components/order_table.html b/src/staff/templates/staff/components/order_table.html index 80e40b88..a5f5e3b4 100644 --- a/src/staff/templates/staff/components/order_table.html +++ b/src/staff/templates/staff/components/order_table.html @@ -1,8 +1,8 @@ {% load django_tables2 %} -
    +

    {{ title }} ({{ count }})

    -
    +
    {% render_table table %}
    diff --git a/src/staff/templates/staff/components/responsible_staff_column.html b/src/staff/templates/staff/components/responsible_staff_column.html new file mode 100644 index 00000000..5ec975b7 --- /dev/null +++ b/src/staff/templates/staff/components/responsible_staff_column.html @@ -0,0 +1,3 @@ +{% load order_tags %} + +{% responsible_staff_multiselect order=record %} diff --git a/src/staff/templates/staff/components/responsible_staff_multiselect.html b/src/staff/templates/staff/components/responsible_staff_multiselect.html new file mode 100644 index 00000000..d5e8ad3b --- /dev/null +++ b/src/staff/templates/staff/components/responsible_staff_multiselect.html @@ -0,0 +1,12 @@ +{% load static %} + +{% block body_javascript %} + + + {{ form.media }} +{% endblock %} + +
    + {% csrf_token %} + {{ form }} +
    diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index 365a7aee..86597047 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -1,5 +1,6 @@ {% extends "staff/base.html" %} {% load i18n %} +{% load order_tags %} {% block content %} @@ -9,10 +10,10 @@ {% #table-cell header=True %}Qty{% /table-cell %} {% endfragment %} -

    Order {{ object }}

    +

    Order {{ object }}

    Back - Assign staff + {% responsible_staff_multiselect order=object %}
    {% if not object.is_seen %} diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index 006de46d..5db3cf9f 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -38,7 +38,7 @@

    Order {{ object }}

    {% endif %}
    - Assign staff + {% responsible_staff_multiselect order=object %}
    diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index b83d1fe8..ee15442b 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -1,3 +1,4 @@ +import uuid from collections import Counter from django import template @@ -11,6 +12,7 @@ Area, Order, ) +from staff.forms import ResponsibleStaffForm from ..tables import ( AssignedOrderTable, @@ -24,6 +26,18 @@ register = template.Library() +@register.inclusion_tag("staff/components/responsible_staff_multiselect.html") +def responsible_staff_multiselect(order: Order | None = None) -> dict: + prefix = f"order_{order.id}" if order else f"new_{uuid.uuid4().hex[:8]}" + + # Add prefix to the form to avoid conflicts with other forms on the page + form = ResponsibleStaffForm(order=order, prefix=prefix) + return { + "form": form, + "order": order, + } + + def generate_order_links(orders: list) -> str: if not orders: return "-" diff --git a/src/staff/urls.py b/src/staff/urls.py index f977135b..d88fb99e 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -1,5 +1,7 @@ from django.urls import path +from staff.api import OrderAPIView + from .views import ( AnalysisOrderDetailView, AnalysisOrderListView, @@ -148,4 +150,9 @@ OrderPrioritizedAdminView.as_view(), name="order-priority", ), + path( + "orders//assign-staff/", + OrderAPIView.as_view(), + name="order-assign-staff", + ), ] diff --git a/src/static/staff/js/responsible-staff-form.js b/src/static/staff/js/responsible-staff-form.js new file mode 100644 index 00000000..dae3812f --- /dev/null +++ b/src/static/staff/js/responsible-staff-form.js @@ -0,0 +1,112 @@ +function initResponsibleStaffForm() { + const forms = document.querySelectorAll(".responsible-staff-form"); + + forms.forEach(function (form) { + const select = form.querySelector("select"); + const updateUrl = form.dataset.updateUrl; + + if (!select || !updateUrl) { + console.warn("Responsible staff form missing select or update URL"); + return; + } + + let currentAbortController = null; + + $(select).on("change", function () { + const selectedUserIds = $(this).val() || []; + + // Cancel previous request if it exists + if (currentAbortController) { + currentAbortController.abort(); + } + + // Create new abort controller for this request + currentAbortController = new AbortController(); + + statusIndicator.showSpinner(form); + + updateAssignedStaff( + updateUrl, + selectedUserIds, + currentAbortController.signal + ) + .then(function (response) { + if (!response.ok) { + throw new Error("Could not update responsible staff"); + } + + statusIndicator.showSuccess(form); + + setTimeout(() => statusIndicator.hide(form), 2000); + + currentAbortController = null; + }) + .catch(function (error) { + // Only handle non-abort errors + if (error.name !== "AbortError") { + statusIndicator.showError(form); + + // Reload the page to reflect server-side changes + setTimeout(() => { + window.location.reload(); + }, 500); + } + + currentAbortController = null; + }); + }); + }); +} + +async function updateAssignedStaff(updateUrl, userIds, signal) { + const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value; + + if (!csrfToken) { + throw new Error("CSRF token not found"); + } + + return await fetch(updateUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + body: JSON.stringify({ + user_ids: userIds, + }), + signal: signal, + }); +} + +const statusIndicator = { + hide: function (form) { + const existing = form.querySelector(".staff-status-indicator"); + if (existing) { + existing.remove(); + } + }, + + show: function (form, type, icon) { + this.hide(form); + const indicator = document.createElement("div"); + indicator.className = `staff-status-indicator ${type}`; + indicator.innerHTML = ``; + form.appendChild(indicator); + }, + + showSpinner: function (form) { + this.show(form, "spinner", "fa-spinner fa-spin"); + }, + + showSuccess: function (form) { + this.show(form, "success", "fa-check"); + }, + + showError: function (form) { + this.show(form, "error", "fa-times"); + }, +}; + +document.addEventListener("DOMContentLoaded", function () { + initResponsibleStaffForm(); +}); diff --git a/src/theme/static_src/src/styles.css b/src/theme/static_src/src/styles.css index f2466780..143239fe 100644 --- a/src/theme/static_src/src/styles.css +++ b/src/theme/static_src/src/styles.css @@ -203,7 +203,6 @@ django-form-collection .dj-form .dj-form-errors { white-space: nowrap; min-height: min-content; @apply flex py-2 mb-2; - } .breadcrumb li { @@ -212,7 +211,6 @@ django-form-collection .dj-form .dj-form-errors { text-transform: capitalize; } - ul.breadcrumb > li + ::before, ol.breadcrumb > li + ::before { content: ""; @@ -282,36 +280,36 @@ ol.breadcrumb > li + ::before { } .custom_order_button { - background-color: #F2F2F2; /* Fill */ - border: 1px solid #BABABA; /* Stroke */ - color: #4A4A4A; /* Text & icon color */ + background-color: #f2f2f2; /* Fill */ + border: 1px solid #bababa; /* Stroke */ + color: #4a4a4a; /* Text & icon color */ } .custom_order_button:hover { background-color: #e0e0e0; /* Optional: darker on hover */ - border: 1px solid #A0A0A0; /* Stroke */ + border: 1px solid #a0a0a0; /* Stroke */ } .custom_order_button_blue { - background-color: #E6F0FA; /* Fill */ - border: 1px solid #8CA5BE; /* Stroke */ - color: #0F3D6A; /* Text & icon color */ + background-color: #e6f0fa; /* Fill */ + border: 1px solid #8ca5be; /* Stroke */ + color: #0f3d6a; /* Text & icon color */ } .custom_order_button_blue:hover { - background-color: #D2E4F5; /* Optional: darker on hover */ - border: 1px solid #7792AB; /* Stroke */ + background-color: #d2e4f5; /* Optional: darker on hover */ + border: 1px solid #7792ab; /* Stroke */ } .custom_order_button_green { - background-color: #DFF7CA; /* Fill */ - border: 1px solid #93AD7D; /* Stroke */ - color: #001D3A; /* Text & icon color */ + background-color: #dff7ca; /* Fill */ + border: 1px solid #93ad7d; /* Stroke */ + color: #001d3a; /* Text & icon color */ } .custom_order_button_green:hover { - background-color: #C9EBB0; /* Optional: darker on hover */ - border: 1px solid #7F996C; /* Stroke */ + background-color: #c9ebb0; /* Optional: darker on hover */ + border: 1px solid #7f996c; /* Stroke */ } .custom_order_button_red { @@ -324,3 +322,46 @@ ol.breadcrumb > li + ::before { background-color: #ebb0b0; /* Optional: darker on hover */ border: 1px solid #996c6c; /* Stroke */ } + +.responsible-staff-form { + position: relative; +} + +.staff-status-indicator { + position: absolute; + top: 8px; + left: 8px; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.staff-status-indicator.spinner { + color: #6b7280; +} + +.staff-status-indicator.success { + color: #22c55e; +} + +.staff-status-indicator.error { + color: #ef4444; +} + +.responsible-staff-form .select2-container.select2-container--default { + max-width: 11rem !important; + width: 11rem !important; + min-width: 11rem !important; +} + +.responsible-staff-form .select2-dropdown { + max-width: 11rem !important; + width: 11rem !important; +} + +.responsible-staff-form .select2-selection { + max-width: 11rem !important; + width: 11rem !important; + min-width: 11rem !important; +} From f93a42470475d807e13ce524d03fcea3903ff845 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:36:53 +0200 Subject: [PATCH 32/42] Add project filtering and activation features (#480) - Introduced ProjectFilter for filtering projects by number, name, verified status, and active status. - Updated ProjectTable to include a toggle for project activation. - Created templates for project verification and activation buttons. - Added ProjectArchiveActionView to handle project activation state changes. - Updated URLs to include the new archive action for projects. Co-authored-by: Morten Madsen Lyngstad --- src/staff/filters.py | 74 +++++++++++++++++++ src/staff/tables.py | 18 ++++- .../components/activation_toggle_column.html | 6 ++ .../components/project_verified_column.html | 14 ++++ .../staff/components/seen_column.html | 2 - src/staff/templates/staff/project_detail.html | 11 ++- src/staff/urls.py | 6 ++ src/staff/views.py | 47 +++++++++--- 8 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 src/staff/templates/staff/components/activation_toggle_column.html create mode 100644 src/staff/templates/staff/components/project_verified_column.html diff --git a/src/staff/filters.py b/src/staff/filters.py index 631e9ce8..a96b24f9 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -19,6 +19,7 @@ SampleMarkerAnalysis, Species, ) +from nina.models import Project class AnalysisOrderFilter(filters.FilterSet): @@ -445,3 +446,76 @@ def filter_queryset(self, queryset: QuerySet) -> QuerySet: queryset = queryset.filter(genlab_id__lte=genlab_max) return queryset + + +class ProjectFilter(filters.FilterSet): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ) -> None: + if not data or not any(data.values()): + data = {"active": "True"} + + super().__init__(data, queryset, request=request, prefix=prefix) + + number = CharFilter( + field_name="number", + lookup_expr="startswith", + label="Project number starts with", + widget=forms.TextInput( + attrs={ + "placeholder": "Enter project number", + } + ), + ) + + name = CharFilter( + field_name="name", + lookup_expr="istartswith", + label="Project name starts with", + widget=forms.TextInput( + attrs={ + "placeholder": "Enter project name", + } + ), + ) + + verified_at = filters.ChoiceFilter( + field_name="verified_at", + label="Verified", + choices=[ + ("True", "Yes"), + ("False", "No"), + ], + method="filter_verified_at", + widget=forms.Select( + attrs={ + "class": "form-check-input", + } + ), + ) + + active = filters.ChoiceFilter( + field_name="active", + label="Active", + choices=[ + ("True", "Yes"), + ("False", "No"), + ], + widget=forms.Select( + attrs={ + "class": "form-check-input", + } + ), + ) + + def filter_verified_at(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: + return queryset.filter(verified_at__isnull=(value == "False")) + + class Meta: + model = Project + fields = () diff --git a/src/staff/tables.py b/src/staff/tables.py index faeba5b5..d0da9982 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -31,11 +31,25 @@ class ProjectTable(tables.Table): orderable=True, empty_values=(), ) - verified_at = tables.BooleanColumn() + verified_at = tables.TemplateColumn( + template_name="staff/components/project_verified_column.html", + verbose_name="Verified", + orderable=True, + empty_values=(), + ) + + toggle_active = tables.TemplateColumn( + template_name="staff/components/activation_toggle_column.html", + verbose_name="Actions", + orderable=True, + empty_values=(), + ) class Meta: model = Project - fields = ("number", "name", "active", "verified_at") + fields = ("number", "name", "verified_at") + sequence = ("number", "name", "toggle_active", "verified_at") + order_by = ("-verified_at",) class OrderTable(OrderStatusMixinTable, PriorityMixinTable): diff --git a/src/staff/templates/staff/components/activation_toggle_column.html b/src/staff/templates/staff/components/activation_toggle_column.html new file mode 100644 index 00000000..cee1158e --- /dev/null +++ b/src/staff/templates/staff/components/activation_toggle_column.html @@ -0,0 +1,6 @@ +{% url 'staff:projects-archive' pk=record.pk as archive_url %} +{% if record.active %} + {% action-button action=archive_url class="btn btn-sm bg-[#D2E4F5] w-[8rem]" submit_text=" Archive" csrf_token=csrf_token %} +{% else %} + {% action-button action=archive_url class="btn btn-sm bg-[#D2E4F5] w-[8rem]" submit_text=" Activate" csrf_token=csrf_token %} +{% endif %} diff --git a/src/staff/templates/staff/components/project_verified_column.html b/src/staff/templates/staff/components/project_verified_column.html new file mode 100644 index 00000000..695eccae --- /dev/null +++ b/src/staff/templates/staff/components/project_verified_column.html @@ -0,0 +1,14 @@ +{% load next_input %} + +{% url 'staff:projects-verify' pk=record.pk as verify_url %} +
    + {% csrf_token %} + {% next_url_input %} +{% if not record.verified_at %} + + {% else %} + {% comment %} Default django true checkmark {% endcomment %} + +{% endif %} diff --git a/src/staff/templates/staff/components/seen_column.html b/src/staff/templates/staff/components/seen_column.html index 7424cb32..85c11ce1 100644 --- a/src/staff/templates/staff/components/seen_column.html +++ b/src/staff/templates/staff/components/seen_column.html @@ -1,5 +1,3 @@ -{% load order_tags %} - {% csrf_token %} diff --git a/src/staff/templates/staff/project_detail.html b/src/staff/templates/staff/project_detail.html index 535fc839..99c977fd 100644 --- a/src/staff/templates/staff/project_detail.html +++ b/src/staff/templates/staff/project_detail.html @@ -1,6 +1,6 @@ {% extends "staff/base.html" %} {% load i18n %} - +{% load next_input %} {% block content %}

    Project {{ object }}

    @@ -9,8 +9,13 @@

    Project {{ object }}

    Back {% url 'staff:projects-verify' pk=object.pk as verify_url %} - {% if object.verified_at is null %} - {% action-button action=verify_url class="btn-secondary text-white" submit_text="Mark as verified" csrf_token=csrf_token %} + + {% csrf_token %} + {% next_url_input %} + {% if not object.verified_at %} + {% endif %}
    diff --git a/src/staff/urls.py b/src/staff/urls.py index d88fb99e..f356eb0c 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -20,6 +20,7 @@ OrderPrioritizedAdminView, OrderToDraftActionView, OrderToNextStatusActionView, + ProjectArchiveActionView, ProjectDetailView, ProjectListView, ProjectValidateActionView, @@ -42,6 +43,11 @@ ProjectValidateActionView.as_view(), name="projects-verify", ), + path( + "projects//archive/", + ProjectArchiveActionView.as_view(), + name="projects-archive", + ), path( "orders/analysis/", AnalysisOrderListView.as_view(), name="order-analysis-list" ), diff --git a/src/staff/views.py b/src/staff/views.py index 9aaac3e5..766ee0ad 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -39,6 +39,7 @@ ExtractionOrderFilter, ExtractionPlateFilter, OrderSampleFilter, + ProjectFilter, SampleFilter, SampleLabFilter, SampleMarkerOrderFilter, @@ -950,41 +951,65 @@ def form_invalid(self, form: Form) -> HttpResponse: class ProjectListView(StaffMixin, SingleTableMixin, FilterView): model = Project table_class = ProjectTable - filterset_fields = { - "number": ["startswith"], - "name": ["startswith"], - "verified_at": ["isnull"], - "active": ["exact"], - } + filterset_class = ProjectFilter class ProjectDetailView(StaffMixin, DetailView): model = Project -class ProjectValidateActionView(SingleObjectMixin, ActionView): +class ProjectValidateActionView(SingleObjectMixin, SafeRedirectMixin, ActionView): model = Project def get_queryset(self) -> QuerySet[Project]: return super().get_queryset().filter(verified_at=None) + def form_valid(self, form: Form) -> HttpResponse: + self.object = self.get_object() + self.object.verified_at = now() + self.object.save() + messages.add_message( + self.request, + messages.SUCCESS, + _("The project is verified"), + ) + + return super().form_valid(form) + + def get_success_url(self) -> str: + return self.get_next_url() + + def get_fallback_url(self) -> str: + return self.request.GET.get("next", reverse("staff:projects-list")) + + def form_invalid(self, form: Form) -> HttpResponse: + return HttpResponseRedirect(self.get_next_url()) + + +class ProjectArchiveActionView(SingleObjectMixin, ActionView): + model = Project + + def get_queryset(self) -> QuerySet[Project]: + return Project.objects.all() + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.object = self.get_object() return super().post(request, *args, **kwargs) def form_valid(self, form: Form) -> HttpResponse: - self.object.verified_at = now() + # Toggle the active state of the project + self.object.active = not self.object.active self.object.save() + status = _("activated") if self.object.active else _("archived") messages.add_message( self.request, messages.SUCCESS, - _("The project is verified"), + _(f"The project is {status}"), ) - return super().form_valid(form) def get_success_url(self) -> str: - return reverse_lazy("staff:projects-detail", kwargs={"pk": self.object.pk}) + return reverse("staff:projects-list") def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) From 7bd485662b1ded96cd40080c3d83903ea4855e18 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 29 Jul 2025 09:41:20 +0200 Subject: [PATCH 33/42] Style dashboard table (#483) --- src/staff/templates/staff/components/order_table.html | 2 +- src/theme/static_src/src/styles.css | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/staff/templates/staff/components/order_table.html b/src/staff/templates/staff/components/order_table.html index a5f5e3b4..4c190a9b 100644 --- a/src/staff/templates/staff/components/order_table.html +++ b/src/staff/templates/staff/components/order_table.html @@ -2,7 +2,7 @@

    {{ title }} ({{ count }})

    -
    +
    {% render_table table %}
    diff --git a/src/theme/static_src/src/styles.css b/src/theme/static_src/src/styles.css index 143239fe..348d8568 100644 --- a/src/theme/static_src/src/styles.css +++ b/src/theme/static_src/src/styles.css @@ -28,7 +28,7 @@ } .tailwind-table thead tr:first-child { - @apply bg-gray-100 text-left; + @apply bg-gray-200 text-left; } .tailwind-table thead tr:first-child th { @@ -40,7 +40,7 @@ } .tailwind-table tbody tr td { - @apply border-b-2 border-[#eee] px-4 py-5; + @apply border-b-2 border-[#eee] p-4; } .tailwind-table.table-sm tbody tr td { From 8d5386eef15ad076d04406f25ccb814369fa5c06 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 29 Jul 2025 10:02:41 +0200 Subject: [PATCH 34/42] Use format_html (#479) --- src/staff/mixins.py | 16 +++++++++------ src/staff/tables.py | 27 +++++++++++++++---------- src/staff/templatetags/order_tags.py | 30 +++++++++++++++++----------- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/src/staff/mixins.py b/src/staff/mixins.py index 3bf73a46..cfe8d21a 100644 --- a/src/staff/mixins.py +++ b/src/staff/mixins.py @@ -3,8 +3,8 @@ import django_tables2 as tables from django.db.models import Case, IntegerField, Value, When from django.db.models.query import QuerySet +from django.utils.html import format_html 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 ( @@ -27,7 +27,7 @@ def render_id( ) -> str: url = record.get_absolute_staff_url() - return mark_safe(f'{record}') # noqa: S308 + return format_html('{}', url, str(record)) def render_status_helper(status: Order.OrderStatus) -> str: @@ -46,8 +46,10 @@ def render_status_helper(status: Order.OrderStatus) -> str: classes = status_colors.get(status, "bg-gray-100 text-gray-800") text = status_text.get(status, "Unknown") - return mark_safe( # noqa: S308 - f'{text}' # noqa: E501 + return format_html( + '{}', # noqa: E501 + classes, + text, ) @@ -109,8 +111,10 @@ def render_sample_status(self, value: Any, record: Sample) -> str: # Use computed status, not value color_class = status_colors.get(status, "bg-gray-100 text-gray-800") - return mark_safe( # noqa: S308 - f'{status}' # noqa: E501 + return format_html( + '{}', # noqa: E501 + color_class, + status, ) def order_sample_status( diff --git a/src/staff/tables.py b/src/staff/tables.py index d0da9982..cb474fb8 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -3,6 +3,7 @@ from typing import Any import django_tables2 as tables +from django.utils.html import format_html from django.utils.safestring import mark_safe from genlab_bestilling.models import ( @@ -241,18 +242,16 @@ def render_id(self, record: Any) -> str: return str(record) def render_is_urgent(self, value: bool) -> str: - html_exclaimation_mark = ( - "" - ) if value: - return mark_safe(html_exclaimation_mark) # noqa: S308 + return mark_safe( + "" # noqa: E501 + ) return "" def render_is_seen(self, value: bool) -> str: if not value: return mark_safe( - '' + '' # noqa: E501 ) return "" @@ -307,7 +306,9 @@ def render_plate_positions(self, value: Any) -> str: return "" def render_checked(self, record: Any) -> str: - return mark_safe(f'') # noqa: S308 + return format_html( + '', record.id + ) def order_name( self, records: Sequence[Any], is_descending: bool @@ -406,8 +407,10 @@ class Meta: order_by = ("genlab_id",) def render_checked(self, record: Any) -> str: - return mark_safe( # noqa: S308 - f'' # noqa: E501 + return format_html( + '', + record.order.id, + record.id, ) @@ -481,8 +484,10 @@ class Meta: empty_text = "No Samples" def render_checked(self, record: SampleMarkerAnalysis) -> str: - return mark_safe( # noqa: S308 - f'' # noqa: E501 + return format_html( + '', + record.order.id, + record.id, ) diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index ee15442b..fe9e0aee 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -41,10 +41,11 @@ def responsible_staff_multiselect(order: Order | None = None) -> dict: def generate_order_links(orders: list) -> str: if not orders: return "-" - links = [ - f'{order}' for order in orders - ] - return mark_safe(", ".join(links)) # noqa: S308 + return format_html_join( + ", ", + "{}", + ((order.get_absolute_staff_url(), str(order)) for order in orders), + ) def render_boolean(value: bool) -> str: @@ -336,21 +337,26 @@ def analysis_order_detail_table(order: Order) -> dict: @register.inclusion_tag("../templates/components/order-detail.html") def analysis_order_samples_detail_table(order: Order, extraction_orders: dict) -> dict: - # Generate links for extraction orders with sample counts - extraction_order_links = [ - f"{generate_order_links([extraction_order])} ({count} sample{'s' if count != 1 else ''})" # noqa: E501 - for extraction_order, count in extraction_orders.items() - ] + extraction_order_links = format_html_join( + "
    ", + "{} ({})", + ( + ( + generate_order_links([extraction_order]), + f"{count} sample{'s' if count > 1 else ''}", + ) + for extraction_order, count in extraction_orders.items() + ), + ) fields = { "Number of samples": order.samples.count(), "Markers": ", ".join(marker.name for marker in order.markers.all()) if order.markers.exists() else "No markers", - "Samples from extraction order": mark_safe("
    ".join(extraction_order_links)) # noqa: S308 - if extraction_order_links - else "-", + "Samples from extraction order": extraction_order_links or "-", } + return { "fields": fields, "header": "Samples", From 7537884640daa44a731144c1988a3ed5dd8fd7bc Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:58:16 +0200 Subject: [PATCH 35/42] Add static choices support and hide statuses by default in filters (#486) Co-authored-by: Morten Madsen Lyngstad --- src/staff/filters.py | 55 ++++++++++++++++++++++++++++++++++++-------- src/staff/mixins.py | 13 +++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/staff/filters.py b/src/staff/filters.py index a96b24f9..aac037d0 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -20,9 +20,32 @@ Species, ) from nina.models import Project +from staff.mixins import HideStatusesByDefaultMixin -class AnalysisOrderFilter(filters.FilterSet): +class StaticModelSelect2Multiple(autocomplete.ModelSelect2Multiple): + def __init__(self, static_choices: list[tuple], *args: Any, **kwargs: Any) -> None: + self.static_choices = static_choices or [] + super().__init__(*args, **kwargs) + + def filter_queryset( + self, request: HttpRequest, term: str, queryset: QuerySet + ) -> list: + # Override to use static choices instead of queryset + if term: + return [ + choice + for choice in self.static_choices + if term.lower() in choice[1].lower() + ] + return self.static_choices + + def get_queryset(self) -> QuerySet: + # Return empty queryset since we're using static data + return self.model.objects.none() + + +class AnalysisOrderFilter(HideStatusesByDefaultMixin, filters.FilterSet): id = filters.CharFilter( field_name="id", label="Order ID", @@ -34,16 +57,17 @@ class AnalysisOrderFilter(filters.FilterSet): ), ) - status = filters.ChoiceFilter( + status = filters.MultipleChoiceFilter( field_name="status", label="Status", choices=Order.OrderStatus.choices, - widget=forms.Select( + widget=StaticModelSelect2Multiple( + static_choices=Order.OrderStatus.choices, attrs={ - "class": "bg-white border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700" # noqa: E501 + "data-placeholder": "Filter by status", + "class": "border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 }, ), - empty_label="", ) genrequest__area = filters.ModelChoiceFilter( @@ -86,6 +110,11 @@ class AnalysisOrderFilter(filters.FilterSet): ), ) + @property + def qs(self) -> QuerySet: + queryset = super().qs + return self.exclude_hidden_statuses(queryset, self.data) + class Meta: model = AnalysisOrder fields = ( @@ -98,7 +127,7 @@ class Meta: ) -class ExtractionOrderFilter(filters.FilterSet): +class ExtractionOrderFilter(HideStatusesByDefaultMixin, filters.FilterSet): id = CharFilter( field_name="id", label="Order ID", @@ -110,16 +139,17 @@ class ExtractionOrderFilter(filters.FilterSet): ), ) - status = ChoiceFilter( + status = filters.MultipleChoiceFilter( field_name="status", label="Status", choices=Order.OrderStatus.choices, - widget=forms.Select( + widget=StaticModelSelect2Multiple( + static_choices=Order.OrderStatus.choices, attrs={ - "class": "bg-white border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700" # noqa: E501 + "data-placeholder": "Filter by status", + "class": "border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 }, ), - empty_label="", ) genrequest__area = filters.ModelChoiceFilter( @@ -152,6 +182,11 @@ class ExtractionOrderFilter(filters.FilterSet): ), ) + @property + def qs(self) -> QuerySet: + queryset = super().qs + return self.exclude_hidden_statuses(queryset, self.data) + class Meta: model = ExtractionOrder fields = ( diff --git a/src/staff/mixins.py b/src/staff/mixins.py index cfe8d21a..b1167629 100644 --- a/src/staff/mixins.py +++ b/src/staff/mixins.py @@ -3,6 +3,7 @@ import django_tables2 as tables from django.db.models import Case, IntegerField, Value, When from django.db.models.query import QuerySet +from django.http import QueryDict from django.utils.html import format_html from django.utils.http import url_has_allowed_host_and_scheme from django.views.generic import View @@ -208,3 +209,15 @@ def get_next_url(self) -> str: if next_url and self.has_next_url(next_url): return next_url return self.get_fallback_url() + + +class HideStatusesByDefaultMixin: + HIDDEN_STATUSES = [Order.OrderStatus.DRAFT, Order.OrderStatus.COMPLETED] + + # Hide statuses by default unless user specifically selects them + def exclude_hidden_statuses(self, queryset: QuerySet, data: QueryDict) -> QuerySet: + selected_statuses = data.getlist("status") + if not selected_statuses: # No statuses selected by user + return queryset.exclude(status__in=self.HIDDEN_STATUSES) + + return queryset From 8e5fa72f009fe99b94711f2a15d6c73d999ab6af Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 29 Jul 2025 11:23:09 +0200 Subject: [PATCH 36/42] Remove genetic from table column name (#487) --- src/staff/tables.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index cb474fb8..f115bf66 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -781,13 +781,13 @@ class DraftOrderTable(StaffIDMixinTable): contact_person = tables.Column( accessor="contact_person", - verbose_name="Responsible genetic researcher", + verbose_name="Genetic researcher", orderable=False, ) contact_email = tables.Column( accessor="contact_email", - verbose_name="Responsible genetic researcher email", + verbose_name="Genetic researcher email", orderable=False, ) From e775b6ddf6b63369bf5f29edb96af6ac3ad00f75 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 29 Jul 2025 11:23:16 +0200 Subject: [PATCH 37/42] Set order to processing when updating sample status (#485) --- src/staff/views.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/staff/views.py b/src/staff/views.py index 766ee0ad..f1565b85 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -443,7 +443,7 @@ def statuses_with_lower_or_equal_priority(self, status_name: str) -> list[str]: def assign_status_to_samples( self, - analyses: models.QuerySet, + analyses: QuerySet[SampleMarkerAnalysis], status_name: str, request: HttpRequest, ) -> None: @@ -489,15 +489,17 @@ def assign_status_to_samples( # Checks if all samples in the order have output # If they are, it updates the order status to completed - def check_all_output(self, analyses: models.QuerySet) -> None: + def check_all_output(self, analyses: QuerySet[SampleMarkerAnalysis]) -> None: + order = self.get_order() + if not analyses.filter(is_outputted=False).exists(): - self.get_order().to_completed() + order.to_completed() messages.success( self.request, "All samples have an output. The order status is updated to completed.", ) - elif self.get_order().status == Order.OrderStatus.COMPLETED: - self.get_order().to_processing() + elif order.status in (Order.OrderStatus.COMPLETED, Order.OrderStatus.DELIVERED): + order.to_processing() messages.success( self.request, "Not all samples have output. The order status is updated to processing.", # noqa: E501 From bd0a936ec6e24b3f21c4560b8614bbb023e28741 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:25:28 +0200 Subject: [PATCH 38/42] Major visual changes (#488) --- .../analysisorder_detail.html | 2 +- .../genlab_bestilling/base_filter.html | 8 +- src/staff/filters.py | 18 ++-- src/staff/mixins.py | 2 +- src/staff/tables.py | 7 +- .../templates/staff/analysisorder_detail.html | 23 ++--- .../templates/staff/analysisorder_filter.html | 10 +- src/staff/templates/staff/base.html | 35 +++++-- .../staff/components/extraction_tabs.html | 8 +- .../staff/components/priority_column.html | 7 +- src/staff/templates/staff/dashboard.html | 4 +- .../staff/equipmentorder_detail.html | 1 - .../staff/equipmentorder_filter.html | 9 +- .../staff/extractionorder_detail.html | 21 ++-- .../staff/extractionorder_filter.html | 9 +- .../staff/extractionplate_filter.html | 9 +- src/staff/templates/staff/project_filter.html | 9 +- src/staff/templates/staff/sample_filter.html | 29 ++---- src/staff/templates/staff/sample_lab.html | 23 ++--- .../staff/samplemarkeranalysis_filter.html | 33 +++---- src/staff/templatetags/order_tags.py | 2 +- src/templates/components.yaml | 1 + src/templates/components/filtering.html | 17 ++++ src/theme/static_src/src/styles.css | 95 +++++++++++++++++-- 24 files changed, 213 insertions(+), 169 deletions(-) create mode 100644 src/templates/components/filtering.html diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html index 9e264540..4cb9dad2 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html @@ -33,7 +33,7 @@
    Contacts for Analysis Results
    {% for contact in results_contacts %}
  • {{ contact.contact_person_results }} — - + {{ contact.contact_email_results }}
  • diff --git a/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html b/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html index ceee53de..145ff1b2 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html @@ -6,13 +6,7 @@

    {% block page-title %}{% endblock page-title %}

    {% block page-inner %}{% endblock page-inner %} - -
    - {{ filter.form | crispy }} - - Clear all -
    - + {% filtering filter=filter request=request %} {% render_table table %} {% endblock %} diff --git a/src/staff/filters.py b/src/staff/filters.py index aac037d0..8b204a17 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -14,7 +14,6 @@ ExtractionOrder, ExtractionPlate, Marker, - Order, Sample, SampleMarkerAnalysis, Species, @@ -22,6 +21,13 @@ from nina.models import Project from staff.mixins import HideStatusesByDefaultMixin +CUSTOM_ORDER_STATUS_CHOICES = [ + ("draft", "Draft"), + ("confirmed", "Not started"), + ("processing", "Processing"), + ("completed", "Completed"), +] + class StaticModelSelect2Multiple(autocomplete.ModelSelect2Multiple): def __init__(self, static_choices: list[tuple], *args: Any, **kwargs: Any) -> None: @@ -60,9 +66,9 @@ class AnalysisOrderFilter(HideStatusesByDefaultMixin, filters.FilterSet): status = filters.MultipleChoiceFilter( field_name="status", label="Status", - choices=Order.OrderStatus.choices, + choices=CUSTOM_ORDER_STATUS_CHOICES, widget=StaticModelSelect2Multiple( - static_choices=Order.OrderStatus.choices, + static_choices=CUSTOM_ORDER_STATUS_CHOICES, attrs={ "data-placeholder": "Filter by status", "class": "border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 @@ -142,9 +148,9 @@ class ExtractionOrderFilter(HideStatusesByDefaultMixin, filters.FilterSet): status = filters.MultipleChoiceFilter( field_name="status", label="Status", - choices=Order.OrderStatus.choices, + choices=CUSTOM_ORDER_STATUS_CHOICES, widget=StaticModelSelect2Multiple( - static_choices=Order.OrderStatus.choices, + static_choices=CUSTOM_ORDER_STATUS_CHOICES, attrs={ "data-placeholder": "Filter by status", "class": "border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 @@ -301,7 +307,7 @@ class Meta: class SampleStatusWidget(forms.Select): def __init__(self, attrs: dict[str, Any] | None = None): choices = ( - ("", "---------"), + ("", "Status"), ("marked", "Marked"), ("plucked", "Plucked"), ("isolated", "Isolated"), diff --git a/src/staff/mixins.py b/src/staff/mixins.py index b1167629..135f395e 100644 --- a/src/staff/mixins.py +++ b/src/staff/mixins.py @@ -28,7 +28,7 @@ def render_id( ) -> str: url = record.get_absolute_staff_url() - return format_html('{}', url, str(record)) + return format_html('{}', url, str(record)) def render_status_helper(status: Order.OrderStatus) -> str: diff --git a/src/staff/tables.py b/src/staff/tables.py index f115bf66..f0c1460d 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -3,6 +3,7 @@ from typing import Any import django_tables2 as tables +from django.templatetags.static import static from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -243,9 +244,9 @@ def render_id(self, record: Any) -> str: def render_is_urgent(self, value: bool) -> str: if value: - return mark_safe( - "" # noqa: E501 - ) + icon_url = static("images/exclaimation_mark.svg") + html = f"Urgent" # noqa: E501 + return mark_safe(html) # noqa: S308 return "" def render_is_seen(self, value: bool) -> str: diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 54fa3f2b..1b22cdae 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -7,31 +7,24 @@

    Order {{ object }}

    - Back - Samples - + {% responsible_staff_multiselect order=object %} +
    + {% if object.status != object.OrderStatus.DRAFT %} + {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} + {% action-button action=to_draft_url class="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} + {% endif %} {% if extraction_orders|length == 1 and not extraction_has_multiple_analysis_orders %} Go to {{ extraction_orders.first }} {% endif %} - -
    - + Samples {% if not object.is_seen %}
    {% csrf_token %} -
    {% endif %} - - {% if object.status != object.OrderStatus.DRAFT %} - {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} - {% action-button action=to_draft_url class="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} - {% endif %} -
    -
    - {% responsible_staff_multiselect order=object %}
    diff --git a/src/staff/templates/staff/analysisorder_filter.html b/src/staff/templates/staff/analysisorder_filter.html index 0d1cc6c3..a542925e 100644 --- a/src/staff/templates/staff/analysisorder_filter.html +++ b/src/staff/templates/staff/analysisorder_filter.html @@ -7,14 +7,6 @@ {% endblock page-title %} {% block page-inner %} -
    -
    - {{ filter.form | crispy }} - - Clear all -
    -
    - +{% filtering filter=filter request=request %} {% render_table table %} - {% endblock page-inner %} diff --git a/src/staff/templates/staff/base.html b/src/staff/templates/staff/base.html index d25107b2..0486d3be 100644 --- a/src/staff/templates/staff/base.html +++ b/src/staff/templates/staff/base.html @@ -5,13 +5,34 @@
    diff --git a/src/staff/templates/staff/components/extraction_tabs.html b/src/staff/templates/staff/components/extraction_tabs.html index 6f42200e..31dd5816 100644 --- a/src/staff/templates/staff/components/extraction_tabs.html +++ b/src/staff/templates/staff/components/extraction_tabs.html @@ -1,15 +1,15 @@
    - diff --git a/src/staff/templates/staff/components/priority_column.html b/src/staff/templates/staff/components/priority_column.html index 0ff60ea2..b599b4ea 100644 --- a/src/staff/templates/staff/components/priority_column.html +++ b/src/staff/templates/staff/components/priority_column.html @@ -1,7 +1,8 @@ {% load next_input %} +{% load static %} {% if record.is_urgent %} - + Urgent {% else %}
    {% csrf_token %} @@ -12,11 +13,11 @@ - Clear all -
    - - +{% filtering filter=filter request=request %} {% render_table table %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index 5db3cf9f..ec7ffdf3 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -8,28 +8,26 @@

    Order {{ object }}

    - Back - Samples - + {% responsible_staff_multiselect order=object %} +
    + {% if object.status != object.OrderStatus.DRAFT %} + {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} + {% action-button action=to_draft_url class="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} + {% endif %} {% if analysis_orders|length == 1 and not analysis_has_multiple_extraction_orders %} Go to {{ analysis_orders.first}} {% endif %} -
    + Samples {% if not object.is_seen %}
    {% csrf_token %} -
    {% endif %} - {% if object.status != object.OrderStatus.DRAFT %} - {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} - {% action-button action=to_draft_url class="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} - {% endif %} - {% if object.status == object.OrderStatus.DELIVERED and object.internal_status == "checked" %} {% url 'staff:order-to-next-status' pk=object.id as to_next_status_url %} {% with " Set as "|add:object.next_status as btn_name %} @@ -37,9 +35,6 @@

    Order {{ object }}

    {% endwith %} {% endif %}
    -
    - {% responsible_staff_multiselect order=object %} -
    diff --git a/src/staff/templates/staff/extractionorder_filter.html b/src/staff/templates/staff/extractionorder_filter.html index 8b9830b3..7f8f80b0 100644 --- a/src/staff/templates/staff/extractionorder_filter.html +++ b/src/staff/templates/staff/extractionorder_filter.html @@ -7,14 +7,7 @@ {% endblock page-title %} {% block page-inner %} -
    -
    - {{ filter.form | crispy }} - - Clear all -
    -
    - +{% filtering filter=filter request=request %} {% render_table table %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/extractionplate_filter.html b/src/staff/templates/staff/extractionplate_filter.html index 9e431c71..b0bcb7ba 100644 --- a/src/staff/templates/staff/extractionplate_filter.html +++ b/src/staff/templates/staff/extractionplate_filter.html @@ -11,13 +11,6 @@ Create
    -
    -
    - {{ filter.form | crispy }} -
    - - Clear all -
    - +{% filtering filter=filter request=request %} {% render_table table %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/project_filter.html b/src/staff/templates/staff/project_filter.html index 1041e9cf..49a5860c 100644 --- a/src/staff/templates/staff/project_filter.html +++ b/src/staff/templates/staff/project_filter.html @@ -7,14 +7,7 @@ {% endblock page-title %} {% block page-inner %} -
    -
    - {{ filter.form | crispy }} - - Clear all -
    -
    - +{% filtering filter=filter request=request %} {% render_table table %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/sample_filter.html b/src/staff/templates/staff/sample_filter.html index c597f819..db087c4e 100644 --- a/src/staff/templates/staff/sample_filter.html +++ b/src/staff/templates/staff/sample_filter.html @@ -4,10 +4,13 @@ {% load next_input %} {% block page-title %} +
    + Back +
    {% if order %} - {{ order }} - Samples + Samples {{ order }} {% else %} Samples {% endif %} @@ -17,19 +20,11 @@ {% block page-inner %} {% if order %} -
    - Back -
    - {% include "staff/components/extraction_tabs.html" with order=order active_tab="ordered" %} -
    -
    - {{ filter.form | crispy }} - - Clear all -
    -
    + {% filtering filter=filter request=request %} + +
    {% csrf_token %} @@ -46,7 +41,7 @@ {{ order.filled_genlab_count }} / {{ order.samples.count }} samples with genlabID
    -
    +
    @@ -56,13 +51,7 @@ {% else %} - -
    - {{ filter.form | crispy }} - - Clear all -
    - +{% filtering filter=filter request=request %} {% render_table table %} diff --git a/src/staff/templates/staff/sample_lab.html b/src/staff/templates/staff/sample_lab.html index 05acdbe9..d27e8601 100644 --- a/src/staff/templates/staff/sample_lab.html +++ b/src/staff/templates/staff/sample_lab.html @@ -4,27 +4,22 @@ {% load next_input %} {% block content %} -

    {% block page-title %}{% if order %}{{ order }} - Samples{% else %}Samples{% endif %}{% endblock page-title %}

    +
    + Back +
    +

    {% block page-title %}{% if order %}Samples {{ order }}{% else %}Samples{% endif %}{% endblock page-title %}

    {% block page-inner %} -
    - Back -
    - {% include "staff/components/extraction_tabs.html" with order=order active_tab="lab" %} -
    -
    - {{ filter.form | crispy }} - - Clear all -
    -
    + {% filtering filter=filter request=request %} + +
    {% csrf_token %} {% next_url_input %} {% for status in statuses %} - {% endfor %} @@ -32,7 +27,7 @@

    {% block page-title %}{% if order %}{{ order }} - Samp - Clear all -

    - +

    {% block page-title %}{% if order %}Samples {{ order }}{% else %}Samples{% endif %}{% endblock page-title %}

    +{% block page-inner %} +{% filtering filter=filter request=request %} +
    {% csrf_token %} {% next_url_input %} {% for status in statuses %} - {% endfor %} - - {% render_table table %} +
    + {% render_table table %} +
    +{% endblock page-inner %} - -{% endblock page-inner %} +{% endblock content %} diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index fe9e0aee..173d8d99 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -378,7 +378,7 @@ def contact_detail_table(order: Order) -> dict: if result_contacts: result_contacts_html = format_html_join( "\n", - '
    {} — {}
    ', # noqa: E501 + '
    {} — {}
    ', # noqa: E501 [(name, email, email) for name, email in result_contacts], ) diff --git a/src/templates/components.yaml b/src/templates/components.yaml index 91fb90ad..a20667f7 100644 --- a/src/templates/components.yaml +++ b/src/templates/components.yaml @@ -5,3 +5,4 @@ components: object-detail: "components/object-detail.html" object-detail-staff: "components/object-detail-staff.html" action-button: "components/action-button.html" + filtering: "components/filtering.html" diff --git a/src/templates/components/filtering.html b/src/templates/components/filtering.html new file mode 100644 index 00000000..8502ada0 --- /dev/null +++ b/src/templates/components/filtering.html @@ -0,0 +1,17 @@ +{% load crispy_forms_tags static %} + +
    +
    + Filters +
    +
    + {{ filter.form | crispy }} +
    + + Clear all +
    +
    +
    diff --git a/src/theme/static_src/src/styles.css b/src/theme/static_src/src/styles.css index 348d8568..2183eb96 100644 --- a/src/theme/static_src/src/styles.css +++ b/src/theme/static_src/src/styles.css @@ -27,6 +27,10 @@ @apply border-yellow-500 bg-yellow-300; } + .tailwind-table thead { + @apply z-20; + } + .tailwind-table thead tr:first-child { @apply bg-gray-200 text-left; } @@ -43,6 +47,10 @@ @apply border-b-2 border-[#eee] p-4; } + .tailwind-table tbody tr:last-child td { + @apply border-b-0; + } + .tailwind-table.table-sm tbody tr td { @apply py-1; } @@ -280,9 +288,9 @@ ol.breadcrumb > li + ::before { } .custom_order_button { - background-color: #f2f2f2; /* Fill */ - border: 1px solid #bababa; /* Stroke */ - color: #4a4a4a; /* Text & icon color */ + background-color: #F2F2F2; /* Fill */ + border: 1px solid #BABABA; /* Stroke */ + color: #000000; /* Text & icon color */ } .custom_order_button:hover { @@ -290,10 +298,20 @@ ol.breadcrumb > li + ::before { border: 1px solid #a0a0a0; /* Stroke */ } +.custom_order_button:active { + background-color: #D6D6D6; /* Optional: darker on hover */ + border: 1px solid #8C8C8C; /* Stroke */ +} + +.custom_order_button:active { + background-color: #D6D6D6; /* Optional: darker on hover */ + border: 1px solid #8C8C8C; /* Stroke */ +} + .custom_order_button_blue { - background-color: #e6f0fa; /* Fill */ - border: 1px solid #8ca5be; /* Stroke */ - color: #0f3d6a; /* Text & icon color */ + background-color: #E6F0FA; /* Fill */ + border: 1px solid #8CA5BE; /* Stroke */ + color: #000000; /* Text & icon color */ } .custom_order_button_blue:hover { @@ -301,10 +319,52 @@ ol.breadcrumb > li + ::before { border: 1px solid #7792ab; /* Stroke */ } +.custom_order_button_blue:active { + background-color: #BFD8F0; /* Optional: darker on hover */ + border: 1px solid #5F7D95; /* Stroke */ +} + +.custom_order_button_dark_blue { + background-color: #C9E4FF; /* Fill */ + border: 1px solid #6891BA; /* Stroke */ + color: #000000; /* Text & icon color */ +} + +.custom_order_button_dark_blue:hover { + background-color: #A7D3FF; /* Optional: darker on hover */ + border: 1px solid #6891BA; /* Stroke */ +} + +.custom_order_button_dark_blue:active { + background-color: #91C8FF; /* Optional: darker on hover */ + border: 1px solid #437AB2; /* Stroke */ +} + +.custom_order_button_blue:active { + background-color: #BFD8F0; /* Optional: darker on hover */ + border: 1px solid #5F7D95; /* Stroke */ +} + +.custom_order_button_dark_blue { + background-color: #C9E4FF; /* Fill */ + border: 1px solid #6891BA; /* Stroke */ + color: #000000; /* Text & icon color */ +} + +.custom_order_button_dark_blue:hover { + background-color: #A7D3FF; /* Optional: darker on hover */ + border: 1px solid #6891BA; /* Stroke */ +} + +.custom_order_button_dark_blue:active { + background-color: #91C8FF; /* Optional: darker on hover */ + border: 1px solid #437AB2; /* Stroke */ +} + .custom_order_button_green { - background-color: #dff7ca; /* Fill */ - border: 1px solid #93ad7d; /* Stroke */ - color: #001d3a; /* Text & icon color */ + background-color: #DFF7CA; /* Fill */ + border: 1px solid #93AD7D; /* Stroke */ + color: #000000; /* Text & icon color */ } .custom_order_button_green:hover { @@ -312,10 +372,20 @@ ol.breadcrumb > li + ::before { border: 1px solid #7f996c; /* Stroke */ } +.custom_order_button_green:active { + background-color: #B5DA9C; /* Optional: darker on hover */ + border: 1px solid #6B855B; /* Stroke */ +} + +.custom_order_button_green:active { + background-color: #B5DA9C; /* Optional: darker on hover */ + border: 1px solid #6B855B; /* Stroke */ +} + .custom_order_button_red { background-color: #f7caca; /* Fill */ border: 1px solid #ad7d7d; /* Stroke */ - color: #3a0000; /* Text & icon color */ + color: #000000; /* Text & icon color */ } .custom_order_button_red:hover { @@ -323,6 +393,11 @@ ol.breadcrumb > li + ::before { border: 1px solid #996c6c; /* Stroke */ } +.custom_order_button_red:active { + background-color: #FFA3A3; /* Optional: darker on hover */ + border: 1px solid #B96363; /* Stroke */ +} + .responsible-staff-form { position: relative; } From 971402712101afba9b112cf6aab69763ea1a9158 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:27:52 +0200 Subject: [PATCH 39/42] Update column order complete sheet csv for terrestrisk (#490) Co-authored-by: Morten Madsen Lyngstad --- src/genlab_bestilling/api/constants.py | 12 ++++++------ src/genlab_bestilling/api/serializers.py | 4 ++++ src/genlab_bestilling/api/views.py | 7 +++---- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/genlab_bestilling/api/constants.py b/src/genlab_bestilling/api/constants.py index 4b62197f..9fba5360 100644 --- a/src/genlab_bestilling/api/constants.py +++ b/src/genlab_bestilling/api/constants.py @@ -128,9 +128,9 @@ "rerun_date", ), "Terrestrisk": ( - "name", "genlab_id", "guid", + "name", "type.name", "species.name", "location.name", @@ -199,9 +199,9 @@ } LABEL_CSV_FIELDS_BY_AREA = { - "Akvatisk": ("fish_id", "genlab_id", "guid"), - "Elvemusling": ("fish_id", "genlab_id", "guid"), - "Terrestrisk": ("genlab_id", "guid", "name"), - "MiljøDNA": ("genlab_id", "guid", "name", "location.name"), - "default": ("genlab_id", "guid", "name"), + "Akvatisk": ("fish_id", "genlab_id", "guid", "order"), + "Elvemusling": ("fish_id", "genlab_id", "guid", "order"), + "Terrestrisk": ("genlab_id", "guid", "name", "order"), + "MiljøDNA": ("genlab_id", "guid", "name", "location.name", "order"), + "default": ("genlab_id", "guid", "name", "order"), } diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index 1bdf9ce8..855de4e0 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -166,6 +166,8 @@ def get_internal_note(self, obj: Sample) -> str: class LabelCSVSerializer(serializers.ModelSerializer): + location = LocationSerializer(allow_null=True, required=False) + class Meta: model = Sample fields = ( @@ -173,6 +175,8 @@ class Meta: "guid", "name", "fish_id", + "order", + "location", ) def get_fish_id(self, obj: Sample) -> str: diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index 4b78ec2a..dc428377 100644 --- a/src/genlab_bestilling/api/views.py +++ b/src/genlab_bestilling/api/views.py @@ -211,10 +211,9 @@ def get_serializer_class(self) -> type[BaseSerializer]: def get_order_id(self, queryset: QuerySet) -> str: order_id = "unknown_order_id" - first_sample = queryset.first() - if first_sample and first_sample.order and first_sample.order.id: - order_id = str(first_sample.order.id) - + order_id_value = queryset.values_list("order__id", flat=True).first() + if order_id_value: + order_id = str(order_id_value) return order_id @action( From ca528c074074a5a43362b896324982f1d4ece701 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:33:46 +0200 Subject: [PATCH 40/42] Fix N+1 query by prefetching order (#491) Co-authored-by: Morten Madsen Lyngstad --- src/staff/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/staff/views.py b/src/staff/views.py index f1565b85..508866bd 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -562,7 +562,9 @@ def get_order(self) -> ExtractionOrder: return self._order def get_queryset(self) -> QuerySet[Sample]: - return Sample.objects.filter(order=self.get_order(), genlab_id__isnull=False) + return Sample.objects.filter( + order=self.get_order(), genlab_id__isnull=False + ).prefetch_related("order") def get_isolation_methods(self) -> QuerySet[IsolationMethod, str]: types = self.get_queryset().values_list("type", flat=True).distinct() From ea9e461c9b42eb3aadee922e5307aea100937a27 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:02:53 +0200 Subject: [PATCH 41/42] Prefetch isolation method to fix N+1 query (#492) Co-authored-by: Morten Madsen Lyngstad --- src/staff/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/staff/views.py b/src/staff/views.py index 508866bd..07c91297 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -564,7 +564,7 @@ def get_order(self) -> ExtractionOrder: def get_queryset(self) -> QuerySet[Sample]: return Sample.objects.filter( order=self.get_order(), genlab_id__isnull=False - ).prefetch_related("order") + ).prefetch_related("order", "type", "isolation_method") def get_isolation_methods(self) -> QuerySet[IsolationMethod, str]: types = self.get_queryset().values_list("type", flat=True).distinct() From 684a41d83a37f238781e8d3d3a33c7f67a19958a Mon Sep 17 00:00:00 2001 From: aastabk Date: Tue, 29 Jul 2025 16:31:32 +0200 Subject: [PATCH 42/42] Filterset for EquipmentOrder --- src/staff/filters.py | 61 ++++++++++++++++++++++++++++++++++++++++++++ src/staff/tables.py | 2 +- src/staff/views.py | 3 ++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/staff/filters.py b/src/staff/filters.py index 8b204a17..7cbb6eaa 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -11,6 +11,7 @@ from genlab_bestilling.models import ( AnalysisOrder, Area, + EquipmentOrder, ExtractionOrder, ExtractionPlate, Marker, @@ -133,6 +134,66 @@ class Meta: ) +class EquipmentOrderFilter(HideStatusesByDefaultMixin, filters.FilterSet): + id = filters.CharFilter( + field_name="id", + label="Order ID", + widget=forms.TextInput( + attrs={ + "class": "bg-white border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 + "placeholder": "Enter Order ID", + } + ), + ) + + status = filters.MultipleChoiceFilter( + field_name="status", + label="Status", + choices=CUSTOM_ORDER_STATUS_CHOICES, + widget=StaticModelSelect2Multiple( + static_choices=CUSTOM_ORDER_STATUS_CHOICES, + attrs={ + "data-placeholder": "Filter by status", + "class": "border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 + }, + ), + ) + + genrequest__area = filters.ModelChoiceFilter( + field_name="genrequest__area", + label="Area", + queryset=Area.objects.all(), + widget=autocomplete.ModelSelect2( + url="autocomplete:area", + attrs={"class": "w-full"}, + ), + ) + + responsible_staff = filters.ModelMultipleChoiceFilter( + field_name="responsible_staff", + label="Assigned Staff", + queryset=User.objects.filter(groups__name="genlab"), + widget=autocomplete.ModelSelect2Multiple( + url="autocomplete:staff-user", + attrs={"class": "w-full"}, + ), + ) + + @property + def qs(self) -> QuerySet: + queryset = super().qs + return self.exclude_hidden_statuses(queryset, self.data) + + class Meta: + model = EquipmentOrder + fields = ( + "id", + "status", + "genrequest__area", + "responsible_staff", + ) + + class ExtractionOrderFilter(HideStatusesByDefaultMixin, filters.FilterSet): id = CharFilter( field_name="id", diff --git a/src/staff/tables.py b/src/staff/tables.py index f0c1460d..751435da 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -194,7 +194,7 @@ class Meta(OrderTable.Meta): ) -class EquipmentOrderTable(tables.Table): +class EquipmentOrderTable(OrderTable): id = tables.Column( linkify=("staff:order-equipment-detail", {"pk": tables.A("id")}), orderable=False, diff --git a/src/staff/views.py b/src/staff/views.py index 07c91297..8aeb4025 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -36,6 +36,7 @@ from .filters import ( AnalysisOrderFilter, + EquipmentOrderFilter, ExtractionOrderFilter, ExtractionPlateFilter, OrderSampleFilter, @@ -165,7 +166,7 @@ def get_queryset(self) -> QuerySet[ExtractionPlate]: class EqupimentOrderListView(StaffMixin, SingleTableMixin, FilterView): model = EquipmentOrder table_class = EquipmentOrderTable - filterset_class = AnalysisOrderFilter + filterset_class = EquipmentOrderFilter table_pagination = {"per_page": 20} def get_queryset(self) -> QuerySet[EquipmentOrder]: