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 @@ -110,6 +110,11 @@ echo "4.6 Running 'python manage.py backfill_num_pages' to fill missing publicat
echo "******************************************"
python manage.py backfill_num_pages

echo "****************** STEP 4.7/5: docker-entrypoint.sh ************************"
echo "4.7 Running 'python manage.py backfill_project_visibility' to resolve is_visible for legacy projects"
echo "******************************************"
python manage.py backfill_project_visibility

# 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
4 changes: 2 additions & 2 deletions makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@
ALLOWED_HOSTS = ['*']

# Makeability Lab Global Variables, including Makeability Lab version
ML_WEBSITE_VERSION = "2.5.0" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "Frontend modernization (Track A, #1288): removed jQuery 1.9.1 and Bootstrap 3.3.6 JavaScript entirely. The navbar collapse (#1290), citation popover (#1292), and hero/project carousels (#1293) were rewritten in vanilla JS; dead Bootstrap scrollspy (#1291), jQuery UI, jQuery Easing, and a dead back-to-top widget were removed (#1289/#1290). Drops ~70KB of CDN JS from every page and eliminates the security-vulnerable jQuery 1.9.1. Bootstrap's CSS is unchanged. Also adds prefers-reduced-motion handling to the carousel."
ML_WEBSITE_VERSION = "2.6.0" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "Projects are now private by default (#1300). A single editor-controlled Project.is_visible flag governs whether a project appears publicly (gallery, landing page, member pages, and as links from publications/talks/videos), replacing the old 'has a thumbnail AND a publication' heuristic that was duplicated across views and templates. New projects start private so the team can set them up and add people before going live; logged-in staff can preview a private project's page while the public gets a 404. A one-shot backfill_project_visibility management command preserves the visibility of existing projects on first deploy and is idempotent (it only resolves projects whose visibility was never set, so manual admin choices are never overwritten)."
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
16 changes: 9 additions & 7 deletions website/admin/data_health/checks/project_health.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""
Data-health check: projects that are incomplete or invisible on the site.
Data-health check: projects that are incomplete.

