Copy Objects between Packages (#59)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#59
This commit is contained in:
2025-08-28 06:27:11 +12:00
parent 13816ecaf3
commit 00d9188c0c
16 changed files with 696 additions and 24 deletions

View File

@ -1,6 +1,6 @@
import re import re
import logging 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.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
@ -13,6 +13,132 @@ from utilities.chem import FormatConverter
logger = logging.getLogger(__name__) 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): 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}") 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}")

View File

@ -35,7 +35,6 @@ def populate_url(apps, schema_editor):
] ]
for model in MODELS: for model in MODELS:
obj_cls = apps.get_model("epdb", model) obj_cls = apps.get_model("epdb", model)
print(f"Populating url for {model}")
for obj in obj_cls.objects.all(): for obj in obj_cls.objects.all():
obj.url = assemble_url(obj) obj.url = assemble_url(obj)
if obj.url is None: if obj.url is None:
@ -49,7 +48,7 @@ def assemble_url(obj):
case 'User': case 'User':
return '{}/user/{}'.format(s.SERVER_URL, obj.uuid) return '{}/user/{}'.format(s.SERVER_URL, obj.uuid)
case 'Group': case 'Group':
return '{}/user/{}'.format(s.SERVER_URL, obj.uuid) return '{}/group/{}'.format(s.SERVER_URL, obj.uuid)
case 'Package': case 'Package':
return '{}/package/{}'.format(s.SERVER_URL, obj.uuid) return '{}/package/{}'.format(s.SERVER_URL, obj.uuid)
case 'Compound': case 'Compound':

View File

@ -26,7 +26,6 @@ def populate_url(apps, schema_editor):
] ]
for model in MODELS: for model in MODELS:
obj_cls = apps.get_model("epdb", model) obj_cls = apps.get_model("epdb", model)
print(f"Populating url for {model}")
for obj in obj_cls.objects.all(): for obj in obj_cls.objects.all():
obj.url = assemble_url(obj) obj.url = assemble_url(obj)
if obj.url is None: if obj.url is None:
@ -40,7 +39,7 @@ def assemble_url(obj):
case 'User': case 'User':
return '{}/user/{}'.format(s.SERVER_URL, obj.uuid) return '{}/user/{}'.format(s.SERVER_URL, obj.uuid)
case 'Group': case 'Group':
return '{}/user/{}'.format(s.SERVER_URL, obj.uuid) return '{}/group/{}'.format(s.SERVER_URL, obj.uuid)
case 'Package': case 'Package':
return '{}/package/{}'.format(s.SERVER_URL, obj.uuid) return '{}/package/{}'.format(s.SERVER_URL, obj.uuid)
case 'Compound': case 'Compound':

View File

@ -533,15 +533,16 @@ class AliasMixin(models.Model):
@transaction.atomic @transaction.atomic
def add_alias(self, new_alias, set_as_default=False): def add_alias(self, new_alias, set_as_default=False):
if set_as_default: if set_as_default:
self.aliases.add(self.name) self.aliases.append(self.name)
self.name = new_alias self.name = new_alias
if new_alias in self.aliases: if new_alias in self.aliases:
self.aliases.remove(new_alias) self.aliases.remove(new_alias)
else: else:
if new_alias not in self.aliases: 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() self.save()
class Meta: class Meta:
@ -762,6 +763,64 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
return cs 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: class Meta:
unique_together = [('uuid', 'package')] unique_together = [('uuid', 'package')]
@ -817,6 +876,14 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti
return cs 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 @property
def as_svg(self, width: int = 800, height: int = 400): def as_svg(self, width: int = 800, height: int = 400):
return IndigoUtils.mol_to_svg(self.smiles, width=width, height=height) 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) return cls.create(*args, **kwargs)
# @transaction.atomic
# @property def copy(self, target: 'Package', mapping: Dict):
# def related_pathways(self): """Copy a rule to the target package."""
# reaction_ids = self.related_reactions.values_list('id', flat=True) if self in mapping:
# pathways = Edge.objects.filter(edge_label__in=reaction_ids).values_list('pathway', flat=True) return mapping[self]
# return Pathway.objects.filter(package=self.package, id__in=set(pathways)).order_by('name')
# # Get the specific rule type and copy accordingly
# @property rule_type = type(self)
# def related_reactions(self):
# return ( if rule_type == SimpleAmbitRule:
# Reaction.objects.filter(package=self.package, rules__in=[self]) new_rule = SimpleAmbitRule.objects.create(
# | package=target,
# Reaction.objects.filter(package=self.package, rules__in=[self]) name=self.name,
# ).order_by('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): class SimpleRule(Rule):
pass pass
@ -1141,6 +1240,50 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
r.save() r.save()
return r 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): def smirks(self):
return f"{'.'.join([cs.smiles for cs in self.educts.all()])}>>{'.'.join([cs.smiles for cs in self.products.all()])}" 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 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 @transaction.atomic
def add_node(self, smiles: str, name: Optional[str] = None, description: Optional[str] = None): def add_node(self, smiles: str, name: Optional[str] = None, description: Optional[str] = None):
return Node.create(self, smiles, 0) return Node.create(self, smiles, 0)

