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 %} -
- 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 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 %} +
+ {% 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 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 @@ -
- {% 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 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 %} +
+ back +
+
+ {% csrf_token %} + {% for status in statuses %} + + {% endfor %} +
+ + +
+ {% render_table table %} + +{% endblock page-inner %}