Skip to content
Open
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
47 changes: 47 additions & 0 deletions admin_site/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
{
Expand Down
8 changes: 7 additions & 1 deletion admin_site/wagtail_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down
18 changes: 18 additions & 0 deletions nodes/migrations/0050_instanceconfig_is_hidden.py
Original file line number Diff line number Diff line change
@@ -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.'),
),
]
4 changes: 4 additions & 0 deletions nodes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading