diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4061410b..3e3c6eab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} @@ -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 diff --git a/.gitignore b/.gitignore index b5fac182..b48164c8 100644 --- a/.gitignore +++ b/.gitignore @@ -335,3 +335,6 @@ staticfiles/ # Local History for devcontainer .devcontainer/bash_history + +### macOS ### +.DS_Store diff --git a/src/config/autocomplete.py b/src/config/autocomplete.py index 72e981c0..9fd60470 100644 --- a/src/config/autocomplete.py +++ b/src/config/autocomplete.py @@ -12,6 +12,7 @@ OrderAutocomplete, SampleTypeAutocomplete, SpeciesAutocomplete, + StatusAutocomplete, ) from nina.autocomplete import ProjectAutocomplete @@ -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"), diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index a9a7839f..227e6064 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -19,10 +19,10 @@ Location, LocationType, Marker, + Order, Organization, Sample, SampleMarkerAnalysis, - SampleStatusAssignment, SampleType, Species, ) @@ -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"] @@ -526,9 +533,5 @@ class AnalysisResultAdmin(ModelAdmin): ] -@admin.register(SampleStatusAssignment) -class SampleStatusAssignmentAdmin(ModelAdmin): ... - - @admin.register(IsolationMethod) class IsolationMethodAdmin(ModelAdmin): ... diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index d8d285d3..e47cfb43 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -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 ( @@ -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", @@ -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() diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index 7f914bfc..9ea27c65 100644 --- a/src/genlab_bestilling/api/views.py +++ b/src/genlab_bestilling/api/views.py @@ -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 @@ -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() @@ -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]: @@ -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"}, ) diff --git a/src/genlab_bestilling/autocomplete.py b/src/genlab_bestilling/autocomplete.py index 120d2cd3..2a3bdb63 100644 --- a/src/genlab_bestilling/autocomplete.py +++ b/src/genlab_bestilling/autocomplete.py @@ -1,4 +1,5 @@ from dal import autocomplete +from django.http import HttpRequest, JsonResponse from .models import ( AnalysisOrder, @@ -18,6 +19,17 @@ class AreaAutocomplete(autocomplete.Select2QuerySetView): model = Area +class StatusAutocomplete(autocomplete.Select2QuerySetView): + def get(self, request: "HttpRequest", *args, **kwargs) -> JsonResponse: + term = request.GET.get("q", "").lower() + results = [ + {"id": choice[0], "text": choice[1]} + for choice in Order.OrderStatus.choices + if term in choice[1].lower() + ] + return JsonResponse({"results": results}) + + class SpeciesAutocomplete(autocomplete.Select2QuerySetView): model = Species diff --git a/src/genlab_bestilling/managers.py b/src/genlab_bestilling/managers.py index 9b2b2319..987b2b5d 100644 --- a/src/genlab_bestilling/managers.py +++ b/src/genlab_bestilling/managers.py @@ -4,7 +4,6 @@ from django.db import models, transaction from django.db.models import QuerySet -from django.db.models.expressions import RawSQL from polymorphic.managers import PolymorphicManager, PolymorphicQuerySet from capps.users.models import User @@ -54,9 +53,6 @@ def filter_in_draft(self) -> QuerySet: ) -DEFAULT_SORTING_FIELDS = ["name_as_int", "name"] - - class SampleQuerySet(models.QuerySet): def filter_allowed(self, user: User) -> QuerySet: """ @@ -76,7 +72,6 @@ def filter_in_draft(self) -> QuerySet: def generate_genlab_ids( self, order_id: int, - sorting_order: list[str] | None = DEFAULT_SORTING_FIELDS, selected_samples: list[int] | None = None, ) -> None: """ @@ -84,28 +79,23 @@ def generate_genlab_ids( """ - # Lock the samples - samples = ( - self.select_related("species") - .filter(order_id=order_id, genlab_id__isnull=True) - .select_for_update() - ) + selected_sample_ids = [int(s) for s in selected_samples] - if selected_samples: - samples = samples.filter(id__in=selected_samples) - - if sorting_order == DEFAULT_SORTING_FIELDS: - # create an annotation containg all integer values - # of "name", so that it's possible to sort numerically and alphabetically - samples = samples.annotate( - name_as_int=RawSQL( - r"substring(%s from '^\d+$')::int", - params=["name"], - output_field=models.IntegerField(), + samples = list( + ( + self.select_related("species") + .filter( + order_id=order_id, + genlab_id__isnull=True, + id__in=selected_sample_ids, ) - ).order_by(*sorting_order) - else: - samples = samples.order_by(*sorting_order) + .select_for_update() + ).all() + ) + + # Sort samples in the order of selected_samples + id_pos = {id_: i for i, id_ in enumerate(selected_sample_ids)} + samples.sort(key=lambda sample: id_pos.get(sample.id, 99999)) # Safe fallback updates = [] for sample in samples: diff --git a/src/genlab_bestilling/migrations/0027_alter_isolationmethod_name.py b/src/genlab_bestilling/migrations/0027_alter_isolationmethod_name.py new file mode 100644 index 00000000..80790b0a --- /dev/null +++ b/src/genlab_bestilling/migrations/0027_alter_isolationmethod_name.py @@ -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), + ), + ] diff --git a/src/genlab_bestilling/migrations/0028_sample_is_isolated_sample_is_marked_and_more.py b/src/genlab_bestilling/migrations/0028_sample_is_isolated_sample_is_marked_and_more.py new file mode 100644 index 00000000..7204ccb1 --- /dev/null +++ b/src/genlab_bestilling/migrations/0028_sample_is_isolated_sample_is_marked_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.3 on 2025-07-17 08:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0027_alter_isolationmethod_name"), + ] + + operations = [ + migrations.AddField( + model_name="sample", + name="is_isolated", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="sample", + name="is_marked", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="sample", + name="is_plucked", + field=models.BooleanField(default=False), + ), + migrations.DeleteModel( + name="SampleStatusAssignment", + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 8211e20f..747faa89 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -4,7 +4,6 @@ from django.conf import settings from django.db import models, transaction -from django.db.models import QuerySet from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -320,9 +319,14 @@ def clone(self) -> None: def to_draft(self) -> None: self.status = Order.OrderStatus.DRAFT + self.is_seen = False self.confirmed_at = None self.save() + def to_processing(self) -> None: + self.status = Order.OrderStatus.PROCESSING + self.save() + def toggle_seen(self) -> None: self.is_seen = not self.is_seen self.save() @@ -495,19 +499,10 @@ def confirm_order(self, persist: bool = True) -> None: if persist: super().confirm_order() - def order_manually_checked(self) -> None: - """ - Set the order as checked by the lab staff, generate a genlab id - """ - self.internal_status = self.Status.CHECKED - self.status = self.OrderStatus.PROCESSING - self.save(update_fields=["internal_status", "status"]) - @transaction.atomic def order_selected_checked( self, - sorting_order: list[str] | None = None, - selected_samples: QuerySet["Sample"] | None = None, + selected_samples: list[int] | None = None, ) -> None: """ Partially set the order as checked by the lab staff, @@ -517,12 +512,11 @@ def order_selected_checked( self.status = self.OrderStatus.PROCESSING self.save(update_fields=["internal_status", "status"]) - if not selected_samples.exists(): + if not selected_samples: return Sample.objects.generate_genlab_ids( order_id=self.id, - sorting_order=sorting_order, selected_samples=selected_samples, ) @@ -646,6 +640,10 @@ class Sample(models.Model): year = models.IntegerField() notes = models.TextField(null=True, blank=True) + is_marked = models.BooleanField(default=False) + is_plucked = models.BooleanField(default=False) + is_isolated = models.BooleanField(default=False) + # "Merknad" in the Excel sheet. internal_note = models.TextField(null=True, blank=True) pop_id = models.CharField(max_length=150, null=True, blank=True) @@ -778,38 +776,6 @@ def generate_genlab_id(self, commit: bool = True) -> str: # assignee (one or plus?) -class SampleStatusAssignment(models.Model): - class SampleStatus(models.TextChoices): - MARKED = "marked", _("Marked") - PLUCKED = "plucked", _("Plucked") - ISOLATED = "isolated", _("Isolated") - - sample = models.ForeignKey( - f"{an}.Sample", - on_delete=models.CASCADE, - related_name="sample_status_assignments", - ) - status = models.CharField( - choices=SampleStatus.choices, - null=True, - blank=True, - verbose_name="Sample status", - help_text="The status of the sample in the lab", - ) - order = models.ForeignKey( - f"{an}.Order", - on_delete=models.CASCADE, - related_name="sample_status_assignments", - null=True, - blank=True, - ) - - assigned_at = models.DateTimeField(auto_now_add=True) - - class Meta: - unique_together = ("sample", "status", "order") - - class SampleIsolationMethod(models.Model): sample = models.ForeignKey( f"{an}.Sample", @@ -827,7 +793,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, diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html index a23995de..11812109 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html @@ -33,20 +33,20 @@
Samples to analyze
- back + Back {% if object.status == 'draft' %} - Edit Order + Edit Order {% if not object.from_order %} - Edit Samples + Edit Samples {% endif %} - Summary Samples + Summary Samples {% url 'genrequest-order-confirm' genrequest_id=object.genrequest_id pk=object.id as confirm_order_url %} {% url 'genrequest-order-clone' genrequest_id=object.genrequest_id pk=object.id as clone_order_url %} - {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} - {% action-button action=clone_order_url class="bg-secondary text-white" submit_text="Clone Order" csrf_token=csrf_token %} - Delete + {% action-button action=confirm_order_url class="btn custom_order_button" submit_text="Deliver order" csrf_token=csrf_token %} + {% action-button action=clone_order_url class="btn custom_order_button" submit_text="Clone Order" csrf_token=csrf_token %} + Delete {% elif object.status == object.OrderStatus.DELIVERED %} - Samples + Samples {% endif %}
{% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_filter.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_filter.html index 23259bf5..9dc763bb 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_filter.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_filter.html @@ -6,8 +6,8 @@ {% block page-inner %}
{% if genrequest %} - back - Equipment order + Back + Equipment order {% endif %}
{% endblock page-inner %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_form.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_form.html index 8379e70f..c201072b 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_form.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_form.html @@ -6,9 +6,9 @@

{% if object.id %}{{ object }}{% else %}Create {{ view
{% if object.id %} - back + Back {% else %} - back + Back {% endif %}
{% formset endpoint=request.path csrf_token=csrf_token form=form %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html b/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html index 0f2c0924..e27e6f3b 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html @@ -9,8 +9,8 @@

{% block page-title %}{% endblock page-title %}

{{ filter.form | crispy }} +
-
{% render_table table %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html index c4f3c066..77f9fbaf 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_detail.html @@ -12,7 +12,7 @@

Order {{ object }}

- back + Back
{% object-detail object=object %} @@ -35,13 +35,13 @@
Requested Equipment
{% if object.status == 'draft' %} - Edit - Edit requested equipment + Edit + Edit requested equipment {% url 'genrequest-order-confirm' genrequest_id=object.genrequest_id pk=object.id as confirm_order_url %} - {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} - Delete + {% action-button action=confirm_order_url class="btn custom_order_button" submit_text="Deliver order" csrf_token=csrf_token %} + Delete {% endif %} {% url 'genrequest-order-clone' genrequest_id=object.genrequest_id pk=object.id as clone_order_url %} - {% action-button action=clone_order_url class="bg-secondary text-white" submit_text="Clone Order" csrf_token=csrf_token %} + {% action-button action=clone_order_url class="btn custom_order_button" submit_text="Clone Order" csrf_token=csrf_token %}
{% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_filter.html b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_filter.html index 9b328e39..d0dad657 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_filter.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_filter.html @@ -6,8 +6,8 @@ {% block page-inner %}
{% if genrequest %} - back - Equipment order + Back + Equipment order {% endif %}
{% endblock page-inner %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_form.html b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_form.html index 0b9e874f..cc0cc260 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_form.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorder_form.html @@ -5,9 +5,9 @@

{% if object.id %}{{ object }}{% else %}Create {{ view.model|verbose_name }}{% endif %}

{% if object.id %} - back + Back {% else %} - back + Back {% endif %}
{% formset endpoint=request.path csrf_token=csrf_token form=form %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorderquantity_form.html b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorderquantity_form.html index e9f38114..2593b8b5 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/equipmentorderquantity_form.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/equipmentorderquantity_form.html @@ -4,7 +4,7 @@ {% block content %}

{% if object.id %}{{ object }}{% else %}Create {{ view.model|verbose_name }}{% endif %}

- back + Back
{% formset endpoint=request.path csrf_token=csrf_token form_collection=form_collection %} {% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html index ae3a5a89..0342d567 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html @@ -16,30 +16,33 @@

Order {{ object }}

- {% object-detail object=object %} - - -
Delivered Samples
-
-

Uploaded {{ object.samples.count }} samples

-
-
- back + Back {% if object.status == 'draft' %} - Edit Order - Edit Samples - {% url 'genrequest-order-confirm' genrequest_id=object.genrequest_id pk=object.id as confirm_order_url %} - {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} - Delete + Edit Order + Edit Samples + + {% url 'genrequest-order-confirm' genrequest_id=object.genrequest_id pk=object.id as confirm_order_url %} + {% action-button action=confirm_order_url class="btn custom_order_button" submit_text="Deliver order" csrf_token=csrf_token %} + + Delete {% endif %} - Samples + + Samples {% if object.status != 'draft' %} - Analyze these samples + Analyze these samples {% endif %} {% url 'genrequest-order-clone' genrequest_id=object.genrequest_id pk=object.id as clone_order_url %} - {% action-button action=clone_order_url class="bg-secondary text-white" submit_text="Clone Order" csrf_token=csrf_token %} + {% action-button action=clone_order_url class="btn custom_order_button" submit_text="Clone Order" csrf_token=csrf_token %} +
+ + {% object-detail object=object %} + + +
Delivered Samples
+
+

Uploaded {{ object.samples.count }} samples

{% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_filter.html b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_filter.html index 6668d719..b0676d9b 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_filter.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_filter.html @@ -6,8 +6,8 @@ {% block page-inner %}
{% if genrequest %} - back - Equipment order + Back + Equipment order {% endif %}
{% endblock page-inner %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_form.html b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_form.html index 15e837a9..1f96a6e8 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_form.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_form.html @@ -6,9 +6,9 @@

{% if object.id %}{{ object }}{% else %}Create {{ view
{% if object.id %} - back + Back {% else %} - back + Back {% endif %}
{% formset endpoint=request.path csrf_token=csrf_token form=form %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_confirm_delete.html b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_confirm_delete.html index e57a4a4f..b3c0b53b 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_confirm_delete.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_confirm_delete.html @@ -5,14 +5,14 @@ {% block content %}

Delete request {{ object }}?

- back + Back

Are you sure you want to delete this request?

{% csrf_token %} - +
{% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html index 191aefc5..a24452f8 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html @@ -15,49 +15,49 @@

{{ object.project_id }} - {{ object.name|d {% endif %} -
- {% object-detail object=object %} + +
+ {% object-detail object=object %}
{% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_filter.html b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_filter.html index 9f64aa39..49382992 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_filter.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_filter.html @@ -5,6 +5,6 @@ {% block page-title %}Genetic Project{% endblock page-title %} {% block page-inner %} {% endblock page-inner %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_form.html b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_form.html index 2ad667d3..d06e6743 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_form.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_form.html @@ -5,10 +5,10 @@

{% if object.id %}{{ object }}{% else %}Create {{ view.model|verbose_name }}{% endif %}

{% if object.id %} - back + Back {% else %} - back - Register UBW project + Back + Register UBW project {% endif %}
{% formset endpoint=request.path csrf_token=csrf_token form=form %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/order_confirm_delete.html b/src/genlab_bestilling/templates/genlab_bestilling/order_confirm_delete.html index 8f762754..76eea90c 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/order_confirm_delete.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/order_confirm_delete.html @@ -5,14 +5,14 @@ {% block content %}

Delete Order {{ object }}?

Are you sure you want to delete this order?

{% csrf_token %} - +
{% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/order_filter.html b/src/genlab_bestilling/templates/genlab_bestilling/order_filter.html index 5e32f0dd..80b5169a 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/order_filter.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/order_filter.html @@ -6,10 +6,10 @@ {% block page-inner %} {% endblock page-inner %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/sample_list.html b/src/genlab_bestilling/templates/genlab_bestilling/sample_list.html index dee3ee67..1ebbb60d 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/sample_list.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/sample_list.html @@ -6,16 +6,16 @@

Samples for Extraction #{{ view.kwargs.pk }}

{% render_table table %}
- back to order + Back to order {% if extraction.status == 'draft' %} {% url 'genrequest-order-confirm' genrequest_id=view.kwargs.genrequest_id pk=view.kwargs.pk as confirm_order_url %} - {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} - edit samples + {% action-button action=confirm_order_url class="btn custom_order_button" submit_text="Deliver order" csrf_token=csrf_token %} + Edit samples {% endif %} {% if extraction.status != 'draft' %} - Analyze these samples + Analyze these samples {% endif %} - Download CSV + Download CSV
{% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/samplemarkeranalysis_list.html b/src/genlab_bestilling/templates/genlab_bestilling/samplemarkeranalysis_list.html index ede4ff29..5b81034a 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/samplemarkeranalysis_list.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/samplemarkeranalysis_list.html @@ -13,12 +13,12 @@

{{ analysis }} - Samples

{% render_table table %}
- back to order + Back to order {% if analysis.status == 'draft' %} {% url 'genrequest-order-confirm' genrequest_id=view.kwargs.genrequest_id pk=view.kwargs.pk as confirm_order_url %} {% action-button action=confirm_order_url class="bg-secondary text-white" submit_text="Deliver order" csrf_token=csrf_token %} {% if not analysis.from_order %} - edit samples + Edit samples {% endif %} {% endif %}
diff --git a/src/genlab_bestilling/tests/test_models.py b/src/genlab_bestilling/tests/test_models.py index 6e1dd7e3..f015df43 100644 --- a/src/genlab_bestilling/tests/test_models.py +++ b/src/genlab_bestilling/tests/test_models.py @@ -142,11 +142,20 @@ def test_full_order_ids_generation(extraction): """ extraction.confirm_order() - Sample.objects.generate_genlab_ids(extraction.id) + sample_ids = list( + Sample.objects.filter(order_id=extraction.id).values_list( + "id", flat=True + ) # selected_samples is always a list + ) + + Sample.objects.generate_genlab_ids( + extraction.id, + selected_samples=[str(pk) for pk in sample_ids], + ) assertQuerySetEqual( Sample.objects.filter(genlab_id__isnull=False), - Sample.objects.all(), + Sample.objects.filter(order_id=extraction.id), ordered=False, ) @@ -159,7 +168,7 @@ def test_order_selected_ids_generation(extraction): Sample.objects.generate_genlab_ids( extraction.id, - selected_samples=extraction.samples.all().values("id")[ + selected_samples=extraction.samples.all().values_list("id", flat=True)[ : extraction.samples.count() - 1 ], ) @@ -170,6 +179,14 @@ def test_order_selected_ids_generation(extraction): ) +def natural_sort_key(s): + """Return a key that sorts numbers numerically and strings lexicographically""" + try: + return (0, int(s.name)) + except (ValueError, TypeError): + return (1, str(s.name)) + + def test_ids_generation_with_only_numeric_names(genlab_setup): """ Test that by default the ordering is done on the column name @@ -226,7 +243,14 @@ def test_ids_generation_with_only_numeric_names(genlab_setup): extraction.confirm_order() - Sample.objects.generate_genlab_ids(order_id=extraction.id) + samples = [s1, s2, s3, s4] + samples.sort(key=natural_sort_key) + sample_ids = [str(s.id) for s in samples] + + Sample.objects.generate_genlab_ids( + order_id=extraction.id, + selected_samples=sample_ids, + ) gid = GIDSequence.objects.get_sequence_for_species_year( species=combo[0][0], year=extraction.confirmed_at.year, lock=False @@ -302,8 +326,13 @@ def test_ids_generation_order_by_pop_id(genlab_setup): extraction.confirm_order() + samples = list(Sample.objects.filter(order_id=extraction.id)) + samples.sort(key=lambda s: (s.pop_id, str(s.name))) + sample_ids = [str(s.id) for s in samples] + Sample.objects.generate_genlab_ids( - order_id=extraction.id, sorting_order=["pop_id", "name"] + order_id=extraction.id, + selected_samples=sample_ids, ) gid = GIDSequence.objects.get_sequence_for_species_year( diff --git a/src/nina/templates/nina/project_detail.html b/src/nina/templates/nina/project_detail.html index 6e5f3acc..bd38e40d 100644 --- a/src/nina/templates/nina/project_detail.html +++ b/src/nina/templates/nina/project_detail.html @@ -14,6 +14,28 @@

{{ object }}

{% endif %} +
+ Back + Edit + Members + {% if object.verified_at %} + Genetic projects + {% if object.active %} + New Genetic project + {% endif %} + {% endif %} +
+
{% object-detail object=object %} @@ -22,27 +44,5 @@

Members

{% render_table table %}
- -
- back - Edit - Members - {% if object.verified_at %} - Genetic projects - {% if object.active %} - new Genetic project - {% endif %} - {% endif %} -
{% endblock %} diff --git a/src/nina/templates/nina/project_form.html b/src/nina/templates/nina/project_form.html index 90d3d1f2..55da8b8d 100644 --- a/src/nina/templates/nina/project_form.html +++ b/src/nina/templates/nina/project_form.html @@ -9,6 +9,6 @@

{% if object.id %}{{ object }}{% else %}Create {{ view {% render_form form "tailwind" %} - + {% endblock %} diff --git a/src/nina/templates/nina/projectmembership_form.html b/src/nina/templates/nina/projectmembership_form.html index 792de137..fbdfb9e1 100644 --- a/src/nina/templates/nina/projectmembership_form.html +++ b/src/nina/templates/nina/projectmembership_form.html @@ -4,7 +4,7 @@ {% block content %}

{{ object }}

- back + Back
{% formset endpoint=request.path csrf_token=csrf_token form_collection=form_collection %} {% endblock %} diff --git a/src/nina/templates/nina/projectmembership_list.html b/src/nina/templates/nina/projectmembership_list.html index 4a0790c1..d626c8fe 100644 --- a/src/nina/templates/nina/projectmembership_list.html +++ b/src/nina/templates/nina/projectmembership_list.html @@ -4,7 +4,7 @@ {% block content %}

NINA Projects you are involved in

- Register a project + Register a project
{% render_table table %} diff --git a/src/staff/filters.py b/src/staff/filters.py index 067247b7..f5d09d43 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -2,45 +2,116 @@ import django_filters as filters from dal import autocomplete +from django import forms from django.db.models import QuerySet from django.http import HttpRequest +from django_filters import CharFilter from genlab_bestilling.models import ( AnalysisOrder, + ExtractionOrder, ExtractionPlate, + Order, Sample, SampleMarkerAnalysis, ) class AnalysisOrderFilter(filters.FilterSet): - def __init__( - self, - data: dict[str, Any] | None = None, - queryset: QuerySet | None = None, - *, - request: HttpRequest | None = None, - prefix: str | None = None, - ) -> None: - super().__init__(data, queryset, request=request, prefix=prefix) - self.filters["genrequest__project"].extra["widget"] = autocomplete.ModelSelect2( - url="autocomplete:project" + class Meta: + model = AnalysisOrder + fields = ["id", "status", "genrequest__area"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.filters["id"].field.label = "Order ID" + self.filters["id"].field.widget = forms.TextInput( + attrs={ + "class": "bg-white border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 + "placeholder": "Enter Order ID", + } + ) + + self.filters["status"].field.label = "Order Status" + self.filters["status"].field.choices = Order.OrderStatus.choices + self.filters["status"].field.widget = autocomplete.ListSelect2( + url="autocomplete:order-status", + attrs={ + "class": "w-full", + }, ) - self.filters["genrequest"].extra["widget"] = autocomplete.ModelSelect2( - url="autocomplete:genrequest" + + self.filters["genrequest__area"].field.label = "Area" + self.filters["genrequest__area"].field.widget = autocomplete.ModelSelect2( + url="autocomplete:area", + attrs={ + "class": "w-full", + }, ) + +class ExtractionOrderFilter(filters.FilterSet): class Meta: - model = AnalysisOrder - fields = [ - "id", - "status", - "genrequest", - "genrequest__project", - ] + model = ExtractionOrder + fields = ["id", "status", "genrequest__area", "sample_types"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.filters["id"].field.label = "Order ID" + self.filters["id"].field.widget = forms.TextInput( + attrs={ + "class": "bg-white border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 + "placeholder": "Enter Order ID", + } + ) + + self.filters["status"].field.label = "Order Status" + self.filters["status"].field.choices = Order.OrderStatus.choices + self.filters["status"].field.widget = autocomplete.ListSelect2( + url="autocomplete:order-status", + attrs={ + "class": "w-full", + }, + ) + + self.filters["genrequest__area"].field.label = "Area" + self.filters["genrequest__area"].field.widget = autocomplete.ModelSelect2( + url="autocomplete:area", + attrs={ + "class": "w-full", + }, + ) + + self.filters["sample_types"].field.label = "Sample types" + self.filters["sample_types"].field.widget = autocomplete.ModelSelect2Multiple( + url="autocomplete:sample-type", + attrs={ + "class": "w-full", + }, + ) class OrderSampleFilter(filters.FilterSet): + genlab_id = CharFilter( + label="GenlabID", + widget=forms.TextInput( + attrs={ + "placeholder": "Type here", + } + ), + ) + + name = CharFilter( + label="Name", + widget=forms.TextInput( + attrs={ + "placeholder": "Type here", + } + ), + ) + def __init__( self, data: dict[str, Any] | None = None, @@ -56,23 +127,20 @@ def __init__( self.filters["type"].extra["widget"] = autocomplete.ModelSelect2( url="autocomplete:sample-type" ) - self.filters["location"].extra["widget"] = autocomplete.ModelSelect2( - url="autocomplete:location" - ) class Meta: model = Sample fields = [ # "order", - "guid", - "name", + # "guid", "genlab_id", + "name", "species", "type", - "year", - "location", - "pop_id", - "type", + # "year", + # "location", + # "pop_id", + # "type", # "desired_extractions", ] diff --git a/src/staff/tables.py b/src/staff/tables.py index a9ce9222..b91b3efb 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -1,8 +1,9 @@ +import re +from collections.abc import Sequence +from datetime import datetime from typing import Any import django_tables2 as tables -from django.db.models import IntegerField -from django.db.models.functions import Cast from django.utils.safestring import mark_safe from genlab_bestilling.models import ( @@ -166,7 +167,7 @@ class SampleBaseTable(tables.Table): empty_values=(), ) - name = tables.Column(order_by=("name_as_int",)) + name = tables.Column() class Meta: model = Sample @@ -192,23 +193,10 @@ class Meta: "species", "type", ) - order_by = ( - "-is_prioritised", - "species", - "genlab_id", - "name_as_int", - ) + order_by = ("-is_prioritised", "species", "genlab_id") empty_text = "No Samples" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if hasattr(self.data, "data"): - self.data.data = self.data.data.annotate( - name_as_int=Cast("name", output_field=IntegerField()) - ) - def render_plate_positions(self, value: Any) -> str: if value: return ", ".join([str(v) for v in value.all()]) @@ -218,6 +206,24 @@ def render_plate_positions(self, value: Any) -> str: def render_checked(self, record: Any) -> str: return mark_safe(f'') # noqa: S308 + def order_name( + self, records: Sequence[Any], is_descending: bool + ) -> tuple[list[Any], bool]: + def natural_sort_key(record: Any) -> list[str]: + name = record.name or "" + parts = re.findall(r"\d+|\D+", name) + key = [] + for part in parts: + if part.isdigit(): + # Pad numbers with zeros for proper string compare, e.g., '000012' > '000001' # noqa: E501 + key.append(f"{int(part):010d}") + else: + key.append(part.lower()) + return key + + sorted_records = sorted(records, key=natural_sort_key, reverse=is_descending) + return (sorted_records, True) + class SampleStatusTable(tables.Table): """ @@ -250,18 +256,21 @@ class SampleStatusTable(tables.Table): orderable=True, yesno="✔,-", default=False, + accessor="is_marked", ) plucked = tables.BooleanColumn( verbose_name="Plucked", orderable=True, yesno="✔,-", default=False, + accessor="is_plucked", ) isolated = tables.BooleanColumn( verbose_name="Isolated", orderable=True, yesno="✔,-", default=False, + accessor="is_isolated", ) class Meta: @@ -269,6 +278,9 @@ class Meta: fields = [ "checked", "genlab_id", + "marked", + "plucked", + "isolated", "internal_note", "isolation_method", "type", @@ -283,11 +295,17 @@ class Meta: "internal_note", "isolation_method", ] + order_by = ("genlab_id",) + + def render_checked(self, record: Any) -> str: + return mark_safe( # noqa: S308 + f'' # noqa: E501 + ) class OrderExtractionSampleTable(SampleBaseTable): class Meta(SampleBaseTable.Meta): - fields = SampleBaseTable.Meta.fields + exclude = ("pop_id", "guid", "plate_positions") class OrderAnalysisSampleTable(tables.Table): @@ -366,7 +384,7 @@ def render_status(self, value: Order.OrderStatus, record: Order) -> str: color_class = status_colors.get(value, "bg-gray-100 text-gray-800") status_text = status_text.get(value, "Unknown") return mark_safe( # noqa: S308 - f'{status_text}' # noqa: E501 + f'{status_text}' # noqa: E501 ) @@ -385,6 +403,13 @@ def render_id( class UrgentOrderTable(StaffIDMixinTable, StatusMixinTable): + priority = tables.TemplateColumn( + orderable=False, + verbose_name="Priority", + accessor="priority", + template_name="staff/components/priority_column.html", + ) + description = tables.Column( accessor="genrequest__name", verbose_name="Description", @@ -392,29 +417,28 @@ class UrgentOrderTable(StaffIDMixinTable, StatusMixinTable): ) delivery_date = tables.Column( - accessor="genrequest__expected_samples_delivery_date", verbose_name="Delivery date", orderable=False, ) - def render_delivery_date(self, value: Any) -> str: + def render_delivery_date(self, value: datetime | None) -> str: if value: return value.strftime("%d/%m/%Y") return "-" class Meta: model = Order - fields = ["id", "description", "delivery_date", "status"] + fields = ["priority", "id", "description", "delivery_date", "status"] empty_text = "No urgent orders" template_name = "django_tables2/tailwind_inner.html" class NewUnseenOrderTable(StaffIDMixinTable): seen = tables.TemplateColumn( + verbose_name="", orderable=False, - verbose_name="Seen", - template_name="staff/components/seen_column.html", empty_values=(), + template_name="staff/components/seen_column.html", ) description = tables.Column( @@ -424,12 +448,11 @@ class NewUnseenOrderTable(StaffIDMixinTable): ) delivery_date = tables.Column( - accessor="genrequest__expected_samples_delivery_date", verbose_name="Delivery date", orderable=False, ) - def render_delivery_date(self, value: Any) -> str: + def render_delivery_date(self, value: datetime | None) -> str: if value: return value.strftime("%d/%m/%Y") return "-" @@ -471,12 +494,11 @@ class NewSeenOrderTable(StaffIDMixinTable): ) delivery_date = tables.Column( - accessor="genrequest__expected_samples_delivery_date", verbose_name="Delivery date", orderable=False, ) - def render_delivery_date(self, value: Any) -> str: + def render_delivery_date(self, value: datetime | None) -> str: if value: return value.strftime("%d/%m/%Y") return "-" @@ -530,9 +552,9 @@ class AssignedOrderTable(StatusMixinTable, StaffIDMixinTable): orderable=False, ) - def render_samples_completed(self, value: int) -> str: + def render_samples_completed(self, value: int, record: Order) -> str: if value > 0: - return "- / " + str(value) + return str(record.isolated_sample_count) + " / " + str(value) return "-" class Meta: @@ -557,12 +579,11 @@ class DraftOrderTable(StaffIDMixinTable): ) delivery_date = tables.Column( - accessor="genrequest__expected_samples_delivery_date", verbose_name="Delivery date", orderable=False, ) - def render_delivery_date(self, value: Any) -> str: + def render_delivery_date(self, value: datetime | None) -> str: if value: return value.strftime("%d/%m/%Y") return "-" diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index fc2ae045..ba9ece3a 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -1,5 +1,6 @@ {% extends "staff/base.html" %} {% load i18n %} +{% load order_tags %} {% block content %} @@ -7,35 +8,43 @@

Order {{ object }}

- back - Samples + Back + Samples {% if extraction_order %} - Go to {{ extraction_order}} + Go to {{ extraction_order}} {% endif %} - Assign staff -
- {% if not object.is_seen %} + {% if object.genrequest.responsible_staff.all|is_responsible:request.user and not object.is_seen %}
{% csrf_token %} - +
{% endif %} - {% if object.status == object.OrderStatus.DELIVERED %} + {% if object.status != object.OrderStatus.DRAFT %} {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} - {% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} + {% action-button action=to_draft_url class="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} {% endif %} - {% if object.status != object.OrderStatus.DRAFT and object.next_status %} + {% if object.status == object.OrderStatus.DELIVERED %} {% 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 %} + {% with " Set as "|add:object.next_status as btn_name %} + {% action-button action=to_next_status_url class="bg-yellow-200 text-yellow-800 border border-yellow-700 hover:bg-yellow-300" submit_text=btn_name csrf_token=csrf_token %} {% endwith %} {% endif %} + + {% if object.status == object.OrderStatus.PROCESSING %} + {% 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="custom_order_button_green" submit_text=btn_name csrf_token=csrf_token %} + {% endwith %} + {% endif %} +
+
+ Assign staff
{% fragment as table_header %} diff --git a/src/staff/templates/staff/analysisorder_filter.html b/src/staff/templates/staff/analysisorder_filter.html index a0ac89a4..c388eb48 100644 --- a/src/staff/templates/staff/analysisorder_filter.html +++ b/src/staff/templates/staff/analysisorder_filter.html @@ -10,8 +10,8 @@
{{ filter.form | crispy }} +
-
{% render_table table %} diff --git a/src/staff/templates/staff/components/seen_column.html b/src/staff/templates/staff/components/seen_column.html index 4e318c3c..4b5deec7 100644 --- a/src/staff/templates/staff/components/seen_column.html +++ b/src/staff/templates/staff/components/seen_column.html @@ -1,5 +1,11 @@ +{% load order_tags %} + +{% if record.genrequest.responsible_staff.all|is_responsible:request.user %}
{% csrf_token %} - +
+{% endif %} diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index 7f76f07b..061dee90 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -11,7 +11,7 @@

Order {{ object }}

- back + Back
{% object-detail object=object %} @@ -33,8 +33,8 @@
Requested Equipment
{% /table %}
- back - Assign staff + Back + Assign staff {% comment %} {% if object.status == 'draft' %} Edit diff --git a/src/staff/templates/staff/equipmentorder_filter.html b/src/staff/templates/staff/equipmentorder_filter.html index 434f0c13..07928ac6 100644 --- a/src/staff/templates/staff/equipmentorder_filter.html +++ b/src/staff/templates/staff/equipmentorder_filter.html @@ -10,8 +10,8 @@
{{ filter.form | crispy }} +
-
{% render_table table %} diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index cf21b4c0..77317877 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -1,5 +1,7 @@ {% extends "staff/base.html" %} {% load i18n %} +{% load order_tags %} +{% load static %} {% block content %} @@ -7,13 +9,13 @@

Order {{ object }}

- back - Samples + Back + Samples {% if analysis_orders|length > 1 %}
{% elif analysis_orders|length == 1 %} - Go to {{ analysis_orders.first}} + Go to {{ analysis_orders.first}} {% endif %} - Assign staff
- {% if not object.is_seen %} + {% if object.genrequest.responsible_staff.all|is_responsible:request.user and not object.is_seen %}
{% csrf_token %} - +
{% endif %} - {% if object.status == object.OrderStatus.DELIVERED %} - {% url 'staff:order-manually-checked' pk=object.id as confirm_check_url %} - {% action-button action=confirm_check_url class="bg-secondary text-white" submit_text="Confirm - Order checked" csrf_token=csrf_token %} - {% endif %} - {% if object.status != object.OrderStatus.DRAFT %} {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} - {% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} + {% action-button action=to_draft_url class="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} {% endif %} - {% if object.next_status %} + {% if object.status == object.OrderStatus.DELIVERED and object.internal_status == "checked" %} {% 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 %} + {% with " Set as "|add:object.next_status as btn_name %} + {% action-button action=to_next_status_url class="bg-yellow-200 text-yellow-800 border border-yellow-700 hover:bg-yellow-300" submit_text=btn_name csrf_token=csrf_token %} {% endwith %} {% endif %}
+
+ Assign staff +
{% fragment as table_header %} {% #table-cell header=True %}GUID{% /table-cell %} diff --git a/src/staff/templates/staff/extractionorder_filter.html b/src/staff/templates/staff/extractionorder_filter.html index e2c1e8d9..c85fc272 100644 --- a/src/staff/templates/staff/extractionorder_filter.html +++ b/src/staff/templates/staff/extractionorder_filter.html @@ -10,8 +10,8 @@
{{ filter.form | crispy }} +
-
{% render_table table %} diff --git a/src/staff/templates/staff/extractionplate_detail.html b/src/staff/templates/staff/extractionplate_detail.html index 07c5904c..e688e864 100644 --- a/src/staff/templates/staff/extractionplate_detail.html +++ b/src/staff/templates/staff/extractionplate_detail.html @@ -10,6 +10,6 @@

Plate {{ object }}

{% object-detail object=object %}
- back + Back
{% endblock %} diff --git a/src/staff/templates/staff/extractionplate_filter.html b/src/staff/templates/staff/extractionplate_filter.html index 26e046dd..4463ae68 100644 --- a/src/staff/templates/staff/extractionplate_filter.html +++ b/src/staff/templates/staff/extractionplate_filter.html @@ -8,14 +8,14 @@ {% block page-inner %}
- Create + Create
{{ filter.form | crispy }}
- +
{% render_table table %} diff --git a/src/staff/templates/staff/extractionplate_form.html b/src/staff/templates/staff/extractionplate_form.html index 83ce49f6..8274f222 100644 --- a/src/staff/templates/staff/extractionplate_form.html +++ b/src/staff/templates/staff/extractionplate_form.html @@ -5,15 +5,15 @@

{% if object.id %}{{ object }}{% else %}Create {{ view.model|verbose_name }}{% endif %}

{% if object.id %} - back + Back {% else %} - back + Back {% endif %}
{{ form|crispy }} {% csrf_token %} -
diff --git a/src/staff/templates/staff/order_staff_edit.html b/src/staff/templates/staff/order_staff_edit.html index efeab57b..9ea3ef7d 100644 --- a/src/staff/templates/staff/order_staff_edit.html +++ b/src/staff/templates/staff/order_staff_edit.html @@ -17,9 +17,9 @@

Manage Responsible Staff - {{ object }}

{% if model_type == "genrequest" %} - Back to Genrequest + Back to Genrequest {% else %} - Back to Order + Back to Order {% endif %}
@@ -43,7 +43,7 @@

Assign Staff to Order

- +
diff --git a/src/staff/templates/staff/project_detail.html b/src/staff/templates/staff/project_detail.html index 1a6b19e3..4aa8eb2a 100644 --- a/src/staff/templates/staff/project_detail.html +++ b/src/staff/templates/staff/project_detail.html @@ -7,7 +7,7 @@

Project {{ object }}

- back + Back {% url 'staff:projects-verify' pk=object.pk as verify_url %} {% if object.verified_at is null %} {% action-button action=verify_url class="btn-secondary text-white" submit_text="Mark as verified" csrf_token=csrf_token %} diff --git a/src/staff/templates/staff/project_filter.html b/src/staff/templates/staff/project_filter.html index b0434b4c..eb62d2fe 100644 --- a/src/staff/templates/staff/project_filter.html +++ b/src/staff/templates/staff/project_filter.html @@ -10,8 +10,8 @@
{{ filter.form | crispy }} +
-
{% render_table table %} diff --git a/src/staff/templates/staff/sample_detail.html b/src/staff/templates/staff/sample_detail.html index 6b6c7c77..477ba47d 100644 --- a/src/staff/templates/staff/sample_detail.html +++ b/src/staff/templates/staff/sample_detail.html @@ -10,6 +10,6 @@

Sample {{ object }}

{% object-detail object=object %}
- back + Back
{% endblock %} diff --git a/src/staff/templates/staff/sample_filter.html b/src/staff/templates/staff/sample_filter.html index 430cc1b9..4d085073 100644 --- a/src/staff/templates/staff/sample_filter.html +++ b/src/staff/templates/staff/sample_filter.html @@ -11,32 +11,47 @@ Samples {% endif %}
- {% if order %} -
- {{ order.filled_genlab_count }} / {{ order.samples.count }} Genlabs generated -
- {% endif %} {% endblock page-title %} {% block page-inner %} {% if order %} -
- back - Download CSV - Lab -
+
+ Back + Lab + {% if order.status == order.OrderStatus.DELIVERED and order.internal_status == "checked" %} + {% url 'staff:order-to-next-status' pk=order.pk as to_next_status_url %} + {% action-button action=to_next_status_url class="bg-secondary text-white" submit_text="Set as processing" csrf_token=csrf_token %} + {% endif %} +
-
- {% csrf_token %} - - + +
+ {{ filter.form | crispy }} + +
+
+ +
+ {% csrf_token %} + +
+ + Download CSV -
This page is under development. The genlab IDs will generate for all, and without sorting as per now.
+
+
+ {{ order.filled_genlab_count }} / {{ order.samples.count }} samples with genlabID +
+
+
+
+
+
- {% render_table table %} + {% render_table table %}
{% endcomment %} +
{% csrf_token %} {% for status in statuses %} - {% endfor %} @@ -18,7 +27,7 @@

{% block page-title %}{% if order %}{{ order }} - Samp - {% render_table table %} diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index 77f63f48..d340b3c4 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -1,6 +1,7 @@ from django import template from django.db import models +from capps.users.models import User from genlab_bestilling.models import Area, Order from ..tables import ( @@ -14,14 +15,33 @@ register = template.Library() +@register.filter +def is_responsible(staff_queryset: models.QuerySet, user: User) -> bool: + return staff_queryset.filter(id=user.id).exists() + + @register.inclusion_tag("staff/components/order_table.html", takes_context=True) def urgent_orders_table(context: dict, area: Area | None = None) -> dict: urgent_orders = ( Order.objects.filter( is_urgent=True, ) - .exclude(status=Order.OrderStatus.DRAFT) + .exclude(status__in=[Order.OrderStatus.DRAFT, Order.OrderStatus.COMPLETED]) .select_related("genrequest") + .annotate( + priority=models.Case( + models.When(is_urgent=True, then=Order.OrderPriority.URGENT), + models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), + default=1, + ), + delivery_date=models.Case( + models.When( + analysisorder__isnull=False, + then="analysisorder__expected_delivery_date", + ), + default=models.Value(None, output_field=models.DateField()), + ), + ) ) if area: @@ -49,7 +69,11 @@ def urgent_orders_table(context: dict, area: Area | None = None) -> dict: @register.inclusion_tag("staff/components/order_table.html", takes_context=True) def new_seen_orders_table(context: dict, area: Area | None = None) -> dict: new_orders = ( - Order.objects.filter(status=Order.OrderStatus.DELIVERED, is_seen=True) + Order.objects.filter( + status__in=[Order.OrderStatus.DELIVERED, Order.OrderStatus.PROCESSING], + is_seen=True, + responsible_staff__isnull=True, + ) .exclude(is_urgent=True) .select_related("genrequest") .annotate( @@ -69,6 +93,13 @@ def new_seen_orders_table(context: dict, area: Area | None = None) -> dict: models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), default=1, ), + delivery_date=models.Case( + models.When( + analysisorder__isnull=False, + then="analysisorder__expected_delivery_date", + ), + default=models.Value(None, output_field=models.DateField()), + ), ) ) @@ -78,7 +109,7 @@ def new_seen_orders_table(context: dict, area: Area | None = None) -> dict: new_orders = new_orders.order_by("-priority", "-created_at") return { - "title": "New seen orders", + "title": "Unassigned orders", "table": NewSeenOrderTable(new_orders), "count": new_orders.count(), "request": context.get("request"), @@ -102,7 +133,14 @@ def new_unseen_orders_table(context: dict, area: Area | None = None) -> dict: then=models.Count("analysisorder__samples", distinct=True), ), default=0, - ) + ), + delivery_date=models.Case( + models.When( + analysisorder__isnull=False, + then="analysisorder__expected_delivery_date", + ), + default=models.Value(None, output_field=models.DateField()), + ), ) ) @@ -112,7 +150,7 @@ def new_unseen_orders_table(context: dict, area: Area | None = None) -> dict: new_orders = new_orders.order_by("-created_at") return { - "title": "New unseen orders", + "title": "New orders", "table": NewUnseenOrderTable(new_orders), "count": new_orders.count(), "request": context.get("request"), @@ -128,13 +166,29 @@ def assigned_orders_table(context: dict) -> dict: status__in=[ Order.OrderStatus.PROCESSING, Order.OrderStatus.DELIVERED, - Order.OrderStatus.COMPLETED, ], responsible_staff=user, + is_seen=True, ) .select_related("genrequest") .annotate( - sample_count=models.Count("extractionorder__samples", distinct=True), + isolated_sample_count=models.Case( + models.When( + extractionorder__isnull=False, + then=models.Count( + "extractionorder__samples", + filter=models.Q(extractionorder__samples__is_isolated=True), + distinct=True, + ), + ) + ), + sample_count=models.Case( + models.When( + extractionorder__isnull=False, + then=models.Count("extractionorder__samples", distinct=True), + ), + default=0, + ), priority=models.Case( models.When(is_urgent=True, then=Order.OrderPriority.URGENT), models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), @@ -173,6 +227,13 @@ def draft_orders_table(context: dict, area: Area) -> dict: models.When(is_urgent=True, then=Order.OrderPriority.URGENT), default=1, ), + delivery_date=models.Case( + models.When( + analysisorder__isnull=False, + then="analysisorder__expected_delivery_date", + ), + default=models.Value(None, output_field=models.DateField()), + ), ) .order_by("-priority", "-created_at") ) diff --git a/src/staff/templatetags/table_header_tag.py b/src/staff/templatetags/table_header_tag.py new file mode 100644 index 00000000..6dfd3b08 --- /dev/null +++ b/src/staff/templatetags/table_header_tag.py @@ -0,0 +1,80 @@ +from django import template +from django.http import HttpRequest + +register = template.Library() + + +@register.inclusion_tag("django_tables2/header.html", takes_context=True) +def render_header(context: dict) -> dict: + url = Url(context["table"].prefixed_order_by_field, context["request"]) + for column in context["table"].columns: + sort_url, remove_sort_url, first_sort, descending = url.get_sort_url( + column.name + ) + column.ext = { + "sort_url": sort_url, + "remove_sort_url": remove_sort_url, + "first_sort": first_sort, + "descending": descending, + "next": remove_sort_url if not first_sort and descending else sort_url, + } + + return context + + +class Url: + """ + Based on code from: + https://github.com/TheRealVizard/django-table-sort/blob/main/django_table_sort/table.py + """ + + def __init__(self, sort_key_name: str, request: HttpRequest): + self.sort_key_name = sort_key_name + self.request = request + + def contains_field(self, lookups: list, field: str) -> int: + """Check if the field is in the sort lookups.""" + try: + return lookups.index(field) + except ValueError: + return -1 + + def get_sort_url(self, field: str) -> tuple[str, str, bool, bool]: + """Generate the urls to sort the table for the given field.""" + lookups = self.request.GET.copy() + removed_lookup = self.request.GET.copy() + + first_sort = True + descending = True + + if self.sort_key_name in lookups.keys(): + current_order = lookups.getlist(self.sort_key_name, []) + removed_order = current_order.copy() + position = self.contains_field(current_order, field) + if position != -1: + first_sort = False + descending = False + current_order[position] = f"-{field}" + removed_order.remove(field) + else: + position = self.contains_field(current_order, f"-{field}") + if position != -1: + first_sort = False + current_order[position] = field + removed_order.remove(f"-{field}") + else: + current_order.append(field) + lookups.setlist(self.sort_key_name, current_order) + if len(removed_order) >= 1: + removed_lookup.setlist(self.sort_key_name, removed_order) + else: + removed_lookup.pop(self.sort_key_name) + else: + lookups.setlist(self.sort_key_name, [field]) + + return ( + lookups.urlencode(), + removed_lookup.urlencode(), + first_sort, + descending, + ) diff --git a/src/staff/urls.py b/src/staff/urls.py index b43c512e..f977135b 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -12,7 +12,6 @@ ExtractionPlateDetailView, ExtractionPlateListView, GenerateGenlabIDsView, - ManaullyCheckedOrderActionView, MarkAsSeenView, OrderAnalysisSamplesListView, OrderExtractionSamplesListView, @@ -69,11 +68,6 @@ OrderToNextStatusActionView.as_view(), name="order-to-next-status", ), - path( - "orders//manually-checked/", - ManaullyCheckedOrderActionView.as_view(), - name="order-manually-checked", - ), path( "//add-staff/", StaffEditView.as_view(), diff --git a/src/staff/views.py b/src/staff/views.py index 7b44831f..f19b9673 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -1,10 +1,10 @@ -from collections import defaultdict from typing import Any from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db import models -from django.db.models import Count +from django.db.models import Case, Count, IntegerField, Value, When +from django.db.models.functions import Cast from django.forms import Form from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404 @@ -28,13 +28,13 @@ Sample, SampleIsolationMethod, SampleMarkerAnalysis, - SampleStatusAssignment, ) from nina.models import Project from shared.views import ActionView from .filters import ( AnalysisOrderFilter, + ExtractionOrderFilter, ExtractionPlateFilter, OrderSampleFilter, SampleFilter, @@ -114,7 +114,7 @@ def get_queryset(self) -> models.QuerySet[AnalysisOrder]: class ExtractionOrderListView(StaffMixin, SingleTableMixin, FilterView): model = ExtractionOrder table_class = ExtractionOrderTable - filterset_class = AnalysisOrderFilter + filterset_class = ExtractionOrderFilter def get_queryset(self) -> models.QuerySet[ExtractionOrder]: return ( @@ -190,6 +190,17 @@ def get_object(self) -> Order: def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: try: order = self.get_object() + + if not order.genrequest.responsible_staff.filter( + id=request.user.id + ).exists(): + messages.error( + request, _("You are not authorized to mark this order as seen.") + ) + return HttpResponseRedirect( + self.get_return_url(request.POST.get("return_to")) + ) + order.toggle_seen() messages.success(request, _("Order is marked as seen")) except Exception as e: @@ -223,18 +234,37 @@ class OrderExtractionSamplesListView(StaffMixin, SingleTableMixin, FilterView): filterset_class = OrderSampleFilter def get_queryset(self) -> models.QuerySet[Sample]: - return ( + queryset = ( super() .get_queryset() .select_related("type", "location", "species") .prefetch_related("plate_positions") .filter(order=self.kwargs["pk"]) - .order_by("species__name", "year", "location__name", "name") + ) + + # added to sort based on type (int/str) + return queryset.annotate( + name_as_int=Case( + When( + name__regex=r"^\d+$", + then=Cast("name", IntegerField()), + ), + default=Value(None), + output_field=IntegerField(), + ) ) def get_context_data(self, **kwargs) -> dict[str, Any]: context = super().get_context_data(**kwargs) - context["order"] = ExtractionOrder.objects.get(pk=self.kwargs.get("pk")) + order = ExtractionOrder.objects.get(pk=self.kwargs.get("pk")) + context["order"] = order + total_samples = order.samples.count() + filled_count = order.filled_genlab_count + context["progress_percent"] = ( + (float(filled_count) / float(total_samples)) * 100 + if total_samples > 0 + else 0 + ) return context def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: @@ -307,7 +337,12 @@ class SampleDetailView(StaffMixin, DetailView): model = Sample -class SampleLabView(StaffMixin, TemplateView): +class SampleLabView(StaffMixin, SingleTableMixin, TemplateView): + MARKED = "marked" + PLUCKED = "plucked" + ISOLATED = "isolated" + VALID_STATUSES = [MARKED, PLUCKED, ISOLATED] + disable_pagination = False template_name = "staff/sample_lab.html" table_class = SampleStatusTable @@ -317,34 +352,16 @@ def get_order(self) -> ExtractionOrder: self._order = get_object_or_404(ExtractionOrder, pk=self.kwargs["pk"]) return self._order - def get_data(self) -> list[Sample]: + def get_table_data(self) -> list[Sample]: order = self.get_order() samples = Sample.objects.filter(order=order, genlab_id__isnull=False) - sample_status = SampleStatusAssignment.SampleStatus.choices - - # Fetch all SampleStatusAssignment entries related to the current order - sample_assignments = SampleStatusAssignment.objects.filter(order_id=order.id) - - # Build a lookup: {sample_id: set of status names} - # This allows us to check status presence without querying per sample - sample_status_map = defaultdict(set) - for assignment in sample_assignments: - name = str(assignment.status) - sample_status_map[assignment.sample_id].add(name) - - # Annotate each sample instance with boolean flags per status - # Equivalent to: sample.status_name = True/False - # based on whether the sample has that status for sample in samples: sample.selected_isolation_method = ( sample.isolation_method.first() if sample.isolation_method.exists() else None ) - status_names = sample_status_map.get(sample.id, set()) - for status, _i in sample_status: - setattr(sample, status, status in status_names) return samples @@ -353,19 +370,20 @@ 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]: - return [v for v, _ in SampleStatusAssignment.SampleStatus.choices] + return self.VALID_STATUSES def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["order"] = self.get_order() context["statuses"] = self.get_base_fields() context["isolation_methods"] = self.get_isolation_methods() - context["table"] = self.table_class(data=self.get_data()) return context @@ -376,7 +394,7 @@ def get_success_url(self) -> str: def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: status_name = request.POST.get("status") - selected_ids = request.POST.getlist("checked") + selected_ids = request.POST.getlist(f"checked-{self.get_order().pk}") isolation_method = request.POST.get("isolation_method") if not selected_ids: @@ -389,52 +407,63 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: samples = Sample.objects.filter(id__in=selected_ids) if status_name: - self.assign_status_to_samples(samples, status_name, order, request) + self.assign_status_to_samples(samples, status_name, request) + if status_name == self.ISOLATED: + # Cannot use "samples" here + # because we need to check all samples in the order + self.check_all_isolated(Sample.objects.filter(order=order)) if isolation_method: self.update_isolation_methods(samples, isolation_method, request) return HttpResponseRedirect(self.get_success_url()) + def statuses_with_lower_or_equal_priority(self, status_name: str) -> list[str]: + index = self.VALID_STATUSES.index(status_name) + return self.VALID_STATUSES[: index + 1] + def assign_status_to_samples( self, samples: models.QuerySet, status_name: str, - order: ExtractionOrder, request: HttpRequest, ) -> None: - statuses = SampleStatusAssignment.SampleStatus.choices - - # Check if the provided status exists - if status_name not in [k for k, _ in statuses]: + if status_name not in self.VALID_STATUSES: messages.error(request, f"Status '{status_name}' is not valid.") - return HttpResponseRedirect(self.get_success_url()) + return - # Get the index of the target status - status_weight = next( - i for i, (name, _) in enumerate(statuses) if name == status_name + statuses_to_turn_on = self.statuses_with_lower_or_equal_priority(status_name) + field_name = f"is_{status_name}" + + samples_to_turn_off_ids = list( + samples.filter(**{field_name: True}).values_list("id", flat=True) + ) + samples_to_turn_on_ids = list( + samples.filter(**{field_name: False}).values_list("id", flat=True) ) - # Slice the list up to that index (inclusive) and extract only the names - statuses_to_apply = [name for name, _ in statuses[: status_weight + 1]] + Sample.objects.filter(id__in=samples_to_turn_off_ids).update( + **{field_name: False} + ) - # Apply status assignments - assignments = [] - for sample in samples: - for status in statuses_to_apply: - assignment = SampleStatusAssignment( - sample=sample, - status=status, - order=order, - ) - assignments.append(assignment) + update_dict = {f"is_{status}": True for status in statuses_to_turn_on} + Sample.objects.filter(id__in=samples_to_turn_on_ids).update(**update_dict) - SampleStatusAssignment.objects.bulk_create( - assignments, - ignore_conflicts=True, - ) + messages.success(request, "Samples updated successfully") - messages.success( - request, f"{samples.count()} samples updated with status '{status_name}'." - ) + # Checks if all samples in the order are isolated + # If they are, it updates the order status to completed + def check_all_isolated(self, samples: models.QuerySet) -> None: + if not samples.filter(is_isolated=False).exists(): + self.get_order().to_next_status() + messages.success( + self.request, + "All samples are isolated. The order status is updated to completed.", + ) + elif self.get_order().status == Order.OrderStatus.COMPLETED: + self.get_order().to_processing() + messages.success( + self.request, + "Not all samples are isolated. The order status is updated to processing.", # noqa: E501 + ) def update_isolation_methods( self, samples: models.QuerySet, isolation_method: str, request: HttpRequest @@ -444,7 +473,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, @@ -485,40 +516,6 @@ def post(self, request: HttpRequest, *args, **kwargs) -> JsonResponse: return JsonResponse({"error": "Sample not found"}, status=404) -class ManaullyCheckedOrderActionView(SingleObjectMixin, ActionView): - model = ExtractionOrder - - def get_queryset(self) -> models.QuerySet[ExtractionOrder]: - return ExtractionOrder.objects.filter(status=Order.OrderStatus.DELIVERED) - - def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - self.object = self.get_object() - return super().post(request, *args, **kwargs) - - def form_valid(self, form: Form) -> HttpResponse: - try: - # TODO: check state transition - self.object.order_manually_checked() - messages.add_message( - self.request, - messages.SUCCESS, - _("The order was checked, GenLab IDs will be generated"), - ) - except Exception as e: - messages.error(self.request, f"Error: {str(e)}") - - return super().form_valid(form) - - def get_success_url(self) -> str: - return reverse_lazy( - f"staff:order-{self.object.get_type()}-detail", - kwargs={"pk": self.object.id}, - ) - - def form_invalid(self, form: Form) -> HttpResponse: - return HttpResponseRedirect(self.get_success_url()) - - class StaffEditView(StaffMixin, SingleObjectMixin, TemplateView): form_class = OrderStaffForm template_name = "staff/order_staff_edit.html" @@ -527,7 +524,7 @@ def get_queryset(self) -> models.QuerySet[Order] | models.QuerySet[Genrequest]: model_type = self._get_model_type() if model_type == "genrequest": return Genrequest.objects.all() - return Order.objects.filter(status=Order.OrderStatus.DELIVERED) + return Order.objects.all() def _get_model_type(self) -> str: """Returns model type based on request data.""" @@ -640,7 +637,10 @@ def form_valid(self, form: Form) -> HttpResponse: return super().form_valid(form) def get_success_url(self) -> str: - return reverse_lazy(f"staff:order-{self.object.get_type()}-list") + return reverse_lazy( + f"staff:order-{self.object.get_type()}-detail", + kwargs={"pk": self.object.pk}, + ) def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) @@ -656,28 +656,20 @@ def get_object(self) -> ExtractionOrder: def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.object = self.get_object() + + # getlist automatically reads the values in the order presented in the table selected_ids = request.POST.getlist("checked") if not selected_ids: messages.error(request, "No samples were selected.") return HttpResponseRedirect(self.get_return_url()) - sort_param = request.POST.get("sort", "") - sorting_order = [s.strip() for s in sort_param.split(",") if s.strip()] - - selected_samples = Sample.objects.filter(pk__in=selected_ids) - - if sorting_order: - selected_samples = selected_samples.order_by(*sorting_order) - try: - self.object.order_selected_checked( - sorting_order=sorting_order, selected_samples=selected_samples - ) + self.object.order_selected_checked(selected_samples=selected_ids) messages.add_message( request, messages.SUCCESS, - _(f"Genlab IDs generated for {selected_samples.count()} samples."), + _(f"Genlab IDs generated for {len(selected_ids)} samples."), ) except Exception as e: messages.add_message( diff --git a/src/templates/components/action-button.html b/src/templates/components/action-button.html index 664403c3..11c2e216 100644 --- a/src/templates/components/action-button.html +++ b/src/templates/components/action-button.html @@ -6,5 +6,5 @@
{% csrf_token %} {% if form %}{{ form }}{% endif %} - +
diff --git a/src/templates/components/formset.html b/src/templates/components/formset.html index 8578faf1..e9576d8e 100644 --- a/src/templates/components/formset.html +++ b/src/templates/components/formset.html @@ -7,8 +7,8 @@ {% if form_collection %}{{ form_collection }}{% endif %} {% if form %}{{ form }}{% endif %} - - + + diff --git a/src/templates/django_tables2/header.html b/src/templates/django_tables2/header.html new file mode 100644 index 00000000..366c8883 --- /dev/null +++ b/src/templates/django_tables2/header.html @@ -0,0 +1,25 @@ + + + {% for column in table.columns %} + + {% if column.orderable %} + + {{ column.header }} + {% if column.is_ordered %} + {% if "-" in column.order_by_alias %} + + {% else %} + + {% endif %} + {% else %} + + {% endif %} + + {% else %} + {{ column.header }} + {% endif %} + + {% endfor %} + + diff --git a/src/templates/django_tables2/tailwind_inner.html b/src/templates/django_tables2/tailwind_inner.html index 24485f46..37ab8436 100644 --- a/src/templates/django_tables2/tailwind_inner.html +++ b/src/templates/django_tables2/tailwind_inner.html @@ -1,53 +1,33 @@ {% load django_tables2 %} +{% load table_header_tag %} {% load i18n %} {% block table %} {% block table.thead %} - {% if table.show_header %} - - - {% for column in table.columns %} - - {% endfor %} - - - {% endif %} + {% if table.show_header %} + {% render_header %} + {% endif %} {% endblock table.thead %} - - {% block table.tbody %} {% for row in table.paginated_rows %} {% block table.tbody.row %} - + - {% for column, cell in row.items %} - - {% endfor %} - + {% if column.localize %} + {{ cell|localize }} + {% else %} + {{ cell|unlocalize }} + {% endif %} + {% endif %} + {% endfor %} + {% endblock table.tbody.row %} {% empty %} {% if table.empty_text %} diff --git a/src/templates/users/user_detail.html b/src/templates/users/user_detail.html index 098dab6c..4e4a2e27 100644 --- a/src/templates/users/user_detail.html +++ b/src/templates/users/user_detail.html @@ -17,8 +17,8 @@

{{ object }}

- My Info - My Info + E-Mail diff --git a/src/templates/users/user_form.html b/src/templates/users/user_form.html index a932af99..fd63f9a8 100644 --- a/src/templates/users/user_form.html +++ b/src/templates/users/user_form.html @@ -15,7 +15,7 @@

{{ user }}

{{ form|crispy }}
- +
diff --git a/src/theme/static_src/src/styles.css b/src/theme/static_src/src/styles.css index 702e889d..3caab71c 100644 --- a/src/theme/static_src/src/styles.css +++ b/src/theme/static_src/src/styles.css @@ -231,6 +231,70 @@ ol.breadcrumb > li + ::before { background-color: transparent; } +.select2-container { + width: 100% !important; +} + .select2-container .select2-selection--single { - height: 3.2rem !important; + height: 2.6rem !important; + position: relative; /* Needed for absolute positioning inside */ + padding-right: 2.5rem; + display: flex; + align-items: center; + border-color: rgb(209 213 219); + opacity: 80%; +} + +.select2-container .select2-selection__arrow { + position: absolute; + top: 50%; + right: 0.75rem; + transform: translateY(20%); + width: 1rem; + height: 1rem; + pointer-events: none; +} + +.custom_order_button { + background-color: #F2F2F2; /* Fill */ + border: 1px solid #BABABA; /* Stroke */ + color: #4A4A4A; /* Text & icon color */ +} + +.custom_order_button:hover { + background-color: #e0e0e0; /* Optional: darker on hover */ + border: 1px solid #A0A0A0; /* Stroke */ +} + +.custom_order_button_blue { + background-color: #E6F0FA; /* Fill */ + border: 1px solid #8CA5BE; /* Stroke */ + color: #0F3D6A; /* Text & icon color */ +} + +.custom_order_button_blue:hover { + background-color: #D2E4F5; /* Optional: darker on hover */ + border: 1px solid #7792AB; /* Stroke */ +} + +.custom_order_button_green { + background-color: #DFF7CA; /* Fill */ + border: 1px solid #93AD7D; /* Stroke */ + color: #001D3A; /* Text & icon color */ +} + +.custom_order_button_green:hover { + background-color: #C9EBB0; /* Optional: darker on hover */ + border: 1px solid #7F996C; /* Stroke */ +} + +.custom_order_button_red { + background-color: #f7caca; /* Fill */ + border: 1px solid #ad7d7d; /* Stroke */ + color: #3a0000; /* Text & icon color */ +} + +.custom_order_button_red:hover { + background-color: #ebb0b0; /* Optional: darker on hover */ + border: 1px solid #996c6c; /* Stroke */ }
- {% if column.orderable %} - {% comment%} - If the column is orderable, two small arrows will show next to the column name to signal that it can be sorted. - {% endcomment%} - - {{ column.header }} - - - {% else %} - {{ column.header }} - {% endif %} -
- {% if column.localize == None %} - {{ cell }} - {% else %} - {% if column.localize %} - {{ cell|localize }} + {% for column, cell in row.items %} + + {% if column.localize == None %} + {{ cell }} {% else %} - {{ cell|unlocalize }} - {% endif %} - {% endif %}