diff --git a/src/capps/core/templates/core/partials/navigation.html b/src/capps/core/templates/core/partials/navigation.html index 70f8f3ce..b60c8a9a 100755 --- a/src/capps/core/templates/core/partials/navigation.html +++ b/src/capps/core/templates/core/partials/navigation.html @@ -1,7 +1,7 @@ {% load i18n %}
tuple: return None, None -IGNORED_FIELDS = ["tagged_items"] +IGNORED_FIELDS = [ + "tagged_items", + "is_seen", + "is_prioritized", + "responsible_staff", +] +IGNORED_FIELDS_STAFF = ["tagged_items"] @register.filter @@ -45,3 +51,17 @@ def get_fields(instance: Model, fields: str | None = None) -> Any: and field.name not in IGNORED_FIELDS ), ) + + +@register.filter +def get_fields_staff(instance: Model, fields: str | None = None) -> Any: + return filter( + lambda x: x[0], + ( + render(field, instance) + for field in instance._meta.get_fields() + if (not fields or field.name in fields.split(" ")) + and not isinstance(field, TaggableManager) + and field.name not in IGNORED_FIELDS_STAFF + ), + ) diff --git a/src/capps/users/forms.py b/src/capps/users/forms.py index 43daad0b..75f21e86 100644 --- a/src/capps/users/forms.py +++ b/src/capps/users/forms.py @@ -20,7 +20,7 @@ class UserAdminCreationForm(admin_forms.UserCreationForm): class Meta(admin_forms.UserCreationForm.Meta): # type: ignore[name-defined] model = User - fields = ["email"] + fields = ("email",) field_classes = {"email": EmailField} error_messages = { "email": {"unique": _("This email has already been taken.")}, diff --git a/src/config/autocomplete.py b/src/config/autocomplete.py index 30d2f15e..3850e0bc 100644 --- a/src/config/autocomplete.py +++ b/src/config/autocomplete.py @@ -2,6 +2,7 @@ from capps.users.autocomplete import StaffUserAutocomplete, UserAutocomplete from genlab_bestilling.autocomplete import ( + AnalysisMarkerAutocomplete, AnalysisOrderAutocomplete, AreaAutocomplete, EquipmentAutocomplete, @@ -42,4 +43,9 @@ IsolationMethodAutocomplete.as_view(), name="isolation-method", ), + path( + "analysis-marker/", + AnalysisMarkerAutocomplete.as_view(), + name="analysis-marker", + ), ] diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index d6700401..413984a4 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.db.models import QuerySet +from django.http import HttpRequest from unfold.admin import ModelAdmin from unfold.contrib.filters import admin as unfold_filters @@ -540,4 +542,27 @@ class AnalysisResultAdmin(ModelAdmin): @admin.register(IsolationMethod) -class IsolationMethodAdmin(ModelAdmin): ... +class IsolationMethodAdmin(ModelAdmin): + M = IsolationMethod + + list_display = [ + M.name.field.name, + "get_sample_types", + ] + + search_help_text = "Search for isolation method name" + search_fields = [M.name.field.name] + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.sample_types.field.name, unfold_filters.AutocompleteSelectMultipleFilter), + ] + autocomplete_fields = [M.sample_types.field.name] + filter_horizontal = [M.sample_types.field.name] + list_filter_submit = True + list_filter_sheet = False + + def get_queryset(self, request: HttpRequest) -> QuerySet[IsolationMethod]: + return super().get_queryset(request).prefetch_related("sample_types") + + def get_sample_types(self, obj: IsolationMethod) -> str: + return ", ".join([sample_type.name for sample_type in obj.sample_types.all()]) diff --git a/src/genlab_bestilling/api/constants.py b/src/genlab_bestilling/api/constants.py index a8189a3a..9fba5360 100644 --- a/src/genlab_bestilling/api/constants.py +++ b/src/genlab_bestilling/api/constants.py @@ -101,6 +101,8 @@ "genlab_id", "fish_id", "guid", + "order", + "analysis_orders", "river", "location.name", "under_locality", @@ -126,9 +128,9 @@ "rerun_date", ), "Terrestrisk": ( - "name", "genlab_id", "guid", + "name", "type.name", "species.name", "location.name", @@ -197,9 +199,9 @@ } LABEL_CSV_FIELDS_BY_AREA = { - "Akvatisk": ("fish_id", "genlab_id", "guid"), - "Elvemusling": ("fish_id", "genlab_id", "guid"), - "Terrestrisk": ("genlab_id", "guid", "name"), - "MiljøDNA": ("genlab_id", "guid", "name", "location.name"), - "default": ("genlab_id", "guid", "name"), + "Akvatisk": ("fish_id", "genlab_id", "guid", "order"), + "Elvemusling": ("fish_id", "genlab_id", "guid", "order"), + "Terrestrisk": ("genlab_id", "guid", "name", "order"), + "MiljøDNA": ("genlab_id", "guid", "name", "location.name", "order"), + "default": ("genlab_id", "guid", "name", "order"), } diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py index 01de72ea..855de4e0 100644 --- a/src/genlab_bestilling/api/serializers.py +++ b/src/genlab_bestilling/api/serializers.py @@ -31,19 +31,19 @@ class KoncivSerializer(EnumSerializer): class MarkerSerializer(serializers.ModelSerializer): class Meta: model = Marker - fields = ["name"] + fields = ("name",) class SampleTypeSerializer(serializers.ModelSerializer): class Meta: model = SampleType - fields = ["id", "name"] + fields = ("id", "name") class SpeciesSerializer(serializers.ModelSerializer): class Meta: model = Species - fields = ["id", "name"] + fields = ("id", "name") class LocationSerializer(serializers.ModelSerializer): @@ -51,7 +51,7 @@ class LocationSerializer(serializers.ModelSerializer): class Meta: model = Location - fields = ["id", "name"] + fields = ("id", "name") def get_name(self, obj: Location) -> str: return str(obj) @@ -60,7 +60,7 @@ def get_name(self, obj: Location) -> str: class LocationCreateSerializer(serializers.ModelSerializer): class Meta: model = Location - fields = ["id", "name"] + fields = ("id", "name") class SampleSerializer(serializers.ModelSerializer): @@ -77,7 +77,7 @@ def get_has_error(self, obj: Sample) -> bool: class Meta: model = Sample - fields = [ + fields = ( "id", "order", "guid", @@ -91,7 +91,12 @@ class Meta: "type", "has_error", "genlab_id", - ] + ) + + +class FlagField(serializers.Field): + def to_representation(self, value: bool) -> str: + return "x" if value else "" class SampleCSVSerializer(serializers.ModelSerializer): @@ -102,14 +107,14 @@ class SampleCSVSerializer(serializers.ModelSerializer): analysis_orders = serializers.SerializerMethodField() project = serializers.SerializerMethodField() isolation_method = serializers.SerializerMethodField() - is_marked = serializers.SerializerMethodField() - is_plucked = serializers.SerializerMethodField() - is_isolated = serializers.SerializerMethodField() + is_marked = FlagField() + is_plucked = FlagField() + is_isolated = FlagField() internal_note = serializers.SerializerMethodField() class Meta: model = Sample - fields = [ + fields = ( "order", "guid", "name", @@ -128,14 +133,21 @@ class Meta: "is_plucked", "is_isolated", "internal_note", - ] + ) 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()] + if not obj.order: + return [] + + analysis_orders = obj.order.analysis_orders.all() + # Return all analysis order IDs as strings + # only if there is exactly one analysis order, else return empty list. + # This is to ensure no duplicate rows in staffs common sheet + if analysis_orders.count() == 1: + return [str(analysis_orders.first().id)] return [] def get_project(self, obj: Sample) -> str: @@ -147,18 +159,6 @@ 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_is_marked(self, obj: Sample) -> str: - return self._flag(obj.is_marked) - - def get_is_plucked(self, obj: Sample) -> str: - return self._flag(obj.is_plucked) - - def get_is_isolated(self, obj: Sample) -> str: - return self._flag(obj.is_isolated) - def get_internal_note(self, obj: Sample) -> str: if obj.internal_note: return obj.internal_note @@ -166,14 +166,18 @@ def get_internal_note(self, obj: Sample) -> str: class LabelCSVSerializer(serializers.ModelSerializer): + location = LocationSerializer(allow_null=True, required=False) + class Meta: model = Sample - fields = [ + fields = ( "genlab_id", "guid", "name", "fish_id", - ] + "order", + "location", + ) def get_fish_id(self, obj: Sample) -> str: return obj.fish_id or "-" @@ -190,7 +194,7 @@ def get_has_error(self, obj: Sample) -> bool: class Meta: model = Sample - fields = [ + fields = ( "id", "order", "guid", @@ -202,7 +206,7 @@ class Meta: "location", "type", "has_error", - ] + ) class SampleBulkSerializer(serializers.ModelSerializer): @@ -219,7 +223,7 @@ class SampleBulkSerializer(serializers.ModelSerializer): class Meta: model = Sample - fields = [ + fields = ( "order", "species", "year", @@ -229,23 +233,23 @@ class Meta: "type", "location", "quantity", - ] + ) class SampleDeleteBulkSerializer(serializers.ModelSerializer): class Meta: model = Sample - fields = ["order"] + fields = ("order",) class GenrequestSerializer(serializers.ModelSerializer): class Meta: model = Genrequest - fields = [ + fields = ( "id", "project", "area", - ] + ) class ExtractionSerializer(serializers.ModelSerializer): @@ -255,7 +259,7 @@ class ExtractionSerializer(serializers.ModelSerializer): class Meta: model = ExtractionOrder - fields = ["id", "genrequest", "species", "sample_types", "needs_guid"] + fields = ("id", "genrequest", "species", "sample_types", "needs_guid") class AnalysisSerializer(serializers.ModelSerializer): @@ -264,7 +268,7 @@ class AnalysisSerializer(serializers.ModelSerializer): class Meta: model = AnalysisOrder - fields = ["id", "genrequest", "markers"] + fields = ("id", "genrequest", "markers") class SampleMarkerAnalysisSerializer(serializers.ModelSerializer): @@ -272,7 +276,7 @@ class SampleMarkerAnalysisSerializer(serializers.ModelSerializer): class Meta: model = SampleMarkerAnalysis - fields = ["id", "order", "sample", "marker"] + fields = ("id", "order", "sample", "marker") class SampleMarkerAnalysisBulkSerializer(serializers.ModelSerializer): @@ -285,7 +289,7 @@ class SampleMarkerAnalysisBulkSerializer(serializers.ModelSerializer): class Meta: model = SampleMarkerAnalysis - fields = ["order", "samples", "markers"] + fields = ("order", "samples", "markers") class SampleMarkerAnalysisBulkDeleteSerializer(serializers.Serializer): diff --git a/src/genlab_bestilling/api/views.py b/src/genlab_bestilling/api/views.py index e6b4965a..dc428377 100644 --- a/src/genlab_bestilling/api/views.py +++ b/src/genlab_bestilling/api/views.py @@ -4,7 +4,7 @@ from typing import Any from django.db import transaction -from django.db.models import QuerySet +from django.db.models import Prefetch, QuerySet from django.http import HttpResponse from django.views import View from drf_spectacular.utils import extend_schema @@ -36,6 +36,7 @@ SpeciesFilter, ) from ..models import ( + AnalysisOrder, AnalysisType, ExtractionOrder, Location, @@ -190,6 +191,12 @@ def get_queryset(self) -> QuerySet: "order__genrequest__area", "location", ) + .prefetch_related( + Prefetch( + "order__analysis_orders", + queryset=AnalysisOrder.objects.only("id"), + ) + ) .order_by("genlab_id", "type") ) @@ -202,6 +209,13 @@ def get_serializer_class(self) -> type[BaseSerializer]: return SampleUpdateSerializer return super().get_serializer_class() + def get_order_id(self, queryset: QuerySet) -> str: + order_id = "unknown_order_id" + order_id_value = queryset.values_list("order__id", flat=True).first() + if order_id_value: + order_id = str(order_id_value) + return order_id + @action( methods=["GET"], url_path="csv", @@ -210,11 +224,14 @@ def get_serializer_class(self) -> type[BaseSerializer]: ) def csv(self, request: Request) -> HttpResponse: queryset = self.filter_queryset(self.get_queryset()) + + filename = f"Complete_sheet_EXT_{self.get_order_id(queryset)}.csv" + return self.render_csv_response( queryset, serializer_class=SampleCSVSerializer, fields_by_area=SAMPLE_CSV_FIELDS_BY_AREA, - filename="samples.csv", + filename=filename, ) @action( @@ -225,11 +242,14 @@ def csv(self, request: Request) -> HttpResponse: ) def labels_csv(self, request: Request) -> HttpResponse: queryset = self.filter_queryset(self.get_queryset()) + + filename = f"EXT_{self.get_order_id(queryset)}.csv" + return self.render_csv_response( queryset, serializer_class=LabelCSVSerializer, fields_by_area=LABEL_CSV_FIELDS_BY_AREA, - filename="sample_labels.csv", + filename=filename, ) @extend_schema( diff --git a/src/genlab_bestilling/autocomplete.py b/src/genlab_bestilling/autocomplete.py index 04514b0f..fbb9be40 100644 --- a/src/genlab_bestilling/autocomplete.py +++ b/src/genlab_bestilling/autocomplete.py @@ -72,3 +72,7 @@ class ExtractionOrderAutocomplete(autocomplete.Select2QuerySetView): class IsolationMethodAutocomplete(autocomplete.Select2QuerySetView): model = IsolationMethod + + +class AnalysisMarkerAutocomplete(autocomplete.Select2QuerySetView): + model = Marker diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index 4c61ac5d..d28798dd 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -3,8 +3,10 @@ from django import forms from django.contrib.auth.models import User from django.core.exceptions import ValidationError +from django.core.validators import validate_email from django.db import transaction from django.db.models import Model +from django.utils.html import strip_tags from formset.renderers.tailwind import FormRenderer from formset.utils import FormMixin from formset.widgets import DualSortableSelector, Selectize, TextInput @@ -14,6 +16,7 @@ from .libs.formset import ContextFormCollection from .models import ( AnalysisOrder, + AnalysisOrderResultsCommunication, EquimentOrderQuantity, EquipmentOrder, ExtractionOrder, @@ -58,7 +61,7 @@ def save(self, commit: bool = True) -> Genrequest: class Meta: model = Genrequest - fields = [ + fields = ( "project", "name", "area", @@ -69,7 +72,7 @@ class Meta: "expected_total_samples", "expected_samples_delivery_date", "expected_analysis_delivery_date", - ] + ) widgets = { "area": Selectize(search_lookup="name_icontains"), "samples_owner": Selectize(search_lookup="name_icontains"), @@ -101,7 +104,7 @@ def __init__(self, *args, **kwargs): ) class Meta(GenrequestForm.Meta): - fields = [ + fields = ( "area", "name", "species", @@ -110,7 +113,7 @@ class Meta(GenrequestForm.Meta): "expected_samples_delivery_date", "expected_analysis_delivery_date", "expected_total_samples", - ] + ) # type: ignore[assignment] class EquipmentOrderForm(FormMixin, forms.ModelForm): @@ -122,6 +125,12 @@ def __init__(self, *args, genrequest: Genrequest, **kwargs): # self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() # type: ignore[attr-defined] + self.fields[ + "contact_person" + ].help_text = "Person to contact with questions about this order" + self.fields[ + "contact_email" + ].help_text = "Email to contact with questions about this order" def save(self, commit: bool = True) -> EquipmentOrder: obj = super().save(commit=False) @@ -133,7 +142,7 @@ def save(self, commit: bool = True) -> EquipmentOrder: class Meta: model = EquipmentOrder - fields = [ + fields = ( "name", "needs_guid", # "species", @@ -143,7 +152,7 @@ class Meta: "is_urgent", "contact_person", "contact_email", - ] + ) widgets = { # "species": DualSortableSelector( # search_lookup="name_icontains", @@ -175,7 +184,7 @@ def clean(self) -> None: class Meta: model = EquimentOrderQuantity - fields = ["id", "equipment", "buffer", "buffer_quantity", "quantity"] + fields = ("id", "equipment", "buffer", "buffer_quantity", "quantity") widgets = { "equipment": Selectize(search_lookup="name_icontains"), "buffer": Selectize(search_lookup="name_icontains"), @@ -258,9 +267,8 @@ def __init__(self, *args, genrequest: Genrequest, **kwargs): self.fields["species"].queryset = genrequest.species.all() # type: ignore[attr-defined] self.fields["sample_types"].queryset = genrequest.sample_types.all() # type: ignore[attr-defined] - # self.fields["markers"].queryset = Marker.objects.filter( - # species__genrequests__id=genrequest.id - # ).distinct() + self.fields["contact_person"].label = "Responsible genetic researcher" + self.fields["contact_email"].label = "Responsible genetic researcher email" def save(self, commit: bool = True) -> ExtractionOrder: obj = super().save(commit=False) @@ -272,7 +280,7 @@ def save(self, commit: bool = True) -> ExtractionOrder: class Meta: model = ExtractionOrder - fields = [ + fields = ( "name", "needs_guid", "species", @@ -284,7 +292,7 @@ class Meta: "is_urgent", "contact_person", "contact_email", - ] + ) widgets = { "species": DualSortableSelector( search_lookup="name_icontains", @@ -329,10 +337,37 @@ def __init__(self, *args, genrequest: Genrequest, **kwargs): " with the sample selection by pressing Submit" ) + self.fields["contact_person"].label = "Responsible genetic researcher" + self.fields["contact_email"].label = "Responsible genetic researcher email" + + def clean_contact_email_results(self) -> str: + emails_raw = self.cleaned_data.get("contact_email_results", "") + emails = [e.strip() for e in emails_raw.split(",") if e.strip()] + + try: + for email in emails: + validate_email(email) + except ValidationError: + msg = f"Invalid email: {email}" + raise forms.ValidationError(msg) from ValidationError(msg) + return ", ".join(emails) + + def clean_contact_person_results(self) -> str: + names_raw = self.cleaned_data.get("contact_person_results", "") + names = [n.strip() for n in names_raw.split(",") if n.strip()] + + for name in names: + # Optionally allow hyphens and apostrophes in names + if not all(c.isalpha() or c.isspace() or c in "-'" for c in name): + msg = f"Invalid name: {name}" + raise forms.ValidationError(msg) from ValidationError(msg) + return ", ".join(names) + def save(self, commit: bool = True) -> Model: if not commit: msg = "This form is always committed" raise NotImplementedError(msg) + with transaction.atomic(): obj = super().save(commit=False) obj.genrequest = self.genrequest @@ -342,9 +377,38 @@ def save(self, commit: bool = True) -> Model: if obj.from_order and not obj.name and obj.from_order.name: obj.name = obj.from_order.name + " - Analysis" + obj.save() self.save_m2m() obj.populate_from_order() + + # Save AnalysisOrderResultsCommunication objects + # Delete old entries first (in case of resubmission) + obj.results_contacts.all().delete() + + names = [ + strip_tags(n.strip()) + for n in self.cleaned_data["contact_person_results"].split(",") + if n.strip() + ] + emails = [ + strip_tags(e.strip()) + for e in self.cleaned_data["contact_email_results"].split(",") + if e.strip() + ] + + if names and emails: + if len(names) != len(emails): + msg = "The number of names must match the number of emails." + raise ValidationError(msg) + + for name, email in zip(names, emails, strict=False): + AnalysisOrderResultsCommunication.objects.create( + analysis_order=obj, + contact_person_results=name, + contact_email_results=email, + ) + return obj def clean(self) -> None: @@ -354,6 +418,18 @@ def clean(self) -> None: msg = "An extraction order must be selected" raise ValidationError(msg) + contact_person_results = forms.CharField( + label="Contact person(s) for results", + help_text="Comma-separated list of names to contact with results", + required=True, + ) + + contact_email_results = forms.CharField( + label="Contact email(s) for results", + help_text="Comma-separated list of emails to contact with results (must match order of names)", # noqa: E501 + required=True, + ) + field_order = [ "name", "use_all_samples", @@ -365,7 +441,7 @@ def clean(self) -> None: class Meta: model = AnalysisOrder - fields = [ + fields = ( "name", "from_order", "markers", @@ -375,7 +451,7 @@ class Meta: "is_urgent", "contact_person", "contact_email", - ] + ) widgets = { "name": TextInput( attrs={"df-show": ".from_order==''||.use_all_samples=='False'"} @@ -393,7 +469,7 @@ class Meta: class AnalysisOrderUpdateForm(AnalysisOrderForm): class Meta(AnalysisOrderForm.Meta): - fields = [ + fields = ( "name", "markers", # "from_order", @@ -403,7 +479,7 @@ class Meta(AnalysisOrderForm.Meta): "is_urgent", "contact_person", "contact_email", - ] + ) # type: ignore[assignment] def __init__(self, *args, genrequest: Genrequest, **kwargs): super().__init__(*args, genrequest=genrequest, **kwargs) diff --git a/src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py b/src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py new file mode 100644 index 00000000..051aa388 --- /dev/null +++ b/src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.3 on 2025-07-24 08:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "genlab_bestilling", + "0032_alter_isolationmethod_remove_species_add_type_20250722_1226", + ), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="contact_person", + field=models.CharField( + help_text="Responsible for genetic bioinformatics analysis", null=True + ), + ), + migrations.CreateModel( + name="AnalysisOrderResultsCommunication", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "contact_person_results", + models.CharField( + help_text="Person to contact for analysis resuls", null=True + ), + ), + ( + "contact_email_results", + models.EmailField( + help_text="Email to send analysis results", + max_length=254, + null=True, + ), + ), + ( + "analysis_order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="results_contacts", + to="genlab_bestilling.analysisorder", + ), + ), + ], + ), + ] diff --git a/src/genlab_bestilling/migrations/0034_delete_all_isolationmethods_20250724_1745.py b/src/genlab_bestilling/migrations/0034_delete_all_isolationmethods_20250724_1745.py new file mode 100644 index 00000000..cdea6216 --- /dev/null +++ b/src/genlab_bestilling/migrations/0034_delete_all_isolationmethods_20250724_1745.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.3 on 2025-07-24 15:45 + +from django.db import migrations +from django.db.migrations.state import StateApps +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def delete_isolation_methods(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor): + IsolationMethod = apps.get_model("genlab_bestilling", "IsolationMethod") + IsolationMethod.objects.all().delete() + + +def reverse_delete_isolation_methods( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ( + "genlab_bestilling", + "0033_alter_order_contact_person_and_more", + ), + ] + + operations = [ + migrations.RunPython( + delete_isolation_methods, + reverse_delete_isolation_methods, + ), + ] diff --git a/src/genlab_bestilling/migrations/0035_add_sample_types_to_isolationmethod_20250724_1749.py b/src/genlab_bestilling/migrations/0035_add_sample_types_to_isolationmethod_20250724_1749.py new file mode 100644 index 00000000..41bf6d5a --- /dev/null +++ b/src/genlab_bestilling/migrations/0035_add_sample_types_to_isolationmethod_20250724_1749.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-07-24 15:49 + +from django.db import migrations +from django.db.models import ManyToManyField + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0034_delete_all_isolationmethods_20250724_1745"), + ] + + operations = [ + migrations.RemoveField(model_name="isolationmethod", name="type"), + migrations.AddField( + model_name="isolationmethod", + name="sample_types", + field=ManyToManyField( + to="genlab_bestilling.SampleType", + related_name="isolation_methods", + verbose_name="Sample types", + blank=True, + help_text="Sample types that this isolation method can be used for", + ), + ), + ] diff --git a/src/genlab_bestilling/migrations/0036_sample_markers.py b/src/genlab_bestilling/migrations/0036_sample_markers.py new file mode 100644 index 00000000..7ea7b2f3 --- /dev/null +++ b/src/genlab_bestilling/migrations/0036_sample_markers.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.3 on 2025-07-28 08:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0035_add_sample_types_to_isolationmethod_20250724_1749"), + ] + + operations = [ + migrations.AddField( + model_name="sample", + name="markers", + field=models.ManyToManyField( + blank=True, + help_text="Markers that are relevant for this sample", + related_name="samples", + through="genlab_bestilling.SampleMarkerAnalysis", + to="genlab_bestilling.marker", + ), + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 14a06c0a..f59d7de6 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -5,6 +5,7 @@ from django.conf import settings from django.db import models, transaction +from django.db.models import Q from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -284,7 +285,7 @@ class OrderPriority: contact_person = models.CharField( null=True, blank=False, - help_text="Person to contact with questions about this order", + help_text="Responsible for genetic bioinformatics analysis", ) contact_email = models.EmailField( null=True, @@ -330,6 +331,14 @@ def to_draft(self) -> None: def to_processing(self) -> None: self.status = Order.OrderStatus.PROCESSING + if not self.is_seen: + self.is_seen = True + self.save() + + def to_completed(self) -> None: + self.status = Order.OrderStatus.COMPLETED + if not self.is_seen: + self.is_seen = True self.save() def toggle_seen(self) -> None: @@ -343,6 +352,13 @@ def toggle_prioritized(self) -> None: def get_type(self) -> str: return "order" + def update_status(self) -> None: + """ + AnalysisOrder and ExtractionOrder should implement this method + """ + msg = "Subclasses must implement update_status()" + raise NotImplementedError(msg) + @property def filled_genlab_count(self) -> int: return self.samples.filter(genlab_id__isnull=False).count() @@ -439,6 +455,9 @@ def confirm_order(self) -> Any: raise Order.CannotConfirm(_("No equipments found")) return super().confirm_order() + def update_status(self) -> None: + pass + def get_type(self) -> str: return "equipment" @@ -506,6 +525,14 @@ def confirm_order(self, persist: bool = True) -> None: if persist: super().confirm_order() + def update_status(self) -> None: + if not self.samples.filter(is_isolated=False).exists(): + super().to_completed() + return + if not self.samples.filter(genlab_id__isnull=True).exists(): + super().to_processing() + return + @transaction.atomic def order_selected_checked( self, @@ -528,6 +555,27 @@ def order_selected_checked( ) +class AnalysisOrderResultsCommunication(models.Model): + analysis_order = models.ForeignKey( + f"{an}.AnalysisOrder", + on_delete=models.CASCADE, + related_name="results_contacts", + ) + contact_person_results = models.CharField( + null=True, + blank=False, + help_text="Person to contact for analysis resuls", + ) + contact_email_results = models.EmailField( + null=True, + blank=False, + help_text="Email to send analysis results", + ) + + def __str__(self): + return f"{str(self.analysis_order)} {str(self.contact_person_results)} {str(self.contact_email_results)}" # noqa: E501 + + class AnalysisOrder(Order): samples = models.ManyToManyField( f"{an}.Sample", blank=True, through="SampleMarkerAnalysis" @@ -579,6 +627,21 @@ def confirm_order(self, persist: bool = True) -> None: if persist: super().confirm_order() + def update_status(self) -> None: + if not SampleMarkerAnalysis.objects.filter( + order=self, is_outputted=False + ).exists(): + super().to_completed() + return + + if ( + SampleMarkerAnalysis.objects.filter(order=self) + .filter(Q(has_pcr=True) | Q(is_analysed=True)) + .exists() + ): + super().to_processing() + return + def populate_from_order(self) -> None: """ Create the list of markers per sample to analyze @@ -676,6 +739,14 @@ class Sample(models.Model): help_text="The isolation method used for this sample", ) + markers = models.ManyToManyField( + f"{an}.Marker", + through="SampleMarkerAnalysis", + related_name="samples", + blank=True, + help_text="Markers that are relevant for this sample", + ) + is_prioritised = models.BooleanField( default=False, help_text="Check this box if the sample is prioritised for processing", @@ -817,11 +888,12 @@ def __str__(self) -> str: class IsolationMethod(models.Model): name = models.CharField(max_length=255) - type = models.ForeignKey( + sample_types = models.ManyToManyField( f"{an}.SampleType", - on_delete=models.CASCADE, - related_name="type_isolation_methods", - help_text="The sample type this isolation method is related to.", + related_name="isolation_methods", + verbose_name="Sample types", + blank=True, + help_text="Sample types that this isolation method can be used for", ) def __str__(self) -> str: diff --git a/src/genlab_bestilling/tables.py b/src/genlab_bestilling/tables.py index 11a68dc8..adfb540a 100644 --- a/src/genlab_bestilling/tables.py +++ b/src/genlab_bestilling/tables.py @@ -17,17 +17,17 @@ class BaseOrderTable(tables.Table): class Meta: model = Order - fields = [ + fields = ( "name", "status", "created_at", "last_modified_at", - ] - sequence = [ + ) + sequence = ( "id", "name", "status", - ] + ) empty_text = "No Orders" def render_id(self, record: Any) -> str: @@ -39,7 +39,7 @@ class OrderTable(BaseOrderTable): class Meta: model = Order - fields = [ + fields = ( "name", "status", "polymorphic_ctype", @@ -47,13 +47,13 @@ class Meta: "genrequest__project", "created_at", "last_modified_at", - ] - sequence = [ + ) + sequence = ( "id", "name", "status", "polymorphic_ctype", - ] + ) empty_text = "No Orders" def render_polymorphic_ctype(self, value: Any) -> str: @@ -68,7 +68,7 @@ class GenrequestTable(tables.Table): class Meta: model = Genrequest - fields = [ + fields = ( "project_id", "name", "area", @@ -77,7 +77,7 @@ class Meta: "expected_total_samples", "expected_samples_delivery_date", "expected_analysis_delivery_date", - ] + ) empty_text = "No projects" @@ -92,7 +92,7 @@ class SampleTable(tables.Table): class Meta: model = Sample - fields = [ + fields = ( "guid", "name", "species", @@ -103,7 +103,7 @@ class Meta: "notes", "genlab_id", "plate_positions", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Samples" @@ -120,7 +120,7 @@ class AnalysisSampleTable(tables.Table): class Meta: model = Sample - fields = [ + fields = ( "sample__genlab_id", "markers_names", "sample__guid", @@ -130,7 +130,7 @@ class Meta: "sample__year", "sample__pop_id", "sample__location__name", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Samples" @@ -145,11 +145,11 @@ class AnalysisOrderTable(BaseOrderTable): class Meta(BaseOrderTable.Meta): model = AnalysisOrder - fields = BaseOrderTable.Meta.fields + [ + fields = BaseOrderTable.Meta.fields + ( "genrequest", "genrequest__project", "return_samples", - ] + ) # type: ignore[assignment] class ExtractionOrderTable(BaseOrderTable): @@ -161,7 +161,7 @@ class ExtractionOrderTable(BaseOrderTable): class Meta(BaseOrderTable.Meta): model = ExtractionOrder - fields = BaseOrderTable.Meta.fields + [ + fields = BaseOrderTable.Meta.fields + ( "species", "sample_types", "internal_status", @@ -170,7 +170,7 @@ class Meta(BaseOrderTable.Meta): "pre_isolated", "genrequest", "genrequest__project", - ] + ) # type: ignore[assignment] class EquipmentOrderTable(BaseOrderTable): @@ -182,4 +182,4 @@ class EquipmentOrderTable(BaseOrderTable): class Meta(BaseOrderTable.Meta): model = EquipmentOrder - fields = BaseOrderTable.Meta.fields + ["needs_guid", "sample_types"] + fields = BaseOrderTable.Meta.fields + ("needs_guid", "sample_types") # type: ignore[assignment] diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html index 11812109..4cb9dad2 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html @@ -26,6 +26,27 @@

