forms.py 61 KB
Newer Older
1
2
3
"""
iPPI-DB django forms
"""
4
5
import itertools
from collections import OrderedDict
6
7
from decimal import Decimal
from math import log10
8

9
from django import forms
10
from django.contrib import messages
11
from django.core.exceptions import ValidationError
12
from django.core.validators import RegexValidator
13
from django.db.models import Count
14
from django.db.models.functions import Upper
15
16
17
18
19
from django.forms import (
    ModelForm,
    modelformset_factory,
    formset_factory,
    inlineformset_factory,
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
20
21
    widgets,
)
22
from django.utils.translation import ugettext_lazy as _, ugettext
23

24
from ippidb import ws, models
25
26
27
28
29
30
from ippidb.ws import (
    get_pdb_uniprot_mapping,
    BibliographicalEntryNotFound,
    pdb_entry_exists,
    EntryNotFoundError,
)
31

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
32

33
34
35
36
class FieldDataList(forms.CharField):
    class Meta:
        abstract = True

37
38
39
    def __init__(
        self, data_class=None, data_attr=None, data_list=None, *args, **kwargs
    ):
40
41
        self.data_class = data_class
        self.data_attr = data_attr
42
        self.data_list = data_list
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
43
44
45
46
        super().__init__(*args, **kwargs)

    def widget_attrs(self, widget):
        attrs = super().widget_attrs(widget)
47
48
49
50
51
        if (
            self.data_class is not None
            and self.data_attr is not None
            and not widget.is_hidden
        ):
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
52
            # The HTML element is datalist, not data_list.
53
54
55
56
57
            attrs["datalist"] = (
                self.data_class.objects.values_list(self.data_attr, flat=True)
                .order_by(Upper(self.data_attr))
                .distinct()
            )
58
        if self.data_list is not None and len(self.data_list) > 0:
59
            attrs["datalist"] = self.data_list
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
60
61
62
        return attrs


63
64
65
66
67
68
69
70
class CharFieldDataList(FieldDataList, forms.CharField):
    pass


class IntegerFieldDataList(FieldDataList, forms.IntegerField):
    pass


71
class BoostrapSelectMultiple(forms.SelectMultiple):
72
73
74
75
76
77
78
79
80
    def __init__(
        self,
        allSelectedText,
        nonSelectedText,
        SelectedText,
        attrs=None,
        *args,
        **kwargs,
    ):
81
82
83
84
        if attrs is not None:
            attrs = attrs.copy()
        else:
            attrs = {}
85
86
87
88
89
90
91
92
93
        attrs.update(
            {
                "data-all-selected-text": allSelectedText,
                "data-non-selected-text": nonSelectedText,
                "data-n-selected-text": SelectedText,
                "class": "bootstrap-multiselect-applicant",
                "style": "display:none;",
            }
        )
94
        super().__init__(attrs=attrs, *args, **kwargs)
95
96


Rachel TORCHET's avatar
Rachel TORCHET committed
97
""" Step 1 : IdForm """
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
98
99


100
101
class IdForm(forms.Form):
    source = forms.ChoiceField(
102
        label="Bibliographic type",
103
        choices=models.Bibliography.SOURCES,
104
        initial="PM",
105
106
        widget=forms.RadioSelect,
    )
107
    id_source = forms.CharField(label="Bibliographic ID", max_length=255)
