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 ( + +
+

Sample Selector

+ +
+ + Back + + + Summary + +
+
+ +
{/* */}
+ +

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) => ( + + ))} + + ))} + + + {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 ( + + ); + })} + + ); + })} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ {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) => ( + + ))} + + ))} + + + {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 ( + + ); + })} + + ); + })} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ {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 ( +
{ + e.preventDefault(); + handleSubmit(); + }} + className="" + id="add-rows" + > +
+ + {({ state, handleChange }) => ( + + + + + )} + + +
+ + {({ state, handleChange, handleBlur }) => ( + + + o.name} + getOptionValue={(o) => o.name} + onBlur={handleBlur} + classNamePrefix="react-select" + className="" + value={state.value} + onChange={handleChange} + required + /> + + )} + +
+
+ [ + state.canSubmit, + state.isSubmitting, + state.values.selectedSamples, + state.values.markers, + ]} + > + {([canSubmit, isSubmitting, samples, markers]) => ( + + )} + +
+ + ); +} 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