Order {{ object }}

{% object-detail object=object %} + {% if results_contacts %} +
Contacts for Analysis Results
+
+ +
+ {% else %} +
Contacts for Analysis Results
+
+

No contacts provided for analysis results.

+
+ {% endif %} +
Samples to analyze
@@ -44,9 +65,11 @@
Samples to analyze
{% url 'genrequest-order-clone' genrequest_id=object.genrequest_id pk=object.id as clone_order_url %} {% 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 %} + {% if all_samples_have_no_genlab_id %} Delete + {% endif %} {% elif object.status == object.OrderStatus.DELIVERED %} - Samples + Samples {% endif %}
{% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html b/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html index 7b7be329..145ff1b2 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/base_filter.html @@ -6,13 +6,7 @@

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

{% block page-inner %}{% endblock page-inner %} -
-
- {{ filter.form | crispy }} - - Clear Filters -
-
+ {% filtering filter=filter request=request %} {% render_table table %} {% endblock %} diff --git a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html index 0342d567..044bbcde 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/extractionorder_detail.html @@ -24,8 +24,9 @@

Order {{ object }}

{% 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 %} - + {% if all_samples_have_no_genlab_id %} Delete + {% endif %} {% endif %} Samples diff --git a/src/genlab_bestilling/views.py b/src/genlab_bestilling/views.py index ed8e464a..e7c98fc5 100644 --- a/src/genlab_bestilling/views.py +++ b/src/genlab_bestilling/views.py @@ -467,6 +467,15 @@ def gen_crumbs(self) -> list[tuple]: (str(self.object), ""), ] + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + all_samples_have_no_genlab_id = not self.object.samples.exclude( + genlab_id__isnull=True + ).exists() + context["all_samples_have_no_genlab_id"] = all_samples_have_no_genlab_id + context["results_contacts"] = self.object.results_contacts.all() + return context + def get_queryset(self) -> QuerySet: return ( super() @@ -499,6 +508,15 @@ def gen_crumbs(self) -> list[tuple]: (str(self.object), ""), ] + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + order = self.object + all_samples_have_no_genlab_id = not order.samples.exclude( + genlab_id__isnull=True + ).exists() + context["all_samples_have_no_genlab_id"] = all_samples_have_no_genlab_id + return context + class GenrequestOrderDeleteView(GenrequestNestedMixin, DeleteView): model = Order @@ -808,6 +826,9 @@ def gen_crumbs(self) -> list[tuple]: ] def get_success_url(self) -> str: + # Clear any leftover error messages before redirect + list(messages.get_messages(self.request)) + obj: AnalysisOrder = self.object # type: ignore[assignment] # Possibly None if obj.from_order: return reverse( @@ -825,6 +846,15 @@ def get_success_url(self) -> str: }, ) + # Override form_invalid to show errors in the form + def form_invalid(self, form: Form) -> HttpResponse: + for field, errors in form.errors.items(): + field_obj = form.fields.get(field) + label = field_obj.label if field_obj is not None else field + for error in errors: + messages.error(self.request, f"{label}: {error}") + return super().form_invalid(form) + class ExtractionOrderCreateView( GenrequestNestedMixin, diff --git a/src/nina/forms.py b/src/nina/forms.py index fd6733d8..12028098 100644 --- a/src/nina/forms.py +++ b/src/nina/forms.py @@ -27,7 +27,7 @@ def save(self, commit: bool = True) -> Model: class Meta: model = ProjectMembership - fields = ["id", "user", "role"] + fields = ("id", "user", "role") class ProjectMembershipCollection(ContextFormCollection): @@ -90,7 +90,7 @@ def clean_number(self) -> int: class Meta: model = Project - fields = ["number", "name"] + fields = ("number", "name") class ProjectUpdateForm(forms.ModelForm): @@ -98,4 +98,4 @@ class ProjectUpdateForm(forms.ModelForm): class Meta: model = Project - fields = ["name"] + fields = ("name",) diff --git a/src/nina/tables.py b/src/nina/tables.py index fd37d8a4..711b3013 100644 --- a/src/nina/tables.py +++ b/src/nina/tables.py @@ -9,10 +9,10 @@ class MyProjectsTable(tables.Table): class Meta: model = ProjectMembership - fields = ["project", "role", "project__active", "project__verified_at"] + fields = ("project", "role", "project__active", "project__verified_at") class MembersTable(tables.Table): class Meta: model = ProjectMembership - fields = ["user", "role"] + fields = ("user", "role") diff --git a/src/staff/api.py b/src/staff/api.py new file mode 100644 index 00000000..f0ca9d3f --- /dev/null +++ b/src/staff/api.py @@ -0,0 +1,48 @@ +from django.contrib import messages +from django.db.models.query import QuerySet +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from capps.users.models import User +from genlab_bestilling.models import Order + + +class OrderAPIView(APIView): + class RequestJson: + user_ids = "user_ids" + + def get_object(self) -> Order: + return Order.objects.get(pk=self.kwargs["pk"]) + + def get_genlab_staff(self) -> QuerySet[User]: + return User.objects.filter(groups__name="genlab") + + def post(self, request: Request, *args, **kwargs) -> Response: + order = self.get_object() + + if not order.is_seen: + msg = "Order must be seen before assigning staff." + + messages.error(request, msg) + return Response( + status=400, + data={"detail": msg}, + ) + + try: + users = self.get_genlab_staff().filter( + id__in=request.data.get(self.RequestJson.user_ids, []) + ) + order.responsible_staff.clear() + order.responsible_staff.set(users) + order.save() + + return Response( + status=200, + ) + except Exception: + # TODO Report to Sentry + return Response( + status=500, + ) diff --git a/src/staff/filters.py b/src/staff/filters.py index d3720407..7cbb6eaa 100644 --- a/src/staff/filters.py +++ b/src/staff/filters.py @@ -5,22 +5,54 @@ from django import forms from django.db.models import QuerySet from django.http import HttpRequest -from django_filters import BooleanFilter, CharFilter, ChoiceFilter +from django_filters import CharFilter, ChoiceFilter from capps.users.models import User from genlab_bestilling.models import ( AnalysisOrder, Area, + EquipmentOrder, ExtractionOrder, ExtractionPlate, - Order, + Marker, Sample, SampleMarkerAnalysis, Species, ) - - -class AnalysisOrderFilter(filters.FilterSet): +from nina.models import Project +from staff.mixins import HideStatusesByDefaultMixin + +CUSTOM_ORDER_STATUS_CHOICES = [ + ("draft", "Draft"), + ("confirmed", "Not started"), + ("processing", "Processing"), + ("completed", "Completed"), +] + + +class StaticModelSelect2Multiple(autocomplete.ModelSelect2Multiple): + def __init__(self, static_choices: list[tuple], *args: Any, **kwargs: Any) -> None: + self.static_choices = static_choices or [] + super().__init__(*args, **kwargs) + + def filter_queryset( + self, request: HttpRequest, term: str, queryset: QuerySet + ) -> list: + # Override to use static choices instead of queryset + if term: + return [ + choice + for choice in self.static_choices + if term.lower() in choice[1].lower() + ] + return self.static_choices + + def get_queryset(self) -> QuerySet: + # Return empty queryset since we're using static data + return self.model.objects.none() + + +class AnalysisOrderFilter(HideStatusesByDefaultMixin, filters.FilterSet): id = filters.CharFilter( field_name="id", label="Order ID", @@ -32,16 +64,17 @@ class AnalysisOrderFilter(filters.FilterSet): ), ) - status = filters.ChoiceFilter( + status = filters.MultipleChoiceFilter( field_name="status", label="Status", - choices=Order.OrderStatus.choices, - widget=forms.Select( + choices=CUSTOM_ORDER_STATUS_CHOICES, + widget=StaticModelSelect2Multiple( + static_choices=CUSTOM_ORDER_STATUS_CHOICES, attrs={ - "class": "bg-white border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700" # noqa: E501 + "data-placeholder": "Filter by status", + "class": "border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 }, ), - empty_label="", ) genrequest__area = filters.ModelChoiceFilter( @@ -74,18 +107,94 @@ class AnalysisOrderFilter(filters.FilterSet): ), ) + markers = filters.ModelMultipleChoiceFilter( + field_name="markers", + label="Markers", + queryset=Marker.objects.all(), + widget=autocomplete.ModelSelect2Multiple( + url="autocomplete:analysis-marker", + attrs={"class": "w-full"}, + ), + ) + + @property + def qs(self) -> QuerySet: + queryset = super().qs + return self.exclude_hidden_statuses(queryset, self.data) + class Meta: model = AnalysisOrder - fields = [ + fields = ( "id", "status", "genrequest__area", "responsible_staff", "genrequest__species", - ] + "markers", + ) + + +class EquipmentOrderFilter(HideStatusesByDefaultMixin, filters.FilterSet): + id = filters.CharFilter( + field_name="id", + label="Order ID", + 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", + } + ), + ) + status = filters.MultipleChoiceFilter( + field_name="status", + label="Status", + choices=CUSTOM_ORDER_STATUS_CHOICES, + widget=StaticModelSelect2Multiple( + static_choices=CUSTOM_ORDER_STATUS_CHOICES, + attrs={ + "data-placeholder": "Filter by status", + "class": "border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 + }, + ), + ) -class ExtractionOrderFilter(filters.FilterSet): + genrequest__area = filters.ModelChoiceFilter( + field_name="genrequest__area", + label="Area", + queryset=Area.objects.all(), + widget=autocomplete.ModelSelect2( + url="autocomplete:area", + attrs={"class": "w-full"}, + ), + ) + + responsible_staff = filters.ModelMultipleChoiceFilter( + field_name="responsible_staff", + label="Assigned Staff", + queryset=User.objects.filter(groups__name="genlab"), + widget=autocomplete.ModelSelect2Multiple( + url="autocomplete:staff-user", + attrs={"class": "w-full"}, + ), + ) + + @property + def qs(self) -> QuerySet: + queryset = super().qs + return self.exclude_hidden_statuses(queryset, self.data) + + class Meta: + model = EquipmentOrder + fields = ( + "id", + "status", + "genrequest__area", + "responsible_staff", + ) + + +class ExtractionOrderFilter(HideStatusesByDefaultMixin, filters.FilterSet): id = CharFilter( field_name="id", label="Order ID", @@ -97,16 +206,17 @@ class ExtractionOrderFilter(filters.FilterSet): ), ) - status = ChoiceFilter( + status = filters.MultipleChoiceFilter( field_name="status", label="Status", - choices=Order.OrderStatus.choices, - widget=forms.Select( + choices=CUSTOM_ORDER_STATUS_CHOICES, + widget=StaticModelSelect2Multiple( + static_choices=CUSTOM_ORDER_STATUS_CHOICES, attrs={ - "class": "bg-white border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700" # noqa: E501 + "data-placeholder": "Filter by status", + "class": "border border-gray-300 rounded-lg py-2 px-4 w-full text-gray-700", # noqa: E501 }, ), - empty_label="", ) genrequest__area = filters.ModelChoiceFilter( @@ -139,15 +249,20 @@ class ExtractionOrderFilter(filters.FilterSet): ), ) + @property + def qs(self) -> QuerySet: + queryset = super().qs + return self.exclude_hidden_statuses(queryset, self.data) + class Meta: model = ExtractionOrder - fields = [ + fields = ( "id", "status", "genrequest__area", "responsible_staff", "genrequest__species", - ] + ) class OrderSampleFilter(filters.FilterSet): @@ -187,12 +302,12 @@ def __init__( class Meta: model = Sample - fields = [ + fields = ( "genlab_id", "name", "species", "type", - ] + ) class SampleMarkerOrderFilter(filters.FilterSet): @@ -239,7 +354,7 @@ def __init__( class Meta: model = SampleMarkerAnalysis - fields = [ + fields = ( "sample__genlab_id", "sample__type", "sample__extractions", @@ -247,10 +362,44 @@ class Meta: # "PCR", # "fluidigm", # "output", - ] + ) + + +class SampleStatusWidget(forms.Select): + def __init__(self, attrs: dict[str, Any] | None = None): + choices = ( + ("", "Status"), + ("marked", "Marked"), + ("plucked", "Plucked"), + ("isolated", "Isolated"), + ) + super().__init__(choices=choices, attrs=attrs) + + +def filter_sample_status( + filter_set: Any, queryset: QuerySet, name: Any, value: str +) -> QuerySet: + if value == "marked": + # Only marked, not plucked or isolated + return queryset.filter(is_marked=True, is_plucked=False, is_isolated=False) + if value == "plucked": + # Plucked but not isolated + return queryset.filter(is_plucked=True, is_isolated=False) + if value == "isolated": + # All isolated samples, regardless of others + return queryset.filter(is_isolated=True) + return queryset class SampleFilter(filters.FilterSet): + filter_sample_status = filter_sample_status + + sample_status = filters.CharFilter( + label="Sample Status", + method="filter_sample_status", + widget=SampleStatusWidget, + ) + def __init__( self, data: dict[str, Any] | None = None, @@ -272,7 +421,7 @@ def __init__( class Meta: model = Sample - fields = [ + fields = ( "name", "genlab_id", "species", @@ -280,63 +429,18 @@ class Meta: "year", "location", "pop_id", - "type", - ] + "sample_status", + ) class ExtractionPlateFilter(filters.FilterSet): class Meta: model = ExtractionPlate - fields = [ - "id", - ] + fields = ("id",) class SampleLabFilter(filters.FilterSet): - is_marked = BooleanFilter( - label="Marked", - method="filter_boolean", - widget=forms.Select( - choices=( - ("", "---------"), - ("true", "Yes"), - ("false", "No"), - ), - attrs={ - "class": "w-full border border-gray-300 rounded-lg py-2 px-4 text-gray-700", # noqa: E501 - }, - ), - ) - - is_plucked = BooleanFilter( - label="Plucked", - method="filter_boolean", - widget=forms.Select( - choices=( - ("", "---------"), - ("true", "Yes"), - ("false", "No"), - ), - attrs={ - "class": "w-full border border-gray-300 rounded-lg py-2 px-4 text-gray-700", # noqa: E501 - }, - ), - ) - - is_isolated = BooleanFilter( - label="Isolated", - method="filter_boolean", - widget=forms.Select( - choices=( - ("", "---------"), - ("true", "Yes"), - ("false", "No"), - ), - attrs={ - "class": "w-full border border-gray-300 rounded-lg py-2 px-4 text-gray-700", # noqa: E501 - }, - ), - ) + filter_sample_status = filter_sample_status genlab_id_min = ChoiceFilter( label="Genlab ID (From)", @@ -350,6 +454,12 @@ class SampleLabFilter(filters.FilterSet): empty_label="Select upper bound", ) + sample_status = filters.CharFilter( + label="Sample Status", + method="filter_sample_status", + widget=SampleStatusWidget, + ) + def __init__( self, data: dict[str, Any] | None = None, @@ -399,17 +509,15 @@ def __init__( class Meta: model = Sample - fields = [ + fields = ( "genlab_id_min", "genlab_id_max", - "is_marked", - "is_plucked", - "is_isolated", + "sample_status", "extractions", "isolation_method", # "fluidigm", # "output", - ] + ) def filter_genlab_id_range( self, queryset: QuerySet, name: str, value: Any @@ -440,3 +548,76 @@ def filter_queryset(self, queryset: QuerySet) -> QuerySet: queryset = queryset.filter(genlab_id__lte=genlab_max) return queryset + + +class ProjectFilter(filters.FilterSet): + def __init__( + self, + data: dict[str, Any] | None = None, + queryset: QuerySet | None = None, + *, + request: HttpRequest | None = None, + prefix: str | None = None, + ) -> None: + if not data or not any(data.values()): + data = {"active": "True"} + + super().__init__(data, queryset, request=request, prefix=prefix) + + number = CharFilter( + field_name="number", + lookup_expr="startswith", + label="Project number starts with", + widget=forms.TextInput( + attrs={ + "placeholder": "Enter project number", + } + ), + ) + + name = CharFilter( + field_name="name", + lookup_expr="istartswith", + label="Project name starts with", + widget=forms.TextInput( + attrs={ + "placeholder": "Enter project name", + } + ), + ) + + verified_at = filters.ChoiceFilter( + field_name="verified_at", + label="Verified", + choices=[ + ("True", "Yes"), + ("False", "No"), + ], + method="filter_verified_at", + widget=forms.Select( + attrs={ + "class": "form-check-input", + } + ), + ) + + active = filters.ChoiceFilter( + field_name="active", + label="Active", + choices=[ + ("True", "Yes"), + ("False", "No"), + ], + widget=forms.Select( + attrs={ + "class": "form-check-input", + } + ), + ) + + def filter_verified_at(self, queryset: QuerySet, name: str, value: Any) -> QuerySet: + return queryset.filter(verified_at__isnull=(value == "False")) + + class Meta: + model = Project + fields = () diff --git a/src/staff/forms.py b/src/staff/forms.py index 62a6b6a7..745c880c 100644 --- a/src/staff/forms.py +++ b/src/staff/forms.py @@ -1,3 +1,4 @@ +from dal import autocomplete from django import forms from django.forms import ModelForm from formset.renderers.tailwind import FormRenderer @@ -9,7 +10,7 @@ class ExtractionPlateForm(ModelForm): class Meta: model = ExtractionPlate - fields = ["name"] + fields = ("name",) class OrderStaffForm(forms.Form): @@ -40,3 +41,29 @@ def get_all_staff(self) -> list[tuple[int, str]]: (user.id, f"{user}") for user in User.objects.filter(groups__name="genlab").all() ] + + +class ResponsibleStaffForm(forms.ModelForm): + id = forms.IntegerField(widget=forms.HiddenInput(), required=True) + + responsible_staff = forms.ModelMultipleChoiceField( + label="", + queryset=User.objects.filter(groups__name="genlab"), + widget=autocomplete.ModelSelect2Multiple( + url="autocomplete:staff-user", + attrs={"data-placeholder": "Assign staff"}, + ), + ) + + def __init__(self, *args, order: Order | None = None, **kwargs): + super().__init__(*args, **kwargs) + if order: + self.fields["id"].initial = order.id + self.fields["responsible_staff"].initial = order.responsible_staff.all() + + class Meta: + model = Order + fields = ( + "id", + "responsible_staff", + ) diff --git a/src/staff/mixins.py b/src/staff/mixins.py index 41163f50..135f395e 100644 --- a/src/staff/mixins.py +++ b/src/staff/mixins.py @@ -1,11 +1,11 @@ -from collections.abc import Sequence from typing import Any import django_tables2 as tables -from django.db import models +from django.db.models import Case, IntegerField, Value, When from django.db.models.query import QuerySet +from django.http import QueryDict +from django.utils.html import format_html from django.utils.http import url_has_allowed_host_and_scheme -from django.utils.safestring import mark_safe from django.views.generic import View from genlab_bestilling.models import ( @@ -28,7 +28,7 @@ def render_id( ) -> str: url = record.get_absolute_staff_url() - return mark_safe(f'{record}') # noqa: S308 + return format_html('{}', url, str(record)) def render_status_helper(status: Order.OrderStatus) -> str: @@ -47,8 +47,10 @@ def render_status_helper(status: Order.OrderStatus) -> str: classes = status_colors.get(status, "bg-gray-100 text-gray-800") text = status_text.get(status, "Unknown") - return mark_safe( # noqa: S308 - f'{text}' # noqa: E501 + return format_html( + '{}', # noqa: E501 + classes, + text, ) @@ -63,11 +65,13 @@ def order_status( ) -> tuple[QuerySet[Order], bool]: prefix = "-" if is_descending else "" sorted_by_status = queryset.annotate( - status_order=models.Case( - models.When(status=Order.OrderStatus.DELIVERED, then=0), - models.When(status=Order.OrderStatus.DRAFT, then=1), - models.When(status=Order.OrderStatus.PROCESSING, then=2), - models.When(status=Order.OrderStatus.COMPLETED, then=3), + status_order=Case( + When(status=Order.OrderStatus.DELIVERED, then=0), + When(status=Order.OrderStatus.DRAFT, then=1), + When(status=Order.OrderStatus.PROCESSING, then=2), + When(status=Order.OrderStatus.COMPLETED, then=3), + default=Value(4), + output_field=IntegerField(), ) ).order_by(f"{prefix}status_order") @@ -84,9 +88,9 @@ class SampleStatusMixinTable(tables.Table): def render_sample_status(self, value: Any, record: Sample) -> str: order = record.order + status = "Unknown" - # Determine status label - if isinstance(order, ExtractionOrder): + if order: if record.is_isolated: status = "Isolated" elif record.is_plucked: @@ -95,8 +99,6 @@ def render_sample_status(self, value: Any, record: Sample) -> str: status = "Marked" else: status = "Not started" - else: - status = getattr(order, "sample_status", "Unknown") # Define color map status_colors = { @@ -110,30 +112,26 @@ def render_sample_status(self, value: Any, record: Sample) -> str: # Use computed status, not value color_class = status_colors.get(status, "bg-gray-100 text-gray-800") - return mark_safe( # noqa: S308 - f'{status}' # noqa: E501 + return format_html( + '{}', # noqa: E501 + color_class, + status, ) def order_sample_status( - self, records: Sequence[Any], is_descending: bool - ) -> tuple[list[Any], bool]: - def get_status_value(record: Any) -> int: - if isinstance(record.order, ExtractionOrder): - if record.is_isolated: - return self.STATUS_PRIORITY["Isolated"] - if record.is_plucked: - return self.STATUS_PRIORITY["Plucked"] - if record.is_marked: - return self.STATUS_PRIORITY["Marked"] - return self.STATUS_PRIORITY["Not started"] - - # fallback for other types of orders - return self.STATUS_PRIORITY.get( - getattr(record.order, "sample_status", ""), -1 - ) - - sorted_records = sorted(records, key=get_status_value, reverse=is_descending) - return (sorted_records, True) + self, queryset: QuerySet[Sample], is_descending: bool + ) -> tuple[QuerySet[Sample], bool]: + prefix = "-" if is_descending else "" + status_order = Case( + When(is_isolated=True, then=Value(3)), + When(is_plucked=True, then=Value(2)), + When(is_marked=True, then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ) + annotated_queryset = queryset.annotate(sample_status_order=status_order) + sorted_queryset = annotated_queryset.order_by(f"{prefix}sample_status_order") + return (sorted_queryset, True) class PriorityMixinTable(tables.Table): @@ -148,11 +146,11 @@ def order_priority( ) -> tuple[QuerySet[Order], bool]: prefix = "-" if is_descending else "" queryset = queryset.annotate( - priority_order=models.Case( - models.When(is_urgent=True, then=2), - models.When(is_prioritized=True, then=1), + priority_order=Case( + When(is_urgent=True, then=2), + When(is_prioritized=True, then=1), default=0, - output_field=models.IntegerField(), + output_field=IntegerField(), ) ) sorted_by_priority = queryset.order_by(f"{prefix}priority_order") @@ -174,27 +172,52 @@ def get_fallback_url(self) -> str: msg = "You must override get_fallback_url()" raise NotImplementedError(msg) - def has_next_url(self) -> bool: - next_url = self.request.POST.get(self.next_param) or self.request.GET.get( - self.next_param - ) - return bool( - next_url - and url_has_allowed_host_and_scheme( - next_url, - allowed_hosts={self.request.get_host()}, - require_https=self.request.is_secure(), - ) + def get_next_url_from_request(self) -> str | None: + """ + Safely extract the next URL from POST and GET data. + Returns a stripped string if present and non-empty, else None. + """ + next_url = self.request.POST.get(self.next_param) + if not next_url: + next_url = self.request.GET.get(self.next_param) + if isinstance(next_url, str): + next_url = next_url.strip() + if next_url: + return next_url + return None + + def has_next_url(self, next_url: str | None = None) -> bool: + """ + Check if a valid and safe next URL is present in the request. + Optionally accepts a next_url to avoid duplicate extraction. + """ + if next_url is None: + next_url = self.get_next_url_from_request() + if not next_url: + return False + return url_has_allowed_host_and_scheme( + url=next_url, + allowed_hosts={self.request.get_host()}, + require_https=self.request.is_secure(), ) def get_next_url(self) -> str: - next_url = self.request.POST.get(self.next_param) or self.request.GET.get( - self.next_param - ) - if next_url and url_has_allowed_host_and_scheme( - next_url, - allowed_hosts={self.request.get_host()}, - require_https=self.request.is_secure(), - ): + """ + Return a safe next URL if available, otherwise fallback URL. + """ + next_url = self.get_next_url_from_request() + if next_url and self.has_next_url(next_url): return next_url return self.get_fallback_url() + + +class HideStatusesByDefaultMixin: + HIDDEN_STATUSES = [Order.OrderStatus.DRAFT, Order.OrderStatus.COMPLETED] + + # Hide statuses by default unless user specifically selects them + def exclude_hidden_statuses(self, queryset: QuerySet, data: QueryDict) -> QuerySet: + selected_statuses = data.getlist("status") + if not selected_statuses: # No statuses selected by user + return queryset.exclude(status__in=self.HIDDEN_STATUSES) + + return queryset diff --git a/src/staff/tables.py b/src/staff/tables.py index 0d2e4b80..751435da 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -3,6 +3,8 @@ from typing import Any import django_tables2 as tables +from django.templatetags.static import static +from django.utils.html import format_html from django.utils.safestring import mark_safe from genlab_bestilling.models import ( @@ -31,11 +33,25 @@ class ProjectTable(tables.Table): orderable=True, empty_values=(), ) - verified_at = tables.BooleanColumn() + verified_at = tables.TemplateColumn( + template_name="staff/components/project_verified_column.html", + verbose_name="Verified", + orderable=True, + empty_values=(), + ) + + toggle_active = tables.TemplateColumn( + template_name="staff/components/activation_toggle_column.html", + verbose_name="Actions", + orderable=True, + empty_values=(), + ) class Meta: model = Project - fields = ["number", "name", "active", "verified_at"] + fields = ("number", "name", "verified_at") + sequence = ("number", "name", "toggle_active", "verified_at") + order_by = ("-verified_at",) class OrderTable(OrderStatusMixinTable, PriorityMixinTable): @@ -72,7 +88,7 @@ def render_id(self, record: Order) -> str: ) class Meta: - fields = [ + fields = ( "priority", "id", "status", @@ -81,15 +97,15 @@ class Meta: "species", "total_samples", "responsible_staff", - ] + ) empty_text = "No Orders" - order_by = ["-priority", "status"] + order_by = ("-priority", "status") class AnalysisOrderTable(OrderTable): id = tables.Column( linkify=("staff:order-analysis-detail", {"pk": tables.A("id")}), - orderable=False, + orderable=True, empty_values=(), ) @@ -108,10 +124,17 @@ class AnalysisOrderTable(OrderTable): empty_values=(), ) + species = tables.Column( + verbose_name="Species", + accessor="samples", + orderable=False, + empty_values=(), + ) + class Meta(OrderTable.Meta): model = AnalysisOrder - fields = OrderTable.Meta.fields + ["markers", "expected_delivery_date"] - sequence = [ + fields = OrderTable.Meta.fields + ("markers", "expected_delivery_date") # type: ignore[assignment] + sequence = ( "priority", "id", "status", @@ -122,7 +145,12 @@ class Meta(OrderTable.Meta): "markers", "responsible_staff", "expected_delivery_date", - ] + ) + + def render_species(self, value: Any) -> str: + return ", ".join( + sorted({sample.species.name for sample in value.all() if sample.species}) + ) class ExtractionOrderTable(OrderTable): @@ -148,11 +176,11 @@ def render_total_samples_isolated(self, record: ExtractionOrder) -> str: class Meta(OrderTable.Meta): model = ExtractionOrder - fields = OrderTable.Meta.fields + [ + fields = OrderTable.Meta.fields + ( "total_samples_isolated", "confirmed_at", - ] - sequence = [ + ) # type: ignore[assignment] + sequence = ( "priority", "id", "status", @@ -163,10 +191,10 @@ class Meta(OrderTable.Meta): "total_samples_isolated", "responsible_staff", "confirmed_at", - ] + ) -class EquipmentOrderTable(tables.Table): +class EquipmentOrderTable(OrderTable): id = tables.Column( linkify=("staff:order-equipment-detail", {"pk": tables.A("id")}), orderable=False, @@ -192,7 +220,7 @@ class EquipmentOrderTable(tables.Table): class Meta(OrderTable.Meta): model = EquipmentOrder - fields = [ + fields = ( "name", "status", "genrequest", @@ -206,27 +234,25 @@ class Meta(OrderTable.Meta): "is_seen", "needs_guid", "sample_types", - ] - sequence = ["is_seen", "is_urgent", "status", "id", "name"] + ) # type: ignore[assignment] + sequence = ("is_seen", "is_urgent", "status", "id", "name") empty_text = "No Orders" - order_by = ["-is_urgent", "last_modified_at", "created_at"] + order_by = ("-is_urgent", "last_modified_at", "created_at") # type: ignore[assignment] def render_id(self, record: Any) -> str: return str(record) def render_is_urgent(self, value: bool) -> str: - html_exclaimation_mark = ( - "" - ) if value: - return mark_safe(html_exclaimation_mark) # noqa: S308 + icon_url = static("images/exclaimation_mark.svg") + html = f"Urgent" # noqa: E501 + return mark_safe(html) # noqa: S308 return "" def render_is_seen(self, value: bool) -> str: if not value: return mark_safe( - '' + '' # noqa: E501 ) return "" @@ -236,12 +262,6 @@ class SampleBaseTable(tables.Table): empty_values=(), orderable=False, verbose_name="Extraction position" ) - is_prioritised = tables.TemplateColumn( - template_name="staff/prioritise_flag.html", - orderable=True, - verbose_name="", - ) - checked = tables.CheckBoxColumn( attrs={ "th__input": {"type": "checkbox", "id": "select-all-checkbox"}, @@ -255,7 +275,7 @@ class SampleBaseTable(tables.Table): class Meta: model = Sample - fields = [ + fields = ( "genlab_id", "guid", "name", @@ -266,18 +286,17 @@ class Meta: "location", "notes", "plate_positions", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} - sequence = [ + sequence = ( "checked", - "is_prioritised", "genlab_id", "guid", "name", "species", "type", - ] - order_by = ["-is_prioritised", "species", "genlab_id"] + ) + order_by = ("species", "genlab_id") empty_text = "No Samples" @@ -288,7 +307,9 @@ def render_plate_positions(self, value: Any) -> str: return "" def render_checked(self, record: Any) -> str: - return mark_safe(f'') # noqa: S308 + return format_html( + '', record.id + ) def order_name( self, records: Sequence[Any], is_descending: bool @@ -364,7 +385,7 @@ class SampleStatusTable(tables.Table): class Meta: model = Sample - fields = [ + fields = ( "checked", "genlab_id", "marked", @@ -373,8 +394,8 @@ class Meta: "internal_note", "isolation_method", "type", - ] - sequence = [ + ) + sequence = ( "checked", "genlab_id", "type", @@ -383,18 +404,20 @@ class Meta: "isolated", "internal_note", "isolation_method", - ] - order_by = ["genlab_id"] + ) + order_by = ("genlab_id",) def render_checked(self, record: Any) -> str: - return mark_safe( # noqa: S308 - f'' # noqa: E501 + return format_html( + '', + record.order.id, + record.id, ) class OrderExtractionSampleTable(SampleBaseTable): class Meta(SampleBaseTable.Meta): - exclude = ["pop_id", "guid", "plate_positions"] + exclude = ("pop_id", "guid", "plate_positions") class OrderAnalysisSampleTable(tables.Table): @@ -413,7 +436,7 @@ class OrderAnalysisSampleTable(tables.Table): ) has_pcr = tables.BooleanColumn( - verbose_name="Has PCR", + verbose_name="PCR", orderable=True, yesno="✔,-", default=False, @@ -428,16 +451,26 @@ class OrderAnalysisSampleTable(tables.Table): accessor="is_analysed", ) is_outputted = tables.BooleanColumn( - verbose_name="Is Outputted", + verbose_name="Output", orderable=True, yesno="✔,-", default=False, accessor="is_outputted", ) + sample__internal_note = tables.TemplateColumn( + template_name="staff/note_input_column.html", + orderable=False, + attrs={ + "td": { + "class": "relative", + }, + }, + ) + class Meta: model = SampleMarkerAnalysis - fields = [ + fields = ( "checked", "sample__genlab_id", "sample__type", @@ -447,13 +480,15 @@ class Meta: "is_outputted", "sample__internal_note", "sample__order", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Samples" def render_checked(self, record: SampleMarkerAnalysis) -> str: - return mark_safe( # noqa: S308 - f'' # noqa: E501 + return format_html( + '', + record.order.id, + record.id, ) @@ -466,13 +501,13 @@ class PlateTable(tables.Table): class Meta: model = ExtractionPlate - fields = [ + fields = ( "id", "name", "created_at", "last_updated_at", "samples_count", - ] + ) attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Plates" @@ -527,19 +562,26 @@ def get_order_id_link(record: Sample) -> str: def render_order__id(self, value: int, record: Sample) -> str: return str(record.order) + markers = tables.ManyToManyColumn( + accessor="markers", + verbose_name="Markers", + orderable=False, + ) + class Meta(SampleBaseTable.Meta): - fields = SampleBaseTable.Meta.fields + [ + fields = SampleBaseTable.Meta.fields + ( "order__id", "order__status", "order__genrequest__project", - ] - sequence = SampleBaseTable.Meta.sequence + [ + ) # type: ignore[assignment] + sequence = SampleBaseTable.Meta.sequence + ( "sample_status", + "markers", "order__id", "order__status", "notes", - ] - exclude = ["plate_positions", "checked", "is_prioritised"] + ) # type: ignore[assignment] + exclude = ("plate_positions", "checked", "is_prioritised") class UrgentOrderTable(StaffIDMixinTable, OrderStatusMixinTable): @@ -562,9 +604,27 @@ class UrgentOrderTable(StaffIDMixinTable, OrderStatusMixinTable): empty_values=(), ) + status = tables.Column( + orderable=False, + ) + + assigned_staff = tables.TemplateColumn( + template_name="staff/components/responsible_staff_column.html", + verbose_name="Assigned staff", + orderable=False, + empty_values=(), + ) + class Meta: model = Order - fields = ["priority", "id", "description", "delivery_date", "status"] + fields = ( + "priority", + "id", + "description", + "delivery_date", + "status", + "assigned_staff", + ) empty_text = "No urgent orders" template_name = "django_tables2/tailwind_inner.html" @@ -609,7 +669,7 @@ def render_samples(self, value: int) -> str: class Meta: model = Order - fields = ["id", "description", "delivery_date", "samples", "markers", "seen"] + fields = ("id", "description", "delivery_date", "samples", "markers", "seen") empty_text = "No new unseen orders" template_name = "django_tables2/tailwind_inner.html" @@ -651,16 +711,24 @@ def render_samples(self, value: int) -> str: transform=lambda x: x.name, ) + assigned_staff = tables.TemplateColumn( + template_name="staff/components/responsible_staff_column.html", + verbose_name="Assigned staff", + orderable=False, + empty_values=(), + ) + class Meta: model = Order - fields = [ + fields = ( "priority", "id", "description", "delivery_date", "markers", "samples", - ] + "assigned_staff", + ) empty_text = "No new seen orders" template_name = "django_tables2/tailwind_inner.html" @@ -668,6 +736,12 @@ class Meta: class AssignedOrderTable(OrderStatusMixinTable, PriorityMixinTable, StaffIDMixinTable): sticky_header = True + priority = tables.TemplateColumn( + orderable=False, + verbose_name="Priority", + template_name="staff/components/priority_column.html", + ) + description = tables.Column( accessor="genrequest__name", verbose_name="Description", @@ -685,9 +759,13 @@ def render_samples_completed(self, value: int, record: Order) -> str: return str(record.isolated_sample_count) + " / " + str(value) return "-" + status = tables.Column( + orderable=False, + ) + class Meta: model = Order - fields = ["priority", "id", "description", "samples_completed", "status"] + fields = ("priority", "id", "description", "samples_completed", "status") empty_text = "No assigned orders" order_by = ["-priority", "status"] template_name = "django_tables2/tailwind_inner.html" @@ -704,23 +782,23 @@ class DraftOrderTable(StaffIDMixinTable): contact_person = tables.Column( accessor="contact_person", - verbose_name="Contact Person", + verbose_name="Genetic researcher", orderable=False, ) contact_email = tables.Column( accessor="contact_email", - verbose_name="Contact Email", + verbose_name="Genetic researcher email", orderable=False, ) class Meta: model = Order - fields = [ + fields = ( "id", "description", "contact_person", "contact_email", - ] + ) empty_text = "No draft orders" template_name = "django_tables2/tailwind_inner.html" diff --git a/src/staff/templates/staff/analysisorder_detail.html b/src/staff/templates/staff/analysisorder_detail.html index 91f4c877..1b22cdae 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -7,43 +7,24 @@

