Skip to content

Commit 35ada26

Browse files
authored
Merge pull request #1302 from makeabilitylab/1300-make-projects-private-by-default
Make projects private by default with a single is_visible flag (#1300)
2 parents 6ee6b99 + 426bdcf commit 35ada26

14 files changed

Lines changed: 400 additions & 49 deletions

File tree

docker-entrypoint.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ echo "4.6 Running 'python manage.py backfill_num_pages' to fill missing publicat
110110
echo "******************************************"
111111
python manage.py backfill_num_pages
112112

113+
echo "****************** STEP 4.7/5: docker-entrypoint.sh ************************"
114+
echo "4.7 Running 'python manage.py backfill_project_visibility' to resolve is_visible for legacy projects"
115+
echo "******************************************"
116+
python manage.py backfill_project_visibility
117+
113118
# echo "****************** STEP 4.3/5: docker-entrypoint.sh ************************"
114119
# echo "4.3 Running 'python manage.py rename_person_images' to rename person images"
115120
# echo "******************************************"

makeabilitylab/settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@
7272
ALLOWED_HOSTS = ['*']
7373

7474
# Makeability Lab Global Variables, including Makeability Lab version
75-
ML_WEBSITE_VERSION = "2.5.0" # Keep this updated with each release and also change the short description below
76-
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."
75+
ML_WEBSITE_VERSION = "2.6.0" # Keep this updated with each release and also change the short description below
76+
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)."
7777
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
7878
MAX_BANNERS = 7 # Maximum number of banners on a page
7979

