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
12 changes: 9 additions & 3 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,15 @@ echo "******************************************"
python manage.py setup_admin_groups

echo "****************** STEP 4.10b/5: docker-entrypoint.sh ************************"
echo "4.10b Running 'python manage.py restandardize_artifact_filenames' to rename legacy talk/poster/pub files to the standardized scheme (#1401)"
echo "******************************************"
python manage.py restandardize_artifact_filenames
echo "4.10b Running 'python manage.py restandardize_artifact_filenames' to rename legacy talk/poster/pub files to the standardized scheme (#1401/#1404)"
echo "******************************************"
# TEMPORARY (#1404): the scheme just gained a trailing artifact-type segment
# ("..._CHI2024_Talk"), so this step would re-rename EVERY already-standardized
# talk and poster in one unattended pass. --dry-run logs what WOULD be renamed,
# touching nothing on disk or in the DB, so the scope can be reviewed on the
# test server (and then prod) first. REMOVE --dry-run and redeploy to perform
# the rename; after that this step is idempotent again and stays in place.
python manage.py restandardize_artifact_filenames --dry-run

echo "****************** STEP 4.10c/5: docker-entrypoint.sh ************************"
echo "4.10c Running 'python manage.py repair_diverged_artifact_filenames' to recover artifacts whose files were renamed on disk but not in the DB (#1390 dotted-name bug)"
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.28.1" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "Adds Project People and Activity Log quick links to the admin header, so two internal pages that nothing in the site nav points at are one click away. The Activity Log link is superuser-only, matching that page's own gate."
ML_WEBSITE_VERSION = "2.29.0" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "Standardized filenames for talks and posters now end in '_Talk' and '_Poster' (#1404), so a downloaded slide deck or poster is no longer indistinguishable from the paper PDF of the same work. Publication filenames are unchanged. Dry-run stage: existing files are NOT renamed yet — the pending renames are inventoried in debug.log for review, and the one-time rename ships in the follow-up release."
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
34 changes: 21 additions & 13 deletions website/management/commands/backfill_original_filenames.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.core.management.base import BaseCommand

from website.models import Artifact, Talk, Poster, Publication
from website.utils import fileutils as ml_fileutils

# This retrieves a Python logging instance (or creates it)
_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -122,19 +123,26 @@ def _backfill_row(self, model, artifact, file_attr, original_attr, dry_run):

current_basename = os.path.basename(file_field.name)
current_no_ext = os.path.splitext(current_basename)[0]
standardized_no_ext = Artifact.generate_filename(artifact)

