Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
31dbc6f
Only one button for generating genlab id and set as "processing" (#248)
aastabk Jul 14, 2025
c259dde
Add logic to mark order as completed when all samples are isolated (#…
mortenlyn Jul 14, 2025
6dd52a7
Add priority to urgent orders table (#251)
omfj Jul 14, 2025
a223d84
Exclude 'pop_id' and 'location' fields from OrderExtractionSampleTabl…
mortenlyn Jul 14, 2025
9cf89ff
Only genrequest members can mark an order as seen (#252)
aastabk Jul 14, 2025
5ce3445
Count isolated samples (#260)
omfj Jul 14, 2025
27ae878
Assign staff to all orders (#263)
omfj Jul 15, 2025
6dd57b6
Changes the buttons to a specified format. Moved buttons to the top. …
aastabk Jul 15, 2025
912f85b
Use correct delivery date on dashboard (#268)
omfj Jul 15, 2025
350e31c
Filter for orders and samples. Lab is missing. (#270)
aastabk Jul 15, 2025
587ff30
Run CI on summer25-*
omfj Jul 15, 2025
6da534c
Download CSV of samples (#265)
mortenlyn Jul 16, 2025
9b9df10
Changed to tailwind styling (#273)
aastabk Jul 16, 2025
f25929e
Completed order does not show in my orders and urgent orders (#275)
aastabk Jul 17, 2025
dc9cd86
Add multicolumn sorting samples (#276)
mortenlyn Jul 17, 2025
845d48a
Change text on dashboard (#280)
mortenlyn Jul 17, 2025
98ab755
Fix name sorting (#281)
aastabk Jul 17, 2025
3e19128
Genlab ID patch (#271)
aastabk Jul 17, 2025
f85aa66
Sample list does no longer show guid or plate position, but shows loc…
aastabk Jul 17, 2025
cecc17c
Added filtering on types for extraction order (#287)
aastabk Jul 17, 2025
6d8b74f
Maked sample statuses constants instead of strings (#289)
mortenlyn Jul 17, 2025
9a15d76
Update order-status-logic when converted to draft (#284)
mortenlyn Jul 17, 2025
9564f9e
Add possibility to toggle sample status on and off
Jul 17, 2025
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
13 changes: 8 additions & 5 deletions src/genlab_bestilling/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
Location,
LocationType,
Marker,
Order,
Organization,
Sample,
SampleMarkerAnalysis,
SampleStatusAssignment,
SampleType,
Species,
)
Expand All @@ -48,6 +48,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 Expand Up @@ -526,9 +533,5 @@ class AnalysisResultAdmin(ModelAdmin):
]


@admin.register(SampleStatusAssignment)
class SampleStatusAssignmentAdmin(ModelAdmin): ...


@admin.register(IsolationMethod)
class IsolationMethodAdmin(ModelAdmin): ...
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
192 changes: 179 additions & 13 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.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 @@ -77,6 +77,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#",
"is_marked": "Marked",
"is_plucked": "Plucked",
"is_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",
"is_marked",
"is_plucked",
"is_isolated",
],
"Elvemusling": [
"genlab_id",
"fish_id",
"guid",
"location.name",
"year",
"name",
"station",
"type.name",
"length",
"notes",
"isolation_method",
"qiagen_number",
"placement_in_fridge",
"is_marked",
"is_plucked",
"is_isolated",
],
"Terrestrisk": [
"genlab_id",
"guid",
"name",
"type.name",
"species.name",
"location.name",
"delivered_to_lab",
"order",
"analysis_orders",
"notes",
"is_marked",
"is_plucked",
"is_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",
"is_marked",
"is_plucked",
"is_isolated",
"isolation_method",
"qiagen_number",
],
}

def get_queryset(self) -> QuerySet:
return (
super()
Expand All @@ -89,7 +198,7 @@ def get_queryset(self) -> QuerySet:
"order__genrequest__area",
"location",
)
.order_by("id")
.order_by("genlab_id", "type")
)

def get_serializer_class(self) -> type[BaseSerializer]:
Expand All @@ -99,21 +208,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