108
109
110
111
112
113
114
115
116
117
118
119
    allow_duplicate = forms.BooleanField(
        label=_("IdForm_allow_duplicate_label"),
        help_text=_("IdForm_allow_duplicate_help_text"),
        initial=False,
        required=False,
    )

    def __init__(self, request, display_allow_duplicate=False, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.display_allow_duplicate = display_allow_duplicate
        self.request = request
        if not display_allow_duplicate:
120
            del self.fields["allow_duplicate"]
121
122
123

    def clean(self):
        ret = super().clean()
124
125
126
        entries = (
            models.Bibliography.objects.filter(id_source=ret["id_source"])
            .filter(source=ret["source"])
127
            .annotate(used=Count("refcompoundbiblio"))
128
        )
129
130
131
        if entries.exists() > 0:
            if entries.filter(used=0).exists():
                # it's ok, allow to document it
132
133
134
                messages.debug(
                    self.request, _("IdForm_allow_duplicate_resume_contribution")
                )
135
            elif not self.display_allow_duplicate:
136
137
138
                raise ValidationError(
                    {"id_source": _("IdForm_allow_duplicate_false_validation_error")}
                )
139
140
            else:
                # it's ok, let's create a new one, and display a message
141
                models.Bibliography.objects.create(
142
143
144
                    source=self.cleaned_data["source"],
                    id_source=self.cleaned_data["id_source"],
                )
145
146
147
                messages.info(
                    self.request, _("IdForm_allow_duplicate_new_contribution")
                )
148
        else:
149
150
            models.Bibliography.validate_source_id(ret["id_source"], ret["source"])
            bibliography = models.Bibliography(
151
152
153
154
155
156
                source=self.cleaned_data["source"],
                id_source=self.cleaned_data["id_source"],
            )
            try:
                bibliography.autofill()
            except BibliographicalEntryNotFound as e:
157
158
159
160
161
162
163
                raise ValidationError({"id_source": str(e)})
            except Exception:
                raise ValidationError(
                    {
                        "id_source": f"Technical error, unable to retrieve data for {bibliography.id_source}."
                    }
                )
164
165
        return ret

166
    def get_instance(self):
167
168
169
170
171
        return (
            models.Bibliography.objects.filter(id_source=self.cleaned_data["id_source"])
            .filter(source=self.cleaned_data["source"])
            .annotate(used=Count("refcompoundbiblio"))
            .filter(used=0)
172
            .first()
173
        )
174

Hervé  MENAGER's avatar
Hervé MENAGER committed
175

Rachel TORCHET's avatar
Rachel TORCHET committed
176
""" Step 2 : BibliographyForm """
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
177
178


179
class BibliographyForm(ModelForm):
180
    class Meta:
181
        model = models.Bibliography
182
        fields = "__all__"
183
        widgets = {
184
185
186
187
188
189
            "source": forms.HiddenInput(),
            "biblio_year": forms.HiddenInput(),
            "id_source": forms.HiddenInput(),
            "title": forms.HiddenInput(),
            "journal_name": forms.HiddenInput(),
            "authors_list": forms.HiddenInput(),
190
        }
191

192

Rachel TORCHET's avatar
Rachel TORCHET committed
193
""" Step 3 : PDBForm """
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
194
195


196
def validate_pdb_exists(value):
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
197
    if not (pdb_entry_exists(value)):
Hervé  MENAGER's avatar
Hervé MENAGER committed
198
        raise ValidationError(
199
            "PDB entry not found: %(value)s", params={"value": value}, code="not_found"
200
        )
Hervé  MENAGER's avatar
Hervé MENAGER committed
201

202

203
204
205
206
207
208
209
210
211
212
213
def validate_pdb_uniprot_mapping_exists(value):
    try:
        get_pdb_uniprot_mapping(value)
    except EntryNotFoundError:
        raise ValidationError(
            "No Uniprot entries for this PDB entry: %(value)s",
            params={"value": value},
            code="no_mapping",
        )


214
class PDBForm(forms.Form):
215
216
217
218
    pdb_id = forms.CharField(
        label="PDB ID",
        max_length=4,
        widget=forms.TextInput(
219
220
            attrs={"placeholder": "e.g 3u85", "required": "required"}
        ),
221
222
223
        required=True,
        validators=[
            RegexValidator(
224
225
226
227
                "^[0-9][a-zA-Z0-9]{3}$",
                message="PDB ID must be 1 numeric + 3 alphanumeric characters",
            ),
            validate_pdb_exists,
228
            validate_pdb_uniprot_mapping_exists,
229
230
        ],
    )
231

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
232
    def get_proteins(self):
233
        pdb_id = self.cleaned_data["pdb_id"]
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
234
235
236
        protein_ids = []
        uniprot_ids = get_pdb_uniprot_mapping(pdb_id)
        for uniprot_id in uniprot_ids:
237
            p, created = models.Protein.objects.get_or_create(uniprot_id=uniprot_id)
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
238
239
            protein_ids.append(p.id)
        return protein_ids
240

Hervé  MENAGER's avatar
Hervé MENAGER committed
241

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
242
class ProteinForm(ModelForm):
Hervé  MENAGER's avatar
Hervé MENAGER committed
243
    class Meta:
244
        model = models.Protein
245
        exclude = ["recommended_name_long", "short_name"]
Hervé  MENAGER's avatar
Hervé MENAGER committed
246
        widgets = {
247
248
249
250
251
252
253
254
255
256
257
258
            "uniprot_id": forms.TextInput(
                attrs={"readonly": "readonly", "class": "readonly"}
            ),
            "gene_name": forms.TextInput(
                attrs={"readonly": "readonly", "class": "readonly"}
            ),
            "entry_name": forms.TextInput(
                attrs={"readonly": "readonly", "class": "readonly"}
            ),
            "organism": forms.TextInput(
                attrs={"readonly": "readonly", "class": "readonly"}
            ),
259
        }
260

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
261

Hervé  MENAGER's avatar
Hervé MENAGER committed
262
ProteinFormSet = modelformset_factory(
263
264
    models.Protein, exclude=("recommended_name_long", "short_name"), extra=0
)
265

Rachel TORCHET's avatar
Rachel TORCHET committed
266
""" Step 4 : ProteinDomainComplexTypeForm aka. Architecture """
267
TYPE_COMPLEX = (("inhibited", "Inhibited"), ("stabilized", "Stabilized"))
268

269
TYPE_CHOICES = (
270
271
272
273
274
275
276
277
278
279
280
281
    ("Inhib_Hetero2merAB", "Inhib_Hetero 2-mer AB"),
    ("Inhib_Homo2merA2", "Inhib_Homo 2-mer A2"),
    ("Inhib_Custom", "Inhib_Custom"),
    ("Stab_Hetero2merAB", "Stab_Hetero 2-mer AB"),
    ("Stab_Homo2merA2", "Stab_Homo 2-mer A2"),
    ("Stab_HomoLike2mer", "Stab_Homo-Like 2-mer A2"),
    ("Stab_Homo3merA3", "Stab_Homo 3-mer A3"),
    ("Stab_Homo3merA2", "Stab_Homo 3-mer A3 inhibited A2-dimer"),
    ("Stab_Homo4merA4", "Stab_Homo 4-mer A4"),
    ("Stab_RingHomo3mer", "Stab_Ring-Like 3-mer A3"),
    ("Stab_RingHomo5mer", "Stab_Ring-Like 5-mer A5"),
    ("Stab_Custom", "Stab_Custom"),
Hervé  MENAGER's avatar
Hervé MENAGER committed
282
283
)

284
285

class ProteinDomainComplexTypeForm(forms.Form):
286
287
288
    complexChoice = forms.CharField(
        label="PPI Complex Type", widget=forms.Select(choices=TYPE_COMPLEX)
    )
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
289
    complexType = forms.CharField(widget=forms.RadioSelect(choices=TYPE_CHOICES))
290

Rachel TORCHET's avatar
Rachel TORCHET committed
291
292

""" Step 5 : ProteinDomainComplexForm aka. Composition """
Hervé  MENAGER's avatar
Hervé MENAGER committed
293

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
294
295

class ProteinDomainComplexForm(ModelForm):
Hervé  MENAGER's avatar
Hervé MENAGER committed
296
    class Meta:
297
        model = models.ProteinDomainComplex
298
        fields = ["protein", "domain", "ppc_copy_nb"]
299

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
300

301
class ComplexCompositionForm(forms.Form):
302
    complex_type = forms.CharField(
303
        widget=forms.Select(
304
305
            choices=(), attrs={"class": "complex_readonly", "readonly": "readonly"}
        )
306
    )
307
    protein = forms.ModelChoiceField(
308
        queryset=models.Protein.objects.none(),
309
        required=True,
310
        widget=forms.Select(attrs={"class": "form-control"}),
311
312
        empty_label=None,
    )
313
    domain = forms.ModelChoiceField(
314
        queryset=models.Domain.objects.none(),
315
        required=False,
316
        widget=forms.Select(attrs={"class": "form-control"}),
317
        empty_label="Unknown",
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
318
    )
319
320
    # ppc_copy_nb is Complex>NbcopyP in the xlsx file of #33
    ppc_copy_nb = IntegerFieldDataList(
321
        label="Number of copies of the protein in the complex", required=True
322
    )
323
    # cc_nb is PPI>NbCopyComplex in the xlsx file of #33
324
    cc_nb = IntegerFieldDataList(label=_("cc_nb_verbose_name"), required=True)
325
326
    # ppp_copy_nb_per_p is Complex>NbPperPocket in the xlsx file of #33
    ppp_copy_nb_per_p = IntegerFieldDataList(
327
        label=_("ppp_copy_nb_per_p"),
328
        required=False,
329
330
331
        widget=forms.NumberInput(
            attrs={"class": "bound-complex-only", "data-required": True}
        ),
332
333
334
        # validators=[
        #     MinValueValidator(1),
        # ],
335
    )
336

337
338
339
340
341
342
343
344
345
346
347
348
    def __init__(
        self,
        protein_ids=None,
        has_bound=False,
        has_partner=False,
        nb_copy_bound=None,
        nb_per_pocket=None,
        nb_copy_partner=None,
        nb_copies_in_complex=None,
        *args,
        **kwargs,
    ):
349
        super(ComplexCompositionForm, self).__init__(*args, **kwargs)
350
        if protein_ids is not None:
351
352
353
354
355
356
            self.fields["protein"].queryset = models.Protein.objects.filter(
                pk__in=protein_ids
            )
            self.fields["domain"].queryset = models.Domain.objects.filter(
                protein__in=protein_ids
            )
357
358
        choices = []
        if has_partner:
359
            choices.append(("Partner", "Partner complex"))
360
        if has_bound:
361
            choices.append(("Bound", "Bound complex"))
362
363
364
365
366
367
368
369
370
371

        nb_copy = None
        if nb_copy_bound is not None:
            nb_copy = nb_copy_bound
        if nb_copy_partner is not None:
            if nb_copy is None:
                nb_copy = nb_copy_partner
            else:
                nb_copy = itertools.chain(nb_copy, nb_copy_partner)
        nb_copy = set(nb_copy)
372
        self.fields["ppc_copy_nb"].widget.attrs["datalist"] = set(nb_copy)
373
        if len(nb_copy) == 1:
374
            self.fields["ppc_copy_nb"].initial = next(iter(nb_copy))
375
376
            self.fields["ppc_copy_nb"].widget = forms.HiddenInput()

377
        self.fields["ppp_copy_nb_per_p"].widget.attrs["datalist"] = nb_per_pocket
378
        if len(nb_per_pocket) == 1:
379
            self.fields["ppp_copy_nb_per_p"].initial = next(iter(nb_per_pocket))
380
381
            self.fields["ppp_copy_nb_per_p"].widget = forms.HiddenInput()

382
        self.fields["cc_nb"].widget.attrs["datalist"] = nb_copies_in_complex
383
        if len(nb_copies_in_complex) == 1:
384
            self.fields["cc_nb"].initial = next(iter(nb_copies_in_complex))
385
            self.fields["cc_nb"].widget = forms.HiddenInput()
386

387
        self.fields["complex_type"].widget.choices = choices
388
389
390

    def full_clean(self):
        super().full_clean()
391
392
        if not self.is_bound:  # Stop further processing.
            return
393
394
395
396
        if (
            self.cleaned_data.get("complex_type", "") == "Bound"
            and self.cleaned_data.get("ppp_copy_nb_per_p", None) is None
        ):
397
            self.add_error("ppp_copy_nb_per_p", _("This field is required"))
398

399

400
401
402
403
404
405
406
407
class ComplexCompositionBaseFormSet(forms.BaseFormSet):
    def full_clean(self):
        """
        Ensure that the formset is sound, i.e that we have enough bound and partner
        """
        super().full_clean()
        if not self.is_bound:  # Stop further processing.
            return
408
        complex_type_dict = set()
409
410
411
        for form in self.forms:
            if form.cleaned_data.get("DELETE", False):
                continue
412
413
            complex_type_dict.add(form.cleaned_data.get("complex_type"))

414
415
416
417
418
419
420
421
422
423
424
425
    def __init__(
        self,
        has_bound=False,
        has_partner=False,
        form_kwargs=None,
        nb_copy_bound=None,
        nb_per_pocket=None,
        nb_copy_partner=None,
        nb_copies_in_complex=None,
        *args,
        **kwargs,
    ):
426
427
428
        form_kwargs = form_kwargs or {}
        form_kwargs["has_bound"] = has_bound
        form_kwargs["has_partner"] = has_partner
429
430
431
        form_kwargs["nb_copy_bound"] = nb_copy_bound
        form_kwargs["nb_copy_partner"] = nb_copy_partner
        form_kwargs["nb_per_pocket"] = nb_per_pocket
432
        form_kwargs["nb_copies_in_complex"] = nb_copies_in_complex
433
434
435
        self.has_partner = has_partner
        self.has_bound = has_bound
        super().__init__(form_kwargs=form_kwargs, *args, **kwargs)
436
437
438


ComplexCompositionFormSet = formset_factory(
439
    form=ComplexCompositionForm, formset=ComplexCompositionBaseFormSet, extra=0
440
)
Hervé  MENAGER's avatar
Hervé MENAGER committed
441

Rachel TORCHET's avatar
Rachel TORCHET committed
442
""" Step 6 : PpiForm """
Hervé  MENAGER's avatar
Hervé MENAGER committed
443

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
444

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
445
class PpiModelForm(ModelForm):
446
    pockets_nb = IntegerFieldDataList(
447
        label=_("Total number of pockets in the complex"), required=True
448
    )
449
    family_name = CharFieldDataList(
450
        data_class=models.PpiFamily,
451
        data_list=[],
452
        data_attr="name",
453
        label="PPI Family",
454
        max_length=30,
455
        required=True,
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
456
    )
457
    ols_diseases = forms.CharField(
458
        label=_("Search for associated diseases"),
459
460
461
462
463
464
465
466
        required=False,
        widget=forms.TextInput(
            attrs={
                "data-olswidget": "select",
                "data-suggest-header": _("Associate to"),
                "data-olsontology": "mondo",
                "data-selectpath": "https://www.ebi.ac.uk/ols/",
                "olstype": "",
Hervé  MENAGER's avatar
Hervé MENAGER committed
467
                "class": "",
468
            }
469
470
471
472
        ),
    )
    selected_diseases = forms.CharField(
        label=_("Associated diseases"),
473
        widget=forms.Textarea(attrs={"class": "d-none"}),
474
475
        required=False,
    )
476

Hervé  MENAGER's avatar
Hervé MENAGER committed
477
    class Meta:
478
        model = models.Ppi
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
479
        fields = (
480
481
482
483
484
485
486
            "id",
            "pdb_id",
            "family",
            "family_name",
            "symmetry",
            "pockets_nb",  # pockets_nb is Ppi.pockets_nb in the xlsx file of #33
            "ols_diseases",
Rachel  TORCHET's avatar
Rachel TORCHET committed
487
            "selected_diseases",
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
488
489
        )
        widgets = {
490
491
492
            "id": forms.HiddenInput(),
            "family": forms.HiddenInput(),
            "diseases": BoostrapSelectMultiple(
493
                allSelectedText=_("All diseases used"),
494
                nonSelectedText=_(""),
495
496
                SelectedText=_(" diseases selected"),
            ),
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
497
498
        }

499
    def __init__(self, symmetry=None, nb_pockets=None, initial=None, *args, **kwargs):
500
        super().__init__(initial=initial, *args, **kwargs)
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
501
        self.fields["pdb_id"].widget.attrs["readonly"] = True
502
        self.fields["symmetry"].empty_choice = None
503
        if nb_pockets is not None:
504
            self.fields["pockets_nb"].widget.attrs["datalist"] = nb_pockets
505
            if len(nb_pockets) == 1:
506
                self.fields["pockets_nb"].initial = next(iter(nb_pockets))
507
                self.fields["pockets_nb"].widget = forms.HiddenInput()
508
509
510
511
        if symmetry is not None:
            self.fields["symmetry"].queryset = symmetry
            if symmetry.count() == 1:
                self.fields["symmetry"].initial = symmetry.first()
512
                self.fields["symmetry"].widget = forms.HiddenInput()
Hervé  MENAGER's avatar
Hervé MENAGER committed
513

514
515
    def full_clean(self):
        super().full_clean()
516
517
518
519
520
        if hasattr(self, "cleaned_data") and "family_name" in self.cleaned_data:
            family, created = models.PpiFamily.objects.get_or_create(
                name=self.cleaned_data["family_name"]
            )
            self.cleaned_data["family"] = family.pk
521

522
523
524
525
    def save(self, commit=True):
        super().save(commit=commit)
        if not commit:
            return self.instance
526
        for new_disease in self.cleaned_data["selected_diseases"].split("\n"):
527
528
529
            new_disease = new_disease.strip()
            if len(new_disease) == 0:
                continue
530
            new_disease = new_disease.split(";")
531
532
533
            if len(new_disease) > 1:
                name = new_disease[1]
                identifier = new_disease[2]
534
            else:
535
536
                name = new_disease[0]
                identifier = None
537
538
539
            disease, created = models.Disease.objects.get_or_create(
                name=name, identifier=identifier
            )
540
            self.instance.diseases.add(disease)
541
542
543
544
        self.instance.family = models.PpiFamily.objects.get(
            id=self.cleaned_data["family"]
        )
        self.instance.save(autofill=True)
545
        return self.instance
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
546

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
547

Rachel TORCHET's avatar
Rachel TORCHET committed
548
""" Step 7 : CompoundForm """
549
TYPE_MOLECULE = (("smiles", "smiles"), ("iupac", "iupac"), ("sketch", "sketch"))
550
551


552
class CompoundForm(forms.Form):
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
553
    compound_name = forms.CharField(
Hervé  MENAGER's avatar
Hervé MENAGER committed
554
        label=_("compound_name_label"), required=True, help_text=_(""),
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
555
    )
556
    ligand_id = forms.CharField(
557
        label=_("PDB Ligand ID"),
558
559
        max_length=3,  # from models.Compound.ligand_id
        required=False,  # from models.Compound.ligand_id
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
560
    )
561
562
    molecule_smiles = forms.CharField(
        label=_("molecule_smiles_label"),
563
        help_text=_(""),
564
        required=False,
Hervé  MENAGER's avatar
Hervé MENAGER committed
565
566
567
568
569
570
571
        widget=forms.Textarea(
            attrs={
                "class": "molecule-composition",
                "rows": "9",
                "oninput": "showCanvas(this)",
            }
        ),
572
573
    )
    molecule_iupac = forms.CharField(
574
        label=_("molecule_iupac_label"),
Rachel TORCHET's avatar
Rachel TORCHET committed
575
        help_text=_(""),
576
        required=False,
Hervé  MENAGER's avatar
Hervé MENAGER committed
577
        widget=forms.Textarea(attrs={"class": "molecule-composition", "rows": "9"}),
578
    )
579

580
    common_name = forms.CharField(label="Common Name", max_length=20, required=False)
581
    is_macrocycle = forms.BooleanField(
582
        label=_("is_macrocycle_verbose_name"), required=False
583
    )
584

585
586
    def full_clean(self):
        super().full_clean()
Hervé  MENAGER's avatar
Hervé MENAGER committed
587
588
589
        if (
            not self.is_bound or self.cleaned_data.get("DELETE") is True
        ):  # Stop further processing.
590
            return
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
        smiles = None
        if (
            self.cleaned_data.get("molecule_smiles", "") == ""
            and self.cleaned_data.get("molecule_iupac", "") == ""
            or self.cleaned_data.get("molecule_smiles", "") != ""
            and self.cleaned_data.get("molecule_iupac", "") != ""
        ):
            self.add_error(
                "molecule_smiles",
                "You have to provide either its IUPAC or its smiles, but not both",
            )
            self.add_error(
                "molecule_iupac",
                "You have to provide either its IUPAC or its smiles, but not both",
            )
606
607
        if self.cleaned_data.get("compound_name", "") == "":
            self.add_error("compound_name", "You have to provide it")
608
        molecule_iupac = self.cleaned_data.get("molecule_iupac", "")
609
610
611
612
        if (
            len(molecule_iupac) > 0
            and not models.Compound.objects.filter(iupac_name=molecule_iupac).exists()
        ):
613
            try:
614
615
616
                self.cleaned_data["computed_smiles"] = ws.convert_iupac_to_smiles(
                    self.cleaned_data["molecule_iupac"]
                )
617
                smiles = self.cleaned_data["computed_smiles"]
618
619
            except ws.EntryNotFoundError as e:
                self.add_error("molecule_iupac", str(e))
620
        else:
621
            smiles = self.cleaned_data["molecule_smiles"]
622
623
624

        if smiles is not None:
            ligand_id = self.cleaned_data.get("ligand_id", None)
625
            c_smiles = models.Compound.objects.filter(canonical_smile=smiles).first()
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
            c_ligand = (
                models.Compound.objects.filter(ligand_id=ligand_id).first()
                if ligand_id is not None
                else None
            )
            if (
                c_smiles is not None
                and c_smiles.ligand_id is not None
                and ligand_id is not None
                and ligand_id != c_smiles.ligand_id
            ):
                self.add_error(
                    "ligand_id",
                    "The PDB Ligand ID differ from the one already known (%s)"
                    % c_smiles.ligand_id,
                )
642
            if c_ligand is not None and smiles != c_ligand.canonical_smile:
643
644
645
646
647
                self.add_error(
                    "ligand_id",
                    "The PDB Ligand ID you provided is associated to an other "
                    "canonical smiles: %s" % c_ligand.canonical_smile,
                )
648
649
650


class CompoundBaseFormSet(forms.BaseFormSet):
651
    def clean(self):
652
        """Checks that two compounds do not have the same compound_name (the name in the publication)."""
653
654
655
656
        if any(self.errors):
            # Don't bother validating the formset unless each form is valid on its own
            return
        compound_names = set()
657
        common_names = set()
658
659
        smiles_set = set()
        iupac_set = set()
Hervé  MENAGER's avatar
Hervé MENAGER committed
660
        ligand_set = set()  # noqa: F841 FIXME
661
662
663
        for form in self.forms:
            if form.cleaned_data.get("DELETE", False):
                continue
664
            compound_name = form.cleaned_data["compound_name"]
665
666
            if compound_name in compound_names:
                raise forms.ValidationError(
667
668
669
670
                    _(
                        "Compound must have distinct compound_name_label as (explanation). Incriminated value:'%s'"
                    )
                    % compound_name
671
672
                )
            compound_names.add(compound_name)
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
673
674
            common_name = form.cleaned_data.get("common_name", "")
            if common_name != "" and common_name in common_names:
675
                raise forms.ValidationError(
676
677
678
679
                    _(
                        "Compound must have distinct common_name_label as (explanation). Incriminated value:'%s'"
                    )
                    % common_name
680
681
                )
            common_names.add(common_name)
682

683
684
            smiles = form.cleaned_data["molecule_smiles"]
            if len(smiles) > 0:
685
686
                if smiles in smiles_set:
                    raise forms.ValidationError(
687
688
                        _("Compound must have distinct smiles. Incriminated value:'%s'")
                        % smiles
689
690
691
                    )
                smiles_set.add(smiles)

692
693
            iupac = form.cleaned_data["molecule_iupac"]
            if len(iupac) > 0:
694
695
                if iupac in iupac_set:
                    raise forms.ValidationError(
696
697
                        _("Compound must have distinct iupac. Incriminated value:'%s'")
                        % iupac
698
699
700
                    )
                iupac_set.add(iupac)

Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
701
702
703
704
705
706
707
    def __init__(self, show_is_a_ligand=False, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.show_is_a_ligand = show_is_a_ligand

    def add_fields(self, form, index):
        super().add_fields(form, index)
        if not self.show_is_a_ligand:
708
            del form.fields["ligand_id"]
Bryan  BRANCOTTE's avatar
Bryan BRANCOTTE committed
709

710

711
def compound_formset_factory(min_num: int = None, max_num: int = None):
712
    return formset_factory(
713
        form=CompoundForm,
714
        formset=CompoundBaseFormSet,
715
716
717
718
719
720
721
722
723
724
        can_delete=True,
        extra=0,
        min_num=min_num,
        validate_min=min_num is not None,
        max_num=max_num,
        validate_max=max_num is not None,
    )


CompoundFormSet = compound_formset_factory(min_num=1)
725

726
727
728
729
730
731
""" Step 8.0 : Toolkit for inlined formet """


class BaseInlineNestedFormSet(forms.BaseInlineFormSet):
    def add_fields(self, form, index):
        super().add_fields(form, index)
732
        delete_field = form.fields.pop("DELETE")
733
        delete_field.widget.attrs["onclick"] = "delete_button_clicked(this)"
734
735
736
        delete_field.widget.attrs["class"] = (
            delete_field.widget.attrs.get("class", "") + " formset-item-delete"
        )
737
738
739
740
741
        delete_field.label = _("DELETE_label")
        delete_help_text = ugettext("DELETE_help_text")
        if delete_help_text != "DELETE_help_text":
            delete_field.help_text = delete_help_text
        form.fields = OrderedDict(
742
            itertools.chain([("DELETE", delete_field)], form.fields.items())
743
744
745
        )

    def is_valid(self):
746
747
748
749
        """
        Check is the forms are valid, but also all their nested forms.
        :return:
        """
750
751
752
        result = super().is_valid()
        if self.is_bound:
            for form in self.forms:
753
754
755
756
                if self.can_delete and self._should_delete_form(form):
                    # This form is going to be deleted so any of its errors
                    # shouldn't cause the entire formset to be invalid.
                    continue
757
                result = result and form.is_valid()
758
                if hasattr(form, "nested"):
759
760
761
762
                    result = result and form.nested.is_valid()

        return result

763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
    def save_new(self, form, commit=True):
        # Ensure the latest copy of the related instance is present on each
        # form (it may have been saved after the formset was originally
        # instantiated).
        if isinstance(self.instance, dict):
            # if the instance is a dict, we override the normal behavior and set form attributes from the keys with
            # their associated value
            for k, v in self.instance.items():
                setattr(form.instance, k, v)
        else:
            setattr(form.instance, self.fk.name, self.instance)
        # Use commit=False so we can assign the parent key afterwards, then
        # save the object.
        obj = form.save(commit=False)
        if isinstance(self.instance, dict):
            # if the instance is a dict, we override the normal behavior and set attributes of the newly created objects
            # from the keys with their associated value
            for k, v in self.instance.items():
                setattr(obj, k, v)
        else:
            pk_value = getattr(self.instance, self.fk.remote_field.field_name)
784
            setattr(obj, self.fk.get_attname(), getattr(pk_value, "pk", pk_value))
785
786
787
        if commit:
            obj.save()
        # form.save_m2m() can be called via the formset later on if commit=False
788
        if commit and hasattr(form, "save_m2m"):
789
790
791
            form.save_m2m()
        return obj

792
793
794
795
    def save(self, commit=True):
        result = super().save(commit=commit)

        for form in self.forms:
796
            if hasattr(form, "nested"):
797
798
799
800
                if not self._should_delete_form(form):
                    form.nested.save(commit=commit)
        return result

801

802
803
804
""" Step 8.1 : TestActivityDescriptionForm """


805
class CompoundActivityResultForm(ModelForm):
806
    compound_name = forms.ChoiceField(choices=(), required=True)
807
    activity_mol = forms.DecimalField(
808
        label="Activity", required=True, max_digits=15, decimal_places=10, min_value=0,
809
810
811
812
    )
    activity_unit = forms.CharField(
        label="Activity unit",
        max_length=5,
813
        required=False,
814
815
816
817
818
819
820
821
822
        widget=widgets.Select(
            choices=(
                (None, "-----"),
                ("1", "mol"),
                ("1e-3", "mmol"),
                ("1e-6", "µmol"),
                ("1e-9", "nmol"),
                ("1e-12", "pmol"),
            ),
823
        ),
824
        help_text="Only required if 'activity type' is not Kd ratio.",
825
    )
826
827

    class Meta:
828
        model = models.CompoundActivityResult
829
830
831
832
833
834
835
836
837
838
839
        fields = (
            "compound_name",
            "modulation_type",
            "activity",
            "activity_type",
            "activity_mol",
            "activity_unit",
        )
        widgets = {
            "activity": widgets.HiddenInput(),
        }
840

841
842
843
844
845
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for f in self.fields.values():
            f.widget.attrs.update({"class": "col-3"})

846
847
848
849
850
851
852
    def has_changed(self):
        """
        Test if the form has changed, we consider that it has not changed if it is not linked to an actual instance and
        the only change is that the DELETE checkbox have been set to True
        :return:
        """
        # If only the delete button was checked, and there is no id, it has not changed and thus should be ignored.
853
854
855
856
857
        if (
            len(self.changed_data) == 1
            and self.changed_data[0] == "DELETE"
            and self.data.get("id", None) is None
        ):
858
859
860
            return False
        return super().has_changed()

861
862
    def clean(self):
        cleaned_data = super().clean()
863
864
865
        if "activity_mol" not in cleaned_data:
            self.add_error("activity_mol", "Must be provided")
            return cleaned_data
866
867
868
869
870
        if cleaned_data["activity_mol"] == 0:
            self.add_error("activity_mol", "Must be greater than 0")
            return cleaned_data
        if cleaned_data["activity_mol"] is None:
            return cleaned_data
871
872
873
874
875
        if (
            cleaned_data.get("activity_unit", "") == ""
            and cleaned_data.get("activity_type", None) != "KdRat"
        ):
            self.add_error("activity_unit", "Unit is required if type is not Kd ratio")
876
877
            return cleaned_data
        try:
878
879
880
881
882
883
            if self.cleaned_data["activity_type"] != "KdRat":
                d = Decimal(
                    -log10(
                        Decimal(self.cleaned_data["activity_mol"])
                        * Decimal(self.cleaned_data["activity_unit"])
                    )
884
                )
885
886
887
888
889
890
891
                d = d.quantize(
                    Decimal(10)
                    ** -self.instance._meta.get_field("activity").decimal_places
                )
                self.cleaned_data["activity"] = d
            else:
                self.cleaned_data["activity"] = self.cleaned_data["activity_mol"]
892
        except Exception as e:
893
894
895
896
897
898
899
900
            self.add_error(
                "activity_mol",
                "Unknown error when computing -log10(activity): %s" % str(e),
            )
            self.add_error(
                "activity_unit",
                "Unknown error when computing -log10(activity): %s" % str(e),
            )
901
902
        return cleaned_data

903
904
905
    def save(self, commit=True):
        # right before an actual save, we set the foreign key that have been created in the meantime from unique
        # identifier (compound_name) we where provided at the initialization of the form.
906
907
        if hasattr(self, "cleaned_data"):
            if "compound_name" in self.cleaned_data:
908
                try:
909
910
911
912
                    self.instance.compound = self.compounds[
                        self.cleaned_data["compound_name"]
                    ]
                except KeyError:
913
914
915
916
917
                    pass
        return super().save(commit=commit)


class CompoundActivityResultBaseInlineNestedFormSet(BaseInlineNestedFormSet):
918
    __compound_names = []
919
920
921
922
923
924
925
926
    # pIC50, pEC50, etc. activity types are labelled below as IC50, EC50, etc.
    # specifically because the user enters them in these forms as the former
    # value but they are converted to and stored in the latter.
    __activity_types = [
        ("pIC50", "IC50 (half maximal inhibitory concentration)"),
        ("pEC50", "EC50 (half maximal effective concentration)"),
        ("pKd", "Kd (dissociation constant)"),
        ("pKi", "Ki (inhibition constant)"),
927
        ("KdRat", "Kd ratio (Kd w/o ligand / Kd with ligand"),
928
    ]
929
930
931
932

    def add_fields(self, form, index):
        super().add_fields(form, index)
        form.fields["compound_name"].choices = self.__compound_names
933
        form.fields["activity_type"].choices = self.__activity_types
934

935
936
937
    def set_modulation_type(self, modulation_type):
        for form in self.forms:
            form.fields["modulation_type"].choices = [
938
939
940
                (key, msg)
                for key, msg in form.fields["modulation_type"].choices
                if key == modulation_type
941
942
            ]

943
944
945
946
947
948
    def set_compound_names(self, compound_names):
        """
        Set the choices of the field compound_name to the compound_names provided
        :param compound_names:
        :return:
        """
949
950
951
        self.__compound_names = list(
            itertools.zip_longest(compound_names, compound_names)
        )
952
953
954
955
956
957
958
959
960
961
962

    def set_compounds(self, compounds):
        """
        Provided to the form the compounds dictionary received from the wizard

        :param complexes:
        :return:
        """
        for form in self.forms:
            form.compounds = compounds

963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
    def full_clean(self):
        """
        Validate the unique_together = ( ('compound', 'test_activity_description', 'activity_type'),)
         of CompoundActivityResult
        """

        super().full_clean()
        if not self.is_bound:  # Stop further processing.
            return
        names = set()
        for form in self.forms:
            if form.cleaned_data.get("DELETE", False):
                continue

            # if the form is empty
978
979
980
981
982
            if not (
                form.has_changed()
                and "compound_name" in form.cleaned_data
                and "activity_type" in form.cleaned_data
            ):
983
984
                continue

985
986
987
988
989
            name = (
                form.cleaned_data["compound_name"]
                + "__"
                + form.cleaned_data["activity_type"]
            )
990
            i