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 %} +