website/admin/data_health/checks/project_health.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""
2-
Data-health check: projects that are incomplete or invisible on the site.
2+
Data-health check: projects that are incomplete.
33
4-
The public member/gallery views only show a project when it has a thumbnail
5-
(``gallery_image``) and a publication (see ``Project.can_show_online`` and the
6-
member-view filter), so a project missing either is effectively invisible.
7-
Also flags projects with no active members or no umbrella. Read-only.
4+
Public visibility is now governed solely by the ``is_visible`` flag (#1300), so
5+
this check focuses on *completeness*: it flags projects missing a thumbnail
6+
(``gallery_image``), a publication, currently-active members, or an umbrella.
7+
The ``is_visible`` column is surfaced for context — a project that is visible
8+
*and* incomplete is the most actionable case. Read-only.
89
"""
910

1011
from datetime import date
@@ -23,8 +24,8 @@ class ProjectHealthCheck(HealthCheck):
2324
)
2425
group = 'Projects'
2526
columns = [
26-
'id', 'name', 'short_name', 'has_thumbnail', 'has_publication',
27-
'active_member_count', 'has_umbrella', 'issues',
27+
'id', 'name', 'short_name', 'is_visible', 'has_thumbnail',
28+
'has_publication', 'active_member_count', 'has_umbrella', 'issues',
2829
]
2930

3031
def get_rows(self):
@@ -58,6 +59,7 @@ def get_rows(self):
5859
'id': project.pk,
5960
'name': project.name,
6061
'short_name': project.short_name,
62+
'is_visible': bool(project.is_visible),
6163
'has_thumbnail': has_thumbnail,
6264
'has_publication': has_publication,
6365
'active_member_count': active_member_count,

website/admin/project_admin.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,20 +53,20 @@ class ProjectAdmin(ImageCroppingMixin, admin.ModelAdmin):
5353

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

6262
fieldsets = [
63-
(None, {'fields': ['name', 'short_name']}),
63+
(None, {'fields': ['name', 'short_name', 'is_visible']}),
6464
('About', {'fields': ['start_date', 'end_date', 'summary', 'about', 'gallery_image', 'cropping', 'thumbnail_alt_text']}),
6565
('Links', {'fields': ['website', 'data_url', 'featured_video', 'featured_code_repo_url']}),
6666
('Associations', {'fields': ['project_umbrellas', 'keywords']}),
6767
]
6868

69-
list_filter = (ActiveProjectsFilter, )
69+
list_filter = (ActiveProjectsFilter, 'is_visible')
7070

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

98108
def formfield_for_manytomany(self, db_field, request=None, **kwargs):
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import logging
2+
from django.core.management.base import BaseCommand
3+
from website.models import Project
4+
5+
# This retrieves a Python logging instance (or creates it)
6+
_logger = logging.getLogger(__name__)
7+
8+
9+
class Command(BaseCommand):
10+
help = (
11+
"One-shot backfill of Project.is_visible for projects that predate the "
12+
"field (issue #1300). Legacy rows are added by the migration as NULL; "
13+
"this resolves each NULL to the project's previous public visibility "
14+
"using the old criteria (has a gallery image AND at least one "
15+
"publication). Idempotent: it only touches rows where is_visible IS "
16+
"NULL, so a manual admin choice (True or False) is never overwritten "
17+
"and it is safe to run on every container start."
18+
)
19+
20+
def add_arguments(self, parser):
21+
parser.add_argument(
22+
"--dry-run",
23+
action="store_true",
24+
help="Report what would change without writing to the database.",
25+
)
26+
27+
def handle(self, *args, **options):
28+
dry_run = options["dry_run"]
29+
_logger.debug(
30+
f"Running backfill_project_visibility.py (dry_run={dry_run}) to "
31+
f"resolve is_visible for legacy projects."
32+
)
33+
34+
# Only projects that haven't had their visibility decided yet. New
35+
# projects are created with is_visible=False (private), so the only
36+
# NULLs are rows that existed before the column was added.
37+
candidates = Project.objects.filter(is_visible__isnull=True)
38+
39+
num_visible = 0
40+
num_private = 0
41+
for project in candidates:
42+
# Legacy public-visibility criteria: a thumbnail AND a publication.
43+
should_be_visible = bool(project.gallery_image) and project.has_publication()
44+
45+
if dry_run:
46+
_logger.debug(
47+
f"[dry-run] Would set is_visible={should_be_visible} for "
48+
f"project id={project.pk} '{project.name}'"
49+
)
50+
else:
51+
# Write via the queryset so this stays a pure data backfill and
52+
# does NOT trigger Project.save() (which auto-closes project
53+
# roles when end_date is set).
54+
Project.objects.filter(pk=project.pk).update(is_visible=should_be_visible)
55+
_logger.debug(
56+
f"Set is_visible={should_be_visible} for project "
57+
f"id={project.pk} '{project.name}'"
58+
)
59+
60+
if should_be_visible:
61+
num_visible += 1
62+
else:
63+
num_private += 1
64+
65+
verb = "Would resolve" if dry_run else "Resolved"
66+
_logger.info(
67+
f"backfill_project_visibility: {verb} {num_visible + num_private} "
68+
f"legacy project(s) — {num_visible} visible, {num_private} private."
69+
)
70+
_logger.debug("Completed backfill_project_visibility.py")

website/management/commands/seed_demo_projects.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ def _make_demo_active_small(self, Project, ProjectRole, Roles, people):
170170
proj = Project.objects.create(
171171
name="Demo Project: Active (Short Sidebar)",
172172
short_name="demo-active-small",
173+
is_visible=True, # demo projects are public so they render for visual testing
173174
start_date=date(2024, 1, 1),
174175
end_date=None,
175176
summary="A small active demo project for visual testing of the short-sidebar case.",
@@ -195,6 +196,7 @@ def _make_demo_active_tall(self, Project, ProjectRole, Roles, people):
195196
proj = Project.objects.create(
196197
name="Demo Project: Active (Tall Sidebar — Sidewalk-like)",
197198
short_name="demo-active-tall",
199+
is_visible=True, # demo projects are public so they render for visual testing
198200
start_date=date(2019, 1, 1),
199201
end_date=None,
200202
summary="A large active demo project with lots of current and former members — sidebar exceeds viewport height.",
@@ -246,6 +248,7 @@ def _make_demo_ended_tall(self, Project, ProjectRole, Roles, people):
246248
proj = Project.objects.create(
247249
name="Demo Project: Ended (Tall Sidebar)",
248250
short_name="demo-ended-tall",
251+
is_visible=True, # demo projects are public so they render for visual testing
249252
start_date=date(2018, 1, 1),
250253
end_date=proj_end,
251254
summary="A completed demo project for testing the 'Former-prefix-dropped' branch (#1245).",
@@ -285,6 +288,7 @@ def _make_demo_tall_main_short_sidebar(self, Project, ProjectRole, Roles, people
285288
proj = Project.objects.create(
286289
name="Demo Project: Tall Main + Short Sidebar",
287290
short_name="demo-tall-main-short-sidebar",
291+
is_visible=True, # demo projects are public so they render for visual testing
288292
start_date=date(2024, 1, 1),
289293
end_date=None,
290294
summary="A project with only a few sidebar entries but lots of publications, so the main content is much taller than the sidebar.",

website/models/project.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,32 @@ def get_thumbnail_size_as_str():
4040
# Short name is used for urls, and should be name.lower().replace(" ", "")
4141
short_name = models.CharField(max_length=255)
4242
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"
43-
43+
44+
# is_visible is the single source of truth for whether a project appears
45+
# publicly (gallery, landing page, member pages, and as links from
46+
# pub/talk/video/award snippets). See issue #1300. This replaces the old
47+
# "has a thumbnail AND a publication" heuristic that was duplicated across
48+
# views and templates.
49+
#
50+
# The field is intentionally nullable with no DB default:
51+
# - New projects start PRIVATE: Project.save() sets is_visible=False when
52+
# creating a project that hasn't set it explicitly (see save()).
53+
# - Existing projects (rows that predate this column) are added as NULL by
54+
# the migration, which the one-shot `backfill_project_visibility`
55+
# management command resolves to True/False based on the legacy
56+
# thumbnail+publication criteria. Keying the backfill on NULL keeps it
57+
# idempotent, so it never clobbers a later manual admin override.
58+
# A `default=False` is deliberately NOT used: Django would backfill every
59+
# pre-existing row with False on ADD COLUMN, silently hiding every
60+
# currently-visible project on the first deploy.
61+
is_visible = models.BooleanField(null=True, blank=True, default=None)
62+
is_visible.help_text = ("Controls whether this project is shown publicly (project gallery, "
63+
"landing page, member pages, and as links from publications/talks/videos). "
64+
"New projects start private so you can set them up and add people before "
65+
"going live; check this when the project is ready to be public.")
66+
is_visible.verbose_name = "Visible on website"
67+
68+
4469
# grants = models.ManyToManyField('Grant', blank=True)
4570
# 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."
4671

@@ -89,6 +114,15 @@ def save(self, *args, **kwargs):
89114
lab departure date, whichever is earlier.
90115
"""
91116
_logger.debug("Running Project.save() method...")
117+
118+
# New projects are private by default (issue #1300). We set this at the
119+
# model layer (rather than via a field default) so it applies to every
120+
# creation path — admin, shell, seeds, tests — while leaving pre-existing
121+
# rows as NULL for the one-shot backfill to resolve. Only applies on
122+
# creation (no pk yet) and only when the caller hasn't set it explicitly.
123+
if self.pk is None and self.is_visible is None:
124+
self.is_visible = False
125+
92126
super(Project, self).save(*args, **kwargs) # Save the Project instance first
93127

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

326360
def can_show_online(self):
327-
"""Returns true if we can show this project on the webpage"""
328-
return self.has_thumbnail() and self.has_publication()
361+
"""
362+
Returns True if this project should be shown publicly.
363+
364+
As of issue #1300 this is governed solely by the ``is_visible`` flag
365+
(editor-controlled) rather than the old "has a thumbnail AND a
366+
publication" heuristic. Kept as a method because templates reference
367+
``project.can_show_online`` to decide whether to link to a project.
368+
``is_visible`` may transiently be None for legacy rows before the
369+
``backfill_project_visibility`` command runs; None is treated as private.
370+
"""
371+
return bool(self.is_visible)
329372

330373
def has_thumbnail(self):
331374
"""Returns true if a project thumbnail has been set"""

website/templates/website/project_listing.html

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,9 @@ <h2 id="active-projects-heading" class="section-heading heading-with-anchor">
150150
</h2>
151151

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

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

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

website/tests/base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,27 @@ def make_news_item(self, title="Test News", author=None, **kwargs):
121121
kwargs.setdefault("date", _date(2024, 1, 1))
122122
kwargs.setdefault("content", "Test news body.")
123123
return News.objects.create(title=title, author=author, **kwargs)
124+
125+
def make_project(self, name="A Test Project", short_name=None,
126+
with_thumbnail=False, **kwargs):
127+
"""
128+
Create and return a Project. By default the project is created exactly
129+
as Project.save() leaves it (is_visible=False, i.e. private), so tests
130+
that care about visibility should pass is_visible=True explicitly or
131+
flip it afterwards.
132+
133+
Args:
134+
with_thumbnail: when True, attaches a small valid gallery_image so
135+
tests can exercise the legacy thumbnail criterion used by the
136+
visibility backfill. Defaults to False to avoid touching the
137+
filesystem unnecessarily.
138+
"""
139+
from website.models import Project
140+
if short_name is None:
141+
short_name = name.lower().replace(" ", "")
142+
if with_thumbnail:
143+
kwargs.setdefault(
144+
"gallery_image",
145+
_make_image_upload(f"{short_name}_thumb.gif"),
146+
)
147+
return Project.objects.create(name=name, short_name=short_name, **kwargs)

0 commit comments

Comments
 (0)