Skip to content
Closed
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
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ env:

on:
pull_request:
branches: ['main', 'summer25']
paths-ignore: ['docs/**']
branches: ["main", "summer25", "summer25-*"]
paths-ignore: ["docs/**"]

push:
branches: ['main', 'summer25']
paths-ignore: ['docs/**']
branches: ["main", "summer25", "summer25-*"]
paths-ignore: ["docs/**"]

concurrency:
group: ${{ github.head_ref || github.run_id }}
Expand All @@ -28,7 +28,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
python-version: "3.11"
# Consider using pre-commit.ci for open source project
- name: Run pre-commit
uses: pre-commit/action@v3.0.0
Expand Down
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
2 changes: 2 additions & 0 deletions src/config/autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
OrderAutocomplete,
SampleTypeAutocomplete,
SpeciesAutocomplete,
StatusAutocomplete,
)
from nina.autocomplete import ProjectAutocomplete

Expand All @@ -20,6 +21,7 @@
path("area/", AreaAutocomplete.as_view(), name="area"),
path("species/", SpeciesAutocomplete.as_view(), name="species"),
path("sample-type/", SampleTypeAutocomplete.as_view(), name="sample-type"),
path("order-status/", StatusAutocomplete.as_view(), name="order-status"),
path("project/", ProjectAutocomplete.as_view(), name="project"),
path("marker/", MarkerAutocomplete.as_view(), name="marker"),
path("user/", UserAutocomplete.as_view(), name="user"),
Expand Down
8 changes: 8 additions & 0 deletions src/genlab_bestilling/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Location,
LocationType,
Marker,
Order,
Organization,
Sample,
SampleMarkerAnalysis,
Expand Down Expand Up @@ -48,6 +49,13 @@ class AreaAdmin(ModelAdmin):
list_filter_sheet = False


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ["id", "name", "status", "created_at"]
list_filter = ["status"]
search_fields = ["id", "name"]


@admin.register(LocationType)
class LocationTypeAdmin(ModelAdmin):
search_fields = ["name"]
Expand Down
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
Loading