diff --git a/admin_site/tests.py b/admin_site/tests.py index 3a699893..eb844a55 100644 --- a/admin_site/tests.py +++ b/admin_site/tests.py @@ -8,10 +8,12 @@ from social_core.backends.base import BaseAuth from paths.const import INSTANCE_SUPER_ADMIN_ROLE +from paths.context import RealmContext, realm_context from admin_site.api import check_user_in_other_clusters from admin_site.auth_backends import NZCPortalOAuth2 from admin_site.auth_pipeline import assign_roles +from admin_site.wagtail_hooks import instance_chooser from frameworks.models import FrameworkConfig from frameworks.tests.factories import FrameworkFactory from nodes.tests.factories import InstanceConfigFactory @@ -100,6 +102,51 @@ def post(*_args: object, **_kwargs: object) -> Never: assert check_user_in_other_clusters('user@example.com', request) is None +def _chooser_labels(user, realm, rf) -> set[str]: + request = rf.get('/admin/') + request.user = user + ctx = RealmContext(realm=realm, user=user) + with realm_context.activate(ctx): + items = instance_chooser.menu_items_for_request(request) + return {item.label for item in items} + + +def test_instance_chooser_omits_hidden_instances(rf) -> None: + admin = UserFactory.create(is_staff=True, is_superuser=True) + visible_a = InstanceConfigFactory.create(identifier='visible-a', name='Visible A') + InstanceConfigFactory.create(identifier='visible-b', name='Visible B') + InstanceConfigFactory.create(identifier='hidden-one', name='Hidden One', is_hidden=True) + + labels = _chooser_labels(admin, visible_a, rf) + + assert 'Visible A' in labels + assert 'Visible B' in labels + assert 'Hidden One' not in labels + + +def test_instance_chooser_keeps_active_hidden_instance(rf) -> None: + # A user currently on a hidden instance must still see it (and be able to + # switch away), so the active realm is exempt from the filter. + admin = UserFactory.create(is_staff=True, is_superuser=True) + InstanceConfigFactory.create(identifier='visible', name='Visible') + hidden = InstanceConfigFactory.create(identifier='hidden', name='Hidden', is_hidden=True) + + labels = _chooser_labels(admin, hidden, rf) + + assert 'Hidden' in labels + assert 'Visible' in labels + + +def test_hidden_instance_still_reachable() -> None: + # The hiding is listing-only: it does not touch get_adminable_instances(), + # which is the authorization gate for directly switching to an instance. + admin = UserFactory.create(is_staff=True, is_superuser=True) + hidden = InstanceConfigFactory.create(identifier='hidden', name='Hidden', is_hidden=True) + + assert admin.user_is_admin_for_instance(hidden) + assert hidden in admin.get_adminable_instances() + + def test_nzcportal_city_admin_maps_to_instance_super_admin() -> None: details = NZCPortalOAuth2(strategy=None)._get_user_details( { diff --git a/admin_site/wagtail_hooks.py b/admin_site/wagtail_hooks.py index a006ff0a..4b2d7247 100644 --- a/admin_site/wagtail_hooks.py +++ b/admin_site/wagtail_hooks.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from django.db.models import Q from django.templatetags.static import static from django.urls import reverse from django.utils.html import format_html @@ -60,7 +61,12 @@ class InstanceItem(MenuItem): class InstanceChooserMenu(Menu): def menu_items_for_request(self, request: HttpRequest): user = user_or_bust(request.user) - instances = user.get_adminable_instances() + # Hide instances flagged as ``is_hidden`` from the chooser, but keep the + # currently-active one visible so a user on a hidden instance can switch + # away. This filters the listing only; ``get_adminable_instances()`` (the + # authorization gate for direct switching) is deliberately left untouched. + current = realm_context.get().realm + instances = user.get_adminable_instances().filter(Q(is_hidden=False) | Q(pk=current.pk if current is not None else None)) if len(instances) < 2: return [] items = [] diff --git a/nodes/migrations/0050_instanceconfig_is_hidden.py b/nodes/migrations/0050_instanceconfig_is_hidden.py new file mode 100644 index 00000000..3ebae493 --- /dev/null +++ b/nodes/migrations/0050_instanceconfig_is_hidden.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-06-18 11:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nodes', '0049_instanceconfig_copy_of_nodeconfig_copy_of'), + ] + + operations = [ + migrations.AddField( + model_name='instanceconfig', + name='is_hidden', + field=models.BooleanField(default=False, help_text='Hide this instance from the admin instance chooser. It remains reachable directly and via permissions.'), + ), + ] diff --git a/nodes/models.py b/nodes/models.py index 1b4ace2b..3676a19e 100644 --- a/nodes/models.py +++ b/nodes/models.py @@ -472,6 +472,10 @@ class InstanceConfig( default=False, help_text=_('Whether end-user mutation surfaces should treat this instance as read-only.'), ) + is_hidden = models.BooleanField( + default=False, + help_text=_('Hide this instance from the admin instance chooser. It remains reachable directly and via permissions.'), + ) created_at = models.DateTimeField(default=timezone.now) modified_at = models.DateTimeField(auto_now=True)