diff --git a/aliases.sh b/aliases.sh index 9169e27d..b6c140d1 100755 --- a/aliases.sh +++ b/aliases.sh @@ -6,6 +6,8 @@ alias dpcli_prod="docker compose --profile prod" alias djcli_dev="docker compose exec -it django-dev ./src/manage.py" alias djcli_prod="docker compose exec -it django ./src/manage.py" +alias run-mypy="uv run mypy ." + alias deps-sync="uv sync" alias deps-sync-prod="uv sync --profile prod --no-dev" alias deps-outdated="uv tree --outdated --depth 1" diff --git a/src/fixtures/users.json b/src/fixtures/users.json index 52c65687..9a12421a 100644 --- a/src/fixtures/users.json +++ b/src/fixtures/users.json @@ -15,5 +15,41 @@ "groups": [1], "user_permissions": [] } + }, + { + "model": "users.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$180000$10jDVElGx6nr$0o2RbVZhcE/BAHG6pCvAX4DI8V4mjIHgnN0pdNOKkr8=", + "last_login": "2023-05-27T09:22:46.831Z", + "is_superuser": false, + "first_name": "Kari", + "last_name": "Nordmann", + "email": "kari.nordmann@norge.no", + "is_staff": true, + "is_active": true, + "date_joined": "2023-05-27T08:35:08.845Z", + "groups": [ + 1 + ], + "user_permissions": [] } - ] + }, + { + "model": "users.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$180000$10jDVElGx6nr$0o2RbVZhcE/BAHG6pCvAX4DI8V4mjIHgnN0pdNOKkr8=", + "last_login": "2023-05-27T09:22:46.831Z", + "is_superuser": false, + "first_name": "Ola", + "last_name": "Nordmann", + "email": "ola.nordmann@norge.no", + "is_staff": true, + "is_active": true, + "date_joined": "2023-05-27T08:35:08.845Z", + "groups": [], + "user_permissions": [] + } + } +] diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 3fe0c062..a9a7839f 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -15,12 +15,14 @@ ExtractionPlate, ExtractPlatePosition, Genrequest, + IsolationMethod, Location, LocationType, Marker, Organization, Sample, SampleMarkerAnalysis, + SampleStatusAssignment, SampleType, Species, ) @@ -200,9 +202,18 @@ class EquipmentTypeAdmin(ModelAdmin): @admin.register(EquipmentBuffer) class EquipmentBufferAdmin(ModelAdmin): - list_display = ["name", "unit"] - list_filter = ["unit"] - search_fields = ["name"] + M = EquipmentBuffer + list_display = [M.name.field.name, M.unit.field.name] + + search_help_text = "Search for equipment buffer name" + search_fields = [M.name.field.name] + + list_filter = [ + (M.name.field.name, unfold_filters.FieldTextFilter), + (M.unit.field.name, unfold_filters.FieldTextFilter), + ] + list_filter_submit = True + list_filter_sheet = False @admin.register(EquimentOrderQuantity) @@ -513,3 +524,11 @@ class AnalysisResultAdmin(ModelAdmin): M.last_modified_at.field.name, M.created_at.field.name, ] + + +@admin.register(SampleStatusAssignment) +class SampleStatusAssignmentAdmin(ModelAdmin): ... + + +@admin.register(IsolationMethod) +class IsolationMethodAdmin(ModelAdmin): ... diff --git a/src/genlab_bestilling/libs/genlabid.py b/src/genlab_bestilling/libs/genlabid.py deleted file mode 100644 index 32af0235..00000000 --- a/src/genlab_bestilling/libs/genlabid.py +++ /dev/null @@ -1,196 +0,0 @@ -from typing import Any - -import sqlglot -import sqlglot.expressions -from django.db import connection, transaction -from sqlglot.expressions import ( - EQ, - Alias, - Cast, - DataType, - Extract, - From, - Literal, - Subquery, - and_, - column, -) - -from ..models import ExtractionOrder, Order, Sample, Species - - -def get_replica_for_sample() -> None: - """ - TODO: implement - """ - pass - - -def get_current_sequences(order_id: int | str) -> Any: - """ - Invoke a Postgres function to get the current sequence number - for a specific combination of year and species. - """ - samples = ( - Sample.objects.select_related("order", "species") - .filter(order=order_id) - .values("order__created_at", "species__code") - .distinct() - ) - - with connection.cursor() as cursor: - sequences = {} - for sample in samples: - query = sqlglot.select( - sqlglot.expressions.func( - "get_genlab_sequence_name", - sqlglot.expressions.Literal.string(sample["species__code"]), - sqlglot.expressions.Literal.number( - sample["order__created_at"].year - ), - dialect="postgres", - ) - ).sql(dialect="postgres") - seq = cursor.execute( - query, - ).fetchone()[0] - sequences[seq] = 0 - - for k in sequences.keys(): - query = sqlglot.select("last_value").from_(k).sql(dialect="postgres") - sequences[k] = cursor.execute(query).fetchone()[0] - - return sequences - - -def generate(order_id: int | str) -> None: - """ - wrapper to handle errors and reset the sequence to the current sequence value - """ - sequences = get_current_sequences(order_id) - print(sequences) - - with connection.cursor() as cursor: - try: - with transaction.atomic(): - cursor.execute(update_genlab_id_query(order_id)) - except Exception: - # if there is an error, reset the sequence - # NOTE: this is unsafe unless this function is executed in a queue - # by just one worker to prevent concurrency - with connection.cursor() as cursor: # noqa: PLW2901 # Was this intentional? - for k, v in sequences.items(): - cursor.execute("SELECT setval(%s, %s)", [k, v]) - - sequences = get_current_sequences(order_id) - print(sequences) - - -def update_genlab_id_query(order_id: int | str) -> Any: - """ - Safe generation of a SQL raw query using sqlglot - The query runs an update on all the rows with a specific order_id - and set genlab_id = generate_genlab_id(code, year) - """ - samples_table = Sample._meta.db_table - extraction_order_table = ExtractionOrder._meta.db_table - order_table = Order._meta.db_table - species_table = Species._meta.db_table - - return sqlglot.expressions.update( - samples_table, - properties={ - "genlab_id": column( - "genlab_id", - table="order_samples", - ) - }, - where=sqlglot.expressions.EQ( - this=column("id", table=samples_table), - expression="order_samples.id", - ), - from_=From( - this=Alias( - this=Subquery( - this=( - sqlglot.select( - column( - "id", - table=samples_table, - ), - Alias( - this=sqlglot.expressions.func( - "generate_genlab_id", - column("code", table=species_table), - Cast( - this=Extract( - this="YEAR", - expression=column( - "confirmed_at", - table=order_table, - ), - ), - to=DataType( - this=DataType.Type.INT, nested=False - ), - ), - ), - alias="genlab_id", - ), - ) - .join( - extraction_order_table, - on=EQ( - this=column( - "order_id", - table=samples_table, - ), - expression=column( - "order_ptr_id", - table=extraction_order_table, - ), - ), - ) - .join( - order_table, - on=EQ( - this=column( - "order_ptr_id", - table=extraction_order_table, - ), - expression=column( - "id", - table=order_table, - ), - ), - ) - .from_(samples_table) - .join( - species_table, - on=EQ( - this=column( - "species_id", - table=samples_table, - ), - expression=column( - "id", - table=species_table, - ), - ), - ) - .where( - and_( - EQ( - this=column(col="order_id"), - expression=Literal.number(order_id), - ), - column(col="genlab_id").is_(None), - ) - ) - .order_by(column("name", table=samples_table)) - ), - ), - alias="order_samples", - ) - ), - ).sql(dialect="postgres") diff --git a/src/genlab_bestilling/managers.py b/src/genlab_bestilling/managers.py index b5ffc099..9b2b2319 100644 --- a/src/genlab_bestilling/managers.py +++ b/src/genlab_bestilling/managers.py @@ -1,9 +1,17 @@ -from django.db import models +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db import models, transaction from django.db.models import QuerySet +from django.db.models.expressions import RawSQL from polymorphic.managers import PolymorphicManager, PolymorphicQuerySet from capps.users.models import User +if TYPE_CHECKING: + from .models import GIDSequence, Species + class GenrequestQuerySet(models.QuerySet): def filter_allowed(self, user: User) -> QuerySet: @@ -46,6 +54,9 @@ def filter_in_draft(self) -> QuerySet: ) +DEFAULT_SORTING_FIELDS = ["name_as_int", "name"] + + class SampleQuerySet(models.QuerySet): def filter_allowed(self, user: User) -> QuerySet: """ @@ -61,6 +72,48 @@ def filter_in_draft(self) -> QuerySet: order__status=self.model.OrderStatus.DRAFT ) + @transaction.atomic + def generate_genlab_ids( + self, + order_id: int, + sorting_order: list[str] | None = DEFAULT_SORTING_FIELDS, + selected_samples: list[int] | None = None, + ) -> None: + """ + genlab ids given a certain order_id, sorting order and sample ids + + """ + + # Lock the samples + samples = ( + self.select_related("species") + .filter(order_id=order_id, genlab_id__isnull=True) + .select_for_update() + ) + + if selected_samples: + samples = samples.filter(id__in=selected_samples) + + if sorting_order == DEFAULT_SORTING_FIELDS: + # create an annotation containg all integer values + # of "name", so that it's possible to sort numerically and alphabetically + samples = samples.annotate( + name_as_int=RawSQL( + r"substring(%s from '^\d+$')::int", + params=["name"], + output_field=models.IntegerField(), + ) + ).order_by(*sorting_order) + else: + samples = samples.order_by(*sorting_order) + + updates = [] + for sample in samples: + sample.generate_genlab_id(commit=False) + updates.append(sample) + + self.bulk_update(updates, ["genlab_id"]) + class SampleAnalysisMarkerQuerySet(models.QuerySet): def filter_allowed(self, user: User) -> QuerySet: @@ -76,3 +129,23 @@ def filter_in_draft(self) -> QuerySet: return self.select_related("order").filter( order__status=self.model.OrderStatus.DRAFT ) + + +class GIDSequenceQuerySet(models.QuerySet): + def get_sequence_for_species_year( + self, species: Species, year: int, lock: bool = False + ) -> GIDSequence: + """ + Get or creates an ID sequence based on the sample year and species + """ + if lock: + s = self.select_for_update() + else: + s = self + + sequence_id, _ = s.get_or_create( + year=year, + species=species, + defaults={"id": f"G{year % 100}{species.code}"}, + ) + return sequence_id diff --git a/src/genlab_bestilling/migrations/0017_order_responsible_staff.py b/src/genlab_bestilling/migrations/0017_order_responsible_staff.py new file mode 100644 index 00000000..fb1c2054 --- /dev/null +++ b/src/genlab_bestilling/migrations/0017_order_responsible_staff.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.3 on 2025-07-02 06:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0016_alter_equimentorderquantity_options"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="responsible_staff", + field=models.ManyToManyField( + help_text="Staff members responsible for this order", + related_name="responsible_orders", + to=settings.AUTH_USER_MODEL, + verbose_name="Responsible staff", + ), + ), + ] diff --git a/src/genlab_bestilling/migrations/0018_alter_analysisorder_expected_delivery_date.py b/src/genlab_bestilling/migrations/0018_alter_analysisorder_expected_delivery_date.py new file mode 100644 index 00000000..3bb52ca6 --- /dev/null +++ b/src/genlab_bestilling/migrations/0018_alter_analysisorder_expected_delivery_date.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.3 on 2025-07-03 08:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0017_order_responsible_staff"), + ] + + operations = [ + migrations.AlterField( + model_name="analysisorder", + name="expected_delivery_date", + field=models.DateField( + blank=True, + help_text="When you need to get the results", + null=True, + verbose_name="Requested analysis result deadline", + ), + ), + ] diff --git a/src/genlab_bestilling/migrations/0019_isolationmethod_samplestatus_samplestatusassignment_and_more.py b/src/genlab_bestilling/migrations/0019_isolationmethod_samplestatus_samplestatusassignment_and_more.py new file mode 100644 index 00000000..79cdcff0 --- /dev/null +++ b/src/genlab_bestilling/migrations/0019_isolationmethod_samplestatus_samplestatusassignment_and_more.py @@ -0,0 +1,107 @@ +# Generated by Django 5.2.3 on 2025-07-03 08:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0018_alter_analysisorder_expected_delivery_date"), + ] + + operations = [ + migrations.CreateModel( + name="IsolationMethod", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ], + ), + migrations.CreateModel( + name="SampleStatus", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("weight", models.IntegerField(default=0)), + ( + "area", + models.ForeignKey( + help_text="The area this status is related to.", + on_delete=django.db.models.deletion.CASCADE, + related_name="area_statuses", + to="genlab_bestilling.area", + ), + ), + ], + ), + migrations.CreateModel( + name="SampleStatusAssignment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("assigned_at", models.DateTimeField(auto_now_add=True)), + ( + "order", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sample_status_assignments", + to="genlab_bestilling.order", + ), + ), + ( + "sample", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sample_status_assignments", + to="genlab_bestilling.sample", + ), + ), + ( + "status", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="status_assignments", + to="genlab_bestilling.samplestatus", + ), + ), + ], + options={ + "unique_together": {("sample", "status", "order")}, + }, + ), + migrations.AddField( + model_name="sample", + name="assigned_statuses", + field=models.ManyToManyField( + blank=True, + related_name="samples", + through="genlab_bestilling.SampleStatusAssignment", + to="genlab_bestilling.samplestatus", + ), + ), + ] diff --git a/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py b/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py new file mode 100644 index 00000000..c9208b96 --- /dev/null +++ b/src/genlab_bestilling/migrations/0020_sample_is_prioritised.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.3 on 2025-07-07 09:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "genlab_bestilling", + "0019_isolationmethod_samplestatus_samplestatusassignment_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="sample", + name="is_prioritised", + field=models.BooleanField( + default=False, + help_text="Check this box if the sample is prioritised for processing", + ), + ), + ] diff --git a/src/genlab_bestilling/migrations/0021_order_is_prioritized_order_is_seen.py b/src/genlab_bestilling/migrations/0021_order_is_prioritized_order_is_seen.py new file mode 100644 index 00000000..a42ed453 --- /dev/null +++ b/src/genlab_bestilling/migrations/0021_order_is_prioritized_order_is_seen.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.3 on 2025-07-07 11:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0020_sample_is_prioritised"), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="is_prioritized", + field=models.BooleanField( + default=False, help_text="If an order should be prioritized internally" + ), + ), + migrations.AddField( + model_name="order", + name="is_seen", + field=models.BooleanField( + default=False, help_text="If an order has been seen by a staff" + ), + ), + ] diff --git a/src/genlab_bestilling/migrations/0022_sample_internal_note.py b/src/genlab_bestilling/migrations/0022_sample_internal_note.py new file mode 100644 index 00000000..48249fe7 --- /dev/null +++ b/src/genlab_bestilling/migrations/0022_sample_internal_note.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-07-07 13:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0021_order_is_prioritized_order_is_seen"), + ] + + operations = [ + migrations.AddField( + model_name="sample", + name="internal_note", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/src/genlab_bestilling/migrations/0023_alter_order_responsible_staff.py b/src/genlab_bestilling/migrations/0023_alter_order_responsible_staff.py new file mode 100644 index 00000000..0848c783 --- /dev/null +++ b/src/genlab_bestilling/migrations/0023_alter_order_responsible_staff.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-07-08 10:19 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0022_sample_internal_note"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="responsible_staff", + field=models.ManyToManyField( + blank=True, + help_text="Staff members responsible for this order", + related_name="responsible_orders", + to=settings.AUTH_USER_MODEL, + verbose_name="Responsible staff", + ), + ), + ] diff --git a/src/genlab_bestilling/migrations/0024_gidsequence.py b/src/genlab_bestilling/migrations/0024_gidsequence.py new file mode 100644 index 00000000..dffb7cee --- /dev/null +++ b/src/genlab_bestilling/migrations/0024_gidsequence.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.3 on 2025-07-09 13:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0023_alter_order_responsible_staff"), + ] + + operations = [ + migrations.CreateModel( + name="GIDSequence", + fields=[ + ("id", models.CharField(primary_key=True, serialize=False)), + ("last_value", models.IntegerField(default=0)), + ("year", models.IntegerField()), + ( + "sample", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="replica_sequence", + to="genlab_bestilling.sample", + ), + ), + ( + "species", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.PROTECT, + to="genlab_bestilling.species", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("year", "species"), name="unique_id_year_species" + ) + ], + }, + ), + ] diff --git a/src/genlab_bestilling/migrations/0025_genrequest_responsible_staff.py b/src/genlab_bestilling/migrations/0025_genrequest_responsible_staff.py new file mode 100644 index 00000000..f8f3bf40 --- /dev/null +++ b/src/genlab_bestilling/migrations/0025_genrequest_responsible_staff.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-07-11 08:39 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0024_gidsequence"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="genrequest", + name="responsible_staff", + field=models.ManyToManyField( + blank=True, + help_text="Staff members responsible for this order", + related_name="responsible_genrequest", + to=settings.AUTH_USER_MODEL, + verbose_name="Responsible staff", + ), + ), + ] diff --git a/src/genlab_bestilling/migrations/0026_alter_samplestatusassignment_status_and_more.py b/src/genlab_bestilling/migrations/0026_alter_samplestatusassignment_status_and_more.py new file mode 100644 index 00000000..8ce2f0a6 --- /dev/null +++ b/src/genlab_bestilling/migrations/0026_alter_samplestatusassignment_status_and_more.py @@ -0,0 +1,90 @@ +# Generated by Django 5.2.3 on 2025-07-11 10:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0025_genrequest_responsible_staff"), + ] + + operations = [ + migrations.AlterField( + model_name="samplestatusassignment", + name="status", + field=models.CharField( + blank=True, + choices=[ + ("marked", "Marked"), + ("plucked", "Plucked"), + ("isolated", "Isolated"), + ], + help_text="The status of the sample in the lab", + null=True, + verbose_name="Sample status", + ), + ), + migrations.RemoveField( + model_name="sample", + name="assigned_statuses", + ), + migrations.AddField( + model_name="isolationmethod", + name="species", + field=models.ForeignKey( + default=None, + help_text="The species this isolation method is related to.", + on_delete=django.db.models.deletion.CASCADE, + related_name="species_isolation_methods", + to="genlab_bestilling.species", + ), + ), + migrations.CreateModel( + name="SampleIsolationMethod", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "isolation_method", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sample_isolation_methods", + to="genlab_bestilling.isolationmethod", + ), + ), + ( + "sample", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="isolation_methods", + to="genlab_bestilling.sample", + ), + ), + ], + options={ + "unique_together": {("sample", "isolation_method")}, + }, + ), + migrations.AddField( + model_name="sample", + name="isolation_method", + field=models.ManyToManyField( + blank=True, + help_text="The isolation method used for this sample", + related_name="samples", + through="genlab_bestilling.SampleIsolationMethod", + to="genlab_bestilling.isolationmethod", + ), + ), + migrations.DeleteModel( + name="SampleStatus", + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index e15d85b9..8211e20f 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -1,17 +1,20 @@ import uuid from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from django.conf import settings from django.db import models, transaction +from django.db.models import QuerySet from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from polymorphic.models import PolymorphicModel -from procrastinate.contrib.django import app from rest_framework.exceptions import ValidationError from taggit.managers import TaggableManager +if TYPE_CHECKING: + from .models import Sample + from . import managers from .libs.helpers import position_to_coordinates @@ -198,6 +201,13 @@ class Genrequest(models.Model): # type: ignore[django-manager-missing] help_text="samples you plan to deliver, you can choose more than one. " + "ONLY sample types selected here will be available later", ) + responsible_staff = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name="responsible_genrequest", + verbose_name="Responsible staff", + help_text="Staff members responsible for this order", + blank=True, + ) markers = models.ManyToManyField(f"{an}.Marker", blank=True) created_at = models.DateTimeField(auto_now_add=True) last_modified_at = models.DateTimeField(auto_now=True) @@ -214,6 +224,9 @@ def get_absolute_url(self) -> str: kwargs={"pk": self.pk}, ) + def get_type(self) -> str: + return "genrequest" + @property def short_timeframe(self) -> bool: return ( @@ -239,6 +252,11 @@ class OrderStatus(models.TextChoices): # COMPLETED: Order has been completed, and results are available. COMPLETED = "completed", _("Completed") + class OrderPriority: + URGENT = 3 + PRIORITIZED = 2 + NORMAL = 1 + STATUS_ORDER = ( OrderStatus.DRAFT, OrderStatus.DELIVERED, @@ -271,6 +289,19 @@ class OrderStatus(models.TextChoices): blank=True, help_text="Email to contact with questions about this order", ) + responsible_staff = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name="responsible_orders", + verbose_name="Responsible staff", + help_text="Staff members responsible for this order", + blank=True, + ) + is_seen = models.BooleanField( + default=False, help_text="If an order has been seen by a staff" + ) + is_prioritized = models.BooleanField( + default=False, help_text="If an order should be prioritized internally" + ) tags = TaggableManager(blank=True) objects = managers.OrderManager() @@ -292,9 +323,21 @@ def to_draft(self) -> None: self.confirmed_at = None self.save() + def toggle_seen(self) -> None: + self.is_seen = not self.is_seen + self.save() + + def toggle_prioritized(self) -> None: + self.is_prioritized = not self.is_prioritized + self.save() + def get_type(self) -> str: return "order" + @property + def filled_genlab_count(self) -> int: + return self.samples.filter(genlab_id__isnull=False).count() + @property def next_status(self) -> OrderStatus | None: current_index = self.STATUS_ORDER.index(self.status) @@ -376,6 +419,9 @@ def get_absolute_url(self) -> str: kwargs={"pk": self.pk, "genrequest_id": self.genrequest_id}, ) + def get_absolute_staff_url(self) -> str: + return reverse("staff:order-equipment-detail", kwargs={"pk": self.pk}) + def confirm_order(self) -> Any: if not EquimentOrderQuantity.objects.filter(order=self).exists(): raise Order.CannotConfirm(_("No equipments found")) @@ -414,6 +460,9 @@ def get_absolute_url(self) -> str: kwargs={"pk": self.pk, "genrequest_id": self.genrequest_id}, ) + def get_absolute_staff_url(self) -> str: + return reverse("staff:order-extraction-detail", kwargs={"pk": self.pk}) + def clone(self) -> None: """ Generates a clone of the model, with a different ID @@ -452,8 +501,30 @@ def order_manually_checked(self) -> None: """ self.internal_status = self.Status.CHECKED self.status = self.OrderStatus.PROCESSING - self.save() - app.configure_task(name="generate-genlab-ids").defer(order_id=self.id) + self.save(update_fields=["internal_status", "status"]) + + @transaction.atomic + def order_selected_checked( + self, + sorting_order: list[str] | None = None, + selected_samples: QuerySet["Sample"] | None = None, + ) -> None: + """ + Partially set the order as checked by the lab staff, + generate a genlab id for the samples selected + """ + self.internal_status = self.Status.CHECKED + self.status = self.OrderStatus.PROCESSING + self.save(update_fields=["internal_status", "status"]) + + if not selected_samples.exists(): + return + + Sample.objects.generate_genlab_ids( + order_id=self.id, + sorting_order=sorting_order, + selected_samples=selected_samples, + ) class AnalysisOrder(Order): @@ -472,6 +543,8 @@ class AnalysisOrder(Order): expected_delivery_date = models.DateField( null=True, blank=True, + verbose_name="Requested analysis result deadline", + help_text="When you need to get the results", ) @property @@ -491,6 +564,9 @@ def get_absolute_url(self) -> str: kwargs={"pk": self.pk, "genrequest_id": self.genrequest_id}, ) + def get_absolute_staff_url(self) -> str: + return reverse("staff:order-analysis-detail", kwargs={"pk": self.pk}) + def get_type(self) -> str: return "analysis" @@ -569,6 +645,9 @@ class Sample(models.Model): species = models.ForeignKey(f"{an}.Species", on_delete=models.PROTECT) year = models.IntegerField() notes = models.TextField(null=True, blank=True) + + # "Merknad" in the Excel sheet. + internal_note = models.TextField(null=True, blank=True) pop_id = models.CharField(max_length=150, null=True, blank=True) location = models.ForeignKey( f"{an}.Location", on_delete=models.PROTECT, null=True, blank=True @@ -579,7 +658,19 @@ class Sample(models.Model): extractions = models.ManyToManyField(f"{an}.ExtractionPlate", blank=True) parent = models.ForeignKey("self", on_delete=models.PROTECT, null=True, blank=True) + isolation_method = models.ManyToManyField( + f"{an}.IsolationMethod", + related_name="samples", + through=f"{an}.SampleIsolationMethod", + blank=True, + help_text="The isolation method used for this sample", + ) + objects = managers.SampleQuerySet.as_manager() + is_prioritised = models.BooleanField( + default=False, + help_text="Check this box if the sample is prioritised for processing", + ) def __str__(self) -> str: return self.genlab_id or f"#SMP_{self.id}" @@ -635,14 +726,16 @@ def has_error(self) -> bool: "GUID, Sample Name, Sample Type, Species and Year are required" ) - if self.order.genrequest.area.location_mandatory: # type: ignore[union-attr] # FIXME: Order can be None. + # type: ignore[union-attr] # FIXME: Order can be None. + if self.order.genrequest.area.location_mandatory: if not self.location_id: raise ValidationError("Location is required") # ensure that location is correct for the selected species elif ( self.species.location_type and self.species.location_type_id - not in self.location.types.values_list("id", flat=True) # type: ignore[union-attr] # FIXME: Order can be None. + # type: ignore[union-attr] # FIXME: Order can be None. + not in self.location.types.values_list("id", flat=True) ): raise ValidationError("Invalid location for the selected species") elif self.location_id and self.species.location_type_id: @@ -657,6 +750,23 @@ def has_error(self) -> bool: return False + @transaction.atomic + def generate_genlab_id(self, commit: bool = True) -> str: + if self.genlab_id: + return self.genlab_id + species = self.species + year = self.order.confirmed_at.year + + sequence = GIDSequence.objects.get_sequence_for_species_year( + year=year, species=species, lock=True + ) + self.genlab_id = sequence.next_value() + + if commit: + self.save(update_fields=["genlab_id"]) + + return self.genlab_id + # class Analysis(models.Model): # type = @@ -668,6 +778,68 @@ def has_error(self) -> bool: # assignee (one or plus?) +class SampleStatusAssignment(models.Model): + class SampleStatus(models.TextChoices): + MARKED = "marked", _("Marked") + PLUCKED = "plucked", _("Plucked") + ISOLATED = "isolated", _("Isolated") + + sample = models.ForeignKey( + f"{an}.Sample", + on_delete=models.CASCADE, + related_name="sample_status_assignments", + ) + status = models.CharField( + choices=SampleStatus.choices, + null=True, + blank=True, + verbose_name="Sample status", + help_text="The status of the sample in the lab", + ) + order = models.ForeignKey( + f"{an}.Order", + on_delete=models.CASCADE, + related_name="sample_status_assignments", + null=True, + blank=True, + ) + + assigned_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("sample", "status", "order") + + +class SampleIsolationMethod(models.Model): + sample = models.ForeignKey( + f"{an}.Sample", + on_delete=models.CASCADE, + related_name="isolation_methods", + ) + isolation_method = models.ForeignKey( + f"{an}.IsolationMethod", + on_delete=models.CASCADE, + related_name="sample_isolation_methods", + ) + + class Meta: + unique_together = ("sample", "isolation_method") + + +class IsolationMethod(models.Model): + name = models.CharField(max_length=255, unique=True) + species = models.ForeignKey( + f"{an}.Species", + on_delete=models.CASCADE, + related_name="species_isolation_methods", + help_text="The species this isolation method is related to.", + default=None, + ) + + def __str__(self) -> str: + return self.name + + # Some extracts can be placed in multiple wells class ExtractPlatePosition(models.Model): plate = models.ForeignKey( @@ -726,3 +898,49 @@ class AnalysisResult(models.Model): def __str__(self) -> str: return f"{self.name}" + + +class GIDSequence(models.Model): + """ + Represents a sequence of IDs + This table provides a way to atomically update + the counter of each combination of (species, year) + + NOTE: this replaces the usage of postgres sequences, + while probably slower, this table can be atomically updated + """ + + id = models.CharField(primary_key=True) + last_value = models.IntegerField(default=0) + year = models.IntegerField() + species = models.ForeignKey( + f"{an}.Species", on_delete=models.PROTECT, db_constraint=False + ) + sample = models.OneToOneField( + f"{an}.Sample", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="replica_sequence", + ) + + def __str__(self): + return f"{self.id}@{self.last_value}" + + class Meta: + constraints = [ + models.UniqueConstraint( + name="unique_id_year_species", fields=["year", "species"] + ), + ] + + objects = managers.GIDSequenceQuerySet.as_manager() + + @transaction.atomic() + def next_value(self) -> str: + """ + Update the last_value transactionally and return the corresponding genlab_id + """ + self.last_value += 1 + self.save(update_fields=["last_value"]) + return f"{self.id}{self.last_value:05d}" diff --git a/src/genlab_bestilling/tasks.py b/src/genlab_bestilling/tasks.py deleted file mode 100644 index 607f301b..00000000 --- a/src/genlab_bestilling/tasks.py +++ /dev/null @@ -1,17 +0,0 @@ -# from .libs.isolation import isolate -from django.db.utils import OperationalError -from procrastinate import RetryStrategy -from procrastinate.contrib.django import app - -from .libs.genlabid import generate as generate_genlab_id - - -@app.task( - name="generate-genlab-ids", - retry=RetryStrategy( - max_attempts=5, linear_wait=5, retry_exceptions={OperationalError} - ), -) -def generate_ids(order_id: str | int) -> None: - generate_genlab_id(order_id=order_id) - # isolate(order_id=order_id) diff --git a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html index 71a042b5..191aefc5 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html @@ -51,6 +51,13 @@

