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
10 changes: 10 additions & 0 deletions website/models/award.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down
7 changes: 5 additions & 2 deletions website/models/person.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 3 additions & 6 deletions website/templates/snippets/display_award_snippet.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,11 @@
<a href="{% url 'website:member_by_name' person.get_url_name %}">{{ person.get_full_name }}</a>{% 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 %}
<a href="{% url 'website:project' project.short_name %}">{{ project.name }}</a>{% if not forloop.last %}, {% endif %}
{% else %}
{{ project.name }}{% if not forloop.last %}, {% endif %}
{% endif %}
{% endfor %}
</p>

Expand Down
106 changes: 106 additions & 0 deletions website/tests/test_project_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
7 changes: 6 additions & 1 deletion website/views/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions website/views/news_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])


Expand Down
6 changes: 3 additions & 3 deletions website/views/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
9 changes: 6 additions & 3 deletions website/views/project_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading