diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py index dcb382c7..d28798dd 100644 --- a/src/genlab_bestilling/forms.py +++ b/src/genlab_bestilling/forms.py @@ -3,8 +3,10 @@ from django import forms from django.contrib.auth.models import User from django.core.exceptions import ValidationError +from django.core.validators import validate_email from django.db import transaction from django.db.models import Model +from django.utils.html import strip_tags from formset.renderers.tailwind import FormRenderer from formset.utils import FormMixin from formset.widgets import DualSortableSelector, Selectize, TextInput @@ -14,6 +16,7 @@ from .libs.formset import ContextFormCollection from .models import ( AnalysisOrder, + AnalysisOrderResultsCommunication, EquimentOrderQuantity, EquipmentOrder, ExtractionOrder, @@ -122,6 +125,12 @@ def __init__(self, *args, genrequest: Genrequest, **kwargs): # self.fields["species"].queryset = genrequest.species.all() self.fields["sample_types"].queryset = genrequest.sample_types.all() # type: ignore[attr-defined] + self.fields[ + "contact_person" + ].help_text = "Person to contact with questions about this order" + self.fields[ + "contact_email" + ].help_text = "Email to contact with questions about this order" def save(self, commit: bool = True) -> EquipmentOrder: obj = super().save(commit=False) @@ -258,9 +267,8 @@ def __init__(self, *args, genrequest: Genrequest, **kwargs): self.fields["species"].queryset = genrequest.species.all() # type: ignore[attr-defined] self.fields["sample_types"].queryset = genrequest.sample_types.all() # type: ignore[attr-defined] - # self.fields["markers"].queryset = Marker.objects.filter( - # species__genrequests__id=genrequest.id - # ).distinct() + self.fields["contact_person"].label = "Responsible genetic researcher" + self.fields["contact_email"].label = "Responsible genetic researcher email" def save(self, commit: bool = True) -> ExtractionOrder: obj = super().save(commit=False) @@ -329,10 +337,37 @@ def __init__(self, *args, genrequest: Genrequest, **kwargs): " with the sample selection by pressing Submit" ) + self.fields["contact_person"].label = "Responsible genetic researcher" + self.fields["contact_email"].label = "Responsible genetic researcher email" + + def clean_contact_email_results(self) -> str: + emails_raw = self.cleaned_data.get("contact_email_results", "") + emails = [e.strip() for e in emails_raw.split(",") if e.strip()] + + try: + for email in emails: + validate_email(email) + except ValidationError: + msg = f"Invalid email: {email}" + raise forms.ValidationError(msg) from ValidationError(msg) + return ", ".join(emails) + + def clean_contact_person_results(self) -> str: + names_raw = self.cleaned_data.get("contact_person_results", "") + names = [n.strip() for n in names_raw.split(",") if n.strip()] + + for name in names: + # Optionally allow hyphens and apostrophes in names + if not all(c.isalpha() or c.isspace() or c in "-'" for c in name): + msg = f"Invalid name: {name}" + raise forms.ValidationError(msg) from ValidationError(msg) + return ", ".join(names) + def save(self, commit: bool = True) -> Model: if not commit: msg = "This form is always committed" raise NotImplementedError(msg) + with transaction.atomic(): obj = super().save(commit=False) obj.genrequest = self.genrequest @@ -342,9 +377,38 @@ def save(self, commit: bool = True) -> Model: if obj.from_order and not obj.name and obj.from_order.name: obj.name = obj.from_order.name + " - Analysis" + obj.save() self.save_m2m() obj.populate_from_order() + + # Save AnalysisOrderResultsCommunication objects + # Delete old entries first (in case of resubmission) + obj.results_contacts.all().delete() + + names = [ + strip_tags(n.strip()) + for n in self.cleaned_data["contact_person_results"].split(",") + if n.strip() + ] + emails = [ + strip_tags(e.strip()) + for e in self.cleaned_data["contact_email_results"].split(",") + if e.strip() + ] + + if names and emails: + if len(names) != len(emails): + msg = "The number of names must match the number of emails." + raise ValidationError(msg) + + for name, email in zip(names, emails, strict=False): + AnalysisOrderResultsCommunication.objects.create( + analysis_order=obj, + contact_person_results=name, + contact_email_results=email, + ) + return obj def clean(self) -> None: @@ -354,6 +418,18 @@ def clean(self) -> None: msg = "An extraction order must be selected" raise ValidationError(msg) + contact_person_results = forms.CharField( + label="Contact person(s) for results", + help_text="Comma-separated list of names to contact with results", + required=True, + ) + + contact_email_results = forms.CharField( + label="Contact email(s) for results", + help_text="Comma-separated list of emails to contact with results (must match order of names)", # noqa: E501 + required=True, + ) + field_order = [ "name", "use_all_samples", diff --git a/src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py b/src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py new file mode 100644 index 00000000..051aa388 --- /dev/null +++ b/src/genlab_bestilling/migrations/0033_alter_order_contact_person_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.3 on 2025-07-24 08:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "genlab_bestilling", + "0032_alter_isolationmethod_remove_species_add_type_20250722_1226", + ), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="contact_person", + field=models.CharField( + help_text="Responsible for genetic bioinformatics analysis", null=True + ), + ), + migrations.CreateModel( + name="AnalysisOrderResultsCommunication", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "contact_person_results", + models.CharField( + help_text="Person to contact for analysis resuls", null=True + ), + ), + ( + "contact_email_results", + models.EmailField( + help_text="Email to send analysis results", + max_length=254, + null=True, + ), + ), + ( + "analysis_order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="results_contacts", + to="genlab_bestilling.analysisorder", + ), + ), + ], + ), + ] diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py index 783e3607..c83f9f12 100644 --- a/src/genlab_bestilling/models.py +++ b/src/genlab_bestilling/models.py @@ -284,7 +284,7 @@ class OrderPriority: contact_person = models.CharField( null=True, blank=False, - help_text="Person to contact with questions about this order", + help_text="Responsible for genetic bioinformatics analysis", ) contact_email = models.EmailField( null=True, @@ -532,6 +532,27 @@ def order_selected_checked( ) +class AnalysisOrderResultsCommunication(models.Model): + analysis_order = models.ForeignKey( + f"{an}.AnalysisOrder", + on_delete=models.CASCADE, + related_name="results_contacts", + ) + contact_person_results = models.CharField( + null=True, + blank=False, + help_text="Person to contact for analysis resuls", + ) + contact_email_results = models.EmailField( + null=True, + blank=False, + help_text="Email to send analysis results", + ) + + def __str__(self): + return f"{str(self.analysis_order)} {str(self.contact_person_results)} {str(self.contact_email_results)}" # noqa: E501 + + class AnalysisOrder(Order): samples = models.ManyToManyField( f"{an}.Sample", blank=True, through="SampleMarkerAnalysis" diff --git a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html index 982a072d..9e264540 100644 --- a/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html +++ b/src/genlab_bestilling/templates/genlab_bestilling/analysisorder_detail.html @@ -26,6 +26,27 @@

Order {{ object }}

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

No contacts provided for analysis results.

+
+ {% endif %} +
Samples to analyze
@@ -48,7 +69,7 @@
Samples to analyze
Delete {% endif %} {% elif object.status == object.OrderStatus.DELIVERED %} - Samples + Samples {% endif %}
{% endblock %} diff --git a/src/genlab_bestilling/views.py b/src/genlab_bestilling/views.py index 19b6874b..e7c98fc5 100644 --- a/src/genlab_bestilling/views.py +++ b/src/genlab_bestilling/views.py @@ -467,6 +467,15 @@ def gen_crumbs(self) -> list[tuple]: (str(self.object), ""), ] + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + all_samples_have_no_genlab_id = not self.object.samples.exclude( + genlab_id__isnull=True + ).exists() + context["all_samples_have_no_genlab_id"] = all_samples_have_no_genlab_id + context["results_contacts"] = self.object.results_contacts.all() + return context + def get_queryset(self) -> QuerySet: return ( super() @@ -475,15 +484,6 @@ def get_queryset(self) -> QuerySet: .prefetch_related("sample_markers", "markers") ) - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: - context = super().get_context_data(**kwargs) - order = self.object - all_samples_have_no_genlab_id = not order.samples.exclude( - genlab_id__isnull=True - ).exists() - context["all_samples_have_no_genlab_id"] = all_samples_have_no_genlab_id - return context - class ExtractionOrderDetailView(GenrequestNestedMixin, DetailView): model = ExtractionOrder @@ -826,6 +826,9 @@ def gen_crumbs(self) -> list[tuple]: ] def get_success_url(self) -> str: + # Clear any leftover error messages before redirect + list(messages.get_messages(self.request)) + obj: AnalysisOrder = self.object # type: ignore[assignment] # Possibly None if obj.from_order: return reverse( @@ -843,6 +846,15 @@ def get_success_url(self) -> str: }, ) + # Override form_invalid to show errors in the form + def form_invalid(self, form: Form) -> HttpResponse: + for field, errors in form.errors.items(): + field_obj = form.fields.get(field) + label = field_obj.label if field_obj is not None else field + for error in errors: + messages.error(self.request, f"{label}: {error}") + return super().form_invalid(form) + class ExtractionOrderCreateView( GenrequestNestedMixin, diff --git a/src/staff/tables.py b/src/staff/tables.py index 318d7910..aac10d18 100644 --- a/src/staff/tables.py +++ b/src/staff/tables.py @@ -704,13 +704,13 @@ class DraftOrderTable(StaffIDMixinTable): contact_person = tables.Column( accessor="contact_person", - verbose_name="Contact Person", + verbose_name="Responsible genetic researcher", orderable=False, ) contact_email = tables.Column( accessor="contact_email", - verbose_name="Contact Email", + verbose_name="Responsible genetic researcher email", orderable=False, ) diff --git a/src/staff/templatetags/order_tags.py b/src/staff/templatetags/order_tags.py index d09fff73..640d5655 100644 --- a/src/staff/templatetags/order_tags.py +++ b/src/staff/templatetags/order_tags.py @@ -2,9 +2,15 @@ from django import template from django.db import models +from django.utils.html import format_html_join from django.utils.safestring import mark_safe -from genlab_bestilling.models import Area, Order +from genlab_bestilling.models import ( + AnalysisOrder, + AnalysisOrderResultsCommunication, + Area, + Order, +) from ..tables import ( AssignedOrderTable, @@ -339,11 +345,30 @@ def analysis_order_samples_detail_table(order: Order, extraction_orders: dict) - @register.inclusion_tag("../templates/components/order-detail.html") def contact_detail_table(order: Order) -> dict: + # Default values + result_contacts_html = "—" + + # Only fetch contacts if it's an AnalysisOrder instance + if isinstance(order, AnalysisOrder): + result_contacts = ( + AnalysisOrderResultsCommunication.objects.filter(analysis_order=order) + .values_list("contact_person_results", "contact_email_results") + .distinct() + ) + if result_contacts: + result_contacts_html = format_html_join( + "\n", + '
{} — {}
', # noqa: E501 + [(name, email, email) for name, email in result_contacts], + ) + fields = { "Samples owner of genetic project": order.genrequest.samples_owner, - "Contact person": order.contact_person, - "Contact Email": order.contact_email, + "Responsible genetic researcher": order.contact_person, + "Responsible genetic researcher email": order.contact_email, + "Contact name and email for analysis results": result_contacts_html, } + return { "fields": fields, "header": "Contact",