Order {{ object }}

- Back - Samples - + {% responsible_staff_multiselect order=object %} +
+ {% 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="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} + {% endif %} {% if extraction_orders|length == 1 and not extraction_has_multiple_analysis_orders %} Go to {{ extraction_orders.first }} {% endif %} - -
- + Samples {% if not object.is_seen %}
{% 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="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} - {% endif %} - - {% 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-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 %} -
-
diff --git a/src/staff/templates/staff/analysisorder_filter.html b/src/staff/templates/staff/analysisorder_filter.html index 4db95d15..a542925e 100644 --- a/src/staff/templates/staff/analysisorder_filter.html +++ b/src/staff/templates/staff/analysisorder_filter.html @@ -7,14 +7,6 @@ {% endblock page-title %} {% block page-inner %} -
-
- {{ filter.form | crispy }} - - Clear Filters -
-
- +{% filtering filter=filter request=request %} {% render_table table %} - {% endblock page-inner %} diff --git a/src/staff/templates/staff/base.html b/src/staff/templates/staff/base.html index 80cb99de..0486d3be 100644 --- a/src/staff/templates/staff/base.html +++ b/src/staff/templates/staff/base.html @@ -1,20 +1,41 @@ {% extends "base.html" %} {% block main %} -
-
+
+ -
+
{% if messages %} {% for message in messages %}
diff --git a/src/staff/templates/staff/components/activation_toggle_column.html b/src/staff/templates/staff/components/activation_toggle_column.html new file mode 100644 index 00000000..cee1158e --- /dev/null +++ b/src/staff/templates/staff/components/activation_toggle_column.html @@ -0,0 +1,6 @@ +{% url 'staff:projects-archive' pk=record.pk as archive_url %} +{% if record.active %} + {% action-button action=archive_url class="btn btn-sm bg-[#D2E4F5] w-[8rem]" submit_text=" Archive" csrf_token=csrf_token %} +{% else %} + {% action-button action=archive_url class="btn btn-sm bg-[#D2E4F5] w-[8rem]" submit_text=" Activate" csrf_token=csrf_token %} +{% endif %} diff --git a/src/staff/templates/staff/components/extraction_tabs.html b/src/staff/templates/staff/components/extraction_tabs.html index 6f42200e..31dd5816 100644 --- a/src/staff/templates/staff/components/extraction_tabs.html +++ b/src/staff/templates/staff/components/extraction_tabs.html @@ -1,15 +1,15 @@
- diff --git a/src/staff/templates/staff/components/order_table.html b/src/staff/templates/staff/components/order_table.html index 80e40b88..4c190a9b 100644 --- a/src/staff/templates/staff/components/order_table.html +++ b/src/staff/templates/staff/components/order_table.html @@ -1,8 +1,8 @@ {% load django_tables2 %} -
+

{{ title }} ({{ count }})

-
+
{% render_table table %}
diff --git a/src/staff/templates/staff/components/priority_column.html b/src/staff/templates/staff/components/priority_column.html index 0ff60ea2..b599b4ea 100644 --- a/src/staff/templates/staff/components/priority_column.html +++ b/src/staff/templates/staff/components/priority_column.html @@ -1,7 +1,8 @@ {% load next_input %} +{% load static %} {% if record.is_urgent %} - + Urgent {% else %}
{% csrf_token %} @@ -12,11 +13,11 @@ + {% else %} + {% comment %} Default django true checkmark {% endcomment %} + +{% endif %} diff --git a/src/staff/templates/staff/components/responsible_staff_column.html b/src/staff/templates/staff/components/responsible_staff_column.html new file mode 100644 index 00000000..5ec975b7 --- /dev/null +++ b/src/staff/templates/staff/components/responsible_staff_column.html @@ -0,0 +1,3 @@ +{% load order_tags %} + +{% responsible_staff_multiselect order=record %} diff --git a/src/staff/templates/staff/components/responsible_staff_multiselect.html b/src/staff/templates/staff/components/responsible_staff_multiselect.html new file mode 100644 index 00000000..d5e8ad3b --- /dev/null +++ b/src/staff/templates/staff/components/responsible_staff_multiselect.html @@ -0,0 +1,12 @@ +{% load static %} + +{% block body_javascript %} + + + {{ form.media }} +{% endblock %} + + + {% csrf_token %} + {{ form }} +
diff --git a/src/staff/templates/staff/components/seen_column.html b/src/staff/templates/staff/components/seen_column.html index 8b8dd9c3..85c11ce1 100644 --- a/src/staff/templates/staff/components/seen_column.html +++ b/src/staff/templates/staff/components/seen_column.html @@ -1,7 +1,7 @@ -{% load order_tags %} -
{% csrf_token %} - +
diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 2be9f882..7361df8f 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -9,13 +9,13 @@
All {% for area in areas %} {{ area.name }} diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index 061dee90..a04344d0 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -1,5 +1,6 @@ {% extends "staff/base.html" %} {% load i18n %} +{% load order_tags %} {% block content %} @@ -9,12 +10,35 @@ {% #table-cell header=True %}Qty{% /table-cell %} {% endfragment %} -

Order {{ object }}

+

Order {{ object }}

- Back + {% responsible_staff_multiselect order=object %} +
+ + {% if not object.is_seen %} +
+ {% csrf_token %} + +
+ {% endif %} + + {% 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-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 %}
- {% object-detail object=object %} + {% object-detail-staff object=object %}
Requested Equipment
@@ -31,18 +55,4 @@
Requested Equipment
{% endfor %} {% /table %} - -
- Back - Assign staff - {% comment %} - {% if object.status == 'draft' %} - 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 - {% endif %} - {% endcomment %} -
{% endblock %} diff --git a/src/staff/templates/staff/equipmentorder_filter.html b/src/staff/templates/staff/equipmentorder_filter.html index 372f35b9..952e8140 100644 --- a/src/staff/templates/staff/equipmentorder_filter.html +++ b/src/staff/templates/staff/equipmentorder_filter.html @@ -7,14 +7,7 @@ {% endblock page-title %} {% block page-inner %} -
-
- {{ filter.form | crispy }} - - Clear Filters -
-
- +{% filtering filter=filter request=request %} {% render_table table %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/extractionorder_detail.html b/src/staff/templates/staff/extractionorder_detail.html index f132f1fa..ec7ffdf3 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -8,26 +8,26 @@

Order {{ object }}

- Back - Samples - + {% responsible_staff_multiselect order=object %} +
+ {% 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="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} + {% endif %} {% if analysis_orders|length == 1 and not analysis_has_multiple_extraction_orders %} Go to {{ analysis_orders.first}} {% endif %} -
+ Samples {% if not object.is_seen %}
{% 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="custom_order_button" submit_text=" Convert to draft"|safe csrf_token=csrf_token %} - {% endif %} - {% 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 %} @@ -35,9 +35,6 @@

Order {{ object }}

{% endwith %} {% endif %}
-
diff --git a/src/staff/templates/staff/extractionorder_filter.html b/src/staff/templates/staff/extractionorder_filter.html index d6849d29..7f8f80b0 100644 --- a/src/staff/templates/staff/extractionorder_filter.html +++ b/src/staff/templates/staff/extractionorder_filter.html @@ -7,14 +7,7 @@ {% endblock page-title %} {% block page-inner %} -
-
- {{ filter.form | crispy }} - - Clear Filters -
-
- +{% filtering filter=filter request=request %} {% render_table table %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/extractionplate_detail.html b/src/staff/templates/staff/extractionplate_detail.html index e688e864..38f6fb67 100644 --- a/src/staff/templates/staff/extractionplate_detail.html +++ b/src/staff/templates/staff/extractionplate_detail.html @@ -7,7 +7,7 @@

Plate {{ object }}

- {% object-detail object=object %} + {% object-detail-staff object=object %}
Back diff --git a/src/staff/templates/staff/extractionplate_filter.html b/src/staff/templates/staff/extractionplate_filter.html index c8ed54ab..b0bcb7ba 100644 --- a/src/staff/templates/staff/extractionplate_filter.html +++ b/src/staff/templates/staff/extractionplate_filter.html @@ -11,13 +11,6 @@ Create
-
-
- {{ filter.form | crispy }} -
- - Clear Filters -
- +{% filtering filter=filter request=request %} {% render_table table %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/note_input_column.html b/src/staff/templates/staff/note_input_column.html index 63255bff..4f50f727 100644 --- a/src/staff/templates/staff/note_input_column.html +++ b/src/staff/templates/staff/note_input_column.html @@ -1,19 +1,20 @@ +
- +>{{ record.internal_note|default:'' }} + - + +
diff --git a/src/staff/templates/staff/project_detail.html b/src/staff/templates/staff/project_detail.html index 4aa8eb2a..99c977fd 100644 --- a/src/staff/templates/staff/project_detail.html +++ b/src/staff/templates/staff/project_detail.html @@ -1,6 +1,6 @@ {% extends "staff/base.html" %} {% load i18n %} - +{% load next_input %} {% block content %}

Project {{ object }}

@@ -9,11 +9,16 @@

Project {{ object }}

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 %} +
+ {% csrf_token %} + {% next_url_input %} + {% if not object.verified_at %} + {% endif %}
- {% object-detail object=object %} + {% object-detail-staff object=object %} {% endblock %} diff --git a/src/staff/templates/staff/project_filter.html b/src/staff/templates/staff/project_filter.html index afc3319e..49a5860c 100644 --- a/src/staff/templates/staff/project_filter.html +++ b/src/staff/templates/staff/project_filter.html @@ -7,14 +7,7 @@ {% endblock page-title %} {% block page-inner %} - -
- {{ filter.form | crispy }} - - Clear Filters -
- - +{% filtering filter=filter request=request %} {% render_table table %} {% endblock page-inner %} diff --git a/src/staff/templates/staff/sample_detail.html b/src/staff/templates/staff/sample_detail.html index 477ba47d..6eb55d3f 100644 --- a/src/staff/templates/staff/sample_detail.html +++ b/src/staff/templates/staff/sample_detail.html @@ -7,7 +7,7 @@

Sample {{ object }}

- {% object-detail object=object %} + {% object-detail-staff object=object %}
Back diff --git a/src/staff/templates/staff/sample_filter.html b/src/staff/templates/staff/sample_filter.html index b2b94506..db087c4e 100644 --- a/src/staff/templates/staff/sample_filter.html +++ b/src/staff/templates/staff/sample_filter.html @@ -4,10 +4,13 @@ {% load next_input %} {% block page-title %} +
+ Back +
{% if order %} - {{ order }} - Samples + Samples {{ order }} {% else %} Samples {% endif %} @@ -17,19 +20,11 @@ {% block page-inner %} {% if order %} -
- Back -
- {% include "staff/components/extraction_tabs.html" with order=order active_tab="ordered" %} -
-
- {{ filter.form | crispy }} - - Clear Filters -
-
+ {% filtering filter=filter request=request %} + +
{% csrf_token %} @@ -46,7 +41,7 @@ {{ order.filled_genlab_count }} / {{ order.samples.count }} samples with genlabID
-
+
@@ -56,37 +51,9 @@ {% else %} - -
- {{ filter.form | crispy }} - -
- +{% filtering filter=filter request=request %} {% render_table table %} {% endif %} - - - - {% endblock %} diff --git a/src/staff/templates/staff/sample_lab.html b/src/staff/templates/staff/sample_lab.html index 781e6054..d27e8601 100644 --- a/src/staff/templates/staff/sample_lab.html +++ b/src/staff/templates/staff/sample_lab.html @@ -4,27 +4,22 @@ {% load next_input %} {% block content %} -

{% block page-title %}{% if order %}{{ order }} - Samples{% else %}Samples{% endif %}{% endblock page-title %}

+
+ Back +
+

{% block page-title %}{% if order %}Samples {{ order }}{% else %}Samples{% endif %}{% endblock page-title %}

{% block page-inner %} -
- Back -
- {% include "staff/components/extraction_tabs.html" with order=order active_tab="lab" %} -
-
- {{ filter.form | crispy }} - - Clear Filters -
-
+ {% filtering filter=filter request=request %} + +
{% csrf_token %} {% next_url_input %} {% for status in statuses %} - {% endfor %} @@ -32,7 +27,7 @@

{% block page-title %}{% if order %}{{ order }} - Samp - Clear Filters -

- +

{% block page-title %}{% if order %}Samples {{ order }}{% else %}Samples{% endif %}{% endblock page-title %}

+{% block page-inner %} +{% filtering filter=filter request=request %} +
{% csrf_token %} {% next_url_input %} {% for status in statuses %} - {% endfor %} - - {% render_table table %} +
+ {% render_table table %} +
+{% endblock page-inner %} - -{% endblock page-inner %} +{% endblock content %} diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index d09fff73..173d8d99 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -1,10 +1,18 @@ +import uuid from collections import Counter from django import template from django.db import models +from django.utils.html import format_html_join from django.utils.safestring import mark_safe -from genlab_bestilling.models import Area, Order +from genlab_bestilling.models import ( + AnalysisOrder, + AnalysisOrderResultsCommunication, + Area, + Order, +) +from staff.forms import ResponsibleStaffForm from ..tables import ( AssignedOrderTable, @@ -18,13 +26,26 @@ register = template.Library() +@register.inclusion_tag("staff/components/responsible_staff_multiselect.html") +def responsible_staff_multiselect(order: Order | None = None) -> dict: + prefix = f"order_{order.id}" if order else f"new_{uuid.uuid4().hex[:8]}" + + # Add prefix to the form to avoid conflicts with other forms on the page + form = ResponsibleStaffForm(order=order, prefix=prefix) + return { + "form": form, + "order": order, + } + + def generate_order_links(orders: list) -> str: if not orders: return "-" - links = [ - f'{order}' for order in orders - ] - return mark_safe(", ".join(links)) # noqa: S308 + return format_html_join( + ", ", + "{}", + ((order.get_absolute_staff_url(), str(order)) for order in orders), + ) def render_boolean(value: bool) -> str: @@ -209,6 +230,7 @@ def assigned_orders_table(context: dict) -> dict: ), ) .order_by( + "-priority", models.Case( models.When(status=Order.OrderStatus.PROCESSING, then=0), models.When(status=Order.OrderStatus.DELIVERED, then=1), @@ -216,7 +238,6 @@ def assigned_orders_table(context: dict) -> dict: default=3, output_field=models.IntegerField(), ), - "-priority", "-created_at", ) ) @@ -267,7 +288,7 @@ def extraction_order_detail_table(order: Order) -> dict: fields = { "Order ID": order.id, "Genetic Project": order.genrequest, - "Species": order.species.name, + "Species": ", ".join(str(s) for s in order.species.all()), "Status": render_status_helper(order.status), "Name": order.name, "Notes": "-" if order.notes == "" else order.notes, @@ -307,7 +328,7 @@ def analysis_order_detail_table(order: Order) -> dict: "Confirmed at": order.confirmed_at.strftime("%d.%m.%Y") if order.confirmed_at else "Not confirmed", - "Expected delivery date": order.expected_delivery_date.strftime("%d.%m.%Y") + "Deadline": order.expected_delivery_date.strftime("%d.%m.%Y") if order.expected_delivery_date else "Not specified", } @@ -316,21 +337,26 @@ def analysis_order_detail_table(order: Order) -> dict: @register.inclusion_tag("../templates/components/order-detail.html") def analysis_order_samples_detail_table(order: Order, extraction_orders: dict) -> dict: - # Generate links for extraction orders with sample counts - extraction_order_links = [ - f"{generate_order_links([extraction_order])} ({count} sample{'s' if count != 1 else ''})" # noqa: E501 - for extraction_order, count in extraction_orders.items() - ] + extraction_order_links = format_html_join( + "
", + "{} ({})", + ( + ( + generate_order_links([extraction_order]), + f"{count} sample{'s' if count > 1 else ''}", + ) + for extraction_order, count in extraction_orders.items() + ), + ) fields = { "Number of samples": order.samples.count(), "Markers": ", ".join(marker.name for marker in order.markers.all()) if order.markers.exists() else "No markers", - "Samples from extraction order": mark_safe("
".join(extraction_order_links)) # noqa: S308 - if extraction_order_links - else "-", + "Samples from extraction order": extraction_order_links or "-", } + return { "fields": fields, "header": "Samples", @@ -339,11 +365,30 @@ def analysis_order_samples_detail_table(order: Order, extraction_orders: dict) - @register.inclusion_tag("../templates/components/order-detail.html") def contact_detail_table(order: Order) -> dict: + # Default values + result_contacts_html = "—" + + # Only fetch contacts if it's an AnalysisOrder instance + if isinstance(order, AnalysisOrder): + result_contacts = ( + AnalysisOrderResultsCommunication.objects.filter(analysis_order=order) + .values_list("contact_person_results", "contact_email_results") + .distinct() + ) + if result_contacts: + result_contacts_html = format_html_join( + "\n", + '
{} — {}
', # noqa: E501 + [(name, email, email) for name, email in result_contacts], + ) + fields = { "Samples owner of genetic project": order.genrequest.samples_owner, - "Contact person": order.contact_person, - "Contact Email": order.contact_email, + "Responsible genetic researcher": order.contact_person, + "Responsible genetic researcher email": order.contact_email, + "Contact name and email for analysis results": result_contacts_html, } + return { "fields": fields, "header": "Contact", diff --git a/src/staff/urls.py b/src/staff/urls.py index f977135b..f356eb0c 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -1,5 +1,7 @@ from django.urls import path +from staff.api import OrderAPIView + from .views import ( AnalysisOrderDetailView, AnalysisOrderListView, @@ -18,6 +20,7 @@ OrderPrioritizedAdminView, OrderToDraftActionView, OrderToNextStatusActionView, + ProjectArchiveActionView, ProjectDetailView, ProjectListView, ProjectValidateActionView, @@ -40,6 +43,11 @@ ProjectValidateActionView.as_view(), name="projects-verify", ), + path( + "projects//archive/", + ProjectArchiveActionView.as_view(), + name="projects-archive", + ), path( "orders/analysis/", AnalysisOrderListView.as_view(), name="order-analysis-list" ), @@ -148,4 +156,9 @@ OrderPrioritizedAdminView.as_view(), name="order-priority", ), + path( + "orders//assign-staff/", + OrderAPIView.as_view(), + name="order-assign-staff", + ), ] diff --git a/src/staff/views.py b/src/staff/views.py index 982a10d7..8aeb4025 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -3,7 +3,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db import models -from django.db.models import Count, QuerySet +from django.db.models import Count, Prefetch, QuerySet from django.forms import Form from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, redirect @@ -23,6 +23,7 @@ ExtractionPlate, Genrequest, IsolationMethod, + Marker, Order, Sample, SampleIsolationMethod, @@ -35,9 +36,11 @@ from .filters import ( AnalysisOrderFilter, + EquipmentOrderFilter, ExtractionOrderFilter, ExtractionPlateFilter, OrderSampleFilter, + ProjectFilter, SampleFilter, SampleLabFilter, SampleMarkerOrderFilter, @@ -113,6 +116,7 @@ def get_queryset(self) -> QuerySet[AnalysisOrder]: "genrequest__project", "genrequest__area", ) + .prefetch_related("samples__species") .annotate(total_samples=Count("samples")) ) @@ -162,7 +166,7 @@ def get_queryset(self) -> QuerySet[ExtractionPlate]: class EqupimentOrderListView(StaffMixin, SingleTableMixin, FilterView): model = EquipmentOrder table_class = EquipmentOrderTable - filterset_class = AnalysisOrderFilter + filterset_class = EquipmentOrderFilter table_pagination = {"per_page": 20} def get_queryset(self) -> QuerySet[EquipmentOrder]: @@ -251,6 +255,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: try: order = self.get_object() order.toggle_seen() + order.update_status() messages.success(request, _("Order is marked as seen")) except Exception as e: @@ -269,6 +274,10 @@ def get_return_url(self, return_to: str | None) -> str: class ExtractionOrderDetailView(StaffMixin, DetailView): model = ExtractionOrder + # Prefetch species to avoid N+1 queries when accessing species in the template + def get_queryset(self) -> QuerySet[ExtractionOrder]: + return super().get_queryset().prefetch_related("species") + def get_analysis_orders_for_samples( self, samples: QuerySet[Sample] ) -> QuerySet[AnalysisOrder]: @@ -435,7 +444,7 @@ def statuses_with_lower_or_equal_priority(self, status_name: str) -> list[str]: def assign_status_to_samples( self, - analyses: models.QuerySet, + analyses: QuerySet[SampleMarkerAnalysis], status_name: str, request: HttpRequest, ) -> None: @@ -481,15 +490,17 @@ def assign_status_to_samples( # Checks if all samples in the order have output # If they are, it updates the order status to completed - def check_all_output(self, analyses: models.QuerySet) -> None: + def check_all_output(self, analyses: QuerySet[SampleMarkerAnalysis]) -> None: + order = self.get_order() + if not analyses.filter(is_outputted=False).exists(): - self.get_order().to_next_status() + order.to_completed() messages.success( self.request, "All samples have an output. The order status is updated to completed.", ) - elif self.get_order().status == Order.OrderStatus.COMPLETED: - self.get_order().to_processing() + elif order.status in (Order.OrderStatus.COMPLETED, Order.OrderStatus.DELIVERED): + order.to_processing() messages.success( self.request, "Not all samples have output. The order status is updated to processing.", # noqa: E501 @@ -518,6 +529,9 @@ def get_queryset(self) -> QuerySet[Sample]: .prefetch_related( "plate_positions", "order__responsible_staff", + Prefetch( + "markers", queryset=Marker.objects.order_by("name").distinct() + ), ) .exclude(order__status=Order.OrderStatus.DRAFT) .order_by("species__name", "year", "location__name", "name") @@ -549,12 +563,14 @@ def get_order(self) -> ExtractionOrder: return self._order def get_queryset(self) -> QuerySet[Sample]: - return Sample.objects.filter(order=self.get_order(), genlab_id__isnull=False) + return Sample.objects.filter( + order=self.get_order(), genlab_id__isnull=False + ).prefetch_related("order", "type", "isolation_method") def get_isolation_methods(self) -> QuerySet[IsolationMethod, str]: types = self.get_queryset().values_list("type", flat=True).distinct() return ( - IsolationMethod.objects.filter(type__in=types) + IsolationMethod.objects.filter(sample_types__in=types) .values_list("name", flat=True) .distinct() ) @@ -641,7 +657,7 @@ def assign_status_to_samples( # If they are, it updates the order status to completed def check_all_isolated(self, samples: QuerySet) -> None: if not samples.filter(is_isolated=False).exists(): - self.get_order().to_next_status() + self.get_order().to_completed() messages.success( self.request, "All samples are isolated. The order status is updated to completed.", @@ -940,41 +956,65 @@ def form_invalid(self, form: Form) -> HttpResponse: class ProjectListView(StaffMixin, SingleTableMixin, FilterView): model = Project table_class = ProjectTable - filterset_fields = { - "number": ["startswith"], - "name": ["startswith"], - "verified_at": ["isnull"], - "active": ["exact"], - } + filterset_class = ProjectFilter class ProjectDetailView(StaffMixin, DetailView): model = Project -class ProjectValidateActionView(SingleObjectMixin, ActionView): +class ProjectValidateActionView(SingleObjectMixin, SafeRedirectMixin, ActionView): model = Project def get_queryset(self) -> QuerySet[Project]: return super().get_queryset().filter(verified_at=None) + def form_valid(self, form: Form) -> HttpResponse: + self.object = self.get_object() + self.object.verified_at = now() + self.object.save() + messages.add_message( + self.request, + messages.SUCCESS, + _("The project is verified"), + ) + + return super().form_valid(form) + + def get_success_url(self) -> str: + return self.get_next_url() + + def get_fallback_url(self) -> str: + return self.request.GET.get("next", reverse("staff:projects-list")) + + def form_invalid(self, form: Form) -> HttpResponse: + return HttpResponseRedirect(self.get_next_url()) + + +class ProjectArchiveActionView(SingleObjectMixin, ActionView): + model = Project + + def get_queryset(self) -> QuerySet[Project]: + return Project.objects.all() + 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: - self.object.verified_at = now() + # Toggle the active state of the project + self.object.active = not self.object.active self.object.save() + status = _("activated") if self.object.active else _("archived") messages.add_message( self.request, messages.SUCCESS, - _("The project is verified"), + _(f"The project is {status}"), ) - return super().form_valid(form) def get_success_url(self) -> str: - return reverse_lazy("staff:projects-detail", kwargs={"pk": self.object.pk}) + return reverse("staff:projects-list") def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) diff --git a/src/static/images/exclaimation_mark.svg b/src/static/images/exclaimation_mark.svg new file mode 100644 index 00000000..25b1688f --- /dev/null +++ b/src/static/images/exclaimation_mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/static/staff/js/responsible-staff-form.js b/src/static/staff/js/responsible-staff-form.js new file mode 100644 index 00000000..dae3812f --- /dev/null +++ b/src/static/staff/js/responsible-staff-form.js @@ -0,0 +1,112 @@ +function initResponsibleStaffForm() { + const forms = document.querySelectorAll(".responsible-staff-form"); + + forms.forEach(function (form) { + const select = form.querySelector("select"); + const updateUrl = form.dataset.updateUrl; + + if (!select || !updateUrl) { + console.warn("Responsible staff form missing select or update URL"); + return; + } + + let currentAbortController = null; + + $(select).on("change", function () { + const selectedUserIds = $(this).val() || []; + + // Cancel previous request if it exists + if (currentAbortController) { + currentAbortController.abort(); + } + + // Create new abort controller for this request + currentAbortController = new AbortController(); + + statusIndicator.showSpinner(form); + + updateAssignedStaff( + updateUrl, + selectedUserIds, + currentAbortController.signal + ) + .then(function (response) { + if (!response.ok) { + throw new Error("Could not update responsible staff"); + } + + statusIndicator.showSuccess(form); + + setTimeout(() => statusIndicator.hide(form), 2000); + + currentAbortController = null; + }) + .catch(function (error) { + // Only handle non-abort errors + if (error.name !== "AbortError") { + statusIndicator.showError(form); + + // Reload the page to reflect server-side changes + setTimeout(() => { + window.location.reload(); + }, 500); + } + + currentAbortController = null; + }); + }); + }); +} + +async function updateAssignedStaff(updateUrl, userIds, signal) { + const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value; + + if (!csrfToken) { + throw new Error("CSRF token not found"); + } + + return await fetch(updateUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + body: JSON.stringify({ + user_ids: userIds, + }), + signal: signal, + }); +} + +const statusIndicator = { + hide: function (form) { + const existing = form.querySelector(".staff-status-indicator"); + if (existing) { + existing.remove(); + } + }, + + show: function (form, type, icon) { + this.hide(form); + const indicator = document.createElement("div"); + indicator.className = `staff-status-indicator ${type}`; + indicator.innerHTML = ``; + form.appendChild(indicator); + }, + + showSpinner: function (form) { + this.show(form, "spinner", "fa-spinner fa-spin"); + }, + + showSuccess: function (form) { + this.show(form, "success", "fa-check"); + }, + + showError: function (form) { + this.show(form, "error", "fa-times"); + }, +}; + +document.addEventListener("DOMContentLoaded", function () { + initResponsibleStaffForm(); +}); diff --git a/src/templates/components.yaml b/src/templates/components.yaml index c7e7ae87..a20667f7 100644 --- a/src/templates/components.yaml +++ b/src/templates/components.yaml @@ -3,4 +3,6 @@ components: table-cell: "components/table-cell.html" formset: "components/formset.html" object-detail: "components/object-detail.html" + object-detail-staff: "components/object-detail-staff.html" action-button: "components/action-button.html" + filtering: "components/filtering.html" diff --git a/src/templates/components/filtering.html b/src/templates/components/filtering.html new file mode 100644 index 00000000..8502ada0 --- /dev/null +++ b/src/templates/components/filtering.html @@ -0,0 +1,17 @@ +{% load crispy_forms_tags static %} + +
+
+ Filters +
+
+ {{ filter.form | crispy }} +
+ + Clear all +
+
+
diff --git a/src/templates/components/object-detail-staff.html b/src/templates/components/object-detail-staff.html new file mode 100644 index 00000000..ec98a596 --- /dev/null +++ b/src/templates/components/object-detail-staff.html @@ -0,0 +1,20 @@ +{% load core %} + +{% fragment as headers %} + {% #table-cell header=True %}Key{% /table-cell %} + {% #table-cell header=True %}Value{% /table-cell %} +{% endfragment %} + +{% var fields=fields %} +{% var object=object %} + +
+ {% #table headers=headers %} + {% for f, v in object|get_fields_staff:fields %} + + {% #table-cell %}{{ f|capfirst }}{% /table-cell %} + {% #table-cell %}{{ v }}{% /table-cell %} + + {% endfor %} + {% /table %} +
diff --git a/src/templates/components/object-detail.html b/src/templates/components/object-detail.html index f1b75f46..05600816 100644 --- a/src/templates/components/object-detail.html +++ b/src/templates/components/object-detail.html @@ -12,7 +12,7 @@ {% #table headers=headers %} {% for f, v in object|get_fields:fields %} - {% #table-cell %}{{ f }}{% /table-cell %} + {% #table-cell %}{{ f|capfirst }}{% /table-cell %} {% #table-cell %}{{ v }}{% /table-cell %} {% endfor %} diff --git a/src/templates/core/partials/navigation.html b/src/templates/core/partials/navigation.html index c7bd16f2..d436729b 100644 --- a/src/templates/core/partials/navigation.html +++ b/src/templates/core/partials/navigation.html @@ -1,7 +1,7 @@ {% load i18n static %}
li + ::before, ol.breadcrumb > li + ::before { content: ""; @@ -284,43 +290,153 @@ ol.breadcrumb > li + ::before { .custom_order_button { background-color: #F2F2F2; /* Fill */ border: 1px solid #BABABA; /* Stroke */ - color: #4A4A4A; /* Text & icon color */ + color: #000000; /* Text & icon color */ } .custom_order_button:hover { background-color: #e0e0e0; /* Optional: darker on hover */ - border: 1px solid #A0A0A0; /* Stroke */ + border: 1px solid #a0a0a0; /* Stroke */ +} + +.custom_order_button:active { + background-color: #D6D6D6; /* Optional: darker on hover */ + border: 1px solid #8C8C8C; /* Stroke */ +} + +.custom_order_button:active { + background-color: #D6D6D6; /* Optional: darker on hover */ + border: 1px solid #8C8C8C; /* Stroke */ } .custom_order_button_blue { background-color: #E6F0FA; /* Fill */ border: 1px solid #8CA5BE; /* Stroke */ - color: #0F3D6A; /* Text & icon color */ + color: #000000; /* Text & icon color */ } .custom_order_button_blue:hover { - background-color: #D2E4F5; /* Optional: darker on hover */ - border: 1px solid #7792AB; /* Stroke */ + background-color: #d2e4f5; /* Optional: darker on hover */ + border: 1px solid #7792ab; /* Stroke */ +} + +.custom_order_button_blue:active { + background-color: #BFD8F0; /* Optional: darker on hover */ + border: 1px solid #5F7D95; /* Stroke */ +} + +.custom_order_button_dark_blue { + background-color: #C9E4FF; /* Fill */ + border: 1px solid #6891BA; /* Stroke */ + color: #000000; /* Text & icon color */ +} + +.custom_order_button_dark_blue:hover { + background-color: #A7D3FF; /* Optional: darker on hover */ + border: 1px solid #6891BA; /* Stroke */ +} + +.custom_order_button_dark_blue:active { + background-color: #91C8FF; /* Optional: darker on hover */ + border: 1px solid #437AB2; /* Stroke */ +} + +.custom_order_button_blue:active { + background-color: #BFD8F0; /* Optional: darker on hover */ + border: 1px solid #5F7D95; /* Stroke */ +} + +.custom_order_button_dark_blue { + background-color: #C9E4FF; /* Fill */ + border: 1px solid #6891BA; /* Stroke */ + color: #000000; /* Text & icon color */ +} + +.custom_order_button_dark_blue:hover { + background-color: #A7D3FF; /* Optional: darker on hover */ + border: 1px solid #6891BA; /* Stroke */ +} + +.custom_order_button_dark_blue:active { + background-color: #91C8FF; /* Optional: darker on hover */ + border: 1px solid #437AB2; /* Stroke */ } .custom_order_button_green { background-color: #DFF7CA; /* Fill */ border: 1px solid #93AD7D; /* Stroke */ - color: #001D3A; /* Text & icon color */ + color: #000000; /* Text & icon color */ } .custom_order_button_green:hover { - background-color: #C9EBB0; /* Optional: darker on hover */ - border: 1px solid #7F996C; /* Stroke */ + background-color: #c9ebb0; /* Optional: darker on hover */ + border: 1px solid #7f996c; /* Stroke */ +} + +.custom_order_button_green:active { + background-color: #B5DA9C; /* Optional: darker on hover */ + border: 1px solid #6B855B; /* Stroke */ +} + +.custom_order_button_green:active { + background-color: #B5DA9C; /* Optional: darker on hover */ + border: 1px solid #6B855B; /* Stroke */ } .custom_order_button_red { background-color: #f7caca; /* Fill */ border: 1px solid #ad7d7d; /* Stroke */ - color: #3a0000; /* Text & icon color */ + color: #000000; /* Text & icon color */ } .custom_order_button_red:hover { background-color: #ebb0b0; /* Optional: darker on hover */ border: 1px solid #996c6c; /* Stroke */ } + +.custom_order_button_red:active { + background-color: #FFA3A3; /* Optional: darker on hover */ + border: 1px solid #B96363; /* Stroke */ +} + +.responsible-staff-form { + position: relative; +} + +.staff-status-indicator { + position: absolute; + top: 8px; + left: 8px; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.staff-status-indicator.spinner { + color: #6b7280; +} + +.staff-status-indicator.success { + color: #22c55e; +} + +.staff-status-indicator.error { + color: #ef4444; +} + +.responsible-staff-form .select2-container.select2-container--default { + max-width: 11rem !important; + width: 11rem !important; + min-width: 11rem !important; +} + +.responsible-staff-form .select2-dropdown { + max-width: 11rem !important; + width: 11rem !important; +} + +.responsible-staff-form .select2-selection { + max-width: 11rem !important; + width: 11rem !important; + min-width: 11rem !important; +}