diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 49f9ced3..a5f3d7ce 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -143,6 +143,11 @@ echo "4.8 Running 'python manage.py recompute_url_names' to de-collide historica echo "******************************************" python manage.py recompute_url_names +echo "****************** STEP 4.9/5: docker-entrypoint.sh ************************" +echo "4.9 Running 'python manage.py propagate_publication_projects' to link talks/videos/posters to their publication's projects (#649)" +echo "******************************************" +python manage.py propagate_publication_projects + # echo "****************** STEP 4.3/5: docker-entrypoint.sh ************************" # echo "4.3 Running 'python manage.py rename_person_images' to rename person images" # echo "******************************************" diff --git a/docs/plans/issue-649-link-artifacts-to-projects.md b/docs/plans/issue-649-link-artifacts-to-projects.md index 0d742854..e78baf23 100644 --- a/docs/plans/issue-649-link-artifacts-to-projects.md +++ b/docs/plans/issue-649-link-artifacts-to-projects.md @@ -14,9 +14,16 @@ propagation flagged, deep-links to each edit page, CSV export, regression-tested (`website/tests/test_unlinked_artifacts_check.py`). Issue #649 updated with the scope table + decision. -**Deferred:** the semi-automated suggestion/apply pipeline below. Not built — -held unless the manual route through the health check proves too slow. Kept here -for reference. +**Shipped (Tier-1 propagation):** `propagate_publication_projects` management +command (`website/management/commands/`) copies a publication's projects onto any +childless `talk`/`video`/`poster` — additive-only, idempotent — wired into +`docker-entrypoint.sh` so it self-heals on every container start. Clears the +"parent publication is linked — inherit its projects" rows automatically. Tested +in `website/tests/test_propagate_publication_projects.py`. + +**Deferred:** the Tier-2 semi-automated suggestion/apply pipeline below (for +artifacts with *no* parent publication to inherit from). Not built — held unless +the manual route through the health check proves too slow. Kept for reference. --- diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index 91576495..0cf0edbf 100644 --- a/makeabilitylab/settings.py +++ b/makeabilitylab/settings.py @@ -86,8 +86,8 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Makeability Lab Global Variables, including Makeability Lab version -ML_WEBSITE_VERSION = "2.17.2" # Keep this updated with each release and also change the short description below -ML_WEBSITE_VERSION_DESCRIPTION = "Fix the /version/ endpoint reporting git_sha and built_at as 'unknown' in deployed environments. The build-info capture in docker-entrypoint.sh needs git (it wasn't installed in the image) and trips git's 'dubious ownership' guard because /code/.git is apache:makelab-owned while the container runs as root. Installs git in the Dockerfile and runs rev-parse with -c safe.directory=/code. The Dockerfile change also forces an image rebuild on deploy, so the entrypoint actually re-runs and writes build-info.json (#1366)." +ML_WEBSITE_VERSION = "2.17.3" # Keep this updated with each release and also change the short description below +ML_WEBSITE_VERSION_DESCRIPTION = "Auto-link a publication's talk, video, and poster to the publication's projects (#649). A publication's child artifacts are the same scholarly work, so they belong to the same projects; the new propagate_publication_projects management command copies a publication's projects onto any of its children that currently have none. It is additive-only (never removes a link, never touches a child that already has projects) and idempotent, and runs on every container start via docker-entrypoint.sh so it self-heals. This clears the 'parent publication is linked — inherit its projects' rows in the Artifacts-not-linked data-health check, leaving only the artifacts that need a human decision." DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed MAX_BANNERS = 7 # Maximum number of banners on a page diff --git a/website/management/commands/propagate_publication_projects.py b/website/management/commands/propagate_publication_projects.py new file mode 100644 index 00000000..21a89880 --- /dev/null +++ b/website/management/commands/propagate_publication_projects.py @@ -0,0 +1,82 @@ +""" +Propagate a publication's projects onto its own talk / video / poster (#649). + +A ``Publication``'s child artifacts -- the ``talk`` that presented it, the +teaser ``video``, the ``poster`` -- are the *same* scholarly artifact, so they +belong to the same projects as the publication. This one-shot command copies a +publication's ``projects`` onto any of its children that currently have **none**, +which clears the "parent publication is linked -- inherit its projects" rows the +``UnlinkedArtifactsCheck`` data-health check flags. + +Safety properties: + +- **Additive only.** Never removes a link and never touches a child that already + has one or more projects (so an intentional, different linkage is preserved). +- **Idempotent.** Re-running changes nothing once children are populated, which + is why it is safe to run on every container start from ``docker-entrypoint.sh`` + (it self-heals future papers whose children are added without a project). + +Run manually with ``--dry-run`` to preview without writing. +""" + +import logging + +from django.core.management.base import BaseCommand + +from website.models import Publication + +_logger = logging.getLogger(__name__) + +# Publication FK fields pointing at its child artifacts (all nullable). +CHILD_FIELDS = ('talk', 'video', 'poster') + + +class Command(BaseCommand): + help = ("Copy each publication's projects onto its own talk/video/poster " + "children that currently have no project (#649). Additive and " + "idempotent.") + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help="Report what would change without writing to the database.", + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + prefix = '[dry-run] ' if dry_run else '' + _logger.info("%sRunning propagate_publication_projects (#649)...", prefix) + + children_updated = 0 + pubs = (Publication.objects + .prefetch_related('projects', 'talk__projects', + 'video__projects', 'poster__projects')) + for pub in pubs: + parent_projects = list(pub.projects.all()) + if not parent_projects: + continue # nothing to propagate + + for field in CHILD_FIELDS: + child = getattr(pub, field, None) + if child is None: + continue + if child.projects.exists(): + continue # already linked -- leave it alone + + _logger.info( + "%sLinking %s id=%s ('%s') to projects %s (from publication " + "id=%s)", + prefix, field, child.pk, child.title, + [p.pk for p in parent_projects], pub.pk, + ) + if not dry_run: + child.projects.add(*parent_projects) + children_updated += 1 + + _logger.info("%sLinked %d child artifact(s) to their publication's " + "projects.", prefix, children_updated) + self.stdout.write( + f"{prefix}Linked {children_updated} child artifact(s) " + f"to their publication's projects." + ) diff --git a/website/tests/test_propagate_publication_projects.py b/website/tests/test_propagate_publication_projects.py new file mode 100644 index 00000000..c2785b84 --- /dev/null +++ b/website/tests/test_propagate_publication_projects.py @@ -0,0 +1,76 @@ +""" +Tests for the propagate_publication_projects management command (#649): +copy a publication's projects onto its childless talk/video/poster. +""" + +from django.core.management import call_command + +from website.tests.base import DatabaseTestCase + + +class PropagatePublicationProjectsTests(DatabaseTestCase): + def _run(self, **kwargs): + call_command('propagate_publication_projects', **kwargs) + + def test_childless_talk_inherits_parent_projects(self): + project = self.make_project(name="Inheriting Project") + talk = self.make_talk(title="Conference talk", year=2024) + pub = self.make_publication(title="The paper", year=2024, talk=talk) + pub.projects.add(project) + + self._run() + talk.refresh_from_db() + self.assertEqual(set(talk.projects.all()), {project}) + + def test_video_and_poster_children_also_inherit(self): + project = self.make_project(name="Multi-child Project") + video = self.make_video(title="Teaser", year=2024) + pub = self.make_publication(title="Paper w/ video", year=2024, video=video) + pub.projects.add(project) + + self._run() + video.refresh_from_db() + self.assertEqual(set(video.projects.all()), {project}) + + def test_existing_child_links_are_left_untouched(self): + """A child that already has a project must not be modified.""" + parent_project = self.make_project(name="Parent Project") + other_project = self.make_project(name="Other Project") + talk = self.make_talk(title="Already linked talk", year=2024) + talk.projects.add(other_project) + pub = self.make_publication(title="Paper", year=2024, talk=talk) + pub.projects.add(parent_project) + + self._run() + talk.refresh_from_db() + # additive-only + skip-if-linked => unchanged, still just other_project + self.assertEqual(set(talk.projects.all()), {other_project}) + + def test_publication_without_projects_propagates_nothing(self): + talk = self.make_talk(title="Orphan talk", year=2024) + self.make_publication(title="Unlinked paper", year=2024, talk=talk) + + self._run() + talk.refresh_from_db() + self.assertEqual(talk.projects.count(), 0) + + def test_dry_run_writes_nothing(self): + project = self.make_project(name="Dry Run Project") + talk = self.make_talk(title="Talk", year=2024) + pub = self.make_publication(title="Paper", year=2024, talk=talk) + pub.projects.add(project) + + self._run(dry_run=True) + talk.refresh_from_db() + self.assertEqual(talk.projects.count(), 0) + + def test_idempotent(self): + project = self.make_project(name="Idempotent Project") + talk = self.make_talk(title="Talk", year=2024) + pub = self.make_publication(title="Paper", year=2024, talk=talk) + pub.projects.add(project) + + self._run() + self._run() + talk.refresh_from_db() + self.assertEqual(set(talk.projects.all()), {project})