The public member/gallery views only show a project when it has a thumbnail
(``gallery_image``) and a publication (see ``Project.can_show_online`` and the
member-view filter), so a project missing either is effectively invisible.
Also flags projects with no active members or no umbrella. Read-only.
Public visibility is now governed solely by the ``is_visible`` flag (#1300), so
this check focuses on *completeness*: it flags projects missing a thumbnail
(``gallery_image``), a publication, currently-active members, or an umbrella.
The ``is_visible`` column is surfaced for context — a project that is visible
*and* incomplete is the most actionable case. Read-only.
"""

from datetime import date
Expand All @@ -23,8 +24,8 @@ class ProjectHealthCheck(HealthCheck):
)
group = 'Projects'
columns = [
'id', 'name', 'short_name', 'has_thumbnail', 'has_publication',
'active_member_count', 'has_umbrella', 'issues',
'id', 'name', 'short_name', 'is_visible', 'has_thumbnail',
'has_publication', 'active_member_count', 'has_umbrella', 'issues',
]

def get_rows(self):
Expand Down Expand Up @@ -58,6 +59,7 @@ def get_rows(self):
'id': project.pk,
'name': project.name,
'short_name': project.short_name,
'is_visible': bool(project.is_visible),
'has_thumbnail': has_thumbnail,
'has_publication': has_publication,
'active_member_count': active_member_count,
Expand Down
16 changes: 13 additions & 3 deletions website/admin/project_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,20 @@ class ProjectAdmin(ImageCroppingMixin, admin.ModelAdmin):

# The list display lets us control what is shown in the Project table at Home > Website > Project
# info on displaying multiple entries comes from http://stackoverflow.com/questions/9164610/custom-columns-using-django-admin
list_display = ('name', 'get_display_thumbnail', 'start_date', 'end_date', 'has_ended',
list_display = ('name', 'is_visible', 'get_display_thumbnail', 'start_date', 'end_date', 'has_ended',
'get_contributor_count', 'get_people_count',
'get_current_member_count', 'get_past_member_count',
'get_most_recent_artifact_date', 'get_most_recent_artifact_type',
'get_publication_count', 'get_video_count', 'get_talk_count', 'get_banner_count')

fieldsets = [
(None, {'fields': ['name', 'short_name']}),
(None, {'fields': ['name', 'short_name', 'is_visible']}),
('About', {'fields': ['start_date', 'end_date', 'summary', 'about', 'gallery_image', 'cropping', 'thumbnail_alt_text']}),
('Links', {'fields': ['website', 'data_url', 'featured_video', 'featured_code_repo_url']}),
('Associations', {'fields': ['project_umbrellas', 'keywords']}),
]

list_filter = (ActiveProjectsFilter, )
list_filter = (ActiveProjectsFilter, 'is_visible')

def get_display_thumbnail(self, obj):
if obj.gallery_image and os.path.isfile(obj.gallery_image.path):
Expand All @@ -93,6 +93,16 @@ def formfield_for_dbfield(self, db_field, **kwargs):
formfield = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'summary':
formfield.widget = forms.Textarea(attrs={'rows': 3, 'class': 'vLargeTextField'})
if db_field.name == 'is_visible':
# is_visible is a nullable BooleanField (NULL = legacy, pre-backfill;
# see Project model / #1300), which Django would otherwise render as a
# three-state Yes/No/Unknown select. Editors only ever want public vs
# private, so present a plain checkbox; unchecked saves False (private).
formfield = forms.BooleanField(
required=False,
label=db_field.verbose_name,
help_text=db_field.help_text,
)
return formfield

def formfield_for_manytomany(self, db_field, request=None, **kwargs):
Expand Down
70 changes: 70 additions & 0 deletions website/management/commands/backfill_project_visibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging
from django.core.management.base import BaseCommand
from website.models import Project

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


class Command(BaseCommand):
help = (
"One-shot backfill of Project.is_visible for projects that predate the "
"field (issue #1300). Legacy rows are added by the migration as NULL; "
"this resolves each NULL to the project's previous public visibility "
"using the old criteria (has a gallery image AND at least one "
"publication). Idempotent: it only touches rows where is_visible IS "
"NULL, so a manual admin choice (True or False) is never overwritten "
"and it is safe to run on every container start."
)

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"]
_logger.debug(
f"Running backfill_project_visibility.py (dry_run={dry_run}) to "
f"resolve is_visible for legacy projects."
)

# Only projects that haven't had their visibility decided yet. New
# projects are created with is_visible=False (private), so the only
# NULLs are rows that existed before the column was added.
candidates = Project.objects.filter(is_visible__isnull=True)

num_visible = 0
num_private = 0
for project in candidates:
# Legacy public-visibility criteria: a thumbnail AND a publication.
should_be_visible = bool(project.gallery_image) and project.has_publication()

if dry_run:
_logger.debug(
f"[dry-run] Would set is_visible={should_be_visible} for "
f"project id={project.pk} '{project.name}'"
)
else:
# Write via the queryset so this stays a pure data backfill and
# does NOT trigger Project.save() (which auto-closes project
# roles when end_date is set).
Project.objects.filter(pk=project.pk).update(is_visible=should_be_visible)
_logger.debug(
f"Set is_visible={should_be_visible} for project "
f"id={project.pk} '{project.name}'"
)

if should_be_visible:
num_visible += 1
else:
num_private += 1

verb = "Would resolve" if dry_run else "Resolved"
_logger.info(
f"backfill_project_visibility: {verb} {num_visible + num_private} "
f"legacy project(s) — {num_visible} visible, {num_private} private."
)
_logger.debug("Completed backfill_project_visibility.py")
4 changes: 4 additions & 0 deletions website/management/commands/seed_demo_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def _make_demo_active_small(self, Project, ProjectRole, Roles, people):
proj = Project.objects.create(
name="Demo Project: Active (Short Sidebar)",
short_name="demo-active-small",
is_visible=True, # demo projects are public so they render for visual testing
start_date=date(2024, 1, 1),
end_date=None,
summary="A small active demo project for visual testing of the short-sidebar case.",
Expand All @@ -195,6 +196,7 @@ def _make_demo_active_tall(self, Project, ProjectRole, Roles, people):
proj = Project.objects.create(
name="Demo Project: Active (Tall Sidebar — Sidewalk-like)",
short_name="demo-active-tall",
is_visible=True, # demo projects are public so they render for visual testing
start_date=date(2019, 1, 1),
end_date=None,
summary="A large active demo project with lots of current and former members — sidebar exceeds viewport height.",
Expand Down Expand Up @@ -246,6 +248,7 @@ def _make_demo_ended_tall(self, Project, ProjectRole, Roles, people):
proj = Project.objects.create(
name="Demo Project: Ended (Tall Sidebar)",
short_name="demo-ended-tall",
is_visible=True, # demo projects are public so they render for visual testing
start_date=date(2018, 1, 1),
end_date=proj_end,
summary="A completed demo project for testing the 'Former-prefix-dropped' branch (#1245).",
Expand Down Expand Up @@ -285,6 +288,7 @@ def _make_demo_tall_main_short_sidebar(self, Project, ProjectRole, Roles, people
proj = Project.objects.create(
name="Demo Project: Tall Main + Short Sidebar",
short_name="demo-tall-main-short-sidebar",
is_visible=True, # demo projects are public so they render for visual testing
start_date=date(2024, 1, 1),
end_date=None,
summary="A project with only a few sidebar entries but lots of publications, so the main content is much taller than the sidebar.",
Expand Down
49 changes: 46 additions & 3 deletions website/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,32 @@ def get_thumbnail_size_as_str():
# Short name is used for urls, and should be name.lower().replace(" ", "")
short_name = models.CharField(max_length=255)
short_name.help_text = "This should be the same as name but lower case with no spaces. It is used in the url of the project"


# is_visible is the single source of truth for whether a project appears
# publicly (gallery, landing page, member pages, and as links from
# pub/talk/video/award snippets). See issue #1300. This replaces the old
# "has a thumbnail AND a publication" heuristic that was duplicated across
# views and templates.
#
# The field is intentionally nullable with no DB default:
# - New projects start PRIVATE: Project.save() sets is_visible=False when
# creating a project that hasn't set it explicitly (see save()).
# - Existing projects (rows that predate this column) are added as NULL by
# the migration, which the one-shot `backfill_project_visibility`
# management command resolves to True/False based on the legacy
# thumbnail+publication criteria. Keying the backfill on NULL keeps it
# idempotent, so it never clobbers a later manual admin override.
# A `default=False` is deliberately NOT used: Django would backfill every
# pre-existing row with False on ADD COLUMN, silently hiding every
# currently-visible project on the first deploy.
is_visible = models.BooleanField(null=True, blank=True, default=None)
is_visible.help_text = ("Controls whether this project is shown publicly (project gallery, "
"landing page, member pages, and as links from publications/talks/videos). "
"New projects start private so you can set them up and add people before "
"going live; check this when the project is ready to be public.")
is_visible.verbose_name = "Visible on website"


# grants = models.ManyToManyField('Grant', blank=True)
# grants.help_text = "Almost all projects in our lab are funded by grants. If you don't know about the project funding, please ask Jon."

Expand Down Expand Up @@ -89,6 +114,15 @@ def save(self, *args, **kwargs):
lab departure date, whichever is earlier.
"""
_logger.debug("Running Project.save() method...")

# New projects are private by default (issue #1300). We set this at the
# model layer (rather than via a field default) so it applies to every
# creation path — admin, shell, seeds, tests — while leaving pre-existing
# rows as NULL for the one-shot backfill to resolve. Only applies on
# creation (no pk yet) and only when the caller hasn't set it explicitly.
if self.pk is None and self.is_visible is None:
self.is_visible = False

super(Project, self).save(*args, **kwargs) # Save the Project instance first

if self.end_date:
Expand Down Expand Up @@ -324,8 +358,17 @@ def has_award(self):
return self.publication_set.filter(award__isnull=False).exclude(award__exact='').exists()

def can_show_online(self):
"""Returns true if we can show this project on the webpage"""
return self.has_thumbnail() and self.has_publication()
"""
Returns True if this project should be shown publicly.

As of issue #1300 this is governed solely by the ``is_visible`` flag
(editor-controlled) rather than the old "has a thumbnail AND a
publication" heuristic. Kept as a method because templates reference
``project.can_show_online`` to decide whether to link to a project.
``is_visible`` may transiently be None for legacy rows before the
``backfill_project_visibility`` command runs; None is treated as private.
"""
return bool(self.is_visible)

def has_thumbnail(self):
"""Returns true if a project thumbnail has been set"""
Expand Down
10 changes: 4 additions & 6 deletions website/templates/website/project_listing.html
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,9 @@ <h2 id="active-projects-heading" class="section-heading heading-with-anchor">
</h2>

<div id="project-grid-active" class="project-grid" role="list">
{# active_projects is already filtered to is_visible projects in the view (#1300) #}
{% for project in active_projects %}
{% if project.has_thumbnail and project.has_publication %}
{% include 'snippets/display_project_snippet.html' %}
{% endif %}
{% include 'snippets/display_project_snippet.html' %}
{% endfor %}
</div>

Expand All @@ -175,10 +174,9 @@ <h2 id="completed-projects-heading" class="section-heading heading-with-anchor">
</h2>

<div id="project-grid-completed" class="project-grid" role="list">
{# completed_projects is already filtered to is_visible projects in the view (#1300) #}
{% for project in completed_projects %}
{% if project.has_thumbnail and project.has_publication %}
{% include 'snippets/display_project_snippet.html' %}
{% endif %}
{% include 'snippets/display_project_snippet.html' %}
{% endfor %}
</div>

Expand Down
24 changes: 24 additions & 0 deletions website/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,27 @@ def make_news_item(self, title="Test News", author=None, **kwargs):
kwargs.setdefault("date", _date(2024, 1, 1))
kwargs.setdefault("content", "Test news body.")
return News.objects.create(title=title, author=author, **kwargs)

def make_project(self, name="A Test Project", short_name=None,
with_thumbnail=False, **kwargs):
"""
Create and return a Project. By default the project is created exactly
as Project.save() leaves it (is_visible=False, i.e. private), so tests
that care about visibility should pass is_visible=True explicitly or
flip it afterwards.

Args:
with_thumbnail: when True, attaches a small valid gallery_image so
tests can exercise the legacy thumbnail criterion used by the
visibility backfill. Defaults to False to avoid touching the
filesystem unnecessarily.
"""
from website.models import Project
if short_name is None:
short_name = name.lower().replace(" ", "")
if with_thumbnail:
kwargs.setdefault(
"gallery_image",
_make_image_upload(f"{short_name}_thumb.gif"),
)
return Project.objects.create(name=name, short_name=short_name, **kwargs)
Loading
Loading