diff --git a/Makefile b/Makefile index f0038ab..d9a3fc9 100644 --- a/Makefile +++ b/Makefile @@ -32,8 +32,10 @@ typecheck: check: uv run pre-commit run --all-files +# Only the container-free SQLite tier for now; the pre-refactor tests are not +# yet ported (see `sqlite` marker in pyproject.toml). test: - uv run pytest + uv run pytest -m sqlite ci: check test diff --git a/hippo/bootstrap.py b/hippo/bootstrap.py index e8aa107..e78dfb7 100644 --- a/hippo/bootstrap.py +++ b/hippo/bootstrap.py @@ -6,8 +6,6 @@ import mrich from django.conf import settings -from .ta_auth_connector import get_auth_target_access - # fix path ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT)) @@ -80,11 +78,12 @@ def load_hippo( mrich.bold('Creating HIPPO animal') mrich.var('target_name', target_name, color='arg') - tas_list = get_auth_target_access(username) + # TODO: disabled because of STFC downtime on 03-07-2026. re-enable when done + # tas_list = get_auth_target_access(username) - if target_access_string not in tas_list: - mrich.error(f'User {username} does not have access to {target_access_string}') - return + # if target_access_string not in tas_list: + # mrich.error(f'User {username} does not have access to {target_access_string}') + # return if db is None: # populate from env diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 7f2ad87..760e3d7 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -60,6 +60,13 @@ 'metadata_info', ) +# When True, HIPPO.__init__ downloads this target's apo_desolv protein PDBs from +# Fragalysis (see HIPPO._ensure_apo_desolv_files). Toggle this module-level flag +# to enable/disable download-on-init -- deliberately NOT read from the +# environment. Currently False because the Fragalysis download/auth services are +# down for maintenance; set True to re-enable. +DOWNLOAD_APO_DESOLV_ON_INIT = False + class HIPPO: """Entry-point class of the xchem-hippo package. @@ -99,14 +106,10 @@ def __init__( self._apo_desolv_path: Path | None = None self._apo_desolv_downloaded_at: datetime | None = None - # Download policy: a first run downloads nothing here (the first add_hits - # fetches the full data). On re-instantiation (a persisted download already - # on disk) only the apo_desolv proteins are refreshed, so a new session has - # current PDBs. - target_dir = DOWNLOADS_DIR / project.project_name / target_name - if (target_dir / 'metadata.csv').is_file() and ( - target_dir / 'aligned_files' - ).is_dir(): + # Optionally download this target's apo_desolv protein PDBs on init, + # gated only by the DOWNLOAD_APO_DESOLV_ON_INIT flag (no longer requires a + # previously downloaded aligned_files directory to be present). + if DOWNLOAD_APO_DESOLV_ON_INIT: try: self._ensure_apo_desolv_files( auth_token=self._auth_token, stack=self._stack diff --git a/hippo/designdb/migrations/0001_initial.py b/hippo/designdb/migrations/0001_initial.py new file mode 100644 index 0000000..29abbf5 --- /dev/null +++ b/hippo/designdb/migrations/0001_initial.py @@ -0,0 +1,821 @@ +# Generated by Django 6.0.3 on 2026-07-09 11:19 + +import designdb.models +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='FeatureModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('feature_family', models.TextField(blank=True, null=True)), + ('feature_chain_name', models.TextField(blank=True, null=True)), + ('feature_residue_name', models.TextField(blank=True, null=True)), + ('feature_residue_number', models.IntegerField(blank=True, null=True)), + ('feature_atom_name', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'features', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='InspirationModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ], + options={ + 'db_table': 'inspirations', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='SubsiteModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('subsite_name', models.TextField()), + ('subsite_metadata', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'subsites', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='CatalogueCompoundModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('catalogue_smiles', models.TextField(blank=True)), + ('catalogue_inchikey', models.TextField(blank=True)), + ('catalogue_hash', models.TextField(blank=True)), + ('rdkit_version', models.TextField(blank=True, null=True)), + ('inchi_version', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'catalogue_compounds', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + 'constraints': [models.UniqueConstraint(fields=('catalogue_smiles',), name='uq_catalogue_compounds_smiles'), models.CheckConstraint(condition=models.Q(('catalogue_hash__isnull', False), ('catalogue_hash__gt', '')), name='ck_catalogue_compounds_hash_nonempty')], + }, + ), + migrations.CreateModel( + name='CataloguePriceModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('vendor', models.TextField(blank=True)), + ('supplier', models.TextField(blank=True, null=True)), + ('supplier_id', models.TextField(blank=True)), + ('amount', models.FloatField(blank=True, null=True)), + ('price', models.FloatField(blank=True, null=True)), + ('currency', models.TextField(blank=True, null=True)), + ('purity', models.FloatField(blank=True, null=True)), + ('lead_time', models.IntegerField(blank=True, null=True)), + ('catalogue_compound', models.ForeignKey(db_column='catalogue_id', null=True, on_delete=django.db.models.deletion.CASCADE, to='designdb.cataloguecompoundmodel')), + ], + options={ + 'db_table': 'catalogue_prices', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='CataloguePriceCompoundJunctionModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('pk', models.CompositePrimaryKey('compound_id', 'catalogue_price_id', blank=True, editable=False, primary_key=True, serialize=False)), + ('match_hash', models.TextField(blank=True)), + ('catalogue_price', models.ForeignKey(db_column='catalogue_price_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.cataloguepricemodel')), + ], + options={ + 'db_table': 'compound_catalogue_map', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='CompoundModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('compound_inchikey', models.TextField(blank=True, null=True)), + ('compound_alias', models.TextField(blank=True, null=True)), + ('compound_smiles', models.TextField(blank=True, null=True)), + ('compound_hash', models.TextField(blank=True, default='a')), + ('compound_mol', models.TextField(blank=True, null=True)), + ('compound_pattern_bfp', models.BinaryField(max_length=2048, null=True)), + ('compound_morgan_bfp', models.BinaryField(max_length=2048, null=True)), + ('compound_metadata', models.TextField(blank=True, null=True)), + ('note', models.TextField(blank=True, null=True)), + ('rdkit_version', models.TextField(blank=True, null=True)), + ('inchi_version', models.TextField(blank=True, null=True)), + ('base_compound', models.ForeignKey(blank=True, db_column='base_compound_id', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='designdb.compoundmodel')), + ], + options={ + 'db_table': 'compounds', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='CompoundEnumerationMethodJunctionModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('pk', models.CompositePrimaryKey('compound_id', 'enumeration_method_id', blank=True, editable=False, primary_key=True, serialize=False)), + ('compound', models.ForeignKey(db_column='compound_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.compoundmodel')), + ], + options={ + 'db_table': 'has_enumeration_methods', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.AddField( + model_name='cataloguepricemodel', + name='compounds', + field=models.ManyToManyField(related_name='prices', through='designdb.CataloguePriceCompoundJunctionModel', to='designdb.compoundmodel'), + ), + migrations.AddField( + model_name='cataloguepricecompoundjunctionmodel', + name='compound', + field=models.ForeignKey(db_column='compound_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.compoundmodel'), + ), + migrations.CreateModel( + name='CompoundTagModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('compound_tag_name', models.TextField()), + ('compound_tag_description', models.TextField(blank=True, null=True)), + ('compound_tag_note', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'compound_tags', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + 'indexes': [models.Index(fields=['created_on'], name='idx_compound_tag_created')], + 'constraints': [models.UniqueConstraint(fields=('compound_tag_name',), name='uc_compound_tag_name')], + }, + ), + migrations.CreateModel( + name='CompoundTagJunctionModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('pk', models.CompositePrimaryKey('compound_id', 'compound_tag_id', blank=True, editable=False, primary_key=True, serialize=False)), + ('compound', models.ForeignKey(db_column='compound_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.compoundmodel')), + ('compound_tag', models.ForeignKey(db_column='compound_tag_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.compoundtagmodel')), + ], + options={ + 'db_table': 'has_compound_tags', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.AddField( + model_name='compoundmodel', + name='tags', + field=models.ManyToManyField(related_name='compounds', through='designdb.CompoundTagJunctionModel', to='designdb.compoundtagmodel'), + ), + migrations.CreateModel( + name='EnumerationMethodModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('enum_name', models.TextField(blank=True, null=True)), + ('enum_description', models.TextField(blank=True, null=True)), + ('enum_version', models.TextField(blank=True, null=True)), + ('enum_organization', models.TextField(blank=True, null=True)), + ('enum_link', models.TextField(blank=True, null=True)), + ('enum_note', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'enumeration_methods', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + 'indexes': [models.Index(fields=['enum_name'], name='idx_enumeration_method_name'), models.Index(fields=['created_on'], name='idx_enumeration_method_created')], + 'constraints': [models.UniqueConstraint(fields=('enum_name', 'enum_version'), name='uc_enumeration_method', nulls_distinct=False)], + }, + ), + migrations.AddField( + model_name='compoundmodel', + name='enumeration_methods', + field=models.ManyToManyField(related_name='compounds', through='designdb.CompoundEnumerationMethodJunctionModel', to='designdb.enumerationmethodmodel'), + ), + migrations.AddField( + model_name='compoundenumerationmethodjunctionmodel', + name='enumeration_method', + field=models.ForeignKey(db_column='enumeration_method_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.enumerationmethodmodel'), + ), + migrations.CreateModel( + name='PoseMethodModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('pose_method_name', models.TextField(blank=True, null=True)), + ('pose_method_description', models.TextField(blank=True, null=True)), + ('pose_method_version', models.TextField(blank=True, null=True)), + ('pose_method_organization', models.TextField(blank=True, null=True)), + ('pose_method_link', models.TextField(blank=True, null=True)), + ('pose_method_note', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'pose_methods', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + 'indexes': [models.Index(fields=['pose_method_name'], name='idx_pose_method_name'), models.Index(fields=['created_on'], name='idx_pose_method_created')], + 'constraints': [models.UniqueConstraint(fields=('pose_method_name', 'pose_method_version'), name='uc_pose_method', nulls_distinct=False)], + }, + ), + migrations.CreateModel( + name='PoseMethodJunctionModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('pk', models.CompositePrimaryKey('pose_id', 'pose_method_id', blank=True, editable=False, primary_key=True, serialize=False)), + ('pose_method', models.ForeignKey(db_column='pose_method_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.posemethodmodel')), + ], + options={ + 'db_table': 'has_pose_methods', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='PoseModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('pose_inchikey', models.TextField(blank=True, null=True)), + ('pose_alias', models.TextField(blank=True, null=True)), + ('pose_smiles', models.TextField(blank=True, null=True)), + ('pose_reference', models.IntegerField(blank=True, null=True)), + ('protein_link', models.TextField(blank=True, null=True)), + ('pose_mol', designdb.models.RDKitMolField(null=True)), + ('pose_fingerprint', models.IntegerField(blank=True, null=True)), + ('pose_metadata', designdb.models.JSONTextField(blank=True, null=True)), + ('note', models.TextField(blank=True, null=True)), + ('rdkit_version', models.TextField(blank=True, null=True)), + ('inchi_version', models.TextField(blank=True, null=True)), + ('compound', models.ForeignKey(db_column='compound_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.compoundmodel')), + ('inspirations', models.ManyToManyField(through='designdb.InspirationModel', through_fields=('derivative_pose', 'original_pose'), to='designdb.posemodel')), + ('methods', models.ManyToManyField(related_name='poses', through='designdb.PoseMethodJunctionModel', to='designdb.posemethodmodel')), + ], + options={ + 'db_table': 'poses', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.AddField( + model_name='posemethodjunctionmodel', + name='pose', + field=models.ForeignKey(db_column='pose_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.posemodel'), + ), + migrations.CreateModel( + name='InteractionModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('interaction_type', models.TextField()), + ('interaction_family', models.TextField()), + ('interaction_atom_id', models.TextField()), + ('interaction_prot_coord', models.TextField()), + ('interaction_lig_coord', models.TextField()), + ('interaction_distance', models.FloatField()), + ('interaction_angle', models.FloatField(blank=True, null=True)), + ('interaction_energy', models.FloatField(blank=True, null=True)), + ('feature', models.ForeignKey(db_column='feature_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.featuremodel')), + ('pose', models.ForeignKey(db_column='pose_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.posemodel')), + ], + options={ + 'db_table': 'interactions', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.AddField( + model_name='inspirationmodel', + name='derivative_pose', + field=models.ForeignKey(db_column='derivative_pose_id', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='designdb.posemodel'), + ), + migrations.AddField( + model_name='inspirationmodel', + name='original_pose', + field=models.ForeignKey(db_column='original_pose_id', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='designdb.posemodel'), + ), + migrations.CreateModel( + name='PoseTagModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('pose_tag_name', models.TextField()), + ('pose_tag_description', models.TextField(blank=True, null=True)), + ('pose_tag_note', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'pose_tags', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + 'indexes': [models.Index(fields=['created_on'], name='idx_pose_tag_created')], + 'constraints': [models.UniqueConstraint(fields=('pose_tag_name',), name='uc_pose_tag')], + }, + ), + migrations.CreateModel( + name='PoseTagJunctionModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('pk', models.CompositePrimaryKey('pose_id', 'pose_tag_id', blank=True, editable=False, primary_key=True, serialize=False)), + ('pose', models.ForeignKey(db_column='pose_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.posemodel')), + ('pose_tag', models.ForeignKey(db_column='pose_tag_id', on_delete=django.db.models.deletion.CASCADE, to='designdb.posetagmodel')), + ], + options={ + 'db_table': 'has_pose_tags', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.AddField( + model_name='posemodel', + name='tags', + field=models.ManyToManyField(related_name='poses', through='designdb.PoseTagJunctionModel', to='designdb.posetagmodel'), + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('project_name', models.TextField(unique=True)), + ('open_to_public', models.BooleanField(default=False)), + ], + options={ + 'db_table': 'projects', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + 'constraints': [models.UniqueConstraint(fields=('project_name',), name='uc_project')], + }, + ), + migrations.CreateModel( + name='ReactionModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('reaction_type', models.TextField(blank=True, null=True)), + ('reaction_product_yield', models.FloatField(blank=True, null=True)), + ('reaction_metadata', models.TextField(blank=True, null=True)), + ('product_compound', models.ForeignKey(db_column='product_compound_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.compoundmodel')), + ], + options={ + 'db_table': 'reactions', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='ReactantModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('reactant_amount', models.FloatField(blank=True, null=True)), + ('compound', models.ForeignKey(db_column='compound_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.compoundmodel')), + ('reaction', models.ForeignKey(db_column='reaction_id', on_delete=django.db.models.deletion.CASCADE, related_name='reactants', to='designdb.reactionmodel')), + ], + options={ + 'db_table': 'reactants', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='RouteModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('product_compound', models.ForeignKey(db_column='product_compound_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.compoundmodel')), + ], + options={ + 'db_table': 'routes', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='ComponentModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('component_type', models.IntegerField(blank=True, null=True)), + ('component_ref', models.IntegerField(blank=True, null=True)), + ('component_amount', models.FloatField(blank=True, null=True)), + ('route', models.ForeignKey(db_column='route_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.routemodel')), + ], + options={ + 'db_table': 'components', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='ScaffoldModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('base_compound', models.ForeignKey(db_column='base_compound_id', on_delete=django.db.models.deletion.CASCADE, related_name='scaffold_bases', to='designdb.compoundmodel')), + ('superstructure_compound', models.ForeignKey(db_column='superstructure_compound_id', on_delete=django.db.models.deletion.CASCADE, related_name='scaffold_superstructures', to='designdb.compoundmodel')), + ], + options={ + 'db_table': 'scaffolds', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.AddField( + model_name='compoundmodel', + name='scaffolds', + field=models.ManyToManyField(through='designdb.ScaffoldModel', to='designdb.compoundmodel'), + ), + migrations.CreateModel( + name='ScoringMethodModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('method_name', models.TextField(blank=True, null=True)), + ('method_description', models.TextField(blank=True, null=True)), + ('method_version', models.TextField(blank=True, null=True)), + ('method_organization', models.TextField(blank=True, null=True)), + ('method_link', models.TextField(blank=True, null=True)), + ('note', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'scoring_methods', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + 'indexes': [models.Index(fields=['method_name'], name='idx_scoring_method_name'), models.Index(fields=['created_on'], name='idx_scoring_method_created')], + 'constraints': [models.UniqueConstraint(fields=('method_name', 'method_version'), name='uc_scoring_method', nulls_distinct=False)], + }, + ), + migrations.CreateModel( + name='ScoreValueModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('pk', models.CompositePrimaryKey('pose_id', 'compound_id', 'scoring_method_id', blank=True, editable=False, primary_key=True, serialize=False)), + ('score', models.JSONField()), + ('compound', models.ForeignKey(db_column='compound_id', on_delete=django.db.models.deletion.RESTRICT, related_name='scores', to='designdb.compoundmodel')), + ('pose', models.ForeignKey(db_column='pose_id', on_delete=django.db.models.deletion.RESTRICT, related_name='scores', to='designdb.posemodel')), + ('scoring_method', models.ForeignKey(db_column='scoring_method_id', on_delete=django.db.models.deletion.RESTRICT, related_name='scores', to='designdb.scoringmethodmodel')), + ], + options={ + 'db_table': 'score_values', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.CreateModel( + name='SubsiteTagModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('subsite_tag_metadata', models.TextField(blank=True, null=True)), + ('pose', models.ForeignKey(db_column='pose_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.posemodel')), + ('subsite', models.ForeignKey(db_column='subsite_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.subsitemodel')), + ], + options={ + 'db_table': 'subsite_tags', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.AddField( + model_name='posemodel', + name='subsites', + field=models.ManyToManyField(through='designdb.SubsiteTagModel', to='designdb.subsitemodel'), + ), + migrations.CreateModel( + name='TargetModel', + fields=[ + ('created_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('updated_on', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('external_target_id', models.BigIntegerField(blank=True, null=True)), + ('target_name', models.TextField()), + ('target_metadata', models.TextField(blank=True, null=True)), + ('project', models.ForeignKey(db_column='project_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.project')), + ], + options={ + 'db_table': 'targets', + 'abstract': False, + 'managed': True, + 'default_related_name': '%(class)ss', + }, + ), + migrations.AddField( + model_name='subsitemodel', + name='target', + field=models.ForeignKey(db_column='target_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.targetmodel'), + ), + migrations.AddField( + model_name='posemodel', + name='target', + field=models.ForeignKey(db_column='target_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.targetmodel'), + ), + migrations.AddField( + model_name='featuremodel', + name='target', + field=models.ForeignKey(db_column='target_id', on_delete=django.db.models.deletion.RESTRICT, to='designdb.targetmodel'), + ), + migrations.AddConstraint( + model_name='cataloguepricemodel', + constraint=models.UniqueConstraint(fields=('catalogue_compound', 'vendor', 'supplier', 'supplier_id', 'amount'), name='uc_catalogue_price'), + ), + migrations.AddConstraint( + model_name='cataloguepricecompoundjunctionmodel', + constraint=models.CheckConstraint(condition=models.Q(('match_hash__isnull', False), ('match_hash__gt', '')), name='ck_compound_catalogue_map_match_hash_nonempty'), + ), + migrations.AddIndex( + model_name='compoundtagjunctionmodel', + index=models.Index(fields=['compound_tag'], name='idx_has_compound_tag_compound_tag_id'), + ), + migrations.AddIndex( + model_name='compoundtagjunctionmodel', + index=models.Index(fields=['created_on'], name='idx_has_compound_tag_created'), + ), + migrations.AddIndex( + model_name='compoundenumerationmethodjunctionmodel', + index=models.Index(fields=['enumeration_method'], name='idx_has_enumeration_methods_enumeration_method_id'), + ), + migrations.AddIndex( + model_name='compoundenumerationmethodjunctionmodel', + index=models.Index(fields=['created_on'], name='idx_has_enumeration_methods_created'), + ), + migrations.AddIndex( + model_name='posemethodjunctionmodel', + index=models.Index(fields=['pose_method'], name='idx_has_pose_methods_pose_method_id'), + ), + migrations.AddIndex( + model_name='posemethodjunctionmodel', + index=models.Index(fields=['created_on'], name='idx_idx_has_pose_methods_created'), + ), + migrations.AddIndex( + model_name='interactionmodel', + index=models.Index(fields=['feature_id'], name='idx_interaction_feature_id'), + ), + migrations.AddIndex( + model_name='interactionmodel', + index=models.Index(fields=['pose'], name='idx_interaction_pose_id'), + ), + migrations.AddIndex( + model_name='interactionmodel', + index=models.Index(fields=['created_on'], name='idx_interaction_created'), + ), + migrations.AddConstraint( + model_name='interactionmodel', + constraint=models.UniqueConstraint(fields=('feature', 'pose', 'interaction_type', 'interaction_family', 'interaction_atom_id'), name='uc_interaction'), + ), + migrations.AddIndex( + model_name='inspirationmodel', + index=models.Index(fields=['original_pose'], name='idx_inspiration_original_pose_id'), + ), + migrations.AddIndex( + model_name='inspirationmodel', + index=models.Index(fields=['derivative_pose'], name='idx_inspiration_derivative_pose_id'), + ), + migrations.AddIndex( + model_name='inspirationmodel', + index=models.Index(fields=['created_on'], name='idx_inspiration_created'), + ), + migrations.AddConstraint( + model_name='inspirationmodel', + constraint=models.UniqueConstraint(fields=('original_pose', 'derivative_pose'), name='uc_inspiration'), + ), + migrations.AddIndex( + model_name='posetagjunctionmodel', + index=models.Index(fields=['pose_tag'], name='idx_has_pose_tag_pose_tag_id'), + ), + migrations.AddIndex( + model_name='posetagjunctionmodel', + index=models.Index(fields=['created_on'], name='idx_has_pose_tag_created'), + ), + migrations.AddIndex( + model_name='reactionmodel', + index=models.Index(fields=['product_compound'], name='idx_reaction_product_compound_id'), + ), + migrations.AddIndex( + model_name='reactionmodel', + index=models.Index(fields=['created_on'], name='idx_reaction_created'), + ), + migrations.AddIndex( + model_name='reactantmodel', + index=models.Index(fields=['reaction'], name='idx_reactant_reaction_id'), + ), + migrations.AddIndex( + model_name='reactantmodel', + index=models.Index(fields=['compound'], name='idx_reactant_compound_id'), + ), + migrations.AddIndex( + model_name='reactantmodel', + index=models.Index(fields=['created_on'], name='idx_reactant_created'), + ), + migrations.AddConstraint( + model_name='reactantmodel', + constraint=models.UniqueConstraint(fields=('reaction', 'compound'), name='uc_reactant'), + ), + migrations.AddIndex( + model_name='routemodel', + index=models.Index(fields=['product_compound'], name='idx_route_product_compound_id'), + ), + migrations.AddIndex( + model_name='routemodel', + index=models.Index(fields=['created_on'], name='idx_route_created'), + ), + migrations.AddIndex( + model_name='componentmodel', + index=models.Index(fields=['route'], name='idx_component_route_id'), + ), + migrations.AddIndex( + model_name='componentmodel', + index=models.Index(fields=['created_on'], name='idx_component_created'), + ), + migrations.AddConstraint( + model_name='componentmodel', + constraint=models.UniqueConstraint(fields=('route', 'component_ref', 'component_type'), name='uc_component'), + ), + migrations.AddIndex( + model_name='scaffoldmodel', + index=models.Index(fields=['base_compound'], name='idx_scaffold_base_compound_id'), + ), + migrations.AddIndex( + model_name='scaffoldmodel', + index=models.Index(fields=['superstructure_compound'], name='idx_scaffold_superstructure_compound_id'), + ), + migrations.AddIndex( + model_name='scaffoldmodel', + index=models.Index(fields=['created_on'], name='idx_scaffold_created'), + ), + migrations.AddConstraint( + model_name='scaffoldmodel', + constraint=models.UniqueConstraint(fields=('base_compound', 'superstructure_compound'), name='uc_scaffold'), + ), + migrations.AddIndex( + model_name='compoundmodel', + index=models.Index(fields=['compound_inchikey'], name='idx_compound_inchikey'), + ), + migrations.AddIndex( + model_name='compoundmodel', + index=models.Index(fields=['created_on'], name='idx_compound_created'), + ), + migrations.AddConstraint( + model_name='compoundmodel', + constraint=models.UniqueConstraint(fields=('compound_inchikey',), name='uc_compound_inchikey'), + ), + migrations.AddIndex( + model_name='scorevaluemodel', + index=models.Index(fields=['pose'], name='idx_score_values_pose_id'), + ), + migrations.AddIndex( + model_name='scorevaluemodel', + index=models.Index(fields=['compound'], name='idx_score_values_compound_id'), + ), + migrations.AddIndex( + model_name='scorevaluemodel', + index=models.Index(fields=['scoring_method'], name='idx_score_values_scoring_method_id'), + ), + migrations.AddIndex( + model_name='scorevaluemodel', + index=models.Index(fields=['created_on'], name='idx_score_values_created'), + ), + migrations.AddIndex( + model_name='subsitetagmodel', + index=models.Index(fields=['subsite'], name='idx_subsite_tag_subsite_id'), + ), + migrations.AddIndex( + model_name='subsitetagmodel', + index=models.Index(fields=['pose'], name='idx_subsite_tag_pose_id'), + ), + migrations.AddIndex( + model_name='subsitetagmodel', + index=models.Index(fields=['created_on'], name='idx_subsite_tag_created'), + ), + migrations.AddConstraint( + model_name='subsitetagmodel', + constraint=models.UniqueConstraint(fields=('subsite', 'pose'), name='uc_subsite_tag'), + ), + migrations.AddIndex( + model_name='targetmodel', + index=models.Index(fields=['target_name'], name='idx_target_name'), + ), + migrations.AddIndex( + model_name='targetmodel', + index=models.Index(fields=['created_on'], name='idx_target_created'), + ), + migrations.AddConstraint( + model_name='targetmodel', + constraint=models.UniqueConstraint(fields=('target_name',), name='uc_target'), + ), + migrations.AddIndex( + model_name='subsitemodel', + index=models.Index(fields=['target'], name='idx_subsite_target_id'), + ), + migrations.AddIndex( + model_name='subsitemodel', + index=models.Index(fields=['created_on'], name='idx_subsite_created'), + ), + migrations.AddConstraint( + model_name='subsitemodel', + constraint=models.UniqueConstraint(fields=('target', 'subsite_name'), name='uc_subsite'), + ), + migrations.AddIndex( + model_name='posemodel', + index=models.Index(fields=['compound'], name='idx_pose_compound_id'), + ), + migrations.AddIndex( + model_name='posemodel', + index=models.Index(fields=['target'], name='idx_pose_target_id'), + ), + migrations.AddIndex( + model_name='posemodel', + index=models.Index(fields=['protein_link'], name='idx_protein_link'), + ), + migrations.AddIndex( + model_name='posemodel', + index=models.Index(fields=['created_on'], name='idx_pose_created'), + ), + migrations.AddIndex( + model_name='featuremodel', + index=models.Index(fields=['target'], name='idx_feature_target_id'), + ), + migrations.AddIndex( + model_name='featuremodel', + index=models.Index(fields=['created_on'], name='idx_feature_created'), + ), + migrations.AddConstraint( + model_name='featuremodel', + constraint=models.UniqueConstraint(fields=('feature_family', 'target', 'feature_chain_name', 'feature_residue_name', 'feature_residue_number', 'feature_atom_name'), name='uc_feature'), + ), + ] diff --git a/hippo/designdb/migrations/__init__.py b/hippo/designdb/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index b9fdc04..a76afee 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -898,7 +898,7 @@ class Meta(BaseModel.Meta): class CataloguePriceCompoundJunctionModel(BaseModel): - ipk = models.CompositePrimaryKey('compound_id', 'catalogue_price_id') + pk = models.CompositePrimaryKey('compound_id', 'catalogue_price_id') catalogue_price = models.ForeignKey( CataloguePriceModel, on_delete=models.CASCADE, diff --git a/hippo/designdb/services/compound.py b/hippo/designdb/services/compound.py index b206bd1..444f634 100644 --- a/hippo/designdb/services/compound.py +++ b/hippo/designdb/services/compound.py @@ -9,12 +9,11 @@ sanitise_smiles, superparent, ) +from django.conf import settings # from mypackage.services.compound import CompoundService from rdkit import Chem - -# from rdkit.Chem import inchi - +from rdkit.Chem.inchi import MolToInchiKey # from .validation.compound import ValidationError, validate_compound_data @@ -59,15 +58,23 @@ def create( h = registration_hash_tautomer_insensitive(sp) + defaults = { + 'compound_smiles': smiles, + 'rdkit_version': rdkit.__version__, + 'inchi_version': Chem.inchi.GetInchiVersion(), + } + + # In SQLite mode there is no cartridge, so populate compound_mol (CTAB) + # and compound_inchikey in Python. In Postgres the BEFORE INSERT trigger + # (populate_compound_cartridge_from_smiles) fills these from the cartridge + # and stays authoritative, so we leave them unset here. + if settings.MANAGE_MODELS: + defaults['compound_mol'] = Chem.MolToMolBlock(mol) + defaults['compound_inchikey'] = MolToInchiKey(mol) + compound, created = CompoundModel.objects.get_or_create( compound_hash=h, - defaults={ - # 'compound_mol': mol, - # 'compound_inchikey': inchikey, - 'compound_smiles': smiles, - 'rdkit_version': rdkit.__version__, - 'inchi_version': Chem.inchi.GetInchiVersion(), - }, + defaults=defaults, ) if not created and logger.level == logging.DEBUG: mrich.warning(f'Skipping compound {h}, duplicate of {compound.pk}') diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 111e5aa..c6263f8 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -1,4 +1,5 @@ import logging +import os import re from dataclasses import dataclass from pathlib import Path @@ -93,18 +94,22 @@ def parse_pdb_mp(pdb_path: Path, residue: int, chain: str) -> str: logger.debug('Reading %s', pdb_path) pdb = mp.parse(pdb_path, verbosity=0) + # protein_link is stored relative to the current working directory (e.g. + # data/downloads/...) so the database stays portable across machines + rel_pdb = os.path.relpath(pdb_path) + # create the single ligand bound pdb lig_residues = pdb.residues['LIG'] if len(lig_residues) > 1 or any(r.contains_alternative_sites for r in lig_residues): pdb = remove_other_ligands(pdb, residue, chain) pdb.prune_alternative_sites('A', verbosity=0) - pose_path = str(pdb_path.resolve()).replace('.pdb', '_hippo.pdb') + pose_path = rel_pdb.replace('.pdb', '_hippo.pdb') # side effect: writes pdb into file mp.write( pose_path, pdb, shift_name=True, verbosity=logger.level == logging.DEBUG ) else: - pose_path = str(pdb_path.resolve()) + pose_path = rel_pdb return pose_path @@ -532,7 +537,7 @@ def ingest_sdf( field_warning=field_warning, ) - pose_path = (output_directory / f'{r[name_col]}.fake.mol').resolve() + pose_path = os.path.relpath(output_directory / f'{r[name_col]}.fake.mol') pose, pose_created = PoseService.create( compound=compound, target=target, @@ -1165,7 +1170,7 @@ def ingest_syndirella_elabs( pose_ids = [] scorer = ScoreService() for _, row in ok.iterrows(): - path = Path(row.path_to_mol).resolve() + path = Path(os.path.relpath(row.path_to_mol)) print('comp id in row', row[f'{num_steps}_product_compound_id']) # closed for testing diff --git a/hippo/designdb/services/recipe.py b/hippo/designdb/services/recipe.py index 168486d..fece14d 100644 --- a/hippo/designdb/services/recipe.py +++ b/hippo/designdb/services/recipe.py @@ -307,7 +307,7 @@ def from_compounds( reactions on the fly """ - from designdb.recipe import Route + from designdb.recipe import Recipe, Route assert isinstance(compounds, CompoundSet) @@ -425,11 +425,17 @@ def from_compounds( if not combo: continue - solution = combo[0] - for i, recipe in enumerate(combo[1:]): - if debug: - mrich.debug(i + 1) - solution += recipe + # Combine the whole combination in one pass. Repeated `solution += + # recipe` was O(n^2) -- each Recipe.__add__ copies the growing sets and + # IngredientSet.add re-concats -- so batch-merge the underlying sets + # instead (see IngredientSet.sum_sets / ReactionSet.union). + solution = Recipe( + products=IngredientSet.sum_sets([r.products for r in combo]), + reactants=IngredientSet.sum_sets([r.reactants for r in combo]), + intermediates=IngredientSet.sum_sets([r.intermediates for r in combo]), + compounds=IngredientSet.sum_sets([r.compounds for r in combo]), + reactions=ReactionSet.union([r.reactions for r in combo]), + ) solutions.append(solution) ok += 1 diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index b7becd6..e6ce157 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -134,14 +134,21 @@ def __getitem__( """ match key: case int(): - index = self.indices[key] - try: - return CompoundModel.objects.get(id=index) - except CompoundModel.DoesNotExist as exc: - raise CompoundModel.DoesNotExist from exc + # index by position in the (ordered) set; support negative + # indices (e.g. cset[-1] -> last compound) + n = len(self) + idx = key + n if key < 0 else key + if not 0 <= idx < n: + raise IndexError(f'CompoundSet index out of range: {key}') + return self._queryset[idx] case slice(): - return CompoundSet(CompoundModel.objects.filter(pk__in=key)) + # positional slice of the ordered members. Slice `.all()` (a + # fresh, unevaluated clone) so this returns a queryset (LIMIT/ + # OFFSET) even when self._queryset is already evaluated -- an + # evaluated queryset would otherwise slice to a list of model + # instances. sort=False: a queryset can't be re-ordered once sliced. + return CompoundSet(self._queryset.all()[key], sort=False) case _: raise NotImplementedError @@ -154,21 +161,20 @@ def __sub__( multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + # local import to avoid the IngredientSet <-> CompoundSet cycle + from designdb.sets.ingredient import IngredientSet + + # materialise pks so set ops stay a single flat query instead of nesting + # pk__in subqueries, which recurses under repeated accumulation + ids = set(self._queryset.values_list('pk', flat=True)) match other: - case CompoundSet(): - return CompoundSet( - CompoundModel.objects.filter( - Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) - ), - sort=False, - ) + case CompoundSet() | IngredientSet(): + ids -= set(other.ids) case int(): - return CompoundSet( - CompoundModel.objects.filter( - Q(pk__in=self._queryset) & ~Q(pk=other.pk) - ), - sort=False, - ) + ids.discard(other) + case _: + raise NotImplementedError + return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False) def __add__( self, @@ -180,53 +186,27 @@ def __add__( # local import to avoid the IngredientSet <-> CompoundSet cycle from designdb.sets.ingredient import IngredientSet + # materialise pks: keep a single flat query, avoiding nested pk__in + # subqueries that recurse when accumulated (e.g. combining many recipes) + ids = set(self._queryset.values_list('pk', flat=True)) match other: case CompoundModel(): - return CompoundSet( - CompoundModel.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other._queryset) - ), - sort=False, - ) - + ids.add(other.pk) case int(): - return CompoundSet( - CompoundModel.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other._queryset) - ), - sort=False, - ) - - case CompoundSet(): - return CompoundSet( - CompoundModel.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other._queryset) - ), - sort=False, - ) - - case IngredientSet(): - return CompoundSet( - CompoundModel.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other._queryset) - ), - sort=False, - ) - + ids.add(other) + case CompoundSet() | IngredientSet(): + ids |= set(other.ids) case _: raise NotImplementedError + return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False) def __and__(self, other: 'CompoundSet'): """AND set operation, returns only compounds in both sets""" match other: case CompoundSet(): - return CompoundSet( - CompoundModel.objects.filter( - Q(pk__in=self._queryset) & Q(pk__in=other.queryset) - ), - sort=False, - ) + ids = set(self._queryset.values_list('pk', flat=True)) & set(other.ids) + return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False) case _: raise NotImplementedError @@ -236,12 +216,8 @@ def __or__(self, other: 'CompoundSet'): match other: case CompoundSet(): - return CompoundSet( - CompoundModel.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other.queryset) - ), - sort=False, - ) + ids = set(self._queryset.values_list('pk', flat=True)) | set(other.ids) + return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False) case _: raise NotImplementedError @@ -252,13 +228,8 @@ def __xor__(self, other: 'CompoundSet'): match other: case CompoundSet(): - return CompoundSet( - CompoundModel.objects.filter( - Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) - & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) - ), - sort=False, - ) + ids = set(self._queryset.values_list('pk', flat=True)) ^ set(other.ids) + return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False) case _: raise NotImplementedError diff --git a/hippo/designdb/sets/ingredient.py b/hippo/designdb/sets/ingredient.py index 91d4bb9..fc0dbae 100644 --- a/hippo/designdb/sets/ingredient.py +++ b/hippo/designdb/sets/ingredient.py @@ -12,7 +12,7 @@ from designdb.components.price import Price from designdb.models import CataloguePriceModel, CompoundModel from designdb.sets.compound import CompoundSet -from pandas import DataFrame, concat, isna +from pandas import DataFrame, concat, isna, to_numeric class IngredientSet: @@ -241,6 +241,50 @@ def from_ingredient_dicts( df = DataFrame(dicts, dtype=object) return cls.from_ingredient_df(df=df, supplier=supplier) + @classmethod + def sum_sets( + cls, + sets: 'list[IngredientSet]', + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Merge several :class:`.IngredientSet`\\ s into one in a single pass. + + Equivalent to accumulating them with ``+=`` (amounts for a shared compound + are summed, the first-seen quote is kept and dropped once the summed amount + exceeds its quoted amount) but O(total ingredients) rather than the O(n^2) + of repeated pairwise addition. See :meth:`.add`. + """ + frames = [s._data for s in sets if not s._data.empty] + if not frames: + return cls(supplier=supplier) + + combined = concat(frames, ignore_index=True, join='inner') + + # first-seen row per compound keeps its quote/supplier/lead_time (even a + # null quote) -- add() never overwrites an existing ingredient's quote + result = combined.drop_duplicates('compound_id', keep='first').set_index( + 'compound_id' + ) + + grouped = combined.groupby('compound_id', sort=False) + result['amount'] = grouped['amount'].sum() + counts = grouped.size() + + # a quote is dropped only for compounds that were actually merged + # (appear >1) whose summed amount exceeds the quoted amount (see add()) + quoted = to_numeric(result['quoted_amount'], errors='coerce') + amount = to_numeric(result['amount'], errors='coerce') + invalid = ( + (counts.reindex(result.index) > 1) + & quoted.notna() + & (quoted != 0) + & (quoted < amount) + ) + result.loc[invalid, 'quote_id'] = None + result.loc[invalid, 'quoted_amount'] = None + + return cls.from_ingredient_df(result.reset_index(), supplier=supplier) + @classmethod def from_compounds( cls, diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index e1e2154..bd0bb95 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -209,16 +209,20 @@ def __getitem__( match key: case int(): - try: - pose = PoseModel.objects.get(pk=key) - except PoseModel.DoesNotExist as exc: - mrich.error(f'list index out of range: {key=} for {self}') - raise PoseModel.DoesNotExist from exc - - return Pose(pose) + # index by position in the (ordered) set, not by pk; support + # negative indices (e.g. pset[-1] -> last pose) + n = len(self) + idx = key + n if key < 0 else key + if not 0 <= idx < n: + raise IndexError(f'PoseSet index out of range: {key}') + return Pose(self._queryset[idx]) case slice(): - return PoseSet(PoseModel.objects.filter(pk__in=key)) + # positional slice of the (ordered) members. Slice `.all()` (a + # fresh, unevaluated clone) so this returns a queryset even when + # self._queryset is already evaluated (which would otherwise slice + # to a list of instances). sort=False: can't re-order a sliced qs. + return PoseSet(self._queryset.all()[key], sort=False) case _: raise NotImplementedError diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py index 9e54b7d..4ace433 100644 --- a/hippo/designdb/sets/reaction.py +++ b/hippo/designdb/sets/reaction.py @@ -131,7 +131,11 @@ def __getitem__(self, key) -> 'ReactionModel | ReactionSet': return reaction case slice(): - return ReactionSet(ReactionModel.objects.filter(pk__in=key)) + # positional slice of the (ordered) members. Slice `.all()` (a + # fresh, unevaluated clone) so this returns a queryset even when + # self._queryset is already evaluated (which would otherwise slice + # to a list of instances). sort=False: can't re-order a sliced qs. + return ReactionSet(self._queryset.all()[key], sort=False) case _: mrich.error( @@ -143,13 +147,15 @@ def __getitem__(self, key) -> 'ReactionModel | ReactionSet': def __add__(self, other: 'ReactionSet') -> 'ReactionSet': """Add a :class:`.ReactionSet` to this one""" - if other: - return ReactionSet( - ReactionModel.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other.queryset) - ), - sort=False, - ) + if not other: + return self.copy() + # materialise pks so repeated accumulation (e.g. combining many recipes) + # stays a single flat query instead of nesting pk__in subqueries, which + # recurses and blows the recursion limit at scale + ids = set(self._queryset.values_list('pk', flat=True)) | set( + other.queryset.values_list('pk', flat=True) + ) + return ReactionSet(ReactionModel.objects.filter(pk__in=ids), sort=False) def __sub__( self, @@ -158,15 +164,28 @@ def __sub__( """Substract a :class:`.ReactionSet` from this set""" match other: case ReactionSet(): + ids = set(self._queryset.values_list('pk', flat=True)) - set( + other.queryset.values_list('pk', flat=True) + ) return ReactionSet( - ReactionModel.objects.filter( - Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) - ), + ReactionModel.objects.filter(pk__in=ids), sort=False, ) ### METHODS + @classmethod + def union(cls, sets: 'list[ReactionSet]') -> 'ReactionSet': + """Union several :class:`.ReactionSet`\\ s into one flat set in a single pass. + + Collects pks from each set and builds one ``pk__in`` query, avoiding the + nested subqueries that repeated ``+`` would accumulate. + """ + ids: set[int] = set() + for s in sets: + ids |= set(s._queryset.values_list('pk', flat=True)) + return cls(ReactionModel.objects.filter(pk__in=ids), sort=False) + def add(self, r: ReactionModel) -> None: """Add a :class:`.ReactionModel` to this set diff --git a/hippo/xchem_hippo/sqlite_migration_settings.py b/hippo/xchem_hippo/sqlite_migration_settings.py new file mode 100644 index 0000000..057cbd0 --- /dev/null +++ b/hippo/xchem_hippo/sqlite_migration_settings.py @@ -0,0 +1,31 @@ +# minimal settings to allow creating a migration for sqlite3 database, +# in case user opts to use a local sqlite database + + +# run like: +# cd hippo +# DJANGO_SETTINGS_MODULE=xchem_hippo.sqlite_migration_settings \ +# python -m django makemigrations designdb + +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +INSTALLED_APPS = [ + 'designdb.apps.DesigndbConfig', +] + +# needs to define a database to run migrations +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + # make sure it's out of source tree + 'NAME': BASE_DIR.parent / 'dev.sqlite3', + } +} + +SECRET_KEY = 'migration-only' +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + +# Important so Django actually creates tables +MANAGE_MODELS = True diff --git a/pyproject.toml b/pyproject.toml index 587439a..4499ea9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,16 @@ exclude = [ "hippo_legacy", ] +[tool.pytest.ini_options] +# hippo is not an installed package; it's imported from the repo root (the +# scripts rely on cwd being on sys.path). Put the repo root on sys.path so +# `import hippo` works under pytest. `tests/` is added automatically by the +# tests/conftest.py, which is what makes `from config import *` resolve. +pythonpath = ["."] +markers = [ + "sqlite: container-free tests that run in SQLite mode (no Postgres/Fragalysis). Run with `pytest -m sqlite`.", +] + [tool.uv.sources] django-rdkit = { git = "https://github.com/rdkit/django-rdkit" } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9c98a44 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,69 @@ +"""Shared pytest fixtures for the SQLite (container-free) test tier. + +These fixtures run hippo in SQLite mode (``load_hippo(..., db="...sqlite")``), +which configures Django with ``manage_models=True``, builds the schema +programmatically, and uses the plain-text ``RDKitMolField`` shim -- so no +Postgres DesignDB container, RDKit cartridge, or Fragalysis access is needed. + +Notes / constraints: +- Django can only be configured once per process, so ``animal`` is + session-scoped: the DB schema is created once and shared across tests. + Tests should therefore create their own uniquely-identified objects rather + than assuming an empty database. +- Import ``designdb.*`` lazily (inside fixtures/tests), never at module top: + ``designdb.models`` reads ``settings.MANAGE_MODELS`` at import time, which is + only defined after ``animal`` has configured Django. +""" + +import pytest + + +def pytest_collection_modifyitems(config, items): + """Skip the pre-refactor tests until they're migrated to the SQLite tier. + + Any test not marked ``sqlite`` is skipped (not failed), so a plain + ``pytest`` run stays green. Migrating a test -- point it at the fixtures in + this conftest and add ``pytestmark = pytest.mark.sqlite`` -- un-skips it + automatically. ``make test`` uses ``-m sqlite`` and never collects these. + """ + skip_legacy = pytest.mark.skip( + reason="pre-refactor test, pending migration to the SQLite tier" + ) + for item in items: + if "sqlite" not in item.keywords: + item.add_marker(skip_legacy) + + +@pytest.fixture(scope="session") +def animal(tmp_path_factory): + """A :class:`HIPPO` animal backed by a fresh throwaway SQLite database. + + Session-scoped (Django is configured once per process). Downloads are off: + ``DOWNLOAD_APO_DESOLV_ON_INIT`` defaults to False, so init does no network. + """ + import hippo + + db_path = tmp_path_factory.mktemp("hippo_db") / "test.sqlite" + + return hippo.HIPPO( + target_name="test", + target_access_string="test-proposal", + username="test-user", + db=str(db_path), + ) + + +@pytest.fixture +def make_compound(animal): + """Factory: register a compound from SMILES and return the ``CompoundModel``. + + Exercises the real ingestion entrypoint (``CompoundService.create``), which + in SQLite mode populates ``compound_mol``/``compound_inchikey`` in Python. + """ + from designdb.services.compound import CompoundService + + def _make(smiles: str): + compound, _ = CompoundService.create(smiles=smiles) + return compound + + return _make diff --git a/tests/test_compound.py b/tests/test_compound.py index fe1194a..3e7afbf 100644 --- a/tests/test_compound.py +++ b/tests/test_compound.py @@ -1,60 +1,81 @@ -from config import * +"""Compound component properties, modernized onto the SQLite ``animal`` fixture. +Replaces the pre-refactor version (positional ``hippo.HIPPO('test', DB)``, +``animal.C1``, legacy ``.db`` property, ``animal.db.close()``). The compound is +registered through the real ingestion entrypoint (``CompoundService.create``, +via the ``make_compound`` fixture) and wrapped in the new ``Compound`` component. + +The fixture compound has no poses/reactions/scaffolds, so properties that need +related data (e.g. ``best_placed_pose``) are covered by data-backed tests +elsewhere, not here. +""" + +import pytest + +pytestmark = pytest.mark.sqlite + +# Must be populated for a freshly-registered compound (no related data). +# 0 / False / empty-collection all count as "not None". NOT_NULL_PROPERTIES = [ - 'id', - 'inchikey', - 'name', - 'smiles', - 'mol', - 'num_heavy_atoms', - 'molecular_weight', - 'num_rings', - 'formula', - 'atomtype_dict', - 'metadata', - 'db', - 'tags', - 'poses', - 'best_placed_pose', - 'num_poses', - 'num_reactions', - 'num_reactant', - 'num_scaffolds', - 'dict', - 'is_scaffold', - 'is_elab', - 'is_product', - 'table', + "id", + "inchikey", + "name", + "smiles", + "mol", + "num_heavy_atoms", + "molecular_weight", + "num_rings", + "formula", + "atomtype_dict", + "tags", + "poses", + "reactions", + "num_poses", + "num_reactions", + "num_reactant", + "num_scaffolds", + "is_scaffold", + "is_elab", + "is_product", + "table", + "dict", ] -PROPERTIES = [ - 'alias', - 'elabs', - 'reaction', - 'reactions', - 'scaffolds', - 'num_atoms_added', +# Legitimately None / empty without related data -- just check access works. +NULLABLE_PROPERTIES = [ + "alias", + "metadata", + "elabs", + "reaction", + "scaffolds", + "num_atoms_added", ] -def test_properties(): +@pytest.fixture +def compound(make_compound): + """A registered ``Compound`` component (phenol).""" + from designdb.components.compound import Compound - import hippo + return Compound(make_compound("c1ccccc1O")) - animal = hippo.HIPPO('test', DB) - compound = animal.C1 +def test_not_null_properties(compound): for prop in NOT_NULL_PROPERTIES: - value = getattr(compound, prop) - print(prop, value) - assert value is not None, f'{prop} is None' + assert getattr(compound, prop) is not None, f"{prop} is None" - for prop in PROPERTIES: - value = getattr(compound, prop) - print(prop, value) - animal.db.close() +def test_nullable_properties_do_not_raise(compound): + for prop in NULLABLE_PROPERTIES: + getattr(compound, prop) # accessing must not raise -if __name__ == '__main__': - test_properties() +def test_core_values(compound): + """Spot-check the computed chemistry for phenol (C6H5OH).""" + assert compound.smiles == "c1ccccc1O" + assert compound.inchikey == "ISWSIDIOOBJBQZ-UHFFFAOYSA-N" + assert compound.name == compound.inchikey # no alias -> falls back to inchikey + assert compound.num_heavy_atoms == 7 # 6 C + 1 O + assert compound.num_rings == 1 + assert compound.num_poses == 0 + assert compound.is_scaffold is False diff --git a/tests/test_compoundset.py b/tests/test_compoundset.py new file mode 100644 index 0000000..28e85a0 --- /dev/null +++ b/tests/test_compoundset.py @@ -0,0 +1,72 @@ +"""CompoundSet indexing tests (SQLite tier). + +Regression for the same two ``__getitem__`` bugs fixed in PoseSet: +- a slice was passed straight to ``filter(pk__in=key)`` (errored); +- integer indexing didn't support negative indices (e.g. ``cset[-1]``). +""" + +import pytest + +pytestmark = pytest.mark.sqlite + + +@pytest.fixture +def compoundset(make_compound): + """A CompoundSet of 10 distinct compounds.""" + from designdb.models import CompoundModel + from designdb.sets.compound import CompoundSet + + smiles = ["C", "CC", "CCC", "CCCC", "CCCCC", "c1ccccc1", "CCO", "CCN", "CCCl", "CCBr"] + ids = [make_compound(s).pk for s in smiles] + return CompoundSet(CompoundModel.objects.filter(pk__in=ids)) + + +def test_compoundset_slice_returns_positional_subset(compoundset): + from designdb.sets.compound import CompoundSet + + sliced = compoundset[1:5] + + assert isinstance(sliced, CompoundSet) + assert len(sliced) == 4 + assert list(sliced.ids) == list(compoundset.ids)[1:5] + + +def test_compoundset_slice_full_and_out_of_range(compoundset): + assert len(compoundset[:]) == len(compoundset) + assert len(compoundset[100:200]) == 0 + + +def test_compoundset_slice_after_evaluation(compoundset): + """Slicing must work even after the underlying queryset was evaluated. + + An evaluated queryset returns a *list of model instances* when sliced (from + its result cache), which previously broke CompoundSet construction. + """ + from designdb.sets.compound import CompoundSet + + list(compoundset) # force-evaluate the underlying queryset (real-world usage) + + sliced = compoundset[1:5] + + assert isinstance(sliced, CompoundSet) + assert len(sliced) == 4 + assert list(sliced.ids) == list(compoundset.ids)[1:5] + + +def test_compoundset_int_indexing_is_positional(compoundset): + """cset[i] selects the i-th member by position, incl. negative indices.""" + from designdb.models import CompoundModel + + ids = list(compoundset.ids) + + first = compoundset[0] + assert isinstance(first, CompoundModel) + assert first.pk == ids[0] + + assert compoundset[1].pk == ids[1] + assert compoundset[-1].pk == ids[-1] # negative indexing -> last compound + + +def test_compoundset_int_index_out_of_range_raises(compoundset): + with pytest.raises(IndexError): + compoundset[len(compoundset)] diff --git a/tests/test_poseset.py b/tests/test_poseset.py new file mode 100644 index 0000000..e610989 --- /dev/null +++ b/tests/test_poseset.py @@ -0,0 +1,80 @@ +"""PoseSet tests (SQLite tier). + +Regression: ``PoseSet.__getitem__`` for a slice passed the ``slice`` object +straight to ``filter(pk__in=key)`` instead of positionally slicing the members +(e.g. ``poseset[1:10]`` errored). +""" + +import pytest + +pytestmark = pytest.mark.sqlite + + +@pytest.fixture +def poseset(animal, make_compound): + """A PoseSet of 20 minimal poses. + + Uses a compound dedicated to this test so it doesn't perturb the pose counts + of compounds used by other tests sharing the session database. + """ + from designdb.models import PoseModel + from designdb.sets.pose import PoseSet + + compound = make_compound("c1ccc(F)cc1") # fluorobenzene + target = animal.target + + poses = [ + PoseModel.objects.create( + compound=compound, target=target, pose_alias=f"slice-{i}" + ) + for i in range(20) + ] + return PoseSet(PoseModel.objects.filter(pk__in=[p.pk for p in poses])) + + +def test_poseset_slice_returns_positional_subset(poseset): + from designdb.sets.pose import PoseSet + + sliced = poseset[1:10] + + assert isinstance(sliced, PoseSet) + assert len(sliced) == 9 + # a slice selects members by position, not by pk + assert list(sliced.ids) == list(poseset.ids)[1:10] + + +def test_poseset_slice_full_and_out_of_range(poseset): + assert len(poseset[:]) == 20 + assert len(poseset[100:200]) == 0 + + +def test_poseset_slice_after_evaluation(poseset): + """Slicing must work even after the underlying queryset was evaluated.""" + from designdb.sets.pose import PoseSet + + list(poseset) # force-evaluate the underlying queryset + + sliced = poseset[1:10] + + assert isinstance(sliced, PoseSet) + assert len(sliced) == 9 + assert list(sliced.ids) == list(poseset.ids)[1:10] + + +def test_poseset_int_indexing_is_positional(poseset): + """pset[i] selects the i-th member by position (not by pk).""" + from designdb.components.pose import Pose + + ids = list(poseset.ids) + + first = poseset[0] + assert isinstance(first, Pose) + assert first.id == ids[0] + + assert poseset[1].id == ids[1] # second pose, by position + assert poseset[-1].id == ids[-1] # negative indexing -> last pose + + +def test_poseset_int_index_out_of_range_raises(poseset): + with pytest.raises(IndexError): + poseset[len(poseset)] diff --git a/tests/test_reactionset.py b/tests/test_reactionset.py new file mode 100644 index 0000000..8c48672 --- /dev/null +++ b/tests/test_reactionset.py @@ -0,0 +1,54 @@ +"""ReactionSet indexing tests (SQLite tier). + +Regression for the slice bug shared with Pose/CompoundSet: ``__getitem__`` for a +slice passed the ``slice`` object straight to ``filter(pk__in=key)`` (errored) +instead of positionally slicing the members. +""" + +import pytest + +pytestmark = pytest.mark.sqlite + + +@pytest.fixture +def reactionset(make_compound): + """A ReactionSet of 10 reactions (one per distinct product compound).""" + from designdb.models import ReactionModel + from designdb.sets.reaction import ReactionSet + + smiles = ["C", "CC", "CCC", "CCCC", "CCCCC", "c1ccccc1", "CCO", "CCN", "CCCl", "CCBr"] + reactions = [ + ReactionModel.objects.get_or_create( + product_compound=make_compound(s), reaction_type="test" + )[0] + for s in smiles + ] + return ReactionSet(ReactionModel.objects.filter(pk__in=[r.pk for r in reactions])) + + +def test_reactionset_slice_returns_positional_subset(reactionset): + from designdb.sets.reaction import ReactionSet + + sliced = reactionset[1:5] + + assert isinstance(sliced, ReactionSet) + assert len(sliced) == 4 + assert list(sliced.ids) == list(reactionset.ids)[1:5] + + +def test_reactionset_slice_full_and_out_of_range(reactionset): + assert len(reactionset[:]) == len(reactionset) + assert len(reactionset[100:200]) == 0 + + +def test_reactionset_slice_after_evaluation(reactionset): + """Slicing must work even after the underlying queryset was evaluated.""" + from designdb.sets.reaction import ReactionSet + + list(reactionset) # force-evaluate the underlying queryset + + sliced = reactionset[1:5] + + assert isinstance(sliced, ReactionSet) + assert len(sliced) == 4 + assert list(sliced.ids) == list(reactionset.ids)[1:5] diff --git a/tests/test_sqlite_smoke.py b/tests/test_sqlite_smoke.py new file mode 100644 index 0000000..53c267b --- /dev/null +++ b/tests/test_sqlite_smoke.py @@ -0,0 +1,43 @@ +"""Smoke tests for the SQLite (container-free) test tier and its conftest fixtures. + +Validates that hippo boots in SQLite mode and that compound registration works +offline -- i.e. that ``CompoundService.create`` populates the fields that the +Postgres cartridge triggers would otherwise fill. +""" + +import pytest + +pytestmark = pytest.mark.sqlite + + +def test_animal_boots_in_sqlite(animal): + """The animal is created and has the expected target.""" + assert animal.target.target_name == "test" + + +def test_compounds_property_is_a_compound_set(animal): + """animal.compounds returns a CompoundSet (empty or not).""" + from designdb.sets.compound import CompoundSet + + assert isinstance(animal.compounds, CompoundSet) + + +def test_create_compound_populates_cartridge_fields(make_compound): + """CompoundService.create fills mol/inchikey/hash in Python (no PG trigger).""" + compound = make_compound("c1ccccc1O") # phenol + + assert compound.pk is not None + assert compound.compound_smiles == "c1ccccc1O" + # populated in Python for the SQLite path + assert compound.compound_inchikey == "ISWSIDIOOBJBQZ-UHFFFAOYSA-N" + assert compound.compound_mol # non-empty CTAB + assert "RDKit" in compound.compound_mol + assert compound.compound_hash # tautomer-insensitive registration hash + + +def test_create_compound_is_idempotent(make_compound): + """Re-registering the same SMILES returns the same compound (dedup by hash).""" + a = make_compound("CCO") + b = make_compound("CCO") + + assert a.pk == b.pk diff --git a/tests/test_target.py b/tests/test_target.py index fef8d0b..927214e 100644 --- a/tests/test_target.py +++ b/tests/test_target.py @@ -1,34 +1,30 @@ -from config import * +"""Target properties, modernized onto the SQLite ``animal`` conftest fixture. -NOT_NULL_PROPERTIES = [ - 'id', - 'name', - 'feature_ids', - 'features', - 'subsites', -] +Replaces the pre-refactor version (positional ``hippo.HIPPO('test', DB)``, +``animal.T1``, ``animal.db.close()``), which targeted the removed legacy API. +``animal.target`` is now a plain :class:`TargetModel`. +""" -PROPERTIES = [] +import pytest +pytestmark = pytest.mark.sqlite -def test_properties(): - import hippo +def test_target_identity(animal): + """animal.target is the configured TargetModel, linked to its project.""" + target = animal.target - animal = hippo.HIPPO('test', DB) - target = animal.T1 + assert target.pk is not None + assert target.target_name == "test" + # the project is created from the target_access_string (see conftest) + assert target.project.project_name == "test-proposal" - for prop in NOT_NULL_PROPERTIES: - value = getattr(target, prop) - print(prop, value) - assert value is not None, f'{prop} is None' - for prop in PROPERTIES: - value = getattr(target, prop) - print(prop, value) +def test_target_has_no_features_or_subsites_when_empty(animal): + """A freshly-created target has no features/subsites until hits are loaded.""" + from designdb.models import FeatureModel, SubsiteModel - animal.db.close() + target = animal.target - -if __name__ == '__main__': - test_properties() + assert FeatureModel.objects.filter(target=target).count() == 0 + assert SubsiteModel.objects.filter(target=target).count() == 0