diff --git a/backend/composer/api/serializers.py b/backend/composer/api/serializers.py index c372844e..b51097e6 100644 --- a/backend/composer/api/serializers.py +++ b/backend/composer/api/serializers.py @@ -27,8 +27,8 @@ ) from ..services.connections_service import get_complete_from_entities_for_destination, \ get_complete_from_entities_for_via +from ..services.statement_service import create_statement_preview from ..services.errors_service import get_connectivity_errors -from ..utils import join_entities # MixIns @@ -644,53 +644,7 @@ def get_entities_journey(self, instance): def get_statement_preview(self, instance): if 'journey' not in self.context: self.context['journey'] = instance.get_journey() - return self.create_statement_preview(instance, self.context['journey']) - - def create_statement_preview(self, instance, journey): - sex = instance.sex.sex_str if instance.sex else None - - species_list = [specie.name for specie in instance.species.all()] - species = join_entities(species_list) - if not species: - species = "" - - phenotype = instance.phenotype.phenotype_str if instance.phenotype else '' - origin_names = [origin.name for origin in instance.origins.all()] - origins = join_entities(origin_names) - if not origins: - origins = "" - - circuit_type = instance.get_circuit_type_display() if instance.circuit_type else None - projection = instance.get_projection_display() if instance.projection else None - projection_phenotype = str(instance.projection_phenotype) if instance.projection_phenotype else '' - - laterality_description = instance.get_laterality_description() - - apinatomy = instance.apinatomy_model if instance.apinatomy_model else "" - journey_sentence = '; '.join(journey) - - # Creating the statement - if sex or species != "": - statement = f"In {sex or ''} {species}, the {phenotype.lower()} connection goes {journey_sentence}.\n" - else: - statement = f"A {phenotype.lower()} connection goes {journey_sentence}.\n" - - statement += f"This " - if projection: - statement += f"{projection.lower()} " - if projection_phenotype: - statement += f"{projection_phenotype.lower()} " - if circuit_type: - statement += f"{circuit_type.lower()} " - - statement += f"connection projects from the {origins}." - if laterality_description: - statement = statement[:-1] + f" and is found {laterality_description}.\n" - - if apinatomy: - statement += f" It is described in {apinatomy} model." - - return statement.strip().replace(" ", " ") + return create_statement_preview(instance, self.context['journey']) def get_errors(self, instance) -> List: return get_connectivity_errors(instance) diff --git a/backend/composer/migrations/0068_connectivitystatement_statement_prefix_and_more.py b/backend/composer/migrations/0068_connectivitystatement_statement_prefix_and_more.py new file mode 100644 index 00000000..d0dcecfb --- /dev/null +++ b/backend/composer/migrations/0068_connectivitystatement_statement_prefix_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.4 on 2024-12-18 15:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("composer", "0067_auto_20241218_0928"), + ] + + operations = [ + migrations.AddField( + model_name="connectivitystatement", + name="statement_prefix", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="connectivitystatement", + name="statement_suffix", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/backend/composer/migrations/0069_auto_20241218_1659.py b/backend/composer/migrations/0069_auto_20241218_1659.py new file mode 100644 index 00000000..a1b56d33 --- /dev/null +++ b/backend/composer/migrations/0069_auto_20241218_1659.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.4 on 2024-12-18 15:59 + +from .helpers.statement_preview import get_migration_prefix_for_statement_preview, get_migration_suffix_for_statement_preview +from django.db import migrations + + +def update_suffix_prefix_for_connectivity_statement_fields(apps, schema_editor): + ConnectivityStatement = apps.get_model('composer', 'ConnectivityStatement') + for cs in ConnectivityStatement.objects.all(): + cs.statement_prefix = get_migration_prefix_for_statement_preview(cs) + cs.statement_suffix = get_migration_suffix_for_statement_preview(cs) + cs.save(update_fields=["statement_prefix", "statement_suffix"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("composer", "0068_connectivitystatement_statement_prefix_and_more"), + ] + + operations = [ + migrations.RunPython( + update_suffix_prefix_for_connectivity_statement_fields) + ] diff --git a/backend/composer/migrations/helpers/__init__.py b/backend/composer/migrations/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/composer/migrations/helpers/statement_preview.py b/backend/composer/migrations/helpers/statement_preview.py new file mode 100644 index 00000000..ef12dadb --- /dev/null +++ b/backend/composer/migrations/helpers/statement_preview.py @@ -0,0 +1,60 @@ +from composer.utils import join_entities +from django.apps import apps + + +def get_migration_prefix_for_statement_preview(cs) -> str: + sex = cs.sex.name if cs.sex else None + + species_list = [ + specie.name for specie in cs.species.all()] + species = join_entities(species_list) + if not species: + species = "" + + phenotype = cs.phenotype.name if cs.phenotype else '' + if sex or species != "": + statement = f"In {sex or ''} {species}, the {phenotype.lower()} connection goes" + else: + statement = f"A {phenotype.lower()} connection goes" + return statement + + +def get_migration_suffix_for_statement_preview(cs): + ConnectivityStatement = apps.get_model( + 'composer', 'ConnectivityStatement') + connectivity_statement = ConnectivityStatement.objects.get( + id=cs.id) + + circuit_type = connectivity_statement.get_circuit_type_display( + ) if connectivity_statement.circuit_type else None + projection = connectivity_statement.get_projection_display( + ) if connectivity_statement.projection else None + projection_phenotype = str( + connectivity_statement.projection_phenotype) if connectivity_statement.projection_phenotype else '' + + laterality_description = connectivity_statement.get_laterality_description() + apinatomy = connectivity_statement.apinatomy_model if connectivity_statement.apinatomy_model else "" + + origin_names = [ + origin.name for origin in connectivity_statement.origins.all()] + origins = join_entities(origin_names) + + if not origins: + origins = "" + + statement = f"This " + if projection: + statement += f"{projection.lower()} " + if projection_phenotype: + statement += f"{projection_phenotype.lower()} " + if circuit_type: + statement += f"{circuit_type.lower()} " + + statement += f"connection projects from the {origins}." + if laterality_description: + statement = statement[:-1] + \ + f" and is found {laterality_description}.\n" + + if apinatomy: + statement += f" It is described in {apinatomy} model." + return statement diff --git a/backend/composer/models.py b/backend/composer/models.py index 8aa68c2e..653cef6a 100644 --- a/backend/composer/models.py +++ b/backend/composer/models.py @@ -191,10 +191,6 @@ class Sex(models.Model): def __str__(self): return self.name - @property - def sex_str(self): - return str(self.name) if self.name else '' - class Meta: ordering = ["name"] verbose_name_plural = "Sex" @@ -568,6 +564,8 @@ class ConnectivityStatement(models.Model): created_date = models.DateTimeField(auto_now_add=True, db_index=True) modified_date = models.DateTimeField(auto_now=True, db_index=True) journey_path = models.JSONField(null=True, blank=True) + statement_prefix = models.TextField(null=True, blank=True) + statement_suffix = models.TextField(null=True, blank=True) def __str__(self): suffix = "" @@ -1030,7 +1028,7 @@ class AlertType(models.Model): def __str__(self): return self.name - + class StatementAlert(models.Model): connectivity_statement = models.ForeignKey( diff --git a/backend/composer/services/statement_service.py b/backend/composer/services/statement_service.py new file mode 100644 index 00000000..59e7a87c --- /dev/null +++ b/backend/composer/services/statement_service.py @@ -0,0 +1,63 @@ +from ..utils import join_entities + + +def get_prefix_for_statement_preview(cs) -> str: + sex = cs.sex.name if cs.sex else None + + species_list = [ + specie.name for specie in cs.species.all()] + species = join_entities(species_list) + if not species: + species = "" + + phenotype = cs.phenotype.name if cs.phenotype else '' + if sex or species != "": + statement = f"In {sex or ''} {species}, the {phenotype.lower()} connection goes" + else: + statement = f"A {phenotype.lower()} connection goes" + return statement + + +def get_suffix_for_statement_preview(cs): + + circuit_type = cs.get_circuit_type_display( + ) if cs.circuit_type else None + projection = cs.get_projection_display( + ) if cs.projection else None + projection_phenotype = str( + cs.projection_phenotype) if cs.projection_phenotype else '' + + laterality_description = cs.get_laterality_description() + apinatomy = cs.apinatomy_model if cs.apinatomy_model else "" + + origin_names = [ + origin.name for origin in cs.origins.all()] + origins = join_entities(origin_names) + + if not origins: + origins = "" + + statement = f"This " + if projection: + statement += f"{projection.lower()} " + if projection_phenotype: + statement += f"{projection_phenotype.lower()} " + if circuit_type: + statement += f"{circuit_type.lower()} " + + statement += f"connection projects from the {origins}." + if laterality_description: + statement = statement[:-1] + \ + f" and is found {laterality_description}.\n" + + if apinatomy: + statement += f" It is described in {apinatomy} model." + return statement + + +def create_statement_preview(cs, journey): + prefix = cs.statement_prefix + journey_sentence = '; '.join(journey) + suffix = cs.statement_suffix + statement = f'{prefix} {journey_sentence}.\n{suffix}' + return statement.strip().replace(" ", " ") diff --git a/backend/composer/signals.py b/backend/composer/signals.py index aad972b1..b789fba9 100644 --- a/backend/composer/signals.py +++ b/backend/composer/signals.py @@ -1,9 +1,14 @@ +import logging from django.dispatch import receiver from django.db.models.signals import post_save, m2m_changed, post_delete from django.contrib.auth import get_user_model from django_fsm.signals import post_transition from composer.services.layers_service import update_from_entities_on_deletion +from composer.services.statement_service import ( + get_suffix_for_statement_preview, + get_prefix_for_statement_preview, +) from .utils import update_modified_date from .enums import CSState, NoteType from .models import ( @@ -17,12 +22,12 @@ Layer, Region, Via, + Specie, ) from .services.export_services import compute_metrics, ConnectivityStatementStateService from .services.graph_service import recompile_journey_path - @receiver(post_save, sender=ExportBatch) def export_batch_post_save(sender, instance=None, created=False, **kwargs): if created and instance: @@ -35,7 +40,7 @@ def post_transition_callback(sender, instance, name, source, target, **kwargs): User = get_user_model() method_kwargs = kwargs.get("method_kwargs", {}) user = method_kwargs.get("by") - system_user = User.objects.get(username='system') + system_user = User.objects.get(username="system") if issubclass(sender, ConnectivityStatement): connectivity_statement = instance else: @@ -105,7 +110,6 @@ def connectivity_statement_origins_changed(sender, instance, action, pk_set, **k pass recompile_journey_path(instance) - # Call `update_from_entities_on_deletion` for each removed entity if action == "post_remove" and pk_set: for deleted_entity_id in pk_set: @@ -127,7 +131,9 @@ def via_anatomical_entities_changed(sender, instance, action, pk_set, **kwargs): # Call `update_from_entities_on_deletion` for each removed entity if action == "post_remove" and pk_set: for deleted_entity_id in pk_set: - update_from_entities_on_deletion(instance.connectivity_statement, deleted_entity_id) + update_from_entities_on_deletion( + instance.connectivity_statement, deleted_entity_id + ) # Signals for Via from_entities @@ -143,7 +149,6 @@ def via_from_entities_changed(sender, instance, action, **kwargs): recompile_journey_path(instance.connectivity_statement) - # Signals for Destination anatomical_entities @receiver(m2m_changed, sender=Destination.anatomical_entities.through) def destination_anatomical_entities_changed(sender, instance, action, **kwargs): @@ -157,7 +162,6 @@ def destination_anatomical_entities_changed(sender, instance, action, **kwargs): recompile_journey_path(instance.connectivity_statement) - # Signals for Destination from_entities @receiver(m2m_changed, sender=Destination.from_entities.through) def destination_from_entities_changed(sender, instance, action, **kwargs): @@ -171,7 +175,6 @@ def destination_from_entities_changed(sender, instance, action, **kwargs): recompile_journey_path(instance.connectivity_statement) - # Signals for Via model changes @receiver(post_save, sender=Via) @receiver(post_delete, sender=Via) @@ -195,13 +198,21 @@ def destination_changed(sender, instance, **kwargs): except ValueError: pass + # TAG: If a sentence/CS tag is changed, update the modified_date -@receiver(m2m_changed, sender=Sentence.tags.through, dispatch_uid="sentence_tags_changed") -@receiver(m2m_changed, sender=ConnectivityStatement.tags.through, dispatch_uid="cs_tags_changed") +@receiver( + m2m_changed, sender=Sentence.tags.through, dispatch_uid="sentence_tags_changed" +) +@receiver( + m2m_changed, + sender=ConnectivityStatement.tags.through, + dispatch_uid="cs_tags_changed", +) def sentence_and_cs_tags_changed(sender, instance, action, **kwargs): if action in ["post_add", "post_remove", "post_clear"]: update_modified_date(instance) + # NOTE: If a note is added, updated, or deleted, update the modified_date of the sentence/CS @receiver(post_save, sender=Note, dispatch_uid="note_post_save") @receiver(post_delete, sender=Note, dispatch_uid="note_post_delete") @@ -210,3 +221,71 @@ def note_post_save_and_delete(sender, instance, **kwargs): update_modified_date(instance.sentence) if instance.connectivity_statement: update_modified_date(instance.connectivity_statement) + + +@receiver( + m2m_changed, + sender=ConnectivityStatement.species.through, + dispatch_uid="update_prefix_for_species", +) +@receiver( + m2m_changed, + sender=ConnectivityStatement.origins.through, + dispatch_uid="update_suffix_for_origins", +) +@receiver( + [post_save, post_delete], + sender=ConnectivityStatement, + dispatch_uid="update_prefix_suffix_for_statement", +) +def update_prefix_suffix_for_connectivity_statement_preview( + sender, instance, action=None, **kwargs +): + if not isinstance(instance, ConnectivityStatement): + return + + relevant_suffix_fields = [ + "circuit_type", + "projection", + "projection_phenotype_id", + "laterality", + "apinatomy_model", + ] + relevant_prefix_fields = ["sex", "phenotype"] + relevant_fields = relevant_suffix_fields + relevant_prefix_fields + + updated_fields = kwargs.get("update_fields", []) or [] + has_relevant_field_changed = any( + field in updated_fields for field in relevant_fields + ) + + update_prefix = False + update_suffix = False + + if sender == ConnectivityStatement.species.through: + update_prefix = action in ["post_add", "post_remove", "post_clear"] + elif sender == ConnectivityStatement.origins.through: + update_suffix = action in ["post_add", "post_remove", "post_clear"] + elif has_relevant_field_changed: + update_prefix = update_suffix = True + + if not (update_prefix or update_suffix): + return + + update_fields = [] + + if update_prefix: + instance.statement_prefix = get_prefix_for_statement_preview(instance) + update_fields.append("statement_prefix") + + if update_suffix: + instance.statement_suffix = get_suffix_for_statement_preview(instance) + update_fields.append("statement_suffix") + + if update_fields: + try: + instance.save(update_fields=update_fields) + except Exception as e: + logging.error( + f"Error updating prefix/suffix for ConnectivityStatement {instance.id}: {str(e)}" + )