diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py index 04f302ac..5a6538c5 100644 --- a/src/genlab_bestilling/admin.py +++ b/src/genlab_bestilling/admin.py @@ -15,6 +15,7 @@ ExtractionPlate, ExtractPlatePosition, Genrequest, + IsolationMethod, Location, LocationType, Marker, @@ -532,3 +533,7 @@ class SampleStatusAdmin(ModelAdmin): ... @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 1616c471..00000000 --- a/src/genlab_bestilling/libs/genlabid.py +++ /dev/null @@ -1,200 +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, - sorting_order: list[str] | None = None, - selected_samples: list[Any] | None = None, -) -> 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..bbf5e9af 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", "order") + .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/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/0023_isolationmethod_species_sampleisolationmethod_and_more.py b/src/genlab_bestilling/migrations/0023_isolationmethod_species_sampleisolationmethod_and_more.py new file mode 100644 index 00000000..02abed71 --- /dev/null +++ b/src/genlab_bestilling/migrations/0023_isolationmethod_species_sampleisolationmethod_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.3 on 2025-07-08 10:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("genlab_bestilling", "0022_sample_internal_note"), + ] + + operations = [ + 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", + ), + ), + ] 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/models.py b/src/genlab_bestilling/models.py index cbc645f0..2b16a2fb 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -9,7 +9,6 @@ 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 @@ -285,6 +284,7 @@ class OrderPriority: 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" @@ -491,9 +491,9 @@ 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, @@ -505,14 +505,15 @@ def order_selected_checked( """ self.internal_status = self.Status.CHECKED self.status = self.OrderStatus.PROCESSING - self.save() + self.save(update_fields=["internal_status", "status"]) - selected_sample_names = list(selected_samples.values_list("id", flat=True)) + if not selected_samples.exists(): + return - app.configure_task(name="generate-genlab-ids").defer( + Sample.objects.generate_genlab_ids( order_id=self.id, sorting_order=sorting_order, - selected_samples=selected_sample_names, + selected_samples=selected_samples, ) @@ -652,6 +653,13 @@ class Sample(models.Model): related_name="samples", 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( @@ -737,6 +745,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 = @@ -786,8 +811,31 @@ 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 @@ -851,3 +899,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 d92b0a78..00000000 --- a/src/genlab_bestilling/tasks.py +++ /dev/null @@ -1,27 +0,0 @@ -# from .libs.isolation import isolate -from typing import Any - -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: int | str, - sorting_order: list[str] | None = None, - selected_samples: list[Any] | None = None, -) -> None: - generate_genlab_id( - order_id=order_id, - sorting_order=sorting_order, - selected_samples=selected_samples, - ) - # isolate(order_id=order_id) 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/tables.py b/src/staff/tables.py index 1b6a69d8..93e6325f 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -48,6 +48,12 @@ class OrderTable(tables.Table): orderable=False, ) + is_seen = tables.Column( + orderable=False, + visible=True, + verbose_name="", + ) + class Meta: fields = [ "name", @@ -60,8 +66,9 @@ class Meta: "created_at", "last_modified_at", "is_urgent", + "is_seen", ] - sequence = ("is_urgent", "status", "id", "name") + sequence = ("is_seen", "is_urgent", "status", "id", "name") empty_text = "No Orders" order_by = ("-is_urgent", "last_modified_at", "created_at") @@ -77,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( @@ -241,8 +256,17 @@ class CustomSampleTable(tables.Table): class Meta: model = Sample - fields = ["checked", "genlab_id", "internal_note"] + list(base_fields) - sequence = ["checked", "genlab_id"] + list(base_fields) + ["internal_note"] + fields = [ + "checked", + "genlab_id", + "internal_note", + "isolation_method", + ] + list(base_fields) + sequence = ( + ["checked", "genlab_id"] + + list(base_fields) + + ["internal_note", "isolation_method"] + ) return CustomSampleTable @@ -480,9 +504,15 @@ class AssignedOrderTable(StatusMixinTable, StaffIDMixinTable): 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 completed", + verbose_name="Samples isolated", orderable=False, ) @@ -493,6 +523,57 @@ def render_samples_completed(self, value: int) -> str: class Meta: model = Order - fields = ["priority", "id", "samples_completed", "status"] + 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 777520ec..74e33d3a 100644 --- a/src/staff/templates/staff/analysisorder_detail.html +++ b/src/staff/templates/staff/analysisorder_detail.html @@ -17,6 +17,14 @@