# Treat the file as already-standardized when its name equals the
# standardized scheme OR is a uniquified variant of it. When a
# standardized name collides on disk, ensure_filename_is_unique()
# (fileutils.py) appends "-<timestamp>" — e.g.
# "Lee_Talk_CHI2021-1782399772.42.pdf" — so the on-disk name still
# STARTS WITH the standardized base. Matching only on exact equality
# would misread those as never-renamed and record the standardized+
# suffix name as the "original" — a false positive.
already_standardized = (
current_no_ext == standardized_no_ext
or current_no_ext.startswith(standardized_no_ext + "-")

# Treat the file as already-standardized when its name matches the
# standardized scheme. matches_standardized_basename also accepts the
# "-<timestamp>" that ensure_filename_is_unique appends on a disk
# collision (e.g. "Lee_Talk_CHI2021-1782399772.42.pdf"), so a renamed-
# then-uniquified file isn't misread as an original upload.
#
# BOTH schemes count (#1404). This command runs at container start
# BEFORE restandardize_artifact_filenames, so on the deploy that
# introduces the artifact-type segment ("..._Talk") every already-renamed
# talk and poster still carries its pre-#1404 name. Checking only the
# current scheme would read the whole corpus as never-renamed and record
# those old standardized names as originals — false provenance, shown to
# editors as "Originally uploaded as".
already_standardized = any(
ml_fileutils.matches_standardized_basename(
current_no_ext,
Artifact.generate_filename(
artifact, include_type_suffix=include_type_suffix))
for include_type_suffix in (True, False)
)
if already_standardized:
# Already renamed — the original upload name is gone.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.core.management.base import BaseCommand

from website.models import Artifact, Talk, Poster, Publication
from website.utils import fileutils as ml_fileutils

# This retrieves a Python logging instance (or creates it)
_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -150,9 +151,15 @@ def _repair_field(self, artifact, field_name, dry_run):
# base, possibly with a "-<timestamp>" uniqueness suffix from colliding
# with the sibling files. Match those, then confirm by content type so we
# never mis-pair a pdf with a pptx/thumbnail (they share the base name).
# The pre-#1404 base (no trailing "_Talk"/"_Poster") is searched too: the
# bug predates that scheme change, so an orphan it left behind on disk
# carries the old base even though the repair target uses the new one.
legacy_base = get_valid_filename(
Artifact.generate_filename(artifact, include_type_suffix=False))
candidates = []
for entry in os.listdir(directory):
if entry == valid_base or entry.startswith(valid_base + "-"):
if any(ml_fileutils.matches_standardized_basename(entry, base)
for base in {valid_base, legacy_base}):
full = os.path.join(directory, entry)
if os.path.isfile(full):
candidates.append(entry)
Expand Down
42 changes: 23 additions & 19 deletions website/management/commands/restandardize_artifact_filenames.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,28 @@
from django.core.management.base import BaseCommand

from website.models import Artifact, Talk, Poster, Publication
from website.utils import fileutils as ml_fileutils

# This retrieves a Python logging instance (or creates it)
_logger = logging.getLogger(__name__)


class Command(BaseCommand):
help = (
"Re-standardizes legacy talk/poster/publication filenames that were "
"never renamed to the Author_TitleInTitleCase_VenueYear scheme (issue "
"#1401). Production has many such rows (bulk-imported, so they never "
"went through an authored Artifact.save()). This reuses the existing, "
"now-correct rename path: when Artifact.do_filenames_need_updating() is "
"True it calls artifact.save(), which renames the pdf_file, raw_file, "
"and thumbnail on disk AND in the DB together. The original upload name "
"is preserved (it was captured into original_*_filename by "
"backfill_original_filenames / #1391 before this runs). Idempotent: "
"once a row is standardized the check returns False, so re-runs do "
"nothing. Safe to run on every container start."
"Re-standardizes talk/poster/publication filenames that don't match the "
"current scheme. Two populations: files that were never renamed at all "
"(issue #1401 — bulk-imported rows that never went through an authored "
"Artifact.save()), and files standardized under a superseded scheme "
"(issue #1404 added the trailing artifact-type segment, e.g. "
"'..._CHI2024_Talk'), which this migrates in one pass. It reuses the "
"existing, now-correct rename path: when Artifact.do_filenames_need_"
"updating() is True it calls artifact.save(), which renames the "
"pdf_file, raw_file, and thumbnail on disk AND in the DB together. The "
"original upload name is preserved (it was captured into "
"original_*_filename by backfill_original_filenames / #1391 before this "
"runs). Idempotent: once a row matches the current scheme the check "
"returns False, so re-runs do nothing. Safe to run on every container "
"start."
)

# The concrete artifact models this covers. Posters are already
Expand Down Expand Up @@ -156,9 +160,12 @@ def _needs_restandardizing(artifact):
standardized name collided on disk and got a ``-<timestamp>`` suffix
(``ensure_filename_is_unique``) reads as "needs updating" forever and
would be re-renamed on every run — churning duplicate-name artifacts'
filenames on every deploy. Here a name that equals the standardized
base OR is a ``-<suffix>`` variant of it counts as already standardized,
which keeps the command idempotent.
filenames on every deploy. ``matches_standardized_basename`` accepts
those variants, which keeps the command idempotent.

Only the CURRENT scheme counts as standardized: a file named under the
pre-#1404 scheme (no trailing "_Talk"/"_Poster") is deliberately flagged,
which is what migrates the existing corpus in a single pass.
"""
standardized = Artifact.generate_filename(artifact)
for file_attr in ("pdf_file", "raw_file"):
Expand All @@ -167,10 +174,7 @@ def _needs_restandardizing(artifact):
continue
current_no_ext = os.path.splitext(
os.path.basename(file_field.name))[0]
is_standardized = (
current_no_ext == standardized
or current_no_ext.startswith(standardized + "-")
)
if not is_standardized:
if not ml_fileutils.matches_standardized_basename(
current_no_ext, standardized):
return True
return False
38 changes: 29 additions & 9 deletions website/models/artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ class Artifact(models.Model):
2. also hook up an authors_changed signal in signals.py. For example, add the line "@receiver(m2m_changed, sender=Grant.authors.through)"
to signals.py's def authors_changed(sender, instance, action, reverse, **kwargs):
"""
# The artifact-type segment appended to standardized filenames (#1404), so a
# downloaded file says what kind of artifact it is. Set on the subclasses
# whose files are otherwise ambiguous — a talk's exported slides and a poster
# generate the same Author_Title_VenueYear name as the paper itself. Left
# None here (and on Publication/Grant): a bare paper PDF is the default
# expectation, and that same name is also the .bib download name.
FILENAME_TYPE_SUFFIX = None

title = models.CharField(max_length=255, blank=True, null=True)
authors = SortedManyToManyField('Person', blank=True)
date = models.DateField(null=True)
Expand Down Expand Up @@ -473,32 +481,44 @@ def do_filenames_need_updating(artifact):
return False

@staticmethod
def generate_filename(artifact, file_extension=None, max_pub_title_length = -1):
def generate_filename(artifact, file_extension=None, max_pub_title_length = -1,
include_type_suffix=True):
"""
Generates a filename for the given artifact.

This method generates a filename based on the artifact's first author's last name, title, forum name, and date.
This method generates a filename based on the artifact's first author's last name, title, forum name, and date,
plus the artifact-type segment of its class (``FILENAME_TYPE_SUFFIX``, e.g. "_Talk" — see #1404).
If a file extension is provided, it is appended to the filename. Otherwise, a filename without extension is returned.

Parameters:
artifact (Artifact): The artifact for which the filename is to be generated.
file_extension (str, optional): The file extension to be appended to the filename. Defaults to None.
include_type_suffix (bool, optional): Pass False for the pre-#1404 name (no type segment). Only the
filename management commands need this, to recognize a file that was standardized under the old
scheme; everything else wants the current scheme. Defaults to True.

Returns:
str: The generated filename.

Example:
>>> artifact = Artifact(first_author_last_name="Froehlich", title="Research Artifact Title", forum_name="CHI", date="2023-12-16")
>>> generate_filename(artifact, file_extension=".pdf")
>>> talk = Talk(title="Research Artifact Title", forum_name="CHI", date="2023-12-16") # first author: Froehlich
>>> generate_filename(talk, file_extension=".pdf")
'Froehlich_ResearchArtifactTitle_CHI2023_Talk.pdf'
>>> generate_filename(talk, file_extension=".pdf", include_type_suffix=False)
'Froehlich_ResearchArtifactTitle_CHI2023.pdf'
"""


# Read off the class, not the instance, so the type segment is a property of the model
# (Poster has no type field, and Talk.talk_type is nullable and editor-editable — deriving
# the segment from data would rename files on a metadata-only edit).
type_suffix = type(artifact).FILENAME_TYPE_SUFFIX if include_type_suffix else None

# An empty string or a string with only whitespace characters is considered False in a boolean context.
if not file_extension or not file_extension.strip():
return ml_fileutils.get_filename_without_ext_for_artifact(
artifact.get_first_author_last_name(), artifact.title,
artifact.forum_name, artifact.date)
artifact.get_first_author_last_name(), artifact.title,
artifact.forum_name, artifact.date, type_suffix=type_suffix)
else:
return ml_fileutils.get_filename_for_artifact(
artifact.get_first_author_last_name(), artifact.title,
artifact.forum_name, artifact.date, file_extension)
artifact.get_first_author_last_name(), artifact.title,
artifact.forum_name, artifact.date, file_extension, type_suffix=type_suffix)
4 changes: 4 additions & 0 deletions website/models/poster.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ class Poster(Artifact):
UPLOAD_DIR = 'posters/'
THUMBNAIL_DIR = os.path.join(UPLOAD_DIR, 'images/')

# Standardized filenames end in "_Poster" (#1404) so a downloaded poster
# isn't indistinguishable from the paper's PDF of the same work.
FILENAME_TYPE_SUFFIX = 'Poster'

external_slides_url = models.URLField(blank=True, null=True)
external_slides_url.help_text = (
"Optional link to the source design (e.g., Figma, Canva, Illustrator "
Expand Down
4 changes: 4 additions & 0 deletions website/models/talk.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class Talk(Artifact):
UPLOAD_DIR = 'talks/'
THUMBNAIL_DIR = os.path.join(UPLOAD_DIR, 'images/')

# Standardized filenames end in "_Talk" (#1404) so a downloaded slide deck
# isn't indistinguishable from the paper's PDF of the same work.
FILENAME_TYPE_SUFFIX = 'Talk'

external_slides_url = models.URLField(blank=True, null=True)
external_slides_url.help_text = (
"Optional link to the source slide deck (e.g., Figma, Google Slides, "
Expand Down
Loading
Loading