Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "******************************************"
Expand Down
13 changes: 10 additions & 3 deletions docs/plans/issue-649-link-artifacts-to-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
4 changes: 2 additions & 2 deletions makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
82 changes: 82 additions & 0 deletions website/management/commands/propagate_publication_projects.py
Original file line number Diff line number Diff line change
@@ -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."
)
76 changes: 76 additions & 0 deletions website/tests/test_propagate_publication_projects.py
Original file line number Diff line number Diff line change
@@ -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})
Loading