Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 101 additions & 122 deletions src/genlab_bestilling/libs/genlabid.py
Copy link
Contributor

@emilte emilte Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Det er en drastisk endring på så viktig funksjonalitet. Hvis denne branchen ikke er avhengig av refaktoreringen hadde det kanskje vært lurt å gjøre denne endringen i en egen dedikert branch slik at det er lettere å reversere i tilfelle noe problem skulle oppstå?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Er det skrevet tester rundt denne funksjonaliteten? Er vi sikre på at det fortsatt fungerer som forventet?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enig - da flytter jeg endringene i genlabid.py til en ny branch og tilpasser heller implementeringen som allerede er der.

Jeg ser ingen tester skrev rundt generering av genlabid.

Copy link
Contributor

@emilte emilte Jul 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jeg foreslår at dere kanskje setter dere sammen og prøver å skrive pytester på den funksjonen, i egen branch

Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,9 @@
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
from django.db.models import Case, QuerySet, When

from ..models import Sample


def get_replica_for_sample() -> None:
Expand Down Expand Up @@ -63,7 +52,11 @@ def get_current_sequences(order_id: int | str) -> Any:
return sequences


def generate(order_id: int | str) -> None:
def generate(
order_id: int,
sorting_order: list[str] | None = None,
selected_samples: list[int] | None = None,
) -> None:
"""
wrapper to handle errors and reset the sequence to the current sequence value
"""
Expand All @@ -73,7 +66,9 @@ def generate(order_id: int | str) -> None:
with connection.cursor() as cursor:
try:
with transaction.atomic():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tror ikke at with transaction.atomic() er nødvendig siden du dekorerer funksjonen din også med det.

cursor.execute(update_genlab_id_query(order_id))
cursor.execute(
update_genlab_id_query(order_id, sorting_order, selected_samples)
)
Comment on lines +69 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Litt usikker på om dette skal funke. cursor.execute kjører den sql-queryen den får, mens update_genlab_id_query kjører en sql query + returnerer None.

Suggested change
cursor.execute(
update_genlab_id_query(order_id, sorting_order, selected_samples)
)
update_genlab_id_query(order_id, sorting_order, selected_samples)

except Exception:
# if there is an error, reset the sequence
# NOTE: this is unsafe unless this function is executed in a queue
Expand All @@ -86,111 +81,95 @@ def generate(order_id: int | str) -> None:
print(sequences)


def update_genlab_id_query(order_id: int | str) -> Any:
# Format the genlab ID as GYYCODEXXXX
def generate_genlab_id(code: str, year: int, count: int) -> str:
"""
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)
Generating the genlab ID in the format GYYCODEXXXX
where:
- YY is the last two digits of the year
- CODE is the species code in uppercase
- XXXX is a zero-padded number starting from 0001 for each species
and year combination.
Example: G23ABC0001 for the first sample of species ABC in 2023.
"""
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")
return f"G{str(year)[-2:]}{code.upper()}{count:04d}"


# Order samples by their names, assuming they are numeric strings
def order(samples: QuerySet) -> QuerySet:
"""
Order samples by their names, assuming they are numeric strings.
"""
name_to_sample = {sample.name.strip(): sample for sample in samples}
names = list(name_to_sample.keys())

# Check all are digits
if not all(name.isdigit() for name in names):
return samples

# Sort names as integers
sorted_names = sorted(names, key=lambda x: int(x))

# Build a CASE statement to preserve order in SQL
when_statements = [
When(name=name, then=pos) for pos, name in enumerate(sorted_names)
]

return samples.order_by(Case(*when_statements))


# New entry point for generating genlab IDs
@transaction.atomic
def update_genlab_id_query(
order_id: int,
sorting_order: list[str] | None = None,
selected_samples: list[int] | None = None,
) -> None:
"""
Generate genlab IDs for samples in a specific order using Django QuerySet API.
"""
if selected_samples is None:
return

# Sort order, default is by name
order_by = sorting_order or ["name"]

