diff --git a/mittab/apps/tab/migrations/0037_debater_ranking_public.py b/mittab/apps/tab/migrations/0037_debater_ranking_public.py new file mode 100644 index 00000000..a6cc6264 --- /dev/null +++ b/mittab/apps/tab/migrations/0037_debater_ranking_public.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2026-03-27 18:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tab', '0036_alter_outround_room_nullable'), + ] + + operations = [ + migrations.AddField( + model_name='debater', + name='ranking_public', + field=models.BooleanField(default=True), + ), + ] diff --git a/mittab/apps/tab/models.py b/mittab/apps/tab/models.py index 70b8be00..56bb22bd 100644 --- a/mittab/apps/tab/models.py +++ b/mittab/apps/tab/models.py @@ -151,6 +151,7 @@ class Debater(models.Model): novice_status = models.IntegerField(choices=NOVICE_CHOICES) tiebreaker = models.IntegerField(unique=True, null=True, blank=True) apda_id = models.IntegerField(blank=True, null=True, default=-1) + ranking_public = models.BooleanField(default=True) def save(self, force_insert=False, diff --git a/mittab/apps/tab/views/public_views.py b/mittab/apps/tab/views/public_views.py index eb0f195c..51e8e755 100644 --- a/mittab/apps/tab/views/public_views.py +++ b/mittab/apps/tab/views/public_views.py @@ -187,9 +187,12 @@ def public_speaker_rankings(request): speaker_lists = { "varsity": [ entry for entry in varsity_speakers - if entry[0].novice_status == Debater.VARSITY + if entry[0].ranking_public + ], + "novice": [ + entry for entry in novice_speakers + if entry[0].ranking_public ], - "novice": novice_speakers, } rows = { slug: build_public_speaker_rows( diff --git a/mittab/libs/tests/views/test_public_views.py b/mittab/libs/tests/views/test_public_views.py index ef475009..70d039d6 100644 --- a/mittab/libs/tests/views/test_public_views.py +++ b/mittab/libs/tests/views/test_public_views.py @@ -9,7 +9,7 @@ from django.contrib.auth import get_user_model from nplusone.core import profiler -from mittab.apps.tab.models import (Judge, Room, TabSettings, Team, +from mittab.apps.tab.models import (Debater, Judge, Room, TabSettings, Team, Round, Outround, RoundStats) from mittab.apps.tab.public_rankings import ( get_ballot_round_settings, @@ -718,6 +718,114 @@ def test_public_display_flags_follow_setting_changes(self): self.assertTrue(flags["ballots"]) + def test_debater_ranking_public_false_excluded_from_varsity_speakers(self): + """A debater with ranking_public=False should not appear in varsity speaker rankings.""" + client = Client() + Debater.objects.update(ranking_public=True) + set_ranking_settings("varsity", True, include_speaks=True, max_visible=1000) + caches["public"].clear() + cache_logic.clear_cache() + + debater = Debater.objects.first() + self.assertIsNotNone(debater, "Expected at least one debater in the fixture") + + # Confirm the debater appears when ranking_public=True + response = client.get(reverse("public_speaker_rankings")) + self.assertEqual(response.status_code, 200) + self.assertIn(debater.name, response.content.decode(), + "Debater should appear in rankings when ranking_public=True; " + "ensure this debater has round stats in the fixture") + + # Now opt the debater out and verify they are hidden + debater.ranking_public = False + debater.save() + caches["public"].clear() + cache_logic.clear_cache() + + response = client.get(reverse("public_speaker_rankings")) + self.assertEqual(response.status_code, 200) + self.assertNotIn(debater.name, response.content.decode()) + + def test_novice_debater_ranking_public_false_excluded_from_novice_speakers(self): + """A novice debater with ranking_public=False should not appear in any section.""" + client = Client() + Debater.objects.update(ranking_public=True) + set_ranking_settings("varsity", True, include_speaks=True, max_visible=1000) + set_ranking_settings("novice", True, include_speaks=True, max_visible=1000) + caches["public"].clear() + cache_logic.clear_cache() + + debater = Debater.objects.filter(novice_status=Debater.NOVICE).first() + self.assertIsNotNone(debater, "Expected at least one novice debater in the fixture") + + # Confirm the debater appears when ranking_public=True + response = client.get(reverse("public_speaker_rankings")) + self.assertEqual(response.status_code, 200) + self.assertIn(debater.name, response.content.decode(), + "Novice debater should appear in rankings when ranking_public=True; " + "ensure this debater has round stats in the fixture") + + # Now opt the debater out and verify they are hidden from both sections + debater.ranking_public = False + debater.save() + caches["public"].clear() + cache_logic.clear_cache() + + response = client.get(reverse("public_speaker_rankings")) + self.assertEqual(response.status_code, 200) + self.assertNotIn(debater.name, response.content.decode()) + + def test_novice_debater_ranking_public_true_appears_in_varsity_and_novice(self): + """A novice debater with ranking_public=True should appear in both varsity and novice sections.""" + client = Client() + Debater.objects.update(ranking_public=True) + set_ranking_settings("varsity", True, include_speaks=True, max_visible=1000) + set_ranking_settings("novice", True, include_speaks=True, max_visible=1000) + caches["public"].clear() + cache_logic.clear_cache() + + debater = Debater.objects.filter(novice_status=Debater.NOVICE).first() + self.assertIsNotNone(debater, "Expected at least one novice debater in the fixture") + + response = client.get(reverse("public_speaker_rankings")) + self.assertEqual(response.status_code, 200) + content = response.content.decode() + self.assertIn("Varsity Speakers", content) + self.assertIn("Novice Speakers", content) + + # Verify the debater's name appears in both the varsity and novice sections + varsity_start = content.index("Varsity Speakers") + novice_start = content.index("Novice Speakers") + varsity_section = content[varsity_start:novice_start] + novice_section = content[novice_start:] + self.assertIn(debater.name, varsity_section, + "Novice debater should appear in the varsity (all-speakers) section") + self.assertIn(debater.name, novice_section, + "Novice debater should appear in the novice section") + + def test_varsity_debater_does_not_appear_in_novice_speakers(self): + """A varsity-only debater should not appear in the novice speaker rankings section.""" + client = Client() + Debater.objects.update(ranking_public=True) + set_ranking_settings("varsity", True, include_speaks=True, max_visible=1000) + set_ranking_settings("novice", True, include_speaks=True, max_visible=1000) + caches["public"].clear() + cache_logic.clear_cache() + + varsity_debater = Debater.objects.filter(novice_status=Debater.VARSITY).first() + self.assertIsNotNone(varsity_debater, "Expected at least one varsity debater in the fixture") + + response = client.get(reverse("public_speaker_rankings")) + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + self.assertIn("Novice Speakers", content, + "Novice section should be present in the response") + novice_start = content.index("Novice Speakers") + novice_section = content[novice_start:] + self.assertNotIn(varsity_debater.name, novice_section, + "Varsity debater should not appear in the novice section") + def test_n_plus_one(self): client = Client()