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 diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index d8d285d3..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 ( @@ -103,10 +99,15 @@ 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() + marked = serializers.SerializerMethodField() + plucked = serializers.SerializerMethodField() + isolated = serializers.SerializerMethodField() class Meta: model = Sample - # Make fields as a list to enable the removal of fish_id dynamically fields = [ "order", "guid", @@ -119,20 +120,43 @@ class Meta: "notes", "genlab_id", "fish_id", + "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 "-" + 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 "" + + 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): has_error = serializers.SerializerMethodField() diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index 7f914bfc..76dac513 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.db.models import Exists, OuterRef, QuerySet +from django.http import HttpResponse from django.views import View from drf_spectacular.utils import extend_schema from rest_framework.decorators import action @@ -33,6 +33,7 @@ Marker, Sample, SampleMarkerAnalysis, + SampleStatusAssignment, SampleType, Species, ) @@ -77,6 +78,115 @@ class SampleViewset(ModelViewSet): pagination_class = IDCursorPagination permission_classes = [AllowSampleDraft, IsAuthenticated] + CSV_FIELD_LABELS: dict[str, str] = { + "genlab_id": "Genlab ID", + "fish_id": "Old 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#", + "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", + "order", + "analysis_orders", + "location.name", + "pop_id", + "name", + "species.name", + "gender", + "length", + "weight", + "classification", + "year", + "notes", + "project", + "type.name", + "isolation_method", + "qiagen_number", + "marked", + "plucked", + "isolated", + ], + "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", + "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", + "marked", + "plucked", + "isolated", + "isolation_method", + "qiagen_number", + ], + } + def get_queryset(self) -> QuerySet: return ( super() @@ -89,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]: @@ -99,21 +226,78 @@ 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" + ) + + # 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( + 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 + 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, + ) + + fields, headers = self.get_csv_fields_and_labels(area_name, queryset) + 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"}, ) 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,