# Fetch samples
samples = (
Sample.objects.select_related("species", "order")
.filter(order_id=order_id, id__in=selected_samples, genlab_id__isnull=True)
.order_by(*order_by)
)

# If sorting order is by name, we might need to order them numerically
if order_by == ["name"]:
samples = order(samples)

# Group counts by species + year
counts = {}
updates = []

# Iterate through samples to generate genlab IDs in correct order
for sample in list(samples):
# Extract species code and year from the sample
species_code = sample.species.code
year = sample.order.confirmed_at.year
key = (species_code, year)

# Initialize count
if key not in counts:
existing_count = Sample.objects.filter(
species__code=species_code,
order__confirmed_at__year=year,
genlab_id__isnull=False,
).count()
counts[key] = existing_count + 1
else:
counts[key] += 1

genlab_id = generate_genlab_id(species_code, year, counts[key])
sample.genlab_id = genlab_id
updates.append(sample)

# Bulk update samples with new genlab IDs
Sample.objects.bulk_update(updates, ["genlab_id"])
24 changes: 24 additions & 0 deletions src/genlab_bestilling/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ def to_draft(self) -> None:
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)
Expand Down Expand Up @@ -452,6 +456,26 @@ def order_manually_checked(self) -> None:
self.save()
app.configure_task(name="generate-genlab-ids").defer(order_id=self.id)

def order_selected_checked(
self,
sorting_order: list[str] | None = None,
selected_samples: list[str] | None = None,
) -> None:
"""
Partially set the order as checked by the lab staff, generate a genlab id
"""
self.internal_status = self.Status.CHECKED
self.status = self.OrderStatus.PROCESSING
self.save()

selected_sample_names = list(selected_samples.values_list("id", flat=True))

app.configure_task(name="generate-genlab-ids").defer(
order_id=self.id,
sorting_order=sorting_order,
selected_samples=selected_sample_names,
)


class AnalysisOrder(Order):
samples = models.ManyToManyField(
Expand Down
14 changes: 12 additions & 2 deletions src/genlab_bestilling/tasks.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# 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
Expand All @@ -12,6 +14,14 @@
max_attempts=5, linear_wait=5, retry_exceptions={OperationalError}
),
)
def generate_ids(order_id: str | int) -> None:
generate_genlab_id(order_id=order_id)
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)
25 changes: 25 additions & 0 deletions src/staff/tables.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -123,6 +125,16 @@ class SampleBaseTable(tables.Table):
plate_positions = tables.Column(
empty_values=(), orderable=False, verbose_name="Extraction position"
)
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
Expand All @@ -139,15 +151,28 @@ class Meta:
"plate_positions",
]
attrs = {"class": "w-full table-auto tailwind-table table-sm"}
sequence = ("checked",)
order_by = ("genlab_id", "name_as_int", "species")

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'<input type="checkbox" name="checked" value="{record.id}">')


class OrderExtractionSampleTable(SampleBaseTable):
class Meta(SampleBaseTable.Meta):
Expand Down
22 changes: 13 additions & 9 deletions src/staff/templates/staff/base_filter.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@
{% block content %}
<h3 class="text-4xl mb-5">{% block page-title %}{% endblock page-title %}</h3>
{% block page-inner %}{% endblock page-inner %}

<form method="get" class="py-3 px-4 border mb-3 ">
<div class="flex flex-wrap gap-4">
{{ filter.form | crispy }}
</div>
<button class="btn bg-primary" type="submit">Search</button>
</form>

{% render_table table %}
{% endblock %}

{% block body_javascript %}
{{ block.super }}
<script type="text/javascript" src="{% static 'admin/js/vendor/jquery/jquery.js' %}"></script>
{{ filter.form.media }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAll = document.getElementById('select-all-checkbox');
if (selectAll) {
selectAll.addEventListener('change', function () {
const checkboxes = document.querySelectorAll('input[name="checked"]');
checkboxes.forEach(cb => {
cb.checked = selectAll.checked;
});
});
}
});
</script>
{% endblock body_javascript %}
Loading
Loading