{{ object.project_id }} - {{ object.name|d href="{% url 'genrequest-delete' pk=genrequest.id %}" > Delete + + {% if user.is_staff %} + + + Assign staff + + {% endif %} {% endblock %} diff --git a/src/genlab_bestilling/tests/test_models.py b/src/genlab_bestilling/tests/test_models.py index e76eb1b3..6e1dd7e3 100644 --- a/src/genlab_bestilling/tests/test_models.py +++ b/src/genlab_bestilling/tests/test_models.py @@ -1,4 +1,17 @@ -from genlab_bestilling.models import AnalysisOrder, Marker +import itertools +import uuid + +import pytest +from django.db import IntegrityError, transaction +from pytest_django.asserts import assertQuerySetEqual + +from genlab_bestilling.models import ( + AnalysisOrder, + ExtractionOrder, + GIDSequence, + Marker, + Sample, +) def test_analysis_populate_without_order(genlab_setup): @@ -43,3 +56,266 @@ def test_analysis_populate_with_order_all_markers(extraction): ao.markers.add(*m) ao.populate_from_order() assert ao.sample_markers.count() == 6 + + +def test_gid_sequence_for_species_year(extraction): + extraction.confirm_order() + assert GIDSequence.objects.exists() is False + sample = extraction.samples.first() + gid = GIDSequence.objects.get_sequence_for_species_year( + year=extraction.confirmed_at.year, species=sample.species, lock=False + ) + assert gid.id == f"G{extraction.confirmed_at.year % 100}{sample.species.code}" + assert gid.last_value == 0 + + +def test_gid_sequence_increment(extraction): + """ + Test the increment of the sequence + """ + extraction.confirm_order() + assert GIDSequence.objects.exists() is False + sample = extraction.samples.first() + + with transaction.atomic(): + gid = GIDSequence.objects.get_sequence_for_species_year( + year=extraction.confirmed_at.year, species=sample.species + ) + assert gid.id == f"G{extraction.confirmed_at.year % 100}{sample.species.code}" + assert gid.last_value == 0 + assert ( + gid.next_value() + == f"G{extraction.confirmed_at.year % 100}{sample.species.code}00001" + ) + assert gid.last_value == 1 + + +def test_gid_sequence_rollback(extraction): + """ + Test the rollback of the sequence + """ + extraction.confirm_order() + assert GIDSequence.objects.exists() is False + sample = extraction.samples.first() + gid = GIDSequence.objects.get_sequence_for_species_year( + year=extraction.confirmed_at.year, species=sample.species + ) + with pytest.raises(IntegrityError): + with transaction.atomic(): + gid = GIDSequence.objects.select_for_update().get(id=gid.id) + assert ( + gid.id == f"G{extraction.confirmed_at.year % 100}{sample.species.code}" + ) + assert gid.last_value == 0 + assert ( + gid.next_value() + == f"G{extraction.confirmed_at.year % 100}{sample.species.code}00001" + ) + assert gid.last_value == 1 + + # Expect an error here + GIDSequence.objects.create( + id=gid.id, + year=gid.year, + species=gid.species, + ) + + gid.refresh_from_db() + assert gid.last_value == 0 + + +def test_sample_genlab_id_generation(extraction): + """ + Test the generation of the id for a single sample + """ + extraction.confirm_order() + sample = extraction.samples.first() + assert ( + sample.generate_genlab_id() + == f"G{extraction.confirmed_at.year % 100}{sample.species.code}00001" + ) + + +def test_full_order_ids_generation(extraction): + """ + Test that by default all the ids are generated + """ + extraction.confirm_order() + + Sample.objects.generate_genlab_ids(extraction.id) + + assertQuerySetEqual( + Sample.objects.filter(genlab_id__isnull=False), + Sample.objects.all(), + ordered=False, + ) + + +def test_order_selected_ids_generation(extraction): + """ + Test that is possible to generate only a subset of ids + """ + extraction.confirm_order() + + Sample.objects.generate_genlab_ids( + extraction.id, + selected_samples=extraction.samples.all().values("id")[ + : extraction.samples.count() - 1 + ], + ) + + assert ( + Sample.objects.filter(genlab_id__isnull=False).count() + 1 + == Sample.objects.all().count() + ) + + +def test_ids_generation_with_only_numeric_names(genlab_setup): + """ + Test that by default the ordering is done on the column name + ordering first the integers, and then all the other rows alphabetically + """ + extraction = ExtractionOrder.objects.create( + genrequest_id=1, + return_samples=False, + pre_isolated=False, + ) + extraction.species.add(*extraction.genrequest.species.all()) + extraction.sample_types.add(*extraction.genrequest.sample_types.all()) + + combo = list( + itertools.product(extraction.species.all(), extraction.sample_types.all()) + ) + year = 2020 + + s1 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name=10, + ) + + s2 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name=1, + ) + + s3 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name="20b", + ) + + s4 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name="20a", + ) + + extraction.confirm_order() + + Sample.objects.generate_genlab_ids(order_id=extraction.id) + + gid = GIDSequence.objects.get_sequence_for_species_year( + species=combo[0][0], year=extraction.confirmed_at.year, lock=False + ) + + s1.refresh_from_db() + s2.refresh_from_db() + s3.refresh_from_db() + s4.refresh_from_db() + + assert s1.genlab_id == gid.id + "00002" + assert s2.genlab_id == gid.id + "00001" + assert s3.genlab_id == gid.id + "00004" + assert s4.genlab_id == gid.id + "00003" + + +def test_ids_generation_order_by_pop_id(genlab_setup): + """ + Test that ids are can be generated using a custom order based on different fields + """ + extraction = ExtractionOrder.objects.create( + genrequest_id=1, + return_samples=False, + pre_isolated=False, + ) + extraction.species.add(*extraction.genrequest.species.all()) + extraction.sample_types.add(*extraction.genrequest.sample_types.all()) + + combo = list( + itertools.product(extraction.species.all(), extraction.sample_types.all()) + ) + year = 2020 + + s1 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name=10, + pop_id="z", + ) + + s2 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name=1, + pop_id="c", + ) + + s3 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name="20b", + pop_id="z", + ) + + s4 = Sample.objects.create( + order=extraction, + guid=uuid.uuid4(), + species=combo[0][0], + type=combo[0][1], + year=year, + name="20a", + pop_id="r", + ) + + extraction.confirm_order() + + Sample.objects.generate_genlab_ids( + order_id=extraction.id, sorting_order=["pop_id", "name"] + ) + + gid = GIDSequence.objects.get_sequence_for_species_year( + species=combo[0][0], year=extraction.confirmed_at.year, lock=False + ) + + s1.refresh_from_db() + s2.refresh_from_db() + s3.refresh_from_db() + s4.refresh_from_db() + + assert s1.genlab_id == gid.id + "00003" + assert s2.genlab_id == gid.id + "00001" + assert s3.genlab_id == gid.id + "00004" + assert s4.genlab_id == gid.id + "00002" diff --git a/src/shared/forms.py b/src/shared/forms.py index 249b3af5..90cc5f2e 100644 --- a/src/shared/forms.py +++ b/src/shared/forms.py @@ -1,12 +1,46 @@ +# ruff: noqa: ANN001, PLR0913 from django import forms class ActionForm(forms.Form): + """ + A form with an hidden field + use this as to submit forms that don't require extra parameters + or specific GET pages + based on Django standard form + + NOTE: it ignores the additional kwargs provided + """ + hidden = forms.CharField(required=False, widget=forms.widgets.HiddenInput()) def __init__( self, - *args, + data=None, + files=None, + auto_id="id_%s", + prefix=None, + initial=None, + error_class=forms.utils.ErrorList, + label_suffix=None, + empty_permitted=False, + field_order=None, + use_required_attribute=None, + renderer=None, + bound_field_class=None, **kwargs, ) -> None: - super().__init__(*args, **kwargs) + super().__init__( + data=data, + files=files, + auto_id=auto_id, + prefix=prefix, + initial=initial, + error_class=error_class, + label_suffix=label_suffix, + empty_permitted=empty_permitted, + field_order=field_order, + use_required_attribute=use_required_attribute, + renderer=renderer, + bound_field_class=bound_field_class, + ) diff --git a/src/staff/forms.py b/src/staff/forms.py index d8962025..e578fe9c 100644 --- a/src/staff/forms.py +++ b/src/staff/forms.py @@ -1,9 +1,42 @@ +from django import forms from django.forms import ModelForm +from formset.renderers.tailwind import FormRenderer -from genlab_bestilling.models import ExtractionPlate +from capps.users.models import User +from genlab_bestilling.models import ExtractionPlate, Genrequest, Order class ExtractionPlateForm(ModelForm): class Meta: model = ExtractionPlate fields = ("name",) + + +class OrderStaffForm(forms.Form): + default_renderer = FormRenderer(field_css_classes="mb-3") + + responsible_staff = forms.MultipleChoiceField( + label="Ansvarlige", + widget=forms.CheckboxSelectMultiple, + help_text="Velg hvilke ansatte som er ansvarlige for denne bestillingen.", + required=False, + ) + + def __init__(self, *args, order: Order | Genrequest | None = None, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["responsible_staff"].choices = self.get_all_staff() + + if order: + self.fields["responsible_staff"].initial = [ + user.id for user in self.get_assigned_staff(order) + ] + + def get_assigned_staff(self, order: Order | Genrequest) -> list[User]: + return list(order.responsible_staff.all()) + + def get_all_staff(self) -> list[tuple[int, str]]: + return [ + (user.id, f"{user.first_name} {user.last_name}") + for user in User.objects.filter(groups__name="genlab").all() + ] diff --git a/src/staff/tables.py b/src/staff/tables.py index ef801b1e..a9ce9222 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -1,6 +1,8 @@ from typing import Any import django_tables2 as tables +from django.db.models import IntegerField +from django.db.models.functions import Cast from django.utils.safestring import mark_safe from genlab_bestilling.models import ( @@ -8,6 +10,7 @@ EquipmentOrder, ExtractionOrder, ExtractionPlate, + Order, Sample, SampleMarkerAnalysis, ) @@ -45,6 +48,12 @@ class OrderTable(tables.Table): orderable=False, ) + is_seen = tables.Column( + orderable=False, + visible=True, + verbose_name="", + ) + class Meta: fields = [ "name", @@ -53,13 +62,13 @@ class Meta: "genrequest__name", "genrequest__project", "genrequest__area", - "genrequest__expected_total_samples", "genrequest__samples_owner", "created_at", "last_modified_at", "is_urgent", + "is_seen", ] - sequence = ("is_urgent", "status", "id") + sequence = ("is_seen", "is_urgent", "status", "id", "name") empty_text = "No Orders" order_by = ("-is_urgent", "last_modified_at", "created_at") @@ -75,6 +84,14 @@ def render_is_urgent(self, value: bool) -> str: else: return "" + def render_is_seen(self, value: bool) -> str: + if not value: + return mark_safe( + '' + ) + return "" + class AnalysisOrderTable(OrderTable): id = tables.Column( @@ -95,6 +112,12 @@ class ExtractionOrderTable(OrderTable): empty_values=(), ) + sample_count = tables.Column( + accessor="sample_count", + verbose_name="Sample Count", + orderable=False, + ) + class Meta(OrderTable.Meta): model = ExtractionOrder fields = OrderTable.Meta.fields + [ @@ -105,6 +128,10 @@ class Meta(OrderTable.Meta): "return_samples", "pre_isolated", ] + sequence = OrderTable.Meta.sequence + ("sample_count",) + + def render_sample_count(self, record: Any) -> str: + return record.sample_count or "0" class EquipmentOrderTable(OrderTable): @@ -124,6 +151,23 @@ 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"}, + }, + accessor="pk", + orderable=False, + empty_values=(), + ) + + name = tables.Column(order_by=("name_as_int",)) + class Meta: model = Sample fields = [ @@ -139,15 +183,107 @@ class Meta: "plate_positions", ] attrs = {"class": "w-full table-auto tailwind-table table-sm"} + sequence = ( + "checked", + "is_prioritised", + "genlab_id", + "guid", + "name", + "species", + "type", + ) + order_by = ( + "-is_prioritised", + "species", + "genlab_id", + "name_as_int", + ) empty_text = "No Samples" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if hasattr(self.data, "data"): + self.data.data = self.data.data.annotate( + name_as_int=Cast("name", output_field=IntegerField()) + ) + def render_plate_positions(self, value: Any) -> str: if value: return ", ".join([str(v) for v in value.all()]) return "" + def render_checked(self, record: Any) -> str: + return mark_safe(f'') # noqa: S308 + + +class SampleStatusTable(tables.Table): + """ + This shows a checkbox in the header. + To display text in the header alongside the checkbox + override the header-property in the CheckBoxColumn class. + """ + + checked = tables.CheckBoxColumn( + accessor="pk", + orderable=False, + attrs={ + "th__input": { + "id": "select-all-checkbox", + }, + "td__input": { + "name": "checked", + }, + }, + empty_values=(), + verbose_name="Mark", + ) + + internal_note = tables.TemplateColumn( + template_name="staff/note_input_column.html", orderable=False + ) + + marked = tables.BooleanColumn( + verbose_name="Marked", + orderable=True, + yesno="✔,-", + default=False, + ) + plucked = tables.BooleanColumn( + verbose_name="Plucked", + orderable=True, + yesno="✔,-", + default=False, + ) + isolated = tables.BooleanColumn( + verbose_name="Isolated", + orderable=True, + yesno="✔,-", + default=False, + ) + + class Meta: + model = Sample + fields = [ + "checked", + "genlab_id", + "internal_note", + "isolation_method", + "type", + ] + sequence = [ + "checked", + "genlab_id", + "type", + "marked", + "plucked", + "isolated", + "internal_note", + "isolation_method", + ] + class OrderExtractionSampleTable(SampleBaseTable): class Meta(SampleBaseTable.Meta): @@ -208,3 +344,250 @@ class Meta: attrs = {"class": "w-full table-auto tailwind-table table-sm"} empty_text = "No Plates" + + +class StatusMixinTable(tables.Table): + status = tables.Column( + orderable=False, + verbose_name="Status", + ) + + def render_status(self, value: Order.OrderStatus, record: Order) -> str: + status_colors = { + "Processing": "bg-yellow-100 text-yellow-800", + "Completed": "bg-green-100 text-green-800", + "Delivered": "bg-red-100 text-red-800", + } + status_text = { + "Processing": "Processing", + "Completed": "Completed", + "Delivered": "Not started", + } + color_class = status_colors.get(value, "bg-gray-100 text-gray-800") + status_text = status_text.get(value, "Unknown") + return mark_safe( # noqa: S308 + f'{status_text}' # noqa: E501 + ) + + +class StaffIDMixinTable(tables.Table): + id = tables.Column( + orderable=False, + empty_values=(), + ) + + def render_id( + self, record: ExtractionOrder | AnalysisOrder | EquipmentOrder + ) -> str: + url = record.get_absolute_staff_url() + + return mark_safe(f'{record}') # noqa: S308 + + +class UrgentOrderTable(StaffIDMixinTable, StatusMixinTable): + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + + delivery_date = tables.Column( + accessor="genrequest__expected_samples_delivery_date", + verbose_name="Delivery date", + orderable=False, + ) + + def render_delivery_date(self, value: Any) -> str: + if value: + return value.strftime("%d/%m/%Y") + return "-" + + class Meta: + model = Order + fields = ["id", "description", "delivery_date", "status"] + empty_text = "No urgent orders" + template_name = "django_tables2/tailwind_inner.html" + + +class NewUnseenOrderTable(StaffIDMixinTable): + seen = tables.TemplateColumn( + orderable=False, + verbose_name="Seen", + template_name="staff/components/seen_column.html", + empty_values=(), + ) + + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + + delivery_date = tables.Column( + accessor="genrequest__expected_samples_delivery_date", + verbose_name="Delivery date", + orderable=False, + ) + + def render_delivery_date(self, value: Any) -> str: + if value: + return value.strftime("%d/%m/%Y") + return "-" + + samples = tables.Column( + accessor="sample_count", + verbose_name="Samples", + orderable=False, + ) + + def render_samples(self, value: int) -> str: + if value > 0: + return str(value) + return "-" + + markers = tables.ManyToManyColumn( + transform=lambda x: x.name, + ) + + class Meta: + model = Order + fields = ["id", "description", "delivery_date", "samples", "markers", "seen"] + empty_text = "No new unseen orders" + template_name = "django_tables2/tailwind_inner.html" + + +class NewSeenOrderTable(StaffIDMixinTable): + priority = tables.TemplateColumn( + orderable=False, + verbose_name="Priority", + accessor="priority", + template_name="staff/components/priority_column.html", + ) + + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + + delivery_date = tables.Column( + accessor="genrequest__expected_samples_delivery_date", + verbose_name="Delivery date", + orderable=False, + ) + + def render_delivery_date(self, value: Any) -> str: + if value: + return value.strftime("%d/%m/%Y") + return "-" + + samples = tables.Column( + accessor="sample_count", + verbose_name="Samples", + orderable=False, + ) + + def render_samples(self, value: int) -> str: + if value > 0: + return str(value) + return "-" + + markers = tables.ManyToManyColumn( + transform=lambda x: x.name, + ) + + class Meta: + model = Order + fields = [ + "priority", + "id", + "description", + "delivery_date", + "markers", + "samples", + ] + empty_text = "No new seen orders" + template_name = "django_tables2/tailwind_inner.html" + + +class AssignedOrderTable(StatusMixinTable, StaffIDMixinTable): + priority = tables.TemplateColumn( + orderable=False, + verbose_name="Priority", + accessor="priority", + template_name="staff/components/priority_column.html", + ) + + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + + samples_completed = tables.Column( + accessor="sample_count", + verbose_name="Samples isolated", + orderable=False, + ) + + def render_samples_completed(self, value: int) -> str: + if value > 0: + return "- / " + str(value) + return "-" + + class Meta: + model = Order + fields = ["priority", "id", "description", "samples_completed", "status"] + empty_text = "No assigned orders" + template_name = "django_tables2/tailwind_inner.html" + + +class DraftOrderTable(StaffIDMixinTable): + priority = tables.TemplateColumn( + orderable=False, + verbose_name="Priority", + accessor="priority", + template_name="staff/components/priority_column.html", + ) + + description = tables.Column( + accessor="genrequest__name", + verbose_name="Description", + orderable=False, + ) + + delivery_date = tables.Column( + accessor="genrequest__expected_samples_delivery_date", + verbose_name="Delivery date", + orderable=False, + ) + + def render_delivery_date(self, value: Any) -> str: + if value: + return value.strftime("%d/%m/%Y") + return "-" + + samples = tables.Column( + accessor="sample_count", + verbose_name="Samples", + orderable=False, + ) + + markers = tables.ManyToManyColumn( + transform=lambda x: x.name, + verbose_name="Markers", + orderable=False, + ) + + class Meta: + model = Order + fields = [ + "priority", + "id", + "description", + "delivery_date", + "markers", + "samples", + ] + 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 521077b5..fc2ae045 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -3,6 +3,41 @@ {% block content %} +

Order {{ object }}

+
+ +
+ back + Samples + + {% if extraction_order %} + Go to {{ extraction_order}} + {% endif %} + + Assign staff + +
+ + {% if not object.is_seen %} +
+ {% csrf_token %} + +
+ {% endif %} + + {% if object.status == object.OrderStatus.DELIVERED %} + {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} + {% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} + {% endif %} + + {% if object.status != object.OrderStatus.DRAFT and object.next_status %} + {% url 'staff:order-to-next-status' pk=object.id as to_next_status_url %} + {% with "Set as "|add:object.next_status as btn_name %} + {% action-button action=to_next_status_url class="bg-secondary text-white" submit_text=btn_name csrf_token=csrf_token %} + {% endwith %} + {% endif %} +
+ {% fragment as table_header %} {% #table-cell header=True %}GUID{% /table-cell %} {% #table-cell header=True %}Type{% /table-cell %} @@ -12,9 +47,6 @@ {% #table-cell header=True %}Date{% /table-cell %} {% #table-cell header=True %}Volume{% /table-cell %} {% endfragment %} -

Order {{ object }}

-
-
{% object-detail object=object %} @@ -24,13 +56,4 @@
Samples to analyze

Selected {{ object.samples.count }} samples

-
- back - Samples - {% if object.status == object.OrderStatus.DELIVERED %} -
- {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} - {% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} - {% endif %} -
{% endblock %} diff --git a/src/staff/templates/staff/analysisorder_filter.html b/src/staff/templates/staff/analysisorder_filter.html index 0a0d6946..a0ac89a4 100644 --- a/src/staff/templates/staff/analysisorder_filter.html +++ b/src/staff/templates/staff/analysisorder_filter.html @@ -1,5 +1,19 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Analysis Orders {% endblock page-title %} + +{% block page-inner %} +
+
+ {{ filter.form | crispy }} +
+ +
+ +{% render_table table %} + +{% endblock page-inner %} diff --git a/src/staff/templates/staff/base_filter.html b/src/staff/templates/staff/base_filter.html index 66b774cf..96f0cd28 100644 --- a/src/staff/templates/staff/base_filter.html +++ b/src/staff/templates/staff/base_filter.html @@ -5,19 +5,23 @@ {% block content %}

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

{% block page-inner %}{% endblock page-inner %} - -
-
- {{ filter.form | crispy }} -
- -
- - {% render_table table %} {% endblock %} {% block body_javascript %} {{ block.super }} {{ filter.form.media }} + {% endblock body_javascript %} diff --git a/src/staff/templates/staff/components/order_table.html b/src/staff/templates/staff/components/order_table.html new file mode 100644 index 00000000..2fb0fc1b --- /dev/null +++ b/src/staff/templates/staff/components/order_table.html @@ -0,0 +1,6 @@ +{% 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 new file mode 100644 index 00000000..ce76db60 --- /dev/null +++ b/src/staff/templates/staff/components/priority_column.html @@ -0,0 +1,21 @@ +{% if value == 3 %} + +{% else %} +
+ {% csrf_token %} + + {% comment %}Outlined flag icon does not work using the tag, so also using the SVG for the filled flag icon for consistency. If it can be fixed in the future it should.{% endcomment %} + + +
+{% endif %} diff --git a/src/staff/templates/staff/components/seen_column.html b/src/staff/templates/staff/components/seen_column.html new file mode 100644 index 00000000..4e318c3c --- /dev/null +++ b/src/staff/templates/staff/components/seen_column.html @@ -0,0 +1,5 @@ +
+ {% csrf_token %} + + +
diff --git a/src/staff/templates/staff/dashboard.html b/src/staff/templates/staff/dashboard.html index 9c687b33..b57cc296 100644 --- a/src/staff/templates/staff/dashboard.html +++ b/src/staff/templates/staff/dashboard.html @@ -1,14 +1,19 @@ {% extends 'staff/base.html' %} + {% load i18n %} {% load static %} {% load tz %} - -{% block head_javascript %} - -{% endblock %} +{% load order_tags %} {% block content %}
+
+ All + {% for area in areas %} + {{ area.name }} + {% endfor %} +
+

{{ now|date:'F j, Y' }} | @@ -16,44 +21,19 @@

- {% if delivered_orders|length > 0 %} -
-

Delivered Orders

- - {% for order in delivered_orders %} - {% if order.polymorphic_ctype.model == 'analysisorder' %} -

{{ order }} - {{ order.name }}

- {% elif order.polymorphic_ctype.model == 'equipmentorder' %} -

{{ order }} - {{ order.name }}

- {% elif order.polymorphic_ctype.model == 'extractionorder' %} -

{{ order }} - {{ order.name }}

- {% else %} -

{{ order }} - {{ order.name }}

- {% endif %} - {% endfor %} +
+
+ {% urgent_orders_table area=area %} + {% new_unseen_orders_table area=area %} + {% new_seen_orders_table area=area %}
- {% endif %} - {% if urgent_orders|length > 0 %} -
-

Urgent orders

+
+ {% assigned_orders_table %} - {% for order in urgent_orders %} - {% if order.polymorphic_ctype.model == 'analysisorder' %} -

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:'-' }} - Status: {{ order.status|default:'-' }}

- {% elif order.polymorphic_ctype.model == 'equipmentorder' %} -

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:'-' }} - Status: {{ order.status|default:'-' }}