View File

@ -13,7 +13,7 @@ from django.views.decorators.csrf import csrf_exempt
from utilities.chem import FormatConverter, IndigoUtils from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required 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, \ from .models import Package, GroupPackagePermission, Group, CompoundStructure, Compound, Reaction, Rule, Pathway, Node, \
EPModel, EnviFormer, MLRelativeReasoning, RuleBaseRelativeReasoning, Scenario, SimpleAmbitRule, APIToken, \ EPModel, EnviFormer, MLRelativeReasoning, RuleBaseRelativeReasoning, Scenario, SimpleAmbitRule, APIToken, \
UserPackagePermission, Permission, License, User, Edge 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) 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): def index(request):
context = get_base_context(request) context = get_base_context(request)
context['title'] = 'enviPath - Home' context['title'] = 'enviPath - Home'
@ -764,6 +790,14 @@ def package(request, package_uuid):
for g in Group.objects.filter(public=True): for g in Group.objects.filter(public=True):
PackageManager.update_permissions(current_user, current_package, g, Permission.READ[0]) PackageManager.update_permissions(current_user, current_package, g, Permission.READ[0])
return redirect(current_package.url) 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: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()

View File

@ -11,6 +11,12 @@
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a role="button" data-toggle="modal" data-target="#set_scenario_modal">
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> <i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
</li> </li>
{% endif %}
<li>
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
<i class="glyphicon glyphicon-duplicate"></i> Copy</a>
</li>
{% if meta.can_edit %}
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a class="button" data-toggle="modal" data-target="#generic_delete_modal">
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a> <i class="glyphicon glyphicon-trash"></i> Delete Compound</a>

View File

@ -8,10 +8,16 @@
<i class="glyphicon glyphicon-plus"></i> Add Reaction</a> <i class="glyphicon glyphicon-plus"></i> Add Reaction</a>
</li> </li>
<li role="separator" class="divider"></li> <li role="separator" class="divider"></li>
{% endif %}
<li>
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
<i class="glyphicon glyphicon-duplicate"></i> Copy</a>
</li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#download_pathway_modal"> <a class="button" data-toggle="modal" data-target="#download_pathway_modal">
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway</a> <i class="glyphicon glyphicon-floppy-save"></i> Download Pathway</a>
</li> </li>
{% if meta.can_edit %}
<li role="separator" class="divider"></li> <li role="separator" class="divider"></li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#edit_pathway_modal"> <a class="button" data-toggle="modal" data-target="#edit_pathway_modal">

View File

@ -7,6 +7,12 @@
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a role="button" data-toggle="modal" data-target="#set_scenario_modal">
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> <i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
</li> </li>
{% endif %}
<li>
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
<i class="glyphicon glyphicon-duplicate"></i> Copy</a>
</li>
{% if meta.can_edit %}
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a class="button" data-toggle="modal" data-target="#generic_delete_modal">
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a> <i class="glyphicon glyphicon-trash"></i> Delete Reaction</a>

View File

