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 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}")

View File

@ -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':

View File

@ -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':

View File

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

View File

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