From 10672e3d9aa4ce0631e7aae8c057f185ccc948ef Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Thu, 19 Jun 2025 15:44:18 +0200 Subject: [PATCH 01/99] Add 'is_urgent' field to Order model and update related forms --- src/genlab_bestilling/forms.py | 8 ++++++++ .../migrations/0014_order_is_urgent.py | 18 ++++++++++++++++++ src/genlab_bestilling/models.py | 1 + 3 files changed, 27 insertions(+) create mode 100644 src/genlab_bestilling/migrations/0014_order_is_urgent.py diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index 6d4991b4..96df68dc 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -116,6 +116,7 @@ def __init__(self, *args, genrequest, **kwargs): super().__init__(*args, **kwargs) self.genrequest = genrequest + self.fields["is_urgent"].label = "Check this box if the order is urgent" # self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() @@ -136,6 +137,7 @@ class Meta: "sample_types", "notes", "tags", + "is_urgent", ) widgets = { # "species": DualSortableSelector( @@ -239,6 +241,7 @@ def __init__(self, *args, genrequest, **kwargs): "You can provide a descriptive name " + "for this order to help you find it later" ) + self.fields["is_urgent"].label = "Check this box if the order is urgent" self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() @@ -265,6 +268,7 @@ class Meta: "tags", "pre_isolated", "return_samples", + "is_urgent", ) widgets = { "species": DualSortableSelector( @@ -294,6 +298,7 @@ def __init__(self, *args, genrequest, **kwargs): "You can provide a descriptive name " + "for this order to help you find it later" ) + self.fields["is_urgent"].label = "Check this box if the order is urgent" self.fields["markers"].queryset = Marker.objects.filter( genrequest__id=genrequest.id @@ -352,6 +357,7 @@ class Meta: "notes", "expected_delivery_date", "tags", + "is_urgent", ] widgets = { "name": TextInput( @@ -377,9 +383,11 @@ class Meta(AnalysisOrderForm.Meta): "notes", "expected_delivery_date", "tags", + "is_urgent", ] def __init__(self, *args, genrequest, **kwargs): super().__init__(*args, genrequest=genrequest, **kwargs) + self.fields["is_urgent"].label = "Check this box if the order is urgent" if "use_all_samples" in self.fields: del self.fields["use_all_samples"] diff --git a/src/genlab_bestilling/migrations/0014_order_is_urgent.py b/src/genlab_bestilling/migrations/0014_order_is_urgent.py new file mode 100644 index 00000000..b1b049d9 --- /dev/null +++ b/src/genlab_bestilling/migrations/0014_order_is_urgent.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.3 on 2025-06-19 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('genlab_bestilling', '0013_location_comment_alter_location_river_id'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='is_urgent', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index b69f596e..59a5eff1 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -252,6 +252,7 @@ class OrderStatus(models.TextChoices): created_at = models.DateTimeField(auto_now_add=True) last_modified_at = models.DateTimeField(auto_now=True) confirmed_at = models.DateTimeField(null=True, blank=True) + is_urgent = models.BooleanField(default=False) tags = TaggableManager(blank=True) objects = managers.OrderManager() From 22deb0c21c41e7b5a011222677b33819cb807a04 Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Fri, 20 Jun 2025 11:16:22 +0200 Subject: [PATCH 02/99] Implement DashboardView and create dashboard template for urgent GenRequests --- src/staff/templates/staff/dashboard.html | 27 ++++++++++++++++++++++++ src/staff/urls.py | 4 ++-- src/staff/views.py | 27 +++++++++++++++++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/staff/templates/staff/dashboard.html diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html new file mode 100644 index 00000000..f65c3874 --- /dev/null +++ b/src/staff/templates/staff/dashboard.html @@ -0,0 +1,27 @@ +{% extends "staff/base.html" %} +{% load i18n %} + +{% block content %} +
+

Dashboard

+
+ +{% if urgent_genrequests|length > 0 %} +
+

Urgent GenRequests

+ + {% for genrequest in urgent_genrequests %} + +

+ {{ genrequest }} - {{ genrequest.project.name|default:"N/A" }} +

+
+ {% endfor %} +
+{% else %} +
+

Urgent GenRequests

+

No urgent GenRequests found.

+
+{% endif %} +{% endblock %} diff --git a/src/staff/urls.py b/src/staff/urls.py index e964089b..a3dbf599 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -1,9 +1,9 @@ from django.urls import path -from django.views.generic import TemplateView from .views import ( AnalysisOrderDetailView, AnalysisOrderListView, + DashboardView, EquipmentOrderDetailView, EqupimentOrderListView, ExtractionOrderDetailView, @@ -27,7 +27,7 @@ app_name = "staff" urlpatterns = [ - path("", TemplateView.as_view(template_name="staff/base.html"), name="dashboard"), + path("", DashboardView.as_view(), name="dashboard"), path("projects/", ProjectListView.as_view(), name="projects-list"), path("projects//", ProjectDetailView.as_view(), name="projects-detail"), path( diff --git a/src/staff/views.py b/src/staff/views.py index c58eb86f..25eb7697 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -7,7 +7,7 @@ from django.urls import reverse_lazy from django.utils.timezone import now from django.utils.translation import gettext as _ -from django.views.generic import CreateView, DetailView +from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic.detail import SingleObjectMixin from django_filters.views import FilterView from django_tables2.views import SingleTableMixin @@ -17,6 +17,7 @@ EquipmentOrder, ExtractionOrder, ExtractionPlate, + Genrequest, Order, Sample, SampleMarkerAnalysis, @@ -56,6 +57,30 @@ def test_func(self): return self.request.user.is_superuser or self.request.user.is_genlab_staff() +class DashboardView(StaffMixin, TemplateView): + template_name = "staff/dashboard.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + urgent_genrequest_ids = ( + Order.objects.filter(is_urgent=True) + .values_list("genrequest_id", flat=True) + .distinct() + ) + + urgent_genrequests = Genrequest.objects.filter( + id__in=urgent_genrequest_ids + ).select_related( + "samples_owner", + "project", + "area", + ) + + context["urgent_genrequests"] = urgent_genrequests + return context + + class AnalysisOrderListView(StaffMixin, SingleTableMixin, FilterView): model = AnalysisOrder table_class = AnalysisOrderTable From 83e255a2dcd30079f9b2d5897bbf5ad598b93e98 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Fri, 20 Jun 2025 14:31:58 +0200 Subject: [PATCH 03/99] Fix broken alias. (#82) --- aliases.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aliases.sh b/aliases.sh index 54014c6d..ab585089 100755 --- a/aliases.sh +++ b/aliases.sh @@ -3,8 +3,8 @@ alias dpcli_dev="docker compose --profile dev" alias dpcli_prod="docker compose --profile prod" -alias djcli_dev="docker compose --profile dev exec -it django-dev ./src/manage.py" -alias djcli_prod="docker compose --profile prod -it django ./src/manage.py" +alias djcli_dev="docker compose exec -it django-dev ./src/manage.py" +alias djcli_prod="docker compose exec -it django ./src/manage.py" alias deps-sync="uv sync" alias deps-sync-prod="uv sync --profile prod --no-dev" From a2ae12e80793e34c460ef465349e475e2965db86 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Fri, 20 Jun 2025 08:58:13 +0200 Subject: [PATCH 04/99] Add week number and date to dashboard frontpage --- src/staff/templates/staff/dashboard.html | 46 +++++++++++++----------- src/static/js/staff/dashboard.js | 20 +++++++++++ src/static/js/utils.js | 4 +++ 3 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 src/static/js/staff/dashboard.js create mode 100644 src/static/js/utils.js diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index f65c3874..665599fd 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -1,27 +1,33 @@ -{% extends "staff/base.html" %} +{% extends 'staff/base.html' %} {% load i18n %} +{% load static %} + +{% block head_javascript %} + +{% endblock %} {% block content %} -
+

Dashboard

-
+
+ +
+

+

+
-{% if urgent_genrequests|length > 0 %} -
-

Urgent GenRequests

+ {% if urgent_genrequests|length > 0 %} +
+

Urgent GenRequests

- {% for genrequest in urgent_genrequests %} - -

- {{ genrequest }} - {{ genrequest.project.name|default:"N/A" }} -

-
- {% endfor %} -
-{% else %} -
-

Urgent GenRequests

-

No urgent GenRequests found.

-
-{% endif %} + {% for genrequest in urgent_genrequests %} +

{{ genrequest }} - {{ genrequest.project.name|default:'N/A' }}

+ {% endfor %} +
+ {% else %} +
+

Urgent GenRequests

+

No urgent GenRequests found.

+
+ {% endif %} {% endblock %} diff --git a/src/static/js/staff/dashboard.js b/src/static/js/staff/dashboard.js new file mode 100644 index 00000000..95d9a34b --- /dev/null +++ b/src/static/js/staff/dashboard.js @@ -0,0 +1,20 @@ +import { capitalize } from "../utils.js"; + +const date = new Date(); + +const formattedDate = date.toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", +}); + +const year = date.getFullYear(); +const yearStart = new Date(year, 0, 1); +const weekNumber = Math.ceil( + ((date - yearStart) / 86400000 + yearStart.getDay() + 1) / 7 +); + +document.getElementById("dashboard__week").textContent = `Week ${weekNumber}`; +document.getElementById("dashboard__date").textContent = + capitalize(formattedDate); diff --git a/src/static/js/utils.js b/src/static/js/utils.js new file mode 100644 index 00000000..0ed4a8c9 --- /dev/null +++ b/src/static/js/utils.js @@ -0,0 +1,4 @@ +export const capitalize = (str) => { + if (typeof str !== "string") return ""; + return str.charAt(0).toUpperCase() + str.slice(1); +}; From 350e6651b9fa29ddcae69a161ebc169b3816ee0f Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Fri, 20 Jun 2025 10:05:04 +0200 Subject: [PATCH 05/99] Display confirmed orders on dashboard frontpage --- src/staff/templates/staff/dashboard.html | 26 ++++++++++++++++++++---- src/staff/views.py | 11 ++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 665599fd..e9353dec 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -9,13 +9,31 @@ {% block content %}

Dashboard

-
-
-

-

+
+

+

+
+ {% if confirmed_orders|length > 0 %} +
+

Confirmed Orders

+ + {% for order in confirmed_orders %} + {% if order.polymorphic_ctype.model == 'analysisorder' %} +

{{ order }} - {{ order.name }}

+ {% elif order.polymorphic_ctype.model == 'equipmentorder' %} +

{{ order }} - {{ order.name }}

+ {% elif order.polymorphic_ctype.model == 'extractionorder' %} +

{{ order }} - {{ order.name }}

+ {% else %} +

{{ order }} - {{ order.name }}

+ {% endif %} + {% endfor %} +
+ {% endif %} + {% if urgent_genrequests|length > 0 %}

Urgent GenRequests

diff --git a/src/staff/views.py b/src/staff/views.py index 25eb7697..4762ed16 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -47,7 +47,8 @@ class StaffMixin(LoginRequiredMixin, UserPassesTestMixin): def get_template_names(self) -> list[str]: - names = super().get_template_names() # type: ignore[misc] # TODO: This doesn't look right, fix later. + # type: ignore[misc] # TODO: This doesn't look right, fix later. + names = super().get_template_names() return [ name.replace("genlab_bestilling", "staff").replace("nina", "staff") for name in names @@ -78,6 +79,11 @@ def get_context_data(self, **kwargs): ) context["urgent_genrequests"] = urgent_genrequests + confirmed_orders = Order.objects.filter( + status=Order.OrderStatus.CONFIRMED) + + context["confirmed_orders"] = confirmed_orders + return context @@ -186,7 +192,8 @@ def get_queryset(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["order"] = ExtractionOrder.objects.get(pk=self.kwargs.get("pk")) + context["order"] = ExtractionOrder.objects.get( + pk=self.kwargs.get("pk")) return context From 085e130a228971d7ba36e18cb6c7fe5dec09ecd5 Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Fri, 20 Jun 2025 15:26:35 +0200 Subject: [PATCH 06/99] Rearrange dashboard header elements for improved layout --- src/staff/templates/staff/dashboard.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index e9353dec..1bedc63e 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -8,11 +8,10 @@ {% block content %}
-

Dashboard

-
-

+

|

+

From 0f794e0e50ab3815361cd46afacfd66e8b02b1c9 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Sat, 21 Jun 2025 20:05:59 +0200 Subject: [PATCH 07/99] Change status Confirmed to Delivered. (#80) --- .../migrations/0014_alter_order_status.py | 25 +++++++++++++++++++ src/genlab_bestilling/models.py | 11 +++++--- .../analysisorder_detail.html | 2 +- .../templates/staff/analysisorder_detail.html | 2 +- .../staff/extractionorder_detail.html | 2 +- src/staff/views.py | 6 ++--- 6 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0014_alter_order_status.py diff --git a/src/genlab_bestilling/migrations/0014_alter_order_status.py b/src/genlab_bestilling/migrations/0014_alter_order_status.py new file mode 100644 index 00000000..163f16b9 --- /dev/null +++ b/src/genlab_bestilling/migrations/0014_alter_order_status.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-06-19 15:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0013_location_comment_alter_location_river_id"), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="status", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("confirmed", "Delivered"), + ("processing", "Processing"), + ("completed", "Completed"), + ], + default="draft", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 59a5eff1..97d92101 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -228,14 +228,19 @@ class CannotConfirm(ValidationError): pass class OrderStatus(models.TextChoices): + # DRAFT: External researcher has created the order, and is currently working on it before having it delivered for processing. # noqa: E501 DRAFT = "draft", _("Draft") - CONFIRMED = "confirmed", _("Confirmed") + # DELIVERED: Order has been delivered from researcher to the NINA staff. + # NOTE: # The old value `confirmed` was preserved during a name change to avoid migration issues. The primary goal is to have a more descriptive name for staff users in the GUI. # noqa: E501 + DELIVERED = "confirmed", _("Delivered") + # PROCESSING: NINA staff has begun processing the order. PROCESSING = "processing", _("Processing") + # COMPLETED: Order has been completed, and results are available. COMPLETED = "completed", _("Completed") STATUS_ORDER = ( OrderStatus.DRAFT, - OrderStatus.CONFIRMED, + OrderStatus.DELIVERED, OrderStatus.PROCESSING, OrderStatus.COMPLETED, ) @@ -258,7 +263,7 @@ class OrderStatus(models.TextChoices): objects = managers.OrderManager() def confirm_order(self): - self.status = Order.OrderStatus.CONFIRMED + self.status = Order.OrderStatus.DELIVERED self.confirmed_at = timezone.now() self.save() diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html index 69b18e3c..081f2d0d 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html @@ -45,7 +45,7 @@
Samples to analyze
{% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Confirm Order" csrf_token=csrf_token %} {% action-button action=clone_order_url class="bg-secondary text-white" submit_text="Clone Order" csrf_token=csrf_token %} Delete - {% elif object.status == 'confirmed' %} + {% elif object.status == object.OrderStatus.DELIVERED %} Samples {% endif %}
diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 353375b3..521077b5 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -27,7 +27,7 @@
Samples to analyze
back Samples - {% if object.status == 'confirmed' %} + {% if object.status == object.OrderStatus.DELIVERED %}
{% url 'staff:order-to-draft' pk=object.id as to_draft_url %} {% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index b90f951a..08ae63cc 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -27,7 +27,7 @@
Delivered Samples
back Samples - {% if object.status == 'confirmed' %} + {% if object.status == object.OrderStatus.DELIVERED %}
{% url 'staff:order-manually-checked' pk=object.id as confirm_check_url %} {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} diff --git a/src/staff/views.py b/src/staff/views.py index 4762ed16..57bcb30c 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -258,7 +258,7 @@ class ManaullyCheckedOrderActionView(SingleObjectMixin, ActionView): model = ExtractionOrder def get_queryset(self): - return ExtractionOrder.objects.filter(status=Order.OrderStatus.CONFIRMED) + return ExtractionOrder.objects.filter(status=Order.OrderStatus.DELIVERED) def post(self, request, *args, **kwargs): self.object = self.get_object() @@ -296,7 +296,7 @@ class OrderToDraftActionView(SingleObjectMixin, ActionView): model = Order def get_queryset(self): - return super().get_queryset().filter(status=Order.OrderStatus.CONFIRMED) + return super().get_queryset().filter(status=Order.OrderStatus.DELIVERED) def post(self, request, *args, **kwargs): self.object = self.get_object() @@ -382,7 +382,7 @@ def get_queryset(self): super() .get_queryset() .select_related("order") - .filter(order__status=Order.OrderStatus.CONFIRMED) + .filter(order__status=Order.OrderStatus.DELIVERED) ) def post(self, request, *args, **kwargs): From 1377a8a50a012ee1a50e7623a1b7156e657be453 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Mon, 23 Jun 2025 08:18:23 +0200 Subject: [PATCH 08/99] Remove capitalize function --- src/staff/templates/staff/dashboard.html | 2 +- src/static/js/staff/dashboard.js | 5 +---- src/static/js/utils.js | 4 ---- 3 files changed, 2 insertions(+), 9 deletions(-) delete mode 100644 src/static/js/utils.js diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 1bedc63e..c75608c0 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -9,7 +9,7 @@ {% block content %}
-

+

|

diff --git a/src/static/js/staff/dashboard.js b/src/static/js/staff/dashboard.js index 95d9a34b..b00de392 100644 --- a/src/static/js/staff/dashboard.js +++ b/src/static/js/staff/dashboard.js @@ -1,5 +1,3 @@ -import { capitalize } from "../utils.js"; - const date = new Date(); const formattedDate = date.toLocaleDateString("en-US", { @@ -16,5 +14,4 @@ const weekNumber = Math.ceil( ); document.getElementById("dashboard__week").textContent = `Week ${weekNumber}`; -document.getElementById("dashboard__date").textContent = - capitalize(formattedDate); +document.getElementById("dashboard__date").textContent = formattedDate; diff --git a/src/static/js/utils.js b/src/static/js/utils.js deleted file mode 100644 index 0ed4a8c9..00000000 --- a/src/static/js/utils.js +++ /dev/null @@ -1,4 +0,0 @@ -export const capitalize = (str) => { - if (typeof str !== "string") return ""; - return str.charAt(0).toUpperCase() + str.slice(1); -}; From 777f4feb6bd254606158a297bc492136f637d817 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Mon, 23 Jun 2025 08:28:55 +0200 Subject: [PATCH 09/99] Reformat code --- src/genlab_bestilling/migrations/0014_order_is_urgent.py | 7 +++---- src/staff/views.py | 6 ++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/genlab_bestilling/migrations/0014_order_is_urgent.py b/src/genlab_bestilling/migrations/0014_order_is_urgent.py index b1b049d9..fb84a95c 100644 --- a/src/genlab_bestilling/migrations/0014_order_is_urgent.py +++ b/src/genlab_bestilling/migrations/0014_order_is_urgent.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('genlab_bestilling', '0013_location_comment_alter_location_river_id'), + ("genlab_bestilling", "0013_location_comment_alter_location_river_id"), ] operations = [ migrations.AddField( - model_name='order', - name='is_urgent', + model_name="order", + name="is_urgent", field=models.BooleanField(default=False), ), ] diff --git a/src/staff/views.py b/src/staff/views.py index 57bcb30c..d8f76453 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -79,8 +79,7 @@ def get_context_data(self, **kwargs): ) context["urgent_genrequests"] = urgent_genrequests - confirmed_orders = Order.objects.filter( - status=Order.OrderStatus.CONFIRMED) + confirmed_orders = Order.objects.filter(status=Order.OrderStatus.CONFIRMED) context["confirmed_orders"] = confirmed_orders @@ -192,8 +191,7 @@ def get_queryset(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["order"] = ExtractionOrder.objects.get( - pk=self.kwargs.get("pk")) + context["order"] = ExtractionOrder.objects.get(pk=self.kwargs.get("pk")) return context From afb3b9f45174233055c19207d6faf2ef5872c945 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Mon, 23 Jun 2025 08:42:07 +0200 Subject: [PATCH 10/99] Merge migrations --- ...e_0014_alter_order_status_0014_order_is_urgent.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/genlab_bestilling/migrations/0015_merge_0014_alter_order_status_0014_order_is_urgent.py diff --git a/src/genlab_bestilling/migrations/0015_merge_0014_alter_order_status_0014_order_is_urgent.py b/src/genlab_bestilling/migrations/0015_merge_0014_alter_order_status_0014_order_is_urgent.py new file mode 100644 index 00000000..9b72d61a --- /dev/null +++ b/src/genlab_bestilling/migrations/0015_merge_0014_alter_order_status_0014_order_is_urgent.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2.3 on 2025-06-23 06:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0014_alter_order_status"), + ("genlab_bestilling", "0014_order_is_urgent"), + ] + + operations = [] From f4ae676898cd7eac427a58b931933faaeeef3a4d Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Mon, 23 Jun 2025 08:44:24 +0200 Subject: [PATCH 11/99] Add help text for is_urgent field in EquipmentOrder model and remove redundant label in EquipmentOrderForm --- src/genlab_bestilling/forms.py | 1 - src/genlab_bestilling/models.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index 96df68dc..a0709093 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -116,7 +116,6 @@ def __init__(self, *args, genrequest, **kwargs): super().__init__(*args, **kwargs) self.genrequest = genrequest - self.fields["is_urgent"].label = "Check this box if the order is urgent" # self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 97d92101..cc8f0e42 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -257,7 +257,9 @@ class OrderStatus(models.TextChoices): created_at = models.DateTimeField(auto_now_add=True) last_modified_at = models.DateTimeField(auto_now=True) confirmed_at = models.DateTimeField(null=True, blank=True) - is_urgent = models.BooleanField(default=False) + is_urgent = models.BooleanField( + default=False, help_text="Check this box if the order is urgent" + ) tags = TaggableManager(blank=True) objects = managers.OrderManager() From e99c9b8e50cca400da9f63309c7b8fccb50b7a26 Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Mon, 23 Jun 2025 14:24:15 +0200 Subject: [PATCH 12/99] Use 'source' instead of '.' for loading aliases-private.sh in aliases.sh --- aliases.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aliases.sh b/aliases.sh index ab585089..9169e27d 100755 --- a/aliases.sh +++ b/aliases.sh @@ -18,4 +18,4 @@ alias django="uv run ./src/manage.py" echo "Aliases loaded successfully." -[ -f aliases-private.sh ] && . aliases-private.sh +[ -f aliases-private.sh ] && source aliases-private.sh From bafa9d167dc91f3b8253e74503c4fa4e8abede8a Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Mon, 23 Jun 2025 10:21:10 +0200 Subject: [PATCH 13/99] Remove redundant label for is_urgent field in forms --- src/genlab_bestilling/forms.py | 3 --- .../migrations/0016_alter_order_is_urgent.py | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0016_alter_order_is_urgent.py diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index a0709093..6dd9a626 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -240,7 +240,6 @@ def __init__(self, *args, genrequest, **kwargs): "You can provide a descriptive name " + "for this order to help you find it later" ) - self.fields["is_urgent"].label = "Check this box if the order is urgent" self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() @@ -297,7 +296,6 @@ def __init__(self, *args, genrequest, **kwargs): "You can provide a descriptive name " + "for this order to help you find it later" ) - self.fields["is_urgent"].label = "Check this box if the order is urgent" self.fields["markers"].queryset = Marker.objects.filter( genrequest__id=genrequest.id @@ -387,6 +385,5 @@ class Meta(AnalysisOrderForm.Meta): def __init__(self, *args, genrequest, **kwargs): super().__init__(*args, genrequest=genrequest, **kwargs) - self.fields["is_urgent"].label = "Check this box if the order is urgent" if "use_all_samples" in self.fields: del self.fields["use_all_samples"] diff --git a/src/genlab_bestilling/migrations/0016_alter_order_is_urgent.py b/src/genlab_bestilling/migrations/0016_alter_order_is_urgent.py new file mode 100644 index 00000000..4df0ec16 --- /dev/null +++ b/src/genlab_bestilling/migrations/0016_alter_order_is_urgent.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.3 on 2025-06-23 08:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "genlab_bestilling", + "0015_merge_0014_alter_order_status_0014_order_is_urgent", + ), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="is_urgent", + field=models.BooleanField( + default=False, help_text="Check this box if the order is urgent" + ), + ), + ] From 4f99de2d946165823d181ea0e20552304a8192de Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Mon, 23 Jun 2025 10:26:32 +0200 Subject: [PATCH 14/99] Change confirmed_orders filter status from CONFIRMED to DELIVERED in DashboardView --- 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 d8f76453..63d53575 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -79,7 +79,7 @@ def get_context_data(self, **kwargs): ) context["urgent_genrequests"] = urgent_genrequests - confirmed_orders = Order.objects.filter(status=Order.OrderStatus.CONFIRMED) + confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) context["confirmed_orders"] = confirmed_orders From 415e8762e168e07a19cb69e52e40859c3e0bc7cc Mon Sep 17 00:00:00 2001 From: aastabk Date: Wed, 25 Jun 2025 08:05:24 +0200 Subject: [PATCH 15/99] Added a field for person to contact with an email field on a per order basis. --- src/genlab_bestilling/forms.py | 12 ++++++++ ...act_email_order_contact_person_and_more.py | 28 +++++++++++++++++++ src/genlab_bestilling/models.py | 10 +++++++ src/staff/views.py | 2 +- 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index a0709093..8a2535af 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -137,6 +137,8 @@ class Meta: "notes", "tags", "is_urgent", + "contact_person", + "contact_email", ) widgets = { # "species": DualSortableSelector( @@ -241,6 +243,8 @@ def __init__(self, *args, genrequest, **kwargs): + "for this order to help you find it later" ) self.fields["is_urgent"].label = "Check this box if the order is urgent" + self.fields["contact_person"].label = "Person to contact about the order" + self.fields["contact_email"].label = "Email to use for contact about the order" self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() @@ -268,6 +272,8 @@ class Meta: "pre_isolated", "return_samples", "is_urgent", + "contact_person", + "contact_email", ) widgets = { "species": DualSortableSelector( @@ -298,6 +304,8 @@ def __init__(self, *args, genrequest, **kwargs): + "for this order to help you find it later" ) self.fields["is_urgent"].label = "Check this box if the order is urgent" + self.fields["contact_person"].label = "Person to contact about the order" + self.fields["contact_email"].label = "Email to use for contact about the order" self.fields["markers"].queryset = Marker.objects.filter( genrequest__id=genrequest.id @@ -357,6 +365,8 @@ class Meta: "expected_delivery_date", "tags", "is_urgent", + "contact_person", + "contact_email", ] widgets = { "name": TextInput( @@ -383,6 +393,8 @@ class Meta(AnalysisOrderForm.Meta): "expected_delivery_date", "tags", "is_urgent", + "contact_person", + "contact_email", ] def __init__(self, *args, genrequest, **kwargs): diff --git a/src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py b/src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py new file mode 100644 index 00000000..89c4dcc0 --- /dev/null +++ b/src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.3 on 2025-06-23 12:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('genlab_bestilling', '0015_merge_0014_alter_order_status_0014_order_is_urgent'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='contact_email', + field=models.EmailField(blank=True, help_text='Email to contact with questions about this order', max_length=254, null=True), + ), + migrations.AddField( + model_name='order', + name='contact_person', + field=models.CharField(blank=True, help_text='Person to contact with questions about this order', null=True), + ), + migrations.AlterField( + model_name='order', + name='is_urgent', + field=models.BooleanField(default=False, help_text='Check this box if the order is urgent'), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index cc8f0e42..b455ccf8 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -260,6 +260,16 @@ class OrderStatus(models.TextChoices): is_urgent = models.BooleanField( default=False, help_text="Check this box if the order is urgent" ) + contact_person = models.CharField( + null=True, + blank=True, + help_text="Person to contact with questions about this order", + ) + contact_email = models.EmailField( + null=True, + blank=True, + help_text="Email to contact with questions about this order", + ) tags = TaggableManager(blank=True) objects = managers.OrderManager() diff --git a/src/staff/views.py b/src/staff/views.py index d8f76453..63d53575 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -79,7 +79,7 @@ def get_context_data(self, **kwargs): ) context["urgent_genrequests"] = urgent_genrequests - confirmed_orders = Order.objects.filter(status=Order.OrderStatus.CONFIRMED) + confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) context["confirmed_orders"] = confirmed_orders From 57f6255478157f791c1eae57a8909e95c412f9b1 Mon Sep 17 00:00:00 2001 From: aastabk Date: Mon, 23 Jun 2025 10:12:30 +0200 Subject: [PATCH 16/99] Draft to display the urgent orders. See tailwind.html for the current attempt. --- src/staff/tables.py | 1 + src/templates/django_tables2/tailwind.html | 55 +++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index 16965a6e..1973568b 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -43,6 +43,7 @@ class Meta: "genrequest__samples_owner", "created_at", "last_modified_at", + "is_urgent", ] sequence = ("id",) empty_text = "No Orders" diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index 9db36560..2b66639b 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -10,7 +10,13 @@ {% if table.show_header %} + + {% if "is_urgent" in table.columns %} + + {% endif %} + {% for column in table.columns %} + {% if column.name != "is_urgent" %} {% if column.orderable %} {{ column.header }} @@ -18,18 +24,63 @@ {{ column.header }} {% endif %} + {% endif %} {% endfor %} {% endif %} {% endblock table.thead %} + + {% block table.tbody %} + {% for row in table.paginated_rows %} {% block table.tbody.row %} + + + {% for column, cell in row.items %} + {% if column.name == "is_urgent" %} + + + {{ row.cells.is_urgent }} + + {{cell|yesno:_("True,False")}} + {{cell.value|yesno:_("True,False")}} + {{ cell.is_active }} + + + {% if cell %} + + {% else %} + + {% endif %} + + {% endif %} + {% endfor %} + + + {% comment %} {% if column.name == "is_urgent" %} + {% if cell %} + + {% else %} + + {% endif %} {% endcomment %} + {% for column, cell in row.items %} - {% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %} + {% if column.name != "is_urgent" %} + + {% if column.localize == None %} + {{ cell }} + {% else %} + {% if column.localize %} + {{ cell|localize }} + {% else %} + {{ cell|unlocalize }} + {% endif %} + {% endif %} + {% endif %} {% endfor %} {% endblock table.tbody.row %} @@ -42,6 +93,8 @@ {% endfor %} {% endblock table.tbody %} + + {% block table.tfoot %} {% if table.has_footer %} From 086a2f9035f1083dc251ed22f68d2c034a67d36d Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Mon, 23 Jun 2025 10:39:03 +0200 Subject: [PATCH 17/99] fix --- src/staff/tables.py | 2 ++ src/templates/django_tables2/tailwind.html | 11 ++--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index 1973568b..a2def28d 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -30,6 +30,8 @@ class OrderTable(tables.Table): orderable=False, empty_values=(), ) + # Override as `tables.Column` to be able to use if-statement + is_urgent = tables.Column() class Meta: fields = [ diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index 2b66639b..aa637737 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -44,14 +44,7 @@ {% if column.name == "is_urgent" %} - {{ row.cells.is_urgent }} - - {{cell|yesno:_("True,False")}} - {{cell.value|yesno:_("True,False")}} - {{ cell.is_active }} - - - {% if cell %} + {% if cell == True %} {% else %} @@ -67,7 +60,7 @@ {% else %} {% endif %} {% endcomment %} - + {% for column, cell in row.items %} {% if column.name != "is_urgent" %} From 4c7e1a475dce7806796a82e4e4af283a7a87dfc4 Mon Sep 17 00:00:00 2001 From: aastabk Date: Mon, 23 Jun 2025 14:34:44 +0200 Subject: [PATCH 18/99] The tables are now sorted by the "is_urgent" field with the "true" value showing first. --- src/staff/tables.py | 6 ++++-- src/templates/django_tables2/tailwind.html | 17 +++++------------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index a2def28d..39ebc789 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -30,8 +30,9 @@ class OrderTable(tables.Table): orderable=False, empty_values=(), ) - # Override as `tables.Column` to be able to use if-statement - is_urgent = tables.Column() + + # Override as `tables.Column` to send a True/False value to the template + is_urgent = tables.Column(orderable=True) class Meta: fields = [ @@ -49,6 +50,7 @@ class Meta: ] sequence = ("id",) empty_text = "No Orders" + order_by = ("-is_urgent",) def render_id(self, record): return str(record) diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index aa637737..be8bfe0c 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -11,9 +11,11 @@ - {% if "is_urgent" in table.columns %} - - {% endif %} + {% for column in table.columns %} + {% if column.name == "is_urgent" %} + + {% endif %} + {% endfor %} {% for column in table.columns %} {% if column.name != "is_urgent" %} @@ -46,21 +48,12 @@ {% if cell == True %} - {% else %} - {% endif %} {% endif %} {% endfor %} - {% comment %} {% if column.name == "is_urgent" %} - {% if cell %} - - {% else %} - - {% endif %} {% endcomment %} - {% for column, cell in row.items %} {% if column.name != "is_urgent" %} From 7b3e460290038f50655b637a556e632b0b4fddfb Mon Sep 17 00:00:00 2001 From: aastabk Date: Wed, 25 Jun 2025 08:22:55 +0200 Subject: [PATCH 19/99] Added comment to the tailwind.html file. --- src/templates/django_tables2/tailwind.html | 46 ++++++++++++++-------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index be8bfe0c..882a3299 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -7,16 +7,24 @@ {% block table %} {% block table.thead %} + + {% comment %} + thead is the first row showing the column names. The is_urgent column is now forced + first in line and does not have a visible column name, as opposed to the other columns. + {% endcomment %} + {% if table.show_header %} + {% comment %} Force the is_urgent column to be first in the header row. {% endcomment %} {% for column in table.columns %} {% if column.name == "is_urgent" %} {% endif %} {% endfor %} + {% comment %} Render the rest of the columns in the header row. {% endcomment %} {% for column in table.columns %} {% if column.name != "is_urgent" %} + {% comment %} + tbody is the rows with the data, matching the column names of the thead. The is_urgent column is also set to show + first in the rows, just like in the thead. It is also overwritten to show the "True" value as an exclaimation mark, to highlight + the urgent orders. + {% endcomment %} + {% for row in table.paginated_rows %} {% block table.tbody.row %} - + {% comment %} Force the is_urgent column to be first in the row. {% endcomment %} {% for column, cell in row.items %} {% if column.name == "is_urgent" %} - {% endif %} - {% endfor %} + {{ cell|unlocalize }} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} {% endblock table.tbody.row %} {% empty %} From 0aa43f6f3aa74211477f3d023dcc246379fff828 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Mon, 23 Jun 2025 13:14:59 +0200 Subject: [PATCH 20/99] Use django template to render date and week --- src/staff/templates/staff/dashboard.html | 11 ++++++----- src/staff/views.py | 1 + src/static/js/staff/dashboard.js | 17 ----------------- 3 files changed, 7 insertions(+), 22 deletions(-) delete mode 100644 src/static/js/staff/dashboard.js diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index c75608c0..6f0e1399 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -1,6 +1,7 @@ {% extends 'staff/base.html' %} {% load i18n %} {% load static %} +{% load tz %} {% block head_javascript %} @@ -8,11 +9,11 @@ {% block content %}
-
-

-

|

-

-
+

+ {{ now|date:'F j, Y' }} + | + Week {{ now|date:'W' }} +

{% if confirmed_orders|length > 0 %} diff --git a/src/staff/views.py b/src/staff/views.py index 63d53575..0087921b 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -82,6 +82,7 @@ def get_context_data(self, **kwargs): confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) context["confirmed_orders"] = confirmed_orders + context["now"] = now() return context diff --git a/src/static/js/staff/dashboard.js b/src/static/js/staff/dashboard.js deleted file mode 100644 index b00de392..00000000 --- a/src/static/js/staff/dashboard.js +++ /dev/null @@ -1,17 +0,0 @@ -const date = new Date(); - -const formattedDate = date.toLocaleDateString("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", -}); - -const year = date.getFullYear(); -const yearStart = new Date(year, 0, 1); -const weekNumber = Math.ceil( - ((date - yearStart) / 86400000 + yearStart.getDay() + 1) / 7 -); - -document.getElementById("dashboard__week").textContent = `Week ${weekNumber}`; -document.getElementById("dashboard__date").textContent = formattedDate; From e4b971670507ec6b65f112b33068e77e506b845d Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Wed, 25 Jun 2025 08:34:35 +0200 Subject: [PATCH 21/99] Refactor urgent orders handling in DashboardView and template --- src/staff/templates/staff/dashboard.html | 20 ++++++++++++++------ src/staff/views.py | 16 ++-------------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index c75608c0..1ce6ef8e 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -33,18 +33,26 @@ {% endif %} - {% if urgent_genrequests|length > 0 %} + {% if urgent_orders|length > 0 %}
-

Urgent GenRequests

+

Urgent orders

- {% for genrequest in urgent_genrequests %} -

{{ genrequest }} - {{ genrequest.project.name|default:'N/A' }}

+ {% for order in urgent_orders %} + {% if order.polymorphic_ctype.model == 'analysisorder' %} +

{{ order }} - {{ order.name }}

+ {% elif order.polymorphic_ctype.model == 'equipmentorder' %} +

{{ order }} - {{ order.name }}

+ {% elif order.polymorphic_ctype.model == 'extractionorder' %} +

{{ order }} - {{ order.name }}

+ {% else %} +

{{ order }} - {{ order.name }}

+ {% endif %} {% endfor %}
{% else %}
-

Urgent GenRequests

-

No urgent GenRequests found.

+

Urgent orders

+

No urgent orders found.

{% endif %} {% endblock %} diff --git a/src/staff/views.py b/src/staff/views.py index 63d53575..5b726383 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -17,7 +17,6 @@ EquipmentOrder, ExtractionOrder, ExtractionPlate, - Genrequest, Order, Sample, SampleMarkerAnalysis, @@ -64,21 +63,10 @@ class DashboardView(StaffMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - urgent_genrequest_ids = ( - Order.objects.filter(is_urgent=True) - .values_list("genrequest_id", flat=True) - .distinct() - ) + urgent_orders = Order.objects.filter(is_urgent=True) - urgent_genrequests = Genrequest.objects.filter( - id__in=urgent_genrequest_ids - ).select_related( - "samples_owner", - "project", - "area", - ) + context["urgent_orders"] = urgent_orders - context["urgent_genrequests"] = urgent_genrequests confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) context["confirmed_orders"] = confirmed_orders From c4980fb5c65c437b4166f434539864345d87debf Mon Sep 17 00:00:00 2001 From: aastabk Date: Wed, 25 Jun 2025 09:25:51 +0200 Subject: [PATCH 22/99] Deleted redundant label fields. --- src/genlab_bestilling/forms.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index 38616f82..34fa946b 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -243,10 +243,6 @@ def __init__(self, *args, genrequest, **kwargs): + "for this order to help you find it later" ) - self.fields["is_urgent"].label = "Check this box if the order is urgent" - self.fields["contact_person"].label = "Person to contact about the order" - self.fields["contact_email"].label = "Email to use for contact about the order" - self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() # self.fields["markers"].queryset = Marker.objects.filter( @@ -305,10 +301,6 @@ def __init__(self, *args, genrequest, **kwargs): + "for this order to help you find it later" ) - self.fields["is_urgent"].label = "Check this box if the order is urgent" - self.fields["contact_person"].label = "Person to contact about the order" - self.fields["contact_email"].label = "Email to use for contact about the order" - self.fields["markers"].queryset = Marker.objects.filter( genrequest__id=genrequest.id ).distinct() From 5123c5a429bf62359551424757d47b615228c129 Mon Sep 17 00:00:00 2001 From: aastabk Date: Mon, 23 Jun 2025 10:12:30 +0200 Subject: [PATCH 23/99] Draft to display the urgent orders. See tailwind.html for the current attempt. --- src/staff/tables.py | 1 + src/templates/django_tables2/tailwind.html | 55 +++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index 16965a6e..1973568b 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -43,6 +43,7 @@ class Meta: "genrequest__samples_owner", "created_at", "last_modified_at", + "is_urgent", ] sequence = ("id",) empty_text = "No Orders" diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index 9db36560..2b66639b 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -10,7 +10,13 @@ {% if table.show_header %}
+ + {% if "is_urgent" in table.columns %} + + {% endif %} + {% for column in table.columns %} + {% if column.name != "is_urgent" %} + {% endif %} {% endfor %} {% endif %} {% endblock table.thead %} + + {% block table.tbody %} + {% for row in table.paginated_rows %} {% block table.tbody.row %} + + + {% for column, cell in row.items %} + {% if column.name == "is_urgent" %} + + {% endif %} + {% endfor %} + + + {% comment %} {% if column.name == "is_urgent" %} + {% if cell %} + + {% else %} + + {% endif %} {% endcomment %} + {% for column, cell in row.items %} - + {% if column.name != "is_urgent" %} + + {% endif %} {% endfor %} {% endblock table.tbody.row %} @@ -42,6 +93,8 @@ {% endfor %} {% endblock table.tbody %} + + {% block table.tfoot %} {% if table.has_footer %} From e3dc8cb65fc5fec973a65b01a29a1928bd26780c Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Mon, 23 Jun 2025 10:39:03 +0200 Subject: [PATCH 24/99] fix --- src/staff/tables.py | 2 ++ src/templates/django_tables2/tailwind.html | 11 ++--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index 1973568b..a2def28d 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -30,6 +30,8 @@ class OrderTable(tables.Table): orderable=False, empty_values=(), ) + # Override as `tables.Column` to be able to use if-statement + is_urgent = tables.Column() class Meta: fields = [ diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index 2b66639b..aa637737 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -44,14 +44,7 @@ {% if column.name == "is_urgent" %} - {% if "is_urgent" in table.columns %} - - {% endif %} + {% for column in table.columns %} + {% if column.name == "is_urgent" %} + + {% endif %} + {% endfor %} {% for column in table.columns %} {% if column.name != "is_urgent" %} @@ -46,21 +48,12 @@ {% if cell == True %} - {% else %} - {% endif %} {% endif %} {% endfor %} - {% comment %} {% if column.name == "is_urgent" %} - {% if cell %} - - {% else %} - - {% endif %} {% endcomment %} - {% for column, cell in row.items %} {% if column.name != "is_urgent" %}
@@ -37,15 +45,21 @@ {% block table.tbody %}
- + {% comment %} Show an exclaimation mark if the cell value is True, otherwise show nothing. {% endcomment %} {% if cell == True %} {% endif %} @@ -53,21 +67,21 @@ {% endif %} {% endfor %} - - {% for column, cell in row.items %} - {% if column.name != "is_urgent" %} - - {% if column.localize == None %} - {{ cell }} + {% comment %} Render the rest of the columns in the row. {% endcomment %} + {% for column, cell in row.items %} + {% if column.name != "is_urgent" %} + + {% if column.localize == None %} + {{ cell }} + {% else %} + {% if column.localize %} + {{ cell|localize }} {% else %} - {% if column.localize %} - {{ cell|localize }} - {% else %} - {{ cell|unlocalize }} - {% endif %} - {% endif %}
{% if column.orderable %} {{ column.header }} @@ -18,18 +24,63 @@ {{ column.header }} {% endif %}
+ + {{ row.cells.is_urgent }} + + {{cell|yesno:_("True,False")}} + {{cell.value|yesno:_("True,False")}} + {{ cell.is_active }} + + + {% if cell %} + + {% else %} + + {% endif %} + {% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %} + {% if column.localize == None %} + {{ cell }} + {% else %} + {% if column.localize %} + {{ cell|localize }} + {% else %} + {{ cell|unlocalize }} + {% endif %} + {% endif %}
- {{ row.cells.is_urgent }} - - {{cell|yesno:_("True,False")}} - {{cell.value|yesno:_("True,False")}} - {{ cell.is_active }} - - - {% if cell %} + {% if cell == True %} {% else %} @@ -67,7 +60,7 @@ {% else %} {% endif %} {% endcomment %} - + {% for column, cell in row.items %} {% if column.name != "is_urgent" %} From d6af6465b3cb3329c4c463e891b03c04ab54cad4 Mon Sep 17 00:00:00 2001 From: aastabk Date: Mon, 23 Jun 2025 14:34:44 +0200 Subject: [PATCH 25/99] The tables are now sorted by the "is_urgent" field with the "true" value showing first. --- src/staff/tables.py | 6 ++++-- src/templates/django_tables2/tailwind.html | 17 +++++------------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index a2def28d..39ebc789 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -30,8 +30,9 @@ class OrderTable(tables.Table): orderable=False, empty_values=(), ) - # Override as `tables.Column` to be able to use if-statement - is_urgent = tables.Column() + + # Override as `tables.Column` to send a True/False value to the template + is_urgent = tables.Column(orderable=True) class Meta: fields = [ @@ -49,6 +50,7 @@ class Meta: ] sequence = ("id",) empty_text = "No Orders" + order_by = ("-is_urgent",) def render_id(self, record): return str(record) diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index aa637737..be8bfe0c 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -11,9 +11,11 @@
From f1cd46ae2a034ac562d76fcee3fbd366166c8891 Mon Sep 17 00:00:00 2001 From: aastabk Date: Wed, 25 Jun 2025 08:22:55 +0200 Subject: [PATCH 26/99] Added comment to the tailwind.html file. --- src/templates/django_tables2/tailwind.html | 46 ++++++++++++++-------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index be8bfe0c..882a3299 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -7,16 +7,24 @@ {% block table %} {% block table.thead %} + + {% comment %} + thead is the first row showing the column names. The is_urgent column is now forced + first in line and does not have a visible column name, as opposed to the other columns. + {% endcomment %} + {% if table.show_header %} + {% comment %} Force the is_urgent column to be first in the header row. {% endcomment %} {% for column in table.columns %} {% if column.name == "is_urgent" %} {% endif %} {% endfor %} + {% comment %} Render the rest of the columns in the header row. {% endcomment %} {% for column in table.columns %} {% if column.name != "is_urgent" %} + {% comment %} + tbody is the rows with the data, matching the column names of the thead. The is_urgent column is also set to show + first in the rows, just like in the thead. It is also overwritten to show the "True" value as an exclaimation mark, to highlight + the urgent orders. + {% endcomment %} + {% for row in table.paginated_rows %} {% block table.tbody.row %} - + {% comment %} Force the is_urgent column to be first in the row. {% endcomment %} {% for column, cell in row.items %} {% if column.name == "is_urgent" %} - {% endif %} - {% endfor %} + {{ cell|unlocalize }} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} {% endblock table.tbody.row %} {% empty %} From a8dd26b5b4f626391e2e1db8a39f9267ce9ee428 Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Wed, 25 Jun 2025 08:34:35 +0200 Subject: [PATCH 27/99] Refactor urgent orders handling in DashboardView and template --- src/staff/templates/staff/dashboard.html | 20 ++++++++++++++------ src/staff/views.py | 16 ++-------------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index c75608c0..1ce6ef8e 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -33,18 +33,26 @@ {% endif %} - {% if urgent_genrequests|length > 0 %} + {% if urgent_orders|length > 0 %}
-

Urgent GenRequests

+

Urgent orders

- {% for genrequest in urgent_genrequests %} -

{{ genrequest }} - {{ genrequest.project.name|default:'N/A' }}

+ {% for order in urgent_orders %} + {% if order.polymorphic_ctype.model == 'analysisorder' %} +

{{ order }} - {{ order.name }}

+ {% elif order.polymorphic_ctype.model == 'equipmentorder' %} +

{{ order }} - {{ order.name }}

+ {% elif order.polymorphic_ctype.model == 'extractionorder' %} +

{{ order }} - {{ order.name }}

+ {% else %} +

{{ order }} - {{ order.name }}

+ {% endif %} {% endfor %}
{% else %}
-

Urgent GenRequests

-

No urgent GenRequests found.

+

Urgent orders

+

No urgent orders found.

{% endif %} {% endblock %} diff --git a/src/staff/views.py b/src/staff/views.py index 63d53575..5b726383 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -17,7 +17,6 @@ EquipmentOrder, ExtractionOrder, ExtractionPlate, - Genrequest, Order, Sample, SampleMarkerAnalysis, @@ -64,21 +63,10 @@ class DashboardView(StaffMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - urgent_genrequest_ids = ( - Order.objects.filter(is_urgent=True) - .values_list("genrequest_id", flat=True) - .distinct() - ) + urgent_orders = Order.objects.filter(is_urgent=True) - urgent_genrequests = Genrequest.objects.filter( - id__in=urgent_genrequest_ids - ).select_related( - "samples_owner", - "project", - "area", - ) + context["urgent_orders"] = urgent_orders - context["urgent_genrequests"] = urgent_genrequests confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) context["confirmed_orders"] = confirmed_orders From ad16c862f016ffbdc1b4712b029ab34c24d33ca9 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Mon, 23 Jun 2025 13:14:59 +0200 Subject: [PATCH 28/99] Use django template to render date and week --- src/staff/templates/staff/dashboard.html | 11 ++++++----- src/staff/views.py | 1 + src/static/js/staff/dashboard.js | 17 ----------------- 3 files changed, 7 insertions(+), 22 deletions(-) delete mode 100644 src/static/js/staff/dashboard.js diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 1ce6ef8e..4364c9ad 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -1,6 +1,7 @@ {% extends 'staff/base.html' %} {% load i18n %} {% load static %} +{% load tz %} {% block head_javascript %} @@ -8,11 +9,11 @@ {% block content %}
-
-

-

|

-

-
+

+ {{ now|date:'F j, Y' }} + | + Week {{ now|date:'W' }} +

{% if confirmed_orders|length > 0 %} diff --git a/src/staff/views.py b/src/staff/views.py index 5b726383..33f09401 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -70,6 +70,7 @@ def get_context_data(self, **kwargs): confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) context["confirmed_orders"] = confirmed_orders + context["now"] = now() return context diff --git a/src/static/js/staff/dashboard.js b/src/static/js/staff/dashboard.js deleted file mode 100644 index b00de392..00000000 --- a/src/static/js/staff/dashboard.js +++ /dev/null @@ -1,17 +0,0 @@ -const date = new Date(); - -const formattedDate = date.toLocaleDateString("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", -}); - -const year = date.getFullYear(); -const yearStart = new Date(year, 0, 1); -const weekNumber = Math.ceil( - ((date - yearStart) / 86400000 + yearStart.getDay() + 1) / 7 -); - -document.getElementById("dashboard__week").textContent = `Week ${weekNumber}`; -document.getElementById("dashboard__date").textContent = formattedDate; From 4757a3094a896176cd6f3e19d4b525cbf1f3c1fd Mon Sep 17 00:00:00 2001 From: aastabk Date: Wed, 25 Jun 2025 08:05:24 +0200 Subject: [PATCH 29/99] Added a field for person to contact with an email field on a per order basis. --- src/staff/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/staff/views.py b/src/staff/views.py index 33f09401..9e02aff4 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -66,7 +66,6 @@ def get_context_data(self, **kwargs): urgent_orders = Order.objects.filter(is_urgent=True) context["urgent_orders"] = urgent_orders - confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) context["confirmed_orders"] = confirmed_orders From cf37435c135475c0fbaf9622cb76b35125540436 Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Wed, 25 Jun 2025 09:56:53 +0200 Subject: [PATCH 30/99] Remove unnecessary whitespace in DashboardView context preparation --- 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 4f81037d..888a6033 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -65,7 +65,7 @@ def get_context_data(self, **kwargs): urgent_orders = Order.objects.filter(is_urgent=True) context["urgent_orders"] = urgent_orders - + confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) context["confirmed_orders"] = confirmed_orders context["now"] = now() From 4ea4d3900011674d7cdd6ea8922e3f5b74383d25 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Wed, 25 Jun 2025 10:21:22 +0200 Subject: [PATCH 31/99] Replace remaining confirmed orders with delivered --- .../templates/genlab_bestilling/analysisorder_detail.html | 2 +- .../templates/genlab_bestilling/equipmentorder_detail.html | 2 +- .../templates/genlab_bestilling/extractionorder_detail.html | 2 +- .../templates/genlab_bestilling/sample_list.html | 2 +- .../genlab_bestilling/samplemarkeranalysis_list.html | 2 +- src/genlab_bestilling/tests/test_e2e.py | 4 ++-- src/genlab_bestilling/views.py | 2 +- src/staff/templates/staff/dashboard.html | 6 +++--- src/staff/templates/staff/equipmentorder_detail.html | 2 +- src/staff/views.py | 6 +++--- 10 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html index 081f2d0d..a23995de 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html @@ -42,7 +42,7 @@
Samples to analyze
Summary Samples {% url 'genrequest-order-confirm' genrequest_id=object.genrequest_id pk=object.id as confirm_order_url %} {% url 'genrequest-order-clone' genrequest_id=object.genrequest_id pk=object.id as clone_order_url %} - {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Confirm Order" csrf_token=csrf_token %} + {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} {% action-button action=clone_order_url class="bg-secondary text-white" submit_text="Clone Order" csrf_token=csrf_token %} Delete {% elif object.status == object.OrderStatus.DELIVERED %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html index 021d9d10..18c66403 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html @@ -38,7 +38,7 @@
Requested Equipment
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="Confirm Order" csrf_token=csrf_token %} + {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} Delete {% endif %} {% url 'genrequest-order-clone' genrequest_id=object.genrequest_id pk=object.id as clone_order_url %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html index f6ffe982..ae3a5a89 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html @@ -30,7 +30,7 @@
Delivered Samples
Edit Order Edit Samples {% 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="Confirm Order" csrf_token=csrf_token %} + {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} Delete {% endif %} Samples diff --git a/src/genlab_bestilling/templates/genlab_bestilling/sample_list.html b/src/genlab_bestilling/templates/genlab_bestilling/sample_list.html index 43e51a2e..dee3ee67 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/sample_list.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/sample_list.html @@ -9,7 +9,7 @@

Samples for Extraction #{{ view.kwargs.pk }}

back to order {% if extraction.status == 'draft' %} {% url 'genrequest-order-confirm' genrequest_id=view.kwargs.genrequest_id pk=view.kwargs.pk as confirm_order_url %} - {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Confirm Order" csrf_token=csrf_token %} + {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} edit samples {% endif %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/samplemarkeranalysis_list.html b/src/genlab_bestilling/templates/genlab_bestilling/samplemarkeranalysis_list.html index 517b5d08..ede4ff29 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/samplemarkeranalysis_list.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/samplemarkeranalysis_list.html @@ -16,7 +16,7 @@

{{ analysis }} - Samples

back to order {% if analysis.status == 'draft' %} {% url 'genrequest-order-confirm' genrequest_id=view.kwargs.genrequest_id pk=view.kwargs.pk as confirm_order_url %} - {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Confirm Order" csrf_token=csrf_token %} + {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} {% if not analysis.from_order %} edit samples {% endif %} diff --git a/src/genlab_bestilling/tests/test_e2e.py b/src/genlab_bestilling/tests/test_e2e.py index 26578eb6..10811157 100644 --- a/src/genlab_bestilling/tests/test_e2e.py +++ b/src/genlab_bestilling/tests/test_e2e.py @@ -137,7 +137,7 @@ def test_equipment_flow(page, live_server_url): page.get_by_role("button", name="Submit").click() page.wait_for_load_state() - page.get_by_role("button", name="Confirm Order").click() + page.get_by_role("button", name="Deliver order").click() page.wait_for_load_state() @@ -180,6 +180,6 @@ def test_extraction_flow(page, live_server_url): page.wait_for_load_state() page.get_by_role("link", name=" back to order").click() page.wait_for_load_state() - page.get_by_role("button", name="Confirm Order").click() + page.get_by_role("button", name="Deliver order").click() page.wait_for_load_state() expect(page.locator("tbody")).to_contain_text("confirmed") diff --git a/src/genlab_bestilling/views.py b/src/genlab_bestilling/views.py index 9f10dc8d..af857224 100644 --- a/src/genlab_bestilling/views.py +++ b/src/genlab_bestilling/views.py @@ -565,7 +565,7 @@ def form_valid(self, form: Any) -> HttpResponse: # TODO: check state transition self.object.confirm_order() messages.add_message( - self.request, messages.SUCCESS, _("Your order is confirmed") + self.request, messages.SUCCESS, _("Your order is delivered") ) except (Order.CannotConfirm, ValidationError) as e: messages.add_message( diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 4364c9ad..1be48727 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -16,11 +16,11 @@

- {% if confirmed_orders|length > 0 %} + {% if delivered_orders|length > 0 %}
-

Confirmed Orders

+

Delivered Orders

- {% for order in confirmed_orders %} + {% for order in delivered_orders %} {% if order.polymorphic_ctype.model == 'analysisorder' %}

{{ order }} - {{ order.name }}

{% elif order.polymorphic_ctype.model == 'equipmentorder' %} diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index 59743053..b008dcfc 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -38,7 +38,7 @@
Requested Equipment
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="Confirm Order" csrf_token=csrf_token %} + {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} Delete {% endif %} {% endcomment %} diff --git a/src/staff/views.py b/src/staff/views.py index 888a6033..a806978e 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -63,11 +63,11 @@ class DashboardView(StaffMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + delivered_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) urgent_orders = Order.objects.filter(is_urgent=True) - context["urgent_orders"] = urgent_orders - confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) - context["confirmed_orders"] = confirmed_orders + context["delivered_orders"] = delivered_orders + context["urgent_orders"] = urgent_orders context["now"] = now() return context From d31a85c9c4f0b9a098c5fcfba900df0428176a18 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Wed, 25 Jun 2025 10:25:29 +0200 Subject: [PATCH 32/99] Merge bad migrations (17) --- ...act_email_order_contact_person_and_more.py | 35 +++++++++++++------ .../migrations/0017_merge_20250625_1024.py | 12 +++++++ 2 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0017_merge_20250625_1024.py diff --git a/src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py b/src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py index 89c4dcc0..1ed89cf6 100644 --- a/src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py +++ b/src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py @@ -4,25 +4,38 @@ class Migration(migrations.Migration): - dependencies = [ - ('genlab_bestilling', '0015_merge_0014_alter_order_status_0014_order_is_urgent'), + ( + "genlab_bestilling", + "0015_merge_0014_alter_order_status_0014_order_is_urgent", + ), ] operations = [ migrations.AddField( - model_name='order', - name='contact_email', - field=models.EmailField(blank=True, help_text='Email to contact with questions about this order', max_length=254, null=True), + model_name="order", + name="contact_email", + field=models.EmailField( + blank=True, + help_text="Email to contact with questions about this order", + max_length=254, + null=True, + ), ), migrations.AddField( - model_name='order', - name='contact_person', - field=models.CharField(blank=True, help_text='Person to contact with questions about this order', null=True), + model_name="order", + name="contact_person", + field=models.CharField( + blank=True, + help_text="Person to contact with questions about this order", + null=True, + ), ), migrations.AlterField( - model_name='order', - name='is_urgent', - field=models.BooleanField(default=False, help_text='Check this box if the order is urgent'), + model_name="order", + name="is_urgent", + field=models.BooleanField( + default=False, help_text="Check this box if the order is urgent" + ), ), ] diff --git a/src/genlab_bestilling/migrations/0017_merge_20250625_1024.py b/src/genlab_bestilling/migrations/0017_merge_20250625_1024.py new file mode 100644 index 00000000..c0738947 --- /dev/null +++ b/src/genlab_bestilling/migrations/0017_merge_20250625_1024.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2.3 on 2025-06-25 08:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0016_alter_order_is_urgent"), + ("genlab_bestilling", "0016_order_contact_email_order_contact_person_and_more"), + ] + + operations = [] From a4d48ceb6a59778796ea38d37b9cfd2df04bdc65 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Wed, 25 Jun 2025 11:59:27 +0200 Subject: [PATCH 33/99] Clean up migration history --- .../migrations/0014_alter_order_status.py | 25 ------------------- ...ct_email_order_contact_person_and_more.py} | 22 +++++++++++----- .../migrations/0014_order_is_urgent.py | 17 ------------- ...alter_order_status_0014_order_is_urgent.py | 12 --------- .../migrations/0016_alter_order_is_urgent.py | 22 ---------------- .../migrations/0017_merge_20250625_1024.py | 12 --------- 6 files changed, 16 insertions(+), 94 deletions(-) delete mode 100644 src/genlab_bestilling/migrations/0014_alter_order_status.py rename src/genlab_bestilling/migrations/{0016_order_contact_email_order_contact_person_and_more.py => 0014_order_contact_email_order_contact_person_and_more.py} (65%) delete mode 100644 src/genlab_bestilling/migrations/0014_order_is_urgent.py delete mode 100644 src/genlab_bestilling/migrations/0015_merge_0014_alter_order_status_0014_order_is_urgent.py delete mode 100644 src/genlab_bestilling/migrations/0016_alter_order_is_urgent.py delete mode 100644 src/genlab_bestilling/migrations/0017_merge_20250625_1024.py diff --git a/src/genlab_bestilling/migrations/0014_alter_order_status.py b/src/genlab_bestilling/migrations/0014_alter_order_status.py deleted file mode 100644 index 163f16b9..00000000 --- a/src/genlab_bestilling/migrations/0014_alter_order_status.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-19 15:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("genlab_bestilling", "0013_location_comment_alter_location_river_id"), - ] - - operations = [ - migrations.AlterField( - model_name="order", - name="status", - field=models.CharField( - choices=[ - ("draft", "Draft"), - ("confirmed", "Delivered"), - ("processing", "Processing"), - ("completed", "Completed"), - ], - default="draft", - ), - ), - ] diff --git a/src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py b/src/genlab_bestilling/migrations/0014_order_contact_email_order_contact_person_and_more.py similarity index 65% rename from src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py rename to src/genlab_bestilling/migrations/0014_order_contact_email_order_contact_person_and_more.py index 1ed89cf6..bf89aace 100644 --- a/src/genlab_bestilling/migrations/0016_order_contact_email_order_contact_person_and_more.py +++ b/src/genlab_bestilling/migrations/0014_order_contact_email_order_contact_person_and_more.py @@ -1,14 +1,11 @@ -# Generated by Django 5.2.3 on 2025-06-23 12:58 +# Generated by Django 5.2.3 on 2025-06-25 09:58 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ( - "genlab_bestilling", - "0015_merge_0014_alter_order_status_0014_order_is_urgent", - ), + ("genlab_bestilling", "0013_location_comment_alter_location_river_id"), ] operations = [ @@ -31,11 +28,24 @@ class Migration(migrations.Migration): null=True, ), ), - migrations.AlterField( + migrations.AddField( model_name="order", name="is_urgent", field=models.BooleanField( default=False, help_text="Check this box if the order is urgent" ), ), + migrations.AlterField( + model_name="order", + name="status", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("confirmed", "Delivered"), + ("processing", "Processing"), + ("completed", "Completed"), + ], + default="draft", + ), + ), ] diff --git a/src/genlab_bestilling/migrations/0014_order_is_urgent.py b/src/genlab_bestilling/migrations/0014_order_is_urgent.py deleted file mode 100644 index fb84a95c..00000000 --- a/src/genlab_bestilling/migrations/0014_order_is_urgent.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-19 10:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("genlab_bestilling", "0013_location_comment_alter_location_river_id"), - ] - - operations = [ - migrations.AddField( - model_name="order", - name="is_urgent", - field=models.BooleanField(default=False), - ), - ] diff --git a/src/genlab_bestilling/migrations/0015_merge_0014_alter_order_status_0014_order_is_urgent.py b/src/genlab_bestilling/migrations/0015_merge_0014_alter_order_status_0014_order_is_urgent.py deleted file mode 100644 index 9b72d61a..00000000 --- a/src/genlab_bestilling/migrations/0015_merge_0014_alter_order_status_0014_order_is_urgent.py +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-23 06:41 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("genlab_bestilling", "0014_alter_order_status"), - ("genlab_bestilling", "0014_order_is_urgent"), - ] - - operations = [] diff --git a/src/genlab_bestilling/migrations/0016_alter_order_is_urgent.py b/src/genlab_bestilling/migrations/0016_alter_order_is_urgent.py deleted file mode 100644 index 4df0ec16..00000000 --- a/src/genlab_bestilling/migrations/0016_alter_order_is_urgent.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-23 08:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "genlab_bestilling", - "0015_merge_0014_alter_order_status_0014_order_is_urgent", - ), - ] - - operations = [ - migrations.AlterField( - model_name="order", - name="is_urgent", - field=models.BooleanField( - default=False, help_text="Check this box if the order is urgent" - ), - ), - ] diff --git a/src/genlab_bestilling/migrations/0017_merge_20250625_1024.py b/src/genlab_bestilling/migrations/0017_merge_20250625_1024.py deleted file mode 100644 index c0738947..00000000 --- a/src/genlab_bestilling/migrations/0017_merge_20250625_1024.py +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-25 08:24 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("genlab_bestilling", "0016_alter_order_is_urgent"), - ("genlab_bestilling", "0016_order_contact_email_order_contact_person_and_more"), - ] - - operations = [] From 24730ccc4448e519a31720fdf8cb9eae7969d256 Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Wed, 25 Jun 2025 10:45:39 +0200 Subject: [PATCH 34/99] Enhance dashboard order display with deadline and status; filter urgent orders by specific statuses --- src/staff/templates/staff/dashboard.html | 6 +++--- src/staff/views.py | 13 ++++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 1be48727..0ba3be44 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -40,11 +40,11 @@

Urgent orders

{% for order in urgent_orders %} {% if order.polymorphic_ctype.model == 'analysisorder' %} -

{{ order }} - {{ order.name }}

+

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:"-"}} - Status: {{ order.status|default:"-" }}

{% elif order.polymorphic_ctype.model == 'equipmentorder' %} -

{{ order }} - {{ order.name }}

+

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:"-" }} - Status: {{ order.status|default:"-" }}

{% elif order.polymorphic_ctype.model == 'extractionorder' %} -

{{ order }} - {{ order.name }}

+

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:"-" }} - Status: {{ order.status|default:"-" }}

{% else %}

{{ order }} - {{ order.name }}

{% endif %} diff --git a/src/staff/views.py b/src/staff/views.py index a806978e..2f731149 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -63,13 +63,16 @@ class DashboardView(StaffMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - delivered_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) - urgent_orders = Order.objects.filter(is_urgent=True) - - context["delivered_orders"] = delivered_orders + urgent_orders = Order.objects.filter( + is_urgent=True, + status__in=[Order.OrderStatus.PROCESSING, Order.OrderStatus.DELIVERED], + ).order_by("-created_at") context["urgent_orders"] = urgent_orders - context["now"] = now() + confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) + + context["confirmed_orders"] = confirmed_orders + context["now"] = now() return context From 117ed1716b35d30cd13963fe1b0b775928e0dd77 Mon Sep 17 00:00:00 2001 From: Ole Magnus Fon Johnsen Date: Wed, 25 Jun 2025 10:04:16 +0200 Subject: [PATCH 35/99] Add admin user to genlab group --- src/capps/core/management/commands/setup.py | 8 +- src/fixtures/groups.json | 769 ++++++++++---------- src/fixtures/nina.json | 20 +- src/fixtures/users.json | 2 +- 4 files changed, 411 insertions(+), 388 deletions(-) diff --git a/src/capps/core/management/commands/setup.py b/src/capps/core/management/commands/setup.py index 47127c70..9fcef3f6 100644 --- a/src/capps/core/management/commands/setup.py +++ b/src/capps/core/management/commands/setup.py @@ -14,13 +14,15 @@ class Command(BaseCommand): def handle(self: Self, **options) -> None: - if User.objects.all().first() is None: - call_command("loaddata", "users.json") - if not Area.objects.all().exists(): call_command("loaddata", "bestilling.json") call_command("loaddata", "locations.json") call_command("loaddata", "groups.json") + + if User.objects.all().first() is None: + call_command("loaddata", "users.json") + + if not Area.objects.all().exists(): call_command("loaddata", "nina.json") species_from_tsv(settings.SRC_DIR / "fixtures" / "species.tsv") diff --git a/src/fixtures/groups.json b/src/fixtures/groups.json index c20ff294..e88d5d82 100644 --- a/src/fixtures/groups.json +++ b/src/fixtures/groups.json @@ -1,386 +1,389 @@ [ - { - "model": "auth.group", - "fields": { - "name": "genlab", - "permissions": [ - [ - "add_analysisorder", - "genlab_bestilling", - "analysisorder" - ], - [ - "change_analysisorder", - "genlab_bestilling", - "analysisorder" - ], - [ - "delete_analysisorder", - "genlab_bestilling", - "analysisorder" - ], - [ - "view_analysisorder", - "genlab_bestilling", - "analysisorder" - ], - [ - "view_area", - "genlab_bestilling", - "area" - ], - [ - "add_equimentorderquantity", - "genlab_bestilling", - "equimentorderquantity" - ], - [ - "change_equimentorderquantity", - "genlab_bestilling", - "equimentorderquantity" - ], - [ - "delete_equimentorderquantity", - "genlab_bestilling", - "equimentorderquantity" - ], - [ - "view_equimentorderquantity", - "genlab_bestilling", - "equimentorderquantity" - ], - [ - "add_equipmentorder", - "genlab_bestilling", - "equipmentorder" - ], - [ - "change_equipmentorder", - "genlab_bestilling", - "equipmentorder" - ], - [ - "delete_equipmentorder", - "genlab_bestilling", - "equipmentorder" - ], - [ - "view_equipmentorder", - "genlab_bestilling", - "equipmentorder" - ], - [ - "view_equipmenttype", - "genlab_bestilling", - "equipmenttype" - ], - [ - "change_genrequest", - "genlab_bestilling", - "genrequest" - ], - [ - "view_genrequest", - "genlab_bestilling", - "genrequest" - ], - [ - "view_location", - "genlab_bestilling", - "location" - ], - [ - "view_locationtype", - "genlab_bestilling", - "locationtype" - ], - [ - "view_analysistype", - "genlab_bestilling", - "analysistype" - ], - [ - "view_marker", - "genlab_bestilling", - "marker" - ], - [ - "view_organization", - "genlab_bestilling", - "organization" - ], - [ - "change_sample", - "genlab_bestilling", - "sample" - ], - [ - "view_sample", - "genlab_bestilling", - "sample" - ], - [ - "view_sampletype", - "genlab_bestilling", - "sampletype" - ], - [ - "view_species", - "genlab_bestilling", - "species" - ] - ] - } - }, - { - "model": "auth.group", - "fields": { - "name": "genlab_admin", - "permissions": [ - [ - "add_analysisorder", - "genlab_bestilling", - "analysisorder" - ], - [ - "add_project", - "nina", - "project" - ], - [ - "change_project", - "nina", - "project" - ], - [ - "delete_project", - "nina", - "project" - ], - [ - "view_project", - "nina", - "project" - ], - [ - "add_projectmembership", - "nina", - "projectmembership" - ], - [ - "change_projectmembership", - "nina", - "projectmembership" - ], - [ - "delete_projectmembership", - "nina", - "projectmembership" - ], - [ - "view_projectmembership", - "nina", - "projectmembership" - ], - [ - "change_user", - "users", - "user" - ], - [ - "view_user", - "users", - "user" - ] - ] - } - }, - { - "model": "auth.group", - "fields": { - "name": "genlab_enum", - "permissions": [ - [ - "add_analysistype", - "genlab_bestilling", - "analysistype" - ], - [ - "change_analysistype", - "genlab_bestilling", - "analysistype" - ], - [ - "delete_analysistype", - "genlab_bestilling", - "analysistype" - ], - [ - "view_analysistype", - "genlab_bestilling", - "analysistype" - ], - [ - "add_area", - "genlab_bestilling", - "area" - ], - [ - "change_area", - "genlab_bestilling", - "area" - ], - [ - "delete_area", - "genlab_bestilling", - "area" - ], - [ - "view_area", - "genlab_bestilling", - "area" - ], - [ - "add_equipmenttype", - "genlab_bestilling", - "equipmenttype" - ], - [ - "change_equipmenttype", - "genlab_bestilling", - "equipmenttype" - ], - [ - "delete_equipmenttype", - "genlab_bestilling", - "equipmenttype" - ], - [ - "view_equipmenttype", - "genlab_bestilling", - "equipmenttype" - ], - [ - "add_location", - "genlab_bestilling", - "location" - ], - [ - "change_location", - "genlab_bestilling", - "location" - ], - [ - "delete_location", - "genlab_bestilling", - "location" - ], - [ - "view_location", - "genlab_bestilling", - "location" - ], - [ - "add_locationtype", - "genlab_bestilling", - "locationtype" - ], - [ - "change_locationtype", - "genlab_bestilling", - "locationtype" - ], - [ - "delete_locationtype", - "genlab_bestilling", - "locationtype" - ], - [ - "view_locationtype", - "genlab_bestilling", - "locationtype" - ], - [ - "add_marker", - "genlab_bestilling", - "marker" - ], - [ - "change_marker", - "genlab_bestilling", - "marker" - ], - [ - "delete_marker", - "genlab_bestilling", - "marker" - ], - [ - "view_marker", - "genlab_bestilling", - "marker" - ], - [ - "add_organization", - "genlab_bestilling", - "organization" - ], - [ - "change_organization", - "genlab_bestilling", - "organization" - ], - [ - "delete_organization", - "genlab_bestilling", - "organization" - ], - [ - "view_organization", - "genlab_bestilling", - "organization" - ], - [ - "add_sampletype", - "genlab_bestilling", - "sampletype" - ], - [ - "change_sampletype", - "genlab_bestilling", - "sampletype" - ], - [ - "delete_sampletype", - "genlab_bestilling", - "sampletype" - ], - [ - "view_sampletype", - "genlab_bestilling", - "sampletype" - ], - [ - "add_species", - "genlab_bestilling", - "species" - ], - [ - "change_species", - "genlab_bestilling", - "species" - ], - [ - "delete_species", - "genlab_bestilling", - "species" - ], - [ - "view_species", - "genlab_bestilling", - "species" - ] - ] - } + { + "model": "auth.group", + "pk": 1, + "fields": { + "name": "genlab", + "permissions": [ + [ + "add_analysisorder", + "genlab_bestilling", + "analysisorder" + ], + [ + "change_analysisorder", + "genlab_bestilling", + "analysisorder" + ], + [ + "delete_analysisorder", + "genlab_bestilling", + "analysisorder" + ], + [ + "view_analysisorder", + "genlab_bestilling", + "analysisorder" + ], + [ + "view_area", + "genlab_bestilling", + "area" + ], + [ + "add_equimentorderquantity", + "genlab_bestilling", + "equimentorderquantity" + ], + [ + "change_equimentorderquantity", + "genlab_bestilling", + "equimentorderquantity" + ], + [ + "delete_equimentorderquantity", + "genlab_bestilling", + "equimentorderquantity" + ], + [ + "view_equimentorderquantity", + "genlab_bestilling", + "equimentorderquantity" + ], + [ + "add_equipmentorder", + "genlab_bestilling", + "equipmentorder" + ], + [ + "change_equipmentorder", + "genlab_bestilling", + "equipmentorder" + ], + [ + "delete_equipmentorder", + "genlab_bestilling", + "equipmentorder" + ], + [ + "view_equipmentorder", + "genlab_bestilling", + "equipmentorder" + ], + [ + "view_equipmenttype", + "genlab_bestilling", + "equipmenttype" + ], + [ + "change_genrequest", + "genlab_bestilling", + "genrequest" + ], + [ + "view_genrequest", + "genlab_bestilling", + "genrequest" + ], + [ + "view_location", + "genlab_bestilling", + "location" + ], + [ + "view_locationtype", + "genlab_bestilling", + "locationtype" + ], + [ + "view_analysistype", + "genlab_bestilling", + "analysistype" + ], + [ + "view_marker", + "genlab_bestilling", + "marker" + ], + [ + "view_organization", + "genlab_bestilling", + "organization" + ], + [ + "change_sample", + "genlab_bestilling", + "sample" + ], + [ + "view_sample", + "genlab_bestilling", + "sample" + ], + [ + "view_sampletype", + "genlab_bestilling", + "sampletype" + ], + [ + "view_species", + "genlab_bestilling", + "species" + ] + ] } + }, + { + "model": "auth.group", + "pk": 2, + "fields": { + "name": "genlab_admin", + "permissions": [ + [ + "add_analysisorder", + "genlab_bestilling", + "analysisorder" + ], + [ + "add_project", + "nina", + "project" + ], + [ + "change_project", + "nina", + "project" + ], + [ + "delete_project", + "nina", + "project" + ], + [ + "view_project", + "nina", + "project" + ], + [ + "add_projectmembership", + "nina", + "projectmembership" + ], + [ + "change_projectmembership", + "nina", + "projectmembership" + ], + [ + "delete_projectmembership", + "nina", + "projectmembership" + ], + [ + "view_projectmembership", + "nina", + "projectmembership" + ], + [ + "change_user", + "users", + "user" + ], + [ + "view_user", + "users", + "user" + ] + ] + } + }, + { + "model": "auth.group", + "pk": 3, + "fields": { + "name": "genlab_enum", + "permissions": [ + [ + "add_analysistype", + "genlab_bestilling", + "analysistype" + ], + [ + "change_analysistype", + "genlab_bestilling", + "analysistype" + ], + [ + "delete_analysistype", + "genlab_bestilling", + "analysistype" + ], + [ + "view_analysistype", + "genlab_bestilling", + "analysistype" + ], + [ + "add_area", + "genlab_bestilling", + "area" + ], + [ + "change_area", + "genlab_bestilling", + "area" + ], + [ + "delete_area", + "genlab_bestilling", + "area" + ], + [ + "view_area", + "genlab_bestilling", + "area" + ], + [ + "add_equipmenttype", + "genlab_bestilling", + "equipmenttype" + ], + [ + "change_equipmenttype", + "genlab_bestilling", + "equipmenttype" + ], + [ + "delete_equipmenttype", + "genlab_bestilling", + "equipmenttype" + ], + [ + "view_equipmenttype", + "genlab_bestilling", + "equipmenttype" + ], + [ + "add_location", + "genlab_bestilling", + "location" + ], + [ + "change_location", + "genlab_bestilling", + "location" + ], + [ + "delete_location", + "genlab_bestilling", + "location" + ], + [ + "view_location", + "genlab_bestilling", + "location" + ], + [ + "add_locationtype", + "genlab_bestilling", + "locationtype" + ], + [ + "change_locationtype", + "genlab_bestilling", + "locationtype" + ], + [ + "delete_locationtype", + "genlab_bestilling", + "locationtype" + ], + [ + "view_locationtype", + "genlab_bestilling", + "locationtype" + ], + [ + "add_marker", + "genlab_bestilling", + "marker" + ], + [ + "change_marker", + "genlab_bestilling", + "marker" + ], + [ + "delete_marker", + "genlab_bestilling", + "marker" + ], + [ + "view_marker", + "genlab_bestilling", + "marker" + ], + [ + "add_organization", + "genlab_bestilling", + "organization" + ], + [ + "change_organization", + "genlab_bestilling", + "organization" + ], + [ + "delete_organization", + "genlab_bestilling", + "organization" + ], + [ + "view_organization", + "genlab_bestilling", + "organization" + ], + [ + "add_sampletype", + "genlab_bestilling", + "sampletype" + ], + [ + "change_sampletype", + "genlab_bestilling", + "sampletype" + ], + [ + "delete_sampletype", + "genlab_bestilling", + "sampletype" + ], + [ + "view_sampletype", + "genlab_bestilling", + "sampletype" + ], + [ + "add_species", + "genlab_bestilling", + "species" + ], + [ + "change_species", + "genlab_bestilling", + "species" + ], + [ + "delete_species", + "genlab_bestilling", + "species" + ], + [ + "view_species", + "genlab_bestilling", + "species" + ] + ] + } + } ] diff --git a/src/fixtures/nina.json b/src/fixtures/nina.json index 97786104..801ef05b 100644 --- a/src/fixtures/nina.json +++ b/src/fixtures/nina.json @@ -1 +1,19 @@ -[{"model": "nina.projectmembership", "pk": 1, "fields": {"project": "000000", "user": 1, "role": "member"}}, {"model": "nina.project", "pk": "000000", "fields": {"name": "Test project", "active": true}}] +[ + { + "model": "nina.projectmembership", + "pk": 1, + "fields": { + "project": "000000", + "user": 1, + "role": "member" + } + }, + { + "model": "nina.project", + "pk": "000000", + "fields": { + "name": "Test project", + "active": true + } + } +] diff --git a/src/fixtures/users.json b/src/fixtures/users.json index 5ae54302..52c65687 100644 --- a/src/fixtures/users.json +++ b/src/fixtures/users.json @@ -12,7 +12,7 @@ "is_staff": true, "is_active": true, "date_joined": "2023-05-27T08:35:08.845Z", - "groups": [], + "groups": [1], "user_permissions": [] } } From 0180947c9a47778045b4aa1ab2b25d860e4d06fe Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:34:57 +0200 Subject: [PATCH 36/99] Disable override code. (#59) --- pyproject.toml | 3 +++ src/capps/users/managers.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index efc05d1e..4f7268f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,9 @@ plugins = ["django_coverage_plugin"] django_settings_module = "config.settings.local" [tool.mypy] +disable_error_code = [ + "override" # Python is a dynamic language, subclasses will get overwritten. +] explicit_package_bases = true # Use mypy_path only (ignores path passed when calling mypy). follow_imports = "normal" ignore_missing_imports = true # Ignore missing imports, e.g. third-party packages without stubs. # Also covered by ruff. diff --git a/src/capps/users/managers.py b/src/capps/users/managers.py index d85ca765..12202646 100644 --- a/src/capps/users/managers.py +++ b/src/capps/users/managers.py @@ -27,7 +27,7 @@ def _create_user( user.save(using=self._db) return user - def create_user( # type: ignore[override] # Intended override. + def create_user( self: Self, email: str, password: str | None = None, @@ -37,7 +37,7 @@ def create_user( # type: ignore[override] # Intended override. extra_fields.setdefault("is_superuser", False) return self._create_user(email, password, **extra_fields) - def create_superuser( # type: ignore[override] # Intended override. + def create_superuser( self: Self, email: str, password: str | None = None, From 8198afa981e787b0952156dc82d9528204cfe9b2 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:44:34 +0200 Subject: [PATCH 37/99] Add ANN rules. (#57) * Add ANN rules. * Add ANN rules with typing. * Fix missing return type. --- pyproject.toml | 19 +- src/capps/core/context_processors.py | 4 +- src/capps/core/templatetags/core.py | 11 +- src/capps/users/adapters.py | 13 +- src/capps/users/managers.py | 2 +- src/capps/users/views.py | 5 +- src/genlab_bestilling/api/serializers.py | 6 +- src/genlab_bestilling/api/views.py | 41 ++-- src/genlab_bestilling/filters.py | 70 +++++-- src/genlab_bestilling/forms.py | 46 +++-- src/genlab_bestilling/libs/formset.py | 6 +- src/genlab_bestilling/libs/genlabid.py | 10 +- src/genlab_bestilling/libs/helpers.py | 2 +- src/genlab_bestilling/libs/isolation.py | 2 +- .../libs/load_csv_fixture.py | 4 +- src/genlab_bestilling/managers.py | 21 +- src/genlab_bestilling/models.py | 61 +++--- src/genlab_bestilling/tables.py | 12 +- src/genlab_bestilling/tasks.py | 2 +- src/genlab_bestilling/views.py | 182 +++++++++--------- src/nina/forms.py | 28 +-- src/nina/models.py | 5 +- src/nina/views.py | 25 +-- src/shared/views.py | 5 +- src/staff/filters.py | 40 +++- src/staff/tables.py | 8 +- src/staff/views.py | 69 +++---- 27 files changed, 423 insertions(+), 276 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4f7268f6..70708723 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,14 +117,27 @@ python_files = ["tests.py", "test_*.py"] fix = true [tool.ruff.lint] -ignore = ["COM812", "RUF012", "RUF005", "RUF010"] -select = ["E", "W", "I", "F", "UP", "S", "B", "A", "COM", "LOG", "PTH", "Q", "PL", "RUF"] +ignore = [ + "COM812", + "RUF012", + "RUF005", + "RUF010", + "ANN002", # Too strict for introduction of ANN. # https://docs.astral.sh/ruff/rules/missing-type-args/ + "ANN003", # Too strict for introduction of ANN. # https://docs.astral.sh/ruff/rules/missing-type-kwargs/ + "ANN204", # Too strict for introduction of ANN. # https://docs.astral.sh/ruff/rules/missing-return-type-special-method/ + "ANN401" # Too strict and too many errors atm. Consider enabling later after more typing is added. Search for `Any` to find unknown types. # https://docs.astral.sh/ruff/rules/any-type/ +] +select = ["E", "W", "I", "F", "UP", "S", "B", "A", "COM", "LOG", "PTH", "Q", "PL", "RUF", "ANN"] + +[tool.ruff.lint.flake8-annotations] +mypy-init-return = true # https://docs.astral.sh/ruff/settings/#lint_flake8-annotations_mypy-init-return [tool.ruff.lint.per-file-ignores] "**/migrations/*.py" = ["E501", "ANN", "I"] "**/tests/*.py" = [ "S101", - "PLR2004" + "PLR2004", + "ANN" # Types on tests is more annoying than useful, types can be optional. # https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/ ] [tool.ruff.lint.pycodestyle] diff --git a/src/capps/core/context_processors.py b/src/capps/core/context_processors.py index 8becdae4..3c50f4d9 100644 --- a/src/capps/core/context_processors.py +++ b/src/capps/core/context_processors.py @@ -1,8 +1,10 @@ +from typing import Any + from django.conf import settings from django.http import HttpRequest -def context_settings(request: HttpRequest): +def context_settings(request: HttpRequest) -> dict[str, Any]: return { "PROJECT_NAME": settings.PROJECT_NAME, "DEPLOYMENT_ENV": settings.DEPLOYMENT_ENV, diff --git a/src/capps/core/templatetags/core.py b/src/capps/core/templatetags/core.py index 208c3c02..f3063e5a 100644 --- a/src/capps/core/templatetags/core.py +++ b/src/capps/core/templatetags/core.py @@ -1,4 +1,7 @@ +from typing import Any + from django import template +from django.db.models import Model from django.db.models import fields as djfields from taggit.managers import TaggableManager @@ -6,11 +9,11 @@ @register.filter -def verbose_name(instance): - return instance._meta.verbose_name +def verbose_name(instance: Model) -> str: + return str(instance._meta.verbose_name) -def render(field, instance): +def render(field: Any, instance: Model) -> tuple: try: v = getattr(instance, field.name) @@ -31,7 +34,7 @@ def render(field, instance): @register.filter -def get_fields(instance, fields=None): +def get_fields(instance: Model, fields: str | None = None) -> Any: return filter( lambda x: x[0], ( diff --git a/src/capps/users/adapters.py b/src/capps/users/adapters.py index 406b8d95..c362cce2 100644 --- a/src/capps/users/adapters.py +++ b/src/capps/users/adapters.py @@ -1,6 +1,6 @@ import logging import traceback -from typing import Self +from typing import Any, Self from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter @@ -8,7 +8,7 @@ from django.http import HttpRequest -def report(e, error): +def report(e: Exception | None, error: Any) -> None: logging.error(str(e)) # noqa: LOG015 logging.error(str(error)) # noqa: LOG015 try: @@ -31,8 +31,13 @@ class SocialAccountAdapter(DefaultSocialAccountAdapter): """ def on_authentication_error( - self: Self, request, provider, error=None, exception=None, extra_context=None - ): + self: Self, + request: HttpRequest, + provider: Any, + error: Any = None, + exception: Exception | None = None, + extra_context: dict | None = None, + ) -> None: report(exception, error) return super().on_authentication_error( request, diff --git a/src/capps/users/managers.py b/src/capps/users/managers.py index 12202646..1929dcb7 100644 --- a/src/capps/users/managers.py +++ b/src/capps/users/managers.py @@ -17,7 +17,7 @@ def _create_user( email: str, password: str | None, **extra_fields, - ): + ) -> User: """Create and save a user with the given email and password.""" if not email: raise ValueError("The given email must be set") diff --git a/src/capps/users/views.py b/src/capps/users/views.py index 0ca429da..1273f2b8 100644 --- a/src/capps/users/views.py +++ b/src/capps/users/views.py @@ -1,8 +1,9 @@ -from typing import Self +from typing import Any, Self from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.messages.views import SuccessMessageMixin +from django.db.models import QuerySet from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, RedirectView, UpdateView @@ -34,7 +35,7 @@ def get_success_url(self: Self) -> str: raise Exception("User is not authenticated") return self.request.user.get_absolute_url() - def get_object(self: Self, _queryset=None): + def get_object(self, queryset: QuerySet | None = None) -> Any: return self.request.user diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index 4a0038e8..a792f2ee 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -53,7 +53,7 @@ class Meta: model = Location fields = ("id", "name") - def get_name(self, obj): + def get_name(self, obj: Location) -> str: return str(obj) @@ -69,7 +69,7 @@ class SampleSerializer(serializers.ModelSerializer): location = LocationSerializer(allow_null=True, required=False) has_error = serializers.SerializerMethodField() - def get_has_error(self, obj): + def get_has_error(self, obj: Sample) -> bool: try: return obj.has_error except exceptions.ValidationError as e: @@ -118,7 +118,7 @@ class Meta: class SampleUpdateSerializer(serializers.ModelSerializer): has_error = serializers.SerializerMethodField() - def get_has_error(self, obj): + def get_has_error(self, obj: Sample) -> bool: try: return obj.has_error except exceptions.ValidationError as e: diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index c9cb1452..3043f229 100644 --- a/src/genlab_bestilling/api/views.py +++ b/src/genlab_bestilling/api/views.py @@ -1,11 +1,15 @@ import uuid from django.db import transaction +from django.db.models import QuerySet +from django.views import View from drf_spectacular.utils import extend_schema from rest_framework.decorators import action from rest_framework.pagination import CursorPagination from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.serializers import BaseSerializer from rest_framework.viewsets import ( # type: ignore[attr-defined] GenericViewSet, ModelViewSet, @@ -59,8 +63,8 @@ class AllowSampleDraft(BasePermission): Prevent any UNSAFE method (POST, PUT, DELETE) on orders that are not draft """ - def has_object_permission(self, request, view, obj): - if obj.order.status != ExtractionOrder.OrderStatus.DRAFT: + def has_object_permission(self, request: Request, view: View, obj: Sample) -> bool: + if obj.order.status != ExtractionOrder.OrderStatus.DRAFT: # type: ignore[union-attr] # FIXME: order could be None. return request.method in SAFE_METHODS return True @@ -72,7 +76,7 @@ class SampleViewset(ModelViewSet): pagination_class = IDCursorPagination permission_classes = [AllowSampleDraft, IsAuthenticated] - def get_queryset(self): + def get_queryset(self) -> QuerySet: return ( super() .get_queryset() @@ -87,7 +91,7 @@ def get_queryset(self): .order_by("id") ) - def get_serializer_class(self): + def get_serializer_class(self) -> type[BaseSerializer]: if self.action in ["update", "partial_update"]: return SampleUpdateSerializer if self.action in ["csv"]: @@ -97,7 +101,7 @@ def get_serializer_class(self): @action( methods=["GET"], url_path="csv", detail=False, renderer_classes=[CSVRenderer] ) - def csv(self, request): + def csv(self, request: Request) -> Response: queryset = self.filter_queryset(self.get_queryset()) serializer = self.get_serializer(queryset, many=True) return Response( @@ -109,7 +113,7 @@ def csv(self, request): request=SampleBulkSerializer, responses={200: OperationStatusSerializer} ) @action(methods=["POST"], url_path="bulk", detail=False) - def bulk_create(self, request): + def bulk_create(self, request: Request) -> Response: """ Creata a multiple samples in bulk """ @@ -153,7 +157,12 @@ def bulk_create(self, request): class AllowOrderDraft(BasePermission): - def has_object_permission(self, request, view, obj): + def has_object_permission( + self, + request: Request, + view: View, + obj: ExtractionOrder, + ) -> bool: if obj.status != ExtractionOrder.OrderStatus.DRAFT: return request.method in SAFE_METHODS return True @@ -165,8 +174,8 @@ class ExtractionOrderViewset( queryset = ExtractionOrder.objects.all() serializer_class = ExtractionSerializer - def get_queryset(self): - qs = super().get_queryset().filter_allowed(self.request.user) + def get_queryset(self) -> QuerySet: + qs = super().get_queryset().filter_allowed(self.request.user) # type: ignore[attr-defined] if self.request.method not in SAFE_METHODS: qs = qs.filter_in_draft() return qs @@ -177,7 +186,7 @@ def get_queryset(self): detail=True, permission_classes=[AllowOrderDraft], ) - def confirm_order(self, request, pk): + def confirm_order(self, request: Request, pk: int | str) -> Response: obj = self.get_object() obj.confirm_order(persist=False) return Response(self.get_serializer(obj).data) @@ -189,7 +198,7 @@ def confirm_order(self, request, pk): detail=True, permission_classes=[AllowOrderDraft], ) - def bulk_delete(self, request, pk): + def bulk_delete(self, request: Request, pk: int | str) -> Response: obj = self.get_object() with transaction.atomic(): obj.samples.all().delete() @@ -225,7 +234,7 @@ class LocationViewset(mixins.ListModelMixin, mixins.CreateModelMixin, GenericVie serializer_class = LocationSerializer filterset_class = LocationFilter - def get_serializer_class(self): + def get_serializer_class(self) -> type[BaseSerializer]: if self.action == "create": return LocationCreateSerializer return super().get_serializer_class() @@ -237,11 +246,11 @@ class SampleMarkerAnalysisViewset(mixins.ListModelMixin, GenericViewSet): filterset_class = SampleMarkerOrderFilter pagination_class = IDCursorPagination - def get_queryset(self): + def get_queryset(self) -> QuerySet: return ( super() .get_queryset() - .filter_allowed(self.request.user) + .filter_allowed(self.request.user) # type: ignore[attr-defined] .select_related( "marker", "order", "sample", "sample__species", "sample__location" ) @@ -252,7 +261,7 @@ def get_queryset(self): responses={200: OperationStatusSerializer}, ) @action(methods=["POST"], url_path="bulk", detail=False) - def bulk_create(self, request): + def bulk_create(self, request: Request) -> Response: serializer = SampleMarkerAnalysisBulkSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -284,7 +293,7 @@ def bulk_create(self, request): responses={200: OperationStatusSerializer}, ) @action(methods=["POST"], url_path="bulk-delete", detail=False) - def bulk_delete(self, request): + def bulk_delete(self, request: Request) -> Response: serializer = SampleMarkerAnalysisBulkDeleteSerializer(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/src/genlab_bestilling/filters.py b/src/genlab_bestilling/filters.py index 6f0f4f5a..4debef8a 100644 --- a/src/genlab_bestilling/filters.py +++ b/src/genlab_bestilling/filters.py @@ -1,5 +1,8 @@ +from typing import Any + from dal import autocomplete -from django.db.models import Q +from django.db.models import Q, QuerySet +from django.http import HttpRequest from django_filters import rest_framework as filters from .models import ( @@ -37,12 +40,22 @@ class Meta: "guid": ["in"], } - def filter_markers_in_list(self, queryset, name, value): + def filter_markers_in_list( + self, + queryset: QuerySet, + name: str, + value: Any, + ) -> QuerySet: if value: return queryset.filter(species__markers__in=value) return queryset - def filter_order_status_not(self, queryset, name, value): + def filter_order_status_not( + self, + queryset: QuerySet, + name: str, + value: Any, + ) -> QuerySet: if value: return queryset.exclude(order__status=value) return queryset @@ -51,7 +64,7 @@ def filter_order_status_not(self, queryset, name, value): class BaseOrderFilter(filters.FilterSet): ext_order = filters.NumberFilter(field_name="ext_order", method="filter_ext_order") - def filter_ext_order(self, queryset, name, value): + def filter_ext_order(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: if value: return queryset.filter(extractionorder=value) return queryset @@ -78,7 +91,12 @@ class Meta: model = Marker fields = {"name": ["icontains", "istartswith"]} - def filter_analysis_order(self, queryset, name, value): + def filter_analysis_order( + self, + queryset: QuerySet, + name: str, + value: Any, + ) -> QuerySet: if value: return queryset.filter(analysisorder=value) return queryset @@ -89,21 +107,21 @@ class LocationFilter(filters.FilterSet): species = filters.NumberFilter(field_name="species", method="filter_species") search = filters.CharFilter(method="filter_search") - def filter_search(self, queryset, name, value): + def filter_search(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: if value: return queryset.filter( Q(name__startswith=value) | Q(river_id__startswith=value) ) return queryset - def filter_ext_order(self, queryset, name, value): + def filter_ext_order(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: if value: order = ExtractionOrder.objects.get(pk=value) if order.genrequest.area.location_mandatory: return queryset.exclude(types=None) return queryset - def filter_species(self, queryset, name, value): + def filter_species(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: if value: return queryset.filter(Q(types__species=value)) return queryset.filter(types=True) @@ -131,7 +149,14 @@ class Meta: class OrderFilter(filters.FilterSet): - def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ): super().__init__(data, queryset, request=request, prefix=prefix) self.filters["genrequest__project"].extra["widget"] = autocomplete.ModelSelect2( url="autocomplete:project" @@ -158,7 +183,14 @@ class Meta: class OrderExtractionFilter(OrderFilter): - def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ): super().__init__(data, queryset, request=request, prefix=prefix) self.filters["species"].extra["widget"] = autocomplete.ModelSelect2Multiple( url="autocomplete:species" @@ -182,7 +214,14 @@ class Meta: class OrderAnalysisFilter(OrderFilter): - def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ): super().__init__(data, queryset, request=request, prefix=prefix) self.filters["markers"].extra["widget"] = autocomplete.ModelSelect2Multiple( url="autocomplete:marker" @@ -199,7 +238,14 @@ class Meta: class GenrequestFilter(filters.FilterSet): - def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ): super().__init__(data, queryset, request=request, prefix=prefix) self.filters["project"].extra["widget"] = autocomplete.ModelSelect2( url="autocomplete:project" diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index 34fa946b..81c662dd 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -1,6 +1,10 @@ +from typing import Any + from django import forms +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import transaction +from django.db.models import Model from formset.renderers.tailwind import FormRenderer from formset.utils import FormMixin from formset.widgets import DualSortableSelector, Selectize, TextInput @@ -26,7 +30,7 @@ class DateInput(forms.DateInput): class GenrequestForm(FormMixin, forms.ModelForm): default_renderer = FormRenderer(field_css_classes="mb-3") - def __init__(self, *args, user=None, project=None, **kwargs): + def __init__(self, *args, user: User | None = None, project: Any = None, **kwargs): super().__init__(*args, **kwargs) self.user = user @@ -43,7 +47,7 @@ def __init__(self, *args, user=None, project=None, **kwargs): "markers" ].help_text = "If you do not know which markers to use, add all" - def save(self, commit=True): + def save(self, commit: bool = True) -> Genrequest: obj = super().save(commit=False) if self.user: obj.creator = self.user @@ -112,14 +116,14 @@ class Meta(GenrequestForm.Meta): class EquipmentOrderForm(FormMixin, forms.ModelForm): default_renderer = FormRenderer(field_css_classes="mb-3") - def __init__(self, *args, genrequest, **kwargs): + def __init__(self, *args, genrequest: Genrequest, **kwargs): super().__init__(*args, **kwargs) self.genrequest = genrequest # self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() - def save(self, commit=True): + def save(self, commit: bool = True) -> EquipmentOrder: obj = super().save(commit=False) obj.genrequest = self.genrequest if commit: @@ -151,10 +155,10 @@ class Meta: class EquipmentOrderQuantityForm(forms.ModelForm): id = forms.IntegerField(required=False, widget=forms.widgets.HiddenInput) - def reinit(self, context): + def reinit(self, context: dict[str, Any]) -> None: self.order_id = context["order_id"] - def save(self, commit=True): + def save(self, commit: bool = True) -> EquipmentOrder: obj = super().save(commit=False) obj.order_id = self.order_id if commit: @@ -162,7 +166,7 @@ def save(self, commit=True): self.save_m2m() return obj - def clean(self): + def clean(self) -> None: cleaned_data = super().clean() if not cleaned_data["equipment"] and not cleaned_data["buffer"]: @@ -189,19 +193,19 @@ class EquipmentQuantityCollection(ContextFormCollection): equipments = EquipmentOrderQuantityForm() default_renderer = FormRenderer(field_css_classes="mb-3") - def retrieve_instance(self, data): - if data := data.get("equipments"): + def retrieve_instance(self, data: dict[str, Any]) -> EquimentOrderQuantity | None: + if equipments := data.get("equipments"): try: - return EquimentOrderQuantity.objects.get(id=data.get("id") or -1) + return EquimentOrderQuantity.objects.get(id=equipments.get("id") or -1) except (AttributeError, EquimentOrderQuantity.DoesNotExist, ValueError): return EquimentOrderQuantity( - equipment_id=data.get("equipment"), - buffer_id=data.get("buffer"), - buffer_quantity=data.get("buffer_quantity"), - quantity=data.get("quantity"), + equipment_id=equipments.get("equipment"), + buffer_id=equipments.get("buffer"), + buffer_quantity=equipments.get("buffer_quantity"), + quantity=equipments.get("quantity"), ) - def update_holder_instances(self, name, holder): + def update_holder_instances(self, name: str, holder: Any) -> None: if name == "equipments": holder.reinit(self.context) @@ -234,7 +238,7 @@ class ExtractionOrderForm(FormMixin, forms.ModelForm): widget=forms.RadioSelect, ) - def __init__(self, *args, genrequest, **kwargs): + def __init__(self, *args, genrequest: Genrequest, **kwargs): super().__init__(*args, **kwargs) self.genrequest = genrequest @@ -249,7 +253,7 @@ def __init__(self, *args, genrequest, **kwargs): # species__genrequests__id=genrequest.id # ).distinct() - def save(self, commit=True): + def save(self, commit: bool = True) -> ExtractionOrder: obj = super().save(commit=False) obj.genrequest = self.genrequest if commit: @@ -292,7 +296,7 @@ class AnalysisOrderForm(FormMixin, forms.ModelForm): widget=forms.RadioSelect, ) - def __init__(self, *args, genrequest, **kwargs): + def __init__(self, *args, genrequest: Genrequest, **kwargs): super().__init__(*args, **kwargs) self.genrequest = genrequest @@ -316,7 +320,7 @@ def __init__(self, *args, genrequest, **kwargs): + " with the sample selection by pressing Submit" ) - def save(self, commit=True): + def save(self, commit: bool = True) -> Model: if not commit: raise NotImplementedError("This form is always committed") with transaction.atomic(): @@ -333,7 +337,7 @@ def save(self, commit=True): obj.populate_from_order() return obj - def clean(self): + def clean(self) -> None: cleaned_data = super().clean() if "use_all_samples" in cleaned_data and "from_order" in cleaned_data: @@ -391,7 +395,7 @@ class Meta(AnalysisOrderForm.Meta): "contact_email", ] - def __init__(self, *args, genrequest, **kwargs): + def __init__(self, *args, genrequest: Genrequest, **kwargs): super().__init__(*args, genrequest=genrequest, **kwargs) if "use_all_samples" in self.fields: del self.fields["use_all_samples"] diff --git a/src/genlab_bestilling/libs/formset.py b/src/genlab_bestilling/libs/formset.py index fc17b0cd..22801968 100644 --- a/src/genlab_bestilling/libs/formset.py +++ b/src/genlab_bestilling/libs/formset.py @@ -1,13 +1,15 @@ +from typing import Any + from formset.collection import FormCollection class ContextFormCollection(FormCollection): - def __init__(self, *args, context=None, **kwargs): + def __init__(self, *args, context: dict[str, Any] | None = None, **kwargs): super().__init__(*args, **kwargs) self.context = context or {} for name, holder in self.declared_holders.items(): self.update_holder_instances(name, holder) - def update_holder_instances(self, name, holder): + def update_holder_instances(self, name: str, holder: Any) -> None: pass diff --git a/src/genlab_bestilling/libs/genlabid.py b/src/genlab_bestilling/libs/genlabid.py index 70ef67c6..32af0235 100644 --- a/src/genlab_bestilling/libs/genlabid.py +++ b/src/genlab_bestilling/libs/genlabid.py @@ -1,3 +1,5 @@ +from typing import Any + import sqlglot import sqlglot.expressions from django.db import connection, transaction @@ -17,14 +19,14 @@ from ..models import ExtractionOrder, Order, Sample, Species -def get_replica_for_sample(): +def get_replica_for_sample() -> None: """ TODO: implement """ pass -def get_current_sequences(order_id): +def get_current_sequences(order_id: int | str) -> Any: """ Invoke a Postgres function to get the current sequence number for a specific combination of year and species. @@ -61,7 +63,7 @@ def get_current_sequences(order_id): return sequences -def generate(order_id): +def generate(order_id: int | str) -> None: """ wrapper to handle errors and reset the sequence to the current sequence value """ @@ -84,7 +86,7 @@ def generate(order_id): print(sequences) -def update_genlab_id_query(order_id): +def update_genlab_id_query(order_id: int | str) -> Any: """ Safe generation of a SQL raw query using sqlglot The query runs an update on all the rows with a specific order_id diff --git a/src/genlab_bestilling/libs/helpers.py b/src/genlab_bestilling/libs/helpers.py index 8a5cf8a0..9b9841b5 100644 --- a/src/genlab_bestilling/libs/helpers.py +++ b/src/genlab_bestilling/libs/helpers.py @@ -2,7 +2,7 @@ COLUMNS = 12 -def position_to_coordinates(index): +def position_to_coordinates(index: int) -> str: """ return the plate coordinate of a certain index """ diff --git a/src/genlab_bestilling/libs/isolation.py b/src/genlab_bestilling/libs/isolation.py index 1f467191..15e32cd4 100644 --- a/src/genlab_bestilling/libs/isolation.py +++ b/src/genlab_bestilling/libs/isolation.py @@ -7,7 +7,7 @@ # NOTE: this is probably a naive implementation # we might need full user control on how to fill a certain plate -def isolate(order_id): +def isolate(order_id: int | str) -> None: with transaction.atomic(): base_query = ( Sample.objects.select_related("species", "location") diff --git a/src/genlab_bestilling/libs/load_csv_fixture.py b/src/genlab_bestilling/libs/load_csv_fixture.py index d2245a05..19dde75f 100644 --- a/src/genlab_bestilling/libs/load_csv_fixture.py +++ b/src/genlab_bestilling/libs/load_csv_fixture.py @@ -6,7 +6,7 @@ from ..models import AnalysisType, Area, Marker, SampleType, Species -def species_from_tsv(path: pathlib.Path): +def species_from_tsv(path: pathlib.Path) -> None: """ read a TSV file and create Area, Species, Analysis Type and Markers dynamically """ @@ -34,7 +34,7 @@ def species_from_tsv(path: pathlib.Path): print(f"Installed {created} objects from {path}") -def sample_types_from_tsv(path: pathlib.Path): +def sample_types_from_tsv(path: pathlib.Path) -> None: """ read a TSV file and create Area, SampleType dynamically """ diff --git a/src/genlab_bestilling/managers.py b/src/genlab_bestilling/managers.py index 79411d87..b5ffc099 100644 --- a/src/genlab_bestilling/managers.py +++ b/src/genlab_bestilling/managers.py @@ -1,9 +1,12 @@ from django.db import models +from django.db.models import QuerySet from polymorphic.managers import PolymorphicManager, PolymorphicQuerySet +from capps.users.models import User + class GenrequestQuerySet(models.QuerySet): - def filter_allowed(self, user): + def filter_allowed(self, user: User) -> QuerySet: """ Get only requests of projects that the user is part of """ @@ -11,13 +14,13 @@ def filter_allowed(self, user): class OrderQuerySet(PolymorphicQuerySet): - def filter_allowed(self, user): + def filter_allowed(self, user: User) -> QuerySet: """ Get only orders of projects that the user is part of """ return self.filter(genrequest__project__memberships=user) - def filter_in_draft(self): + def filter_in_draft(self) -> QuerySet: """ Get only orders in draft """ @@ -28,13 +31,13 @@ def filter_in_draft(self): class EquipmentOrderQuantityQuerySet(models.QuerySet): - def filter_allowed(self, user): + def filter_allowed(self, user: User) -> QuerySet: """ Get only orders of projects that the user is part of """ return self.filter(order__genrequest__project__memberships=user) - def filter_in_draft(self): + def filter_in_draft(self) -> QuerySet: """ Get only orders in draft """ @@ -44,13 +47,13 @@ def filter_in_draft(self): class SampleQuerySet(models.QuerySet): - def filter_allowed(self, user): + def filter_allowed(self, user: User) -> QuerySet: """ Get only samples of projects that the user is part of """ return self.filter(order__genrequest__project__memberships=user) - def filter_in_draft(self): + def filter_in_draft(self) -> QuerySet: """ Get only samples of orders in draft """ @@ -60,13 +63,13 @@ def filter_in_draft(self): class SampleAnalysisMarkerQuerySet(models.QuerySet): - def filter_allowed(self, user): + def filter_allowed(self, user: User) -> QuerySet: """ Get only samples of projects that the user is part of """ return self.filter(order__genrequest__project__memberships=user) - def filter_in_draft(self): + def filter_in_draft(self) -> QuerySet: """ Get only samples of orders in draft """ diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index b455ccf8..d5744cf1 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -1,5 +1,6 @@ import uuid from datetime import timedelta +from typing import Any from django.conf import settings from django.db import models, transaction @@ -83,11 +84,11 @@ def __str__(self) -> str: return self.name or "" @property - def konciv_id(self): + def konciv_id(self) -> str: return f"ST_{self.id}" @property - def konciv_type(self): + def konciv_type(self) -> str: return "SAMPLE_TYPE" class Meta: @@ -101,11 +102,11 @@ def __str__(self) -> str: return self.name or "" @property - def konciv_id(self): + def konciv_id(self) -> str: return f"AT_{self.id}" @property - def konciv_type(self): + def konciv_type(self) -> str: return "ANALYSIS_TYPE" class Meta: @@ -207,14 +208,14 @@ class Genrequest(models.Model): # type: ignore[django-manager-missing] def __str__(self): return f"#GEN_{self.id}" - def get_absolute_url(self): + def get_absolute_url(self) -> str: return reverse( "genrequest-detail", kwargs={"pk": self.pk}, ) @property - def short_timeframe(self): + def short_timeframe(self) -> bool: return ( self.expected_analysis_delivery_date - self.expected_samples_delivery_date ) < timedelta(days=30) @@ -274,34 +275,34 @@ class OrderStatus(models.TextChoices): tags = TaggableManager(blank=True) objects = managers.OrderManager() - def confirm_order(self): + def confirm_order(self) -> None: self.status = Order.OrderStatus.DELIVERED self.confirmed_at = timezone.now() self.save() - def clone(self): + def clone(self) -> None: self.id = None self.pk = None self.status = self.OrderStatus.DRAFT self.confirmed_at = None self.save() - def to_draft(self): + def to_draft(self) -> None: self.status = Order.OrderStatus.DRAFT self.confirmed_at = None self.save() - def get_type(self): + def get_type(self) -> str: return "order" @property - def next_status(self): + def next_status(self) -> OrderStatus | None: current_index = self.STATUS_ORDER.index(self.status) if current_index + 1 < len(self.STATUS_ORDER): return self.STATUS_ORDER[current_index + 1] return None - def to_next_status(self): + def to_next_status(self) -> None: if status := self.next_status: self.status = status self.save() @@ -366,21 +367,21 @@ class EquipmentOrder(Order): def __str__(self) -> str: return f"#EQP_{self.id}" - def get_absolute_url(self): + def get_absolute_url(self) -> str: return reverse( "genrequest-equipment-detail", kwargs={"pk": self.pk, "genrequest_id": self.genrequest_id}, ) - def confirm_order(self): + def confirm_order(self) -> Any: if not EquimentOrderQuantity.objects.filter(order=self).exists(): raise Order.CannotConfirm(_("No equipments found")) return super().confirm_order() - def get_type(self): + def get_type(self) -> str: return "equipment" - def clone(self): + def clone(self) -> None: sample_types = self.sample_types.all() super().clone() self.sample_types.add(*sample_types) @@ -401,16 +402,16 @@ class Status(models.TextChoices): def __str__(self) -> str: return f"#EXT_{self.id}" - def get_type(self): + def get_type(self) -> str: return "extraction" - def get_absolute_url(self): + def get_absolute_url(self) -> str: return reverse( "genrequest-extraction-detail", kwargs={"pk": self.pk, "genrequest_id": self.genrequest_id}, ) - def clone(self): + def clone(self) -> None: """ Generates a clone of the model, with a different ID """ @@ -422,7 +423,7 @@ def clone(self): self.species.add(*species) self.sample_types.add(*sample_types) - def confirm_order(self, persist=True): + def confirm_order(self, persist: bool = True) -> None: with transaction.atomic(): if not self.samples.all().exists(): raise ValidationError(_("No samples found")) @@ -442,7 +443,7 @@ def confirm_order(self, persist=True): if persist: super().confirm_order() - def order_manually_checked(self): + def order_manually_checked(self) -> None: """ Set the order as checked by the lab staff, generate a genlab id """ @@ -471,7 +472,7 @@ class AnalysisOrder(Order): ) @property - def short_timeframe(self): + def short_timeframe(self) -> bool: if not self.expected_delivery_date: return False return (self.expected_delivery_date - self.created_at.date()) < timedelta( @@ -481,16 +482,16 @@ def short_timeframe(self): def __str__(self) -> str: return f"#ANL_{self.id}" - def get_absolute_url(self): + def get_absolute_url(self) -> str: return reverse( "genrequest-analysis-detail", kwargs={"pk": self.pk, "genrequest_id": self.genrequest_id}, ) - def get_type(self): + def get_type(self) -> str: return "analysis" - def confirm_order(self, persist=True): + def confirm_order(self, persist: bool = True) -> None: with transaction.atomic(): if not self.samples.all().exists(): raise ValidationError(_("No samples found")) @@ -498,7 +499,7 @@ def confirm_order(self, persist=True): if persist: super().confirm_order() - def populate_from_order(self): + def populate_from_order(self) -> None: """ Create the list of markers per sample to analyze based on a previous extraction order @@ -584,7 +585,7 @@ class Meta: models.UniqueConstraint(fields=["genlab_id"], name="unique_genlab_id") ] - def create_replica(self): + def create_replica(self) -> None: pk = self.id self.id = None self.genlab_id = None @@ -592,7 +593,7 @@ def create_replica(self): self.save() @property - def has_error(self): + def has_error(self) -> bool: """ Check if all the fields are filled correctly depending on several factors. @@ -613,14 +614,14 @@ def has_error(self): "GUID, Sample Name, Sample Type, Species and Year are required" ) - if self.order.genrequest.area.location_mandatory: + if self.order.genrequest.area.location_mandatory: # type: ignore[union-attr] # FIXME: Order can be None. if not self.location_id: raise ValidationError("Location is required") # ensure that location is correct for the selected species elif ( self.species.location_type and self.species.location_type_id - not in self.location.types.values_list("id", flat=True) + not in self.location.types.values_list("id", flat=True) # type: ignore[union-attr] # FIXME: Order can be None. ): raise ValidationError("Invalid location for the selected species") elif self.location_id and self.species.location_type_id: diff --git a/src/genlab_bestilling/tables.py b/src/genlab_bestilling/tables.py index c434fc37..11a68dc8 100644 --- a/src/genlab_bestilling/tables.py +++ b/src/genlab_bestilling/tables.py @@ -1,3 +1,5 @@ +from typing import Any + import django_tables2 as tables from .models import ( @@ -28,7 +30,7 @@ class Meta: ] empty_text = "No Orders" - def render_id(self, record): + def render_id(self, record: Any) -> str: return str(record) @@ -54,10 +56,10 @@ class Meta: ] empty_text = "No Orders" - def render_polymorphic_ctype(self, value): + def render_polymorphic_ctype(self, value: Any) -> str: return value.name - def render_id(self, record): + def render_id(self, record: Any) -> str: return str(record) @@ -79,7 +81,7 @@ class Meta: empty_text = "No projects" - def render_tags(self, record): + def render_tags(self, record: Any) -> str: return ",".join(map(str, record.tags.all())) @@ -106,7 +108,7 @@ class Meta: empty_text = "No Samples" - def render_plate_positions(self, value): + def render_plate_positions(self, value: Any) -> str: return ", ".join([str(v) for v in value.all()]) diff --git a/src/genlab_bestilling/tasks.py b/src/genlab_bestilling/tasks.py index 1a7dfac9..607f301b 100644 --- a/src/genlab_bestilling/tasks.py +++ b/src/genlab_bestilling/tasks.py @@ -12,6 +12,6 @@ max_attempts=5, linear_wait=5, retry_exceptions={OperationalError} ), ) -def generate_ids(order_id): +def generate_ids(order_id: str | int) -> None: generate_genlab_id(order_id=order_id) # isolate(order_id=order_id) diff --git a/src/genlab_bestilling/views.py b/src/genlab_bestilling/views.py index af857224..a8766000 100644 --- a/src/genlab_bestilling/views.py +++ b/src/genlab_bestilling/views.py @@ -4,7 +4,8 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.postgres.aggregates import StringAgg from django.db.models.query import QuerySet -from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.forms import Form +from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect from django.middleware.csrf import get_token from django.urls import reverse from django.utils.functional import cached_property @@ -72,7 +73,7 @@ class GenrequestListView( filterset_class = GenrequestFilter @cached_property - def crumbs(self): + def crumbs(self) -> list[tuple]: return [(self.model._meta.verbose_name_plural, reverse("genrequest-list"))] def get_queryset(self) -> QuerySet[Any]: @@ -90,14 +91,14 @@ class GenrequestDetailView(BaseBreadcrumbMixin, LoginRequiredMixin, DetailView): add_home = False @cached_property - def crumbs(self): + def crumbs(self) -> list[tuple]: return [ (self.model._meta.verbose_name_plural, reverse("genrequest-list")), (str(self.object), ""), ] - def get_queryset(self): - return super().get_queryset().filter_allowed(self.request.user) + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter_allowed(self.request.user) # type: ignore[attr-defined] class GenrequestUpdateView(BaseBreadcrumbMixin, FormsetUpdateView): @@ -106,7 +107,7 @@ class GenrequestUpdateView(BaseBreadcrumbMixin, FormsetUpdateView): add_home = False @cached_property - def crumbs(self): + def crumbs(self) -> list[tuple]: return [ (self.model._meta.verbose_name_plural, reverse("genrequest-list")), ( @@ -116,10 +117,10 @@ def crumbs(self): ("Update", ""), ] - def get_queryset(self): - return super().get_queryset().filter_allowed(self.request.user) + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter_allowed(self.request.user) # type: ignore[attr-defined] - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "genrequest-detail", kwargs={"pk": self.object.id}, @@ -132,7 +133,7 @@ class GenrequestDeleteView(BaseBreadcrumbMixin, DeleteView): add_home = False @cached_property - def crumbs(self): + def crumbs(self) -> list[tuple]: return [ (self.model._meta.verbose_name_plural, reverse("genrequest-list")), ( @@ -142,7 +143,7 @@ def crumbs(self): ("Delete", ""), ] - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "genrequest-list", ) @@ -155,16 +156,16 @@ class GenrequestCreateView(BaseBreadcrumbMixin, FormsetCreateView): add_home = False @cached_property - def crumbs(self): + def crumbs(self) -> list[tuple]: return [ (self.model._meta.verbose_name_plural, reverse("genrequest-list")), ("Create", ""), ] - def get_queryset(self): - return super().get_queryset().filter_allowed(self.request.user) + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter_allowed(self.request.user) # type: ignore[attr-defined] - def get_form_kwargs(self): + def get_form_kwargs(self) -> dict[str, Any]: kwargs = super().get_form_kwargs() kwargs["user"] = self.request.user if self.request.GET.get("project"): @@ -173,10 +174,10 @@ def get_form_kwargs(self): ).first() return kwargs - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "genrequest-detail", - kwargs={"pk": self.object.id}, + kwargs={"pk": self.object.id}, # type: ignore[union-attr] # FIXME: Can object be None? ) @@ -195,7 +196,7 @@ class GenrequestNestedMixin(BaseBreadcrumbMixin, LoginRequiredMixin): gen_crumbs: list[tuple] = [] @cached_property - def crumbs(self): + def crumbs(self) -> list[tuple]: return [ (Genrequest._meta.verbose_name_plural, reverse("genrequest-list")), ( @@ -204,16 +205,16 @@ def crumbs(self): ), ] + self.gen_crumbs - def get_genrequest(self): + def get_genrequest(self) -> Genrequest: return Genrequest.objects.filter_allowed(self.request.user).get( id=self.kwargs["genrequest_id"] ) - def post(self, request, *args, **kwargs): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.genrequest = self.get_genrequest() return super().post(request, *args, **kwargs) - def get(self, request, *args, **kwargs): + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.genrequest = self.get_genrequest() return super().get(request, *args, **kwargs) @@ -239,7 +240,7 @@ class GenrequestOrderListView(GenrequestNestedMixin, SingleTableMixin, FilterVie filterset_class = OrderFilter gen_crumbs = [("Orders", "")] - def get_queryset(self): + def get_queryset(self) -> QuerySet: return super().get_queryset().select_related("genrequest", "polymorphic_ctype") @@ -249,7 +250,7 @@ class OrderListView(SingleTableMixin, FilterView): filterset_class = OrderFilter crumbs = [("Orders", "")] - def get_queryset(self): + def get_queryset(self) -> QuerySet: return ( super() .get_queryset() @@ -265,7 +266,7 @@ class GenrequestEquipmentOrderListView( filterset_class = OrderEquipmentFilter @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -280,7 +281,7 @@ def gen_crumbs(self): ), ] - def get_queryset(self): + def get_queryset(self) -> QuerySet: return super().get_queryset().select_related("genrequest") @@ -290,7 +291,7 @@ class EquipmentOrderListView(SingleTableMixin, FilterView): filterset_class = OrderEquipmentFilter @cached_property - def crumbs(self): + def crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -304,8 +305,10 @@ def crumbs(self): ), ] - def get_queryset(self): - super().get_queryset().select_related("genrequest", "genrequest__project") + def get_queryset(self) -> QuerySet: + return ( + super().get_queryset().select_related("genrequest", "genrequest__project") + ) class GenrequestExtractionOrderListView( @@ -316,7 +319,7 @@ class GenrequestExtractionOrderListView( filterset_class = OrderExtractionFilter @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -331,7 +334,7 @@ def gen_crumbs(self): ), ] - def get_queryset(self): + def get_queryset(self) -> QuerySet: return super().get_queryset().select_related("genrequest") @@ -341,7 +344,7 @@ class ExtractionOrderListView(SingleTableMixin, FilterView): filterset_class = OrderExtractionFilter @cached_property - def crumbs(self): + def crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -355,7 +358,7 @@ def crumbs(self): ), ] - def get_queryset(self): + def get_queryset(self) -> QuerySet: return ( super().get_queryset().select_related("genrequest", "genrequest__project") ) @@ -369,7 +372,7 @@ class GenrequestAnalysisOrderListView( filterset_class = OrderAnalysisFilter @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -384,7 +387,7 @@ def gen_crumbs(self): ), ] - def get_queryset(self): + def get_queryset(self) -> QuerySet: return ( super().get_queryset().select_related("genrequest", "genrequest__project") ) @@ -396,7 +399,7 @@ class AnalysisOrderListView(SingleTableMixin, FilterView): filterset_class = OrderAnalysisFilter @cached_property - def crumbs(self): + def crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -410,7 +413,7 @@ def crumbs(self): ), ] - def get_queryset(self): + def get_queryset(self) -> QuerySet: return ( super().get_queryset().select_related("genrequest", "genrequest__project") ) @@ -420,7 +423,7 @@ class EquipmentOrderDetailView(GenrequestNestedMixin, DetailView): model = EquipmentOrder @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -444,7 +447,7 @@ class AnalysisOrderDetailView(GenrequestNestedMixin, DetailView): model = AnalysisOrder @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -463,7 +466,7 @@ def gen_crumbs(self): (str(self.object), ""), ] - def get_queryset(self): + def get_queryset(self) -> QuerySet: return ( super() .get_queryset() @@ -476,7 +479,7 @@ class ExtractionOrderDetailView(GenrequestNestedMixin, DetailView): model = ExtractionOrder @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -501,7 +504,7 @@ class GenrequestOrderDeleteView(GenrequestNestedMixin, DeleteView): template_name = "genlab_bestilling/order_confirm_delete.html" @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -525,7 +528,7 @@ def get_queryset(self) -> QuerySet[Any]: qs = super().get_queryset() return qs.filter(status=Order.OrderStatus.DRAFT) - def get_object(self, queryset=None) -> Order: + def get_object(self, queryset: QuerySet | None = None) -> Order: return (super().get_object(queryset)).get_real_instance() def get_form_kwargs(self) -> dict[str, Any]: @@ -533,7 +536,7 @@ def get_form_kwargs(self) -> dict[str, Any]: del kwargs["genrequest"] return kwargs - def form_valid(self, form): + def form_valid(self, form: Form) -> HttpResponse: response = super().form_valid(form) messages.success(self.request, f"Order #{self.kwargs['pk']} deleted!") return response @@ -547,15 +550,15 @@ def get_success_url(self) -> str: class ConfirmOrderActionView(GenrequestNestedMixin, SingleObjectMixin, ActionView): model = Order - def get_queryset(self): - return super().get_queryset().filter_in_draft() + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter_in_draft() # type: ignore[attr-defined] - def post(self, request, *args, **kwargs): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.genrequest = self.get_genrequest() self.object = (self.get_object()).get_real_instance() return super().post(request, *args, **kwargs) - def get_form_kwargs(self): + def get_form_kwargs(self) -> dict[str, Any]: kwargs = super().get_form_kwargs() kwargs.pop("genrequest") return kwargs @@ -578,14 +581,14 @@ def form_valid(self, form: Any) -> HttpResponse: def get_success_url(self) -> str: return self.object.get_absolute_url() - def form_invalid(self, form): + def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) class CloneOrderActionView(GenrequestNestedMixin, SingleObjectMixin, ActionView): model = Order - def post(self, request, *args, **kwargs): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.genrequest = self.get_genrequest() self.object = (self.get_object()).get_real_instance() return super().post(request, *args, **kwargs) @@ -626,7 +629,7 @@ def get_success_url(self) -> str: ) return self.object.get_absolute_url() - def form_invalid(self, form): + def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) @@ -638,7 +641,7 @@ class EquipmentOrderEditView( form_class = EquipmentOrderForm @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -658,10 +661,10 @@ def gen_crumbs(self): ("Update", ""), ] - def get_queryset(self): - return super().get_queryset().filter_in_draft() + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter_in_draft() # type: ignore[attr-defined] - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "genrequest-equipment-detail", kwargs={"genrequest_id": self.genrequest.id, "pk": self.object.id}, @@ -676,7 +679,7 @@ class EquipmentOrderCreateView( form_class = EquipmentOrderForm @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -689,10 +692,10 @@ def gen_crumbs(self): ("Create", ""), ] - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "genrequest-equipment-quantity-update", - kwargs={"genrequest_id": self.genrequest.id, "pk": self.object.id}, + kwargs={"genrequest_id": self.genrequest.id, "pk": self.object.id}, # type: ignore[union-attr] # FIXME: Can object be None? ) @@ -704,7 +707,7 @@ class AnalysisOrderEditView( form_class = AnalysisOrderUpdateForm @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -718,10 +721,10 @@ def gen_crumbs(self): ("Update", ""), ] - def get_queryset(self): - return super().get_queryset().filter_in_draft() + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter_in_draft() # type: ignore[attr-defined] - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "genrequest-analysis-detail", kwargs={"genrequest_id": self.genrequest.id, "pk": self.object.id}, @@ -736,7 +739,7 @@ class ExtractionOrderEditView( form_class = ExtractionOrderForm @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -750,10 +753,10 @@ def gen_crumbs(self): ("Update", ""), ] - def get_queryset(self): - return super().get_queryset().filter_in_draft() + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter_in_draft() # type: ignore[attr-defined] - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "genrequest-extraction-detail", kwargs={"genrequest_id": self.genrequest.id, "pk": self.object.id}, @@ -767,7 +770,7 @@ class AnalysisOrderCreateView( form_class = AnalysisOrderForm model = AnalysisOrder - def get_initial(self): + def get_initial(self) -> Any: initial = super().get_initial() if "from_order" in self.request.GET: @@ -791,7 +794,7 @@ def get_initial(self): return initial @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -804,16 +807,23 @@ def gen_crumbs(self): ("Create", ""), ] - def get_success_url(self): - if self.object.from_order: + def get_success_url(self) -> str: + obj: AnalysisOrder = self.object # type: ignore[assignment] # Possibly None + if obj.from_order: return reverse( "genrequest-analysis-samples", - kwargs={"genrequest_id": self.genrequest.id, "pk": self.object.id}, + kwargs={ + "genrequest_id": self.genrequest.id, + "pk": obj.id, + }, ) else: return reverse( "genrequest-analysis-samples-edit", - kwargs={"genrequest_id": self.genrequest.id, "pk": self.object.id}, + kwargs={ + "genrequest_id": self.genrequest.id, + "pk": self.object.id, # type: ignore[union-attr] + }, ) @@ -825,7 +835,7 @@ class ExtractionOrderCreateView( model = ExtractionOrder @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -838,10 +848,10 @@ def gen_crumbs(self): ("Create", ""), ] - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "genrequest-extraction-samples-edit", - kwargs={"genrequest_id": self.genrequest.id, "pk": self.object.id}, + kwargs={"genrequest_id": self.genrequest.id, "pk": self.object.id}, # type: ignore[union-attr] # FIXME: Can object be None? ) @@ -852,7 +862,7 @@ class EquipmentOrderQuantityUpdateView(GenrequestNestedMixin, BulkEditCollection genrequest_accessor = "order__genrequest" @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: self.get_queryset() return [ ( @@ -882,18 +892,18 @@ def get_queryset(self) -> QuerySet[Any]: .select_related("order", "equipment") ) - def get_collection_kwargs(self): + def get_collection_kwargs(self) -> dict[str, Any]: kwargs = super().get_collection_kwargs() kwargs["context"] = {"order_id": self.kwargs["pk"]} return kwargs - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "genrequest-equipment-detail", kwargs={"genrequest_id": self.genrequest.id, "pk": self.kwargs["pk"]}, ) - def get_initial(self): + def get_initial(self) -> Any: collection_class = self.get_collection_class() queryset = self.get_queryset() initial = collection_class( @@ -907,7 +917,7 @@ class SamplesFrontendView(GenrequestNestedMixin, DetailView): template_name = "genlab_bestilling/sample_form_frontend.html" @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -940,8 +950,8 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: } return context - def get_queryset(self): - return super().get_queryset().filter_in_draft() + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter_in_draft() # type: ignore[attr-defined] class SamplesListView(GenrequestNestedMixin, SingleTableView): @@ -952,7 +962,7 @@ class SamplesListView(GenrequestNestedMixin, SingleTableView): table_class = SampleTable @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: self.get_queryset() return [ ( @@ -989,7 +999,7 @@ class AnalysisSamplesFrontendView(GenrequestNestedMixin, DetailView): template_name = "genlab_bestilling/analysis_sample_form_frontend.html" @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: return [ ( "Orders", @@ -1022,8 +1032,8 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: } return context - def get_queryset(self): - return super().get_queryset().filter_in_draft() + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter_in_draft() # type: ignore[attr-defined] class AnalysisSamplesListView(GenrequestNestedMixin, SingleTableView): @@ -1034,7 +1044,7 @@ class AnalysisSamplesListView(GenrequestNestedMixin, SingleTableView): table_class = AnalysisSampleTable @cached_property - def gen_crumbs(self): + def gen_crumbs(self) -> list[tuple]: self.get_queryset() return [ ( diff --git a/src/nina/forms.py b/src/nina/forms.py index f6ee504d..cf5e76e4 100644 --- a/src/nina/forms.py +++ b/src/nina/forms.py @@ -1,6 +1,9 @@ +from typing import Any + from django import forms from django.core.exceptions import ValidationError from django.db import models, transaction +from django.db.models import Model from formset.renderers.tailwind import FormRenderer from genlab_bestilling.libs.formset import ContextFormCollection @@ -11,10 +14,10 @@ class ProjectMembershipForm(forms.ModelForm): id = forms.IntegerField(required=False, widget=forms.widgets.HiddenInput) - def reinit(self, context): + def reinit(self, context: dict[str, Any]) -> None: self.project_id = context["project"] - def save(self, commit=True): + def save(self, commit: bool = True) -> Model: obj = super().save(commit=False) obj.project_id = self.project_id if commit: @@ -33,18 +36,19 @@ class ProjectMembershipCollection(ContextFormCollection): members = ProjectMembershipForm() default_renderer = FormRenderer(field_css_classes="mb-3") - def retrieve_instance(self, data): - if data := data.get("members"): + def retrieve_instance(self, data: dict[str, Any]) -> ProjectMembership | None: + if members := data.get("members"): try: - return ProjectMembership.objects.get(id=data.get("id") or -1) + return ProjectMembership.objects.get(id=members.get("id") or -1) except (AttributeError, ProjectMembership.DoesNotExist, ValueError): return ProjectMembership( - user_id=data.get("user"), - role=data.get("role"), - project_id=data.get("project_id"), + user_id=members.get("user"), + role=members.get("role"), + project_id=members.get("project_id"), ) + return None - def update_holder_instances(self, name, holder): + def update_holder_instances(self, name: str, holder: Any) -> None: if name == "members": holder.reinit(self.context) @@ -52,11 +56,11 @@ def update_holder_instances(self, name, holder): class ProjectCreateForm(forms.ModelForm): default_renderer = FormRenderer(field_css_classes="mb-3") - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: self.user = kwargs.pop("user") super().__init__(*args, **kwargs) - def save(self, commit=True): + def save(self, commit: bool = True) -> Model: with transaction.atomic(): obj = super().save(commit=True) ProjectMembership.objects.create( @@ -64,7 +68,7 @@ def save(self, commit=True): ) return obj - def clean_number(self): + def clean_number(self) -> int: number = self.cleaned_data["number"] try: p = Project.objects.prefetch_related("members").get(pk=number) diff --git a/src/nina/models.py b/src/nina/models.py index f41baddc..2030997d 100644 --- a/src/nina/models.py +++ b/src/nina/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models import QuerySet from django.urls import reverse @@ -31,7 +32,7 @@ def __str__(self) -> str: class ProjectManager(models.Manager): - def filter_selectable(self): + def filter_selectable(self) -> QuerySet: """ Obtain only active and verified projects """ @@ -55,5 +56,5 @@ def __str__(self) -> str: return self.number - def get_absolute_url(self): + def get_absolute_url(self) -> str: return reverse("nina:project-detail", kwargs={"pk": self.pk}) diff --git a/src/nina/views.py b/src/nina/views.py index 8437bd36..16dba500 100644 --- a/src/nina/views.py +++ b/src/nina/views.py @@ -1,4 +1,7 @@ +from typing import Any + from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.db.models import QuerySet from django.urls import reverse from django.views.generic import DetailView from django_tables2.views import SingleTableView @@ -17,14 +20,14 @@ class ProjectList(LoginRequiredMixin, SingleTableView): model = ProjectMembership table_class = MyProjectsTable - def get_queryset(self): + def get_queryset(self) -> QuerySet: return super().get_queryset().filter(user=self.request.user) class ProjectDetailView(LoginRequiredMixin, DetailView): model = Project - def get_queryset(self): + def get_queryset(self) -> QuerySet: return ( super() .get_queryset() @@ -32,7 +35,7 @@ def get_queryset(self): .filter(memberships=self.request.user) ) - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict[str, Any]: ctx = super().get_context_data(**kwargs) ctx["table"] = MembersTable(data=self.object.members.all()) return ctx @@ -45,7 +48,7 @@ class ProjectMembershipUpdateView( template_name = "nina/projectmembership_form.html" model = ProjectMembership - def test_func(self): + def test_func(self) -> bool: return ( self.get_queryset() .filter( @@ -55,21 +58,21 @@ def test_func(self): .exists() ) - def get_queryset(self): + def get_queryset(self) -> QuerySet: return super().get_queryset().filter(project=self.kwargs["pk"]) - def get_collection_kwargs(self): + def get_collection_kwargs(self) -> dict[str, Any]: kwargs = super().get_collection_kwargs() kwargs["context"] = {"project": self.kwargs["pk"]} return kwargs - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "nina:project-detail", kwargs={"pk": self.kwargs["pk"]}, ) - def get_initial(self): + def get_initial(self) -> Any: collection_class = self.get_collection_class() queryset = self.get_queryset() initial = collection_class( @@ -82,12 +85,12 @@ class ProjectCreateView(FormsetCreateView): model = Project form_class = ProjectCreateForm - def get_form_kwargs(self): + def get_form_kwargs(self) -> dict[str, Any]: kwargs = super().get_form_kwargs() kwargs["user"] = self.request.user return kwargs - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "nina:project-detail", kwargs={"pk": self.object.pk}, @@ -98,7 +101,7 @@ class ProjectEditView(FormsetUpdateView): model = Project form_class = ProjectUpdateForm - def get_success_url(self): + def get_success_url(self) -> str: return reverse( "nina:project-detail", kwargs={"pk": self.object.pk}, diff --git a/src/shared/views.py b/src/shared/views.py index d661aaff..45673f0c 100644 --- a/src/shared/views.py +++ b/src/shared/views.py @@ -1,4 +1,5 @@ from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpRequest, HttpResponse from django.views.generic import ( CreateView, FormView, @@ -30,11 +31,11 @@ class FormsetUpdateView( class ActionView(FormView): form_class = ActionForm - def get(self, request, *args, **kwargs): + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """ Action forms should be used just to modify the system """ - self.http_method_not_allowed(self, request, *args, **kwargs) + return self.http_method_not_allowed(request, *args, **kwargs) def get_success_url(self) -> str: return self.request.path_info diff --git a/src/staff/filters.py b/src/staff/filters.py index b4ff8502..067247b7 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -1,5 +1,9 @@ +from typing import Any + import django_filters as filters from dal import autocomplete +from django.db.models import QuerySet +from django.http import HttpRequest from genlab_bestilling.models import ( AnalysisOrder, @@ -10,7 +14,14 @@ class AnalysisOrderFilter(filters.FilterSet): - def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ) -> None: super().__init__(data, queryset, request=request, prefix=prefix) self.filters["genrequest__project"].extra["widget"] = autocomplete.ModelSelect2( url="autocomplete:project" @@ -30,7 +41,14 @@ class Meta: class OrderSampleFilter(filters.FilterSet): - def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ) -> None: super().__init__(data, queryset, request=request, prefix=prefix) self.filters["species"].extra["widget"] = autocomplete.ModelSelect2( url="autocomplete:species" @@ -60,7 +78,14 @@ class Meta: class SampleMarkerOrderFilter(filters.FilterSet): - def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ) -> None: super().__init__(data, queryset, request=request, prefix=prefix) self.filters["sample__species"].extra["widget"] = autocomplete.ModelSelect2( url="autocomplete:species" @@ -95,7 +120,14 @@ class Meta: class SampleFilter(filters.FilterSet): - def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ) -> None: super().__init__(data, queryset, request=request, prefix=prefix) self.filters["species"].extra["widget"] = autocomplete.ModelSelect2( url="autocomplete:species" diff --git a/src/staff/tables.py b/src/staff/tables.py index 39ebc789..b3dc8a18 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -1,3 +1,5 @@ +from typing import Any + import django_tables2 as tables from genlab_bestilling.models import ( @@ -52,7 +54,7 @@ class Meta: empty_text = "No Orders" order_by = ("-is_urgent",) - def render_id(self, record): + def render_id(self, record: Any) -> str: return str(record) @@ -122,7 +124,7 @@ class Meta: empty_text = "No Samples" - def render_plate_positions(self, value): + def render_plate_positions(self, value: Any) -> str: if value: return ", ".join([str(v) for v in value.all()]) @@ -147,7 +149,7 @@ class Meta: attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Samples" - def render_sample__plate_positions(self, value): + def render_sample__plate_positions(self, value: Any) -> str: if value: return ", ".join([str(v) for v in value.all()]) diff --git a/src/staff/views.py b/src/staff/views.py index 2f731149..75beff70 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -3,7 +3,8 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db import models -from django.http import HttpResponse, HttpResponseRedirect +from django.forms import Form +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.urls import reverse_lazy from django.utils.timezone import now from django.utils.translation import gettext as _ @@ -53,14 +54,14 @@ def get_template_names(self) -> list[str]: for name in names ] - def test_func(self): + def test_func(self) -> bool: return self.request.user.is_superuser or self.request.user.is_genlab_staff() class DashboardView(StaffMixin, TemplateView): template_name = "staff/dashboard.html" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict[str, Any]: context = super().get_context_data(**kwargs) urgent_orders = Order.objects.filter( @@ -81,7 +82,7 @@ class AnalysisOrderListView(StaffMixin, SingleTableMixin, FilterView): table_class = AnalysisOrderTable filterset_class = AnalysisOrderFilter - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[AnalysisOrder]: return ( super() .get_queryset() @@ -100,7 +101,7 @@ class ExtractionOrderListView(StaffMixin, SingleTableMixin, FilterView): table_class = ExtractionOrderTable filterset_class = AnalysisOrderFilter - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[ExtractionOrder]: return ( super() .get_queryset() @@ -120,7 +121,7 @@ class ExtractionPlateListView(StaffMixin, SingleTableMixin, FilterView): table_class = PlateTable filterset_class = ExtractionPlateFilter - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[ExtractionPlate]: return ( super() .get_queryset() @@ -135,7 +136,7 @@ class EqupimentOrderListView(StaffMixin, SingleTableMixin, FilterView): table_class = EquipmentOrderTable filterset_class = AnalysisOrderFilter - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[EquipmentOrder]: return ( super() .get_queryset() @@ -169,7 +170,7 @@ class OrderExtractionSamplesListView(StaffMixin, SingleTableMixin, FilterView): table_class = OrderExtractionSampleTable filterset_class = OrderSampleFilter - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[Sample]: return ( super() .get_queryset() @@ -179,7 +180,7 @@ def get_queryset(self): .order_by("species__name", "year", "location__name", "name") ) - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["order"] = ExtractionOrder.objects.get(pk=self.kwargs.get("pk")) return context @@ -192,7 +193,7 @@ class OrderAnalysisSamplesListView(StaffMixin, SingleTableMixin, FilterView): table_class = OrderAnalysisSampleTable filterset_class = SampleMarkerOrderFilter - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[SampleMarkerAnalysis]: return ( super() .get_queryset() @@ -209,7 +210,7 @@ def get_queryset(self): ) ) - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["order"] = AnalysisOrder.objects.get(pk=self.kwargs.get("pk")) return context @@ -220,7 +221,7 @@ class SamplesListView(StaffMixin, SingleTableMixin, FilterView): table_class = SampleTable filterset_class = SampleFilter - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[Sample]: return ( super() .get_queryset() @@ -245,14 +246,14 @@ class SampleDetailView(StaffMixin, DetailView): class ManaullyCheckedOrderActionView(SingleObjectMixin, ActionView): model = ExtractionOrder - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[ExtractionOrder]: return ExtractionOrder.objects.filter(status=Order.OrderStatus.DELIVERED) - def post(self, request, *args, **kwargs): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.object = self.get_object() return super().post(request, *args, **kwargs) - def form_valid(self, form: Any) -> HttpResponse: + def form_valid(self, form: Form) -> HttpResponse: try: # TODO: check state transition self.object.order_manually_checked() @@ -276,21 +277,21 @@ def get_success_url(self) -> str: kwargs={"pk": self.object.id}, ) - def form_invalid(self, form): + def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) class OrderToDraftActionView(SingleObjectMixin, ActionView): model = Order - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[Order]: return super().get_queryset().filter(status=Order.OrderStatus.DELIVERED) - def post(self, request, *args, **kwargs): - self.object = self.get_object() + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object: Order = self.get_object() return super().post(request, *args, **kwargs) - def form_valid(self, form: Any) -> HttpResponse: + def form_valid(self, form: Form) -> HttpResponse: try: # TODO: check state transition self.object.to_draft() @@ -311,21 +312,21 @@ def form_valid(self, form: Any) -> HttpResponse: def get_success_url(self) -> str: return reverse_lazy(f"staff:order-{self.object.get_type()}-list") - def form_invalid(self, form): + def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) class OrderToNextStatusActionView(SingleObjectMixin, ActionView): model = Order - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[Order]: return Order.objects.all() - def post(self, request, *args, **kwargs): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.object = self.get_object() return super().post(request, *args, **kwargs) - def form_valid(self, form: Any) -> HttpResponse: + def form_valid(self, form: Form) -> HttpResponse: try: # TODO: check state transition self.object.to_next_status() @@ -346,7 +347,7 @@ def form_valid(self, form: Any) -> HttpResponse: def get_success_url(self) -> str: return reverse_lazy(f"staff:order-{self.object.get_type()}-list") - def form_invalid(self, form): + def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) @@ -354,7 +355,7 @@ class ExtractionPlateCreateView(StaffMixin, CreateView): model = ExtractionPlate form_class = ExtractionPlateForm - def get_success_url(self): + def get_success_url(self) -> str: return reverse_lazy("staff:plates-list") @@ -365,7 +366,7 @@ class ExtractionPlateDetailView(StaffMixin, DetailView): class SampleReplicaActionView(SingleObjectMixin, ActionView): model = Sample - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[Sample]: return ( super() .get_queryset() @@ -373,11 +374,11 @@ def get_queryset(self): .filter(order__status=Order.OrderStatus.DELIVERED) ) - def post(self, request, *args, **kwargs): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.object = self.get_object() return super().post(request, *args, **kwargs) - def form_valid(self, form: Any) -> HttpResponse: + def form_valid(self, form: Form) -> HttpResponse: try: # TODO: check state transition self.object = self.object.create_replica() @@ -398,7 +399,7 @@ def form_valid(self, form: Any) -> HttpResponse: def get_success_url(self) -> str: return reverse_lazy("staff:samples-detail", kwargs={"id": self.object.pk}) - def form_invalid(self, form): + def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) @@ -420,14 +421,14 @@ class ProjectDetailView(StaffMixin, DetailView): class ProjectValidateActionView(SingleObjectMixin, ActionView): model = Project - def get_queryset(self): + def get_queryset(self) -> models.QuerySet[Project]: return super().get_queryset().filter(verified_at=None) - def post(self, request, *args, **kwargs): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.object = self.get_object() return super().post(request, *args, **kwargs) - def form_valid(self, form: Any) -> HttpResponse: + def form_valid(self, form: Form) -> HttpResponse: self.object.verified_at = now() self.object.save() messages.add_message( @@ -441,5 +442,5 @@ def form_valid(self, form: Any) -> HttpResponse: def get_success_url(self) -> str: return reverse_lazy("staff:projects-detail", kwargs={"pk": self.object.pk}) - def form_invalid(self, form): + def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) From 0ed43996b672e6ad03b02b848da4b112fb80920a Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:53:57 +0200 Subject: [PATCH 38/99] Run actions on target summer25. (#125) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9363137e..4061410b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,11 @@ env: on: pull_request: - branches: ['main'] + branches: ['main', 'summer25'] paths-ignore: ['docs/**'] push: - branches: ['main'] + branches: ['main', 'summer25'] paths-ignore: ['docs/**'] concurrency: From 334bfa769043442057bdc700b0983174b103e6b2 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:54:19 +0200 Subject: [PATCH 39/99] Register Permission. (#107) --- src/capps/users/admin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/capps/users/admin.py b/src/capps/users/admin.py index 306c9fbc..72a59b32 100644 --- a/src/capps/users/admin.py +++ b/src/capps/users/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin from django.contrib.auth import admin as auth_admin from django.contrib.auth import decorators, get_user_model +from django.contrib.auth import models as auth_models from django.utils.translation import gettext_lazy as _ from unfold.admin import ModelAdmin @@ -9,6 +10,8 @@ User = get_user_model() +admin.site.register(auth_models.Permission) + if settings.DJANGO_ADMIN_FORCE_ALLAUTH: # Force the `admin` sign in process to go through the `django-allauth` workflow: # https://django-allauth.readthedocs.io/en/stable/common/admin.html From 85c2eb5344ae3c81d33598a9059ada30b5b9c257 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:55:07 +0200 Subject: [PATCH 40/99] Improve SampleAdmin. (#114) --- src/genlab_bestilling/admin.py | 53 +++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 82a113eb..f4650447 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -1,8 +1,6 @@ from django.contrib import admin from unfold.admin import ModelAdmin -from unfold.contrib.filters.admin import ( - RelatedDropdownFilter, -) +from unfold.contrib.filters import admin as unfold_filters from .models import ( AnalysisOrder, @@ -48,7 +46,7 @@ class LocationTypeAdmin(ModelAdmin): class LocationAdmin(ModelAdmin): list_display = ["name", "river_id", "code"] search_fields = ["name", "river_id", "code"] - list_filter = [("types", RelatedDropdownFilter)] + list_filter = [("types", unfold_filters.RelatedDropdownFilter)] list_filter_submit = True @@ -65,13 +63,13 @@ class GenrequestAdmin(ModelAdmin): list_filter_submit = True list_filter = [ - ("project", RelatedDropdownFilter), - ("area", RelatedDropdownFilter), - ("sample_types", RelatedDropdownFilter), - ("markers", RelatedDropdownFilter), - ("species", RelatedDropdownFilter), - ("samples_owner", RelatedDropdownFilter), - ("creator", RelatedDropdownFilter), + ("project", unfold_filters.RelatedDropdownFilter), + ("area", unfold_filters.RelatedDropdownFilter), + ("sample_types", unfold_filters.RelatedDropdownFilter), + ("markers", unfold_filters.RelatedDropdownFilter), + ("species", unfold_filters.RelatedDropdownFilter), + ("samples_owner", unfold_filters.RelatedDropdownFilter), + ("creator", unfold_filters.RelatedDropdownFilter), ] autocomplete_fields = [ @@ -92,7 +90,7 @@ class MarkerAdmin(ModelAdmin): @admin.register(Species) class SpeciesAdmin(ModelAdmin): list_display = ["name", "area"] - list_filter = [("area", RelatedDropdownFilter)] + list_filter = [("area", unfold_filters.RelatedDropdownFilter)] list_filter_submit = True search_fields = ["name"] @@ -145,7 +143,36 @@ class SampleMarkerAnalysisAdmin(ModelAdmin): ... @admin.register(Sample) -class SampleAdmin(ModelAdmin): ... +class SampleAdmin(ModelAdmin): + list_display = [ + Sample.order.field.name, + Sample.guid.field.name, + Sample.name.field.name, + Sample.type.field.name, + Sample.species.field.name, + Sample.year.field.name, + Sample.notes.field.name, + Sample.pop_id.field.name, + Sample.location.field.name, + Sample.volume.field.name, + Sample.genlab_id.field.name, + Sample.parent.field.name, + ] + search_fields = [ + Sample.name.field.name, + Sample.guid.field.name, + Sample.genlab_id.field.name, + ] + list_filter = [ + (Sample.name.field.name, unfold_filters.FieldTextFilter), + (Sample.guid.field.name, unfold_filters.FieldTextFilter), + (Sample.genlab_id.field.name, unfold_filters.FieldTextFilter), + (Sample.year.field.name, unfold_filters.SingleNumericFilter), + (Sample.species.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (Sample.type.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + ] + list_filter_submit = True + list_filter_sheet = False @admin.register(ExtractPlatePosition) From a6024f02efc75387233c61ce9756a7b290c80b80 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:59:05 +0200 Subject: [PATCH 41/99] Improve ExtractionOrderAdmin. (#118) --- src/genlab_bestilling/admin.py | 41 +++++++++++++++++++++++++++++++-- src/genlab_bestilling/models.py | 2 +- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index f4650447..d5a44526 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -59,7 +59,7 @@ class GenrequestAdmin(ModelAdmin): # "sample_types", "area", ] - search_fields = ["name"] + search_fields = ["name", "project__name"] list_filter_submit = True list_filter = [ @@ -131,7 +131,44 @@ class EquipmentOrderAdmin(ModelAdmin): ... @admin.register(ExtractionOrder) -class ExtractionOrderAdmin(ModelAdmin): ... +class ExtractionOrderAdmin(ModelAdmin): + EO = ExtractionOrder + list_filter_submit = True + + list_display = [ + EO.name.field.name, + EO.genrequest.field.name, + EO.status.field.name, + EO.internal_status.field.name, + EO.needs_guid.field.name, + EO.return_samples.field.name, + EO.pre_isolated.field.name, + EO.confirmed_at.field.name, + EO.last_modified_at.field.name, + EO.created_at.field.name, + ] + filter_horizontal = [ + EO.species.field.name, + EO.sample_types.field.name, + ] + + search_help_text = "Search for extraction name" + search_fields = [ + EO.name.field.name, + ] + list_filter = [ + (EO.species.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (EO.sample_types.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (EO.genrequest.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + EO.status.field.name, + EO.internal_status.field.name, + EO.needs_guid.field.name, + EO.return_samples.field.name, + EO.pre_isolated.field.name, + EO.confirmed_at.field.name, + EO.last_modified_at.field.name, + EO.created_at.field.name, + ] @admin.register(AnalysisOrder) diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index d5744cf1..7ac50f77 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -206,7 +206,7 @@ class Genrequest(models.Model): # type: ignore[django-manager-missing] tags = TaggableManager(blank=True) def __str__(self): - return f"#GEN_{self.id}" + return f"#GEN_{self.id} ({self.project})" def get_absolute_url(self) -> str: return reverse( From 91d111242defed8f83752b5a69e6a6a11166e3fe Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:07:47 +0200 Subject: [PATCH 42/99] Improve AnalysisOrderAdmin. (#122) * Improve AnalysisOrderAdmin. * Fix id search. --- src/genlab_bestilling/admin.py | 71 +++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index d5a44526..929edf52 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -152,9 +152,10 @@ class ExtractionOrderAdmin(ModelAdmin): EO.sample_types.field.name, ] - search_help_text = "Search for extraction name" + search_help_text = "Search for extraction name or id" search_fields = [ EO.name.field.name, + EO.id.field.name, ] list_filter = [ (EO.species.field.name, unfold_filters.AutocompleteSelectMultipleFilter), @@ -172,7 +173,71 @@ class ExtractionOrderAdmin(ModelAdmin): @admin.register(AnalysisOrder) -class AnalysisOrderAdmin(ModelAdmin): ... +class AnalysisOrderAdmin(ModelAdmin): + """ + + + name = models.CharField(null=True, blank=True) + genrequest = models.ForeignKey( + notes = models.TextField(blank=True, null=True) + status = models.CharField(default=OrderStatus.DRAFT, choices=OrderStatus) + created_at = models.DateTimeField(auto_now_add=True) + last_modified_at = models.DateTimeField(auto_now=True) + confirmed_at = models.DateTimeField(null=True, blank=True) + + samples = models.ManyToManyField( + markers = models.ManyToManyField(f"{an}.Marker", blank=True) + from_order = models.ForeignKey( + expected_delivery_date = models.DateField( + + AO.name.field.name + AO.genrequest.field.name + AO.notes.field.name + AO.status.field.name + AO.created_at.field.name + AO.last_modified_at.field.name + AO.confirmed_at.field.name + + AO.samples.field.name + AO.markers.field.name + AO.from_order.field.name + AO.expected_delivery_date.field.name + + + """ + + AO = AnalysisOrder + list_filter_submit = True + + list_display = [ + AO.name.field.name, + AO.genrequest.field.name, + AO.status.field.name, + AO.from_order.field.name, + AO.expected_delivery_date.field.name, + AO.confirmed_at.field.name, + AO.last_modified_at.field.name, + AO.created_at.field.name, + ] + filter_horizontal = [ + AO.markers.field.name, + ] + + search_help_text = "Search for analysis name" + search_fields = [ + AO.name.field.name, + ] + list_filter = [ + (AO.samples.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (AO.markers.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (AO.genrequest.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (AO.from_order.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + AO.status.field.name, + AO.expected_delivery_date.field.name, + AO.confirmed_at.field.name, + AO.last_modified_at.field.name, + AO.created_at.field.name, + ] @admin.register(SampleMarkerAnalysis) @@ -195,10 +260,12 @@ class SampleAdmin(ModelAdmin): Sample.genlab_id.field.name, Sample.parent.field.name, ] + search_help_text = "Search for sample name, genlab ID, GUID or id" search_fields = [ Sample.name.field.name, Sample.guid.field.name, Sample.genlab_id.field.name, + Sample.id.field.name, ] list_filter = [ (Sample.name.field.name, unfold_filters.FieldTextFilter), From 2d76d59c71545c9c77c15777bdff19fdadbdf4af Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:13:20 +0200 Subject: [PATCH 43/99] Add order filter. (#127) --- src/genlab_bestilling/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 929edf52..bc503450 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -272,6 +272,7 @@ class SampleAdmin(ModelAdmin): (Sample.guid.field.name, unfold_filters.FieldTextFilter), (Sample.genlab_id.field.name, unfold_filters.FieldTextFilter), (Sample.year.field.name, unfold_filters.SingleNumericFilter), + (Sample.order.field.name, unfold_filters.AutocompleteSelectMultipleFilter), (Sample.species.field.name, unfold_filters.AutocompleteSelectMultipleFilter), (Sample.type.field.name, unfold_filters.AutocompleteSelectMultipleFilter), ] From aaabfcb6bfff0ae897d68d8e4c0e75793453d1f4 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:58:14 +0200 Subject: [PATCH 44/99] Improve EquipmentOrderAdmin. (#129) --- src/genlab_bestilling/admin.py | 70 ++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index bc503450..3017e5cc 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -127,7 +127,43 @@ class EquimentOrderQuantityAdmin(ModelAdmin): ... @admin.register(EquipmentOrder) -class EquipmentOrderAdmin(ModelAdmin): ... +class EquipmentOrderAdmin(ModelAdmin): + EO = EquipmentOrder + list_filter_submit = True + + list_display = [ + EO.name.field.name, + EO.genrequest.field.name, + EO.status.field.name, + EO.is_urgent.field.name, + EO.needs_guid.field.name, + EO.contact_person.field.name, + EO.contact_email.field.name, + EO.confirmed_at.field.name, + EO.last_modified_at.field.name, + EO.created_at.field.name, + ] + filter_horizontal = [ + EO.sample_types.field.name, + ] + + search_help_text = "Search for equipment name or id" + search_fields = [ + EO.name.field.name, + EO.id.field.name, + ] + list_filter = [ + (EO.sample_types.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (EO.genrequest.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + EO.status.field.name, + EO.is_urgent.field.name, + (EO.contact_person.field.name, unfold_filters.FieldTextFilter), + (EO.contact_email.field.name, unfold_filters.FieldTextFilter), + EO.needs_guid.field.name, + EO.confirmed_at.field.name, + EO.last_modified_at.field.name, + EO.created_at.field.name, + ] @admin.register(ExtractionOrder) @@ -174,38 +210,6 @@ class ExtractionOrderAdmin(ModelAdmin): @admin.register(AnalysisOrder) class AnalysisOrderAdmin(ModelAdmin): - """ - - - name = models.CharField(null=True, blank=True) - genrequest = models.ForeignKey( - notes = models.TextField(blank=True, null=True) - status = models.CharField(default=OrderStatus.DRAFT, choices=OrderStatus) - created_at = models.DateTimeField(auto_now_add=True) - last_modified_at = models.DateTimeField(auto_now=True) - confirmed_at = models.DateTimeField(null=True, blank=True) - - samples = models.ManyToManyField( - markers = models.ManyToManyField(f"{an}.Marker", blank=True) - from_order = models.ForeignKey( - expected_delivery_date = models.DateField( - - AO.name.field.name - AO.genrequest.field.name - AO.notes.field.name - AO.status.field.name - AO.created_at.field.name - AO.last_modified_at.field.name - AO.confirmed_at.field.name - - AO.samples.field.name - AO.markers.field.name - AO.from_order.field.name - AO.expected_delivery_date.field.name - - - """ - AO = AnalysisOrder list_filter_submit = True From 8495bcfcff5bb68d126acc61ad8faf6d2e399dcf Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:17:35 +0200 Subject: [PATCH 45/99] Generalise out of template (#115) --- src/staff/tables.py | 26 ++++++++++-- src/templates/django_tables2/tailwind.html | 48 ++++------------------ 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index b3dc8a18..ef801b1e 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -1,6 +1,7 @@ from typing import Any import django_tables2 as tables +from django.utils.safestring import mark_safe from genlab_bestilling.models import ( AnalysisOrder, @@ -33,8 +34,16 @@ class OrderTable(tables.Table): empty_values=(), ) - # Override as `tables.Column` to send a True/False value to the template - is_urgent = tables.Column(orderable=True) + is_urgent = tables.Column( + orderable=True, + visible=True, + verbose_name="", + ) + + status = tables.Column( + verbose_name="Status", + orderable=False, + ) class Meta: fields = [ @@ -50,13 +59,22 @@ class Meta: "last_modified_at", "is_urgent", ] - sequence = ("id",) + sequence = ("is_urgent", "status", "id") empty_text = "No Orders" - order_by = ("-is_urgent",) + order_by = ("-is_urgent", "last_modified_at", "created_at") 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 + else: + return "" + class AnalysisOrderTable(OrderTable): id = tables.Column( diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index 882a3299..57650799 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -7,34 +7,24 @@ {% block table %}
@@ -37,15 +45,21 @@ {% block table.tbody %}
- + {% comment %} Show an exclaimation mark if the cell value is True, otherwise show nothing. {% endcomment %} {% if cell == True %} {% endif %} @@ -53,21 +67,21 @@ {% endif %} {% endfor %} - - {% for column, cell in row.items %} - {% if column.name != "is_urgent" %} - - {% if column.localize == None %} - {{ cell }} + {% comment %} Render the rest of the columns in the row. {% endcomment %} + {% for column, cell in row.items %} + {% if column.name != "is_urgent" %} + + {% if column.localize == None %} + {{ cell }} + {% else %} + {% if column.localize %} + {{ cell|localize }} {% else %} - {% if column.localize %} - {{ cell|localize }} - {% else %} - {{ cell|unlocalize }} - {% endif %} - {% endif %}
{% block table.thead %} - - {% comment %} - thead is the first row showing the column names. The is_urgent column is now forced - first in line and does not have a visible column name, as opposed to the other columns. - {% endcomment %} - {% if table.show_header %} - - {% comment %} Force the is_urgent column to be first in the header row. {% endcomment %} - {% for column in table.columns %} - {% if column.name == "is_urgent" %} - - {% endif %} - {% endfor %} - - {% comment %} Render the rest of the columns in the header row. {% endcomment %} {% for column in table.columns %} - {% if column.name != "is_urgent" %} - {% endif %} {% endfor %} @@ -44,32 +34,11 @@ {% block table.tbody %} - - {% comment %} - tbody is the rows with the data, matching the column names of the thead. The is_urgent column is also set to show - first in the rows, just like in the thead. It is also overwritten to show the "True" value as an exclaimation mark, to highlight - the urgent orders. - {% endcomment %} - {% for row in table.paginated_rows %} {% block table.tbody.row %} - {% comment %} Force the is_urgent column to be first in the row. {% endcomment %} - {% for column, cell in row.items %} - {% if column.name == "is_urgent" %} - - {% endif %} - {% endfor %} - - {% comment %} Render the rest of the columns in the row. {% endcomment %} {% for column, cell in row.items %} - {% if column.name != "is_urgent" %} - {% endif %} {% endfor %} {% endblock table.tbody.row %} From 844a6bc32a52cfa27ebda1a57473f2a49d814017 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:50:44 +0200 Subject: [PATCH 46/99] Auto-generate fish_id and conditionally include it in csv based on aquatic area (#120) Co-authored-by: Morten Madsen Lyngstad --- src/genlab_bestilling/api/serializers.py | 27 ++++++++++++++++++++++-- src/genlab_bestilling/api/views.py | 8 +++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index a792f2ee..8fea4981 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -1,3 +1,7 @@ +from collections.abc import Mapping +from typing import Any + +from django.forms import Field from rest_framework import exceptions, serializers from ..models import ( @@ -98,10 +102,12 @@ class SampleCSVSerializer(serializers.ModelSerializer): type = SampleTypeSerializer() species = SpeciesSerializer() location = LocationSerializer(allow_null=True, required=False) + fish_id = serializers.SerializerMethodField() class Meta: model = Sample - fields = ( + # Make fields as a list to enable the removal of fish_id dynamically + fields = [ "order", "guid", "name", @@ -112,7 +118,24 @@ class Meta: "location", "notes", "genlab_id", - ) + "fish_id", + ] + + def get_field_names( + self, declared_fields: Mapping[str, Field], info: Any + ) -> list[str]: + field_names = super().get_field_names(declared_fields, info) + if not self.context.get("include_fish_id", False): + # Remove fish_id if the area is not aquatic (only relevant for aquatic area) + field_names.remove("fish_id") + return field_names + + def get_fish_id(self, obj: Sample) -> str: + if obj.location and obj.location.code: + format_year = str(obj.year)[-2:] # Get the last two digits + format_name = str(obj.name).zfill(4) # Fill from left with zeros + return f"{obj.location.code}_{format_year}_{format_name}" + return "-" class SampleUpdateSerializer(serializers.ModelSerializer): diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index 3043f229..7f914bfc 100644 --- a/src/genlab_bestilling/api/views.py +++ b/src/genlab_bestilling/api/views.py @@ -1,4 +1,5 @@ import uuid +from typing import Any from django.db import transaction from django.db.models import QuerySet @@ -98,6 +99,13 @@ def get_serializer_class(self) -> type[BaseSerializer]: return SampleCSVSerializer return super().get_serializer_class() + def get_serializer_context(self, *args, **kwargs) -> dict[str, Any]: + context = super().get_serializer_context(*args, **kwargs) + queryset = self.filter_queryset(self.get_queryset()) + is_aquatic = queryset.filter(order__genrequest__area__name="Akvatisk").exists() + context["include_fish_id"] = is_aquatic + return context + @action( methods=["GET"], url_path="csv", detail=False, renderer_classes=[CSVRenderer] ) From 538f7c6828322861fa2bf2a22f6ff4396c4ff175 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Mon, 30 Jun 2025 13:43:06 +0200 Subject: [PATCH 47/99] Fix typo confirmed -> delivered (#130) --- src/staff/templates/staff/dashboard.html | 8 ++++---- src/staff/views.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 0ba3be44..9c687b33 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -40,14 +40,14 @@

Urgent orders

{% for order in urgent_orders %} {% if order.polymorphic_ctype.model == 'analysisorder' %} -

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:"-"}} - Status: {{ order.status|default:"-" }}

+

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:'-' }} - Status: {{ order.status|default:'-' }}

{% elif order.polymorphic_ctype.model == 'equipmentorder' %} -

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:"-" }} - Status: {{ order.status|default:"-" }}

+

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:'-' }} - Status: {{ order.status|default:'-' }}

{% elif order.polymorphic_ctype.model == 'extractionorder' %} -

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:"-" }} - Status: {{ order.status|default:"-" }}

+

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:'-' }} - Status: {{ order.status|default:'-' }}

{% else %}

{{ order }} - {{ order.name }}

- {% endif %} + {% endif %} {% endfor %} {% else %} diff --git a/src/staff/views.py b/src/staff/views.py index 75beff70..5bf21248 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -70,9 +70,9 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: ).order_by("-created_at") context["urgent_orders"] = urgent_orders - confirmed_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) + delivered_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) - context["confirmed_orders"] = confirmed_orders + context["delivered_orders"] = delivered_orders context["now"] = now() return context From b41718552c4e7fbd7c82760ec330670f1604dc2d Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:24:42 +0200 Subject: [PATCH 48/99] Display fish_id in admin. (#134) --- src/genlab_bestilling/admin.py | 8 +++++--- src/genlab_bestilling/api/serializers.py | 6 +----- src/genlab_bestilling/models.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 3017e5cc..8013cb02 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -251,9 +251,12 @@ class SampleMarkerAnalysisAdmin(ModelAdmin): ... @admin.register(Sample) class SampleAdmin(ModelAdmin): list_display = [ - Sample.order.field.name, - Sample.guid.field.name, + "__str__", Sample.name.field.name, + Sample.genlab_id.field.name, + Sample.guid.field.name, + "fish_id", + Sample.order.field.name, Sample.type.field.name, Sample.species.field.name, Sample.year.field.name, @@ -261,7 +264,6 @@ class SampleAdmin(ModelAdmin): Sample.pop_id.field.name, Sample.location.field.name, Sample.volume.field.name, - Sample.genlab_id.field.name, Sample.parent.field.name, ] search_help_text = "Search for sample name, genlab ID, GUID or id" diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index 8fea4981..d8d285d3 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -131,11 +131,7 @@ def get_field_names( return field_names def get_fish_id(self, obj: Sample) -> str: - if obj.location and obj.location.code: - format_year = str(obj.year)[-2:] # Get the last two digits - format_name = str(obj.name).zfill(4) # Fill from left with zeros - return f"{obj.location.code}_{format_year}_{format_name}" - return "-" + return obj.fish_id or "-" class SampleUpdateSerializer(serializers.ModelSerializer): diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 7ac50f77..d21ad2bc 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -592,6 +592,23 @@ def create_replica(self) -> None: self.parent_id = pk self.save() + @property + def fish_id(self) -> str | None: + """ + Generate a unique fish ID for the sample. + + NOTE: + Only relevant for aquatic projects. + This function does not check if the sample is connected to an aquatic project + to prevent unnecessary database queries. + It is the responsibility of the caller to ensure that this is the case. + """ + if not (self.location and self.location.code and self.name and self.year): + return None + format_year = str(self.year)[-2:] # Get the last two digits. + format_name = str(self.name).zfill(4) # Fill from left with zeros. + return f"{self.location.code}_{format_year}_{format_name}" + @property def has_error(self) -> bool: """ From c512e56aeb33aa6f2785dc3463db1551408a69bd Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:50:54 +0200 Subject: [PATCH 49/99] Improve SampleMarkerAnalysisAdmin. (#136) * Improve SampleMarkerAnalysisAdmin. * Fix SampleMarkerAnalysis verbose_name_plural. --- src/genlab_bestilling/admin.py | 30 +++++++++++++++++++++++++++++- src/genlab_bestilling/models.py | 1 + 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 8013cb02..7816fe53 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -245,7 +245,35 @@ class AnalysisOrderAdmin(ModelAdmin): @admin.register(SampleMarkerAnalysis) -class SampleMarkerAnalysisAdmin(ModelAdmin): ... +class SampleMarkerAnalysisAdmin(ModelAdmin): + SMA = SampleMarkerAnalysis + + search_help_text = "Search for id or transaction UUID" + search_fields = [ + SMA.id.field.name, + SMA.transaction.field.name, + ] + list_display = [ + SMA.id.field.name, + SMA.sample.field.name, + SMA.order.field.name, + SMA.marker.field.name, + SMA.transaction.field.name, + ] + list_filter = [ + (SMA.id.field.name, unfold_filters.SingleNumericFilter), + (SMA.sample.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (SMA.order.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (SMA.marker.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (SMA.transaction.field.name, unfold_filters.FieldTextFilter), + ] + autocomplete_fields = [ + SMA.sample.field.name, + SMA.order.field.name, + SMA.marker.field.name, + ] + list_filter_sheet = False + list_filter_submit = True @admin.register(Sample) diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index d21ad2bc..9cd342cc 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -536,6 +536,7 @@ class SampleMarkerAnalysis(models.Model): transaction = models.UUIDField(blank=True, null=True) class Meta: + verbose_name_plural = "Sample marker analyses" constraints = [ models.UniqueConstraint( fields=["sample", "order", "marker"], From 58a1fb507e05a3ab226138759f8a8888fa31c75a Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:04:41 +0200 Subject: [PATCH 50/99] Add missing migration. (#140) --- .../0015_alter_samplemarkeranalysis_options.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/genlab_bestilling/migrations/0015_alter_samplemarkeranalysis_options.py diff --git a/src/genlab_bestilling/migrations/0015_alter_samplemarkeranalysis_options.py b/src/genlab_bestilling/migrations/0015_alter_samplemarkeranalysis_options.py new file mode 100644 index 00000000..c7fd3aef --- /dev/null +++ b/src/genlab_bestilling/migrations/0015_alter_samplemarkeranalysis_options.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.3 on 2025-06-30 14:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0014_order_contact_email_order_contact_person_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="samplemarkeranalysis", + options={"verbose_name_plural": "Sample marker analyses"}, + ), + ] From 51ae68b42f44a0ef86cfbb6dc58e70b2f73e5202 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:04:54 +0200 Subject: [PATCH 51/99] Improve ExtractPlatePositionAdmin. (#138) --- src/genlab_bestilling/admin.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 7816fe53..8b655b59 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -315,7 +315,37 @@ class SampleAdmin(ModelAdmin): @admin.register(ExtractPlatePosition) -class ExtractPlatePositionAdmin(ModelAdmin): ... +class ExtractPlatePositionAdmin(ModelAdmin): + """ + plate = models.ForeignKey( + sample = models.ForeignKey( + position = models.IntegerField() + extracted_at = models.DateTimeField(auto_now=True) + notes = models.CharField(null=True, blank=True) + + """ + + M = ExtractPlatePosition + list_display = [ + "__str__", + M.plate.field.name, + M.sample.field.name, + M.position.field.name, + M.extracted_at.field.name, + ] + + search_help_text = "Search for id" + search_fields = [M.id.field.name] + list_filter = [ + (M.id.field.name, unfold_filters.SingleNumericFilter), + (M.plate.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.sample.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.position.field.name, unfold_filters.SingleNumericFilter), + M.extracted_at.field.name, + ] + + list_filter_submit = True + list_filter_sheet = False @admin.register(ExtractionPlate) From d4d0643f68494b13d09178fabdba311ade7e1029 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:50:45 +0200 Subject: [PATCH 52/99] Stop makemigration in entrypoint. (#143) --- entrypoint.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index 49c9185e..49a27eb0 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -23,7 +23,6 @@ if [[ -z "${DJANGO_MIGRATE}" ]] then echo "Skip migration and setup" else - ./src/manage.py makemigrations ./src/manage.py migrate ./src/manage.py setup fi From 8832cbfc9e4cc1c4e30e77c1395ae28bcd4145ad Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:51:05 +0200 Subject: [PATCH 53/99] Improve SpeciesAdmin. (#148) --- src/genlab_bestilling/admin.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 8b655b59..405784dd 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -89,13 +89,24 @@ class MarkerAdmin(ModelAdmin): @admin.register(Species) class SpeciesAdmin(ModelAdmin): - list_display = ["name", "area"] - list_filter = [("area", unfold_filters.RelatedDropdownFilter)] - list_filter_submit = True + M = Species + list_display = [M.name.field.name, M.area.field.name, M.code.field.name] - search_fields = ["name"] + search_help_text = "Search for species name" + search_fields = [M.name.field.name] - autocomplete_fields = ["markers", "area"] + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.code.field.name, unfold_filters.FieldTextFilter), + (M.area.field.name, unfold_filters.RelatedDropdownFilter), + ] + autocomplete_fields = [ + M.markers.field.name, + M.area.field.name, + M.location_type.field.name, + ] + list_filter_submit = True + list_filter_sheet = False @admin.register(SampleType) From 0dee8a4c0058e7ee7a65e77c97a3224c49b1f5d1 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:51:24 +0200 Subject: [PATCH 54/99] Improve MarkerAdmin. (#150) --- src/genlab_bestilling/admin.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 405784dd..1f701ba3 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -84,7 +84,19 @@ class GenrequestAdmin(ModelAdmin): @admin.register(Marker) class MarkerAdmin(ModelAdmin): - search_fields = ["name"] + M = Marker + list_display = [M.name.field.name, M.analysis_type.field.name] + + search_help_text = "Search for marker name" + search_fields = [M.name.field.name] + + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.analysis_type.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + ] + autocomplete_fields = [M.analysis_type.field.name] + list_filter_submit = True + list_filter_sheet = False @admin.register(Species) From 52ae9f494b15e2e460033995ee51e87e6deac030 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:06:59 +0200 Subject: [PATCH 55/99] Improve AnalysisResultAdmin. (#154) --- src/genlab_bestilling/admin.py | 31 ++++++++++++++++++++++++++++++- src/genlab_bestilling/models.py | 13 ++++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 1f701ba3..9cad527c 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -376,4 +376,33 @@ class ExtractionPlateAdmin(ModelAdmin): ... @admin.register(AnalysisResult) -class AnalysisResultAdmin(ModelAdmin): ... +class AnalysisResultAdmin(ModelAdmin): + M = AnalysisResult + list_display = [ + M.name.field.name, + M.marker.field.name, + M.order.field.name, + M.analysis_date.field.name, + M.last_modified_at.field.name, + M.created_at.field.name, + ] + + search_help_text = "Search for analysis result name" + search_fields = [M.name.field.name] + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.marker.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.order.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + M.analysis_date.field.name, + M.last_modified_at.field.name, + M.created_at.field.name, + ] + autocomplete_fields = [M.marker.field.name, M.order.field.name] + list_filter_submit = True + list_filter_sheet = False + filter_horizontal = [M.samples.field.name] + readonly_fields = [ + M.analysis_date.field.name, + M.last_modified_at.field.name, + M.created_at.field.name, + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 9cd342cc..296ab501 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -707,16 +707,19 @@ def __str__(self): class AnalysisResult(models.Model): name = models.CharField() - created_at = models.DateTimeField(auto_now_add=True) - last_modified_at = models.DateTimeField(auto_now=True) analysis_date = models.DateTimeField(null=True, blank=True) marker = models.ForeignKey(f"{an}.Marker", on_delete=models.DO_NOTHING) - result_file = models.FileField(null=True, blank=True) - samples = models.ManyToManyField(f"{an}.Sample", blank=True) - extra = models.JSONField(null=True, blank=True) order = models.ForeignKey( f"{an}.AnalysisOrder", null=True, blank=True, on_delete=models.SET_NULL, ) + result_file = models.FileField(null=True, blank=True) + samples = models.ManyToManyField(f"{an}.Sample", blank=True) + extra = models.JSONField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + last_modified_at = models.DateTimeField(auto_now=True) + + def __str__(self) -> str: + return f"{self.name}" From d23109de71ab3706b773331ea0d8be394c35a725 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:07:20 +0200 Subject: [PATCH 56/99] Improve ExtractionPlateAdmin. (#152) --- src/genlab_bestilling/admin.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 9cad527c..d80991b4 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -372,7 +372,26 @@ class ExtractPlatePositionAdmin(ModelAdmin): @admin.register(ExtractionPlate) -class ExtractionPlateAdmin(ModelAdmin): ... +class ExtractionPlateAdmin(ModelAdmin): + M = ExtractionPlate + list_display = [ + "__str__", + M.name.field.name, + M.last_modified_at.field.name, + M.created_at.field.name, + ] + + search_help_text = "Search for id or name" + search_fields = [M.id.field.name, M.name.field.name] + list_filter = [ + (M.id.field.name, unfold_filters.SingleNumericFilter), + (M.name.field.name, unfold_filters.FieldTextFilter), + M.last_modified_at.field.name, + M.created_at.field.name, + ] + + list_filter_submit = True + list_filter_sheet = False @admin.register(AnalysisResult) From 50ba051c6ba6987211871d337051a0cc31a13d0b Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:10:06 +0200 Subject: [PATCH 57/99] Update equipment order detail template to clarify buffer/volume column. (#162) Make equipment order information on staff page and other page the same. Co-authored-by: Morten Madsen Lyngstad --- .../templates/genlab_bestilling/equipmentorder_detail.html | 2 +- src/staff/templates/staff/equipmentorder_detail.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html index 18c66403..c4f3c066 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html @@ -6,7 +6,7 @@ {% block content %} {% fragment as table_header %} {% #table-cell header=True %}Equipment{% /table-cell %} - {% #table-cell header=True %}Buffer{% /table-cell %} + {% #table-cell header=True %}Buffer/Volume{% /table-cell %} {% #table-cell header=True %}Qty{% /table-cell %} {% endfragment %} diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index b008dcfc..bb7bb54a 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -5,7 +5,7 @@ {% block content %} {% fragment as table_header %} {% #table-cell header=True %}Equipment{% /table-cell %} - {% #table-cell header=True %}Unit{% /table-cell %} + {% #table-cell header=True %}Buffer/Volume{% /table-cell %} {% #table-cell header=True %}Qty{% /table-cell %} {% endfragment %} @@ -22,7 +22,7 @@
Requested Equipment
{% for oq in object.equipments.all %}
{% #table-cell %}{{ oq.equipment.name }}{% /table-cell %} - {% #table-cell %}{{ oq.equipment.unit }}{% /table-cell %} + {% #table-cell %}{{ oq.buffer.name }} {{ oq.buffer_quantity}} {{ oq.buffer.unit }}{% /table-cell %} {% #table-cell %}{{ oq.quantity }}{% /table-cell %} {% empty %} From 267d08f6b780bfd8629e8962bc505e8c86202b1e Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:29:17 +0200 Subject: [PATCH 58/99] Improve AreaAdmin. (#156) --- src/genlab_bestilling/admin.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index d80991b4..4eef0e03 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -34,7 +34,16 @@ class OrganizationAdmin(ModelAdmin): @admin.register(Area) class AreaAdmin(ModelAdmin): - search_fields = ["name"] + M = Area + search_help_text = "Search for area name" + search_fields = [M.name.field.name] + list_display = [M.name.field.name, M.location_mandatory.field.name] + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + M.location_mandatory.field.name, + ] + list_filter_submit = True + list_filter_sheet = False @admin.register(LocationType) From 81bf44e4e23877ac575cc86b27f4774ed33af118 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Tue, 1 Jul 2025 12:56:44 +0200 Subject: [PATCH 59/99] Improve LocationAdmin. (#158) --- src/genlab_bestilling/admin.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 4eef0e03..c3ceeba1 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -53,9 +53,22 @@ class LocationTypeAdmin(ModelAdmin): @admin.register(Location) class LocationAdmin(ModelAdmin): - list_display = ["name", "river_id", "code"] - search_fields = ["name", "river_id", "code"] - list_filter = [("types", unfold_filters.RelatedDropdownFilter)] + M = Location + + search_help_text = "Search for location name, river ID or code" + search_fields = [M.name.field.name, M.river_id.field.name, M.code.field.name] + + list_display = [M.name.field.name, M.river_id.field.name, M.code.field.name] + + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.river_id.field.name, unfold_filters.FieldTextFilter), + (M.code.field.name, unfold_filters.FieldTextFilter), + (M.fylke.field.name, unfold_filters.FieldTextFilter), + (M.types.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + ] + + filter_horizontal = [M.types.field.name] list_filter_submit = True From 4af0248d75ea2be8d168c8f18ddab487bccebe18 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Tue, 1 Jul 2025 16:10:24 +0200 Subject: [PATCH 60/99] Improve SampleTypeAdmin. (#167) --- src/genlab_bestilling/admin.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index c3ceeba1..61f7c1f1 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -145,7 +145,16 @@ class SpeciesAdmin(ModelAdmin): @admin.register(SampleType) class SampleTypeAdmin(ModelAdmin): - search_fields = ["name"] + M = SampleType + search_help_text = "Search for sample type name" + search_fields = [M.name.field.name] + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.areas.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + ] + autocomplete_fields = [M.areas.field.name] + list_filter_submit = True + list_filter_sheet = False @admin.register(AnalysisType) From 03c8d4330f413578e921a5fb7bf19a7d8bf940f9 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Tue, 1 Jul 2025 16:13:46 +0200 Subject: [PATCH 61/99] Improve GenrequestAdmin. (#160) --- src/genlab_bestilling/admin.py | 60 ++++++++++++++++++++++----------- src/genlab_bestilling/models.py | 20 +++++------ 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 61f7c1f1..e823cff2 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -74,34 +74,54 @@ class LocationAdmin(ModelAdmin): @admin.register(Genrequest) class GenrequestAdmin(ModelAdmin): + M = Genrequest list_display = [ - "project", - "name", - "samples_owner", - # "sample_types", - "area", + M.name.field.name, + M.project.field.name, + M.samples_owner.field.name, + M.area.field.name, + M.expected_total_samples.field.name, + M.expected_samples_delivery_date.field.name, + M.expected_analysis_delivery_date.field.name, + M.creator.field.name, + M.last_modified_at.field.name, + M.created_at.field.name, ] - search_fields = ["name", "project__name"] - list_filter_submit = True + search_help_text = "Search for genrequest name or project name" + search_fields = [M.name.field.name, "project__name"] list_filter = [ - ("project", unfold_filters.RelatedDropdownFilter), - ("area", unfold_filters.RelatedDropdownFilter), - ("sample_types", unfold_filters.RelatedDropdownFilter), - ("markers", unfold_filters.RelatedDropdownFilter), - ("species", unfold_filters.RelatedDropdownFilter), - ("samples_owner", unfold_filters.RelatedDropdownFilter), - ("creator", unfold_filters.RelatedDropdownFilter), + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.project.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.area.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.sample_types.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.markers.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.species.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.samples_owner.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.creator.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.expected_total_samples.field.name, unfold_filters.RangeNumericFilter), + M.expected_samples_delivery_date.field.name, + M.expected_analysis_delivery_date.field.name, + M.last_modified_at.field.name, + M.created_at.field.name, ] autocomplete_fields = [ - "samples_owner", - "area", - "project", - "species", - "sample_types", - "markers", + M.area.field.name, + M.project.field.name, + M.samples_owner.field.name, + M.creator.field.name, ] + filter_horizontal = [ + M.markers.field.name, + M.sample_types.field.name, + M.species.field.name, + ] + readonly_fields = [ + M.created_at.field.name, + M.last_modified_at.field.name, + ] + list_filter_submit = True @admin.register(Marker) diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 296ab501..7e1d4699 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -177,16 +177,6 @@ class Genrequest(models.Model): # type: ignore[django-manager-missing] related_name="genrequests_created", ) area = models.ForeignKey(f"{an}.Area", on_delete=models.PROTECT) - species = models.ManyToManyField( - f"{an}.Species", blank=True, related_name="genrequests" - ) - sample_types = models.ManyToManyField( - f"{an}.SampleType", - blank=True, - help_text="samples you plan to deliver, you can choose more than one. " - + "ONLY sample types selected here will be available later", - ) - markers = models.ManyToManyField(f"{an}.Marker", blank=True) expected_samples_delivery_date = models.DateField( help_text="When you plan to start delivering the samples" ) @@ -199,6 +189,16 @@ class Genrequest(models.Model): # type: ignore[django-manager-missing] help_text="This helps the Lab estimating the workload, " + "provide how many samples you're going to deliver", ) + species = models.ManyToManyField( + f"{an}.Species", blank=True, related_name="genrequests" + ) + sample_types = models.ManyToManyField( + f"{an}.SampleType", + blank=True, + help_text="samples you plan to deliver, you can choose more than one. " + + "ONLY sample types selected here will be available later", + ) + markers = models.ManyToManyField(f"{an}.Marker", blank=True) created_at = models.DateTimeField(auto_now_add=True) last_modified_at = models.DateTimeField(auto_now=True) From dbdb21bc6e42eb86bb92d25c829cd75c45de9700 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Tue, 1 Jul 2025 16:14:02 +0200 Subject: [PATCH 62/99] Improve EquipmentTypeAdmin. (#169) --- src/genlab_bestilling/admin.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index e823cff2..2d2f16fb 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -184,9 +184,18 @@ class AnalysisTypeAdmin(ModelAdmin): @admin.register(EquipmentType) class EquipmentTypeAdmin(ModelAdmin): - list_display = ["name", "unit"] - list_filter = ["unit"] - search_fields = ["name"] + M = EquipmentType + list_display = [M.name.field.name, M.unit.field.name] + + search_help_text = "Search for equipment type name" + search_fields = [M.name.field.name] + + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.unit.field.name, unfold_filters.FieldTextFilter), + ] + list_filter_submit = True + list_filter_sheet = False @admin.register(EquipmentBuffer) From 7b25b2bd596aeeae6a4177fe46f51e154be74856 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Tue, 1 Jul 2025 16:20:28 +0200 Subject: [PATCH 63/99] Improve EquimentOrderQuantityAdmin. (#171) --- src/genlab_bestilling/admin.py | 30 ++++++++++++++++++- ...016_alter_equimentorderquantity_options.py | 16 ++++++++++ src/genlab_bestilling/models.py | 3 ++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/genlab_bestilling/migrations/0016_alter_equimentorderquantity_options.py diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 2d2f16fb..3fe0c062 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -206,7 +206,35 @@ class EquipmentBufferAdmin(ModelAdmin): @admin.register(EquimentOrderQuantity) -class EquimentOrderQuantityAdmin(ModelAdmin): ... +class EquimentOrderQuantityAdmin(ModelAdmin): + M = EquimentOrderQuantity + list_display = [ + M.id.field.name, + M.equipment.field.name, + M.order.field.name, + M.buffer.field.name, + M.buffer_quantity.field.name, + M.quantity.field.name, + ] + search_help_text = "Search for id" + search_fields = [ + M.id.field.name, + ] + list_filter = [ + (M.id.field.name, unfold_filters.SingleNumericFilter), + (M.equipment.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.order.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.buffer.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + (M.buffer_quantity.field.name, unfold_filters.RangeNumericFilter), + (M.quantity.field.name, unfold_filters.RangeNumericFilter), + ] + autocomplete_fields = [ + M.equipment.field.name, + M.order.field.name, + M.buffer.field.name, + ] + list_filter_sheet = False + list_filter_submit = True @admin.register(EquipmentOrder) diff --git a/src/genlab_bestilling/migrations/0016_alter_equimentorderquantity_options.py b/src/genlab_bestilling/migrations/0016_alter_equimentorderquantity_options.py new file mode 100644 index 00000000..42f6ce42 --- /dev/null +++ b/src/genlab_bestilling/migrations/0016_alter_equimentorderquantity_options.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.3 on 2025-07-01 14:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0015_alter_samplemarkeranalysis_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="equimentorderquantity", + options={"verbose_name_plural": "Equipment order quantities"}, + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 7e1d4699..e15d85b9 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -359,6 +359,9 @@ class EquimentOrderQuantity(models.Model): objects = managers.EquipmentOrderQuantityQuerySet.as_manager() + class Meta: + verbose_name_plural = "Equipment order quantities" + class EquipmentOrder(Order): needs_guid = models.BooleanField() # TODO: default? From 43dec1fdebcca612e5c103f1820c3f441cec52c1 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Wed, 2 Jul 2025 13:35:10 +0200 Subject: [PATCH 64/99] Assign staff to order (#165) --- src/fixtures/users.json | 72 ++++++++++++++----- .../0017_order_responsible_staff.py | 24 +++++++ src/genlab_bestilling/models.py | 12 +++- src/staff/forms.py | 35 ++++++++- .../templates/staff/analysisorder_detail.html | 1 + src/staff/templates/staff/dashboard.html | 23 ++++++ .../staff/equipmentorder_detail.html | 2 + .../staff/extractionorder_detail.html | 3 + .../templates/staff/order_staff_edit.html | 46 ++++++++++++ src/staff/urls.py | 6 ++ src/staff/views.py | 49 ++++++++++++- 11 files changed, 252 insertions(+), 21 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0017_order_responsible_staff.py create mode 100644 src/staff/templates/staff/order_staff_edit.html diff --git a/src/fixtures/users.json b/src/fixtures/users.json index 52c65687..00469fed 100644 --- a/src/fixtures/users.json +++ b/src/fixtures/users.json @@ -1,19 +1,57 @@ [ - { - "model": "users.user", - "pk": 1, - "fields": { - "password": "pbkdf2_sha256$180000$10jDVElGx6nr$0o2RbVZhcE/BAHG6pCvAX4DI8V4mjIHgnN0pdNOKkr8=", - "last_login": "2023-05-27T09:22:46.831Z", - "is_superuser": true, - "first_name": "", - "last_name": "", - "email": "admin@nina.no", - "is_staff": true, - "is_active": true, - "date_joined": "2023-05-27T08:35:08.845Z", - "groups": [1], - "user_permissions": [] - } + { + "model": "users.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$180000$10jDVElGx6nr$0o2RbVZhcE/BAHG6pCvAX4DI8V4mjIHgnN0pdNOKkr8=", + "last_login": "2023-05-27T09:22:46.831Z", + "is_superuser": true, + "first_name": "", + "last_name": "", + "email": "admin@nina.no", + "is_staff": true, + "is_active": true, + "date_joined": "2023-05-27T08:35:08.845Z", + "groups": [ + 1 + ], + "user_permissions": [] } - ] + }, + { + "model": "users.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$180000$10jDVElGx6nr$0o2RbVZhcE/BAHG6pCvAX4DI8V4mjIHgnN0pdNOKkr8=", + "last_login": "2023-05-27T09:22:46.831Z", + "is_superuser": false, + "first_name": "Kari", + "last_name": "Nordmann", + "email": "kari.nordmann@norge.no", + "is_staff": true, + "is_active": true, + "date_joined": "2023-05-27T08:35:08.845Z", + "groups": [ + 1 + ], + "user_permissions": [] + } + }, + { + "model": "users.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$180000$10jDVElGx6nr$0o2RbVZhcE/BAHG6pCvAX4DI8V4mjIHgnN0pdNOKkr8=", + "last_login": "2023-05-27T09:22:46.831Z", + "is_superuser": false, + "first_name": "Ola", + "last_name": "Nordmann", + "email": "ola.nordmann@norge.no", + "is_staff": true, + "is_active": true, + "date_joined": "2023-05-27T08:35:08.845Z", + "groups": [], + "user_permissions": [] + } + } +] diff --git a/src/genlab_bestilling/migrations/0017_order_responsible_staff.py b/src/genlab_bestilling/migrations/0017_order_responsible_staff.py new file mode 100644 index 00000000..fb1c2054 --- /dev/null +++ b/src/genlab_bestilling/migrations/0017_order_responsible_staff.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.3 on 2025-07-02 06:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0016_alter_equimentorderquantity_options"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="responsible_staff", + field=models.ManyToManyField( + help_text="Staff members responsible for this order", + related_name="responsible_orders", + to=settings.AUTH_USER_MODEL, + verbose_name="Responsible staff", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index e15d85b9..95e27562 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -271,6 +271,12 @@ class OrderStatus(models.TextChoices): blank=True, help_text="Email to contact with questions about this order", ) + responsible_staff = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name="responsible_orders", + verbose_name="Responsible staff", + help_text="Staff members responsible for this order", + ) tags = TaggableManager(blank=True) objects = managers.OrderManager() @@ -635,14 +641,16 @@ def has_error(self) -> bool: "GUID, Sample Name, Sample Type, Species and Year are required" ) - if self.order.genrequest.area.location_mandatory: # type: ignore[union-attr] # FIXME: Order can be None. + # type: ignore[union-attr] # FIXME: Order can be None. + if self.order.genrequest.area.location_mandatory: if not self.location_id: raise ValidationError("Location is required") # ensure that location is correct for the selected species elif ( self.species.location_type and self.species.location_type_id - not in self.location.types.values_list("id", flat=True) # type: ignore[union-attr] # FIXME: Order can be None. + # type: ignore[union-attr] # FIXME: Order can be None. + not in self.location.types.values_list("id", flat=True) ): raise ValidationError("Invalid location for the selected species") elif self.location_id and self.species.location_type_id: diff --git a/src/staff/forms.py b/src/staff/forms.py index d8962025..76e115d7 100644 --- a/src/staff/forms.py +++ b/src/staff/forms.py @@ -1,9 +1,42 @@ +from django import forms from django.forms import ModelForm +from formset.renderers.tailwind import FormRenderer -from genlab_bestilling.models import ExtractionPlate +from capps.users.models import User +from genlab_bestilling.models import ExtractionPlate, Order class ExtractionPlateForm(ModelForm): class Meta: model = ExtractionPlate fields = ("name",) + + +class OrderStaffForm(forms.Form): + default_renderer = FormRenderer(field_css_classes="mb-3") + + responsible_staff = forms.MultipleChoiceField( + label="Ansvarlige", + widget=forms.CheckboxSelectMultiple, + help_text="Velg hvilke ansatte som er ansvarlige for denne bestillingen.", + required=False, + ) + + def __init__(self, *args, order: Order | None = None, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["responsible_staff"].choices = self.get_all_staff() + + if order: + self.fields["responsible_staff"].initial = [ + user.id for user in self.get_assigned_staff(order) + ] + + def get_assigned_staff(self, order: Order) -> list[User]: + return list(order.responsible_staff.all()) + + def get_all_staff(self) -> list[tuple[int, str]]: + return [ + (user.id, f"{user.first_name} {user.last_name}") + for user in User.objects.filter(groups__name="genlab").all() + ] diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 521077b5..c1703262 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -27,6 +27,7 @@
Samples to analyze
back Samples + Assign staff {% if object.status == object.OrderStatus.DELIVERED %}
{% url 'staff:order-to-draft' pk=object.id as to_draft_url %} diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 9c687b33..46797bc1 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -56,4 +56,27 @@

Urgent orders

No urgent orders found.

{% endif %} + + {% if assigned_orders|length > 0 %} +
+

My orders ({{ assigned_orders|length }})

+ + {% for order in assigned_orders %} + {% if order.polymorphic_ctype.model == 'analysisorder' %} +

{{ order }} - {{ order.name }}

+ {% elif order.polymorphic_ctype.model == 'equipmentorder' %} +

{{ order }} - {{ order.name }}

+ {% elif order.polymorphic_ctype.model == 'extractionorder' %} +

{{ order }} - {{ order.name }}

+ {% else %} +

{{ order }} - {{ order.name }}

+ {% endif %} + {% endfor %} +
+ {% else %} +
+

Assigned orders

+

No assigned orders found.

+
+ {% endif %} {% endblock %} diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index bb7bb54a..4969abaf 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -33,6 +33,8 @@
Requested Equipment
{% /table %}
+ back + Assign staff {% comment %} {% if object.status == 'draft' %} Edit diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index 08ae63cc..33cf66c6 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -27,6 +27,8 @@
Delivered Samples
back Samples + Assign staff + {% if object.status == object.OrderStatus.DELIVERED %}
{% url 'staff:order-manually-checked' pk=object.id as confirm_check_url %} @@ -36,6 +38,7 @@
Delivered Samples
{% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} {% endif %} + {% if object.next_status %} {% url 'staff:order-to-next-status' pk=object.id as to_next_status_url %} {% with "Set as "|add:object.next_status as btn_name %} diff --git a/src/staff/templates/staff/order_staff_edit.html b/src/staff/templates/staff/order_staff_edit.html new file mode 100644 index 00000000..b3b28df5 --- /dev/null +++ b/src/staff/templates/staff/order_staff_edit.html @@ -0,0 +1,46 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags core %} + +{% block css %} + +{% endblock %} + +{% block content %} +

Manage Responsible Staff - {{ object }}

+ + + +
+

Assign Staff to Order

+
+ {% csrf_token %} + +
+ {{ form.responsible_staff.label_tag }} + +
{{ form.responsible_staff }}
+ + {% if form.responsible_staff.help_text %} +

{{ form.responsible_staff.help_text }}

+ {% endif %} + + {% if form.responsible_staff.errors %} +
{{ form.responsible_staff.errors }}
+ {% endif %} +
+ +
+ +
+ +
+{% endblock %} diff --git a/src/staff/urls.py b/src/staff/urls.py index a3dbf599..73a25ad2 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -14,6 +14,7 @@ ManaullyCheckedOrderActionView, OrderAnalysisSamplesListView, OrderExtractionSamplesListView, + OrderStaffEditView, OrderToDraftActionView, OrderToNextStatusActionView, ProjectDetailView, @@ -68,6 +69,11 @@ ManaullyCheckedOrderActionView.as_view(), name="order-manually-checked", ), + path( + "orders//add-staff/", + OrderStaffEditView.as_view(), + name="order-add-staff", + ), path( "orders/extraction//samples/", OrderExtractionSamplesListView.as_view(), diff --git a/src/staff/views.py b/src/staff/views.py index 5bf21248..0c740351 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -32,7 +32,7 @@ SampleFilter, SampleMarkerOrderFilter, ) -from .forms import ExtractionPlateForm +from .forms import ExtractionPlateForm, OrderStaffForm from .tables import ( AnalysisOrderTable, EquipmentOrderTable, @@ -73,6 +73,9 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: delivered_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) context["delivered_orders"] = delivered_orders + context["assigned_orders"] = self.request.user.responsible_orders.filter( + status__in=[Order.OrderStatus.DELIVERED, Order.OrderStatus.PROCESSING] + ).order_by("-created_at") context["now"] = now() return context @@ -281,6 +284,50 @@ def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) +class OrderStaffEditView(StaffMixin, SingleObjectMixin, TemplateView): + model = Order + form_class = OrderStaffForm + template_name = "staff/order_staff_edit.html" + + def get_queryset(self) -> models.QuerySet[Order]: + return super().get_queryset().filter(status=Order.OrderStatus.DELIVERED) + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + form = self.form_class(request.POST, order=self.object) + + if form.is_valid(): + responsible_staff = form.cleaned_data.get("responsible_staff", []) + self.object.responsible_staff.set(responsible_staff) + + messages.add_message( + request, + messages.SUCCESS, + "Staff assignment updated successfully", + ) + + return HttpResponseRedirect(self.get_success_url()) + + return self.render_to_response(self.get_context_data(form=form)) + + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + context["object"] = self.object + context["form"] = self.form_class(order=self.object) + + return context + + def get_success_url(self) -> str: + return reverse_lazy( + f"staff:order-{self.object.get_type()}-detail", + kwargs={"pk": self.object.pk}, + ) + + class OrderToDraftActionView(SingleObjectMixin, ActionView): model = Order From da6e9b69f5ea19dff2b7c7997903ad38f0738e24 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:02:48 +0200 Subject: [PATCH 65/99] Improve EquipmentBufferAdmin. (#173) --- src/genlab_bestilling/admin.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 3fe0c062..3b25900c 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -200,9 +200,18 @@ class EquipmentTypeAdmin(ModelAdmin): @admin.register(EquipmentBuffer) class EquipmentBufferAdmin(ModelAdmin): - list_display = ["name", "unit"] - list_filter = ["unit"] - search_fields = ["name"] + M = EquipmentBuffer + list_display = [M.name.field.name, M.unit.field.name] + + search_help_text = "Search for equipment buffer name" + search_fields = [M.name.field.name] + + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.unit.field.name, unfold_filters.FieldTextFilter), + ] + list_filter_submit = True + list_filter_sheet = False @admin.register(EquimentOrderQuantity) From 5b7da41a9860f6cdeb9d71639d3b6c07de906db7 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:34:37 +0200 Subject: [PATCH 66/99] Add verbose_name and help_text to expected_delivery_date attribute in AnalysisOrder (#176) Co-authored-by: Morten Madsen Lyngstad --- ...er_analysisorder_expected_delivery_date.py | 22 +++++++++++++++++++ src/genlab_bestilling/models.py | 2 ++ 2 files changed, 24 insertions(+) create mode 100644 src/genlab_bestilling/migrations/0018_alter_analysisorder_expected_delivery_date.py diff --git a/src/genlab_bestilling/migrations/0018_alter_analysisorder_expected_delivery_date.py b/src/genlab_bestilling/migrations/0018_alter_analysisorder_expected_delivery_date.py new file mode 100644 index 00000000..3bb52ca6 --- /dev/null +++ b/src/genlab_bestilling/migrations/0018_alter_analysisorder_expected_delivery_date.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.3 on 2025-07-03 08:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0017_order_responsible_staff"), + ] + + operations = [ + migrations.AlterField( + model_name="analysisorder", + name="expected_delivery_date", + field=models.DateField( + blank=True, + help_text="When you need to get the results", + null=True, + verbose_name="Requested analysis result deadline", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 95e27562..f3756a15 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -478,6 +478,8 @@ class AnalysisOrder(Order): expected_delivery_date = models.DateField( null=True, blank=True, + verbose_name="Requested analysis result deadline", + help_text="When you need to get the results", ) @property From 2398cefcd3ca1be30d92d7054495b817dfe96c6f Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Fri, 4 Jul 2025 08:44:10 +0200 Subject: [PATCH 67/99] Add status columns and batch update for samples (#141) * Add new page with status columns for samples Co-authored-by: Morten Madsen Lyngstad --- src/genlab_bestilling/admin.py | 10 ++ ...estatus_samplestatusassignment_and_more.py | 107 ++++++++++++++++++ src/genlab_bestilling/models.py | 51 +++++++++ src/staff/tables.py | 38 +++++++ src/staff/templates/staff/sample_filter.html | 1 + src/staff/templates/staff/sample_lab.html | 40 +++++++ src/staff/urls.py | 6 + src/staff/views.py | 98 ++++++++++++++++ 8 files changed, 351 insertions(+) create mode 100644 src/genlab_bestilling/migrations/0019_isolationmethod_samplestatus_samplestatusassignment_and_more.py create mode 100644 src/staff/templates/staff/sample_lab.html diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 3b25900c..04f302ac 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -21,6 +21,8 @@ Organization, Sample, SampleMarkerAnalysis, + SampleStatus, + SampleStatusAssignment, SampleType, Species, ) @@ -522,3 +524,11 @@ class AnalysisResultAdmin(ModelAdmin): M.last_modified_at.field.name, M.created_at.field.name, ] + + +@admin.register(SampleStatus) +class SampleStatusAdmin(ModelAdmin): ... + + +@admin.register(SampleStatusAssignment) +class SampleStatusAssignmentAdmin(ModelAdmin): ... diff --git a/src/genlab_bestilling/migrations/0019_isolationmethod_samplestatus_samplestatusassignment_and_more.py b/src/genlab_bestilling/migrations/0019_isolationmethod_samplestatus_samplestatusassignment_and_more.py new file mode 100644 index 00000000..79cdcff0 --- /dev/null +++ b/src/genlab_bestilling/migrations/0019_isolationmethod_samplestatus_samplestatusassignment_and_more.py @@ -0,0 +1,107 @@ +# Generated by Django 5.2.3 on 2025-07-03 08:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0018_alter_analysisorder_expected_delivery_date"), + ] + + operations = [ + migrations.CreateModel( + name="IsolationMethod", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ], + ), + migrations.CreateModel( + name="SampleStatus", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("weight", models.IntegerField(default=0)), + ( + "area", + models.ForeignKey( + help_text="The area this status is related to.", + on_delete=django.db.models.deletion.CASCADE, + related_name="area_statuses", + to="genlab_bestilling.area", + ), + ), + ], + ), + migrations.CreateModel( + name="SampleStatusAssignment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("assigned_at", models.DateTimeField(auto_now_add=True)), + ( + "order", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sample_status_assignments", + to="genlab_bestilling.order", + ), + ), + ( + "sample", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sample_status_assignments", + to="genlab_bestilling.sample", + ), + ), + ( + "status", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="status_assignments", + to="genlab_bestilling.samplestatus", + ), + ), + ], + options={ + "unique_together": {("sample", "status", "order")}, + }, + ), + migrations.AddField( + model_name="sample", + name="assigned_statuses", + field=models.ManyToManyField( + blank=True, + related_name="samples", + through="genlab_bestilling.SampleStatusAssignment", + to="genlab_bestilling.samplestatus", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index f3756a15..63d3b5b9 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -586,6 +586,12 @@ class Sample(models.Model): extractions = models.ManyToManyField(f"{an}.ExtractionPlate", blank=True) parent = models.ForeignKey("self", on_delete=models.PROTECT, null=True, blank=True) + assigned_statuses = models.ManyToManyField( + f"{an}.SampleStatus", + through=f"{an}.SampleStatusAssignment", + related_name="samples", + blank=True, + ) objects = managers.SampleQuerySet.as_manager() @@ -678,6 +684,51 @@ def has_error(self) -> bool: # assignee (one or plus?) +class SampleStatus(models.Model): + name = models.CharField(max_length=255) + weight = models.IntegerField( + default=0, + ) + area = models.ForeignKey( + f"{an}.Area", + on_delete=models.CASCADE, + related_name="area_statuses", + help_text="The area this status is related to.", + ) + + +class SampleStatusAssignment(models.Model): + sample = models.ForeignKey( + f"{an}.Sample", + on_delete=models.CASCADE, + related_name="sample_status_assignments", + ) + status = models.ForeignKey( + f"{an}.SampleStatus", + on_delete=models.CASCADE, + related_name="status_assignments", + ) + order = models.ForeignKey( + f"{an}.Order", + on_delete=models.CASCADE, + related_name="sample_status_assignments", + null=True, + blank=True, + ) + + assigned_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("sample", "status", "order") + + +class IsolationMethod(models.Model): + name = models.CharField(max_length=255, unique=True) + + def __str__(self) -> str: + return self.name + + # Some extracts can be placed in multiple wells class ExtractPlatePosition(models.Model): plate = models.ForeignKey( diff --git a/src/staff/tables.py b/src/staff/tables.py index ef801b1e..f87ad306 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -149,6 +149,44 @@ def render_plate_positions(self, value: Any) -> str: return "" +def create_sample_table(base_fields: list[str] | None = None) -> type[tables.Table]: + class CustomSampleTable(tables.Table): + """ + This shows a checkbox in the header. + To display text in the header alongside the checkbox + override the header-property in the CheckBoxColumn class. + """ + + checked = tables.CheckBoxColumn( + accessor="pk", + orderable=True, + attrs={ + "th__input": { + "id": "select-all-checkbox", + }, + "td__input": { + "name": "checked", + }, + }, + empty_values=(), + verbose_name="Mark", + ) + + for field in base_fields: + locals()[field] = tables.BooleanColumn( + verbose_name=field.capitalize(), + orderable=True, + yesno="✔,-", + default=False, + ) + + class Meta: + model = Sample + fields = ["checked", "genlab_id"] + list(base_fields) + + return CustomSampleTable + + class OrderExtractionSampleTable(SampleBaseTable): class Meta(SampleBaseTable.Meta): fields = SampleBaseTable.Meta.fields diff --git a/src/staff/templates/staff/sample_filter.html b/src/staff/templates/staff/sample_filter.html index 7fc5448f..221b70d9 100644 --- a/src/staff/templates/staff/sample_filter.html +++ b/src/staff/templates/staff/sample_filter.html @@ -9,6 +9,7 @@ {% endif %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/sample_lab.html b/src/staff/templates/staff/sample_lab.html new file mode 100644 index 00000000..6994df77 --- /dev/null +++ b/src/staff/templates/staff/sample_lab.html @@ -0,0 +1,40 @@ +{% extends "staff/base.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} + +{% block content %} +

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

+{% block page-inner %} +
+ back +
+
+ {% csrf_token %} + {% for status in statuses %} + + {% endfor %} + + {% render_table table %} + +{% endblock page-inner %} + +{% endblock %} + +{% block body_javascript %} + + +{% endblock body_javascript %} diff --git a/src/staff/urls.py b/src/staff/urls.py index 73a25ad2..28be320b 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -21,6 +21,7 @@ ProjectListView, ProjectValidateActionView, SampleDetailView, + SampleLabView, SampleReplicaActionView, SamplesListView, ) @@ -79,6 +80,11 @@ OrderExtractionSamplesListView.as_view(), name="order-extraction-samples", ), + path( + "orders/extraction//samples/lab", + SampleLabView.as_view(), + name="order-extraction-samples-lab", + ), path( "orders/analysis//samples/", OrderAnalysisSamplesListView.as_view(), diff --git a/src/staff/views.py b/src/staff/views.py index 0c740351..6d4da586 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -1,3 +1,4 @@ +from collections import defaultdict from typing import Any from django.contrib import messages @@ -5,6 +6,7 @@ from django.db import models from django.forms import Form from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy from django.utils.timezone import now from django.utils.translation import gettext as _ @@ -21,6 +23,8 @@ Order, Sample, SampleMarkerAnalysis, + SampleStatus, + SampleStatusAssignment, ) from nina.models import Project from shared.views import ActionView @@ -42,6 +46,7 @@ PlateTable, ProjectTable, SampleTable, + create_sample_table, ) @@ -246,6 +251,99 @@ class SampleDetailView(StaffMixin, DetailView): model = Sample +class SampleLabView(StaffMixin, TemplateView): + disable_pagination = False + template_name = "staff/sample_lab.html" + + def get_order(self) -> ExtractionOrder: + if not hasattr(self, "_order"): + self._order = get_object_or_404(ExtractionOrder, pk=self.kwargs["pk"]) + return self._order + + def get_data(self) -> list[Sample]: + order = self.get_order() + samples = Sample.objects.filter(order=order) + sample_status = SampleStatus.objects.filter( + area__name=samples.first().order.genrequest.area + if samples.exists() + else None + ) + + # Fetch all SampleStatusAssignment entries related to the current order + sample_assignments = SampleStatusAssignment.objects.filter( + order_id=order.id + ).select_related("status") + + # Build a lookup: {sample_id: set of status names} + # This allows us to check status presence without querying per sample + sample_status_map = defaultdict(set) + for assignment in sample_assignments: + sample_status_map[assignment.sample_id].add(assignment.status.name) + + # Annotate each sample instance with boolean flags per status + # Equivalent to: sample.status_name = True/False + # based on whether the sample has that status + for sample in samples: + status_names = sample_status_map.get(sample.id, set()) + for status in sample_status: + setattr(sample, status.name, status.name in status_names) + + return samples + + def get_base_fields(self) -> list[str]: + return SampleStatus.objects.filter( + area__name=self.get_order().genrequest.area + ).values_list("name", flat=True) + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + context["order"] = self.get_order() + context["statuses"] = SampleStatus.objects.filter( + area=context["order"].genrequest.area + ) + table_class = create_sample_table(base_fields=self.get_base_fields()) + context["table"] = table_class( + self.get_data(), + ) + return context + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + status_name = request.POST.get("status") + selected_ids = request.POST.getlist("checked") + + redirect_url = self.request.path + + if not selected_ids or not status_name: + messages.error(request, "No samples or status selected.") + return HttpResponseRedirect(redirect_url) + + order = self.get_order() + + try: + # Get status based on name and area to ensure only one status is returned + status = SampleStatus.objects.get( + name=status_name, area=order.genrequest.area + ) + except SampleStatus.DoesNotExist: + messages.error(request, f"Status '{status_name}' not found.") + return HttpResponseRedirect(redirect_url) + + samples = Sample.objects.filter(id__in=selected_ids) + + # Create status assignments if not existing + for sample in samples: + SampleStatusAssignment.objects.get_or_create( + sample=sample, + status=status, + order=order, + ) + + messages.success( + request, f"{len(samples)} samples updated with status '{status_name}'." + ) + return HttpResponseRedirect(redirect_url) + + class ManaullyCheckedOrderActionView(SingleObjectMixin, ActionView): model = ExtractionOrder From 4f5f0e14771f2d1799addc59ae315764f1de5872 Mon Sep 17 00:00:00 2001 From: Emil Telstad <22004178+emilte@users.noreply.github.com> Date: Fri, 4 Jul 2025 15:30:36 +0200 Subject: [PATCH 68/99] Add mypy alias. (#193) --- aliases.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aliases.sh b/aliases.sh index 9169e27d..b6c140d1 100755 --- a/aliases.sh +++ b/aliases.sh @@ -6,6 +6,8 @@ alias dpcli_prod="docker compose --profile prod" alias djcli_dev="docker compose exec -it django-dev ./src/manage.py" alias djcli_prod="docker compose exec -it django ./src/manage.py" +alias run-mypy="uv run mypy ." + alias deps-sync="uv sync" alias deps-sync-prod="uv sync --profile prod --no-dev" alias deps-outdated="uv tree --outdated --depth 1" From 8a1edb0672abb0c84adfd8e416b9b03cc4dacca3 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 7 Jul 2025 08:45:40 +0200 Subject: [PATCH 69/99] Now shown the number of samples in an order, not the samples in the project. (#179) --- src/staff/tables.py | 13 +++++++++++-- src/staff/views.py | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index f87ad306..341002d5 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -53,13 +53,12 @@ class Meta: "genrequest__name", "genrequest__project", "genrequest__area", - "genrequest__expected_total_samples", "genrequest__samples_owner", "created_at", "last_modified_at", "is_urgent", ] - sequence = ("is_urgent", "status", "id") + sequence = ("is_urgent", "status", "id", "name") empty_text = "No Orders" order_by = ("-is_urgent", "last_modified_at", "created_at") @@ -95,6 +94,12 @@ class ExtractionOrderTable(OrderTable): empty_values=(), ) + sample_count = tables.Column( + accessor="sample_count", + verbose_name="Sample Count", + orderable=False, + ) + class Meta(OrderTable.Meta): model = ExtractionOrder fields = OrderTable.Meta.fields + [ @@ -105,6 +110,10 @@ class Meta(OrderTable.Meta): "return_samples", "pre_isolated", ] + sequence = OrderTable.Meta.sequence + ("sample_count",) + + def render_sample_count(self, record: Any) -> str: + return record.sample_count or "0" class EquipmentOrderTable(OrderTable): diff --git a/src/staff/views.py b/src/staff/views.py index 6d4da586..3582a83c 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -4,6 +4,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 from django.forms import Form from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 @@ -121,6 +122,7 @@ def get_queryset(self) -> models.QuerySet[ExtractionOrder]: "genrequest__area", ) .prefetch_related("species", "sample_types") + .annotate(sample_count=Count("samples")) ) From abaee707d49658844e2ba53686438c9aacc849b3 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 7 Jul 2025 09:15:58 +0200 Subject: [PATCH 70/99] Refactor SampleLabView to use a dedicated success URL for redirection after status updates (#190) Co-authored-by: Morten Madsen Lyngstad --- src/staff/views.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/staff/views.py b/src/staff/views.py index 3582a83c..7eeffb01 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -8,7 +8,7 @@ from django.forms import Form from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.utils.timezone import now from django.utils.translation import gettext as _ from django.views.generic import CreateView, DetailView, TemplateView @@ -309,15 +309,18 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: ) return context + def get_success_url(self) -> str: + return reverse( + "staff:order-extraction-samples-lab", kwargs={"pk": self.get_order().pk} + ) + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: status_name = request.POST.get("status") selected_ids = request.POST.getlist("checked") - redirect_url = self.request.path - if not selected_ids or not status_name: messages.error(request, "No samples or status selected.") - return HttpResponseRedirect(redirect_url) + return HttpResponseRedirect(self.get_success_url()) order = self.get_order() @@ -328,7 +331,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: ) except SampleStatus.DoesNotExist: messages.error(request, f"Status '{status_name}' not found.") - return HttpResponseRedirect(redirect_url) + return HttpResponseRedirect(self.get_success_url()) samples = Sample.objects.filter(id__in=selected_ids) @@ -343,7 +346,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: messages.success( request, f"{len(samples)} samples updated with status '{status_name}'." ) - return HttpResponseRedirect(redirect_url) + return HttpResponseRedirect(self.get_success_url()) class ManaullyCheckedOrderActionView(SingleObjectMixin, ActionView): From a626b2c5be524e5ecf6bb255115ae8d70c31e6fa Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 7 Jul 2025 09:29:18 +0200 Subject: [PATCH 71/99] 180 able to prioritize samples internally (#183) * Added a field for internal priority. Changes in column order so that prioritised shows up on top. * Fix for linter errors --- .../migrations/0020_sample_is_prioritised.py | 23 +++++++++++++++++++ src/genlab_bestilling/models.py | 4 ++++ src/staff/tables.py | 8 +++++++ .../templates/staff/prioritise_flag.html | 8 +++++++ src/staff/views.py | 12 ++++++++++ 5 files changed, 55 insertions(+) create mode 100644 src/genlab_bestilling/migrations/0020_sample_is_prioritised.py create mode 100644 src/staff/templates/staff/prioritise_flag.html diff --git a/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py b/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py new file mode 100644 index 00000000..3c431782 --- /dev/null +++ b/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.3 on 2025-07-07 06:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "genlab_bestilling", + "0019_isolationmethod_samplestatus_samplestatusassignment_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="sample", + name="is_prioritised", + field=models.BooleanField( + default=False, + help_text="Check this box if the sample is prioritised for processing", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 63d3b5b9..4e5bff44 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -594,6 +594,10 @@ class Sample(models.Model): ) objects = managers.SampleQuerySet.as_manager() + is_prioritised = models.BooleanField( + default=False, + help_text="Check this box if the sample is prioritised for processing", + ) def __str__(self) -> str: return self.genlab_id or f"#SMP_{self.id}" diff --git a/src/staff/tables.py b/src/staff/tables.py index 341002d5..f5f8575d 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -133,6 +133,12 @@ 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="", + ) + class Meta: model = Sample fields = [ @@ -148,6 +154,8 @@ class Meta: "plate_positions", ] attrs = {"class": "w-full table-auto tailwind-table table-sm"} + sequence = ("is_prioritised", "genlab_id", "guid", "name", "species", "type") + order_by = ("-is_prioritised", "species", "genlab_id", "name") empty_text = "No Samples" diff --git a/src/staff/templates/staff/prioritise_flag.html b/src/staff/templates/staff/prioritise_flag.html new file mode 100644 index 00000000..43e42094 --- /dev/null +++ b/src/staff/templates/staff/prioritise_flag.html @@ -0,0 +1,8 @@ +
+ {% csrf_token %} + + + diff --git a/src/staff/views.py b/src/staff/views.py index 7eeffb01..9292a26b 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -195,6 +195,18 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: context["order"] = ExtractionOrder.objects.get(pk=self.kwargs.get("pk")) return context + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + sample_id = request.POST.get("sample_id") + + if sample_id: + sample = get_object_or_404(Sample, pk=sample_id) + sample.is_prioritised = not sample.is_prioritised + sample.save() + + return self.get( + request, *args, **kwargs + ) # Re-render the view with updated data + class OrderAnalysisSamplesListView(StaffMixin, SingleTableMixin, FilterView): table_pagination = False From a558293f40ec4149549471a72a4c49c8fb92a00b Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:04:36 +0200 Subject: [PATCH 72/99] 196 make a user interface for generating genlab ids (#197) * Frontend for generating genlab IDs * Fix linter newline errors * Minor changes (types, error function) --- src/genlab_bestilling/libs/genlabid.py | 6 ++- src/genlab_bestilling/models.py | 31 ++++++++++- src/genlab_bestilling/tasks.py | 14 ++++- src/staff/tables.py | 41 +++++++++++++- .../templates/staff/analysisorder_filter.html | 14 +++++ src/staff/templates/staff/base_filter.html | 22 ++++---- .../staff/equipmentorder_filter.html | 14 +++++ .../staff/extractionorder_filter.html | 14 +++++ .../staff/extractionplate_filter.html | 11 ++++ src/staff/templates/staff/project_filter.html | 14 +++++ src/staff/templates/staff/sample_filter.html | 29 +++++++++- .../staff/samplemarkeranalysis_filter.html | 12 +++++ src/staff/urls.py | 6 +++ src/staff/views.py | 54 +++++++++++++++++-- 14 files changed, 261 insertions(+), 21 deletions(-) diff --git a/src/genlab_bestilling/libs/genlabid.py b/src/genlab_bestilling/libs/genlabid.py index 32af0235..1616c471 100644 --- a/src/genlab_bestilling/libs/genlabid.py +++ b/src/genlab_bestilling/libs/genlabid.py @@ -63,7 +63,11 @@ def get_current_sequences(order_id: int | str) -> Any: return sequences -def generate(order_id: int | str) -> None: +def generate( + order_id: int | str, + sorting_order: list[str] | None = None, + selected_samples: list[Any] | None = None, +) -> None: """ wrapper to handle errors and reset the sequence to the current sequence value """ diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 4e5bff44..5a86aa2f 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -1,9 +1,10 @@ import uuid from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from django.conf import settings from django.db import models, transaction +from django.db.models import QuerySet from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -12,6 +13,9 @@ from rest_framework.exceptions import ValidationError from taggit.managers import TaggableManager +if TYPE_CHECKING: + from .models import Sample + from . import managers from .libs.helpers import position_to_coordinates @@ -301,6 +305,10 @@ def to_draft(self) -> None: def get_type(self) -> str: return "order" + @property + def filled_genlab_count(self) -> int: + return self.samples.filter(genlab_id__isnull=False).count() + @property def next_status(self) -> OrderStatus | None: current_index = self.STATUS_ORDER.index(self.status) @@ -461,6 +469,27 @@ def order_manually_checked(self) -> None: self.save() app.configure_task(name="generate-genlab-ids").defer(order_id=self.id) + def order_selected_checked( + self, + sorting_order: list[str] | None = None, + selected_samples: QuerySet["Sample"] | None = None, + ) -> None: + """ + Partially set the order as checked by the lab staff, + generate a genlab id for the samples selected + """ + self.internal_status = self.Status.CHECKED + self.status = self.OrderStatus.PROCESSING + self.save() + + selected_sample_names = list(selected_samples.values_list("id", flat=True)) + + app.configure_task(name="generate-genlab-ids").defer( + order_id=self.id, + sorting_order=sorting_order, + selected_samples=selected_sample_names, + ) + class AnalysisOrder(Order): samples = models.ManyToManyField( diff --git a/src/genlab_bestilling/tasks.py b/src/genlab_bestilling/tasks.py index 607f301b..d92b0a78 100644 --- a/src/genlab_bestilling/tasks.py +++ b/src/genlab_bestilling/tasks.py @@ -1,4 +1,6 @@ # from .libs.isolation import isolate +from typing import Any + from django.db.utils import OperationalError from procrastinate import RetryStrategy from procrastinate.contrib.django import app @@ -12,6 +14,14 @@ max_attempts=5, linear_wait=5, retry_exceptions={OperationalError} ), ) -def generate_ids(order_id: str | int) -> None: - generate_genlab_id(order_id=order_id) +def generate_ids( + order_id: int | str, + sorting_order: list[str] | None = None, + selected_samples: list[Any] | None = None, +) -> None: + generate_genlab_id( + order_id=order_id, + sorting_order=sorting_order, + selected_samples=selected_samples, + ) # isolate(order_id=order_id) diff --git a/src/staff/tables.py b/src/staff/tables.py index f5f8575d..ebb149fd 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -1,6 +1,8 @@ from typing import Any import django_tables2 as tables +from django.db.models import IntegerField +from django.db.models.functions import Cast from django.utils.safestring import mark_safe from genlab_bestilling.models import ( @@ -139,6 +141,17 @@ class SampleBaseTable(tables.Table): verbose_name="", ) + checked = tables.CheckBoxColumn( + attrs={ + "th__input": {"type": "checkbox", "id": "select-all-checkbox"}, + }, + accessor="pk", + orderable=False, + empty_values=(), + ) + + name = tables.Column(order_by=("name_as_int",)) + class Meta: model = Sample fields = [ @@ -154,17 +167,41 @@ class Meta: "plate_positions", ] attrs = {"class": "w-full table-auto tailwind-table table-sm"} - sequence = ("is_prioritised", "genlab_id", "guid", "name", "species", "type") - order_by = ("-is_prioritised", "species", "genlab_id", "name") + sequence = ( + "checked", + "is_prioritised", + "genlab_id", + "guid", + "name", + "species", + "type", + ) + order_by = ( + "-is_prioritised", + "species", + "genlab_id", + "name_as_int", + ) empty_text = "No Samples" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if hasattr(self.data, "data"): + self.data.data = self.data.data.annotate( + name_as_int=Cast("name", output_field=IntegerField()) + ) + def render_plate_positions(self, value: Any) -> str: if value: return ", ".join([str(v) for v in value.all()]) return "" + def render_checked(self, record: Any) -> str: + return mark_safe(f'') # noqa: S308 + def create_sample_table(base_fields: list[str] | None = None) -> type[tables.Table]: class CustomSampleTable(tables.Table): diff --git a/src/staff/templates/staff/analysisorder_filter.html b/src/staff/templates/staff/analysisorder_filter.html index 0a0d6946..a0ac89a4 100644 --- a/src/staff/templates/staff/analysisorder_filter.html +++ b/src/staff/templates/staff/analysisorder_filter.html @@ -1,5 +1,19 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Analysis Orders {% endblock page-title %} + +{% block page-inner %} +
+
+ {{ filter.form | crispy }} +
+ + + +{% render_table table %} + +{% endblock page-inner %} diff --git a/src/staff/templates/staff/base_filter.html b/src/staff/templates/staff/base_filter.html index 66b774cf..96f0cd28 100644 --- a/src/staff/templates/staff/base_filter.html +++ b/src/staff/templates/staff/base_filter.html @@ -5,19 +5,23 @@ {% block content %}

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

{% block page-inner %}{% endblock page-inner %} - -
-
- {{ filter.form | crispy }} -
- - - - {% render_table table %} {% endblock %} {% block body_javascript %} {{ block.super }} {{ filter.form.media }} + {% endblock body_javascript %} diff --git a/src/staff/templates/staff/equipmentorder_filter.html b/src/staff/templates/staff/equipmentorder_filter.html index 25b2704d..434f0c13 100644 --- a/src/staff/templates/staff/equipmentorder_filter.html +++ b/src/staff/templates/staff/equipmentorder_filter.html @@ -1,5 +1,19 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Equipment Orders {% endblock page-title %} + +{% block page-inner %} +
+
+ {{ filter.form | crispy }} +
+ + + +{% render_table table %} + +{% endblock page-inner %} diff --git a/src/staff/templates/staff/extractionorder_filter.html b/src/staff/templates/staff/extractionorder_filter.html index f0ff30b7..e2c1e8d9 100644 --- a/src/staff/templates/staff/extractionorder_filter.html +++ b/src/staff/templates/staff/extractionorder_filter.html @@ -1,5 +1,19 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Extraction Orders {% endblock page-title %} + +{% block page-inner %} +
+
+ {{ filter.form | crispy }} +
+ + + +{% 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 877fca41..26e046dd 100644 --- a/src/staff/templates/staff/extractionplate_filter.html +++ b/src/staff/templates/staff/extractionplate_filter.html @@ -1,4 +1,6 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Extraction plates @@ -8,4 +10,13 @@ + +
+
+ {{ filter.form | crispy }} +
+ + + +{% 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 22279374..b0434b4c 100644 --- a/src/staff/templates/staff/project_filter.html +++ b/src/staff/templates/staff/project_filter.html @@ -1,5 +1,19 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Projects {% endblock page-title %} + +{% block page-inner %} +
+
+ {{ filter.form | crispy }} +
+ + + +{% 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 221b70d9..55f3d5d8 100644 --- a/src/staff/templates/staff/sample_filter.html +++ b/src/staff/templates/staff/sample_filter.html @@ -1,7 +1,22 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} -{% if order %}{{ order }} - Samples{% else %}Samples{% endif %} +
+
+ {% if order %} + {{ order }} - Samples + {% else %} + Samples + {% endif %} +
+ {% if order %} +
+ {{ order.filled_genlab_count }} / {{ order.samples.count }} Genlabs generated +
+ {% endif %} +
{% endblock page-title %} {% block page-inner %} @@ -11,5 +26,17 @@ Download CSV Lab
+ +
+ {% csrf_token %} + + + +
This page is under development. The genlab IDs will generate for all, and without sorting as per now.
+ + {% render_table table %} + {% endif %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/samplemarkeranalysis_filter.html b/src/staff/templates/staff/samplemarkeranalysis_filter.html index 4f81078d..227e0a18 100644 --- a/src/staff/templates/staff/samplemarkeranalysis_filter.html +++ b/src/staff/templates/staff/samplemarkeranalysis_filter.html @@ -1,4 +1,6 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} {% if order %}{{ order }} - Samples{% else %}Samples{% endif %} @@ -10,4 +12,14 @@ back
{% endif %} + +
+
+ {{ filter.form | crispy }} +
+ + + +{% render_table table %} + {% endblock page-inner %} diff --git a/src/staff/urls.py b/src/staff/urls.py index 28be320b..6dc62299 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -11,6 +11,7 @@ ExtractionPlateCreateView, ExtractionPlateDetailView, ExtractionPlateListView, + GenerateGenlabIDsView, ManaullyCheckedOrderActionView, OrderAnalysisSamplesListView, OrderExtractionSamplesListView, @@ -85,6 +86,11 @@ SampleLabView.as_view(), name="order-extraction-samples-lab", ), + path( + "orders/extraction//samples/generate-genlab-ids/", + GenerateGenlabIDsView.as_view(), + name="generate-genlab-ids", + ), path( "orders/analysis//samples/", OrderAnalysisSamplesListView.as_view(), diff --git a/src/staff/views.py b/src/staff/views.py index 9292a26b..dbcddfdd 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -381,11 +381,7 @@ def form_valid(self, form: Form) -> HttpResponse: _("The order was checked, GenLab IDs will be generated"), ) except Exception as e: - messages.add_message( - self.request, - messages.ERROR, - f"Error: {str(e)}", - ) + messages.error(self.request, f"Error: {str(e)}") return super().form_valid(form) @@ -513,6 +509,54 @@ def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) +class GenerateGenlabIDsView( + SingleObjectMixin, StaffMixin, SingleTableMixin, FilterView +): + model = ExtractionOrder + + def get_object(self) -> ExtractionOrder: + return ExtractionOrder.objects.get(pk=self.kwargs["pk"]) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + selected_ids = request.POST.getlist("checked") + + if not selected_ids: + messages.error(request, "No samples were selected.") + return HttpResponseRedirect(self.get_return_url()) + + sort_param = request.POST.get("sort", "") + sorting_order = [s.strip() for s in sort_param.split(",") if s.strip()] + + selected_samples = Sample.objects.filter(pk__in=selected_ids) + + if sorting_order: + selected_samples = selected_samples.order_by(*sorting_order) + + try: + self.object.order_selected_checked( + sorting_order=sorting_order, selected_samples=selected_samples + ) + messages.add_message( + request, + messages.SUCCESS, + _(f"Genlab IDs generated for {selected_samples.count()} samples."), + ) + except Exception as e: + messages.add_message( + request, + messages.ERROR, + f"Error: {str(e)}", + ) + + return HttpResponseRedirect(self.get_return_url()) + + def get_return_url(self) -> str: + return reverse_lazy( + "staff:order-extraction-samples", kwargs={"pk": self.object.pk} + ) + + class ExtractionPlateCreateView(StaffMixin, CreateView): model = ExtractionPlate form_class = ExtractionPlateForm From f9239d15f1acd4fc41c6885a59d68f4db38e8702 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:44:48 +0200 Subject: [PATCH 73/99] Always an option to convert to draft (#199) * Always an option to convert to draft * Minor linter fix --- src/staff/templates/staff/analysisorder_detail.html | 2 +- src/staff/templates/staff/extractionorder_detail.html | 9 +++++---- src/staff/views.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index c1703262..9dddec5e 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -28,7 +28,7 @@
Samples to analyze
back Samples Assign staff - {% if object.status == object.OrderStatus.DELIVERED %} + {% 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="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index 33cf66c6..ac5f472f 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -29,15 +29,16 @@
Delivered Samples
Samples Assign staff +
{% if object.status == object.OrderStatus.DELIVERED %} -
{% url 'staff:order-manually-checked' pk=object.id as confirm_check_url %} - {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} - {% action-button action=confirm_check_url class="bg-secondary text-white" submit_text="Confirm - Order checked" csrf_token=csrf_token %} - {% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=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="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} + {% endif %} {% if object.next_status %} {% url 'staff:order-to-next-status' pk=object.id as to_next_status_url %} diff --git a/src/staff/views.py b/src/staff/views.py index dbcddfdd..7e6a64ec 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -443,7 +443,7 @@ class OrderToDraftActionView(SingleObjectMixin, ActionView): model = Order def get_queryset(self) -> models.QuerySet[Order]: - return super().get_queryset().filter(status=Order.OrderStatus.DELIVERED) + return super().get_queryset() def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.object: Order = self.get_object() From 78ca1c30d7248d2869ce183ee2f1e9e1ee255eb2 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:59:18 +0200 Subject: [PATCH 74/99] Add button to link from extraction order to related analysis order (#198) Co-authored-by: Morten Madsen Lyngstad --- .../staff/extractionorder_detail.html | 48 +++++++++++-------- src/staff/views.py | 6 +++ 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index ac5f472f..0101dcce 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -3,30 +3,22 @@ {% block content %} - {% fragment as table_header %} - {% #table-cell header=True %}GUID{% /table-cell %} - {% #table-cell header=True %}Type{% /table-cell %} - {% #table-cell header=True %}Species{% /table-cell %} - {% #table-cell header=True %}Markers{% /table-cell %} - {% #table-cell header=True %}Location{% /table-cell %} - {% #table-cell header=True %}Date{% /table-cell %} - {% #table-cell header=True %}Volume{% /table-cell %} - {% endfragment %}

Order {{ object }}

-
-
- - {% object-detail object=object %} - - -
Delivered Samples
-
-

{{ object.samples.count }} samples were delivered

-
+
back Samples + {% if analysis_orders|length > 1 %} + + {% elif analysis_orders|length == 1 %} + Go to {{ analysis_orders.first}} + {% endif %} Assign staff
@@ -46,5 +38,23 @@
Delivered Samples
{% action-button action=to_next_status_url class="bg-secondary text-white" submit_text=btn_name csrf_token=csrf_token %} {% endwith %} {% endif %} + +
+ + {% fragment as table_header %} + {% #table-cell header=True %}GUID{% /table-cell %} + {% #table-cell header=True %}Type{% /table-cell %} + {% #table-cell header=True %}Species{% /table-cell %} + {% #table-cell header=True %}Markers{% /table-cell %} + {% #table-cell header=True %}Location{% /table-cell %} + {% #table-cell header=True %}Date{% /table-cell %} + {% #table-cell header=True %}Volume{% /table-cell %} + {% endfragment %} + + {% object-detail object=object %} + +
Delivered Samples
+
+

{{ object.samples.count }} samples were delivered

{% endblock %} diff --git a/src/staff/views.py b/src/staff/views.py index 7e6a64ec..23573f0e 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -172,6 +172,12 @@ class EquipmentOrderDetailView(StaffMixin, DetailView): class ExtractionOrderDetailView(StaffMixin, DetailView): model = ExtractionOrder + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + extraction_order = self.object + context["analysis_orders"] = extraction_order.analysis_orders.all() + return context + class OrderExtractionSamplesListView(StaffMixin, SingleTableMixin, FilterView): table_pagination = False From 2376f28af8c8cb295654b2750f32c0977d0d024a Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Mon, 7 Jul 2025 13:16:58 +0200 Subject: [PATCH 75/99] Add urgent, new and assigned orders to dashboard (#178) --- src/genlab_bestilling/models.py | 9 ++ src/staff/tables.py | 137 ++++++++++++++++++ .../staff/components/order_table.html | 6 + src/staff/templates/staff/dashboard.html | 80 ++-------- src/staff/templatetags/__init__.py | 0 src/staff/templatetags/order_tags.py | 107 ++++++++++++++ src/staff/views.py | 28 ++-- src/templates/django_tables2/tailwind.html | 127 +--------------- .../django_tables2/tailwind_inner.html | 120 +++++++++++++++ 9 files changed, 415 insertions(+), 199 deletions(-) create mode 100644 src/staff/templates/staff/components/order_table.html create mode 100644 src/staff/templatetags/__init__.py create mode 100644 src/staff/templatetags/order_tags.py create mode 100644 src/templates/django_tables2/tailwind_inner.html diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 5a86aa2f..4a0e16b5 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -390,6 +390,9 @@ def get_absolute_url(self) -> str: kwargs={"pk": self.pk, "genrequest_id": self.genrequest_id}, ) + def get_absolute_staff_url(self) -> str: + return reverse("staff:order-equipment-detail", kwargs={"pk": self.pk}) + def confirm_order(self) -> Any: if not EquimentOrderQuantity.objects.filter(order=self).exists(): raise Order.CannotConfirm(_("No equipments found")) @@ -428,6 +431,9 @@ def get_absolute_url(self) -> str: kwargs={"pk": self.pk, "genrequest_id": self.genrequest_id}, ) + def get_absolute_staff_url(self) -> str: + return reverse("staff:order-extraction-detail", kwargs={"pk": self.pk}) + def clone(self) -> None: """ Generates a clone of the model, with a different ID @@ -528,6 +534,9 @@ def get_absolute_url(self) -> str: kwargs={"pk": self.pk, "genrequest_id": self.genrequest_id}, ) + def get_absolute_staff_url(self) -> str: + return reverse("staff:order-analysis-detail", kwargs={"pk": self.pk}) + def get_type(self) -> str: return "analysis" diff --git a/src/staff/tables.py b/src/staff/tables.py index ebb149fd..5ea38a30 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -10,6 +10,7 @@ EquipmentOrder, ExtractionOrder, ExtractionPlate, + Order, Sample, SampleMarkerAnalysis, ) @@ -300,3 +301,139 @@ class Meta: attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Plates" + + +FLAG_OUTLINE = "" +FLAG_FILLED = "" +URGENT_FILLED = ( + "" +) + + +class StatusMixinTable(tables.Table): + status = tables.Column( + orderable=False, + verbose_name="Status", + ) + + def render_status(self, value: Order.OrderStatus, record: Order) -> str: + status_colors = { + "Processing": "bg-yellow-100 text-yellow-800", + "Completed": "bg-green-100 text-green-800", + "Delivered": "bg-red-100 text-red-800", + } + status_text = { + "Processing": "Processing", + "Completed": "Completed", + "Delivered": "Not started", + } + color_class = status_colors.get(value, "bg-gray-100 text-gray-800") + status_text = status_text.get(value, "Unknown") + return mark_safe( # noqa: S308 + f'{status_text}' # noqa: E501 + ) + + +class StaffIDMixinTable(tables.Table): + id = tables.Column( + orderable=False, + empty_values=(), + ) + + def render_id( + self, record: ExtractionOrder | AnalysisOrder | EquipmentOrder + ) -> str: + url = record.get_absolute_staff_url() + + return mark_safe(f'{record}') # noqa: S308 + + +class UrgentOrderTable(StaffIDMixinTable, StatusMixinTable): + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + + delivery_date = tables.Column( + accessor="genrequest__expected_samples_delivery_date", + verbose_name="Delivery date", + orderable=False, + ) + + def render_delivery_date(self, value: Any) -> str: + if value: + return value.strftime("%d/%m/%Y") + return "-" + + class Meta: + model = Order + fields = ["id", "description", "delivery_date", "status"] + empty_text = "No urgent orders" + template_name = "django_tables2/tailwind_inner.html" + + +class NewOrderTable(StaffIDMixinTable): + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + + delivery_date = tables.Column( + accessor="genrequest__expected_samples_delivery_date", + verbose_name="Delivery date", + orderable=False, + ) + + def render_delivery_date(self, value: Any) -> str: + if value: + return value.strftime("%d/%m/%Y") + return "-" + + samples = tables.Column( + accessor="sample_count", + verbose_name="Samples", + orderable=False, + ) + + def render_samples(self, value: int) -> str: + if value > 0: + return str(value) + return "-" + + class Meta: + model = Order + fields = ["id", "description", "delivery_date", "samples"] + empty_text = "No new orders" + template_name = "django_tables2/tailwind_inner.html" + + +class AssignedOrderTable(StatusMixinTable, StaffIDMixinTable): + priority = tables.Column( + orderable=False, + verbose_name="Priority", + accessor="is_urgent", + ) + + def render_priority(self, value: bool) -> str: + if value: + return mark_safe(URGENT_FILLED) # noqa: S308 + return "" + + samples_completed = tables.Column( + accessor="sample_count", + verbose_name="Samples completed", + orderable=False, + ) + + def render_samples_completed(self, value: int) -> str: + if value > 0: + return "- / " + str(value) + return "-" + + class Meta: + model = Order + fields = ["priority", "id", "samples_completed", "status"] + empty_text = "No assigned orders" + template_name = "django_tables2/tailwind_inner.html" diff --git a/src/staff/templates/staff/components/order_table.html b/src/staff/templates/staff/components/order_table.html new file mode 100644 index 00000000..2fb0fc1b --- /dev/null +++ b/src/staff/templates/staff/components/order_table.html @@ -0,0 +1,6 @@ +{% load django_tables2 %} + +
+

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

+ {% render_table table %} +
diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 46797bc1..6ff9aaf4 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -1,14 +1,19 @@ {% extends 'staff/base.html' %} + {% load i18n %} {% load static %} {% load tz %} - -{% block head_javascript %} - -{% endblock %} +{% load order_tags %} {% block content %}
+
+ All + {% for area in areas %} + {{ area.name }} + {% endfor %} +
+

{{ now|date:'F j, Y' }} | @@ -16,67 +21,14 @@

- {% if delivered_orders|length > 0 %} -
-

Delivered Orders

- - {% for order in delivered_orders %} - {% if order.polymorphic_ctype.model == 'analysisorder' %} -

{{ order }} - {{ order.name }}

- {% elif order.polymorphic_ctype.model == 'equipmentorder' %} -

{{ order }} - {{ order.name }}

- {% elif order.polymorphic_ctype.model == 'extractionorder' %} -

{{ order }} - {{ order.name }}

- {% else %} -

{{ order }} - {{ order.name }}

- {% endif %} - {% endfor %} +
+
+ {% urgent_orders_table area=area %} + {% new_orders_table area=area %}
- {% endif %} - - {% if urgent_orders|length > 0 %} -
-

Urgent orders

- {% for order in urgent_orders %} - {% if order.polymorphic_ctype.model == 'analysisorder' %} -

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:'-' }} - Status: {{ order.status|default:'-' }}

- {% elif order.polymorphic_ctype.model == 'equipmentorder' %} -

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:'-' }} - Status: {{ order.status|default:'-' }}

- {% elif order.polymorphic_ctype.model == 'extractionorder' %} -

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:'-' }} - Status: {{ order.status|default:'-' }}

- {% else %} -

{{ order }} - {{ order.name }}

- {% endif %} - {% endfor %} -
- {% else %} -
-

Urgent orders

-

No urgent orders found.

-
- {% endif %} - - {% if assigned_orders|length > 0 %} -
-

My orders ({{ assigned_orders|length }})

- - {% for order in assigned_orders %} - {% if order.polymorphic_ctype.model == 'analysisorder' %} -

{{ order }} - {{ order.name }}

- {% elif order.polymorphic_ctype.model == 'equipmentorder' %} -

{{ order }} - {{ order.name }}

- {% elif order.polymorphic_ctype.model == 'extractionorder' %} -

{{ order }} - {{ order.name }}

- {% else %} -

{{ order }} - {{ order.name }}

- {% endif %} - {% endfor %} +
+ {% assigned_orders_table %}
- {% else %} -
-

Assigned orders

-

No assigned orders found.

-
- {% endif %} +
{% endblock %} diff --git a/src/staff/templatetags/__init__.py b/src/staff/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py new file mode 100644 index 00000000..766c190d --- /dev/null +++ b/src/staff/templatetags/order_tags.py @@ -0,0 +1,107 @@ +from django import template +from django.db import models + +from genlab_bestilling.models import Area, Order + +from ..tables import AssignedOrderTable, NewOrderTable, UrgentOrderTable + +register = template.Library() + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def urgent_orders_table(context: dict, area: Area | None = None) -> dict: + urgent_orders = Order.objects.filter( + is_urgent=True, + status__in=[Order.OrderStatus.PROCESSING, Order.OrderStatus.DELIVERED], + ).select_related("genrequest") + + if area: + urgent_orders = urgent_orders.filter(genrequest__area=area) + + urgent_orders = urgent_orders.only( + "id", "genrequest__name", "genrequest__expected_samples_delivery_date", "status" + ).order_by( + models.Case( + models.When(status=Order.OrderStatus.PROCESSING, then=0), + models.When(status=Order.OrderStatus.DELIVERED, then=1), + models.When(status=Order.OrderStatus.COMPLETED, then=2), + default=3, + output_field=models.IntegerField(), + ), + "-created_at", + ) + + return { + "title": "Urgent orders", + "table": UrgentOrderTable(urgent_orders), + "count": urgent_orders.count(), + "request": context.get("request"), + } + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def new_orders_table(context: dict, area: Area | None = None) -> dict: + new_orders = ( + Order.objects.filter(status=Order.OrderStatus.DELIVERED) + .select_related("genrequest") + .annotate(sample_count=models.Count("extractionorder__samples")) + .only( + "id", + "genrequest__name", + "genrequest__expected_samples_delivery_date", + "status", + ) + .order_by("status") + ) + + if area: + new_orders = new_orders.filter(genrequest__area=area) + + new_orders = new_orders.order_by("-created_at") + + return { + "title": "New orders", + "table": NewOrderTable(new_orders), + "count": new_orders.count(), + "request": context.get("request"), + } + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def assigned_orders_table(context: dict) -> dict: + assigned_orders = ( + Order.objects.filter( + status__in=[ + Order.OrderStatus.PROCESSING, + Order.OrderStatus.DELIVERED, + Order.OrderStatus.COMPLETED, + ] + ) + .select_related("genrequest") + .annotate( + sample_count=models.Count("extractionorder__samples"), + ) + .only( + "id", + "genrequest__name", + "genrequest__expected_samples_delivery_date", + "status", + ) + .order_by( + models.Case( + models.When(status=Order.OrderStatus.PROCESSING, then=0), + models.When(status=Order.OrderStatus.DELIVERED, then=1), + models.When(status=Order.OrderStatus.COMPLETED, then=2), + default=3, + output_field=models.IntegerField(), + ), + "-created_at", + ) + ) + + return { + "title": "My orders", + "table": AssignedOrderTable(assigned_orders), + "count": assigned_orders.count(), + "request": context.get("request"), + } diff --git a/src/staff/views.py b/src/staff/views.py index 23573f0e..956c576f 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -18,6 +18,7 @@ from genlab_bestilling.models import ( AnalysisOrder, + Area, EquipmentOrder, ExtractionOrder, ExtractionPlate, @@ -67,22 +68,25 @@ def test_func(self) -> bool: class DashboardView(StaffMixin, TemplateView): template_name = "staff/dashboard.html" - def get_context_data(self, **kwargs) -> dict[str, Any]: - context = super().get_context_data(**kwargs) + def get_area_from_query(self) -> Area | None: + area_id = self.request.GET.get("area") + if area_id: + try: + return Area.objects.get(pk=area_id) + except Area.DoesNotExist: + return None + return None - urgent_orders = Order.objects.filter( - is_urgent=True, - status__in=[Order.OrderStatus.PROCESSING, Order.OrderStatus.DELIVERED], - ).order_by("-created_at") - context["urgent_orders"] = urgent_orders + def get_areas(self) -> models.QuerySet[Area]: + return Area.objects.all().order_by("name") - delivered_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) - context["delivered_orders"] = delivered_orders - context["assigned_orders"] = self.request.user.responsible_orders.filter( - status__in=[Order.OrderStatus.DELIVERED, Order.OrderStatus.PROCESSING] - ).order_by("-created_at") context["now"] = now() + context["areas"] = self.get_areas() + context["area"] = self.get_area_from_query() + return context diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index 57650799..a322ecb5 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -1,126 +1,7 @@ -{% load django_tables2 %} -{% load i18n %} {% block table-wrapper %} -
-
- {% block table %} -
{% if column.orderable %} - {{ column.header }} + {% comment%} + If the column is orderable, two small arrows will show next to the column name to signal that it can be sorted. + {% endcomment%} + + {{ column.header }} + + {% else %} {{ column.header }} {% endif %}
- {% comment %} Show an exclaimation mark if the cell value is True, otherwise show nothing. {% endcomment %} - {% if cell == True %} - - {% endif %} - {% if column.localize == None %} {{ cell }} @@ -80,7 +49,6 @@ {{ cell|unlocalize }} {% endif %} {% endif %}
- {% block table.thead %} - {% if table.show_header %} - - - {% for column in table.columns %} - - {% endfor %} - - - {% endif %} - {% endblock table.thead %} - - - {% block table.tbody %} - - {% for row in table.paginated_rows %} - {% block table.tbody.row %} - - - {% for column, cell in row.items %} - - {% endfor %} - - {% endblock table.tbody.row %} - {% empty %} - {% if table.empty_text %} - {% block table.tbody.empty_text %} - - {% endblock table.tbody.empty_text %} - {% endif %} - {% endfor %} - - {% endblock table.tbody %} - - - {% block table.tfoot %} - {% if table.has_footer %} - - - {% for column in table.columns %} - - {% endfor %} - - - {% endif %} - {% endblock table.tfoot %} -
- {% if column.orderable %} - {% comment%} - If the column is orderable, two small arrows will show next to the column name to signal that it can be sorted. - {% endcomment%} - - {{ column.header }} - - - {% else %} - {{ column.header }} - {% endif %} -
- {% if column.localize == None %} - {{ cell }} - {% else %} - {% if column.localize %} - {{ cell|localize }} - {% else %} - {{ cell|unlocalize }} - {% endif %} - {% endif %}
{{ table.empty_text }}
{{ column.footer }}
- {% endblock table %} - - {% block pagination %} - {% if table.page and table.paginator.num_pages > 1 %} - - {% endif %} - {% endblock pagination %} - +
+
+ {% include 'django_tables2/tailwind_inner.html' %} +
{% endblock table-wrapper %} diff --git a/src/templates/django_tables2/tailwind_inner.html b/src/templates/django_tables2/tailwind_inner.html new file mode 100644 index 00000000..24485f46 --- /dev/null +++ b/src/templates/django_tables2/tailwind_inner.html @@ -0,0 +1,120 @@ +{% load django_tables2 %} +{% load i18n %} + +{% block table %} + + {% block table.thead %} + {% if table.show_header %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.thead %} + + + {% block table.tbody %} + + {% for row in table.paginated_rows %} + {% block table.tbody.row %} + + + {% for column, cell in row.items %} + + {% endfor %} + + {% endblock table.tbody.row %} + {% empty %} + {% if table.empty_text %} + {% block table.tbody.empty_text %} + + {% endblock table.tbody.empty_text %} + {% endif %} + {% endfor %} + + {% endblock table.tbody %} + + + {% block table.tfoot %} + {% if table.has_footer %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.tfoot %} +
+ {% if column.orderable %} + {% comment%} + If the column is orderable, two small arrows will show next to the column name to signal that it can be sorted. + {% endcomment%} + + {{ column.header }} + + + {% else %} + {{ column.header }} + {% endif %} +
+ {% if column.localize == None %} + {{ cell }} + {% else %} + {% if column.localize %} + {{ cell|localize }} + {% else %} + {{ cell|unlocalize }} + {% endif %} + {% endif %}
{{ table.empty_text }}
{{ column.footer }}
+{% endblock table %} + +{% block pagination %} + {% if table.page and table.paginator.num_pages > 1 %} + + {% endif %} +{% endblock pagination %} From 2ef09b0a1b69dae586061893402301bbae0ceee7 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Mon, 7 Jul 2025 13:25:38 +0200 Subject: [PATCH 76/99] Able to change status and convert to draft for analysis orders (#202) * Able to change status and convert to draft for analysis orders * Minor spacing --- src/staff/templates/staff/analysisorder_detail.html | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 9dddec5e..f51d23f5 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -28,10 +28,18 @@
Samples to analyze
back Samples Assign staff - {% if object.status != object.OrderStatus.DRAFT%} -
+ +
+ {% if object.status == object.OrderStatus.DELIVERED %} {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} {% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} {% endif %} + + {% if object.status != object.OrderStatus.DRAFT and object.next_status %} + {% 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-secondary text-white" submit_text=btn_name csrf_token=csrf_token %} + {% endwith %} + {% endif %} {% endblock %} From 01713f63a861ee53caedd72906552e491bb6043e Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Mon, 7 Jul 2025 13:54:28 +0200 Subject: [PATCH 77/99] Add is_seen and is_prioritized to order (#204) --- ...0021_order_is_prioritized_order_is_seen.py | 26 +++++++++++++++++++ src/genlab_bestilling/models.py | 14 ++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/genlab_bestilling/migrations/0021_order_is_prioritized_order_is_seen.py diff --git a/src/genlab_bestilling/migrations/0021_order_is_prioritized_order_is_seen.py b/src/genlab_bestilling/migrations/0021_order_is_prioritized_order_is_seen.py new file mode 100644 index 00000000..a42ed453 --- /dev/null +++ b/src/genlab_bestilling/migrations/0021_order_is_prioritized_order_is_seen.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.3 on 2025-07-07 11:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0020_sample_is_prioritised"), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="is_prioritized", + field=models.BooleanField( + default=False, help_text="If an order should be prioritized internally" + ), + ), + migrations.AddField( + model_name="order", + name="is_seen", + field=models.BooleanField( + default=False, help_text="If an order has been seen by a staff" + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 4a0e16b5..beeb87bb 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -281,6 +281,12 @@ class OrderStatus(models.TextChoices): verbose_name="Responsible staff", help_text="Staff members responsible for this order", ) + is_seen = models.BooleanField( + default=False, help_text="If an order has been seen by a staff" + ) + is_prioritized = models.BooleanField( + default=False, help_text="If an order should be prioritized internally" + ) tags = TaggableManager(blank=True) objects = managers.OrderManager() @@ -302,6 +308,14 @@ def to_draft(self) -> None: self.confirmed_at = None self.save() + def toggle_seen(self) -> None: + self.is_seen = not self.is_seen + self.save() + + def toggle_prioritized(self) -> None: + self.is_prioritized = not self.is_prioritized + self.save() + def get_type(self) -> str: return "order" From fdbbc92cb3f1439d23af05730edff5b38e9eb6f8 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Mon, 7 Jul 2025 14:44:18 +0200 Subject: [PATCH 78/99] Mark orders as seen and prioritized from the dashboard (#191) --- src/genlab_bestilling/models.py | 5 ++ src/staff/tables.py | 71 ++++++++++++---- .../staff/components/priority_column.html | 21 +++++ .../staff/components/seen_column.html | 4 + src/staff/templates/staff/dashboard.html | 3 +- src/staff/templatetags/order_tags.py | 81 +++++++++++++------ src/staff/urls.py | 8 ++ src/staff/views.py | 26 ++++++ 8 files changed, 178 insertions(+), 41 deletions(-) create mode 100644 src/staff/templates/staff/components/priority_column.html create mode 100644 src/staff/templates/staff/components/seen_column.html diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index beeb87bb..cf094cc1 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -243,6 +243,11 @@ class OrderStatus(models.TextChoices): # COMPLETED: Order has been completed, and results are available. COMPLETED = "completed", _("Completed") + class OrderPriority: + URGENT = 3 + PRIORITIZED = 2 + NORMAL = 1 + STATUS_ORDER = ( OrderStatus.DRAFT, OrderStatus.DELIVERED, diff --git a/src/staff/tables.py b/src/staff/tables.py index 5ea38a30..43066242 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -303,13 +303,6 @@ class Meta: empty_text = "No Plates" -FLAG_OUTLINE = "" -FLAG_FILLED = "" -URGENT_FILLED = ( - "" -) - - class StatusMixinTable(tables.Table): status = tables.Column( orderable=False, @@ -373,7 +366,14 @@ class Meta: template_name = "django_tables2/tailwind_inner.html" -class NewOrderTable(StaffIDMixinTable): +class NewUnseenOrderTable(StaffIDMixinTable): + seen = tables.TemplateColumn( + orderable=False, + verbose_name="Seen", + template_name="staff/components/seen_column.html", + empty_values=(), + ) + description = tables.Column( accessor="genrequest__name", verbose_name="Description", @@ -404,22 +404,61 @@ def render_samples(self, value: int) -> str: class Meta: model = Order - fields = ["id", "description", "delivery_date", "samples"] - empty_text = "No new orders" + fields = ["id", "description", "delivery_date", "samples", "seen"] + empty_text = "No new unseen orders" template_name = "django_tables2/tailwind_inner.html" -class AssignedOrderTable(StatusMixinTable, StaffIDMixinTable): - priority = tables.Column( +class NewSeenOrderTable(StaffIDMixinTable): + priority = tables.TemplateColumn( orderable=False, verbose_name="Priority", - accessor="is_urgent", + accessor="priority", + template_name="staff/components/priority_column.html", ) - def render_priority(self, value: bool) -> str: + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + + delivery_date = tables.Column( + accessor="genrequest__expected_samples_delivery_date", + verbose_name="Delivery date", + orderable=False, + ) + + def render_delivery_date(self, value: Any) -> str: if value: - return mark_safe(URGENT_FILLED) # noqa: S308 - return "" + return value.strftime("%d/%m/%Y") + return "-" + + samples = tables.Column( + accessor="sample_count", + verbose_name="Samples", + orderable=False, + ) + + def render_samples(self, value: int) -> str: + if value > 0: + return str(value) + return "-" + + class Meta: + model = Order + fields = ["priority", "id", "description", "delivery_date", "samples"] + empty_text = "No new seen orders" + template_name = "django_tables2/tailwind_inner.html" + + +class AssignedOrderTable(StatusMixinTable, StaffIDMixinTable): + priority = tables.TemplateColumn( + orderable=False, + verbose_name="Priority", + accessor="priority", + template_name="staff/components/priority_column.html", + ) samples_completed = tables.Column( accessor="sample_count", diff --git a/src/staff/templates/staff/components/priority_column.html b/src/staff/templates/staff/components/priority_column.html new file mode 100644 index 00000000..ce76db60 --- /dev/null +++ b/src/staff/templates/staff/components/priority_column.html @@ -0,0 +1,21 @@ +{% if value == 3 %} + +{% else %} +
+ {% csrf_token %} + + {% comment %}Outlined flag icon does not work using the tag, so also using the SVG for the filled flag icon for consistency. If it can be fixed in the future it should.{% endcomment %} + + +
+{% endif %} diff --git a/src/staff/templates/staff/components/seen_column.html b/src/staff/templates/staff/components/seen_column.html new file mode 100644 index 00000000..d8c004b9 --- /dev/null +++ b/src/staff/templates/staff/components/seen_column.html @@ -0,0 +1,4 @@ +
+ {% csrf_token %} + +
diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 6ff9aaf4..1530d21a 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -24,7 +24,8 @@
{% urgent_orders_table area=area %} - {% new_orders_table area=area %} + {% new_unseen_orders_table area=area %} + {% new_seen_orders_table area=area %}
diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index 766c190d..da318fe4 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -3,24 +3,30 @@ from genlab_bestilling.models import Area, Order -from ..tables import AssignedOrderTable, NewOrderTable, UrgentOrderTable +from ..tables import ( + AssignedOrderTable, + NewSeenOrderTable, + NewUnseenOrderTable, + UrgentOrderTable, +) register = template.Library() @register.inclusion_tag("staff/components/order_table.html", takes_context=True) def urgent_orders_table(context: dict, area: Area | None = None) -> dict: - urgent_orders = Order.objects.filter( - is_urgent=True, - status__in=[Order.OrderStatus.PROCESSING, Order.OrderStatus.DELIVERED], - ).select_related("genrequest") + urgent_orders = ( + Order.objects.filter( + is_urgent=True, + ) + .exclude(status=Order.OrderStatus.DRAFT) + .select_related("genrequest") + ) if area: urgent_orders = urgent_orders.filter(genrequest__area=area) - urgent_orders = urgent_orders.only( - "id", "genrequest__name", "genrequest__expected_samples_delivery_date", "status" - ).order_by( + urgent_orders = urgent_orders.order_by( models.Case( models.When(status=Order.OrderStatus.PROCESSING, then=0), models.When(status=Order.OrderStatus.DELIVERED, then=1), @@ -40,18 +46,43 @@ def urgent_orders_table(context: dict, area: Area | None = None) -> dict: @register.inclusion_tag("staff/components/order_table.html", takes_context=True) -def new_orders_table(context: dict, area: Area | None = None) -> dict: +def new_seen_orders_table(context: dict, area: Area | None = None) -> dict: new_orders = ( - Order.objects.filter(status=Order.OrderStatus.DELIVERED) + Order.objects.filter(status=Order.OrderStatus.DELIVERED, is_seen=True) + .exclude(is_urgent=True) .select_related("genrequest") - .annotate(sample_count=models.Count("extractionorder__samples")) - .only( - "id", - "genrequest__name", - "genrequest__expected_samples_delivery_date", - "status", + .annotate( + sample_count=models.Count("extractionorder__samples"), ) - .order_by("status") + .annotate( + priority=models.Case( + models.When(is_urgent=True, then=Order.OrderPriority.URGENT), + models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), + default=1, + ) + ) + ) + + if area: + new_orders = new_orders.filter(genrequest__area=area) + + new_orders = new_orders.order_by("-priority", "-created_at") + + return { + "title": "New seen orders", + "table": NewSeenOrderTable(new_orders), + "count": new_orders.count(), + "request": context.get("request"), + } + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def new_unseen_orders_table(context: dict, area: Area | None = None) -> dict: + new_orders = ( + Order.objects.filter(status=Order.OrderStatus.DELIVERED, is_seen=False) + .exclude(is_urgent=True) + .select_related("genrequest") + .annotate(sample_count=models.Count("extractionorder__samples")) ) if area: @@ -60,8 +91,8 @@ def new_orders_table(context: dict, area: Area | None = None) -> dict: new_orders = new_orders.order_by("-created_at") return { - "title": "New orders", - "table": NewOrderTable(new_orders), + "title": "New unseen orders", + "table": NewUnseenOrderTable(new_orders), "count": new_orders.count(), "request": context.get("request"), } @@ -81,11 +112,12 @@ def assigned_orders_table(context: dict) -> dict: .annotate( sample_count=models.Count("extractionorder__samples"), ) - .only( - "id", - "genrequest__name", - "genrequest__expected_samples_delivery_date", - "status", + .annotate( + priority=models.Case( + models.When(is_urgent=True, then=Order.OrderPriority.URGENT), + models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), + default=1, + ) ) .order_by( models.Case( @@ -95,6 +127,7 @@ def assigned_orders_table(context: dict) -> dict: default=3, output_field=models.IntegerField(), ), + "-priority", "-created_at", ) ) diff --git a/src/staff/urls.py b/src/staff/urls.py index 6dc62299..46c8ba5a 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -15,6 +15,8 @@ ManaullyCheckedOrderActionView, OrderAnalysisSamplesListView, OrderExtractionSamplesListView, + OrderPrioritizedAdminView, + OrderSeenAdminView, OrderStaffEditView, OrderToDraftActionView, OrderToNextStatusActionView, @@ -136,4 +138,10 @@ ExtractionPlateDetailView.as_view(), name="plates-detail", ), + path("orders//seen/", OrderSeenAdminView.as_view(), name="order-seen"), + path( + "orders//priority/", + OrderPrioritizedAdminView.as_view(), + name="order-priority", + ), ] diff --git a/src/staff/views.py b/src/staff/views.py index 956c576f..feea2742 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -660,3 +660,29 @@ def get_success_url(self) -> str: def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) + + +class OrderSeenAdminView(StaffMixin, ActionView): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + pk = kwargs.get("pk") + order = Order.objects.get(pk=pk) + order.toggle_seen() + + return HttpResponseRedirect( + reverse( + "staff:dashboard", + ) + ) + + +class OrderPrioritizedAdminView(StaffMixin, ActionView): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + pk = kwargs.get("pk") + order = Order.objects.get(pk=pk) + order.toggle_prioritized() + + return HttpResponseRedirect( + reverse( + "staff:dashboard", + ) + ) From 3a7e7ef3a24157d0a8407dab541589e67e15109a Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:58:09 +0200 Subject: [PATCH 79/99] Add link to related extraction order analysis order (#205) * Refactor analysis order selection in extraction order page * Add link to related extraction order from analysis order page --------- Co-authored-by: Morten Madsen Lyngstad --- .../templates/staff/analysisorder_detail.html | 42 ++++++++++--------- .../staff/extractionorder_detail.html | 30 +++++++++---- src/staff/views.py | 6 +++ 3 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index f51d23f5..6a9955d3 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -3,30 +3,15 @@ {% block content %} - {% fragment as table_header %} - {% #table-cell header=True %}GUID{% /table-cell %} - {% #table-cell header=True %}Type{% /table-cell %} - {% #table-cell header=True %}Species{% /table-cell %} - {% #table-cell header=True %}Markers{% /table-cell %} - {% #table-cell header=True %}Location{% /table-cell %} - {% #table-cell header=True %}Date{% /table-cell %} - {% #table-cell header=True %}Volume{% /table-cell %} - {% endfragment %}

Order {{ object }}

-
-
- - {% object-detail object=object %} - - -
Samples to analyze
-
-

Selected {{ object.samples.count }} samples

-
+
back Samples + + Go to {{ extraction_order}} + Assign staff
@@ -42,4 +27,23 @@
Samples to analyze
{% endwith %} {% endif %}
+ + {% fragment as table_header %} + {% #table-cell header=True %}GUID{% /table-cell %} + {% #table-cell header=True %}Type{% /table-cell %} + {% #table-cell header=True %}Species{% /table-cell %} + {% #table-cell header=True %}Markers{% /table-cell %} + {% #table-cell header=True %}Location{% /table-cell %} + {% #table-cell header=True %}Date{% /table-cell %} + {% #table-cell header=True %}Volume{% /table-cell %} + {% endfragment %} + + {% object-detail object=object %} + + +
Samples to analyze
+
+

Selected {{ object.samples.count }} samples

+
+ {% endblock %} diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index 0101dcce..c8a80876 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -10,14 +10,29 @@

Order {{ object }}

back Samples {% if analysis_orders|length > 1 %} - +
+ + + +
{% elif analysis_orders|length == 1 %} - Go to {{ analysis_orders.first}} + Go to {{ analysis_orders.first}} {% endif %} Assign staff @@ -38,7 +53,6 @@

Order {{ object }}

{% action-button action=to_next_status_url class="bg-secondary text-white" submit_text=btn_name csrf_token=csrf_token %} {% endwith %} {% endif %} -
{% fragment as table_header %} diff --git a/src/staff/views.py b/src/staff/views.py index feea2742..74229087 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -168,6 +168,12 @@ def get_queryset(self) -> models.QuerySet[EquipmentOrder]: class AnalysisOrderDetailView(StaffMixin, DetailView): model = AnalysisOrder + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + analysis_order = self.object + context["extraction_order"] = analysis_order.from_order + return context + class EquipmentOrderDetailView(StaffMixin, DetailView): model = EquipmentOrder From 3d7de82c0d5fdbbf8d70f7aef7b932b3ec998a77 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:35:18 +0200 Subject: [PATCH 80/99] Add column note in sample status page (#194) * Add note field to Sample model and implement update functionality * Rename "note" to "internal_note" to minimize confusion --------- Co-authored-by: Morten Madsen Lyngstad --- .../migrations/0020_sample_is_prioritised.py | 2 +- .../migrations/0022_sample_internal_note.py | 17 ++++++++ src/genlab_bestilling/models.py | 3 ++ src/staff/tables.py | 7 +++- .../templates/staff/note_input_column.html | 7 ++++ src/staff/templates/staff/sample_lab.html | 40 ++++++++++++++----- src/staff/urls.py | 6 +++ src/staff/views.py | 23 ++++++++++- 8 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0022_sample_internal_note.py create mode 100644 src/staff/templates/staff/note_input_column.html diff --git a/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py b/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py index 3c431782..c9208b96 100644 --- a/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py +++ b/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.3 on 2025-07-07 06:40 +# Generated by Django 5.2.3 on 2025-07-07 09:53 from django.db import migrations, models diff --git a/src/genlab_bestilling/migrations/0022_sample_internal_note.py b/src/genlab_bestilling/migrations/0022_sample_internal_note.py new file mode 100644 index 00000000..48249fe7 --- /dev/null +++ b/src/genlab_bestilling/migrations/0022_sample_internal_note.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-07-07 13:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0021_order_is_prioritized_order_is_seen"), + ] + + operations = [ + migrations.AddField( + model_name="sample", + name="internal_note", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index cf094cc1..cbc645f0 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -634,6 +634,9 @@ class Sample(models.Model): species = models.ForeignKey(f"{an}.Species", on_delete=models.PROTECT) year = models.IntegerField() notes = models.TextField(null=True, blank=True) + + # "Merknad" in the Excel sheet. + internal_note = models.TextField(null=True, blank=True) pop_id = models.CharField(max_length=150, null=True, blank=True) location = models.ForeignKey( f"{an}.Location", on_delete=models.PROTECT, null=True, blank=True diff --git a/src/staff/tables.py b/src/staff/tables.py index 43066242..2f7f42a0 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -235,9 +235,14 @@ class CustomSampleTable(tables.Table): default=False, ) + internal_note = tables.TemplateColumn( + template_name="staff/note_input_column.html", orderable=False + ) + class Meta: model = Sample - fields = ["checked", "genlab_id"] + list(base_fields) + fields = ["checked", "genlab_id", "internal_note"] + list(base_fields) + sequence = ["checked", "genlab_id"] + list(base_fields) + ["internal_note"] return CustomSampleTable diff --git a/src/staff/templates/staff/note_input_column.html b/src/staff/templates/staff/note_input_column.html new file mode 100644 index 00000000..c98206c3 --- /dev/null +++ b/src/staff/templates/staff/note_input_column.html @@ -0,0 +1,7 @@ + diff --git a/src/staff/templates/staff/sample_lab.html b/src/staff/templates/staff/sample_lab.html index 6994df77..84752049 100644 --- a/src/staff/templates/staff/sample_lab.html +++ b/src/staff/templates/staff/sample_lab.html @@ -24,17 +24,37 @@

{% block page-title %}{% if order %}{{ order }} - Samp {% block body_javascript %} - -{% endblock body_javascript %} +{% endblock body_javascript%} diff --git a/src/staff/urls.py b/src/staff/urls.py index 46c8ba5a..07a7e696 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -27,6 +27,7 @@ SampleLabView, SampleReplicaActionView, SamplesListView, + UpdateLabViewFields, ) app_name = "staff" @@ -93,6 +94,11 @@ GenerateGenlabIDsView.as_view(), name="generate-genlab-ids", ), + path( + "orders/samples/update/", + UpdateLabViewFields.as_view(), + name="update-sample", + ), path( "orders/analysis//samples/", OrderAnalysisSamplesListView.as_view(), diff --git a/src/staff/views.py b/src/staff/views.py index 74229087..6bc92bca 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -6,7 +6,7 @@ from django.db import models from django.db.models import Count from django.forms import Form -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy from django.utils.timezone import now @@ -377,6 +377,27 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) +class UpdateLabViewFields(StaffMixin, ActionView): + def post(self, request: HttpRequest, *args, **kwargs) -> JsonResponse: + sample_id = request.POST.get("sample_id") + field_name = request.POST.get("field_name") + field_value = request.POST.get("field_value") + + if not sample_id or not field_name or field_value is None: + return JsonResponse({"error": "Invalid input"}, status=400) + + try: + sample = Sample.objects.get(id=sample_id) + + if field_name == "internal_note-input": + sample.internal_note = field_value + sample.save() + + return JsonResponse({"success": True}) + except Sample.DoesNotExist: + return JsonResponse({"error": "Sample not found"}, status=404) + + class ManaullyCheckedOrderActionView(SingleObjectMixin, ActionView): model = ExtractionOrder From 47e6391d354978752d9022775499b03fd702fa07 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:59:03 +0200 Subject: [PATCH 81/99] Fix conditional rendering for extraction order link in analysis order detail (#208) Co-authored-by: Morten Madsen Lyngstad --- src/staff/templates/staff/analysisorder_detail.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 6a9955d3..777520ec 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -10,7 +10,9 @@

Order {{ object }}

back Samples - Go to {{ extraction_order}} + {% if extraction_order %} + Go to {{ extraction_order}} + {% endif %} Assign staff From 6e0d06c4cad3141a4f69e106b75c5669363bd039 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 8 Jul 2025 08:12:02 +0200 Subject: [PATCH 82/99] Display markers on dashboard (#207) --- src/staff/tables.py | 19 +++++++++++++++++-- src/staff/templatetags/order_tags.py | 14 ++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index 2f7f42a0..1b6a69d8 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -407,9 +407,13 @@ def render_samples(self, value: int) -> str: return str(value) return "-" + markers = tables.ManyToManyColumn( + transform=lambda x: x.name, + ) + class Meta: model = Order - fields = ["id", "description", "delivery_date", "samples", "seen"] + fields = ["id", "description", "delivery_date", "samples", "markers", "seen"] empty_text = "No new unseen orders" template_name = "django_tables2/tailwind_inner.html" @@ -450,9 +454,20 @@ def render_samples(self, value: int) -> str: return str(value) return "-" + markers = tables.ManyToManyColumn( + transform=lambda x: x.name, + ) + class Meta: model = Order - fields = ["priority", "id", "description", "delivery_date", "samples"] + fields = [ + "priority", + "id", + "description", + "delivery_date", + "markers", + "samples", + ] empty_text = "No new seen orders" template_name = "django_tables2/tailwind_inner.html" diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index da318fe4..ff83f0db 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -53,13 +53,14 @@ def new_seen_orders_table(context: dict, area: Area | None = None) -> dict: .select_related("genrequest") .annotate( sample_count=models.Count("extractionorder__samples"), - ) - .annotate( priority=models.Case( models.When(is_urgent=True, then=Order.OrderPriority.URGENT), models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), default=1, - ) + ), + ) + .prefetch_related( + "analysisorder__markers", ) ) @@ -82,7 +83,12 @@ def new_unseen_orders_table(context: dict, area: Area | None = None) -> dict: Order.objects.filter(status=Order.OrderStatus.DELIVERED, is_seen=False) .exclude(is_urgent=True) .select_related("genrequest") - .annotate(sample_count=models.Count("extractionorder__samples")) + .annotate( + sample_count=models.Count("extractionorder__samples"), + ) + .prefetch_related( + "analysisorder__markers", + ) ) if area: From 28636f8baaee1b46a1b9786eab26f9ba2429888c Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:03:11 +0200 Subject: [PATCH 83/99] Fix bug where dropdown in header is disabled in sample status page (#213) Co-authored-by: Morten Madsen Lyngstad --- src/staff/templates/staff/sample_lab.html | 34 ++++++++++------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/staff/templates/staff/sample_lab.html b/src/staff/templates/staff/sample_lab.html index 84752049..6fa4ed64 100644 --- a/src/staff/templates/staff/sample_lab.html +++ b/src/staff/templates/staff/sample_lab.html @@ -1,28 +1,24 @@ {% extends "staff/base.html" %} -{% load crispy_forms_tags static %} {% load render_table from django_tables2 %} {% block content %} -

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

-{% block page-inner %} -
- back -
-
- {% csrf_token %} - {% for status in statuses %} - - {% endfor %} +

{% if order %}{{ order }} - Samples{% else %}Samples{% endif %}

- {% render_table table %} -
-{% endblock page-inner %} +
+ back +
+ +
+ {% csrf_token %} + {% for status in statuses %} + + {% endfor %} -{% endblock %} + {% render_table table %} +
-{% block body_javascript %} -{% endblock body_javascript%} +{% endblock content %} From 60605127008912a873ca7cc4ae74567378e3d68a Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:06:56 +0200 Subject: [PATCH 84/99] Reorganize project detail layout and conditional rendering for verification button (#210) Co-authored-by: Morten Madsen Lyngstad --- src/staff/templates/staff/project_detail.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/staff/templates/staff/project_detail.html b/src/staff/templates/staff/project_detail.html index bcd80534..1a6b19e3 100644 --- a/src/staff/templates/staff/project_detail.html +++ b/src/staff/templates/staff/project_detail.html @@ -4,14 +4,16 @@ {% block content %}

Project {{ object }}

-
-
- - {% object-detail object=object %} +
back {% url 'staff:projects-verify' pk=object.pk as verify_url %} - {% action-button action=verify_url class="btn-secondary text-white" submit_text="Mark as verified" csrf_token=csrf_token %} + {% 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 %} + {% endif %}
+ + {% object-detail object=object %} + {% endblock %} From 8206b85bf92b803a1f463ef2f27bdc1391074fb9 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 8 Jul 2025 11:07:40 +0200 Subject: [PATCH 85/99] Get sample count for analysis (#216) --- src/staff/tables.py | 2 +- src/staff/templatetags/order_tags.py | 30 ++++++++++++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index 1b6a69d8..e5407b27 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -482,7 +482,7 @@ class AssignedOrderTable(StatusMixinTable, StaffIDMixinTable): samples_completed = tables.Column( accessor="sample_count", - verbose_name="Samples completed", + verbose_name="Samples isolated", orderable=False, ) diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index ff83f0db..09278823 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -52,7 +52,17 @@ def new_seen_orders_table(context: dict, area: Area | None = None) -> dict: .exclude(is_urgent=True) .select_related("genrequest") .annotate( - sample_count=models.Count("extractionorder__samples"), + sample_count=models.Case( + models.When( + extractionorder__isnull=False, + then=models.Count("extractionorder__samples", distinct=True), + ), + models.When( + analysisorder__isnull=False, + then=models.Count("analysisorder__samples", distinct=True), + ), + default=0, + ), priority=models.Case( models.When(is_urgent=True, then=Order.OrderPriority.URGENT), models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), @@ -84,7 +94,17 @@ def new_unseen_orders_table(context: dict, area: Area | None = None) -> dict: .exclude(is_urgent=True) .select_related("genrequest") .annotate( - sample_count=models.Count("extractionorder__samples"), + sample_count=models.Case( + models.When( + extractionorder__isnull=False, + then=models.Count("extractionorder__samples", distinct=True), + ), + models.When( + analysisorder__isnull=False, + then=models.Count("analysisorder__samples", distinct=True), + ), + default=0, + ) ) .prefetch_related( "analysisorder__markers", @@ -116,14 +136,12 @@ def assigned_orders_table(context: dict) -> dict: ) .select_related("genrequest") .annotate( - sample_count=models.Count("extractionorder__samples"), - ) - .annotate( + sample_count=models.Count("extractionorder__samples", distinct=True), priority=models.Case( models.When(is_urgent=True, then=Order.OrderPriority.URGENT), models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), default=1, - ) + ), ) .order_by( models.Case( From 21118da4387a0f90539d65893f83d83f912762a7 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 8 Jul 2025 11:30:32 +0200 Subject: [PATCH 86/99] Remove redundant prefetch (#219) --- src/staff/templatetags/order_tags.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index 09278823..1d7581fb 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -69,9 +69,6 @@ def new_seen_orders_table(context: dict, area: Area | None = None) -> dict: default=1, ), ) - .prefetch_related( - "analysisorder__markers", - ) ) if area: @@ -106,9 +103,6 @@ def new_unseen_orders_table(context: dict, area: Area | None = None) -> dict: default=0, ) ) - .prefetch_related( - "analysisorder__markers", - ) ) if area: From 97232f01d1e10919d1d411d9d777bbc8e3a13acf Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 8 Jul 2025 15:49:33 +0200 Subject: [PATCH 87/99] Make responsible staff blank (#220) --- .../0023_alter_order_responsible_staff.py | 25 +++++++++++++++++++ src/genlab_bestilling/models.py | 1 + 2 files changed, 26 insertions(+) create mode 100644 src/genlab_bestilling/migrations/0023_alter_order_responsible_staff.py diff --git a/src/genlab_bestilling/migrations/0023_alter_order_responsible_staff.py b/src/genlab_bestilling/migrations/0023_alter_order_responsible_staff.py new file mode 100644 index 00000000..0848c783 --- /dev/null +++ b/src/genlab_bestilling/migrations/0023_alter_order_responsible_staff.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-07-08 10:19 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0022_sample_internal_note"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="responsible_staff", + field=models.ManyToManyField( + blank=True, + help_text="Staff members responsible for this order", + related_name="responsible_orders", + to=settings.AUTH_USER_MODEL, + verbose_name="Responsible staff", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index cbc645f0..8667dcc0 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -285,6 +285,7 @@ class OrderPriority: related_name="responsible_orders", verbose_name="Responsible staff", help_text="Staff members responsible for this order", + blank=True, ) is_seen = models.BooleanField( default=False, help_text="If an order has been seen by a staff" From e5b844f0857275c7aa01f63c1ff9855978e8600e Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 8 Jul 2025 15:50:23 +0200 Subject: [PATCH 88/99] Add description to my orders (#221) --- src/staff/tables.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index e5407b27..ad5e2c7a 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -480,6 +480,12 @@ class AssignedOrderTable(StatusMixinTable, StaffIDMixinTable): template_name="staff/components/priority_column.html", ) + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + samples_completed = tables.Column( accessor="sample_count", verbose_name="Samples isolated", @@ -493,6 +499,6 @@ def render_samples_completed(self, value: int) -> str: class Meta: model = Order - fields = ["priority", "id", "samples_completed", "status"] + fields = ["priority", "id", "description", "samples_completed", "status"] empty_text = "No assigned orders" template_name = "django_tables2/tailwind_inner.html" From 2df47b58eec88c9464ab1d7e72388d0d3b8d2a4e Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 8 Jul 2025 15:50:32 +0200 Subject: [PATCH 89/99] Stack dashboard on smaller screens (#224) --- src/staff/templates/staff/dashboard.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 1530d21a..6b1db90f 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -21,7 +21,7 @@

-
+
{% urgent_orders_table area=area %} {% new_unseen_orders_table area=area %} From f7ef331b84080704c57f3b22a4c0300de4056257 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Tue, 8 Jul 2025 15:50:41 +0200 Subject: [PATCH 90/99] Display orders assigned to user (#223) --- src/staff/templatetags/order_tags.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index 1d7581fb..97591078 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -120,13 +120,16 @@ def new_unseen_orders_table(context: dict, area: Area | None = None) -> dict: @register.inclusion_tag("staff/components/order_table.html", takes_context=True) def assigned_orders_table(context: dict) -> dict: + user = context.get("user") + assigned_orders = ( Order.objects.filter( status__in=[ Order.OrderStatus.PROCESSING, Order.OrderStatus.DELIVERED, Order.OrderStatus.COMPLETED, - ] + ], + responsible_staff=user, ) .select_related("genrequest") .annotate( From b141c1214695461ea5c4d284f445e5a5c1aaeae6 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Wed, 9 Jul 2025 08:35:27 +0200 Subject: [PATCH 91/99] Assign statuses with lower weight than selected (#226) --- src/staff/views.py | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/staff/views.py b/src/staff/views.py index 6bc92bca..06da1f43 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -351,25 +351,38 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) order = self.get_order() + statuses = SampleStatus.objects.filter( + area=order.genrequest.area, + ).all() - try: - # Get status based on name and area to ensure only one status is returned - status = SampleStatus.objects.get( - name=status_name, area=order.genrequest.area - ) - except SampleStatus.DoesNotExist: - messages.error(request, f"Status '{status_name}' not found.") + # Check if the provided status exists + if status_name not in [status.name for status in statuses]: + messages.error(request, f"Status '{status_name}' is not valid.") return HttpResponseRedirect(self.get_success_url()) + # Get the selected samples samples = Sample.objects.filter(id__in=selected_ids) - # Create status assignments if not existing + # Get the selected status and all statuses with a lower or equal weight + selected_status = statuses.filter(name=status_name).first() + statuses_to_apply = statuses.filter(weight__lte=selected_status.weight) + + # Apply status assignments + assignments = [] for sample in samples: - SampleStatusAssignment.objects.get_or_create( - sample=sample, - status=status, - order=order, - ) + for status in statuses_to_apply: + assignments.append( + SampleStatusAssignment( + sample=sample, + status=status, + order=order, + ) + ) + + SampleStatusAssignment.objects.bulk_create( + assignments, + ignore_conflicts=True, + ) messages.success( request, f"{len(samples)} samples updated with status '{status_name}'." From d230dbe531b4f93ef3af9e514421d452e40ab334 Mon Sep 17 00:00:00 2001 From: Ole Magnus Date: Wed, 9 Jul 2025 09:41:04 +0200 Subject: [PATCH 92/99] Add table for draft orders to dashboard (#227) --- src/staff/tables.py | 51 ++++++++++++++++++++++++ src/staff/templates/staff/dashboard.html | 4 ++ src/staff/templatetags/order_tags.py | 27 +++++++++++++ 3 files changed, 82 insertions(+) diff --git a/src/staff/tables.py b/src/staff/tables.py index ad5e2c7a..8a33a464 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -502,3 +502,54 @@ class Meta: fields = ["priority", "id", "description", "samples_completed", "status"] empty_text = "No assigned orders" template_name = "django_tables2/tailwind_inner.html" + + +class DraftOrderTable(StaffIDMixinTable): + priority = tables.TemplateColumn( + orderable=False, + verbose_name="Priority", + accessor="priority", + template_name="staff/components/priority_column.html", + ) + + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + + delivery_date = tables.Column( + accessor="genrequest__expected_samples_delivery_date", + verbose_name="Delivery date", + orderable=False, + ) + + def render_delivery_date(self, value: Any) -> str: + if value: + return value.strftime("%d/%m/%Y") + return "-" + + samples = tables.Column( + accessor="sample_count", + verbose_name="Samples", + orderable=False, + ) + + markers = tables.ManyToManyColumn( + transform=lambda x: x.name, + verbose_name="Markers", + orderable=False, + ) + + class Meta: + model = Order + fields = [ + "priority", + "id", + "description", + "delivery_date", + "markers", + "samples", + ] + empty_text = "No draft orders" + template_name = "django_tables2/tailwind_inner.html" diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 6b1db90f..b57cc296 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -30,6 +30,10 @@
{% assigned_orders_table %} + + {% if user.is_superuser %} + {% draft_orders_table area=area %} + {% endif %}
{% endblock %} diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index 97591078..77f63f48 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -5,6 +5,7 @@ from ..tables import ( AssignedOrderTable, + DraftOrderTable, NewSeenOrderTable, NewUnseenOrderTable, UrgentOrderTable, @@ -159,3 +160,29 @@ def assigned_orders_table(context: dict) -> dict: "count": assigned_orders.count(), "request": context.get("request"), } + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def draft_orders_table(context: dict, area: Area) -> dict: + draft_orders = ( + Order.objects.filter(status=Order.OrderStatus.DRAFT) + .select_related("genrequest") + .annotate( + sample_count=models.Count("extractionorder__samples", distinct=True), + priority=models.Case( + models.When(is_urgent=True, then=Order.OrderPriority.URGENT), + default=1, + ), + ) + .order_by("-priority", "-created_at") + ) + + if area: + draft_orders = draft_orders.filter(genrequest__area=area) + + return { + "title": "Draft orders", + "table": DraftOrderTable(draft_orders), + "count": draft_orders.count(), + "request": context.get("request"), + } From b9da6bb1dfed406a421d34a93e95acaf4c57eaf8 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Wed, 9 Jul 2025 09:42:26 +0200 Subject: [PATCH 93/99] 203 add button to check new orders if you are responsible analysis and extraction (#217) * Added frontend button to mark as seen * Frontend for checking orders as "is_seen". No checks for responsible genetic project staff. * Added bell icon to the rows that are not yet marked as seen. * Mark as seen is now one url and view. * Added hidden field to separate the return pages. * Removed unnecessary code --- src/staff/tables.py | 17 ++++++++- .../templates/staff/analysisorder_detail.html | 8 ++++ .../staff/components/seen_column.html | 5 ++- .../staff/extractionorder_detail.html | 8 ++++ src/staff/urls.py | 8 +++- src/staff/views.py | 37 ++++++++++++------- 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/staff/tables.py b/src/staff/tables.py index 8a33a464..ff4ec665 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -48,6 +48,12 @@ class OrderTable(tables.Table): orderable=False, ) + is_seen = tables.Column( + orderable=False, + visible=True, + verbose_name="", + ) + class Meta: fields = [ "name", @@ -60,8 +66,9 @@ class Meta: "created_at", "last_modified_at", "is_urgent", + "is_seen", ] - sequence = ("is_urgent", "status", "id", "name") + sequence = ("is_seen", "is_urgent", "status", "id", "name") empty_text = "No Orders" order_by = ("-is_urgent", "last_modified_at", "created_at") @@ -77,6 +84,14 @@ def render_is_urgent(self, value: bool) -> str: else: return "" + def render_is_seen(self, value: bool) -> str: + if not value: + return mark_safe( + '' + ) + return "" + class AnalysisOrderTable(OrderTable): id = tables.Column( diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 777520ec..74e33d3a 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -17,6 +17,14 @@

Order {{ object }}

Assign staff
+ + {% if not object.is_seen %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if object.status == object.OrderStatus.DELIVERED %} {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} {% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} diff --git a/src/staff/templates/staff/components/seen_column.html b/src/staff/templates/staff/components/seen_column.html index d8c004b9..4e318c3c 100644 --- a/src/staff/templates/staff/components/seen_column.html +++ b/src/staff/templates/staff/components/seen_column.html @@ -1,4 +1,5 @@ -
+ {% csrf_token %} - + +
diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index c8a80876..6ff27ac4 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -37,6 +37,14 @@

Order {{ object }}

Assign staff
+ + {% if not object.is_seen %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if object.status == object.OrderStatus.DELIVERED %} {% url 'staff:order-manually-checked' pk=object.id as confirm_check_url %} {% action-button action=confirm_check_url class="bg-secondary text-white" submit_text="Confirm - Order checked" csrf_token=csrf_token %} diff --git a/src/staff/urls.py b/src/staff/urls.py index 07a7e696..fcfd21fd 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -13,10 +13,10 @@ ExtractionPlateListView, GenerateGenlabIDsView, ManaullyCheckedOrderActionView, + MarkAsSeenView, OrderAnalysisSamplesListView, OrderExtractionSamplesListView, OrderPrioritizedAdminView, - OrderSeenAdminView, OrderStaffEditView, OrderToDraftActionView, OrderToNextStatusActionView, @@ -124,6 +124,11 @@ EquipmentOrderDetailView.as_view(), name="order-equipment-detail", ), + path( + "order/mark-as-seen//", + MarkAsSeenView.as_view(), + name="mark-as-seen", + ), path( "orders/extraction//", ExtractionOrderDetailView.as_view(), @@ -144,7 +149,6 @@ ExtractionPlateDetailView.as_view(), name="plates-detail", ), - path("orders//seen/", OrderSeenAdminView.as_view(), name="order-seen"), path( "orders//priority/", OrderPrioritizedAdminView.as_view(), diff --git a/src/staff/views.py b/src/staff/views.py index 06da1f43..391d6849 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -179,6 +179,30 @@ class EquipmentOrderDetailView(StaffMixin, DetailView): model = EquipmentOrder +class MarkAsSeenView(StaffMixin, DetailView): + model = Order + + def get_object(self) -> Order: + return Order.objects.get(pk=self.kwargs["pk"]) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + try: + order = self.get_object() + order.toggle_seen() + messages.success(request, _("Order is marked as seen")) + except Exception as e: + messages.error(request, f"Error: {str(e)}") + + return_to = request.POST.get("return_to") + return HttpResponseRedirect(self.get_return_url(return_to)) + + def get_return_url(self, return_to: str) -> str: + if return_to == "dashboard": + return reverse("staff:dashboard") + else: + return self.get_object().get_absolute_staff_url() + + class ExtractionOrderDetailView(StaffMixin, DetailView): model = ExtractionOrder @@ -702,19 +726,6 @@ def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) -class OrderSeenAdminView(StaffMixin, ActionView): - def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - pk = kwargs.get("pk") - order = Order.objects.get(pk=pk) - order.toggle_seen() - - return HttpResponseRedirect( - reverse( - "staff:dashboard", - ) - ) - - class OrderPrioritizedAdminView(StaffMixin, ActionView): def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: pk = kwargs.get("pk") From bf2f69146fa0efc145b9eaa033db4cddf46548a4 Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:30:30 +0200 Subject: [PATCH 94/99] Made a workaround to not have a buggy upper flag in samples list. (#228) * Made a workaround to not have a buggy upper flag in samples list. * Fix linter errors --- src/staff/templates/staff/prioritise_flag.html | 11 +++-------- src/staff/templates/staff/sample_filter.html | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/staff/templates/staff/prioritise_flag.html b/src/staff/templates/staff/prioritise_flag.html index 43e42094..36eb56ca 100644 --- a/src/staff/templates/staff/prioritise_flag.html +++ b/src/staff/templates/staff/prioritise_flag.html @@ -1,8 +1,3 @@ -
- {% csrf_token %} - - -
+ diff --git a/src/staff/templates/staff/sample_filter.html b/src/staff/templates/staff/sample_filter.html index 55f3d5d8..430cc1b9 100644 --- a/src/staff/templates/staff/sample_filter.html +++ b/src/staff/templates/staff/sample_filter.html @@ -38,5 +38,22 @@ {% render_table table %} + + {% endif %} + {% endblock page-inner %} From 3c5af4a5236f1583de8bf3b246d881a050a5159d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Cant=C3=B9?= Date: Thu, 10 Jul 2025 15:16:04 +0200 Subject: [PATCH 95/99] BREAKING CHANGE - remove the use of database sequences (#230) --- src/genlab_bestilling/libs/genlabid.py | 200 ------------- src/genlab_bestilling/managers.py | 75 ++++- .../migrations/0024_gidsequence.py | 46 +++ src/genlab_bestilling/models.py | 77 ++++- src/genlab_bestilling/tasks.py | 27 -- src/genlab_bestilling/tests/test_models.py | 278 +++++++++++++++++- src/shared/forms.py | 38 ++- 7 files changed, 503 insertions(+), 238 deletions(-) delete mode 100644 src/genlab_bestilling/libs/genlabid.py create mode 100644 src/genlab_bestilling/migrations/0024_gidsequence.py delete mode 100644 src/genlab_bestilling/tasks.py diff --git a/src/genlab_bestilling/libs/genlabid.py b/src/genlab_bestilling/libs/genlabid.py deleted file mode 100644 index 1616c471..00000000 --- a/src/genlab_bestilling/libs/genlabid.py +++ /dev/null @@ -1,200 +0,0 @@ -from typing import Any - -import sqlglot -import sqlglot.expressions -from django.db import connection, transaction -from sqlglot.expressions import ( - EQ, - Alias, - Cast, - DataType, - Extract, - From, - Literal, - Subquery, - and_, - column, -) - -from ..models import ExtractionOrder, Order, Sample, Species - - -def get_replica_for_sample() -> None: - """ - TODO: implement - """ - pass - - -def get_current_sequences(order_id: int | str) -> Any: - """ - Invoke a Postgres function to get the current sequence number - for a specific combination of year and species. - """ - samples = ( - Sample.objects.select_related("order", "species") - .filter(order=order_id) - .values("order__created_at", "species__code") - .distinct() - ) - - with connection.cursor() as cursor: - sequences = {} - for sample in samples: - query = sqlglot.select( - sqlglot.expressions.func( - "get_genlab_sequence_name", - sqlglot.expressions.Literal.string(sample["species__code"]), - sqlglot.expressions.Literal.number( - sample["order__created_at"].year - ), - dialect="postgres", - ) - ).sql(dialect="postgres") - seq = cursor.execute( - query, - ).fetchone()[0] - sequences[seq] = 0 - - for k in sequences.keys(): - query = sqlglot.select("last_value").from_(k).sql(dialect="postgres") - sequences[k] = cursor.execute(query).fetchone()[0] - - return sequences - - -def generate( - order_id: int | str, - sorting_order: list[str] | None = None, - selected_samples: list[Any] | None = None, -) -> None: - """ - wrapper to handle errors and reset the sequence to the current sequence value - """ - sequences = get_current_sequences(order_id) - print(sequences) - - with connection.cursor() as cursor: - try: - with transaction.atomic(): - cursor.execute(update_genlab_id_query(order_id)) - except Exception: - # if there is an error, reset the sequence - # NOTE: this is unsafe unless this function is executed in a queue - # by just one worker to prevent concurrency - with connection.cursor() as cursor: # noqa: PLW2901 # Was this intentional? - for k, v in sequences.items(): - cursor.execute("SELECT setval(%s, %s)", [k, v]) - - sequences = get_current_sequences(order_id) - print(sequences) - - -def update_genlab_id_query(order_id: int | str) -> Any: - """ - Safe generation of a SQL raw query using sqlglot - The query runs an update on all the rows with a specific order_id - and set genlab_id = generate_genlab_id(code, year) - """ - samples_table = Sample._meta.db_table - extraction_order_table = ExtractionOrder._meta.db_table - order_table = Order._meta.db_table - species_table = Species._meta.db_table - - return sqlglot.expressions.update( - samples_table, - properties={ - "genlab_id": column( - "genlab_id", - table="order_samples", - ) - }, - where=sqlglot.expressions.EQ( - this=column("id", table=samples_table), - expression="order_samples.id", - ), - from_=From( - this=Alias( - this=Subquery( - this=( - sqlglot.select( - column( - "id", - table=samples_table, - ), - Alias( - this=sqlglot.expressions.func( - "generate_genlab_id", - column("code", table=species_table), - Cast( - this=Extract( - this="YEAR", - expression=column( - "confirmed_at", - table=order_table, - ), - ), - to=DataType( - this=DataType.Type.INT, nested=False - ), - ), - ), - alias="genlab_id", - ), - ) - .join( - extraction_order_table, - on=EQ( - this=column( - "order_id", - table=samples_table, - ), - expression=column( - "order_ptr_id", - table=extraction_order_table, - ), - ), - ) - .join( - order_table, - on=EQ( - this=column( - "order_ptr_id", - table=extraction_order_table, - ), - expression=column( - "id", - table=order_table, - ), - ), - ) - .from_(samples_table) - .join( - species_table, - on=EQ( - this=column( - "species_id", - table=samples_table, - ), - expression=column( - "id", - table=species_table, - ), - ), - ) - .where( - and_( - EQ( - this=column(col="order_id"), - expression=Literal.number(order_id), - ), - column(col="genlab_id").is_(None), - ) - ) - .order_by(column("name", table=samples_table)) - ), - ), - alias="order_samples", - ) - ), - ).sql(dialect="postgres") diff --git a/src/genlab_bestilling/managers.py b/src/genlab_bestilling/managers.py index b5ffc099..bbf5e9af 100644 --- a/src/genlab_bestilling/managers.py +++ b/src/genlab_bestilling/managers.py @@ -1,9 +1,17 @@ -from django.db import models +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db import models, transaction from django.db.models import QuerySet +from django.db.models.expressions import RawSQL from polymorphic.managers import PolymorphicManager, PolymorphicQuerySet from capps.users.models import User +if TYPE_CHECKING: + from .models import GIDSequence, Species + class GenrequestQuerySet(models.QuerySet): def filter_allowed(self, user: User) -> QuerySet: @@ -46,6 +54,9 @@ def filter_in_draft(self) -> QuerySet: ) +DEFAULT_SORTING_FIELDS = ["name_as_int", "name"] + + class SampleQuerySet(models.QuerySet): def filter_allowed(self, user: User) -> QuerySet: """ @@ -61,6 +72,48 @@ def filter_in_draft(self) -> QuerySet: order__status=self.model.OrderStatus.DRAFT ) + @transaction.atomic + def generate_genlab_ids( + self, + order_id: int, + sorting_order: list[str] | None = DEFAULT_SORTING_FIELDS, + selected_samples: list[int] | None = None, + ) -> None: + """ + genlab ids given a certain order_id, sorting order and sample ids + + """ + + # Lock the samples + samples = ( + self.select_related("species", "order") + .filter(order_id=order_id, genlab_id__isnull=True) + .select_for_update() + ) + + if selected_samples: + samples = samples.filter(id__in=selected_samples) + + if sorting_order == DEFAULT_SORTING_FIELDS: + # create an annotation containg all integer values + # of "name", so that it's possible to sort numerically and alphabetically + samples = samples.annotate( + name_as_int=RawSQL( + r"substring(%s from '^\d+$')::int", + params=["name"], + output_field=models.IntegerField(), + ) + ).order_by(*sorting_order) + else: + samples = samples.order_by(*sorting_order) + + updates = [] + for sample in samples: + sample.generate_genlab_id(commit=False) + updates.append(sample) + + self.bulk_update(updates, ["genlab_id"]) + class SampleAnalysisMarkerQuerySet(models.QuerySet): def filter_allowed(self, user: User) -> QuerySet: @@ -76,3 +129,23 @@ def filter_in_draft(self) -> QuerySet: return self.select_related("order").filter( order__status=self.model.OrderStatus.DRAFT ) + + +class GIDSequenceQuerySet(models.QuerySet): + def get_sequence_for_species_year( + self, species: Species, year: int, lock: bool = False + ) -> GIDSequence: + """ + Get or creates an ID sequence based on the sample year and species + """ + if lock: + s = self.select_for_update() + else: + s = self + + sequence_id, _ = s.get_or_create( + year=year, + species=species, + defaults={"id": f"G{year % 100}{species.code}"}, + ) + return sequence_id diff --git a/src/genlab_bestilling/migrations/0024_gidsequence.py b/src/genlab_bestilling/migrations/0024_gidsequence.py new file mode 100644 index 00000000..dffb7cee --- /dev/null +++ b/src/genlab_bestilling/migrations/0024_gidsequence.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.3 on 2025-07-09 13:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0023_alter_order_responsible_staff"), + ] + + operations = [ + migrations.CreateModel( + name="GIDSequence", + fields=[ + ("id", models.CharField(primary_key=True, serialize=False)), + ("last_value", models.IntegerField(default=0)), + ("year", models.IntegerField()), + ( + "sample", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="replica_sequence", + to="genlab_bestilling.sample", + ), + ), + ( + "species", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.PROTECT, + to="genlab_bestilling.species", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("year", "species"), name="unique_id_year_species" + ) + ], + }, + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 8667dcc0..57d42414 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -9,7 +9,6 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from polymorphic.models import PolymorphicModel -from procrastinate.contrib.django import app from rest_framework.exceptions import ValidationError from taggit.managers import TaggableManager @@ -492,9 +491,9 @@ def order_manually_checked(self) -> None: """ self.internal_status = self.Status.CHECKED self.status = self.OrderStatus.PROCESSING - self.save() - app.configure_task(name="generate-genlab-ids").defer(order_id=self.id) + self.save(update_fields=["internal_status", "status"]) + @transaction.atomic def order_selected_checked( self, sorting_order: list[str] | None = None, @@ -506,14 +505,15 @@ def order_selected_checked( """ self.internal_status = self.Status.CHECKED self.status = self.OrderStatus.PROCESSING - self.save() + self.save(update_fields=["internal_status", "status"]) - selected_sample_names = list(selected_samples.values_list("id", flat=True)) + if not selected_samples.exists(): + return - app.configure_task(name="generate-genlab-ids").defer( + Sample.objects.generate_genlab_ids( order_id=self.id, sorting_order=sorting_order, - selected_samples=selected_sample_names, + selected_samples=selected_samples, ) @@ -738,6 +738,23 @@ def has_error(self) -> bool: return False + @transaction.atomic + def generate_genlab_id(self, commit: bool = True) -> str: + if self.genlab_id: + return self.genlab_id + species = self.species + year = self.order.confirmed_at.year + + sequence = GIDSequence.objects.get_sequence_for_species_year( + year=year, species=species, lock=True + ) + self.genlab_id = sequence.next_value() + + if commit: + self.save(update_fields=["genlab_id"]) + + return self.genlab_id + # class Analysis(models.Model): # type = @@ -852,3 +869,49 @@ class AnalysisResult(models.Model): def __str__(self) -> str: return f"{self.name}" + + +class GIDSequence(models.Model): + """ + Represents a sequence of IDs + This table provides a way to atomically update + the counter of each combination of (species, year) + + NOTE: this replaces the usage of postgres sequences, + while probably slower, this table can be atomically updated + """ + + id = models.CharField(primary_key=True) + last_value = models.IntegerField(default=0) + year = models.IntegerField() + species = models.ForeignKey( + f"{an}.Species", on_delete=models.PROTECT, db_constraint=False + ) + sample = models.OneToOneField( + f"{an}.Sample", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="replica_sequence", + ) + + def __str__(self): + return f"{self.id}@{self.last_value}" + + class Meta: + constraints = [ + models.UniqueConstraint( + name="unique_id_year_species", fields=["year", "species"] + ), + ] + + objects = managers.GIDSequenceQuerySet.as_manager() + + @transaction.atomic() + def next_value(self) -> str: + """ + Update the last_value transactionally and return the corresponding genlab_id + """ + self.last_value += 1 + self.save(update_fields=["last_value"]) + return f"{self.id}{self.last_value:05d}" diff --git a/src/genlab_bestilling/tasks.py b/src/genlab_bestilling/tasks.py deleted file mode 100644 index d92b0a78..00000000 --- a/src/genlab_bestilling/tasks.py +++ /dev/null @@ -1,27 +0,0 @@ -# from .libs.isolation import isolate -from typing import Any - -from django.db.utils import OperationalError -from procrastinate import RetryStrategy -from procrastinate.contrib.django import app - -from .libs.genlabid import generate as generate_genlab_id - - -@app.task( - name="generate-genlab-ids", - retry=RetryStrategy( - max_attempts=5, linear_wait=5, retry_exceptions={OperationalError} - ), -) -def generate_ids( - order_id: int | str, - sorting_order: list[str] | None = None, - selected_samples: list[Any] | None = None, -) -> None: - generate_genlab_id( - order_id=order_id, - sorting_order=sorting_order, - selected_samples=selected_samples, - ) - # isolate(order_id=order_id) diff --git a/src/genlab_bestilling/tests/test_models.py b/src/genlab_bestilling/tests/test_models.py index e76eb1b3..6e1dd7e3 100644 --- a/src/genlab_bestilling/tests/test_models.py +++ b/src/genlab_bestilling/tests/test_models.py @@ -1,4 +1,17 @@ -from genlab_bestilling.models import AnalysisOrder, Marker +import itertools +import uuid + +import pytest +from django.db import IntegrityError, transaction +from pytest_django.asserts import assertQuerySetEqual + +from genlab_bestilling.models import ( + AnalysisOrder, + ExtractionOrder, + GIDSequence, + Marker, + Sample, +) def test_analysis_populate_without_order(genlab_setup): @@ -43,3 +56,266 @@ def test_analysis_populate_with_order_all_markers(extraction): ao.markers.add(*m) ao.populate_from_order() assert ao.sample_markers.count() == 6 + + +def test_gid_sequence_for_species_year(extraction): + extraction.confirm_order() + assert GIDSequence.objects.exists() is False + sample = extraction.samples.first() + gid = GIDSequence.objects.get_sequence_for_species_year( + year=extraction.confirmed_at.year, species=sample.species, lock=False + ) + assert gid.id == f"G{extraction.confirmed_at.year % 100}{sample.species.code}" + assert gid.last_value == 0 + + +def test_gid_sequence_increment(extraction): + """ + Test the increment of the sequence + """ + extraction.confirm_order() + assert GIDSequence.objects.exists() is False + sample = extraction.samples.first() + + with transaction.atomic(): + gid = GIDSequence.objects.get_sequence_for_species_year( + year=extraction.confirmed_at.year, species=sample.species + ) + assert gid.id == f"G{extraction.confirmed_at.year % 100}{sample.species.code}" + assert gid.last_value == 0 + assert ( + gid.next_value() + == f"G{extraction.confirmed_at.year % 100}{sample.species.code}00001" + ) + assert gid.last_value == 1 + + +def test_gid_sequence_rollback(extraction): + """ + Test the rollback of the sequence + """ + extraction.confirm_order() + assert GIDSequence.objects.exists() is False + sample = extraction.samples.first() + gid = GIDSequence.objects.get_sequence_for_species_year( + year=extraction.confirmed_at.year, species=sample.species + ) + with pytest.raises(IntegrityError): + with transaction.atomic(): + gid = GIDSequence.objects.select_for_update().get(id=gid.id) + assert ( + gid.id == f"G{extraction.confirmed_at.year % 100}{sample.species.code}" + ) + assert gid.last_value == 0 + assert ( + gid.next_value() + == f"G{extraction.confirmed_at.year % 100}{sample.species.code}00001" + ) + assert gid.last_value == 1 + + # Expect an error here + GIDSequence.objects.create( + id=gid.id, + year=gid.year, + species=gid.species, + ) + + gid.refresh_from_db() + assert gid.last_value == 0 + + +def test_sample_genlab_id_generation(extraction): + """ + Test the generation of the id for a single sample + """ + extraction.confirm_order() + sample = extraction.samples.first() + assert ( + sample.generate_genlab_id() + == f"G{extraction.confirmed_at.year % 100}{sample.species.code}00001" + ) + + +def test_full_order_ids_generation(extraction): + """ + Test that by default all the ids are generated + """ + extraction.confirm_order() + + Sample.objects.generate_genlab_ids(extraction.id) + + assertQuerySetEqual( + Sample.objects.filter(genlab_id__isnull=False), + Sample.objects.all(), + ordered=False, + ) + + +def test_order_selected_ids_generation(extraction): + """ + Test that is possible to generate only a subset of ids + """ + extraction.confirm_order() + + Sample.objects.generate_genlab_ids( + extraction.id, + selected_samples=extraction.samples.all().values("id")[ + : extraction.samples.count() - 1 + ], + ) + + assert ( + Sample.objects.filter(genlab_id__isnull=False).count() + 1 + == Sample.objects.all().count() + ) + + +def test_ids_generation_with_only_numeric_names(genlab_setup): + """ + Test that by default the ordering is done on the column name + ordering first the integers, and then all the other rows alphabetically + """ + extraction = ExtractionOrder.objects.create( + genrequest_id=1, + return_samples=False, + pre_isolated=False, + ) + extraction.species.add(*extraction.genrequest.species.all()) + extraction.sample_types.add(*extraction.genrequest.sample_types.all()) + + combo = list( + itertools.product(extraction.species.all(), extraction.sample_types.all()) + ) + year = 2020 + + s1 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name=10, + ) + + s2 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name=1, + ) + + s3 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name="20b", + ) + + s4 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name="20a", + ) + + extraction.confirm_order() + + Sample.objects.generate_genlab_ids(order_id=extraction.id) + + gid = GIDSequence.objects.get_sequence_for_species_year( + species=combo[0][0], year=extraction.confirmed_at.year, lock=False + ) + + s1.refresh_from_db() + s2.refresh_from_db() + s3.refresh_from_db() + s4.refresh_from_db() + + assert s1.genlab_id == gid.id + "00002" + assert s2.genlab_id == gid.id + "00001" + assert s3.genlab_id == gid.id + "00004" + assert s4.genlab_id == gid.id + "00003" + + +def test_ids_generation_order_by_pop_id(genlab_setup): + """ + Test that ids are can be generated using a custom order based on different fields + """ + extraction = ExtractionOrder.objects.create( + genrequest_id=1, + return_samples=False, + pre_isolated=False, + ) + extraction.species.add(*extraction.genrequest.species.all()) + extraction.sample_types.add(*extraction.genrequest.sample_types.all()) + + combo = list( + itertools.product(extraction.species.all(), extraction.sample_types.all()) + ) + year = 2020 + + s1 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name=10, + pop_id="z", + ) + + s2 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name=1, + pop_id="c", + ) + + s3 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name="20b", + pop_id="z", + ) + + s4 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name="20a", + pop_id="r", + ) + + extraction.confirm_order() + + Sample.objects.generate_genlab_ids( + order_id=extraction.id, sorting_order=["pop_id", "name"] + ) + + gid = GIDSequence.objects.get_sequence_for_species_year( + species=combo[0][0], year=extraction.confirmed_at.year, lock=False + ) + + s1.refresh_from_db() + s2.refresh_from_db() + s3.refresh_from_db() + s4.refresh_from_db() + + assert s1.genlab_id == gid.id + "00003" + assert s2.genlab_id == gid.id + "00001" + assert s3.genlab_id == gid.id + "00004" + assert s4.genlab_id == gid.id + "00002" diff --git a/src/shared/forms.py b/src/shared/forms.py index 249b3af5..90cc5f2e 100644 --- a/src/shared/forms.py +++ b/src/shared/forms.py @@ -1,12 +1,46 @@ +# ruff: noqa: ANN001, PLR0913 from django import forms class ActionForm(forms.Form): + """ + A form with an hidden field + use this as to submit forms that don't require extra parameters + or specific GET pages + based on Django standard form + + NOTE: it ignores the additional kwargs provided + """ + hidden = forms.CharField(required=False, widget=forms.widgets.HiddenInput()) def __init__( self, - *args, + data=None, + files=None, + auto_id="id_%s", + prefix=None, + initial=None, + error_class=forms.utils.ErrorList, + label_suffix=None, + empty_permitted=False, + field_order=None, + use_required_attribute=None, + renderer=None, + bound_field_class=None, **kwargs, ) -> None: - super().__init__(*args, **kwargs) + super().__init__( + data=data, + files=files, + auto_id=auto_id, + prefix=prefix, + initial=initial, + error_class=error_class, + label_suffix=label_suffix, + empty_permitted=empty_permitted, + field_order=field_order, + use_required_attribute=use_required_attribute, + renderer=renderer, + bound_field_class=bound_field_class, + ) From 159583d78e843a7bb15a93748f498787db03a89f Mon Sep 17 00:00:00 2001 From: Bertine <112892518+aastabk@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:07:43 +0200 Subject: [PATCH 96/99] Able to add staff to genrequest (#244) * Able to add staff to genrequest * Fixed migration issues --- .../0025_genrequest_responsible_staff.py | 25 ++++++++++++++++ src/genlab_bestilling/models.py | 10 +++++++ .../genlab_bestilling/genrequest_detail.html | 7 +++++ src/staff/forms.py | 6 ++-- .../templates/staff/analysisorder_detail.html | 2 +- .../staff/equipmentorder_detail.html | 2 +- .../staff/extractionorder_detail.html | 2 +- .../templates/staff/order_staff_edit.html | 6 +++- src/staff/urls.py | 8 ++--- src/staff/views.py | 30 ++++++++++++++----- 10 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0025_genrequest_responsible_staff.py diff --git a/src/genlab_bestilling/migrations/0025_genrequest_responsible_staff.py b/src/genlab_bestilling/migrations/0025_genrequest_responsible_staff.py new file mode 100644 index 00000000..f8f3bf40 --- /dev/null +++ b/src/genlab_bestilling/migrations/0025_genrequest_responsible_staff.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-07-11 08:39 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0024_gidsequence"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="genrequest", + name="responsible_staff", + field=models.ManyToManyField( + blank=True, + help_text="Staff members responsible for this order", + related_name="responsible_genrequest", + to=settings.AUTH_USER_MODEL, + verbose_name="Responsible staff", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 57d42414..0956a4f3 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -201,6 +201,13 @@ class Genrequest(models.Model): # type: ignore[django-manager-missing] help_text="samples you plan to deliver, you can choose more than one. " + "ONLY sample types selected here will be available later", ) + responsible_staff = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name="responsible_genrequest", + verbose_name="Responsible staff", + help_text="Staff members responsible for this order", + blank=True, + ) markers = models.ManyToManyField(f"{an}.Marker", blank=True) created_at = models.DateTimeField(auto_now_add=True) last_modified_at = models.DateTimeField(auto_now=True) @@ -217,6 +224,9 @@ def get_absolute_url(self) -> str: kwargs={"pk": self.pk}, ) + def get_type(self) -> str: + return "genrequest" + @property def short_timeframe(self) -> bool: return ( diff --git a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html index 71a042b5..191aefc5 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html @@ -51,6 +51,13 @@

{{ object.project_id }} - {{ object.name|d href="{% url 'genrequest-delete' pk=genrequest.id %}" > Delete + + {% if user.is_staff %} + + + Assign staff + + {% endif %}

{% endblock %} diff --git a/src/staff/forms.py b/src/staff/forms.py index 76e115d7..e578fe9c 100644 --- a/src/staff/forms.py +++ b/src/staff/forms.py @@ -3,7 +3,7 @@ from formset.renderers.tailwind import FormRenderer from capps.users.models import User -from genlab_bestilling.models import ExtractionPlate, Order +from genlab_bestilling.models import ExtractionPlate, Genrequest, Order class ExtractionPlateForm(ModelForm): @@ -22,7 +22,7 @@ class OrderStaffForm(forms.Form): required=False, ) - def __init__(self, *args, order: Order | None = None, **kwargs): + def __init__(self, *args, order: Order | Genrequest | None = None, **kwargs): super().__init__(*args, **kwargs) self.fields["responsible_staff"].choices = self.get_all_staff() @@ -32,7 +32,7 @@ def __init__(self, *args, order: Order | None = None, **kwargs): user.id for user in self.get_assigned_staff(order) ] - def get_assigned_staff(self, order: Order) -> list[User]: + def get_assigned_staff(self, order: Order | Genrequest) -> list[User]: return list(order.responsible_staff.all()) def get_all_staff(self) -> list[tuple[int, str]]: diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 74e33d3a..fc2ae045 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -14,7 +14,7 @@

Order {{ object }}

Go to {{ extraction_order}} {% endif %} - Assign staff + Assign staff
diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index 4969abaf..7f76f07b 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -34,7 +34,7 @@
Requested Equipment
back - Assign staff + Assign staff {% comment %} {% if object.status == 'draft' %} Edit diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index 6ff27ac4..cf21b4c0 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -34,7 +34,7 @@

Order {{ object }}

{% elif analysis_orders|length == 1 %} Go to {{ analysis_orders.first}} {% endif %} - Assign staff + Assign staff
diff --git a/src/staff/templates/staff/order_staff_edit.html b/src/staff/templates/staff/order_staff_edit.html index b3b28df5..efeab57b 100644 --- a/src/staff/templates/staff/order_staff_edit.html +++ b/src/staff/templates/staff/order_staff_edit.html @@ -16,7 +16,11 @@

Manage Responsible Staff - {{ object }}

- Back to Order + {% if model_type == "genrequest" %} + Back to Genrequest + {% else %} + Back to Order + {% endif %}
diff --git a/src/staff/urls.py b/src/staff/urls.py index fcfd21fd..6f7a5abd 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -17,7 +17,6 @@ OrderAnalysisSamplesListView, OrderExtractionSamplesListView, OrderPrioritizedAdminView, - OrderStaffEditView, OrderToDraftActionView, OrderToNextStatusActionView, ProjectDetailView, @@ -27,6 +26,7 @@ SampleLabView, SampleReplicaActionView, SamplesListView, + StaffEditView, UpdateLabViewFields, ) @@ -75,9 +75,9 @@ name="order-manually-checked", ), path( - "orders//add-staff/", - OrderStaffEditView.as_view(), - name="order-add-staff", + "//add-staff/", + StaffEditView.as_view(), + name="add-staff", ), path( "orders/extraction//samples/", diff --git a/src/staff/views.py b/src/staff/views.py index 391d6849..61d77b62 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -22,6 +22,7 @@ EquipmentOrder, ExtractionOrder, ExtractionPlate, + Genrequest, Order, Sample, SampleMarkerAnalysis, @@ -469,13 +470,19 @@ def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) -class OrderStaffEditView(StaffMixin, SingleObjectMixin, TemplateView): - model = Order +class StaffEditView(StaffMixin, SingleObjectMixin, TemplateView): form_class = OrderStaffForm template_name = "staff/order_staff_edit.html" - def get_queryset(self) -> models.QuerySet[Order]: - return super().get_queryset().filter(status=Order.OrderStatus.DELIVERED) + def get_queryset(self) -> models.QuerySet[Order] | models.QuerySet[Genrequest]: + model_type = self._get_model_type() + if model_type == "genrequest": + return Genrequest.objects.all() + return Order.objects.filter(status=Order.OrderStatus.DELIVERED) + + def _get_model_type(self) -> str: + """Returns model type based on request data.""" + return self.kwargs["model_type"] def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.object = self.get_object() @@ -494,8 +501,8 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: messages.SUCCESS, "Staff assignment updated successfully", ) - - return HttpResponseRedirect(self.get_success_url()) + model_type = self._get_model_type() + return HttpResponseRedirect(self.get_success_url(model_type)) return self.render_to_response(self.get_context_data(form=form)) @@ -503,12 +510,19 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["object"] = self.object context["form"] = self.form_class(order=self.object) + context["model_type"] = self._get_model_type() return context - def get_success_url(self) -> str: + def get_success_url(self, model_type: str | None) -> str: + if model_type == "genrequest": + return reverse( + "genrequest-detail", + kwargs={"pk": self.object.id}, + ) + return reverse_lazy( - f"staff:order-{self.object.get_type()}-detail", + f"staff:order-{model_type}-detail", kwargs={"pk": self.object.pk}, ) From 5d3d77b4399e7d0229ba647ba1615538dd2e6723 Mon Sep 17 00:00:00 2001 From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:30:46 +0200 Subject: [PATCH 97/99] Column for isolation method sample status (#243) * WIP: added isolation_method to sample and SampleIsolationMethod class * Add isolation method handling in sample status page (#238) Co-authored-by: Morten Madsen Lyngstad * Update migrations * Refactor post method to work with new changes and remove sorting on checkboxes * Refactor sample status handling Remove SampleStatus model, update migrations, and adjust related views and templates --------- Co-authored-by: Morten Madsen Lyngstad --- src/genlab_bestilling/admin.py | 10 +- ..._samplestatusassignment_status_and_more.py | 90 ++++++++++++ src/genlab_bestilling/models.py | 59 +++++--- src/staff/tables.py | 95 +++++++----- src/staff/templates/staff/sample_lab.html | 63 +++++--- src/staff/urls.py | 8 +- src/staff/views.py | 137 ++++++++++++------ 7 files changed, 335 insertions(+), 127 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0026_alter_samplestatusassignment_status_and_more.py diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 04f302ac..a9a7839f 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -15,13 +15,13 @@ ExtractionPlate, ExtractPlatePosition, Genrequest, + IsolationMethod, Location, LocationType, Marker, Organization, Sample, SampleMarkerAnalysis, - SampleStatus, SampleStatusAssignment, SampleType, Species, @@ -526,9 +526,9 @@ class AnalysisResultAdmin(ModelAdmin): ] -@admin.register(SampleStatus) -class SampleStatusAdmin(ModelAdmin): ... - - @admin.register(SampleStatusAssignment) class SampleStatusAssignmentAdmin(ModelAdmin): ... + + +@admin.register(IsolationMethod) +class IsolationMethodAdmin(ModelAdmin): ... diff --git a/src/genlab_bestilling/migrations/0026_alter_samplestatusassignment_status_and_more.py b/src/genlab_bestilling/migrations/0026_alter_samplestatusassignment_status_and_more.py new file mode 100644 index 00000000..8ce2f0a6 --- /dev/null +++ b/src/genlab_bestilling/migrations/0026_alter_samplestatusassignment_status_and_more.py @@ -0,0 +1,90 @@ +# Generated by Django 5.2.3 on 2025-07-11 10:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0025_genrequest_responsible_staff"), + ] + + operations = [ + migrations.AlterField( + model_name="samplestatusassignment", + name="status", + field=models.CharField( + blank=True, + choices=[ + ("marked", "Marked"), + ("plucked", "Plucked"), + ("isolated", "Isolated"), + ], + help_text="The status of the sample in the lab", + null=True, + verbose_name="Sample status", + ), + ), + migrations.RemoveField( + model_name="sample", + name="assigned_statuses", + ), + migrations.AddField( + model_name="isolationmethod", + name="species", + field=models.ForeignKey( + default=None, + help_text="The species this isolation method is related to.", + on_delete=django.db.models.deletion.CASCADE, + related_name="species_isolation_methods", + to="genlab_bestilling.species", + ), + ), + migrations.CreateModel( + name="SampleIsolationMethod", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "isolation_method", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sample_isolation_methods", + to="genlab_bestilling.isolationmethod", + ), + ), + ( + "sample", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="isolation_methods", + to="genlab_bestilling.sample", + ), + ), + ], + options={ + "unique_together": {("sample", "isolation_method")}, + }, + ), + migrations.AddField( + model_name="sample", + name="isolation_method", + field=models.ManyToManyField( + blank=True, + help_text="The isolation method used for this sample", + related_name="samples", + through="genlab_bestilling.SampleIsolationMethod", + to="genlab_bestilling.isolationmethod", + ), + ), + migrations.DeleteModel( + name="SampleStatus", + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 0956a4f3..8211e20f 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -657,11 +657,13 @@ class Sample(models.Model): extractions = models.ManyToManyField(f"{an}.ExtractionPlate", blank=True) parent = models.ForeignKey("self", on_delete=models.PROTECT, null=True, blank=True) - assigned_statuses = models.ManyToManyField( - f"{an}.SampleStatus", - through=f"{an}.SampleStatusAssignment", + + isolation_method = models.ManyToManyField( + f"{an}.IsolationMethod", related_name="samples", + through=f"{an}.SampleIsolationMethod", blank=True, + help_text="The isolation method used for this sample", ) objects = managers.SampleQuerySet.as_manager() @@ -776,29 +778,23 @@ def generate_genlab_id(self, commit: bool = True) -> str: # assignee (one or plus?) -class SampleStatus(models.Model): - name = models.CharField(max_length=255) - weight = models.IntegerField( - default=0, - ) - area = models.ForeignKey( - f"{an}.Area", - on_delete=models.CASCADE, - related_name="area_statuses", - help_text="The area this status is related to.", - ) - - class SampleStatusAssignment(models.Model): + class SampleStatus(models.TextChoices): + MARKED = "marked", _("Marked") + PLUCKED = "plucked", _("Plucked") + ISOLATED = "isolated", _("Isolated") + sample = models.ForeignKey( f"{an}.Sample", on_delete=models.CASCADE, related_name="sample_status_assignments", ) - status = models.ForeignKey( - f"{an}.SampleStatus", - on_delete=models.CASCADE, - related_name="status_assignments", + status = models.CharField( + choices=SampleStatus.choices, + null=True, + blank=True, + verbose_name="Sample status", + help_text="The status of the sample in the lab", ) order = models.ForeignKey( f"{an}.Order", @@ -814,8 +810,31 @@ class Meta: unique_together = ("sample", "status", "order") +class SampleIsolationMethod(models.Model): + sample = models.ForeignKey( + f"{an}.Sample", + on_delete=models.CASCADE, + related_name="isolation_methods", + ) + isolation_method = models.ForeignKey( + f"{an}.IsolationMethod", + on_delete=models.CASCADE, + related_name="sample_isolation_methods", + ) + + class Meta: + unique_together = ("sample", "isolation_method") + + class IsolationMethod(models.Model): name = models.CharField(max_length=255, unique=True) + species = models.ForeignKey( + f"{an}.Species", + on_delete=models.CASCADE, + related_name="species_isolation_methods", + help_text="The species this isolation method is related to.", + default=None, + ) def __str__(self) -> str: return self.name diff --git a/src/staff/tables.py b/src/staff/tables.py index ff4ec665..d6f757fb 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -219,47 +219,68 @@ def render_checked(self, record: Any) -> str: return mark_safe(f'') # noqa: S308 -def create_sample_table(base_fields: list[str] | None = None) -> type[tables.Table]: - class CustomSampleTable(tables.Table): - """ - This shows a checkbox in the header. - To display text in the header alongside the checkbox - override the header-property in the CheckBoxColumn class. - """ - - checked = tables.CheckBoxColumn( - accessor="pk", - orderable=True, - attrs={ - "th__input": { - "id": "select-all-checkbox", - }, - "td__input": { - "name": "checked", - }, - }, - empty_values=(), - verbose_name="Mark", - ) +class SampleStatusTable(tables.Table): + """ + This shows a checkbox in the header. + To display text in the header alongside the checkbox + override the header-property in the CheckBoxColumn class. + """ - for field in base_fields: - locals()[field] = tables.BooleanColumn( - verbose_name=field.capitalize(), - orderable=True, - yesno="✔,-", - default=False, - ) + checked = tables.CheckBoxColumn( + accessor="pk", + orderable=False, + attrs={ + "th__input": { + "id": "select-all-checkbox", + }, + "td__input": { + "name": "checked", + }, + }, + empty_values=(), + verbose_name="Mark", + ) - internal_note = tables.TemplateColumn( - template_name="staff/note_input_column.html", orderable=False - ) + internal_note = tables.TemplateColumn( + template_name="staff/note_input_column.html", orderable=False + ) - class Meta: - model = Sample - fields = ["checked", "genlab_id", "internal_note"] + list(base_fields) - sequence = ["checked", "genlab_id"] + list(base_fields) + ["internal_note"] + marked = tables.BooleanColumn( + verbose_name="Marked", + orderable=True, + yesno="✔,-", + default=False, + ) + plucked = tables.BooleanColumn( + verbose_name="Plucked", + orderable=True, + yesno="✔,-", + default=False, + ) + isolated = tables.BooleanColumn( + verbose_name="Isolated", + orderable=True, + yesno="✔,-", + default=False, + ) - return CustomSampleTable + class Meta: + model = Sample + fields = [ + "checked", + "genlab_id", + "internal_note", + "isolation_method", + ] + sequence = [ + "checked", + "genlab_id", + "marked", + "plucked", + "isolated", + "internal_note", + "isolation_method", + ] class OrderExtractionSampleTable(SampleBaseTable): diff --git a/src/staff/templates/staff/sample_lab.html b/src/staff/templates/staff/sample_lab.html index 6fa4ed64..b8ee3564 100644 --- a/src/staff/templates/staff/sample_lab.html +++ b/src/staff/templates/staff/sample_lab.html @@ -2,22 +2,51 @@ {% load render_table from django_tables2 %} {% block content %} -

{% if order %}{{ order }} - Samples{% else %}Samples{% endif %}

- -
- back -
- -
- {% csrf_token %} - {% for status in statuses %} - - {% endfor %} - - {% render_table table %} -
+

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

+{% block page-inner %} +
+ back +
+
+ {% csrf_token %} + {% for status in statuses %} + + {% endfor %} +
+ + +
+ {% render_table table %} +
+{% endblock page-inner %}