diff --git a/src/config/routers.py b/src/config/routers.py
index 24beca5..b79d564 100644
--- a/src/config/routers.py
+++ b/src/config/routers.py
@@ -9,6 +9,7 @@
ExtractionOrderViewset,
LocationViewset,
MarkerViewset,
+ SampleMarkerAnalysisViewset,
SampleTypeViewset,
SampleViewset,
SpeciesViewset,
@@ -24,6 +25,11 @@
router.register("analysis-types", AnalysisTypeViewset, basename="analysis_types")
router.register("locations", LocationViewset, basename="locations")
router.register("extraction-order", ExtractionOrderViewset, basename="extraction-order")
+router.register(
+ "sample-marker-analysis",
+ SampleMarkerAnalysisViewset,
+ basename="sample-marker-analysis",
+)
urlpatterns = [
diff --git a/src/frontend/src/analysis_samples/App.jsx b/src/frontend/src/analysis_samples/App.jsx
new file mode 100644
index 0000000..6be4ab8
--- /dev/null
+++ b/src/frontend/src/analysis_samples/App.jsx
@@ -0,0 +1,31 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { Toaster } from "react-hot-toast";
+import SearchApplyMarker from "./components/SearchApplyMarker";
+import OrderMarker from "./components/OrderMarker";
+const queryClient = new QueryClient();
+
+function App() {
+ return (
+
+
+
+ {/* */}
+
+ Selected Samples
+
+
+ );
+}
+
+export default App;
diff --git a/src/frontend/src/analysis_samples/components/IndeterminateCheckbox.jsx b/src/frontend/src/analysis_samples/components/IndeterminateCheckbox.jsx
new file mode 100644
index 0000000..9770f5d
--- /dev/null
+++ b/src/frontend/src/analysis_samples/components/IndeterminateCheckbox.jsx
@@ -0,0 +1,24 @@
+import { useRef, useEffect } from "react";
+
+export default function IndeterminateCheckbox({
+ indeterminate,
+ className = "",
+ ...rest
+}) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (typeof indeterminate === "boolean") {
+ ref.current.indeterminate = !rest.checked && indeterminate;
+ }
+ }, [ref, indeterminate]);
+
+ return (
+
+ );
+}
diff --git a/src/frontend/src/analysis_samples/components/OrderMarker.jsx b/src/frontend/src/analysis_samples/components/OrderMarker.jsx
new file mode 100644
index 0000000..d96ed30
--- /dev/null
+++ b/src/frontend/src/analysis_samples/components/OrderMarker.jsx
@@ -0,0 +1,10 @@
+import { useState } from "react";
+import Table from "./SampleMarkerTable";
+
+
+export default function OrderMarker() {
+ const [selection, setSelection] = useState({});
+ return (
+
+ )
+}
diff --git a/src/frontend/src/analysis_samples/components/SampleMarkerTable.jsx b/src/frontend/src/analysis_samples/components/SampleMarkerTable.jsx
new file mode 100644
index 0000000..8a33c11
--- /dev/null
+++ b/src/frontend/src/analysis_samples/components/SampleMarkerTable.jsx
@@ -0,0 +1,278 @@
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ useMutation,
+ useQueryClient,
+ // useMutation,
+ // useQueryClient,
+} from "@tanstack/react-query";
+import { client, config } from "../config";
+import {
+ useReactTable,
+ flexRender,
+ getCoreRowModel,
+ createColumnHelper,
+} from "@tanstack/react-table";
+import { useEffect, useRef, useCallback, useMemo, useState } from "react";
+
+import { useVirtualizer } from "@tanstack/react-virtual";
+import IndeterminateCheckbox from "./IndeterminateCheckbox";
+import { Button } from "@headlessui/react";
+import toast from "react-hot-toast";
+// import toast from "react-hot-toast";
+// import { AxiosError } from "axios";
+
+async function getSamples({ pageParam }) {
+ const url = pageParam || `/api/sample-marker-analysis/?order=${config.order}`;
+ const response = await client.get(url);
+ return response.data;
+}
+
+const columnHelper = createColumnHelper();
+
+export default function Table({ rowSelection, setRowSelection }) {
+ const tableContainerRef = useRef(null);
+ const queryClient = useQueryClient();
+
+ const columns = useMemo(() => [
+ {
+ id: 'select',
+ size: 50,
+ header: ({ table }) => (
+
+ ),
+ cell: ({ row }) => (
+
+
+
+ ),
+ },
+ columnHelper.accessor("sample.genlab_id", {
+ header: "Genlab ID",
+ }),
+ columnHelper.accessor("marker", {
+ header: "Marker",
+ }),
+ columnHelper.accessor("sample.guid", {
+ header: "GUID",
+ size: 350,
+ }),
+ columnHelper.accessor("sample.name", {
+ header: "Sample Name",
+ size: 350,
+ }),
+ columnHelper.accessor("sample.species.name", {
+ header: "Species",
+ }),
+ columnHelper.accessor("sample.year", {
+ header: "Year",
+ size: 100
+ }),
+ columnHelper.accessor("sample.pop_id", {
+ header: "Pop ID",
+ }),
+ columnHelper.accessor("sample.location.name", {
+ header: "Location",
+ }),
+ columnHelper.accessor("sample.type.name", {
+ header: "Sample Type",
+ }),
+ columnHelper.accessor("sample.notes", {
+ header: "Notes",
+ }),
+ ], [])
+
+ const bulkDelete = useMutation({
+ mutationFn: (value) => {
+ return client.post("/api/sample-marker-analysis/bulk-delete/", {
+ ids: Object.entries(value).filter(([_k, value]) => value).map(([key, _v]) => key),
+ });
+ },
+ onSuccess: () => {
+ toast.success("Samples deleted!");
+ queryClient.invalidateQueries({ queryKey: ["sample-marker-analysis"] });
+ },
+ onError: () => {
+ toast.error("There was an error!");
+ },
+ });
+
+ const { data, fetchNextPage, isFetching, isLoading } = useInfiniteQuery({
+ queryKey: ["sample-marker-analysis"],
+ queryFn: getSamples,
+ getNextPageParam: (lastGroup) => lastGroup.next,
+ refetchOnWindowFocus: false,
+ placeholderData: keepPreviousData,
+ });
+
+ const flatData = useMemo(
+ () => data?.pages?.flatMap((page) => page.results) ?? [],
+ [data]
+ );
+
+ const last = useMemo(() => {
+ try {
+ const lastIndex = data?.pages?.length - 1;
+ return data?.pages[lastIndex];
+ } catch (e) {
+ console.log(e);
+ }
+ return null;
+ }, [data]);
+
+ const fetchMoreOnBottomReached = useCallback(
+ (containerRefElement) => {
+ if (containerRefElement) {
+ const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
+ //once the user has scrolled within 500px of the bottom of the table, fetch more data if we can
+ if (
+ scrollHeight - scrollTop - clientHeight < 500 &&
+ !isFetching &&
+ last?.next
+ ) {
+ fetchNextPage();
+ }
+ }
+ },
+ [fetchNextPage, isFetching, last]
+ );
+
+ useEffect(() => {
+ fetchMoreOnBottomReached(tableContainerRef.current);
+ }, [fetchMoreOnBottomReached]);
+
+ const pendingState = isLoading || isFetching;
+
+ const table = useReactTable({
+ data: flatData,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ onRowSelectionChange: setRowSelection,
+ state: {
+ rowSelection,
+ },
+ getRowId: row => row.id,
+ enableRowSelection: true,
+ });
+
+ const { rows } = table.getRowModel();
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ estimateSize: () => 33, //estimate row height for accurate scrollbar dragging
+ getScrollElement: () => tableContainerRef.current,
+ //measure dynamic row height, except in firefox because it measures table border height incorrectly
+ measureElement:
+ typeof window !== "undefined" &&
+ navigator.userAgent.indexOf("Firefox") === -1
+ ? (element) => element?.getBoundingClientRect().height
+ : undefined,
+ overscan: 5,
+ });
+
+ return (
+ <>
+
+
fetchMoreOnBottomReached(e.target)}
+ ref={tableContainerRef}
+ style={{
+ overflow: "auto", //our scrollable table container
+ position: "relative", //needed for sticky header
+ height: "600px", //should be a fixed height
+ }}
+ >
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ |
+ ))}
+
+ ))}
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const row = rows[virtualRow.index];
+ return (
+ rowVirtualizer.measureElement(node)} //measure dynamic row height
+ key={row.id}
+ className={`flex absolute w-full ${row.getIsSelected() ? 'bg-yellow-200' : ''}`}
+ style={{
+ transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll
+ }}
+ onClick={row.getToggleSelectedHandler()}
+ >
+ {row.getVisibleCells().map((cell) => {
+ return (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ |
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+ {(isLoading || isFetching || pendingState) && (
+
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/frontend/src/analysis_samples/components/SampleTable.jsx b/src/frontend/src/analysis_samples/components/SampleTable.jsx
new file mode 100644
index 0000000..b638b44
--- /dev/null
+++ b/src/frontend/src/analysis_samples/components/SampleTable.jsx
@@ -0,0 +1,299 @@
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ // useMutation,
+ // useQueryClient,
+} from "@tanstack/react-query";
+import { client } from "../config";
+import {
+ useReactTable,
+ flexRender,
+ getCoreRowModel,
+ createColumnHelper,
+} from "@tanstack/react-table";
+import { useEffect, useRef, useCallback, useMemo, useState } from "react";
+
+import { useVirtualizer } from "@tanstack/react-virtual";
+import IndeterminateCheckbox from "./IndeterminateCheckbox";
+// import toast from "react-hot-toast";
+// import { AxiosError } from "axios";
+
+async function getSamples({ pageParam }) {
+ const url = pageParam || `/api/samples/?order__status=completed`;
+ const response = await client.get(url);
+ return response.data;
+}
+
+const columnHelper = createColumnHelper();
+
+// const speciesOptions = async (input) => {
+// return (
+// await client.get(
+// `/api/species/?ext_order=${config.order}&name__icontains=${input}`
+// )
+// ).data;
+// };
+
+// const sampleTypesOptions = async (input) => {
+// return (
+// await client.get(
+// `/api/sample-types/?ext_order=${config.order}&name__icontains=${input}`
+// )
+// ).data;
+// };
+
+// const markersOptions = async (input) => {
+// return (await client.get(`/api/markers/?order=${config.order}&name__icontains=${input}`)).data;
+// };
+
+// const locationOptions = (species) => async (input) => {
+// let base = `/api/locations/?order=${config.order}&species=${species?.id}`;
+// if (input) {
+// base += `&name__icontains=${input}`;
+// }
+// return (await client.get(base)).data;
+// };
+
+// function handleError(e) {
+// console.error(e);
+
+// if (e instanceof AxiosError) {
+// e.response.data.errors.forEach((err) => {
+// toast.error(err.detail);
+// });
+// } else {
+// toast.error("There was an error");
+// }
+// }
+
+export default function Table({ rowSelection, setRowSelection }) {
+ const tableContainerRef = useRef(null);
+ // const queryClient = useQueryClient();s
+
+ const columns = useMemo(() => [
+ {
+ id: 'select',
+ size: 50,
+ header: ({ table }) => (
+
+ ),
+ cell: ({ row }) => (
+
+
+
+ ),
+ },
+ columnHelper.accessor("genlab_id", {
+ header: "Genlab ID",
+ }),
+ columnHelper.accessor("guid", {
+ header: "GUID",
+ size: 350,
+ }),
+ columnHelper.accessor("name", {
+ header: "Sample Name",
+ size: 350,
+ }),
+ columnHelper.accessor("species.name", {
+ header: "Species",
+ }),
+ columnHelper.accessor("year", {
+ header: "Year",
+ size: 100
+ }),
+ columnHelper.accessor("pop_id", {
+ header: "Pop ID",
+ }),
+ columnHelper.accessor("location", {
+ header: "Location",
+ }),
+ columnHelper.accessor("type.name", {
+ header: "Sample Type",
+ }),
+ columnHelper.accessor("order", {
+ header: "Order",
+ }),
+ columnHelper.accessor("notes", {
+ header: "Notes",
+ }),
+ ], [])
+
+ const { data, fetchNextPage, isFetching, isLoading } = useInfiniteQuery({
+ queryKey: ["samples"],
+ queryFn: getSamples,
+ getNextPageParam: (lastGroup) => lastGroup.next,
+ refetchOnWindowFocus: false,
+ placeholderData: keepPreviousData,
+ });
+
+ const flatData = useMemo(
+ () => data?.pages?.flatMap((page) => page.results) ?? [],
+ [data]
+ );
+
+ const last = useMemo(() => {
+ try {
+ const lastIndex = data?.pages?.length - 1;
+ return data?.pages[lastIndex];
+ } catch (e) {
+ console.log(e);
+ }
+ return null;
+ }, [data]);
+
+ const fetchMoreOnBottomReached = useCallback(
+ (containerRefElement) => {
+ if (containerRefElement) {
+ const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
+ //once the user has scrolled within 500px of the bottom of the table, fetch more data if we can
+ if (
+ scrollHeight - scrollTop - clientHeight < 500 &&
+ !isFetching &&
+ last?.next
+ ) {
+ fetchNextPage();
+ }
+ }
+ },
+ [fetchNextPage, isFetching, last]
+ );
+
+ useEffect(() => {
+ fetchMoreOnBottomReached(tableContainerRef.current);
+ }, [fetchMoreOnBottomReached]);
+
+ const pendingState = isLoading || isFetching;
+
+ const table = useReactTable({
+ data: flatData,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ onRowSelectionChange: setRowSelection,
+ state: {
+ rowSelection,
+ },
+ getRowId: row => row.id,
+ enableRowSelection: true,
+ meta: {
+ // updateData: updateCell.mutateAsync,
+ },
+ });
+
+ const { rows } = table.getRowModel();
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ estimateSize: () => 33, //estimate row height for accurate scrollbar dragging
+ getScrollElement: () => tableContainerRef.current,
+ //measure dynamic row height, except in firefox because it measures table border height incorrectly
+ measureElement:
+ typeof window !== "undefined" &&
+ navigator.userAgent.indexOf("Firefox") === -1
+ ? (element) => element?.getBoundingClientRect().height
+ : undefined,
+ overscan: 5,
+ });
+
+ return (
+ <>
+
+
fetchMoreOnBottomReached(e.target)}
+ ref={tableContainerRef}
+ style={{
+ overflow: "auto", //our scrollable table container
+ position: "relative", //needed for sticky header
+ height: "600px", //should be a fixed height
+ }}
+ >
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ |
+ ))}
+
+ ))}
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const row = rows[virtualRow.index];
+ return (
+ rowVirtualizer.measureElement(node)} //measure dynamic row height
+ key={row.id}
+ className={`flex absolute w-full ${row.getIsSelected() ? 'bg-yellow-200' : ''}`}
+ style={{
+ transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll
+ }}
+ onClick={row.getToggleSelectedHandler()}
+ >
+ {row.getVisibleCells().map((cell) => {
+ return (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ |
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+ {(isLoading || isFetching || pendingState) && (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/frontend/src/analysis_samples/components/SearchApplyMarker.jsx b/src/frontend/src/analysis_samples/components/SearchApplyMarker.jsx
new file mode 100644
index 0000000..b908112
--- /dev/null
+++ b/src/frontend/src/analysis_samples/components/SearchApplyMarker.jsx
@@ -0,0 +1,123 @@
+import { HUIField, Label } from "../../helpers/components";
+import { Button } from "@headlessui/react";
+import { useForm } from "@tanstack/react-form";
+import Table from "./SampleTable";
+import { client, config } from "../config";
+import AsyncSelect from "react-select/async";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+
+const markersOptions = async (input) => {
+ return (
+ await client.get(
+ `/api/markers/?analysis_order=${config.order}&name__istartswith=${input}`
+ )
+ ).data;
+};
+
+export default function SearchApplyMarker() {
+ const queryClient = useQueryClient()
+;
+ const bulkCreate = useMutation({
+ mutationFn: (value) => {
+ return client.post("/api/sample-marker-analysis/bulk/", {
+ samples: Object.entries(value.selectedSamples).filter(([_k, value]) => value).map(([key, _v]) => key),
+ markers: value.markers.map(m => m.name),
+ order: config.order,
+ });
+ },
+ onSuccess: () => {
+ toast.success("Samples added!");
+ queryClient.invalidateQueries({ queryKey: ["sample-marker-analysis"] });
+ },
+ onError: () => {
+ toast.error("There was an error!");
+ },
+ });
+
+ const { handleSubmit, Field, Subscribe } = useForm({
+ onSubmit: async ({ value, formApi }) => {
+ try {
+ console.log(value);
+ await bulkCreate.mutateAsync(value);
+ // formApi.reset();
+ } catch (e) {
+ // console.log(e);
+ }
+ },
+ defaultValues: {
+ selectedSamples: {},
+ markers: [],
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/src/frontend/src/analysis_samples/config.js b/src/frontend/src/analysis_samples/config.js
new file mode 100644
index 0000000..c9b011c
--- /dev/null
+++ b/src/frontend/src/analysis_samples/config.js
@@ -0,0 +1,11 @@
+import axios from 'axios';
+
+export const config = JSON.parse(document.getElementById('initial-data').textContent)
+
+export const client = axios.create({
+ headers: {
+ "X-CSRFToken": config.csrf
+ }
+})
+
+window.config = config;
diff --git a/src/frontend/src/analysis_samples/index.css b/src/frontend/src/analysis_samples/index.css
new file mode 100644
index 0000000..fd84513
--- /dev/null
+++ b/src/frontend/src/analysis_samples/index.css
@@ -0,0 +1,3 @@
+#markers-label + div {
+ width: 30rem;
+}
diff --git a/src/frontend/src/analysis_samples/main.jsx b/src/frontend/src/analysis_samples/main.jsx
new file mode 100644
index 0000000..751f5ae
--- /dev/null
+++ b/src/frontend/src/analysis_samples/main.jsx
@@ -0,0 +1,13 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+
+import 'vite/modulepreload-polyfill';
+import './index.css';
+
+import App from './App.jsx'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/src/frontend/src/helpers/components.jsx b/src/frontend/src/helpers/components.jsx
new file mode 100644
index 0000000..af274dd
--- /dev/null
+++ b/src/frontend/src/helpers/components.jsx
@@ -0,0 +1,2 @@
+export const HUIField = (props) => ;
+export const Label = (props) => ;
diff --git a/src/frontend/vite.config.js b/src/frontend/vite.config.js
index c9e9f26..8259fca 100644
--- a/src/frontend/vite.config.js
+++ b/src/frontend/vite.config.js
@@ -13,7 +13,8 @@ export default defineConfig({
outDir: resolve(__dirname, "static/frontend"),
rollupOptions: {
input: {
- samples: resolve(__dirname, 'src/samples/main.jsx')
+ samples: resolve(__dirname, 'src/samples/main.jsx'),
+ analysisSamples: resolve(__dirname, 'src/analysis_samples/main.jsx')
}
}
}
diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py
index 7599686..8f3935d 100644
--- a/src/genlab_bestilling/api/serializers.py
+++ b/src/genlab_bestilling/api/serializers.py
@@ -1,11 +1,13 @@
from rest_framework import exceptions, serializers
from ..models import (
+ AnalysisOrder,
ExtractionOrder,
Genrequest,
Location,
Marker,
Sample,
+ SampleMarkerAnalysis,
SampleType,
Species,
)
@@ -98,6 +100,7 @@ class Meta:
"volume",
"type",
"has_error",
+ "genlab_id",
)
@@ -178,3 +181,39 @@ class ExtractionSerializer(serializers.ModelSerializer):
class Meta:
model = ExtractionOrder
fields = ("id", "genrequest", "species", "sample_types", "needs_guid")
+
+
+class AnalysisSerializer(serializers.ModelSerializer):
+ markers = MarkerSerializer(many=True, read_only=True)
+ genrequest = GenrequestSerializer()
+
+ class Meta:
+ model = AnalysisOrder
+ fields = ("id", "genrequest", "markers")
+
+
+class SampleMarkerAnalysisSerializer(serializers.ModelSerializer):
+ sample = SampleSerializer(read_only=True)
+
+ class Meta:
+ model = SampleMarkerAnalysis
+ fields = ("id", "order", "sample", "marker")
+
+
+class SampleMarkerAnalysisBulkSerializer(serializers.ModelSerializer):
+ markers = serializers.PrimaryKeyRelatedField(
+ many=True, queryset=Marker.objects.all()
+ )
+ samples = serializers.PrimaryKeyRelatedField(
+ many=True, queryset=Sample.objects.all()
+ )
+
+ class Meta:
+ model = SampleMarkerAnalysis
+ fields = ("order", "samples", "markers")
+
+
+class SampleMarkerAnalysisBulkDeleteSerializer(serializers.Serializer):
+ ids = serializers.ListField(
+ child=serializers.IntegerField(required=True), required=True
+ )
diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py
index 9be0bc4..71f8aef 100644
--- a/src/genlab_bestilling/api/views.py
+++ b/src/genlab_bestilling/api/views.py
@@ -12,6 +12,7 @@
LocationFilter,
MarkerFilter,
SampleFilter,
+ SampleMarkerOrderFilter,
SampleTypeFilter,
SpeciesFilter,
)
@@ -21,6 +22,7 @@
Location,
Marker,
Sample,
+ SampleMarkerAnalysis,
SampleType,
Species,
)
@@ -33,6 +35,9 @@
MarkerSerializer,
OperationStatusSerializer,
SampleBulkSerializer,
+ SampleMarkerAnalysisBulkDeleteSerializer,
+ SampleMarkerAnalysisBulkSerializer,
+ SampleMarkerAnalysisSerializer,
SampleSerializer,
SampleUpdateSerializer,
)
@@ -61,7 +66,6 @@ def get_queryset(self):
return (
super()
.get_queryset()
- .filter_allowed(self.request.user)
.select_related(
"type",
"species",
@@ -199,3 +203,66 @@ def get_serializer_class(self):
if self.action == "create":
return LocationCreateSerializer
return super().get_serializer_class()
+
+
+class SampleMarkerAnalysisViewset(mixins.ListModelMixin, GenericViewSet):
+ queryset = SampleMarkerAnalysis.objects.all()
+ serializer_class = SampleMarkerAnalysisSerializer
+ filterset_class = SampleMarkerOrderFilter
+ pagination_class = IDCursorPagination
+
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .filter_allowed(self.request.user)
+ .select_related(
+ "marker", "order", "sample", "sample__species", "sample__location"
+ )
+ )
+
+ @extend_schema(
+ request=SampleMarkerAnalysisBulkSerializer,
+ responses={200: OperationStatusSerializer},
+ )
+ @action(methods=["POST"], url_path="bulk", detail=False)
+ def bulk_create(self, request):
+ serializer = SampleMarkerAnalysisBulkSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ markers = serializer.validated_data.pop("markers")
+ samples = serializer.validated_data.pop("samples")
+ with transaction.atomic():
+ for marker in markers:
+ for sample in samples:
+ if (
+ not serializer.validated_data["order"]
+ .markers.filter(name=marker)
+ .exists()
+ ):
+ continue
+
+ if not marker.species.filter(id=sample.species_id).exists():
+ continue
+
+ SampleMarkerAnalysis.objects.get_or_create(
+ marker=marker, sample=sample, **serializer.validated_data
+ )
+
+ return Response(data=OperationStatusSerializer({"success": True}).data)
+
+ @extend_schema(
+ request=SampleMarkerAnalysisBulkDeleteSerializer,
+ responses={200: OperationStatusSerializer},
+ )
+ @action(methods=["POST"], url_path="bulk-delete", detail=False)
+ def bulk_delete(self, request):
+ serializer = SampleMarkerAnalysisBulkDeleteSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ with transaction.atomic():
+ self.get_queryset().filter(
+ id__in=serializer.validated_data.pop("ids")
+ ).delete()
+
+ return Response(data=OperationStatusSerializer({"success": True}).data)
diff --git a/src/genlab_bestilling/filters.py b/src/genlab_bestilling/filters.py
index fc4e95b..fc66513 100644
--- a/src/genlab_bestilling/filters.py
+++ b/src/genlab_bestilling/filters.py
@@ -1,6 +1,6 @@
from django_filters import rest_framework as filters
-from .models import Location, Marker, Sample, SampleType, Species
+from .models import Location, Marker, Sample, SampleMarkerAnalysis, SampleType, Species
class SampleFilter(filters.FilterSet):
@@ -29,12 +29,36 @@ class Meta:
class MarkerFilter(BaseOrderFilter):
+ analysis_order = filters.NumberFilter(
+ field_name="analysis_order", method="filter_analysis_order"
+ )
+
class Meta:
model = Marker
- fields = {"name": ["icontains"]}
+ fields = {"name": ["icontains", "istartswith"]}
+
+ def filter_analysis_order(self, queryset, name, value):
+ return queryset.filter(analysisorder=value)
class LocationFilter(filters.FilterSet):
class Meta:
model = Location
fields = {"name": ["icontains"]}
+
+
+class SampleMarkerOrderFilter(filters.FilterSet):
+ class Meta:
+ model = SampleMarkerAnalysis
+ fields = [
+ "order",
+ "marker",
+ "sample__guid",
+ "sample__name",
+ "sample__genlab_id",
+ "sample__species",
+ "sample__type",
+ "sample__year",
+ "sample__location",
+ "sample__pop_id",
+ ]
diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py
index 6b9f88b..4314ef8 100644
--- a/src/genlab_bestilling/models.py
+++ b/src/genlab_bestilling/models.py
@@ -50,7 +50,7 @@ class Meta:
class Species(models.Model):
name = models.CharField(max_length=255)
area = models.ForeignKey("Area", on_delete=models.CASCADE)
- markers = models.ManyToManyField("Marker")
+ markers = models.ManyToManyField("Marker", related_name="species")
location_type = models.ForeignKey(
"LocationType", null=True, blank=True, on_delete=models.CASCADE
)
@@ -204,6 +204,13 @@ class OrderStatus(models.TextChoices):
PROCESSING = "processing", _("Processing")
COMPLETED = "completed", _("Completed")
+ STATUS_ORDER = (
+ OrderStatus.DRAFT,
+ OrderStatus.CONFIRMED,
+ OrderStatus.PROCESSING,
+ OrderStatus.COMPLETED,
+ )
+
name = models.CharField(null=True, blank=True)
genrequest = models.ForeignKey(
"Genrequest", on_delete=models.CASCADE, related_name="orders"
@@ -237,6 +244,18 @@ def to_draft(self):
def get_type(self):
return "order"
+ @property
+ def next_status(self):
+ current_index = self.STATUS_ORDER.index(self.status)
+ if current_index + 1 < len(self.STATUS_ORDER):
+ return self.STATUS_ORDER[current_index + 1]
+ return None
+
+ def to_next_status(self):
+ if status := self.next_status:
+ self.status = status
+ self.save()
+
def __str__(self):
return f"#ORD_{self.id}"
diff --git a/src/genlab_bestilling/staff/filters.py b/src/genlab_bestilling/staff/filters.py
index ad3b4a7..016fbb4 100644
--- a/src/genlab_bestilling/staff/filters.py
+++ b/src/genlab_bestilling/staff/filters.py
@@ -36,7 +36,7 @@ class SampleMarkerOrderFilter(filters.FilterSet):
class Meta:
model = SampleMarkerAnalysis
fields = [
- # "order",
+ "order",
"marker",
"sample__guid",
"sample__name",
diff --git a/src/genlab_bestilling/staff/templates/staff/extractionorder_detail.html b/src/genlab_bestilling/staff/templates/staff/extractionorder_detail.html
index b22ac16..b90f951 100644
--- a/src/genlab_bestilling/staff/templates/staff/extractionorder_detail.html
+++ b/src/genlab_bestilling/staff/templates/staff/extractionorder_detail.html
@@ -35,5 +35,12 @@ Delivered Samples
{% action-button action=confirm_check_url class="bg-secondary text-white" submit_text="Confirm - Order checked" csrf_token=csrf_token %}
{% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %}
{% endif %}
+
+ {% if object.next_status %}
+ {% url 'staff:order-to-next-status' pk=object.id as to_next_status_url %}
+ {% with "Set as "|add:object.next_status as btn_name %}
+ {% action-button action=to_next_status_url class="bg-secondary text-white" submit_text=btn_name csrf_token=csrf_token %}
+ {% endwith %}
+ {% endif %}
{% endblock %}
diff --git a/src/genlab_bestilling/staff/urls.py b/src/genlab_bestilling/staff/urls.py
index 52b445e..d5a6e2c 100644
--- a/src/genlab_bestilling/staff/urls.py
+++ b/src/genlab_bestilling/staff/urls.py
@@ -1,7 +1,6 @@
from django.urls import path
from django.views.generic import TemplateView
-from ..models import AnalysisOrder, EquipmentOrder
from .views import (
AnalysisOrderDetailView,
AnalysisOrderListView,
@@ -14,6 +13,7 @@
OrderAnalysisSamplesListView,
OrderExtractionSamplesListView,
OrderToDraftActionView,
+ OrderToNextStatusActionView,
SamplesListView,
)
@@ -41,16 +41,17 @@
),
path(
"orders//to-draft/",
- OrderToDraftActionView.as_view(model=AnalysisOrder),
+ OrderToDraftActionView.as_view(),
name="order-to-draft",
),
path(
- "orders//to-draft/",
- OrderToDraftActionView.as_view(model=EquipmentOrder),
+ "orders//to-next-status/",
+ OrderToNextStatusActionView.as_view(),
+ name="order-to-next-status",
),
path(
"orders//manually-checked/",
- ManaullyCheckedOrderActionView.as_view(model=AnalysisOrder),
+ ManaullyCheckedOrderActionView.as_view(),
name="order-manually-checked",
),
path(
diff --git a/src/genlab_bestilling/staff/views.py b/src/genlab_bestilling/staff/views.py
index fb4bda0..0ab0296 100644
--- a/src/genlab_bestilling/staff/views.py
+++ b/src/genlab_bestilling/staff/views.py
@@ -206,10 +206,10 @@ def get_queryset(self):
class ManaullyCheckedOrderActionView(SingleObjectMixin, ActionView):
- model = Order
+ model = ExtractionOrder
def get_queryset(self):
- return super().get_queryset().filter(status=Order.OrderStatus.CONFIRMED)
+ return ExtractionOrder.objects.filter(status=Order.OrderStatus.CONFIRMED)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
@@ -276,3 +276,38 @@ def get_success_url(self) -> str:
def form_invalid(self, form):
return HttpResponseRedirect(self.get_success_url())
+
+
+class OrderToNextStatusActionView(SingleObjectMixin, ActionView):
+ model = Order
+
+ def get_queryset(self):
+ return Order.objects.all()
+
+ def post(self, request, *args, **kwargs):
+ self.object = self.get_object()
+ return super().post(request, *args, **kwargs)
+
+ def form_valid(self, form: Any) -> HttpResponse:
+ try:
+ # TODO: check state transition
+ self.object.to_next_status()
+ messages.add_message(
+ self.request,
+ messages.SUCCESS,
+ _(f"The order status changed to {self.object.get_status_display()}"),
+ )
+ except Exception as e:
+ messages.add_message(
+ self.request,
+ messages.ERROR,
+ f'Error: {",".join(map(lambda error: str(error), e.detail))}',
+ )
+
+ return super().form_valid(form)
+
+ def get_success_url(self) -> str:
+ return reverse_lazy(f"staff:order-{self.object.get_type()}-list")
+
+ def form_invalid(self, form):
+ return HttpResponseRedirect(self.get_success_url())
diff --git a/src/genlab_bestilling/views.py b/src/genlab_bestilling/views.py
index 980f7d2..f62f167 100644
--- a/src/genlab_bestilling/views.py
+++ b/src/genlab_bestilling/views.py
@@ -27,7 +27,7 @@
from rest_framework.exceptions import ValidationError
from view_breadcrumbs import BaseBreadcrumbMixin
-from .api.serializers import ExtractionSerializer
+from .api.serializers import AnalysisSerializer, ExtractionSerializer
from .forms import (
ActionForm,
AnalysisOrderForm,
@@ -778,7 +778,7 @@ def get_context_data(self, **kwargs) -> dict[str, Any]:
context["frontend_args"] = {
"order": self.object.id,
"csrf": get_token(self.request),
- # "analysis_data": ExtractionSerializer(self.object).data,
+ "analysis_data": AnalysisSerializer(self.object).data,
}
return context