@ -7,6 +7,12 @@
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a role="button" data-toggle="modal" data-target="#set_scenario_modal">
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> <i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
</li> </li>
{% endif %}
<li>
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
<i class="glyphicon glyphicon-duplicate"></i> Copy</a>
</li>
{% if meta.can_edit %}
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a class="button" data-toggle="modal" data-target="#generic_delete_modal">
<i class="glyphicon glyphicon-trash"></i> Delete Rule</a> <i class="glyphicon glyphicon-trash"></i> Delete Rule</a>

View File

@ -0,0 +1,61 @@
{% load static %}
<!-- Copy Object -->
<div id="generic_copy_object_modal" class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Copy {{ object_type|capfirst }}</h3>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form id="generic-copy-object-modal-form" accept-charset="UTF-8" data-remote="true" method="post">
{% csrf_token %}
<label for="target-package">Select the Target Package you want to copy this {{ object_type }}
into</label>
<select id="target-package" name="target-package" data-actions-box='true' class="form-control"
data-width='100%'>
<option disabled selected>Select Target Package</option>
{% for p in meta.writeable_packages %}
<option value="{{ p.url }}">{{ p.name }}</option>`
{% endfor %}
</select>
<input type="hidden" name="hidden" value="copy">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="generic-copy-object-modal-form-submit">
Copy
</button>
</div>
</div>
</div>
</div>
<script>
$(function () {
$('#generic-copy-object-modal-form-submit').click(function (e) {
e.preventDefault();
const packageUrl = $('#target-package').find(":selected").val();
if (packageUrl === 'Select Target Package' || packageUrl === '' || packageUrl === null || packageUrl === undefined) {
return;
}
const formData = {
hidden: 'copy',
object_to_copy: '{{ current_object.url }}',
}
$.post(packageUrl, formData, function (response) {
if (response.success) {
window.location.href = response.success;
}
});
});
})
</script>

View File

@ -5,6 +5,7 @@
{% block action_modals %} {% block action_modals %}
{% include "modals/objects/edit_rule_modal.html" %} {% include "modals/objects/edit_rule_modal.html" %}
{% include "modals/objects/generic_set_scenario_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" %} {% include "modals/objects/generic_delete_modal.html" %}
{% endblock action_modals %} {% endblock action_modals %}

View File

@ -6,6 +6,7 @@
{% include "modals/objects/edit_compound_modal.html" %} {% include "modals/objects/edit_compound_modal.html" %}
{% include "modals/objects/add_structure_modal.html" %} {% include "modals/objects/add_structure_modal.html" %}
{% include "modals/objects/generic_set_scenario_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" %} {% include "modals/objects/generic_delete_modal.html" %}
{% endblock action_modals %} {% endblock action_modals %}

View File

@ -82,6 +82,7 @@
{% include "modals/objects/add_pathway_node_modal.html" %} {% include "modals/objects/add_pathway_node_modal.html" %}
{% include "modals/objects/add_pathway_edge_modal.html" %} {% include "modals/objects/add_pathway_edge_modal.html" %}
{% include "modals/objects/download_pathway_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/edit_pathway_modal.html" %}
{% include "modals/objects/generic_set_scenario_modal.html" %} {% include "modals/objects/generic_set_scenario_modal.html" %}
{% include "modals/objects/delete_pathway_node_modal.html" %} {% include "modals/objects/delete_pathway_node_modal.html" %}

View File

@ -5,6 +5,7 @@
{% block action_modals %} {% block action_modals %}
{% include "modals/objects/edit_reaction_modal.html" %} {% include "modals/objects/edit_reaction_modal.html" %}
{% include "modals/objects/generic_set_scenario_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" %} {% include "modals/objects/generic_delete_modal.html" %}
{% endblock action_modals %} {% endblock action_modals %}

View File

@ -5,6 +5,7 @@
{% block action_modals %} {% block action_modals %}
{% include "modals/objects/edit_rule_modal.html" %} {% include "modals/objects/edit_rule_modal.html" %}
{% include "modals/objects/generic_set_scenario_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" %} {% include "modals/objects/generic_delete_modal.html" %}
{% endblock action_modals %} {% endblock action_modals %}

200
tests/test_copy_objects.py Normal file
View File

@ -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)