- {% elif order.polymorphic_ctype.model == 'extractionorder' %} -

{{ order }} - {{ order.name }} - Deadline: {{ order.expected_delivery_date|default:'-' }} - Status: {{ order.status|default:'-' }}

- {% else %} -

{{ order }} - {{ order.name }}

- {% endif %} - {% endfor %} + {% if user.is_superuser %} + {% draft_orders_table area=area %} + {% endif %}
- {% else %} -
-

Urgent orders

-

No urgent orders found.

-
- {% endif %} +
{% endblock %} diff --git a/src/staff/templates/staff/equipmentorder_detail.html b/src/staff/templates/staff/equipmentorder_detail.html index bb7bb54a..7f76f07b 100644 --- a/src/staff/templates/staff/equipmentorder_detail.html +++ b/src/staff/templates/staff/equipmentorder_detail.html @@ -33,6 +33,8 @@
Requested Equipment
{% /table %}
+ back + Assign staff {% comment %} {% if object.status == 'draft' %} Edit diff --git a/src/staff/templates/staff/equipmentorder_filter.html b/src/staff/templates/staff/equipmentorder_filter.html index 25b2704d..434f0c13 100644 --- a/src/staff/templates/staff/equipmentorder_filter.html +++ b/src/staff/templates/staff/equipmentorder_filter.html @@ -1,5 +1,19 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Equipment Orders {% endblock page-title %} + +{% block page-inner %} +
+
+ {{ filter.form | crispy }} +
+ +
+ +{% 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 08ae63cc..cf21b4c0 100644 --- a/src/staff/templates/staff/extractionorder_detail.html +++ b/src/staff/templates/staff/extractionorder_detail.html @@ -3,36 +3,55 @@ {% block content %} - {% fragment as table_header %} - {% #table-cell header=True %}GUID{% /table-cell %} - {% #table-cell header=True %}Type{% /table-cell %} - {% #table-cell header=True %}Species{% /table-cell %} - {% #table-cell header=True %}Markers{% /table-cell %} - {% #table-cell header=True %}Location{% /table-cell %} - {% #table-cell header=True %}Date{% /table-cell %} - {% #table-cell header=True %}Volume{% /table-cell %} - {% endfragment %}

Order {{ object }}

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

{{ object.samples.count }} samples were delivered

-
+
back Samples + {% if analysis_orders|length > 1 %} +
+ + + +
+ {% elif analysis_orders|length == 1 %} + Go to {{ analysis_orders.first}} + {% endif %} + Assign staff + +
+ + {% if not object.is_seen %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if object.status == object.OrderStatus.DELIVERED %} -
{% url 'staff:order-manually-checked' pk=object.id as confirm_check_url %} - {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} - {% action-button action=confirm_check_url class="bg-secondary text-white" submit_text="Confirm - Order checked" csrf_token=csrf_token %} + {% endif %} + + {% if object.status != object.OrderStatus.DRAFT %} + {% url 'staff:order-to-draft' pk=object.id as to_draft_url %} {% action-button action=to_draft_url class="bg-secondary text-white" submit_text="Convert to draft" csrf_token=csrf_token %} {% endif %} @@ -43,4 +62,21 @@
Delivered Samples
{% endwith %} {% endif %}
+ + {% fragment as table_header %} + {% #table-cell header=True %}GUID{% /table-cell %} + {% #table-cell header=True %}Type{% /table-cell %} + {% #table-cell header=True %}Species{% /table-cell %} + {% #table-cell header=True %}Markers{% /table-cell %} + {% #table-cell header=True %}Location{% /table-cell %} + {% #table-cell header=True %}Date{% /table-cell %} + {% #table-cell header=True %}Volume{% /table-cell %} + {% endfragment %} + + {% object-detail object=object %} + +
Delivered Samples
+
+

{{ object.samples.count }} samples were delivered

+
{% endblock %} diff --git a/src/staff/templates/staff/extractionorder_filter.html b/src/staff/templates/staff/extractionorder_filter.html index f0ff30b7..e2c1e8d9 100644 --- a/src/staff/templates/staff/extractionorder_filter.html +++ b/src/staff/templates/staff/extractionorder_filter.html @@ -1,5 +1,19 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Extraction Orders {% endblock page-title %} + +{% block page-inner %} +
+
+ {{ filter.form | crispy }} +
+ +
+ +{% render_table table %} + +{% endblock page-inner %} diff --git a/src/staff/templates/staff/extractionplate_filter.html b/src/staff/templates/staff/extractionplate_filter.html index 877fca41..26e046dd 100644 --- a/src/staff/templates/staff/extractionplate_filter.html +++ b/src/staff/templates/staff/extractionplate_filter.html @@ -1,4 +1,6 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Extraction plates @@ -8,4 +10,13 @@ + +
+
+ {{ filter.form | crispy }} +
+ +
+ +{% 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 new file mode 100644 index 00000000..c98206c3 --- /dev/null +++ b/src/staff/templates/staff/note_input_column.html @@ -0,0 +1,7 @@ + diff --git a/src/staff/templates/staff/order_staff_edit.html b/src/staff/templates/staff/order_staff_edit.html new file mode 100644 index 00000000..efeab57b --- /dev/null +++ b/src/staff/templates/staff/order_staff_edit.html @@ -0,0 +1,50 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags core %} + +{% block css %} + +{% endblock %} + +{% block content %} +

Manage Responsible Staff - {{ object }}

+ +
+ {% if model_type == "genrequest" %} + Back to Genrequest + {% else %} + Back to Order + {% endif %} +
+ +
+

Assign Staff to Order

+
+ {% csrf_token %} + +
+ {{ form.responsible_staff.label_tag }} + +
{{ form.responsible_staff }}
+ + {% if form.responsible_staff.help_text %} +

{{ form.responsible_staff.help_text }}

+ {% endif %} + + {% if form.responsible_staff.errors %} +
{{ form.responsible_staff.errors }}
+ {% endif %} +
+ +
+ +
+
+
+{% endblock %} diff --git a/src/staff/templates/staff/prioritise_flag.html b/src/staff/templates/staff/prioritise_flag.html new file mode 100644 index 00000000..36eb56ca --- /dev/null +++ b/src/staff/templates/staff/prioritise_flag.html @@ -0,0 +1,3 @@ + diff --git a/src/staff/templates/staff/project_detail.html b/src/staff/templates/staff/project_detail.html index bcd80534..1a6b19e3 100644 --- a/src/staff/templates/staff/project_detail.html +++ b/src/staff/templates/staff/project_detail.html @@ -4,14 +4,16 @@ {% block content %}

Project {{ object }}

-
-
- - {% object-detail object=object %} +
back {% url 'staff:projects-verify' pk=object.pk as verify_url %} - {% action-button action=verify_url class="btn-secondary text-white" submit_text="Mark as verified" csrf_token=csrf_token %} + {% 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 %} + {% endif %}
+ + {% object-detail object=object %} + {% endblock %} diff --git a/src/staff/templates/staff/project_filter.html b/src/staff/templates/staff/project_filter.html index 22279374..b0434b4c 100644 --- a/src/staff/templates/staff/project_filter.html +++ b/src/staff/templates/staff/project_filter.html @@ -1,5 +1,19 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} Projects {% endblock page-title %} + +{% block page-inner %} +
+
+ {{ filter.form | crispy }} +
+ +
+ +{% render_table table %} + +{% endblock page-inner %} diff --git a/src/staff/templates/staff/sample_filter.html b/src/staff/templates/staff/sample_filter.html index 7fc5448f..430cc1b9 100644 --- a/src/staff/templates/staff/sample_filter.html +++ b/src/staff/templates/staff/sample_filter.html @@ -1,7 +1,22 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} -{% if order %}{{ order }} - Samples{% else %}Samples{% endif %} +
+
+ {% if order %} + {{ order }} - Samples + {% else %} + Samples + {% endif %} +
+ {% if order %} +
+ {{ order.filled_genlab_count }} / {{ order.samples.count }} Genlabs generated +
+ {% endif %} +
{% endblock page-title %} {% block page-inner %} @@ -9,6 +24,36 @@ + +
+ {% csrf_token %} + + + +
This page is under development. The genlab IDs will generate for all, and without sorting as per now.
+ + {% render_table table %} +
+ + {% endif %} + {% endblock page-inner %} diff --git a/src/staff/templates/staff/sample_lab.html b/src/staff/templates/staff/sample_lab.html new file mode 100644 index 00000000..47368e3c --- /dev/null +++ b/src/staff/templates/staff/sample_lab.html @@ -0,0 +1,85 @@ +{% extends "staff/base.html" %} +{% load render_table from django_tables2 %} + +{% block content %} +

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

+{% block page-inner %} +
+ back +
+
+ {% csrf_token %} + {% for status in statuses %} + + {% endfor %} +
+ + +
+ {% render_table table %} +
+{% endblock page-inner %} + + +{% endblock content %} diff --git a/src/staff/templates/staff/samplemarkeranalysis_filter.html b/src/staff/templates/staff/samplemarkeranalysis_filter.html index 4f81078d..227e0a18 100644 --- a/src/staff/templates/staff/samplemarkeranalysis_filter.html +++ b/src/staff/templates/staff/samplemarkeranalysis_filter.html @@ -1,4 +1,6 @@ {% extends "staff/base_filter.html" %} +{% load crispy_forms_tags static %} +{% load render_table from django_tables2 %} {% block page-title %} {% if order %}{{ order }} - Samples{% else %}Samples{% endif %} @@ -10,4 +12,14 @@ back
{% endif %} + +
+
+ {{ filter.form | crispy }} +
+ +
+ +{% render_table table %} + {% endblock page-inner %} diff --git a/src/staff/templatetags/__init__.py b/src/staff/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py new file mode 100644 index 00000000..77f63f48 --- /dev/null +++ b/src/staff/templatetags/order_tags.py @@ -0,0 +1,188 @@ +from django import template +from django.db import models + +from genlab_bestilling.models import Area, Order + +from ..tables import ( + AssignedOrderTable, + DraftOrderTable, + NewSeenOrderTable, + NewUnseenOrderTable, + UrgentOrderTable, +) + +register = template.Library() + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def urgent_orders_table(context: dict, area: Area | None = None) -> dict: + urgent_orders = ( + Order.objects.filter( + is_urgent=True, + ) + .exclude(status=Order.OrderStatus.DRAFT) + .select_related("genrequest") + ) + + if area: + urgent_orders = urgent_orders.filter(genrequest__area=area) + + urgent_orders = urgent_orders.order_by( + models.Case( + models.When(status=Order.OrderStatus.PROCESSING, then=0), + models.When(status=Order.OrderStatus.DELIVERED, then=1), + models.When(status=Order.OrderStatus.COMPLETED, then=2), + default=3, + output_field=models.IntegerField(), + ), + "-created_at", + ) + + return { + "title": "Urgent orders", + "table": UrgentOrderTable(urgent_orders), + "count": urgent_orders.count(), + "request": context.get("request"), + } + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def new_seen_orders_table(context: dict, area: Area | None = None) -> dict: + new_orders = ( + Order.objects.filter(status=Order.OrderStatus.DELIVERED, is_seen=True) + .exclude(is_urgent=True) + .select_related("genrequest") + .annotate( + sample_count=models.Case( + models.When( + extractionorder__isnull=False, + then=models.Count("extractionorder__samples", distinct=True), + ), + models.When( + analysisorder__isnull=False, + then=models.Count("analysisorder__samples", distinct=True), + ), + default=0, + ), + priority=models.Case( + models.When(is_urgent=True, then=Order.OrderPriority.URGENT), + models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), + default=1, + ), + ) + ) + + if area: + new_orders = new_orders.filter(genrequest__area=area) + + new_orders = new_orders.order_by("-priority", "-created_at") + + return { + "title": "New seen orders", + "table": NewSeenOrderTable(new_orders), + "count": new_orders.count(), + "request": context.get("request"), + } + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def new_unseen_orders_table(context: dict, area: Area | None = None) -> dict: + new_orders = ( + Order.objects.filter(status=Order.OrderStatus.DELIVERED, is_seen=False) + .exclude(is_urgent=True) + .select_related("genrequest") + .annotate( + sample_count=models.Case( + models.When( + extractionorder__isnull=False, + then=models.Count("extractionorder__samples", distinct=True), + ), + models.When( + analysisorder__isnull=False, + then=models.Count("analysisorder__samples", distinct=True), + ), + default=0, + ) + ) + ) + + if area: + new_orders = new_orders.filter(genrequest__area=area) + + new_orders = new_orders.order_by("-created_at") + + return { + "title": "New unseen orders", + "table": NewUnseenOrderTable(new_orders), + "count": new_orders.count(), + "request": context.get("request"), + } + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def assigned_orders_table(context: dict) -> dict: + user = context.get("user") + + assigned_orders = ( + Order.objects.filter( + status__in=[ + Order.OrderStatus.PROCESSING, + Order.OrderStatus.DELIVERED, + Order.OrderStatus.COMPLETED, + ], + responsible_staff=user, + ) + .select_related("genrequest") + .annotate( + sample_count=models.Count("extractionorder__samples", distinct=True), + priority=models.Case( + models.When(is_urgent=True, then=Order.OrderPriority.URGENT), + models.When(is_prioritized=True, then=Order.OrderPriority.PRIORITIZED), + default=1, + ), + ) + .order_by( + models.Case( + models.When(status=Order.OrderStatus.PROCESSING, then=0), + models.When(status=Order.OrderStatus.DELIVERED, then=1), + models.When(status=Order.OrderStatus.COMPLETED, then=2), + default=3, + output_field=models.IntegerField(), + ), + "-priority", + "-created_at", + ) + ) + + return { + "title": "My orders", + "table": AssignedOrderTable(assigned_orders), + "count": assigned_orders.count(), + "request": context.get("request"), + } + + +@register.inclusion_tag("staff/components/order_table.html", takes_context=True) +def draft_orders_table(context: dict, area: Area) -> dict: + draft_orders = ( + Order.objects.filter(status=Order.OrderStatus.DRAFT) + .select_related("genrequest") + .annotate( + sample_count=models.Count("extractionorder__samples", distinct=True), + priority=models.Case( + models.When(is_urgent=True, then=Order.OrderPriority.URGENT), + default=1, + ), + ) + .order_by("-priority", "-created_at") + ) + + if area: + draft_orders = draft_orders.filter(genrequest__area=area) + + return { + "title": "Draft orders", + "table": DraftOrderTable(draft_orders), + "count": draft_orders.count(), + "request": context.get("request"), + } diff --git a/src/staff/urls.py b/src/staff/urls.py index a3dbf599..b43c512e 100644 --- a/src/staff/urls.py +++ b/src/staff/urls.py @@ -11,17 +11,23 @@ ExtractionPlateCreateView, ExtractionPlateDetailView, ExtractionPlateListView, + GenerateGenlabIDsView, ManaullyCheckedOrderActionView, + MarkAsSeenView, OrderAnalysisSamplesListView, OrderExtractionSamplesListView, + OrderPrioritizedAdminView, OrderToDraftActionView, OrderToNextStatusActionView, ProjectDetailView, ProjectListView, ProjectValidateActionView, SampleDetailView, + SampleLabView, SampleReplicaActionView, SamplesListView, + StaffEditView, + UpdateInternalNote, ) app_name = "staff" @@ -68,11 +74,31 @@ ManaullyCheckedOrderActionView.as_view(), name="order-manually-checked", ), + path( + "//add-staff/", + StaffEditView.as_view(), + name="add-staff", + ), path( "orders/extraction//samples/", OrderExtractionSamplesListView.as_view(), name="order-extraction-samples", ), + path( + "orders/extraction//samples/lab", + SampleLabView.as_view(), + name="order-extraction-samples-lab", + ), + path( + "orders/extraction//samples/generate-genlab-ids/", + GenerateGenlabIDsView.as_view(), + name="generate-genlab-ids", + ), + path( + "orders/samples/update/internal-note/", + UpdateInternalNote.as_view(), + name="update-internal-note", + ), path( "orders/analysis//samples/", OrderAnalysisSamplesListView.as_view(), @@ -98,6 +124,11 @@ EquipmentOrderDetailView.as_view(), name="order-equipment-detail", ), + path( + "order/mark-as-seen//", + MarkAsSeenView.as_view(), + name="mark-as-seen", + ), path( "orders/extraction//", ExtractionOrderDetailView.as_view(), @@ -118,4 +149,9 @@ ExtractionPlateDetailView.as_view(), name="plates-detail", ), + path( + "orders//priority/", + OrderPrioritizedAdminView.as_view(), + name="order-priority", + ), ] diff --git a/src/staff/views.py b/src/staff/views.py index 5bf21248..7b44831f 100644 --- a/src/staff/views.py +++ b/src/staff/views.py @@ -1,11 +1,14 @@ +from collections import defaultdict from typing import Any from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db import models +from django.db.models import Count from django.forms import Form -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect -from django.urls import reverse_lazy +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse, reverse_lazy from django.utils.timezone import now from django.utils.translation import gettext as _ from django.views.generic import CreateView, DetailView, TemplateView @@ -15,12 +18,17 @@ from genlab_bestilling.models import ( AnalysisOrder, + Area, EquipmentOrder, ExtractionOrder, ExtractionPlate, + Genrequest, + IsolationMethod, Order, Sample, + SampleIsolationMethod, SampleMarkerAnalysis, + SampleStatusAssignment, ) from nina.models import Project from shared.views import ActionView @@ -32,7 +40,7 @@ SampleFilter, SampleMarkerOrderFilter, ) -from .forms import ExtractionPlateForm +from .forms import ExtractionPlateForm, OrderStaffForm from .tables import ( AnalysisOrderTable, EquipmentOrderTable, @@ -41,6 +49,7 @@ OrderExtractionSampleTable, PlateTable, ProjectTable, + SampleStatusTable, SampleTable, ) @@ -61,19 +70,25 @@ def test_func(self) -> bool: class DashboardView(StaffMixin, TemplateView): template_name = "staff/dashboard.html" - def get_context_data(self, **kwargs) -> dict[str, Any]: - context = super().get_context_data(**kwargs) + def get_area_from_query(self) -> Area | None: + area_id = self.request.GET.get("area") + if area_id: + try: + return Area.objects.get(pk=area_id) + except Area.DoesNotExist: + return None + return None - urgent_orders = Order.objects.filter( - is_urgent=True, - status__in=[Order.OrderStatus.PROCESSING, Order.OrderStatus.DELIVERED], - ).order_by("-created_at") - context["urgent_orders"] = urgent_orders + def get_areas(self) -> models.QuerySet[Area]: + return Area.objects.all().order_by("name") - delivered_orders = Order.objects.filter(status=Order.OrderStatus.DELIVERED) + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) - context["delivered_orders"] = delivered_orders context["now"] = now() + context["areas"] = self.get_areas() + context["area"] = self.get_area_from_query() + return context @@ -113,6 +128,7 @@ def get_queryset(self) -> models.QuerySet[ExtractionOrder]: "genrequest__area", ) .prefetch_related("species", "sample_types") + .annotate(sample_count=Count("samples")) ) @@ -154,14 +170,50 @@ def get_queryset(self) -> models.QuerySet[EquipmentOrder]: class AnalysisOrderDetailView(StaffMixin, DetailView): model = AnalysisOrder + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + analysis_order = self.object + context["extraction_order"] = analysis_order.from_order + return context + class EquipmentOrderDetailView(StaffMixin, DetailView): model = EquipmentOrder +class MarkAsSeenView(StaffMixin, DetailView): + model = Order + + def get_object(self) -> Order: + return Order.objects.get(pk=self.kwargs["pk"]) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + try: + order = self.get_object() + order.toggle_seen() + messages.success(request, _("Order is marked as seen")) + except Exception as e: + messages.error(request, f"Error: {str(e)}") + + return_to = request.POST.get("return_to") + return HttpResponseRedirect(self.get_return_url(return_to)) + + def get_return_url(self, return_to: str) -> str: + if return_to == "dashboard": + return reverse("staff:dashboard") + else: + return self.get_object().get_absolute_staff_url() + + class ExtractionOrderDetailView(StaffMixin, DetailView): model = ExtractionOrder + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + extraction_order = self.object + context["analysis_orders"] = extraction_order.analysis_orders.all() + return context + class OrderExtractionSamplesListView(StaffMixin, SingleTableMixin, FilterView): table_pagination = False @@ -185,6 +237,18 @@ def get_context_data(self, **kwargs) -> dict[str, Any]: context["order"] = ExtractionOrder.objects.get(pk=self.kwargs.get("pk")) return context + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + sample_id = request.POST.get("sample_id") + + if sample_id: + sample = get_object_or_404(Sample, pk=sample_id) + sample.is_prioritised = not sample.is_prioritised + sample.save() + + return self.get( + request, *args, **kwargs + ) # Re-render the view with updated data + class OrderAnalysisSamplesListView(StaffMixin, SingleTableMixin, FilterView): table_pagination = False @@ -243,6 +307,184 @@ class SampleDetailView(StaffMixin, DetailView): model = Sample +class SampleLabView(StaffMixin, TemplateView): + disable_pagination = False + template_name = "staff/sample_lab.html" + table_class = SampleStatusTable + + def get_order(self) -> ExtractionOrder: + if not hasattr(self, "_order"): + self._order = get_object_or_404(ExtractionOrder, pk=self.kwargs["pk"]) + return self._order + + def get_data(self) -> list[Sample]: + order = self.get_order() + samples = Sample.objects.filter(order=order, genlab_id__isnull=False) + sample_status = SampleStatusAssignment.SampleStatus.choices + + # Fetch all SampleStatusAssignment entries related to the current order + sample_assignments = SampleStatusAssignment.objects.filter(order_id=order.id) + + # Build a lookup: {sample_id: set of status names} + # This allows us to check status presence without querying per sample + sample_status_map = defaultdict(set) + for assignment in sample_assignments: + name = str(assignment.status) + + sample_status_map[assignment.sample_id].add(name) + + # Annotate each sample instance with boolean flags per status + # Equivalent to: sample.status_name = True/False + # based on whether the sample has that status + for sample in samples: + sample.selected_isolation_method = ( + sample.isolation_method.first() + if sample.isolation_method.exists() + else None + ) + status_names = sample_status_map.get(sample.id, set()) + for status, _i in sample_status: + setattr(sample, status, status in status_names) + + return samples + + def get_isolation_methods(self) -> list[str]: + order = self.get_order() + samples = Sample.objects.filter(order=order) + species_ids = samples.values_list("species_id", flat=True).distinct() + + return IsolationMethod.objects.filter(species_id__in=species_ids).values_list( + "name", flat=True + ) + + def get_base_fields(self) -> list[str]: + return [v for v, _ in SampleStatusAssignment.SampleStatus.choices] + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + context["order"] = self.get_order() + context["statuses"] = self.get_base_fields() + context["isolation_methods"] = self.get_isolation_methods() + context["table"] = self.table_class(data=self.get_data()) + + return context + + def get_success_url(self) -> str: + return reverse( + "staff:order-extraction-samples-lab", kwargs={"pk": self.get_order().pk} + ) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + status_name = request.POST.get("status") + selected_ids = request.POST.getlist("checked") + isolation_method = request.POST.get("isolation_method") + + if not selected_ids: + messages.error(request, "No samples selected.") + return HttpResponseRedirect(self.get_success_url()) + + order = self.get_order() + + # Get the selected samples + samples = Sample.objects.filter(id__in=selected_ids) + + if status_name: + self.assign_status_to_samples(samples, status_name, order, request) + if isolation_method: + self.update_isolation_methods(samples, isolation_method, request) + return HttpResponseRedirect(self.get_success_url()) + + def assign_status_to_samples( + self, + samples: models.QuerySet, + status_name: str, + order: ExtractionOrder, + request: HttpRequest, + ) -> None: + statuses = SampleStatusAssignment.SampleStatus.choices + + # Check if the provided status exists + if status_name not in [k for k, _ in statuses]: + messages.error(request, f"Status '{status_name}' is not valid.") + return HttpResponseRedirect(self.get_success_url()) + + # Get the index of the target status + status_weight = next( + i for i, (name, _) in enumerate(statuses) if name == status_name + ) + + # Slice the list up to that index (inclusive) and extract only the names + statuses_to_apply = [name for name, _ in statuses[: status_weight + 1]] + + # Apply status assignments + assignments = [] + for sample in samples: + for status in statuses_to_apply: + assignment = SampleStatusAssignment( + sample=sample, + status=status, + order=order, + ) + assignments.append(assignment) + + SampleStatusAssignment.objects.bulk_create( + assignments, + ignore_conflicts=True, + ) + + messages.success( + request, f"{samples.count()} samples updated with status '{status_name}'." + ) + + def update_isolation_methods( + self, samples: models.QuerySet, isolation_method: str, request: HttpRequest + ) -> None: + selected_isolation_method = IsolationMethod.objects.filter( + name=isolation_method + ).first() + + try: + im = IsolationMethod.objects.get(name=selected_isolation_method.name) + except IsolationMethod.DoesNotExist: + messages.error( + request, + f"Isolation method '{selected_isolation_method.name}' not found.", + ) + return + + for sample in samples: + # Remove any existing methods for this sample + SampleIsolationMethod.objects.filter(sample=sample).delete() + + # Add the new one + SampleIsolationMethod.objects.create(sample=sample, isolation_method=im) + messages.success( + request, + f"{samples.count()} samples updated with isolation method '{isolation_method}'.", # noqa: E501 + ) + + +class UpdateInternalNote(StaffMixin, ActionView): + def post(self, request: HttpRequest, *args, **kwargs) -> JsonResponse: + sample_id = request.POST.get("sample_id") + field_name = request.POST.get("field_name") + field_value = request.POST.get("field_value") + + if not sample_id or not field_name or field_value is None: + return JsonResponse({"error": "Invalid input"}, status=400) + + try: + sample = Sample.objects.get(id=sample_id) + + if field_name == "internal_note-input": + sample.internal_note = field_value + sample.save() + + return JsonResponse({"success": True}) + except Sample.DoesNotExist: + return JsonResponse({"error": "Sample not found"}, status=404) + + class ManaullyCheckedOrderActionView(SingleObjectMixin, ActionView): model = ExtractionOrder @@ -263,11 +505,7 @@ def form_valid(self, form: Form) -> HttpResponse: _("The order was checked, GenLab IDs will be generated"), ) except Exception as e: - messages.add_message( - self.request, - messages.ERROR, - f"Error: {str(e)}", - ) + messages.error(self.request, f"Error: {str(e)}") return super().form_valid(form) @@ -281,11 +519,68 @@ def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) +class StaffEditView(StaffMixin, SingleObjectMixin, TemplateView): + form_class = OrderStaffForm + template_name = "staff/order_staff_edit.html" + + def get_queryset(self) -> models.QuerySet[Order] | models.QuerySet[Genrequest]: + model_type = self._get_model_type() + if model_type == "genrequest": + return Genrequest.objects.all() + return Order.objects.filter(status=Order.OrderStatus.DELIVERED) + + def _get_model_type(self) -> str: + """Returns model type based on request data.""" + return self.kwargs["model_type"] + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + form = self.form_class(request.POST, order=self.object) + + if form.is_valid(): + responsible_staff = form.cleaned_data.get("responsible_staff", []) + self.object.responsible_staff.set(responsible_staff) + + messages.add_message( + request, + messages.SUCCESS, + "Staff assignment updated successfully", + ) + model_type = self._get_model_type() + return HttpResponseRedirect(self.get_success_url(model_type)) + + return self.render_to_response(self.get_context_data(form=form)) + + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + context["object"] = self.object + context["form"] = self.form_class(order=self.object) + context["model_type"] = self._get_model_type() + + return context + + def get_success_url(self, model_type: str | None) -> str: + if model_type == "genrequest": + return reverse( + "genrequest-detail", + kwargs={"pk": self.object.id}, + ) + + return reverse_lazy( + f"staff:order-{model_type}-detail", + kwargs={"pk": self.object.pk}, + ) + + class OrderToDraftActionView(SingleObjectMixin, ActionView): model = Order def get_queryset(self) -> models.QuerySet[Order]: - return super().get_queryset().filter(status=Order.OrderStatus.DELIVERED) + return super().get_queryset() def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: self.object: Order = self.get_object() @@ -351,6 +646,54 @@ def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) +class GenerateGenlabIDsView( + SingleObjectMixin, StaffMixin, SingleTableMixin, FilterView +): + model = ExtractionOrder + + def get_object(self) -> ExtractionOrder: + return ExtractionOrder.objects.get(pk=self.kwargs["pk"]) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + selected_ids = request.POST.getlist("checked") + + if not selected_ids: + messages.error(request, "No samples were selected.") + return HttpResponseRedirect(self.get_return_url()) + + sort_param = request.POST.get("sort", "") + sorting_order = [s.strip() for s in sort_param.split(",") if s.strip()] + + selected_samples = Sample.objects.filter(pk__in=selected_ids) + + if sorting_order: + selected_samples = selected_samples.order_by(*sorting_order) + + try: + self.object.order_selected_checked( + sorting_order=sorting_order, selected_samples=selected_samples + ) + messages.add_message( + request, + messages.SUCCESS, + _(f"Genlab IDs generated for {selected_samples.count()} samples."), + ) + except Exception as e: + messages.add_message( + request, + messages.ERROR, + f"Error: {str(e)}", + ) + + return HttpResponseRedirect(self.get_return_url()) + + def get_return_url(self) -> str: + return reverse_lazy( + "staff:order-extraction-samples", kwargs={"pk": self.object.pk} + ) + + class ExtractionPlateCreateView(StaffMixin, CreateView): model = ExtractionPlate form_class = ExtractionPlateForm @@ -444,3 +787,16 @@ def get_success_url(self) -> str: def form_invalid(self, form: Form) -> HttpResponse: return HttpResponseRedirect(self.get_success_url()) + + +class OrderPrioritizedAdminView(StaffMixin, ActionView): + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + pk = kwargs.get("pk") + order = Order.objects.get(pk=pk) + order.toggle_prioritized() + + return HttpResponseRedirect( + reverse( + "staff:dashboard", + ) + ) diff --git a/src/templates/django_tables2/tailwind.html b/src/templates/django_tables2/tailwind.html index 57650799..a322ecb5 100644 --- a/src/templates/django_tables2/tailwind.html +++ b/src/templates/django_tables2/tailwind.html @@ -1,126 +1,7 @@ -{% load django_tables2 %} -{% load i18n %} {% block table-wrapper %} -
-
- {% block table %} - - {% block table.thead %} - {% if table.show_header %} - - - {% for column in table.columns %} - - {% endfor %} - - - {% endif %} - {% endblock table.thead %} - - - {% block table.tbody %} - - {% for row in table.paginated_rows %} - {% block table.tbody.row %} - - - {% for column, cell in row.items %} - - {% endfor %} - - {% endblock table.tbody.row %} - {% empty %} - {% if table.empty_text %} - {% block table.tbody.empty_text %} - - {% endblock table.tbody.empty_text %} - {% endif %} - {% endfor %} - - {% endblock table.tbody %} - - - {% block table.tfoot %} - {% if table.has_footer %} - - - {% for column in table.columns %} - - {% endfor %} - - - {% endif %} - {% endblock table.tfoot %} -
- {% if column.orderable %} - {% comment%} - If the column is orderable, two small arrows will show next to the column name to signal that it can be sorted. - {% endcomment%} - - {{ column.header }} - - - {% else %} - {{ column.header }} - {% endif %} -
- {% if column.localize == None %} - {{ cell }} - {% else %} - {% if column.localize %} - {{ cell|localize }} - {% else %} - {{ cell|unlocalize }} - {% endif %} - {% endif %}
{{ table.empty_text }}
{{ column.footer }}
- {% endblock table %} - - {% block pagination %} - {% if table.page and table.paginator.num_pages > 1 %} - - {% endif %} - {% endblock pagination %} -
+
+
+ {% include 'django_tables2/tailwind_inner.html' %} +
{% endblock table-wrapper %} diff --git a/src/templates/django_tables2/tailwind_inner.html b/src/templates/django_tables2/tailwind_inner.html new file mode 100644 index 00000000..24485f46 --- /dev/null +++ b/src/templates/django_tables2/tailwind_inner.html @@ -0,0 +1,120 @@ +{% load django_tables2 %} +{% load i18n %} + +{% block table %} + + {% block table.thead %} + {% if table.show_header %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.thead %} + + + {% block table.tbody %} + + {% for row in table.paginated_rows %} + {% block table.tbody.row %} + + + {% for column, cell in row.items %} + + {% endfor %} + + {% endblock table.tbody.row %} + {% empty %} + {% if table.empty_text %} + {% block table.tbody.empty_text %} + + {% endblock table.tbody.empty_text %} + {% endif %} + {% endfor %} + + {% endblock table.tbody %} + + + {% block table.tfoot %} + {% if table.has_footer %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.tfoot %} +
+ {% if column.orderable %} + {% comment%} + If the column is orderable, two small arrows will show next to the column name to signal that it can be sorted. + {% endcomment%} + + {{ column.header }} + + + {% else %} + {{ column.header }} + {% endif %} +
+ {% if column.localize == None %} + {{ cell }} + {% else %} + {% if column.localize %} + {{ cell|localize }} + {% else %} + {{ cell|unlocalize }} + {% endif %} + {% endif %}
{{ table.empty_text }}
{{ column.footer }}
+{% endblock table %} + +{% block pagination %} + {% if table.page and table.paginator.num_pages > 1 %} + + {% endif %} +{% endblock pagination %}