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 01/15] 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 %}
-
-
-{% endblock page-inner %}
+
+
+
-{% 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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 11/15] 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 %}
+
+ {% 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 @@
-
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 %}
+
+ {% 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 12/15] 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 @@
-
+
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 13/15] 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 3d8ba802fbdcc8e3d8a118e4c89a34f2800914fe Mon Sep 17 00:00:00 2001
From: Morten Madsen Lyngstad
Date: Tue, 8 Jul 2025 14:48:23 +0200
Subject: [PATCH 14/15] WIP: added isolation_method to sample and
SampleIsolationMethod class
---
src/genlab_bestilling/admin.py | 5 ++
..._species_sampleisolationmethod_and_more.py | 68 +++++++++++++++++++
src/genlab_bestilling/models.py | 30 ++++++++
3 files changed, 103 insertions(+)
create mode 100644 src/genlab_bestilling/migrations/0023_isolationmethod_species_sampleisolationmethod_and_more.py
diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py
index 04f302ac..5a6538c5 100644
--- a/src/genlab_bestilling/admin.py
+++ b/src/genlab_bestilling/admin.py
@@ -15,6 +15,7 @@
ExtractionPlate,
ExtractPlatePosition,
Genrequest,
+ IsolationMethod,
Location,
LocationType,
Marker,
@@ -532,3 +533,7 @@ class SampleStatusAdmin(ModelAdmin): ...
@admin.register(SampleStatusAssignment)
class SampleStatusAssignmentAdmin(ModelAdmin): ...
+
+
+@admin.register(IsolationMethod)
+class IsolationMethodAdmin(ModelAdmin): ...
diff --git a/src/genlab_bestilling/migrations/0023_isolationmethod_species_sampleisolationmethod_and_more.py b/src/genlab_bestilling/migrations/0023_isolationmethod_species_sampleisolationmethod_and_more.py
new file mode 100644
index 00000000..02abed71
--- /dev/null
+++ b/src/genlab_bestilling/migrations/0023_isolationmethod_species_sampleisolationmethod_and_more.py
@@ -0,0 +1,68 @@
+# Generated by Django 5.2.3 on 2025-07-08 10:17
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("genlab_bestilling", "0022_sample_internal_note"),
+ ]
+
+ operations = [
+ 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",
+ ),
+ ),
+ ]
diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py
index 57d42414..2b16a2fb 100644
--- a/src/genlab_bestilling/models.py
+++ b/src/genlab_bestilling/models.py
@@ -653,6 +653,13 @@ class Sample(models.Model):
related_name="samples",
blank=True,
)
+ 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()
is_prioritised = models.BooleanField(
@@ -804,8 +811,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
From a57dbfec59a3cf7906ec297e1e3b4ddde7467842 Mon Sep 17 00:00:00 2001
From: Morten Lyngstad <81157760+mortenlyn@users.noreply.github.com>
Date: Fri, 11 Jul 2025 07:52:49 +0200
Subject: [PATCH 15/15] Add isolation method handling in sample status page
(#238)
Co-authored-by: Morten Madsen Lyngstad
---
src/staff/tables.py | 13 ++-
src/staff/templates/staff/sample_lab.html | 58 ++++++++++----
src/staff/urls.py | 8 +-
src/staff/views.py | 97 ++++++++++++++++++++---
4 files changed, 141 insertions(+), 35 deletions(-)
diff --git a/src/staff/tables.py b/src/staff/tables.py
index ff4ec665..93e6325f 100644
--- a/src/staff/tables.py
+++ b/src/staff/tables.py
@@ -256,8 +256,17 @@ class CustomSampleTable(tables.Table):
class Meta:
model = Sample
- fields = ["checked", "genlab_id", "internal_note"] + list(base_fields)
- sequence = ["checked", "genlab_id"] + list(base_fields) + ["internal_note"]
+ fields = [
+ "checked",
+ "genlab_id",
+ "internal_note",
+ "isolation_method",
+ ] + list(base_fields)
+ sequence = (
+ ["checked", "genlab_id"]
+ + list(base_fields)
+ + ["internal_note", "isolation_method"]
+ )
return CustomSampleTable
diff --git a/src/staff/templates/staff/sample_lab.html b/src/staff/templates/staff/sample_lab.html
index 6fa4ed64..32cb2b76 100644
--- a/src/staff/templates/staff/sample_lab.html
+++ b/src/staff/templates/staff/sample_lab.html
@@ -2,22 +2,48 @@
{% load render_table from django_tables2 %}
{% block content %}
-{% if order %}{{ order }} - Samples{% else %}Samples{% endif %}
+{% block page-title %}{% if order %}{{ order }} - Samples{% else %}Samples{% endif %}{% endblock page-title %}
+{% block page-inner %}
+
+
+{% endblock page-inner %}