From 9323a9f7d780fcba7c7aca715390740ddfd03b60 Mon Sep 17 00:00:00 2001 From: jebus Date: Thu, 3 Jul 2025 07:17:04 +1200 Subject: [PATCH] Implement Compound CRUD (#22) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/22 --- epdb/models.py | 115 +++++++--- epdb/views.py | 21 +- templates/actions/objects/compound.html | 4 + .../collections/new_compound_modal.html | 44 +++- .../modals/objects/add_structure_modal.html | 78 +++++++ templates/objects/compound.html | 43 +++- tests/test_compound_model.py | 196 ++++++++++++++++++ utilities/chem.py | 8 +- utilities/plugin.py | 3 +- 9 files changed, 472 insertions(+), 40 deletions(-) create mode 100644 templates/modals/objects/add_structure_modal.html create mode 100644 tests/test_compound_model.py diff --git a/epdb/models.py b/epdb/models.py index eed66553..c015c6cd 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -281,58 +281,121 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin): def structures(self): return CompoundStructure.objects.filter(compound=self) + @property + def normalized_structure(self): + return CompoundStructure.objects.get(compound=self, normalized_structure=True) + @property def url(self): return '{}/compound/{}'.format(self.package.url, self.uuid) - # @property - # def related_pathways(self): - # pathways = Node.objects.filter(node_labels__in=[self.default_structure]).values_list('pathway', flat=True) - # return Pathway.objects.filter(package=self.package, id__in=set(pathways)).order_by('name') + @transaction.atomic + def set_default_structure(self, cs: 'CompoundStructure'): + if cs.compound != self: + raise ValueError("Attempt to set a CompoundStructure stored in a different compound as default") - # @property - # def related_reactions(self): - # return ( - # Reaction.objects.filter(package=self.package, educts__in=[self.default_structure]) - # | - # Reaction.objects.filter(package=self.package, products__in=[self.default_structure]) - # ).order_by('name') + self.default_structure = cs + self.save() + + @property + def related_pathways(self): + pathways = Node.objects.filter(node_labels__in=[self.default_structure]).values_list('pathway', flat=True) + return Pathway.objects.filter(package=self.package, id__in=set(pathways)).order_by('name') + + @property + def related_reactions(self): + return ( + Reaction.objects.filter(package=self.package, educts__in=[self.default_structure]) + | + Reaction.objects.filter(package=self.package, products__in=[self.default_structure]) + ).order_by('name') @staticmethod @transaction.atomic - def create(package: Package, smiles: str, name: str = None, description: str = None, *args, **kwargs): + def create(package: Package, smiles: str, name: str = None, description: str = None, *args, **kwargs) -> 'Compound': - # Pre check - # Validity of SMILES etc + if smiles is None or smiles == '': + raise ValueError('SMILES is required') + smiles = smiles.strip() + + parsed = FormatConverter.from_smiles(smiles) + if parsed is None: + raise ValueError('Given SMILES is invalid') + + standardized_smiles = FormatConverter.standardize(smiles) + + # Check if we find a direct match for a given SMILES if CompoundStructure.objects.filter(smiles=smiles, compound__package=package).exists(): return CompoundStructure.objects.get(smiles=smiles, compound__package=package).compound + # Check if we can find the standardized one + if CompoundStructure.objects.filter(smiles=standardized_smiles, compound__package=package).exists(): + # TODO should we add a structure? + return CompoundStructure.objects.get(smiles=standardized_smiles, compound__package=package).compound + # Generate Compound c = Compound() c.package = package - if name is not None: + + # For name and description we have defaults so only set them if they carry a real value + if name is not None and name != '': c.name = name - if description is not None: + if description is not None and description != '': c.description = description c.save() - normalized_smiles = smiles # chem.normalize(smiles) + is_standardized = standardized_smiles == smiles - if normalized_smiles != smiles: - _ = CompoundStructure.create(c, normalized_smiles, name='Normalized structure of {}'.format(name), + if not is_standardized: + _ = CompoundStructure.create(c, standardized_smiles, name='Normalized structure of {}'.format(name), description='{} (in its normalized form)'.format(description), normalized_structure=True) - cs = CompoundStructure.create(c, smiles, name=name, description=description) + cs = CompoundStructure.create(c, smiles, name=name, description=description, normalized_structure=is_standardized) c.default_structure = cs c.save() return c + @transaction.atomic + def add_structure(self, smiles: str, name: str = None, description: str = None, default_structure: bool = False, + *args, **kwargs) -> 'CompoundStructure': + + if smiles is None or smiles == '': + raise ValueError('SMILES is required') + + smiles = smiles.strip() + + parsed = FormatConverter.from_smiles(smiles) + if parsed is None: + raise ValueError('Given SMILES is invalid') + + standardized_smiles = FormatConverter.standardize(smiles) + + is_standardized = standardized_smiles == smiles + + if self.normalized_structure.smiles != standardized_smiles: + raise ValueError('The standardized SMILES does not match the compounds standardized one!') + + if is_standardized: + CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package) + + # Check if we find a direct match for a given SMILES and/or its standardized SMILES + if CompoundStructure.objects.filter(smiles__in=smiles, compound__package=self.package).exists(): + return CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package) + + cs = CompoundStructure.create(self, smiles, name=name, description=description, normalized_structure=is_standardized) + + if default_structure: + self.default_structure = cs + self.save() + + return cs + class Meta: unique_together = [('uuid', 'package')] @@ -391,6 +454,10 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin): def InChIKey(self): return FormatConverter.InChIKey(self.smiles) + @property + def canonical_smiles(self): + return FormatConverter.canonicalize(self.smiles) + @property def as_svg(self): return IndigoUtils.mol_to_svg(self.smiles) @@ -401,10 +468,10 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): # I think this only affects Django Admin which we are barely using # # https://github.com/django-polymorphic/django-polymorphic/issues/229 - # _non_polymorphic = models.Manager() - # - # class Meta: - # base_manager_name = '_non_polymorphic' + _non_polymorphic = models.Manager() + + class Meta: + base_manager_name = '_non_polymorphic' @abc.abstractmethod def apply(self, *args, **kwargs): diff --git a/epdb/views.py b/epdb/views.py index 5e113205..b94eef6a 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -684,7 +684,7 @@ def package_compound(request, package_uuid, compound_uuid): return render(request, 'objects/compound.html', context) - if request.method == 'POST': + elif request.method == 'POST': if hidden := request.POST.get('hidden', None): if hidden == 'delete-compound': current_compound.delete() @@ -706,6 +706,8 @@ def package_compound(request, package_uuid, compound_uuid): return redirect(current_compound.url) else: return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() # https://envipath.org/package//compound//structure @@ -724,7 +726,7 @@ def package_compound_structures(request, package_uuid, compound_uuid): reviewed_compound_structure_qs = CompoundStructure.objects.none() unreviewed_compound_structure_qs = CompoundStructure.objects.none() - if package.reviewed: + if current_package.reviewed: reviewed_compound_structure_qs = current_compound.structures.order_by('name') else: unreviewed_compound_structure_qs = current_compound.structures.order_by('name') @@ -734,13 +736,24 @@ def package_compound_structures(request, package_uuid, compound_uuid): return render(request, 'collections/objects_list.html', context) + elif request.method == 'POST': + structure_name = request.POST.get('structure-name') + structure_smiles = request.POST.get('structure-smiles') + structure_description = request.POST.get('structure-description') + + cs = current_compound.add_structure(structure_smiles, structure_name, structure_description) + + return redirect(cs.url) + + else: + return HttpResponseBadRequest() # https://envipath.org/package//compound//structure/ def package_compound_structure(request, package_uuid, compound_uuid, structure_uuid): current_user = _anonymous_or_real(request) current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_compound = Compound.objects.get(package=current_package, uuid=compound_uuid) - current_structure = CompoundStructure.get(compound=current_compound, uuid=structure_uuid) + current_structure = CompoundStructure.objects.get(compound=current_compound, uuid=structure_uuid) if request.method == 'GET': context = get_base_context(request) @@ -818,6 +831,8 @@ def package_rules(request, package_uuid): r = Rule.create(current_package, rule_type, name=rule_name, description=rule_description, **params) return redirect(r.url) + else: + return HttpResponseBadRequest() # https://envipath.org/package//rule/ def package_rule(request, package_uuid, rule_uuid): diff --git a/templates/actions/objects/compound.html b/templates/actions/objects/compound.html index d2c07dc4..ab69f18a 100644 --- a/templates/actions/objects/compound.html +++ b/templates/actions/objects/compound.html @@ -2,6 +2,10 @@ Edit Compound +
  • + + Add Structure +
  • Delete Compound diff --git a/templates/modals/collections/new_compound_modal.html b/templates/modals/collections/new_compound_modal.html index 0bb34762..fbb2ab96 100644 --- a/templates/modals/collections/new_compound_modal.html +++ b/templates/modals/collections/new_compound_modal.html @@ -16,12 +16,13 @@ + +

    -

    @@ -35,16 +36,43 @@ diff --git a/templates/modals/objects/add_structure_modal.html b/templates/modals/objects/add_structure_modal.html new file mode 100644 index 00000000..fae4e026 --- /dev/null +++ b/templates/modals/objects/add_structure_modal.html @@ -0,0 +1,78 @@ +{% load static %} + + diff --git a/templates/objects/compound.html b/templates/objects/compound.html index bb161990..9fe6657f 100644 --- a/templates/objects/compound.html +++ b/templates/objects/compound.html @@ -4,6 +4,7 @@ {% block action_modals %} {% include "modals/objects/edit_compound_modal.html" %} +{% include "modals/objects/add_structure_modal.html" %} {% include "modals/objects/delete_compound_modal.html" %} {% endblock action_modals %} @@ -71,7 +72,20 @@ - + + +
    +
    + {{ compound.default_structure.canonical_smiles }} +
    +
    + + - + +
    +
    + {% for r in compound.related_reactions %} + {{ r.name }} ({{ r.package.name }}) + {% endfor %} +
    +
    +
    +

    + Pathways +

    +
    +
    +
    + {% for r in compound.related_pathways %} + {{ r.name }} ({{ r.package.name }}) + {% endfor %} +
    +
    diff --git a/tests/test_compound_model.py b/tests/test_compound_model.py new file mode 100644 index 00000000..74bafe98 --- /dev/null +++ b/tests/test_compound_model.py @@ -0,0 +1,196 @@ +from django.test import TestCase + +from epdb.logic import PackageManager +from epdb.models import Compound, User, CompoundStructure + + +class CompoundTest(TestCase): + fixtures = ["test_fixture.json.gz"] + + def setUp(self): + pass + + @classmethod + def setUpClass(cls): + super(CompoundTest, cls).setUpClass() + cls.user = User.objects.get(username='anonymous') + cls.package = PackageManager.create_package(cls.user, 'Anon Test Package', 'No Desc') + + @classmethod + def tearDownClass(cls): + pass + + def tearDown(self): + pass + + def test_smoke(self): + c = Compound.create( + self.package, + smiles='C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C4=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F', + name='Afoxolaner', + description='No Desc' + ) + + self.assertEqual(c.default_structure.smiles, + 'C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C4=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F') + self.assertEqual(c.name, 'Afoxolaner') + self.assertEqual(c.description, 'No Desc') + + def test_missing_smiles(self): + with self.assertRaises(ValueError): + _ = Compound.create( + self.package, + smiles=None, + name='Afoxolaner', + description='No Desc' + ) + + with self.assertRaises(ValueError): + _ = Compound.create( + self.package, + smiles='', + name='Afoxolaner', + description='No Desc' + ) + + def test_smiles_are_trimmed(self): + c = Compound.create( + self.package, + smiles=' C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C4=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F ', + name='Afoxolaner', + description='No Desc' + ) + + self.assertEqual(c.default_structure.smiles, + 'C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C4=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F') + + def test_name_and_description_optional(self): + c = Compound.create( + self.package, + smiles='C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C4=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F', + ) + + self.assertEqual(c.name, 'no name') + self.assertEqual(c.description, 'no description') + + def test_empty_name_and_description_are_ignored(self): + c = Compound.create( + self.package, + smiles='C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C4=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F', + name='', + description='', + ) + + self.assertEqual(c.name, 'no name') + self.assertEqual(c.description, 'no description') + + def test_deduplication(self): + c1 = Compound.create( + self.package, + smiles='C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C4=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F', + name='Afoxolaner', + description='No Desc' + ) + + c2 = Compound.create( + self.package, + smiles='C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C4=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F', + name='Afoxolaner', + description='No Desc' + ) + + # Check if create detects that this Compound already exist + # In this case the existing object should be returned + self.assertEqual(c1.pk, c2.pk) + self.assertEqual(len(self.package.compounds), 1) + + def test_wrong_smiles(self): + with self.assertRaises(ValueError): + _ = Compound.create( + self.package, + smiles='C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F', + name='Afoxolaner', + description='No Desc' + ) + + def test_create_with_standardized_smiles(self): + c = Compound.create( + self.package, + smiles='O=C(O)C1=CC=C([N+](=O)[O-])C=C1', + name='Standardized SMILES', + description='No Desc' + ) + self.assertEqual(len(c.structures.all()), 1) + + cs = c.structures.all()[0] + self.assertEqual(cs.normalized_structure, True) + self.assertEqual(cs.smiles, 'O=C(O)C1=CC=C([N+](=O)[O-])C=C1') + + def test_create_with_non_standardized_smiles(self): + c = Compound.create( + self.package, + smiles='[O-][N+](=O)c1ccc(C(=O)[O-])cc1', + name='Non Standardized SMILES', + description='No Desc' + ) + + self.assertEqual(len(c.structures.all()), 2) + for cs in c.structures.all(): + if cs.normalized_structure: + self.assertEqual(cs.smiles, 'O=C(O)C1=CC=C([N+](=O)[O-])C=C1') + break + else: + # Loop finished without break, lets fail... + self.assertEqual(1, 2) + + def test_add_structure_smoke(self): + c = Compound.create( + self.package, + smiles='O=C(O)C1=CC=C([N+](=O)[O-])C=C1', + name='Standardized SMILES', + description='No Desc' + ) + + c.add_structure('[O-][N+](=O)c1ccc(C(=O)[O-])cc1', 'Non Standardized SMILES') + + self.assertEqual(len(c.structures.all()), 2) + + def test_add_structure_with_different_normalized_smiles(self): + c = Compound.create( + self.package, + smiles='O=C(O)C1=CC=C([N+](=O)[O-])C=C1', + name='Standardized SMILES', + description='No Desc' + ) + + with self.assertRaises(ValueError): + c.add_structure( + 'C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C4=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F', + 'Different Standardized SMILES') + + def test_delete(self): + c = Compound.create( + self.package, + smiles='O=C(O)C1=CC=C([N+](=O)[O-])C=C1', + name='Standardization Test', + description='No Desc' + ) + + c.delete() + + self.assertEqual(Compound.objects.filter(package=self.package).count(), 0) + self.assertEqual(CompoundStructure.objects.filter(compound__package=self.package).count(), 0) + + def test_set_as_default_structure(self): + c1 = Compound.create( + self.package, + smiles='O=C(O)C1=CC=C([N+](=O)[O-])C=C1', + name='Standardized SMILES', + description='No Desc' + ) + default_structure = c1.default_structure + + c2 = c1.add_structure('[O-][N+](=O)c1ccc(C(=O)[O-])cc1', 'Non Standardized SMILES') + + c1.set_default_structure(c2) + self.assertNotEqual(default_structure, c2) diff --git a/utilities/chem.py b/utilities/chem.py index 9e2353f1..b51d6652 100644 --- a/utilities/chem.py +++ b/utilities/chem.py @@ -70,13 +70,17 @@ class FormatConverter(object): return Chem.MolFromSmiles(smiles) @staticmethod - def to_smiles(mol): - return Chem.MolToSmiles(mol) + def to_smiles(mol, canonical=False): + return Chem.MolToSmiles(mol, canonical=canonical) @staticmethod def InChIKey(smiles): return Chem.MolToInchiKey(FormatConverter.from_smiles(smiles)) + @staticmethod + def canonicalize(smiles: str): + return FormatConverter.to_smiles(FormatConverter.from_smiles(smiles), canonical=True) + @staticmethod def maccs(smiles): mol = Chem.MolFromSmiles(smiles) diff --git a/utilities/plugin.py b/utilities/plugin.py index 03d7b8e4..97d295e1 100644 --- a/utilities/plugin.py +++ b/utilities/plugin.py @@ -36,7 +36,8 @@ def ensure_plugins_installed(): if not is_installed(package_name): install_wheel(wheel_path) else: - print(f"Plugin already installed: {package_name}") + # print(f"Plugin already installed: {package_name}") + pass def discover_plugins(_cls: Type = None) -> Dict[str, Type]: