From aab6a37b044a23fec20606c5f040f4be97e39e92 Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Tue, 15 Jul 2025 10:06:53 +0200 Subject: [PATCH 1/4] Update IsolationMethod model to allow non-unique names and optimize queries in SampleLabView --- .../0027_alter_isolationmethod_name.py | 17 +++++++++++++++++ src/genlab_bestilling/models.py | 2 +- src/staff/views.py | 10 +++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 src/genlab_bestilling/migrations/0027_alter_isolationmethod_name.py diff --git a/src/genlab_bestilling/migrations/0027_alter_isolationmethod_name.py b/src/genlab_bestilling/migrations/0027_alter_isolationmethod_name.py new file mode 100644 index 00000000..80790b0a --- /dev/null +++ b/src/genlab_bestilling/migrations/0027_alter_isolationmethod_name.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-07-15 07:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0026_alter_samplestatusassignment_status_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="isolationmethod", + name="name", + field=models.CharField(max_length=255), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 3c0549b2..47f646c3 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -819,7 +819,7 @@ class Meta: class IsolationMethod(models.Model): - name = models.CharField(max_length=255, unique=True) + name = models.CharField(max_length=255, unique=False) species = models.ForeignKey( f"{an}.Species", on_delete=models.CASCADE, diff --git a/src/staff/views.py b/src/staff/views.py index 018584fd..25aad648 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -364,8 +364,10 @@ def get_isolation_methods(self) -> list[str]: samples = Sample.objects.filter(order=order) species_ids = samples.values_list("species_id", flat=True).distinct() - return IsolationMethod.objects.filter(species_id__in=species_ids).values_list( - "name", flat=True + return ( + IsolationMethod.objects.filter(species_id__in=species_ids) + .values_list("name", flat=True) + .distinct() ) def get_base_fields(self) -> list[str]: @@ -478,7 +480,9 @@ def update_isolation_methods( ).first() try: - im = IsolationMethod.objects.get(name=selected_isolation_method.name) + im = IsolationMethod.objects.filter( + name=selected_isolation_method.name + ).first() except IsolationMethod.DoesNotExist: messages.error( request, From 53b8f5cf9ac17333188bf45a2321f1474832a6da Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Tue, 15 Jul 2025 10:08:37 +0200 Subject: [PATCH 2/4] Add macOS specific files to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b5fac182..b48164c8 100644 --- a/.gitignore +++ b/.gitignore @@ -335,3 +335,6 @@ staticfiles/ # Local History for devcontainer .devcontainer/bash_history + +### macOS ### +.DS_Store From 863144cbdcebe390dd6d3dbe1585c4fb50501bea Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Tue, 15 Jul 2025 10:11:30 +0200 Subject: [PATCH 3/4] Add analysis orders, project, and isolation method fields to SampleCSVSerializer and update CSV export logic --- src/genlab_bestilling/api/serializers.py | 22 +++- src/genlab_bestilling/api/views.py | 131 ++++++++++++++++++++--- 2 files changed, 140 insertions(+), 13 deletions(-) diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index d8d285d3..244f879b 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -103,10 +103,13 @@ class SampleCSVSerializer(serializers.ModelSerializer): species = SpeciesSerializer() location = LocationSerializer(allow_null=True, required=False) fish_id = serializers.SerializerMethodField() + analysis_orders = serializers.SerializerMethodField() + project = serializers.SerializerMethodField() + isolation_method = serializers.SerializerMethodField() class Meta: model = Sample - # Make fields as a list to enable the removal of fish_id dynamically + # Make fields as a list to enable the removal of fields dynamically fields = [ "order", "guid", @@ -119,6 +122,9 @@ class Meta: "notes", "genlab_id", "fish_id", + "analysis_orders", + "project", + "isolation_method", ] def get_field_names( @@ -133,6 +139,20 @@ def get_field_names( def get_fish_id(self, obj: Sample) -> str: return obj.fish_id or "-" + def get_analysis_orders(self, obj: Sample) -> list[str]: + if obj.order and obj.order.analysis_orders.exists(): + return [str(anl.id) for anl in obj.order.analysis_orders.all()] + return [] + + def get_project(self, obj: Sample) -> str: + if obj.order and obj.order.genrequest and obj.order.genrequest.project: + return str(obj.order.genrequest.project) + return "" + + def get_isolation_method(self, obj: Sample) -> str: + method = obj.isolation_method.first() + return method.name if method else "-" + class SampleUpdateSerializer(serializers.ModelSerializer): has_error = serializers.SerializerMethodField() diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index 7f914bfc..fc8c7f9d 100644 --- a/src/genlab_bestilling/api/views.py +++ b/src/genlab_bestilling/api/views.py @@ -1,8 +1,8 @@ import uuid -from typing import Any from django.db import transaction from django.db.models import QuerySet +from django.http import HttpResponse from django.views import View from drf_spectacular.utils import extend_schema from rest_framework.decorators import action @@ -77,6 +77,67 @@ class SampleViewset(ModelViewSet): pagination_class = IDCursorPagination permission_classes = [AllowSampleDraft, IsAuthenticated] + CSV_FIELD_LABELS: dict[str, str] = { + "genlab_id": "Genlab ID", + "fish_id": "Gammel Genlab ID", + "guid": "GUID", + "name": "Name", + "species.name": "Species", + "location.name": "Location", + "order": "EXT_order", + "analysis_orders": "ANL_order", + "pop_id": "PopID", + "type.name": "Sample Type", + "gender": "Gender", + "length": "Length", + "weight": "Weight", + "classification": "Classification", + "year": "Date", + "notes": "Remarks", + "project": "Projectnumber", + "isolation_method": "Isolation Method", + "qiagen_number": "Qiagen#", + } + + CSV_FIELDS_BY_AREA: dict[str, list[str]] = { + "Akvatisk": [ + "genlab_id", + "fish_id", + "guid", + "name", + "species.name", + "location.name", + "order", + "analysis_orders", + "pop_id", + "type.name", + "gender", + "length", + "weight", + "classification", + "year", + "notes", + "project", + "isolation_method", + "qiagen_number", + ], + "default": [ + "genlab_id", + "guid", + "name", + "species.name", + "location.name", + "order", + "analysis_orders", + "pop_id", + "type.name", + "notes", + "project", + "isolation_method", + "qiagen_number", + ], + } + def get_queryset(self) -> QuerySet: return ( super() @@ -99,21 +160,67 @@ def get_serializer_class(self) -> type[BaseSerializer]: return SampleCSVSerializer return super().get_serializer_class() - def get_serializer_context(self, *args, **kwargs) -> dict[str, Any]: - context = super().get_serializer_context(*args, **kwargs) - queryset = self.filter_queryset(self.get_queryset()) - is_aquatic = queryset.filter(order__genrequest__area__name="Akvatisk").exists() - context["include_fish_id"] = is_aquatic - return context + def get_area_name(self, queryset: QuerySet) -> str: + return ( + queryset.values_list("order__genrequest__area__name", flat=True).first() + or "default" + ) + + def get_csv_fields_and_labels(self, area_name: str) -> tuple[list[str], list[str]]: + fields = self.CSV_FIELDS_BY_AREA.get( + area_name, self.CSV_FIELDS_BY_AREA["default"] + ) + labels = [self.CSV_FIELD_LABELS.get(f, f) for f in fields] + return fields, labels + + def get_nested(self, obj: dict, dotted: str) -> str | None: + for part in dotted.split("."): + obj = obj.get(part) if isinstance(obj, dict) else None + return obj + + def build_csv_data( + self, serialized_data: list[dict], fields: list[str] + ) -> list[dict[str, str]]: + return [ + { + self.CSV_FIELD_LABELS[f]: ( + ", ".join(v) + if isinstance(v := self.get_nested(item, f), list) + else v or "" + ) + for f in fields + } + for item in serialized_data + ] @action( - methods=["GET"], url_path="csv", detail=False, renderer_classes=[CSVRenderer] + methods=["GET"], + url_path="csv", + detail=False, + renderer_classes=[CSVRenderer], ) - def csv(self, request: Request) -> Response: + def csv(self, request: Request) -> HttpResponse: queryset = self.filter_queryset(self.get_queryset()) - serializer = self.get_serializer(queryset, many=True) - return Response( - serializer.data, + area_name = self.get_area_name(queryset) + + serializer = self.get_serializer( + queryset, + many=True, + context={"include_fish_id": area_name == "Akvatisk"}, + ) + + fields, headers = self.get_csv_fields_and_labels(area_name) + data = self.build_csv_data(serializer.data, fields) + + csv_data = CSVRenderer().render( + data, + media_type="text/csv", + renderer_context={"header": headers}, + ) + + return HttpResponse( + csv_data, + content_type="text/csv; charset=utf-8", headers={"Content-Disposition": "attachment; filename=samples.csv"}, ) From e3345276c1eca0472ec149ee8745a4a87350c0cd Mon Sep 17 00:00:00 2001 From: Morten Madsen Lyngstad Date: Tue, 15 Jul 2025 14:05:56 +0200 Subject: [PATCH 4/4] Add marked, plucked, and isolated fields to SampleCSVSerializer and update SampleViewset for CSV export --- src/genlab_bestilling/api/serializers.py | 34 ++++---- src/genlab_bestilling/api/views.py | 105 ++++++++++++++++++++--- 2 files changed, 110 insertions(+), 29 deletions(-) diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index 244f879b..e47cfb43 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -1,7 +1,3 @@ -from collections.abc import Mapping -from typing import Any - -from django.forms import Field from rest_framework import exceptions, serializers from ..models import ( @@ -106,10 +102,12 @@ class SampleCSVSerializer(serializers.ModelSerializer): analysis_orders = serializers.SerializerMethodField() project = serializers.SerializerMethodField() isolation_method = serializers.SerializerMethodField() + marked = serializers.SerializerMethodField() + plucked = serializers.SerializerMethodField() + isolated = serializers.SerializerMethodField() class Meta: model = Sample - # Make fields as a list to enable the removal of fields dynamically fields = [ "order", "guid", @@ -125,17 +123,11 @@ class Meta: "analysis_orders", "project", "isolation_method", + "marked", + "plucked", + "isolated", ] - def get_field_names( - self, declared_fields: Mapping[str, Field], info: Any - ) -> list[str]: - field_names = super().get_field_names(declared_fields, info) - if not self.context.get("include_fish_id", False): - # Remove fish_id if the area is not aquatic (only relevant for aquatic area) - field_names.remove("fish_id") - return field_names - def get_fish_id(self, obj: Sample) -> str: return obj.fish_id or "-" @@ -151,7 +143,19 @@ def get_project(self, obj: Sample) -> str: def get_isolation_method(self, obj: Sample) -> str: method = obj.isolation_method.first() - return method.name if method else "-" + return method.name if method else "" + + def _flag(self, value: bool) -> str: + return "x" if value else "" + + def get_marked(self, obj: Sample) -> str: + return self._flag(obj.is_marked) + + def get_plucked(self, obj: Sample) -> str: + return self._flag(obj.is_plucked) + + def get_isolated(self, obj: Sample) -> str: + return self._flag(obj.is_isolated) class SampleUpdateSerializer(serializers.ModelSerializer): diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index fc8c7f9d..76dac513 100644 --- a/src/genlab_bestilling/api/views.py +++ b/src/genlab_bestilling/api/views.py @@ -1,7 +1,7 @@ import uuid from django.db import transaction -from django.db.models import QuerySet +from django.db.models import Exists, OuterRef, QuerySet from django.http import HttpResponse from django.views import View from drf_spectacular.utils import extend_schema @@ -33,6 +33,7 @@ Marker, Sample, SampleMarkerAnalysis, + SampleStatusAssignment, SampleType, Species, ) @@ -79,7 +80,7 @@ class SampleViewset(ModelViewSet): CSV_FIELD_LABELS: dict[str, str] = { "genlab_id": "Genlab ID", - "fish_id": "Gammel Genlab ID", + "fish_id": "Old Genlab ID", "guid": "GUID", "name": "Name", "species.name": "Species", @@ -97,20 +98,26 @@ class SampleViewset(ModelViewSet): "project": "Projectnumber", "isolation_method": "Isolation Method", "qiagen_number": "Qiagen#", + "marked": "Marked", + "plucked": "Plucked", + "isolated": "Isolated", + "station": "Station", + "placement_in_fridge": "Placement in fridge", + "delivered_to_lab": "Delivered to lab", } + # NOTE: This can be modified to include more fields based on species or area. CSV_FIELDS_BY_AREA: dict[str, list[str]] = { "Akvatisk": [ "genlab_id", "fish_id", "guid", - "name", - "species.name", - "location.name", "order", "analysis_orders", + "location.name", "pop_id", - "type.name", + "name", + "species.name", "gender", "length", "weight", @@ -118,21 +125,63 @@ class SampleViewset(ModelViewSet): "year", "notes", "project", + "type.name", "isolation_method", "qiagen_number", + "marked", + "plucked", + "isolated", ], - "default": [ + "Elvemusling": [ + "genlab_id", + "fish_id", + "guid", + "location.name", + "year", + "name", + "station", + "type.name", + "length", + "notes", + "isolation_method", + "qiagen_number", + "placement_in_fridge", + "marked", + "plucked", + "isolated", + ], + "Terrestrisk": [ "genlab_id", "guid", "name", + "type.name", "species.name", "location.name", + "delivered_to_lab", "order", "analysis_orders", - "pop_id", + "notes", + "marked", + "plucked", + "isolated", + "isolation_method", + "qiagen_number", + ], + # Same as "Terrestrisk" for now, can be modified later if needed. + "default": [ + "genlab_id", + "guid", + "name", "type.name", + "species.name", + "location.name", + "delivered_to_lab", + "order", + "analysis_orders", "notes", - "project", + "marked", + "plucked", + "isolated", "isolation_method", "qiagen_number", ], @@ -150,7 +199,24 @@ def get_queryset(self) -> QuerySet: "order__genrequest__area", "location", ) - .order_by("id") + .annotate( + is_marked=Exists( + SampleStatusAssignment.objects.filter( + sample=OuterRef("pk"), status="marked" + ) + ), + is_plucked=Exists( + SampleStatusAssignment.objects.filter( + sample=OuterRef("pk"), status="plucked" + ) + ), + is_isolated=Exists( + SampleStatusAssignment.objects.filter( + sample=OuterRef("pk"), status="isolated" + ) + ), + ) + .order_by("genlab_id", "type") ) def get_serializer_class(self) -> type[BaseSerializer]: @@ -166,13 +232,25 @@ def get_area_name(self, queryset: QuerySet) -> str: or "default" ) - def get_csv_fields_and_labels(self, area_name: str) -> tuple[list[str], list[str]]: + # NOTE: If the headers differ from species to species, we can add more headers + # to the CSV_FIELDS_BY_AREA dict, and then use the species name to determine + # which headers to use. + def get_csv_fields_and_labels( + self, area_name: str, queryset: QuerySet + ) -> tuple[list[str], list[str]]: + get_fields = area_name + if area_name == "Akvatisk": + species = queryset.values_list("species__name", flat=True).distinct() + if species.first() == "Elvemusling": + get_fields = "Elvemusling" + fields = self.CSV_FIELDS_BY_AREA.get( - area_name, self.CSV_FIELDS_BY_AREA["default"] + get_fields, self.CSV_FIELDS_BY_AREA["default"] ) labels = [self.CSV_FIELD_LABELS.get(f, f) for f in fields] return fields, labels + # Helper function to get nested values from a dict using dotted notation. def get_nested(self, obj: dict, dotted: str) -> str | None: for part in dotted.split("."): obj = obj.get(part) if isinstance(obj, dict) else None @@ -206,10 +284,9 @@ def csv(self, request: Request) -> HttpResponse: serializer = self.get_serializer( queryset, many=True, - context={"include_fish_id": area_name == "Akvatisk"}, ) - fields, headers = self.get_csv_fields_and_labels(area_name) + fields, headers = self.get_csv_fields_and_labels(area_name, queryset) data = self.build_csv_data(serializer.data, fields) csv_data = CSVRenderer().render(