diff --git a/website/models/award.py b/website/models/award.py index 44efbb8d..cc3c7d50 100644 --- a/website/models/award.py +++ b/website/models/award.py @@ -70,6 +70,16 @@ def get_project_names(self): get_project_names.short_description = "Projects" + def get_visible_projects(self): + """ + Returns the honored projects that are publicly visible (#1300). + + Used by the public award snippet so a private project is not mentioned + on the Awards page. The admin-facing get_project_names() intentionally + still lists all projects. + """ + return self.projects.filter(is_visible=True) + def get_honorees(self): """Returns a combined, human-readable list of recipients and projects.""" parts = [self.get_recipient_names(), self.get_project_names()] diff --git a/website/models/person.py b/website/models/person.py index 9671d330..4663a8d9 100644 --- a/website/models/person.py +++ b/website/models/person.py @@ -598,9 +598,12 @@ def get_projects_sorted_by_contrib(self, filter_out_projs_with_zero_pubs=True): """ Project = apps.get_model('website', 'Project') - # Start with projects where this person has a role + # Start with publicly-visible projects where this person has a role. + # This feeds the public People page, so private projects (#1300) are + # excluded. projects_qs = Project.objects.filter( - projectrole__person=self + projectrole__person=self, + is_visible=True ).annotate( # Count publications by this person on each project pub_count=Count( diff --git a/website/templates/snippets/display_award_snippet.html b/website/templates/snippets/display_award_snippet.html index 6cdc719e..0b5a863b 100644 --- a/website/templates/snippets/display_award_snippet.html +++ b/website/templates/snippets/display_award_snippet.html @@ -21,14 +21,11 @@ {{ person.get_full_name }}{% if not forloop.last %}, {% endif %} {% endfor %} - {% if award.recipients.exists and award.projects.exists %}, {% endif %} + {% if award.recipients.exists and award.get_visible_projects.exists %}, {% endif %} - {% for project in award.projects.all %} - {% if project.can_show_online %} + {# Only list publicly-visible projects so private projects aren't named here (#1300) #} + {% for project in award.get_visible_projects %} {{ project.name }}{% if not forloop.last %}, {% endif %} - {% else %} - {{ project.name }}{% if not forloop.last %}, {% endif %} - {% endif %} {% endfor %}

diff --git a/website/tests/test_project_visibility.py b/website/tests/test_project_visibility.py index aa112fd9..77d0d3c8 100644 --- a/website/tests/test_project_visibility.py +++ b/website/tests/test_project_visibility.py @@ -20,6 +20,7 @@ from django.urls import reverse from website.models import Project +from website.models.project_role import ProjectRole from website.tests.base import DatabaseTestCase @@ -191,3 +192,108 @@ def test_visible_project_200_for_anonymous(self): reverse("website:project", kwargs={"project_name": project.short_name}) ) self.assertEqual(response.status_code, 200) + + +# --- Secondary surfaces: nothing should mention a private project -------- + + +class AwardVisibleProjectsTests(DatabaseTestCase): + """Award.get_visible_projects (used by the public awards snippet) excludes private projects.""" + + def test_only_visible_projects_returned(self): + from website.models import Award + visible = self.make_project(name="Award Visible", is_visible=True) + private = self.make_project(name="Award Private", is_visible=False) + award = Award.objects.create(title="Best Paper", date=date(2024, 1, 1)) + award.projects.add(visible, private) + + names = {p.name for p in award.get_visible_projects()} + self.assertEqual(names, {"Award Visible"}) + + +class PersonProjectsContribVisibilityTests(DatabaseTestCase): + """get_projects_sorted_by_contrib (public People page) excludes private projects.""" + + def _link(self, person, project): + ProjectRole.objects.create( + project=project, person=person, start_date=date(2024, 1, 1) + ) + pub = self.make_publication(title=f"Pub {project.name}") + pub.authors.add(person) + pub.projects.add(project) + + def test_private_project_excluded(self): + person = self.make_person(first_name="Grace", last_name="Hopper") + visible = self.make_project(name="Contrib Visible", is_visible=True) + private = self.make_project(name="Contrib Private", is_visible=False) + self._link(person, visible) + self._link(person, private) + + names = {p.name for p in person.get_projects_sorted_by_contrib()} + self.assertEqual(names, {"Contrib Visible"}) + + +class LandingBannerVisibilityTests(DatabaseTestCase): + """get_landing_page_banners drops banners tied to a private project.""" + + def test_private_project_banner_excluded_but_projectless_kept(self): + from website.models import Banner + from website.views.index import get_landing_page_banners + + private = self.make_project(name="Banner Private", is_visible=False) + visible = self.make_project(name="Banner Visible", is_visible=True) + private_banner = Banner.objects.create( + title="Private Banner", landing_page=True, favorite=True, project=private + ) + visible_banner = Banner.objects.create( + title="Visible Banner", landing_page=True, favorite=True, project=visible + ) + projectless_banner = Banner.objects.create( + title="Projectless Banner", landing_page=True, favorite=True + ) + + returned = set(get_landing_page_banners(10)) + self.assertIn(visible_banner, returned) + self.assertIn(projectless_banner, returned) + self.assertNotIn(private_banner, returned) + + +class ProjectListingUmbrellaFilterVisibilityTests(DatabaseTestCase): + """The umbrella filter counts/names only publicly-visible projects.""" + + def test_private_project_excluded_from_umbrella_map(self): + from website.models import ProjectUmbrella + umbrella = ProjectUmbrella.objects.create( + name="Accessibility", short_name="a11y" + ) + for name, vis in [("U Visible", True), ("U Private", False)]: + project = self.make_project( + name=name, with_thumbnail=True, is_visible=vis, + start_date=date(2020, 1, 1), + ) + project.project_umbrellas.add(umbrella) + pub = self.make_publication(title=f"Pub {name}") + pub.projects.add(project) + + response = self.client.get(reverse("website:projects")) + umbrella_map = response.context["map_project_umbrella_to_projects"] + self.assertEqual(umbrella_map.get("a11y"), ["U Visible"]) + + +class NewsItemRelatedProjectsVisibilityTests(DatabaseTestCase): + """The news item page lists only publicly-visible related projects.""" + + def test_private_related_project_hidden(self): + visible = self.make_project(name="News Visible Proj", is_visible=True, + start_date=date(2020, 1, 1)) + private = self.make_project(name="News Private Proj", is_visible=False, + start_date=date(2020, 1, 1)) + news = self.make_news_item(title="A Discovery") + news.project.add(visible, private) + + response = self.client.get( + reverse("website:news_item_by_id", kwargs={"id": news.id}) + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "News Visible Proj") + self.assertNotContains(response, "News Private Proj") diff --git a/website/views/index.py b/website/views/index.py index b38a5884..d27710ed 100644 --- a/website/views/index.py +++ b/website/views/index.py @@ -95,12 +95,17 @@ def index(request): def get_landing_page_banners(max_num_banners=5): # Get favorite banners that should appear on the landing page. Order by recency. # The "?" allows us to randomize the order of banners added on the same day - fav_banners = list(Banner.objects.filter(favorite=True, landing_page=True).order_by('-date_added')) + # Exclude banners tied to a private project (#1300) so a hidden project + # isn't named (with a now-404 link) in the landing carousel. Banners with + # no project, or a visible project, are unaffected. + fav_banners = list(Banner.objects.filter(favorite=True, landing_page=True) + .exclude(project__is_visible=False).order_by('-date_added')) random.shuffle(fav_banners) banners = fav_banners[:max_num_banners] if len(banners) < max_num_banners: other_banners = list(Banner.objects.filter(landing_page=True) + .exclude(project__is_visible=False) .exclude(id__in=[b.id for b in banners]) # exclude banners in original list .order_by('-date_added', '?')[:max_num_banners-len(banners)]) random.shuffle(other_banners) diff --git a/website/views/news_item.py b/website/views/news_item.py index 9fcb2e4f..2ac8db0e 100644 --- a/website/views/news_item.py +++ b/website/views/news_item.py @@ -40,11 +40,14 @@ def news_item(request, slug=None, id=None): excluded_ids = list(recent_ml_news.values_list('id', flat=True)) - related_projects = cur_news_item.project.all() + # All projects tied to this news item drive the "related news" lookup below; + # only the publicly-visible ones are shown to the reader (#1300). + all_related_projects = cur_news_item.project.all() + related_projects = all_related_projects.filter(is_visible=True) recent_news_about_projects_mentioned = (News.objects .exclude(id=cur_news_item.id) # exclude the current news item .exclude(id__in=excluded_ids) # don't want to repeat - .filter(project__in=related_projects) + .filter(project__in=all_related_projects) .order_by('-date').distinct()[:MAX_RECENT_NEWS_ITEMS_BY_AUTHOR]) diff --git a/website/views/project.py b/website/views/project.py index c1443cf0..5a49962b 100644 --- a/website/views/project.py +++ b/website/views/project.py @@ -78,9 +78,9 @@ def project(request, project_name): # Get all candidates first related_project_candidates = project.get_related_projects_by_umbrella(match_all_umbrellas=True) - # Filter using Python list comprehension to ensure the attribute exists and is not empty - # This matches the logic used in your template: {% if related_project.gallery_image %} - related_projects = [p for p in related_project_candidates if p.gallery_image][:5] + # Only surface related projects that are publicly visible (#1300) and have a + # thumbnail (the related-project cards render gallery_image). + related_projects = [p for p in related_project_candidates if p.is_visible and p.gallery_image][:5] # related_projects_by_pub = project.get_related_projects_by_pub() # _logger.debug(f"Related projects by publication: {related_projects_by_pub}") diff --git a/website/views/project_listing.py b/website/views/project_listing.py index b70298f2..37167b72 100644 --- a/website/views/project_listing.py +++ b/website/views/project_listing.py @@ -41,13 +41,16 @@ def project_listing(request): # Now get all project umbrellas for interactive project filtering map_project_umbrella_to_projects = {} + # Only count/list publicly-visible projects so private projects (#1300) + # don't inflate the filter counts or leak their names into the page. project_umbrellas_with_projects = (ProjectUmbrella.objects.annotate( - num_projects=Count('project')).filter(num_projects__gt=0)) # Get all project umbrellas with at least one project + num_projects=Count('project', filter=Q(project__is_visible=True))) + .filter(num_projects__gt=0)) # Get all project umbrellas with at least one visible project # Iterate over the queryset for project_umbrella in project_umbrellas_with_projects: - # Get the list of associated Project instances - projects = project_umbrella.project_set.all() + # Get the list of associated, publicly-visible Project instances + projects = project_umbrella.project_set.filter(is_visible=True) map_project_umbrella_to_projects[project_umbrella.short_name] = [project.name for project in projects] # Sort the dictionary by project count