Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,6 @@ staticfiles/

# Local History for devcontainer
.devcontainer/bash_history

### macOS ###
.DS_Store
52 changes: 38 additions & 14 deletions src/genlab_bestilling/api/serializers.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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",
Expand All @@ -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()
Expand Down
212 changes: 198 additions & 14 deletions src/genlab_bestilling/api/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -33,6 +33,7 @@
Marker,
Sample,
SampleMarkerAnalysis,
SampleStatusAssignment,
SampleType,
Species,
)
Expand Down Expand Up @@ -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()
Expand All @@ -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]:
Expand All @@ -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"},
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
2 changes: 1 addition & 1 deletion src/genlab_bestilling/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions src/staff/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
Expand Down