diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c455341f..e27fa2ff 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -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 "******************************************" diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index ec0a3c7d..41e0c6e4 100644 --- a/makeabilitylab/settings.py +++ b/makeabilitylab/settings.py @@ -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 diff --git a/website/admin/data_health/checks/project_health.py b/website/admin/data_health/checks/project_health.py index 2d0f6117..b0b7c113 100644 --- a/website/admin/data_health/checks/project_health.py +++ b/website/admin/data_health/checks/project_health.py @@ -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 @@ -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): @@ -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, diff --git a/website/admin/project_admin.py b/website/admin/project_admin.py index 514fe30f..fa15e5f3 100644 --- a/website/admin/project_admin.py +++ b/website/admin/project_admin.py @@ -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): @@ -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): diff --git a/website/management/commands/backfill_project_visibility.py b/website/management/commands/backfill_project_visibility.py new file mode 100644 index 00000000..303e8673 --- /dev/null +++ b/website/management/commands/backfill_project_visibility.py @@ -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") diff --git a/website/management/commands/seed_demo_projects.py b/website/management/commands/seed_demo_projects.py index 7fc00f70..cbdb0575 100644 --- a/website/management/commands/seed_demo_projects.py +++ b/website/management/commands/seed_demo_projects.py @@ -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.", @@ -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.", @@ -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).", @@ -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.", diff --git a/website/models/project.py b/website/models/project.py index 7fe163c4..99526e70 100644 --- a/website/models/project.py +++ b/website/models/project.py @@ -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." @@ -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: @@ -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""" diff --git a/website/templates/website/project_listing.html b/website/templates/website/project_listing.html index 9d13299c..ed78c6ae 100644 --- a/website/templates/website/project_listing.html +++ b/website/templates/website/project_listing.html @@ -150,10 +150,9 @@

+ {# 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 %}
@@ -175,10 +174,9 @@

+ {# 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 %}
diff --git a/website/tests/base.py b/website/tests/base.py index dd88604e..99af5e72 100644 --- a/website/tests/base.py +++ b/website/tests/base.py @@ -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) diff --git a/website/tests/test_project_visibility.py b/website/tests/test_project_visibility.py new file mode 100644 index 00000000..aa112fd9 --- /dev/null +++ b/website/tests/test_project_visibility.py @@ -0,0 +1,193 @@ +""" +Tests for the Project ``is_visible`` flag (issue #1300). + +Visibility used to be an implicit "has a thumbnail AND a publication" heuristic +duplicated across views and templates. It's now a single editor-controlled flag: +new projects start private, and a one-shot backfill preserves the visibility of +projects that predate the column. These tests pin: + + * new projects default to private (set in Project.save()); + * can_show_online() reflects is_visible; + * the backfill resolves only legacy NULL rows and is idempotent; + * the public gallery / landing page / individual page honor the flag, while + logged-in staff can still preview a private project page. +""" + +from datetime import date + +from django.contrib.auth.models import User +from django.core.management import call_command +from django.urls import reverse + +from website.models import Project +from website.tests.base import DatabaseTestCase + + +def _set_legacy_null(project): + """ + Force a project's is_visible back to NULL to simulate a row that predates + the column. Project.save() defaults new projects to False, so we write the + NULL directly via the queryset (which bypasses save()). + """ + Project.objects.filter(pk=project.pk).update(is_visible=None) + project.refresh_from_db() + + +class ProjectVisibilityDefaultTests(DatabaseTestCase): + """New projects are private by default; the default is set in save().""" + + def test_new_project_defaults_to_private(self): + project = self.make_project(name="Fresh Project") + self.assertFalse(project.is_visible) + + def test_explicit_visible_is_respected(self): + project = self.make_project(name="Public Project", is_visible=True) + self.assertTrue(project.is_visible) + + def test_save_does_not_override_existing_value(self): + """Re-saving a project must not reset a manually-set visibility.""" + project = self.make_project(name="Toggled", is_visible=True) + project.is_visible = False + project.save() + project.refresh_from_db() + self.assertFalse(project.is_visible) + + def test_can_show_online_reflects_flag(self): + visible = self.make_project(name="Shown", is_visible=True) + hidden = self.make_project(name="Hidden", is_visible=False) + self.assertTrue(visible.can_show_online()) + self.assertFalse(hidden.can_show_online()) + + def test_can_show_online_treats_null_as_private(self): + project = self.make_project(name="Legacy") + _set_legacy_null(project) + self.assertFalse(project.can_show_online()) + + +class BackfillProjectVisibilityTests(DatabaseTestCase): + """ + backfill_project_visibility resolves legacy NULL rows using the old + thumbnail+publication criteria and leaves already-decided rows alone. + """ + + def _add_publication(self, project): + pub = self.make_publication(title=f"Pub for {project.name}") + pub.projects.add(project) + return pub + + def test_null_with_thumbnail_and_pub_becomes_visible(self): + project = self.make_project(name="Complete Legacy", with_thumbnail=True) + self._add_publication(project) + _set_legacy_null(project) + + call_command("backfill_project_visibility") + project.refresh_from_db() + self.assertTrue(project.is_visible) + + def test_null_missing_thumbnail_becomes_private(self): + project = self.make_project(name="No Thumb Legacy") + self._add_publication(project) + _set_legacy_null(project) + + call_command("backfill_project_visibility") + project.refresh_from_db() + self.assertFalse(project.is_visible) + + def test_null_missing_publication_becomes_private(self): + project = self.make_project(name="No Pub Legacy", with_thumbnail=True) + _set_legacy_null(project) + + call_command("backfill_project_visibility") + project.refresh_from_db() + self.assertFalse(project.is_visible) + + def test_already_set_values_are_not_clobbered(self): + """ + A complete project an admin deliberately hid (is_visible=False) must + stay hidden across a backfill re-run — the command only touches NULLs. + """ + project = self.make_project( + name="Admin Hidden", with_thumbnail=True, is_visible=False + ) + self._add_publication(project) + + call_command("backfill_project_visibility") + project.refresh_from_db() + self.assertFalse(project.is_visible) + + def test_dry_run_makes_no_changes(self): + project = self.make_project(name="Dry Legacy", with_thumbnail=True) + self._add_publication(project) + _set_legacy_null(project) + + call_command("backfill_project_visibility", "--dry-run") + project.refresh_from_db() + self.assertIsNone(project.is_visible) + + +class ProjectListingVisibilityTests(DatabaseTestCase): + """The public project gallery shows only is_visible=True projects.""" + + def _make_listed_project(self, name, is_visible, end_date=None): + project = self.make_project( + name=name, with_thumbnail=True, is_visible=is_visible, + start_date=date(2020, 1, 1), end_date=end_date, + ) + pub = self.make_publication(title=f"Pub for {name}") + pub.projects.add(project) + return project + + def test_visible_active_project_appears(self): + self._make_listed_project("Visible Active", is_visible=True) + response = self.client.get(reverse("website:projects")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Visible Active") + + def test_private_project_hidden_even_with_thumbnail_and_pub(self): + self._make_listed_project("Secret Active", is_visible=False) + response = self.client.get(reverse("website:projects")) + self.assertNotContains(response, "Secret Active") + + def test_visible_completed_project_appears(self): + self._make_listed_project( + "Visible Done", is_visible=True, end_date=date(2021, 1, 1) + ) + response = self.client.get(reverse("website:projects")) + self.assertContains(response, "Visible Done") + + +class IndividualProjectPageVisibilityTests(DatabaseTestCase): + """ + A private project's page 404s for the public but is previewable by staff. + """ + + def _make_project(self, is_visible): + return self.make_project( + name="Stealth Project", short_name="stealthproject", + is_visible=is_visible, start_date=date(2020, 1, 1), + ) + + def test_private_project_404_for_anonymous(self): + project = self._make_project(is_visible=False) + response = self.client.get( + reverse("website:project", kwargs={"project_name": project.short_name}) + ) + self.assertEqual(response.status_code, 404) + + def test_private_project_visible_to_staff(self): + project = self._make_project(is_visible=False) + staff = User.objects.create_user( + username="admin", password="pw", is_staff=True + ) + self.client.force_login(staff) + response = self.client.get( + reverse("website:project", kwargs={"project_name": project.short_name}) + ) + self.assertEqual(response.status_code, 200) + + def test_visible_project_200_for_anonymous(self): + project = self._make_project(is_visible=True) + response = self.client.get( + reverse("website:project", kwargs={"project_name": project.short_name}) + ) + self.assertEqual(response.status_code, 200) diff --git a/website/views/index.py b/website/views/index.py index 6695007e..b38a5884 100644 --- a/website/views/index.py +++ b/website/views/index.py @@ -2,7 +2,7 @@ from website.models import Banner, Publication, Talk, Video, Project, Person, News, Sponsor import website.utils.ml_utils as ml_utils from django.shortcuts import render # for render https://docs.djangoproject.com/en/4.0/topics/http/shortcuts/#render -from django.db.models import OuterRef, Subquery +from django.db.models import OuterRef, Subquery, F from django.db.models import Sum # for summing grant funding amounts from django.db.models.functions import Coalesce # for replacing None with 0 when summing grant funding amounts @@ -51,12 +51,13 @@ def index(request): # Get the most recent publication date for each project latest_publication_dates = Publication.objects.filter(projects=OuterRef('pk')).order_by('-date') - # Get all projects that have at least one publication, a gallery image, and - # are active (i.e., have no end date) - # ordered by most recent pub date and limited to MAX_NUM_PROJECTS - active_projects = (Project.objects.filter(publication__isnull=False, gallery_image__isnull=False, end_date__isnull=True) + # Get all visible, active projects (i.e., marked is_visible and with no end + # date), ordered by most recent pub date and limited to MAX_NUM_PROJECTS. + # Visibility is governed solely by the is_visible flag (#1300). nulls_last + # keeps a visible project that has no publication yet from sorting to the top. + active_projects = (Project.objects.filter(is_visible=True, end_date__isnull=True) .annotate(most_recent_publication=Subquery(latest_publication_dates.values('date')[:1])) - .order_by('-most_recent_publication', 'id').distinct())[:MAX_NUM_PROJECTS] + .order_by(F('most_recent_publication').desc(nulls_last=True), 'id').distinct())[:MAX_NUM_PROJECTS] # Get all sponsors, annotate each with the sum of their grants' funding_amount # In this code, Coalesce(Sum('grant__funding_amount'), 0) calculates the sum of the diff --git a/website/views/member.py b/website/views/member.py index 6f79b5ee..8cec6f74 100644 --- a/website/views/member.py +++ b/website/views/member.py @@ -94,14 +94,9 @@ def member(request, member_name=None, member_id=None): if not person.bio: auto_generated_bio = auto_generate_bio(person) - # filter projects to those that have a thumbnail and have been published - # TODO: might consider moving this to ml_utils so we have consistent determination - # of what projects to show publicly - filtered_projects = list() - for proj in projects: - if proj.gallery_image is not None and proj.has_publication(): - filtered_projects.append(proj) - projects = filtered_projects + # Show only projects marked visible (#1300). Visibility is governed solely + # by the is_visible flag, replacing the old thumbnail+publication heuristic. + projects = [proj for proj in projects if proj.is_visible] context = {'person': person, 'auto_generated_bio': auto_generated_bio, diff --git a/website/views/project.py b/website/views/project.py index d99014b7..c1443cf0 100644 --- a/website/views/project.py +++ b/website/views/project.py @@ -6,7 +6,7 @@ from django.shortcuts import render, get_object_or_404, redirect from operator import attrgetter from django.template.loader import render_to_string -from django.http import HttpResponse +from django.http import HttpResponse, Http404 from django.db.models import Q, F @@ -33,6 +33,13 @@ def project(request, project_name): _logger.debug(f"Starting views/project {project_name} at {func_start_time:0.4f}") project = get_object_or_404(Project, short_name__iexact=project_name) + + # Private projects (is_visible False/None) are hidden from the public but + # remain previewable by logged-in staff so they can build a project before + # going live (#1300). + if not project.is_visible and not request.user.is_staff: + raise Http404("Project is not available") + all_banners = project.banner_set.all() displayed_banners = ml_utils.choose_banners(all_banners) diff --git a/website/views/project_listing.py b/website/views/project_listing.py index 4cf5af23..b70298f2 100644 --- a/website/views/project_listing.py +++ b/website/views/project_listing.py @@ -2,7 +2,7 @@ from django.utils import timezone # for timezone-aware date operations from website.models import Project, ProjectUmbrella, Publication from django.db.models import Count, Q # see https://docs.djangoproject.com/en/4.2/topics/db/aggregation/ -from django.db.models import OuterRef, Subquery +from django.db.models import OuterRef, Subquery, F from django.shortcuts import render # for render https://docs.djangoproject.com/en/4.0/topics/http/shortcuts/#render # For logging @@ -22,22 +22,21 @@ def project_listing(request): # Get the most recent publication date for each project latest_publication_dates = Publication.objects.filter(projects=OuterRef('pk')).order_by('-date') - # Get all projects that have at least one publication, a gallery image, and - # are active (i.e., have no end date OR have an end date today or in the future) - # ordered by most recent pub date - active_projects = (Project.objects.filter( - publication__isnull=False, - gallery_image__isnull=False) + # Get all visible, active projects (i.e., marked is_visible and with no end + # date OR an end date today or in the future), ordered by most recent pub + # date. Visibility is governed solely by the is_visible flag (#1300). + # nulls_last keeps a visible project that has no publication yet from + # sorting to the top. + active_projects = (Project.objects.filter(is_visible=True) .filter(Q(end_date__isnull=True) | Q(end_date__gt=today)) .annotate(most_recent_publication=Subquery(latest_publication_dates.values('date')[:1])) - .order_by('-most_recent_publication', 'id').distinct()) - - # Get completed projects that have at least one publication, a gallery image, - # and have an end date that is before today + .order_by(F('most_recent_publication').desc(nulls_last=True), 'id').distinct()) + + # Get visible, completed projects (an end date before today), # ordered by most recent pub date - completed_projects = (Project.objects.filter(publication__isnull=False, gallery_image__isnull=False, end_date__isnull=False, end_date__lte=today) + completed_projects = (Project.objects.filter(is_visible=True, end_date__isnull=False, end_date__lte=today) .annotate(most_recent_publication=Subquery(latest_publication_dates.values('date')[:1])) - .order_by('-most_recent_publication', 'id').distinct()) + .order_by(F('most_recent_publication').desc(nulls_last=True), 'id').distinct()) # Now get all project umbrellas for interactive project filtering map_project_umbrella_to_projects = {}