From 0db5075a7fabe11353c86839fe9311aab702e78a Mon Sep 17 00:00:00 2001 From: jgoldste <0uTfr1p@n> Date: Mon, 17 Aug 2020 14:17:56 -0600 Subject: [PATCH] SSA-6324: schema init adds convenience methods suggested by Thomas; Project how has authors cascade; deploy requires Python 3.8; a project updater test now requires pytest-mock; updater and datafetcher tests complete, and most exercise CLI --- .../utilities/s_code_project_updater/setup.py | 1 + .../src/s_code_project_updater/commands.py | 75 ++++- .../s_code_project_updater/project_fetcher.py | 4 +- .../test/test_updater.py | 281 +++++++++++++++--- apps/cli/utilities/test_data/__init__.py | 0 deploy.sh | 2 +- shared/schema/src/schema/__init__.py | 22 ++ shared/schema/src/schema/model.py | 3 +- 8 files changed, 334 insertions(+), 54 deletions(-) mode change 100644 => 100755 apps/cli/utilities/s_code_project_updater/test/test_updater.py delete mode 100644 apps/cli/utilities/test_data/__init__.py diff --git a/apps/cli/utilities/s_code_project_updater/setup.py b/apps/cli/utilities/s_code_project_updater/setup.py index fc1c96408..242796185 100644 --- a/apps/cli/utilities/s_code_project_updater/setup.py +++ b/apps/cli/utilities/s_code_project_updater/setup.py @@ -17,6 +17,7 @@ setup( url='TBD', license="GPL", install_requires=['pycapo', 'pymygdala', 'schema', 'sqlalchemy', 'support'], + tests_require=['pytest-mock'], requires=['sqlalchemy', 'mysqldb'], keywords=[], packages=['s_code_project_updater'], diff --git a/apps/cli/utilities/s_code_project_updater/src/s_code_project_updater/commands.py b/apps/cli/utilities/s_code_project_updater/src/s_code_project_updater/commands.py index e726f4031..901eaad40 100644 --- a/apps/cli/utilities/s_code_project_updater/src/s_code_project_updater/commands.py +++ b/apps/cli/utilities/s_code_project_updater/src/s_code_project_updater/commands.py @@ -47,7 +47,26 @@ class ScodeProjectUpdater: :param kwargs: the command line arguments or namespace with the arguments to the parser """ self._make_parser() - self.args = self.parser.parse_args(**kwargs) + try: + _LOG.warning('parsing....') + self.args = self.parser.parse_args(**kwargs) + _LOG.warning('parsed') + + except Exception as exc: + _LOG.error(f'parser threw {exc}') + self.exit_with_error('Capo profile and project code are ' + 'required', 2) + + # at a minimum, Capo profile and project code are required + if not self.args.profile or not self.args.project: + if not self.args.profile and not self.args.project: + self.exit_with_error('Capo profile and project code are ' + 'required', 2) + if not self.args.profile: + self.exit_with_error('Capo profile not specified', 2) + if not self.args.project: + self.exit_with_error('project code not specified', 2) + args_dict = self.args.__dict__ if args_dict['dry']: @@ -57,13 +76,16 @@ class ScodeProjectUpdater: if not args_dict['investigators'] and not args_dict['title'] and not args_dict['abstract']: self.set_minimum_properties_from_args(args_dict) - return + # return self.project_code = args_dict['project'] self.stored_project = None _LOG.debug(f'{self.args}') - self.capo_config = get_my_capo_config(profile=self.args.profile) + try: + self.capo_config = get_my_capo_config(profile=self.args.profile) + except Exception as exc: + self.exit_with_error(f'Capo configuration error: {exc}', 1) try: self.archive_context = ArchiveDBSession('SDM', profile=self.capo_config.profile) self.pst_context = ArchiveDBSession('PST', profile=self.capo_config.profile) @@ -243,7 +265,6 @@ class ScodeProjectUpdater: # we want the PI's pst_person_id followed by the CoIs' pst_person_ids in numeric order pi = investigator_list[0] if pi.pst_person_id is not None: - self.is_alma = False coi_pst_ids = [int(coi.pst_person_id) for coi in investigator_list[1:]] coi_pst_ids = sorted(coi_pst_ids) author_pst_ids = [int(pi.pst_person_id)] @@ -251,8 +272,6 @@ class ScodeProjectUpdater: authors_to_print = [str(id) for id in author_pst_ids] id_list = ' '.join(authors_to_print) output.append(f'Authors: {id_list}') - else: - self.is_alma = True return output @@ -269,7 +288,11 @@ class ScodeProjectUpdater: :return: ''' fetcher = ArchiveProjectFetcher(self.args.profile) - self.project = fetcher.fetch_project(self.project_code) + try: + self.project = fetcher.fetch_project(self.project_code) + except AttributeError: + self.exit_with_error(f'project code "{self.project_code}" not ' + f'found', 3) if self.is_fetch_only(): output = fetcher.build_project_info() try: @@ -296,8 +319,8 @@ class ScodeProjectUpdater: self.exit_with_error('No project found for the project_code provided', 3) if self.is_alma(): - raise ValueError(f'{self.stored_project.project_code} ' - f'is an ALMA project; update not permitted') + self.exit_with_error(f'{self.stored_project.project_code} ' + f'is an ALMA project; update not permitted', 2) if self.args.investigators: proposed_investigators = self.get_pst_users(self.args.investigators) @@ -445,6 +468,40 @@ class ArchiveProject: return new_project.investigators + +class UpdateArgParser: + ''' Command-line argument parser for ScodeProjectUpdater ''' + def __init__(self): + self.parser = self._make_parser() + + def _make_parser(self): + parser = ap.ArgumentParser(description=_DESCRIPTION.format(version), + formatter_class=ap.RawTextHelpFormatter, + epilog=_EPILOG) + parser.add_argument('-C', '--project', action='store', + help='project_code to update') + parser.add_argument('-P', '--profile', action='store', + help='profile name to use, e.g. test, production') + parser.add_argument('-T', '--title', action='store', + help='a quoted string for the new title for the project') + parser.add_argument('-A', '--abstract', action='store', + help='a quoted string for the new abstract for the project') + parser.add_argument('-I', '--investigators', action='store', type=int, nargs='+', + help='a PST ID, or list of PST IDs, of investigators for the project, ' + 'as an unquoted integer or space seperated integer list. The ' + 'first ID in the list will be added as the PI and all subsequenct ' + 'IDs will be added as CoIs.') + parser.add_argument('-d', '--dry', action='store_true', + help='perform a dry run, going through the motions, but not committing ' + 'changes and not performing a re-index of the project. This may ' + 'be useful because it will print the current state of the project ' + 'and what the project would look like after the changes.') + return parser + + def parse_args(self, **kwargs): + return self.parser.parse_args(kwargs) + + class UpdateException(Exception): ''' throw this if there is trouble during the update ''' pass diff --git a/apps/cli/utilities/s_code_project_updater/src/s_code_project_updater/project_fetcher.py b/apps/cli/utilities/s_code_project_updater/src/s_code_project_updater/project_fetcher.py index 1c321e67d..639c0c304 100644 --- a/apps/cli/utilities/s_code_project_updater/src/s_code_project_updater/project_fetcher.py +++ b/apps/cli/utilities/s_code_project_updater/src/s_code_project_updater/project_fetcher.py @@ -31,7 +31,7 @@ class ArchiveProjectFetcher: def __exit__(self, exc_type, exc_val, exc_tb): self.archive_context.close() - self.pst_context.close() + self.pst_context.session.close() def fetch_project(self, project_code: str): @@ -101,7 +101,7 @@ class ArchiveProjectFetcher: def _is_alma(self): ''' is this an alma project? ''' - with warnings.catch_warnings(), self.archive_context, self.pst_context: + with warnings.catch_warnings(): # Suppress SQLAlchemy warnings warnings.simplefilter("ignore", category=sa_exc.SAWarning) diff --git a/apps/cli/utilities/s_code_project_updater/test/test_updater.py b/apps/cli/utilities/s_code_project_updater/test/test_updater.py old mode 100644 new mode 100755 index e0137834b..aefa05cb6 --- a/apps/cli/utilities/s_code_project_updater/test/test_updater.py +++ b/apps/cli/utilities/s_code_project_updater/test/test_updater.py @@ -5,7 +5,7 @@ import unittest import warnings import pytest -from s_code_project_updater.commands import UpdateException +from s_code_project_updater.commands import UpdateException, ScodeProjectUpdater from schema import create_session from schema.model import Project from schema.pstmodel import Session @@ -19,7 +19,6 @@ _LOG = get_console_logger("scode_project_updater_tests", logging.DEBUG) _UPDATE_COMMAND = 'update_sproj' PROFILE = 'local' - class UpdaterTestCase(unittest.TestCase): @classmethod @@ -27,8 +26,13 @@ class UpdaterTestCase(unittest.TestCase): os.environ['CAPO_PROFILE'] = PROFILE cls.return_values = build_updater_return_values() - def setUp(self) -> None: - self.initialize_test_data() + @classmethod + def setUp(cls) -> None: + cls.initialize_test_data(cls) + + @classmethod + def tearDownClass(cls) -> None: + cls.remove_test_data(cls) def test_dry_run_does_not_update(self): fake_project = ScodeTestProject().project @@ -37,7 +41,9 @@ class UpdaterTestCase(unittest.TestCase): return_code = None try: new_title = 'this is the new title' - self.assertNotEqual(fake_project.title, new_title) + self.assertNotEqual(fake_project.title, new_title, + f'new title should be {new_title}; got ' + f'{fake_project.title}') args = [ '-C', project_code, '-P', PROFILE, @@ -53,10 +59,19 @@ class UpdaterTestCase(unittest.TestCase): if not return_code: updated = self.get_project_from_db(session, project_code) # nothing should have been updated - self.assertEqual(fake_project.title, updated.title) - self.assertEqual(fake_project.abstract, updated.abstract) + self.assertEqual(fake_project.title, updated.title, + f'expecting same title, but before is ' + f'{fake_project.title} and after is {updated.title}') + self.assertEqual(fake_project.abstract, updated.abstract, + f'expecting same abstract, but before is ' + f'{fake_project.abstract} and updated is {updated.abstract}') self.assertEqual(len(fake_project.authors), - len(updated.authors)) + len(updated.authors), + f'expecting same number of authors, ' + f'but before has {len(fake_project.authors)} ' + f'and after has {len(updated.authors)}') + else: + pytest.fail(f'unexpected failure; return code ={return_code}') except Exception as exc: pytest.fail(f'{project_code}: {exc}') @@ -77,17 +92,26 @@ class UpdaterTestCase(unittest.TestCase): session = create_session('SDM') try: updated = self.get_project_from_db(session, project_code) - self.assertEqual(fake_project.title, updated.title) - self.assertEqual(fake_project.abstract, updated.abstract) + self.assertEqual(fake_project.title, updated.title, + f'expecting same title, but before is ' + f'{fake_project.title} and after is {updated.title}') + self.assertEqual(fake_project.abstract, updated.abstract, + f'expecting same abstract, but before is ' + f'{fake_project.abstract} and updated is {updated.abstract}') self.assertEqual(len(fake_project.authors), - len(updated.authors)) + len(updated.authors), + f'expecting same number of authors, ' + f'but before has {len(fake_project.authors)} ' + f'and after has {len(updated.authors)}') count = 0 for orig_author in fake_project.authors: for author in updated.authors: if author.username == orig_author.username: count += 1 break - self.assertEqual(len(fake_project.authors), count) + self.assertEqual(len(fake_project.authors), count, + f'before and after projects should have ' + f'same authors') finally: session.close() except Exception as exc: @@ -99,7 +123,8 @@ class UpdaterTestCase(unittest.TestCase): project_code = fake_project.project_code session = create_session('SDM') new_abstract = "Well, here's another nice mess you've gotten us into, Ollie" - self.assertNotEqual(fake_project.abstract, new_abstract) + self.assertNotEqual(fake_project.abstract, new_abstract, + f'expecting new abstract {new_abstract} but got {fake_project.abstract}') args = [ '-C', project_code, '-P', PROFILE, @@ -119,8 +144,12 @@ class UpdaterTestCase(unittest.TestCase): updated = self.get_project_from_db(session, project_code) # only abstract should have been updated; # all else should be same - self.assertEqual(fake_project.title, updated.title) - self.assertEqual(new_abstract, updated.abstract) + self.assertEqual(fake_project.title, updated.title, + f'expecting same title, but before is ' + f'{fake_project.title} and after is {updated.title}') + self.assertEqual(new_abstract, updated.abstract, + f'expecting same abstract, but before is ' + f'{fake_project.abstract} and updated is {updated.abstract}') self.assertEqual(len(fake_project.authors), len(updated.authors)) else: @@ -137,8 +166,12 @@ class UpdaterTestCase(unittest.TestCase): session = create_session('SDM') new_abstract = "I think you ought to know I'm feeling very depressed" new_title = 'A Survey of the Mattresses of Sqornshellous Zeta' - self.assertNotEqual(fake_project.abstract, new_abstract) - self.assertNotEqual(fake_project.title, new_title) + self.assertNotEqual(fake_project.abstract, new_abstract, + f'expecting new abstract {new_abstract}, ' + f'but abstract was not changed from {fake_project.abstract}') + self.assertNotEqual(fake_project.title, new_title, + f'expecting new title {new_title}, ' + f'but abstract was not changed from {fake_project.title}') args = [ '-C', project_code, '-P', PROFILE, @@ -159,10 +192,13 @@ class UpdaterTestCase(unittest.TestCase): updated = self.get_project_from_db(session, project_code) # abstract and title should have been updated; # all else should be same - self.assertEqual(new_title, updated.title) - self.assertEqual(new_abstract, updated.abstract) + self.assertEqual(new_title, updated.title, + 'title should not have changed') + self.assertEqual(new_abstract, updated.abstract, + 'abstract should not have changed') self.assertEqual(len(fake_project.authors), - len(updated.authors)) + len(updated.authors), + 'authors should not have changed') else: raise UpdateException() @@ -173,15 +209,22 @@ class UpdaterTestCase(unittest.TestCase): def test_adds_new_abstract_deletes_author(self): fake_project = ScodeTestProject().project - new_project = fake_project project_code = fake_project.project_code + new_project = Project(project_code=project_code, + title=fake_project.title, + abstract=fake_project.abstract) new_abstract = "First there is a mountain, then there is no " \ "mountain, then there is" self.assertNotEqual(new_abstract, fake_project.abstract) new_project.abstract = new_abstract original_authors = fake_project.authors.copy() + self.assertEqual(4, len(original_authors), + 'expected 4 authors before update') + last_author = original_authors[3] new_authors = original_authors[:3] - self.assertEqual(len(original_authors) - 1, len(new_authors)) + self.assertEqual(len(original_authors) - 1, len(new_authors), + f'expecting {len(original_authors) - 1} new authors, ' + f'but there are {len(new_authors)}') new_project.authors = new_authors args = [ '-C', project_code, @@ -203,23 +246,31 @@ class UpdaterTestCase(unittest.TestCase): text = self.return_values[return_code] if return_code else '' pytest.fail(f'{exc} {text}') - if not return_code: - updated = self.get_project_from_db(session, project_code) - # last author should have been removed and the abstract changed; - # title should remain same - self.assertEqual(new_abstract, updated.abstract) - self.assertEqual(fake_project.title, updated.title) - self.assertEqual(len(new_authors), len(updated.authors)) - self.assertFalse(original_authors[3] in updated.authors) - count = 0 - for orig_author in original_authors[:3]: - for new_author in updated.authors: - if new_author.username == orig_author.username: - count += 1 - break - self.assertEqual(len(new_authors), count) - else: - raise UpdateException() + self.assertEqual(0, return_code, f'command should have succeeded ' + f'but return code was {return_code}') + + updated = self.get_project_from_db(session, project_code) + # last author should have been removed and the abstract changed; + # title should remain same + self.assertNotEqual(fake_project.abstract, updated.abstract, + 'abstract should have changed') + self.assertEqual(fake_project.title, updated.title, + 'title should not have changed') + expected = len(original_authors) - 1 + actual = len(updated.authors) + self.assertEqual(expected, actual, + 'one author should have been removed') + authors_updated = last_author in updated.authors + self.assertFalse(authors_updated, 'THIS IS THE MESSAGE') + count = 0 + for orig_author in original_authors[:3]: + for new_author in updated.authors: + if new_author.username == orig_author.username: + count += 1 + break + self.assertEqual(len(new_authors), count, + f'expected {len(new_authors)} authors in ' + f'updated project; there were {count}') except Exception as exc: pytest.fail(f'{project_code}: {exc}') @@ -227,11 +278,115 @@ class UpdaterTestCase(unittest.TestCase): finally: session.close() + def test_output_is_as_expected(self): + fake_project = ScodeTestProject().project + project_code = fake_project.project_code + args = [ + '-C', project_code, + '-P', PROFILE, + ] + + runner = CommandLineUpdaterLauncher(args) + return_code = runner.run() + if return_code: + text = self.return_values[return_code] + pytest.fail(text) + + stdout = runner.stdout + self.assertIsNotNone(stdout, 'program output is expected') + self.assertTrue('Title: ' + fake_project.title in stdout, + 'title should be in output') + self.assertTrue('Abstract: ' + fake_project.abstract in stdout, + 'abstract should be in output') + pst_ids = [str(id) for id in get_author_pst_ids(fake_project)] + pst_id_str = ' '.join(pst_ids) + self.assertTrue('Authors: ' + pst_id_str in stdout, + f'output should have PST IDs {pst_ids}') + + def test_copes_with_single_pi(self): + project = ScodeTestProject().project + args = ['-P', PROFILE, '-C', project.project_code, '-I', '4686'] + return_code = CommandLineUpdaterLauncher(args=args).run() + self.assertEqual(0, return_code, + 'update to single author should succeed') + + def test_alma_project_is_rejected(self): + project_code = '2018.A.00062.S' + args = ['-P', PROFILE, '-C', project_code, + '-T', 'Physics at High Angular Resolution in Nearby Galaxies: ' + 'The Local Galaxy Inventory Continued'] + + with pytest.raises(SystemExit) as exc: + ScodeProjectUpdater(args=args).update_project() + self.assertEqual(2, exc.code, 'ALMA project should be rejected') + + def test_errors_return_expected_codes(self): + # minimum required arguments -- profile & project -- omitted + return_code = CommandLineUpdaterLauncher([]).run() + self.assertEqual(return_code, 2, + 'expected return code 2 for no args') + + project_code = ScodeTestProject().project.project_code + + # update failure + result = FailingUpdater().update_project() + self.assertIsInstance(result, SystemExit) + self.assertEqual(5, result.code, + 'expecting return code 5 for update failure') + + # profile not specified + args = ['-C', project_code,] + return_code = CommandLineUpdaterLauncher(args).run() + self.assertEqual(return_code, 2, + 'expecting return code 2 when profile not specified') + + # project code not specified + args = ['-P', PROFILE] + self.assertEqual(CommandLineUpdaterLauncher(args).run(), 2, + 'expecting return code 2 when project not specified') + + # profile value missing + args = ['-P', '-C', project_code] + return_code = CommandLineUpdaterLauncher(args).run() + self.assertEqual(return_code, 2, + 'expecting return code 2 for missing profile') + + # project code missing + args = ['-P', PROFILE, '-C'] + self.assertEqual(CommandLineUpdaterLauncher(args).run(), 2, + 'expecting return code 2 for missing project code') + + # bad project code + args = ['-P', PROFILE, '-C', 'bogus'] + self.assertEqual(CommandLineUpdaterLauncher(args).run(), 3, + 'expecting return code 3 for invalid project code') + + # bad profile + args = ['-P', 'not_a_profile', '-C', project_code] + self.assertEqual(CommandLineUpdaterLauncher(args).run(), 1, + 'expecting return code 1 for invalid Capo profile') + + # missing title as last argument + args = ['-P', PROFILE, '-C', project_code, '-T'] + self.assertEqual(CommandLineUpdaterLauncher(args).run(), 2, + 'expecting return code 2 for missing title') + + # missing title as first argument + args = [ '-T', '-P', PROFILE, '-C', project_code,] + self.assertEqual(CommandLineUpdaterLauncher(args).run(), 2, + 'expecting return code 2 for missing title') + + # nonexistent investigator + args = ['-P', PROFILE, '-C', project_code, '-I', '-22'] + self.assertEqual(CommandLineUpdaterLauncher(args).run(), 4, + 'expecting return code 4 for invalid investigator') + ### UTILITIES ### def initialize_test_data(self): session = create_session('SDM') + num_commits = num_found = 0 try: with warnings.catch_warnings(): # Suppress SQLAlchemy warnings @@ -240,7 +395,6 @@ class UpdaterTestCase(unittest.TestCase): fake_projects = [ScodeTestProject().project, ScienceTestProject().project, AlmaTestProject().project] - num_commits = 0 try: for fake_project in fake_projects: project_code = fake_project.project_code @@ -249,18 +403,46 @@ class UpdaterTestCase(unittest.TestCase): project_code) \ .first() if existing is not None: + num_found += 1 session.delete(existing) session.commit() session.add(fake_project) session.commit() num_commits += 1 - self.assertEqual(len(fake_projects), num_commits) + if num_commits < num_found: + pytest.fail(f'{num_found} fake projects were found ' + f'and deleted, but {num_commits} were ' + f'added and committed') except Exception as exc: pytest.fail(f'{exc}') finally: session.close() + def remove_test_data(self): + session = create_session('SDM') + try: + with warnings.catch_warnings(): + # Suppress SQLAlchemy warnings + warnings.simplefilter("ignore", category=sa_exc.SAWarning) + + fake_projects = [ScodeTestProject().project, + ScienceTestProject().project, + AlmaTestProject().project] + for fake_project in fake_projects: + project_code = fake_project.project_code + existing = session.query(Project) \ + .filter(Project.project_code == + project_code) \ + .first() + if existing is not None: + session.delete(existing) + session.commit() + except Exception as exc: + pytest.fail(f'{exc}') + finally: + session.close() + def get_project_from_db(self, session: Session, project_code: str): with warnings.catch_warnings(): # Suppress SQLAlchemy warnings @@ -270,6 +452,22 @@ class UpdaterTestCase(unittest.TestCase): .filter(Project.project_code == project_code) \ .first() +class FailingUpdaterHelper: + # def __init__(self, **kwargs): + # pass + + @pytest.fixture() + def update_project(self): + return SystemExit(5) + +class FailingUpdater: + def __init__(self): + self.helper = FailingUpdaterHelper() + + def update_project(self): + return SystemExit(5) + + class CommandLineUpdaterLauncher: def __init__(self, args: list): @@ -292,6 +490,7 @@ class CommandLineUpdaterLauncher: check=False, bufsize=1, universal_newlines=True) + self.stdout = proc.stdout return proc.returncode except Exception as exc: _LOG.error(f'{exc}') diff --git a/apps/cli/utilities/test_data/__init__.py b/apps/cli/utilities/test_data/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/deploy.sh b/deploy.sh index 9d2d00437..7540346c3 100755 --- a/deploy.sh +++ b/deploy.sh @@ -32,7 +32,7 @@ if [ "$VIRTUAL_ENV" = "" ]; then # Make sure we have an appropriate venv available here: if [ ! -d venv ]; then echo "No virtual environment detected, creating a basic one." - python3.6 -m venv venv + python3.8 -m venv venv source ./venv/bin/activate pip install --upgrade pip pip install -r deployment/requirements.txt diff --git a/shared/schema/src/schema/__init__.py b/shared/schema/src/schema/__init__.py index 18dcffe8e..407635643 100644 --- a/shared/schema/src/schema/__init__.py +++ b/shared/schema/src/schema/__init__.py @@ -1,5 +1,7 @@ # publish our behavior-enhanced table classes import sqlalchemy +from sqlalchemy.sql import ClauseElement + from .model import * from sqlalchemy.orm import sessionmaker from pycapo import CapoConfig @@ -47,6 +49,26 @@ def create_session(instrument, **kwargs): session = session_mkr() return session +def create_model_instance(session, model, defaults=None, commit=True, **kwargs): + params = dict((k, v) for k, v in kwargs.items() if not isinstance(v, ClauseElement)) + params.update(defaults or {}) + instance = model(**params) + session.add(instance) + if commit: + session.commit() + # Need to read it back to get the new PK, etc. + session.refresh(instance) + return instance + +def get_or_create_model_instance(session, model, defaults=None, commit=True, + **kwargs): + instance = session.query(model).filter_by(**kwargs).first() + if instance: + return instance, False + else: + instance = create_model_instance(session, model, defaults=defaults, commit=commit, **kwargs) + return instance, True + class ArchiveDBSession: """A class to create context manager around an archive connection.""" diff --git a/shared/schema/src/schema/model.py b/shared/schema/src/schema/model.py index dfa18bb13..bc46a8f58 100644 --- a/shared/schema/src/schema/model.py +++ b/shared/schema/src/schema/model.py @@ -693,7 +693,8 @@ class Project(Base): proprietary_duration = Column(Float(53)) science_products = relationship('ScienceProduct', secondary='science_products_projects') - authors = relationship('Author') + authors = relationship('Author', cascade="all,delete, delete-orphan", + backref='parent') execution_blocks = relationship('ExecutionBlock') file_groups = relationship('Filegroup') alma_ouses = relationship('AlmaOus', backref='projects') -- GitLab