From 00d9188c0c8fb5ce43be0975bd7bbca527b8f5b8 Mon Sep 17 00:00:00 2001 From: jebus Date: Thu, 28 Aug 2025 06:27:11 +1200 Subject: [PATCH] Copy Objects between Packages (#59) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/59 --- epdb/logic.py | 128 ++++++++- ...abilitydomain_url_compound_url_and_more.py | 3 +- ...abilitydomain_url_compound_url_and_more.py | 3 +- epdb/models.py | 260 ++++++++++++++++-- epdb/views.py | 36 ++- templates/actions/objects/compound.html | 6 + templates/actions/objects/pathway.html | 6 + templates/actions/objects/reaction.html | 6 + templates/actions/objects/rule.html | 6 + .../objects/generic_copy_object_modal.html | 61 ++++ templates/objects/composite_rule.html | 1 + templates/objects/compound.html | 1 + templates/objects/pathway.html | 1 + templates/objects/reaction.html | 1 + templates/objects/simple_rule.html | 1 + tests/test_copy_objects.py | 200 ++++++++++++++ 16 files changed, 696 insertions(+), 24 deletions(-) create mode 100644 templates/modals/objects/generic_copy_object_modal.html create mode 100644 tests/test_copy_objects.py diff --git a/epdb/logic.py b/epdb/logic.py index 2efec048..a5f7ded4 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -1,6 +1,6 @@ import re import logging -from typing import Union, List, Optional, Set, Dict +from typing import Union, List, Optional, Set, Dict, Any from django.contrib.auth import get_user_model from django.db import transaction @@ -13,6 +13,132 @@ from utilities.chem import FormatConverter logger = logging.getLogger(__name__) +class EPDBURLParser: + + UUID_PATTERN = r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}' + + MODEL_PATTERNS = { + 'epdb.User': re.compile(rf'^.*/user/{UUID_PATTERN}'), + 'epdb.Group': re.compile(rf'^.*/group/{UUID_PATTERN}'), + 'epdb.Package': re.compile(rf'^.*/package/{UUID_PATTERN}'), + 'epdb.Compound': re.compile(rf'^.*/package/{UUID_PATTERN}/compound/{UUID_PATTERN}'), + 'epdb.CompoundStructure': re.compile(rf'^.*/package/{UUID_PATTERN}/compound/{UUID_PATTERN}/structure/{UUID_PATTERN}'), + 'epdb.Rule': re.compile(rf'^.*/package/{UUID_PATTERN}/(?:simple-ambit-rule|simple-rdkit-rule|parallel-rule|sequential-rule|rule)/{UUID_PATTERN}'), + 'epdb.Reaction': re.compile(rf'^.*/package/{UUID_PATTERN}/reaction/{UUID_PATTERN}$'), + 'epdb.Pathway': re.compile(rf'^.*/package/{UUID_PATTERN}/pathway/{UUID_PATTERN}'), + 'epdb.Node': re.compile(rf'^.*/package/{UUID_PATTERN}/pathway/{UUID_PATTERN}/node/{UUID_PATTERN}'), + 'epdb.Edge': re.compile(rf'^.*/package/{UUID_PATTERN}/pathway/{UUID_PATTERN}/edge/{UUID_PATTERN}'), + 'epdb.Scenario': re.compile(rf'^.*/package/{UUID_PATTERN}/scenario/{UUID_PATTERN}'), + 'epdb.EPModel': re.compile(rf'^.*/package/{UUID_PATTERN}/model/{UUID_PATTERN}'), + 'epdb.Setting': re.compile(rf'^.*/setting/{UUID_PATTERN}'), + } + + def __init__(self, url: str): + self.url = url + self._matches = {} + self._analyze_url() + + def _analyze_url(self): + for model_path, pattern in self.MODEL_PATTERNS.items(): + match = pattern.findall(self.url) + if match: + self._matches[model_path] = match[0] + + def _get_model_class(self, model_path: str): + try: + from django.apps import apps + app_label, model_name = model_path.split('.')[-2:] + return apps.get_model(app_label, model_name) + except (ImportError, LookupError, ValueError): + raise ValueError(f"Model {model_path} does not exist!") + + def _get_object_by_url(self, model_path: str, url: str): + model_class = self._get_model_class(model_path) + return model_class.objects.get(url=url) + + def is_package_url(self) -> bool: + return bool(re.compile(rf'^.*/package/{self.UUID_PATTERN}$').findall(self.url)) + + def contains_package_url(self): + return bool(self.MODEL_PATTERNS['epdb.Package'].findall(self.url)) and not self.is_package_url() + + def is_user_url(self) -> bool: + return bool(self.MODEL_PATTERNS['epdb.User'].findall(self.url)) + + def is_group_url(self) -> bool: + return bool(self.MODEL_PATTERNS['epdb.Group'].findall(self.url)) + + def is_setting_url(self) -> bool: + return bool(self.MODEL_PATTERNS['epdb.Setting'].findall(self.url)) + + def get_object(self) -> Optional[Any]: + # Define priority order from most specific to least specific + priority_order = [ + # 3rd level + 'epdb.CompoundStructure', + 'epdb.Node', + 'epdb.Edge', + # 2nd level + 'epdb.Compound', + 'epdb.Rule', + 'epdb.Reaction', + 'epdb.Scenario', + 'epdb.EPModel', + 'epdb.Pathway', + # 1st level + 'epdb.Package', + 'epdb.Setting', + 'epdb.Group', + 'epdb.User', + ] + + for model_path in priority_order: + if model_path in self._matches: + url = self._matches[model_path] + return self._get_object_by_url(model_path, url) + + raise ValueError(f"No object found for URL {self.url}") + + def get_objects(self) -> List[Any]: + """ + Get all Django model objects along the URL path in hierarchical order. + Returns objects from parent to child (e.g., Package -> Compound -> Structure). + """ + objects = [] + + hierarchy_order = [ + # 1st level + 'epdb.Package', + 'epdb.Setting', + 'epdb.Group', + 'epdb.User', + # 2nd level + 'epdb.Compound', + 'epdb.Rule', + 'epdb.Reaction', + 'epdb.Scenario', + 'epdb.EPModel', + 'epdb.Pathway', + # 3rd level + 'epdb.CompoundStructure', + 'epdb.Node', + 'epdb.Edge', + ] + + for model_path in hierarchy_order: + if model_path in self._matches: + url = self._matches[model_path] + objects.append(self._get_object_by_url(model_path, url)) + + return objects + + def __str__(self) -> str: + return f"EPDBURLParser(url='{self.url}')" + + def __repr__(self) -> str: + return f"EPDBURLParser(url='{self.url}', matches={list(self._matches.keys())})" + + class UserManager(object): user_pattern = re.compile(r".*/user/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}") diff --git a/epdb/migrations/0001_squashed_0003_applicabilitydomain_url_compound_url_and_more.py b/epdb/migrations/0001_squashed_0003_applicabilitydomain_url_compound_url_and_more.py index d1d3071a..a6eb8f50 100644 --- a/epdb/migrations/0001_squashed_0003_applicabilitydomain_url_compound_url_and_more.py +++ b/epdb/migrations/0001_squashed_0003_applicabilitydomain_url_compound_url_and_more.py @@ -35,7 +35,6 @@ def populate_url(apps, schema_editor): ] for model in MODELS: obj_cls = apps.get_model("epdb", model) - print(f"Populating url for {model}") for obj in obj_cls.objects.all(): obj.url = assemble_url(obj) if obj.url is None: @@ -49,7 +48,7 @@ def assemble_url(obj): case 'User': return '{}/user/{}'.format(s.SERVER_URL, obj.uuid) case 'Group': - return '{}/user/{}'.format(s.SERVER_URL, obj.uuid) + return '{}/group/{}'.format(s.SERVER_URL, obj.uuid) case 'Package': return '{}/package/{}'.format(s.SERVER_URL, obj.uuid) case 'Compound': diff --git a/epdb/migrations/0003_applicabilitydomain_url_compound_url_and_more.py b/epdb/migrations/0003_applicabilitydomain_url_compound_url_and_more.py index 6c60499d..5cb39127 100644 --- a/epdb/migrations/0003_applicabilitydomain_url_compound_url_and_more.py +++ b/epdb/migrations/0003_applicabilitydomain_url_compound_url_and_more.py @@ -26,7 +26,6 @@ def populate_url(apps, schema_editor): ] for model in MODELS: obj_cls = apps.get_model("epdb", model) - print(f"Populating url for {model}") for obj in obj_cls.objects.all(): obj.url = assemble_url(obj) if obj.url is None: @@ -40,7 +39,7 @@ def assemble_url(obj): case 'User': return '{}/user/{}'.format(s.SERVER_URL, obj.uuid) case 'Group': - return '{}/user/{}'.format(s.SERVER_URL, obj.uuid) + return '{}/group/{}'.format(s.SERVER_URL, obj.uuid) case 'Package': return '{}/package/{}'.format(s.SERVER_URL, obj.uuid) case 'Compound': diff --git a/epdb/models.py b/epdb/models.py index 8b1ce008..6ce37918 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -533,15 +533,16 @@ class AliasMixin(models.Model): @transaction.atomic def add_alias(self, new_alias, set_as_default=False): if set_as_default: - self.aliases.add(self.name) + self.aliases.append(self.name) self.name = new_alias if new_alias in self.aliases: self.aliases.remove(new_alias) else: if new_alias not in self.aliases: - self.aliases.add(new_alias) + self.aliases.append(new_alias) + self.aliases = sorted(list(set(self.aliases)), key=lambda x: x.lower()) self.save() class Meta: @@ -762,6 +763,64 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin return cs + @transaction.atomic + def copy(self, target: 'Package', mapping: Dict): + if self in mapping: + return mapping[self] + + new_compound = Compound.objects.create( + package=target, + name=self.name, + description=self.description, + kv=self.kv.copy() if self.kv else {} + ) + mapping[self] = new_compound + + # Copy compound structures + for structure in self.structures.all(): + if structure not in mapping: + new_structure = CompoundStructure.objects.create( + compound=new_compound, + smiles=structure.smiles, + canonical_smiles=structure.canonical_smiles, + inchikey=structure.inchikey, + normalized_structure=structure.normalized_structure, + name=structure.name, + description=structure.description, + kv=structure.kv.copy() if structure.kv else {} + ) + mapping[structure] = new_structure + + # Copy external identifiers for structure + for ext_id in structure.external_identifiers.all(): + ExternalIdentifier.objects.create( + content_object=new_structure, + database=ext_id.database, + identifier_value=ext_id.identifier_value, + url=ext_id.url, + is_primary=ext_id.is_primary + ) + + if self.default_structure: + new_compound.default_structure = mapping.get(self.default_structure) + new_compound.save() + + for a in self.aliases: + new_compound.add_alias(a) + new_compound.save() + + # Copy external identifiers for compound + for ext_id in self.external_identifiers.all(): + ExternalIdentifier.objects.create( + content_object=new_compound, + database=ext_id.database, + identifier_value=ext_id.identifier_value, + url=ext_id.url, + is_primary=ext_id.is_primary + ) + + return new_compound + class Meta: unique_together = [('uuid', 'package')] @@ -817,6 +876,14 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti return cs + @transaction.atomic + def copy(self, target: 'Package', mapping: Dict): + if self in mapping: + return mapping[self] + + self.compound.copy(target, mapping) + return mapping[self] + @property def as_svg(self, width: int = 800, height: int = 400): return IndigoUtils.mol_to_svg(self.smiles, width=width, height=height) @@ -856,22 +923,54 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): return cls.create(*args, **kwargs) -# -# @property -# def related_pathways(self): -# reaction_ids = self.related_reactions.values_list('id', flat=True) -# pathways = Edge.objects.filter(edge_label__in=reaction_ids).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, rules__in=[self]) -# | -# Reaction.objects.filter(package=self.package, rules__in=[self]) -# ).order_by('name') -# -# + @transaction.atomic + def copy(self, target: 'Package', mapping: Dict): + """Copy a rule to the target package.""" + if self in mapping: + return mapping[self] + + # Get the specific rule type and copy accordingly + rule_type = type(self) + + if rule_type == SimpleAmbitRule: + new_rule = SimpleAmbitRule.objects.create( + package=target, + name=self.name, + description=self.description, + smirks=self.smirks, + reactant_filter_smarts=self.reactant_filter_smarts, + product_filter_smarts=self.product_filter_smarts, + kv=self.kv.copy() if self.kv else {} + ) + elif rule_type == SimpleRDKitRule: + new_rule = SimpleRDKitRule.objects.create( + package=target, + name=self.name, + description=self.description, + reaction_smarts=self.reaction_smarts, + kv=self.kv.copy() if self.kv else {} + ) + elif rule_type == ParallelRule: + new_rule = ParallelRule.objects.create( + package=target, + name=self.name, + description=self.description, + kv=self.kv.copy() if self.kv else {} + ) + # Copy simple rules relationships + for simple_rule in self.simple_rules.all(): + copied_simple_rule = simple_rule.copy(target, mapping) + new_rule.simple_rules.add(copied_simple_rule) + elif rule_type == SequentialRule: + raise ValueError("SequentialRule copy not implemented!") + else: + raise ValueError(f"Unknown rule type: {rule_type}") + + mapping[self] = new_rule + + return new_rule + + class SimpleRule(Rule): pass @@ -1141,6 +1240,50 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin r.save() return r + @transaction.atomic + def copy(self, target: 'Package', mapping: Dict ) -> 'Reaction': + """Copy a reaction to the target package.""" + if self in mapping: + return mapping[self] + + # Create new reaction + new_reaction = Reaction.objects.create( + package=target, + name=self.name, + description=self.description, + multi_step=self.multi_step, + medline_references=self.medline_references, + kv=self.kv.copy() if self.kv else {} + ) + mapping[self] = new_reaction + + # Copy educts (reactant compounds) + for educt in self.educts.all(): + copied_educt = educt.copy(target, mapping) + new_reaction.educts.add(copied_educt) + + # Copy products + for product in self.products.all(): + copied_product = product.copy(target, mapping) + new_reaction.products.add(copied_product) + + # Copy rules + for rule in self.rules.all(): + copied_rule = rule.copy(target, mapping) + new_reaction.rules.add(copied_rule) + + # Copy external identifiers + for ext_id in self.external_identifiers.all(): + ExternalIdentifier.objects.create( + content_object=new_reaction, + database=ext_id.database, + identifier_value=ext_id.identifier_value, + url=ext_id.url, + is_primary=ext_id.is_primary + ) + + return new_reaction + def smirks(self): return f"{'.'.join([cs.smiles for cs in self.educts.all()])}>>{'.'.join([cs.smiles for cs in self.products.all()])}" @@ -1375,6 +1518,87 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): return pw + @transaction.atomic + def copy(self, target: 'Package', mapping: Dict) -> 'Pathway': + + if self in mapping: + return mapping[self] + + # Start copying the pathway + new_pathway = Pathway.objects.create( + package=target, + name=self.name, + description=self.description, + setting=self.setting, # TODO copy settings? + kv=self.kv.copy() if self.kv else {} + ) + + # # Copy aliases if they exist + # if hasattr(self, 'aliases'): + # new_pathway.aliases.set(self.aliases.all()) + # + # # Copy scenarios if they exist + # if hasattr(self, 'scenarios'): + # new_pathway.scenarios.set(self.scenarios.all()) + + # Copy all nodes first + for node in self.nodes.all(): + # Copy the compound structure for the node label + copied_structure = None + if node.default_node_label: + copied_compound = node.default_node_label.compound.copy(target, mapping) + # Find the corresponding structure in the copied compound + for structure in copied_compound.structures.all(): + if structure.smiles == node.default_node_label.smiles: + copied_structure = structure + break + + new_node = Node.objects.create( + pathway=new_pathway, + default_node_label=copied_structure, + depth=node.depth, + name=node.name, + description=node.description, + kv=node.kv.copy() if node.kv else {} + ) + mapping[node] = new_node + + # Copy node labels (many-to-many relationship) + for label in node.node_labels.all(): + copied_label_compound = label.compound.copy(target, mapping) + for structure in copied_label_compound.structures.all(): + if structure.smiles == label.smiles: + new_node.node_labels.add(structure) + break + + # Copy all edges + for edge in self.edges.all(): + # Copy the reaction for edge label if it exists + copied_reaction = None + if edge.edge_label: + copied_reaction = edge.edge_label.copy(target, mapping) + + new_edge = Edge.objects.create( + pathway=new_pathway, + edge_label=copied_reaction, + name=edge.name, + description=edge.description, + kv=edge.kv.copy() if edge.kv else {} + ) + + # Copy start and end nodes relationships + for start_node in edge.start_nodes.all(): + if start_node in mapping: + new_edge.start_nodes.add(mapping[start_node]) + + for end_node in edge.end_nodes.all(): + if end_node in mapping: + new_edge.end_nodes.add(mapping[end_node]) + + mapping[self] = new_pathway + + return new_pathway + @transaction.atomic def add_node(self, smiles: str, name: Optional[str] = None, description: Optional[str] = None): return Node.create(self, smiles, 0) diff --git a/epdb/views.py b/epdb/views.py index b8c02c56..2b4ed66f 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -13,7 +13,7 @@ from django.views.decorators.csrf import csrf_exempt from utilities.chem import FormatConverter, IndigoUtils from utilities.decorators import package_permission_required -from .logic import GroupManager, PackageManager, UserManager, SettingManager, SearchManager +from .logic import GroupManager, PackageManager, UserManager, SettingManager, SearchManager, EPDBURLParser from .models import Package, GroupPackagePermission, Group, CompoundStructure, Compound, Reaction, Rule, Pathway, Node, \ EPModel, EnviFormer, MLRelativeReasoning, RuleBaseRelativeReasoning, Scenario, SimpleAmbitRule, APIToken, \ UserPackagePermission, Permission, License, User, Edge @@ -211,6 +211,32 @@ def set_scenarios(current_user, attach_object, scenario_urls: List[str]): attach_object.set_scenarios(scens) + +def copy_object(current_user, target_package: 'Package', source_object_url: str): + # Ensures that source is readable + source_package = PackageManager.get_package_by_url(current_user, source_object_url) + + parser = EPDBURLParser(source_object_url) + + # if the url won't contain a package or is a plain package + if not parser.contains_package_url(): + raise ValueError(f"Object {source_object_url} can't be copied!") + + # Gets the most specific object + source_object = parser.get_object() + + if hasattr(source_object, 'copy'): + mapping = dict() + copy = source_object.copy(target_package, mapping) + + if s.DEBUG: + for k, v in mapping.items(): + logger.debug(f"Mapping {k.url} to {v.url}") + + return copy + + raise ValueError(f"Object {source_object} can't be copied!") + def index(request): context = get_base_context(request) context['title'] = 'enviPath - Home' @@ -764,6 +790,14 @@ def package(request, package_uuid): for g in Group.objects.filter(public=True): PackageManager.update_permissions(current_user, current_package, g, Permission.READ[0]) return redirect(current_package.url) + elif hidden == 'copy': + object_to_copy = request.POST.get('object_to_copy') + + if not object_to_copy: + return error(request, 'Invalid target package.', 'Please select a target package.') + + copied_object = copy_object(current_user, current_package, object_to_copy) + return JsonResponse({'success': copied_object.url}) else: return HttpResponseBadRequest() diff --git a/templates/actions/objects/compound.html b/templates/actions/objects/compound.html index 88ecb27c..b312b3d7 100644 --- a/templates/actions/objects/compound.html +++ b/templates/actions/objects/compound.html @@ -11,6 +11,12 @@ Set Scenarios +{% endif %} +
  • + + Copy +
  • +{% if meta.can_edit %}
  • Delete Compound diff --git a/templates/actions/objects/pathway.html b/templates/actions/objects/pathway.html index 78b36ea3..8d893256 100644 --- a/templates/actions/objects/pathway.html +++ b/templates/actions/objects/pathway.html @@ -8,10 +8,16 @@ Add Reaction
  • +{% endif %} +
  • + + Copy +
  • Download Pathway
  • +{% if meta.can_edit %}
  • diff --git a/templates/actions/objects/reaction.html b/templates/actions/objects/reaction.html index 3578e881..18869c2d 100644 --- a/templates/actions/objects/reaction.html +++ b/templates/actions/objects/reaction.html @@ -7,6 +7,12 @@ Set Scenarios
  • +{% endif %} +
  • + + Copy +
  • +{% if meta.can_edit %}
  • Delete Reaction diff --git a/templates/actions/objects/rule.html b/templates/actions/objects/rule.html index 3e37ea68..416883ed 100644 --- a/templates/actions/objects/rule.html +++ b/templates/actions/objects/rule.html @@ -7,6 +7,12 @@ Set Scenarios
  • +{% endif %} +
  • + + Copy +
  • +{% if meta.can_edit %}
  • Delete Rule diff --git a/templates/modals/objects/generic_copy_object_modal.html b/templates/modals/objects/generic_copy_object_modal.html new file mode 100644 index 00000000..871d81e0 --- /dev/null +++ b/templates/modals/objects/generic_copy_object_modal.html @@ -0,0 +1,61 @@ +{% load static %} + + + diff --git a/templates/objects/composite_rule.html b/templates/objects/composite_rule.html index c0bb5ccb..2fe741b5 100644 --- a/templates/objects/composite_rule.html +++ b/templates/objects/composite_rule.html @@ -5,6 +5,7 @@ {% block action_modals %} {% include "modals/objects/edit_rule_modal.html" %} {% include "modals/objects/generic_set_scenario_modal.html" %} +{% include "modals/objects/generic_copy_object_modal.html" %} {% include "modals/objects/generic_delete_modal.html" %} {% endblock action_modals %} diff --git a/templates/objects/compound.html b/templates/objects/compound.html index 80994163..4a1028f7 100644 --- a/templates/objects/compound.html +++ b/templates/objects/compound.html @@ -6,6 +6,7 @@ {% include "modals/objects/edit_compound_modal.html" %} {% include "modals/objects/add_structure_modal.html" %} {% include "modals/objects/generic_set_scenario_modal.html" %} + {% include "modals/objects/generic_copy_object_modal.html" %} {% include "modals/objects/generic_delete_modal.html" %} {% endblock action_modals %} diff --git a/templates/objects/pathway.html b/templates/objects/pathway.html index 38ccd6fe..bd1e3880 100644 --- a/templates/objects/pathway.html +++ b/templates/objects/pathway.html @@ -82,6 +82,7 @@ {% include "modals/objects/add_pathway_node_modal.html" %} {% include "modals/objects/add_pathway_edge_modal.html" %} {% include "modals/objects/download_pathway_modal.html" %} + {% include "modals/objects/generic_copy_object_modal.html" %} {% include "modals/objects/edit_pathway_modal.html" %} {% include "modals/objects/generic_set_scenario_modal.html" %} {% include "modals/objects/delete_pathway_node_modal.html" %} diff --git a/templates/objects/reaction.html b/templates/objects/reaction.html index bf128497..b22ebd7f 100644 --- a/templates/objects/reaction.html +++ b/templates/objects/reaction.html @@ -5,6 +5,7 @@ {% block action_modals %} {% include "modals/objects/edit_reaction_modal.html" %} {% include "modals/objects/generic_set_scenario_modal.html" %} + {% include "modals/objects/generic_copy_object_modal.html" %} {% include "modals/objects/generic_delete_modal.html" %} {% endblock action_modals %} diff --git a/templates/objects/simple_rule.html b/templates/objects/simple_rule.html index 72916a73..d3e30893 100644 --- a/templates/objects/simple_rule.html +++ b/templates/objects/simple_rule.html @@ -5,6 +5,7 @@ {% block action_modals %} {% include "modals/objects/edit_rule_modal.html" %} {% include "modals/objects/generic_set_scenario_modal.html" %} + {% include "modals/objects/generic_copy_object_modal.html" %} {% include "modals/objects/generic_delete_modal.html" %} {% endblock action_modals %} diff --git a/tests/test_copy_objects.py b/tests/test_copy_objects.py new file mode 100644 index 00000000..3c7e089f --- /dev/null +++ b/tests/test_copy_objects.py @@ -0,0 +1,200 @@ +import json + +from django.test import TestCase + +from epdb.logic import PackageManager +from epdb.models import Compound, User, CompoundStructure, Reaction, Rule, MLRelativeReasoning, Pathway + + +class CopyTest(TestCase): + fixtures = ["test_fixture.cleaned.json"] + + def setUp(self): + pass + + @classmethod + def setUpClass(cls): + super(CopyTest, cls).setUpClass() + cls.user = User.objects.get(username='anonymous') + cls.package = PackageManager.create_package(cls.user, 'Source Package', 'No Desc') + cls.AFOXOLANER = Compound.create( + cls.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='Test compound for copying' + ) + + cls.FOUR_NITROBENZOIC_ACID = Compound.create( + cls.package, + smiles='[O-][N+](=O)c1ccc(C(=O)[O-])cc1', # Normalized: O=C(O)C1=CC=C([N+](=O)[O-])C=C1', + name='Test Compound', + description='Compound with multiple structures' + ) + + cls.ETHANOL = Compound.create( + cls.package, + smiles='CCO', + name='Ethanol', + description='Simple alcohol' + ) + cls.target_package = PackageManager.create_package(cls.user, 'Target Package', 'No Desc') + + cls.reaction_educt = Compound.create( + cls.package, + smiles='C(CCl)Cl', + name='1,2-Dichloroethane', + description='Eawag BBD compound c0001' + ).default_structure + + cls.reaction_product = Compound.create( + cls.package, + smiles='C(CO)Cl', + name='2-Chloroethanol', + description='Eawag BBD compound c0005' + ).default_structure + + cls.REACTION = Reaction.create( + package=cls.package, + name='Eawag BBD reaction r0001', + educts=[cls.reaction_educt], + products=[cls.reaction_product], + multi_step=False + ) + + + @classmethod + def tearDownClass(cls): + pass + + def tearDown(self): + pass + + + def test_compound_copy_basic(self): + """Test basic compound copying functionality""" + mapping = dict() + copied_compound = self.AFOXOLANER.copy(self.target_package, mapping) + + self.assertNotEqual(self.AFOXOLANER.uuid, copied_compound.uuid) + self.assertEqual(self.AFOXOLANER.name, copied_compound.name) + self.assertEqual(self.AFOXOLANER.description, copied_compound.description) + self.assertEqual(copied_compound.package, self.target_package) + self.assertEqual(self.AFOXOLANER.package, self.package) + self.assertEqual(self.AFOXOLANER.default_structure.smiles, copied_compound.default_structure.smiles) + + def test_compound_copy_with_multiple_structures(self): + """Test copying a compound with multiple structures""" + + original_structure_count = self.FOUR_NITROBENZOIC_ACID.structures.count() + + mapping = dict() + # Copy the compound + copied_compound = self.FOUR_NITROBENZOIC_ACID.copy(self.target_package, mapping) + + # Verify all structures were copied + self.assertEqual(copied_compound.structures.count(), original_structure_count) + + # Verify default_structure is properly set + self.assertIsNotNone(copied_compound.default_structure) + self.assertEqual( + copied_compound.default_structure.smiles, + self.FOUR_NITROBENZOIC_ACID.default_structure.smiles + ) + + def test_compound_copy_preserves_aliases(self): + """Test that compound copying preserves aliases""" + # Create a compound and add aliases + original_compound = self.ETHANOL + + # Add aliases if the method exists + if hasattr(original_compound, 'add_alias'): + original_compound.add_alias('Ethyl alcohol') + original_compound.add_alias('Grain alcohol') + + mapping = dict() + copied_compound = original_compound.copy(self.target_package, mapping) + + # Verify aliases were copied if they exist + if hasattr(original_compound, 'aliases') and hasattr(copied_compound, 'aliases'): + original_aliases = original_compound.aliases + copied_aliases = copied_compound.aliases + self.assertEqual(original_aliases, copied_aliases) + + def test_compound_copy_preserves_external_identifiers(self): + """Test that external identifiers are preserved during copying""" + original_compound = self.ETHANOL + + # Add external identifiers if the methods exist + if hasattr(original_compound, 'add_cas_number'): + original_compound.add_cas_number('64-17-5') + if hasattr(original_compound, 'add_pubchem_compound_id'): + original_compound.add_pubchem_compound_id('702') + + mapping = dict() + copied_compound = original_compound.copy(self.target_package, mapping) + + # Verify external identifiers were copied + original_ext_ids = original_compound.external_identifiers.all() + copied_ext_ids = copied_compound.external_identifiers.all() + + self.assertEqual(original_ext_ids.count(), copied_ext_ids.count()) + + # Check that identifier values match + original_values = set(ext_id.identifier_value for ext_id in original_ext_ids) + copied_values = set(ext_id.identifier_value for ext_id in copied_ext_ids) + self.assertEqual(original_values, copied_values) + + def test_compound_copy_structure_properties(self): + """Test that structure properties are properly copied""" + original_compound = self.ETHANOL + + mapping = dict() + copied_compound = original_compound.copy(self.target_package, mapping) + + # Verify structure properties + original_structure = original_compound.default_structure + copied_structure = copied_compound.default_structure + + self.assertEqual(original_structure.smiles, copied_structure.smiles) + self.assertEqual(original_structure.canonical_smiles, copied_structure.canonical_smiles) + self.assertEqual(original_structure.inchikey, copied_structure.inchikey) + self.assertEqual(original_structure.normalized_structure, copied_structure.normalized_structure) + + # Verify they are different objects + self.assertNotEqual(original_structure.uuid, copied_structure.uuid) + self.assertEqual(copied_structure.compound, copied_compound) + + def test_reaction_copy_basic(self): + """Test basic reaction copying functionality""" + mapping = dict() + copied_reaction = self.REACTION.copy(self.target_package, mapping) + + self.assertNotEqual(self.REACTION.uuid, copied_reaction.uuid) + self.assertEqual(self.REACTION.name, copied_reaction.name) + self.assertEqual(self.REACTION.description, copied_reaction.description) + self.assertEqual(self.REACTION.multi_step, copied_reaction.multi_step) + self.assertEqual(copied_reaction.package, self.target_package) + self.assertEqual(self.REACTION.package, self.package) + + + def test_reaction_copy_structures(self): + """Test basic reaction copying functionality""" + mapping = dict() + copied_reaction = self.REACTION.copy(self.target_package, mapping) + + for orig_educt, copy_educt in zip(self.REACTION.educts.all(), copied_reaction.educts.all()): + self.assertNotEqual(orig_educt.uuid, copy_educt.uuid) + self.assertEqual(orig_educt.name, copy_educt.name) + self.assertEqual(orig_educt.description, copy_educt.description) + self.assertEqual(copy_educt.compound.package, self.target_package) + self.assertEqual(orig_educt.compound.package, self.package) + self.assertEqual(orig_educt.smiles, copy_educt.smiles) + + for orig_product, copy_product in zip(self.REACTION.products.all(), copied_reaction.products.all()): + self.assertNotEqual(orig_product.uuid, copy_product.uuid) + self.assertEqual(orig_product.name, copy_product.name) + self.assertEqual(orig_product.description, copy_product.description) + self.assertEqual(copy_product.compound.package, self.target_package) + self.assertEqual(orig_product.compound.package, self.package) + self.assertEqual(orig_product.smiles, copy_product.smiles) +