netbox-sqlquery 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ from rest_framework.viewsets import ModelViewSet
2
+
3
+ from netbox_sqlquery.models import SavedQuery
4
+
5
+ from .serializers import SavedQuerySerializer
6
+
7
+
8
+ class SavedQueryViewSet(ModelViewSet):
9
+ serializer_class = SavedQuerySerializer
10
+
11
+ def get_queryset(self):
12
+ return SavedQuery.visible_to(self.request.user)
13
+
14
+ def perform_create(self, serializer):
15
+ serializer.save(owner=self.request.user)
@@ -0,0 +1,12 @@
1
+ import django_filters
2
+
3
+ from .models import SavedQuery
4
+
5
+
6
+ class SavedQueryFilterSet(django_filters.FilterSet):
7
+ name = django_filters.CharFilter(lookup_expr="icontains")
8
+ visibility = django_filters.ChoiceFilter(choices=SavedQuery.VISIBILITY_CHOICES)
9
+
10
+ class Meta:
11
+ model = SavedQuery
12
+ fields = ["name", "visibility"]
@@ -0,0 +1,20 @@
1
+ from django import forms
2
+
3
+ from .models import SavedQuery
4
+
5
+
6
+ class SavedQueryForm(forms.ModelForm):
7
+ class Meta:
8
+ model = SavedQuery
9
+ fields = ["name", "description", "sql", "visibility", "tags"]
10
+ widgets = {
11
+ "sql": forms.Textarea(attrs={"rows": 10}),
12
+ }
13
+
14
+
15
+ class SavedQueryFilterForm(forms.Form):
16
+ name = forms.CharField(required=False)
17
+ visibility = forms.ChoiceField(
18
+ choices=[("", "---------")] + SavedQuery.VISIBILITY_CHOICES,
19
+ required=False,
20
+ )
File without changes
File without changes
@@ -0,0 +1,39 @@
1
+ from django.core.management.base import BaseCommand
2
+
3
+ from netbox_sqlquery.abstract_schema import drop_views, ensure_views
4
+
5
+
6
+ class Command(BaseCommand):
7
+ help = "Create or replace abstract SQL views for the netbox-sqlquery plugin"
8
+
9
+ def add_arguments(self, parser):
10
+ parser.add_argument(
11
+ "--dry-run",
12
+ action="store_true",
13
+ help="Print the SQL without executing",
14
+ )
15
+ parser.add_argument(
16
+ "--drop",
17
+ action="store_true",
18
+ help="Drop all nb_* views instead of creating them",
19
+ )
20
+
21
+ def handle(self, *args, **options):
22
+ if options["drop"]:
23
+ dropped = drop_views()
24
+ for name in dropped:
25
+ self.stdout.write(f"Dropped {name}")
26
+ self.stdout.write(self.style.SUCCESS(f"Dropped {len(dropped)} views"))
27
+ return
28
+
29
+ results = ensure_views(dry_run=options["dry_run"])
30
+
31
+ for view_name, sql in results:
32
+ if options["dry_run"]:
33
+ self.stdout.write(f"\n-- {view_name}")
34
+ self.stdout.write(sql)
35
+ else:
36
+ self.stdout.write(f"Created {view_name}")
37
+
38
+ action = "Generated" if options["dry_run"] else "Created"
39
+ self.stdout.write(self.style.SUCCESS(f"{action} {len(results)} views"))
@@ -0,0 +1,95 @@
1
+ import django.db.models.deletion
2
+ from django.conf import settings
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ initial = True
9
+
10
+ dependencies = [
11
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+ ("auth", "0012_alter_user_first_name_max_length"),
13
+ ("extras", "0001_initial"),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name="SavedQuery",
19
+ fields=[
20
+ (
21
+ "id",
22
+ models.BigAutoField(
23
+ auto_created=True,
24
+ primary_key=True,
25
+ serialize=False,
26
+ verbose_name="ID",
27
+ ),
28
+ ),
29
+ ("name", models.CharField(max_length=100)),
30
+ ("description", models.CharField(blank=True, max_length=256)),
31
+ ("sql", models.TextField()),
32
+ (
33
+ "visibility",
34
+ models.CharField(
35
+ choices=[
36
+ ("private", "Private"),
37
+ ("global", "Global (read-only)"),
38
+ ("global_editable", "Global (editable by staff)"),
39
+ ],
40
+ default="private",
41
+ max_length=20,
42
+ ),
43
+ ),
44
+ ("created", models.DateTimeField(auto_now_add=True)),
45
+ ("last_run", models.DateTimeField(null=True, blank=True)),
46
+ ("run_count", models.PositiveIntegerField(default=0)),
47
+ (
48
+ "owner",
49
+ models.ForeignKey(
50
+ on_delete=django.db.models.deletion.CASCADE,
51
+ related_name="saved_queries",
52
+ to=settings.AUTH_USER_MODEL,
53
+ ),
54
+ ),
55
+ ("tags", models.ManyToManyField(blank=True, to="extras.tag")),
56
+ ],
57
+ options={
58
+ "ordering": ["name"],
59
+ "verbose_name_plural": "saved queries",
60
+ },
61
+ ),
62
+ migrations.CreateModel(
63
+ name="TablePermission",
64
+ fields=[
65
+ (
66
+ "id",
67
+ models.BigAutoField(
68
+ auto_created=True,
69
+ primary_key=True,
70
+ serialize=False,
71
+ verbose_name="ID",
72
+ ),
73
+ ),
74
+ ("pattern", models.CharField(max_length=100)),
75
+ (
76
+ "scope",
77
+ models.CharField(
78
+ choices=[
79
+ ("exact", "Exact table name"),
80
+ ("prefix", "Table prefix (e.g. dcim_)"),
81
+ ],
82
+ default="exact",
83
+ max_length=10,
84
+ ),
85
+ ),
86
+ ("require_staff", models.BooleanField(default=False)),
87
+ ("require_superuser", models.BooleanField(default=False)),
88
+ ("allow", models.BooleanField(default=True)),
89
+ ("groups", models.ManyToManyField(blank=True, to="auth.group")),
90
+ ],
91
+ options={
92
+ "ordering": ["-require_superuser", "-require_staff", "pattern"],
93
+ },
94
+ ),
95
+ ]
@@ -0,0 +1,21 @@
1
+ from django.db import migrations
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+
6
+ dependencies = [
7
+ ("netbox_sqlquery", "0001_initial"),
8
+ ]
9
+
10
+ operations = [
11
+ migrations.CreateModel(
12
+ name="QueryPermission",
13
+ fields=[],
14
+ options={
15
+ "managed": False,
16
+ "default_permissions": ("view", "change"),
17
+ "verbose_name": "SQL query permission",
18
+ "verbose_name_plural": "SQL query permissions",
19
+ },
20
+ ),
21
+ ]
@@ -0,0 +1,17 @@
1
+ from django.db import migrations, models
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+
6
+ dependencies = [
7
+ ("netbox_sqlquery", "0002_query_permissions"),
8
+ ("users", "0001_squashed_0011"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AlterField(
13
+ model_name="tablepermission",
14
+ name="groups",
15
+ field=models.ManyToManyField(blank=True, to="users.group"),
16
+ ),
17
+ ]
File without changes
@@ -0,0 +1,106 @@
1
+
2
+ from django.conf import settings
3
+ from django.core.validators import RegexValidator
4
+ from django.db import models
5
+ from utilities.querysets import RestrictedQuerySet
6
+
7
+ SAFE_NAME_VALIDATOR = RegexValidator(
8
+ regex=r'^[a-zA-Z0-9][a-zA-Z0-9 _\-\.]*$',
9
+ message=(
10
+ "Name must start with a letter or number and contain only"
11
+ " letters, numbers, spaces, hyphens, underscores, and periods."
12
+ ),
13
+ )
14
+
15
+
16
+ class SavedQuery(models.Model):
17
+ VISIBILITY_PRIVATE = "private"
18
+ VISIBILITY_GLOBAL = "global"
19
+ VISIBILITY_GLOBAL_EDITABLE = "global_editable"
20
+
21
+ VISIBILITY_CHOICES = [
22
+ (VISIBILITY_PRIVATE, "Private"),
23
+ (VISIBILITY_GLOBAL, "Global (read-only)"),
24
+ (VISIBILITY_GLOBAL_EDITABLE, "Global (editable by staff)"),
25
+ ]
26
+
27
+ name = models.CharField(max_length=100, validators=[SAFE_NAME_VALIDATOR])
28
+ description = models.CharField(max_length=256, blank=True)
29
+ sql = models.TextField()
30
+ owner = models.ForeignKey(
31
+ settings.AUTH_USER_MODEL,
32
+ on_delete=models.CASCADE,
33
+ related_name="saved_queries",
34
+ )
35
+ visibility = models.CharField(
36
+ max_length=20,
37
+ choices=VISIBILITY_CHOICES,
38
+ default=VISIBILITY_PRIVATE,
39
+ )
40
+ created = models.DateTimeField(auto_now_add=True)
41
+ last_run = models.DateTimeField(null=True, blank=True)
42
+ run_count = models.PositiveIntegerField(default=0)
43
+ tags = models.ManyToManyField("extras.Tag", blank=True)
44
+
45
+ objects = RestrictedQuerySet.as_manager()
46
+
47
+ class Meta:
48
+ ordering = ["name"]
49
+ verbose_name_plural = "saved queries"
50
+
51
+ def __str__(self):
52
+ return self.name
53
+
54
+ def get_absolute_url(self):
55
+ from django.urls import reverse
56
+ return reverse("plugins:netbox_sqlquery:savedquery", kwargs={"pk": self.pk})
57
+
58
+ @staticmethod
59
+ def visible_to(user):
60
+ return SavedQuery.objects.filter(
61
+ models.Q(owner=user) | ~models.Q(visibility=SavedQuery.VISIBILITY_PRIVATE)
62
+ )
63
+
64
+
65
+ class TablePermission(models.Model):
66
+ SCOPE_EXACT = "exact"
67
+ SCOPE_PREFIX = "prefix"
68
+
69
+ SCOPE_CHOICES = [
70
+ (SCOPE_EXACT, "Exact table name"),
71
+ (SCOPE_PREFIX, "Table prefix (e.g. dcim_)"),
72
+ ]
73
+
74
+ pattern = models.CharField(max_length=100)
75
+ scope = models.CharField(max_length=10, choices=SCOPE_CHOICES, default=SCOPE_EXACT)
76
+ groups = models.ManyToManyField("users.Group", blank=True)
77
+ require_staff = models.BooleanField(default=False)
78
+ require_superuser = models.BooleanField(default=False)
79
+ allow = models.BooleanField(default=True)
80
+
81
+ class Meta:
82
+ ordering = ["-require_superuser", "-require_staff", "pattern"]
83
+
84
+ def __str__(self):
85
+ action = "Allow" if self.allow else "Deny"
86
+ return f"{action} {self.get_scope_display()}: {self.pattern}"
87
+
88
+ def matches(self, table_name):
89
+ if self.scope == self.SCOPE_EXACT:
90
+ return self.pattern == table_name
91
+ return table_name.startswith(self.pattern)
92
+
93
+
94
+ class QueryPermission(models.Model):
95
+ """Proxy model to register plugin permissions with NetBox's ObjectPermission system.
96
+
97
+ Admins assign permissions on this object type:
98
+ - view: Can access the SQL query editor
99
+ - change: Can execute write queries (INSERT/UPDATE/DELETE)
100
+ """
101
+
102
+ class Meta:
103
+ managed = False
104
+ default_permissions = ("view", "change")
105
+ verbose_name = "SQL query permission"
106
+ verbose_name_plural = "SQL query permissions"
@@ -0,0 +1,44 @@
1
+ from netbox.plugins import PluginMenu, PluginMenuItem
2
+
3
+ QUERY_PERMS = ["netbox_sqlquery.view_querypermission"]
4
+
5
+
6
+ def get_menu():
7
+ """Return a top-level PluginMenu for the nav bar."""
8
+ return PluginMenu(
9
+ label="SQL Query",
10
+ groups=(
11
+ (
12
+ "Queries",
13
+ (
14
+ PluginMenuItem(
15
+ link="plugins:netbox_sqlquery:query",
16
+ link_text="SQL Console",
17
+ permissions=QUERY_PERMS,
18
+ ),
19
+ PluginMenuItem(
20
+ link="plugins:netbox_sqlquery:savedquery_list",
21
+ link_text="Saved Queries",
22
+ permissions=QUERY_PERMS,
23
+ ),
24
+ ),
25
+ ),
26
+ ),
27
+ icon_class="mdi mdi-database-search",
28
+ )
29
+
30
+
31
+ def get_menu_items():
32
+ """Return menu items for the shared Plugins dropdown."""
33
+ return (
34
+ PluginMenuItem(
35
+ link="plugins:netbox_sqlquery:query",
36
+ link_text="SQL Console",
37
+ permissions=QUERY_PERMS,
38
+ ),
39
+ PluginMenuItem(
40
+ link="plugins:netbox_sqlquery:savedquery_list",
41
+ link_text="Saved Queries",
42
+ permissions=QUERY_PERMS,
43
+ ),
44
+ )
@@ -0,0 +1,64 @@
1
+ from netbox.choices import ColorChoices
2
+ from users.preferences import UserPreference
3
+
4
+ COLOR_CHOICES = ColorChoices.CHOICES
5
+
6
+ preferences = {
7
+ "highlight_enabled": UserPreference(
8
+ label="SQL Query: Syntax highlighting",
9
+ choices=(
10
+ ("on", "On"),
11
+ ("off", "Off"),
12
+ ),
13
+ default="on",
14
+ description="Enable SQL syntax highlighting and auto-uppercase in the query editor.",
15
+ ),
16
+ "color_keyword": UserPreference(
17
+ label="SQL Query: Keyword color",
18
+ choices=COLOR_CHOICES,
19
+ default=ColorChoices.COLOR_BLUE,
20
+ description="Color for SQL keywords (SELECT, FROM, WHERE, etc.).",
21
+ ),
22
+ "color_function": UserPreference(
23
+ label="SQL Query: Function color",
24
+ choices=COLOR_CHOICES,
25
+ default=ColorChoices.COLOR_PURPLE,
26
+ description="Color for SQL functions (COUNT, SUM, AVG, etc.).",
27
+ ),
28
+ "color_string": UserPreference(
29
+ label="SQL Query: String color",
30
+ choices=COLOR_CHOICES,
31
+ default=ColorChoices.COLOR_DARK_GREEN,
32
+ description="Color for string literals ('active', 'test', etc.).",
33
+ ),
34
+ "color_number": UserPreference(
35
+ label="SQL Query: Number color",
36
+ choices=COLOR_CHOICES,
37
+ default=ColorChoices.COLOR_DARK_ORANGE,
38
+ description="Color for numeric literals (42, 3.14, etc.).",
39
+ ),
40
+ "color_operator": UserPreference(
41
+ label="SQL Query: Operator color",
42
+ choices=COLOR_CHOICES,
43
+ default=ColorChoices.COLOR_DARK_RED,
44
+ description="Color for operators (=, <>, >=, etc.).",
45
+ ),
46
+ "color_comment": UserPreference(
47
+ label="SQL Query: Comment color",
48
+ choices=COLOR_CHOICES,
49
+ default=ColorChoices.COLOR_GREY,
50
+ description="Color for SQL comments (-- line comments).",
51
+ ),
52
+ "skip_write_confirm": UserPreference(
53
+ label="SQL Query: Skip write confirmation",
54
+ choices=(
55
+ ("off", "Off (always confirm)"),
56
+ ("on", "On (skip confirmation)"),
57
+ ),
58
+ default="off",
59
+ description=(
60
+ "Skip the confirmation dialog when running"
61
+ " INSERT, UPDATE, or DELETE queries."
62
+ ),
63
+ ),
64
+ }
@@ -0,0 +1,52 @@
1
+ from django.core.cache import cache
2
+ from django.db import connection
3
+
4
+ SCHEMA_CACHE_KEY = "netbox_sqlquery_schema"
5
+ ABSTRACT_SCHEMA_CACHE_KEY = "netbox_sqlquery_abstract_schema"
6
+ SCHEMA_CACHE_TTL = 300
7
+
8
+
9
+ def get_schema():
10
+ cached = cache.get(SCHEMA_CACHE_KEY)
11
+ if cached is not None:
12
+ return cached
13
+
14
+ with connection.cursor() as cursor:
15
+ cursor.execute("""
16
+ SELECT t.table_name, c.column_name, c.data_type
17
+ FROM information_schema.tables t
18
+ JOIN information_schema.columns c
19
+ ON t.table_name = c.table_name
20
+ AND t.table_schema = c.table_schema
21
+ WHERE t.table_schema = 'public'
22
+ AND t.table_type = 'BASE TABLE'
23
+ ORDER BY t.table_name, c.ordinal_position
24
+ """)
25
+ schema = {}
26
+ for table, column, dtype in cursor.fetchall():
27
+ schema.setdefault(table, []).append((column, dtype))
28
+
29
+ cache.set(SCHEMA_CACHE_KEY, schema, SCHEMA_CACHE_TTL)
30
+ return schema
31
+
32
+
33
+ def get_abstract_schema():
34
+ """Return schema for abstract (nb_*) views only."""
35
+ cached = cache.get(ABSTRACT_SCHEMA_CACHE_KEY)
36
+ if cached is not None:
37
+ return cached
38
+
39
+ with connection.cursor() as cursor:
40
+ cursor.execute(r"""
41
+ SELECT c.table_name, c.column_name, c.data_type
42
+ FROM information_schema.columns c
43
+ WHERE c.table_schema = 'public'
44
+ AND c.table_name LIKE 'nb\_%'
45
+ ORDER BY c.table_name, c.ordinal_position
46
+ """)
47
+ schema = {}
48
+ for table, column, dtype in cursor.fetchall():
49
+ schema.setdefault(table, []).append((column, dtype))
50
+
51
+ cache.set(ABSTRACT_SCHEMA_CACHE_KEY, schema, SCHEMA_CACHE_TTL)
52
+ return schema
@@ -0,0 +1,18 @@
1
+ import django_tables2 as tables
2
+ from netbox.tables import NetBoxTable, columns
3
+
4
+ from .models import SavedQuery
5
+
6
+
7
+ class SavedQueryTable(NetBoxTable):
8
+ name = tables.Column(linkify=True)
9
+ owner = tables.Column()
10
+ visibility = tables.Column()
11
+ run_count = tables.Column()
12
+ last_run = tables.DateTimeColumn()
13
+ actions = columns.ActionsColumn(actions=("edit", "delete"))
14
+
15
+ class Meta(NetBoxTable.Meta):
16
+ model = SavedQuery
17
+ fields = ("pk", "name", "owner", "visibility", "run_count", "last_run", "actions")
18
+ default_columns = ("name", "owner", "visibility", "run_count", "last_run", "actions")
File without changes
@@ -0,0 +1,96 @@
1
+ from django.contrib.auth import get_user_model
2
+ from django.test import TestCase, override_settings
3
+ from users.models import Group
4
+
5
+ from netbox_sqlquery.access import ALL_TABLES, SHARED_TABLES, _allowed_tables, check_access, extract_tables
6
+ from netbox_sqlquery.models import TablePermission
7
+
8
+ User = get_user_model()
9
+
10
+ PLUGIN_CONFIG = {
11
+ "netbox_sqlquery": {
12
+ "deny_tables": ["auth_user", "users_token", "users_userconfig"],
13
+ }
14
+ }
15
+
16
+
17
+ class ExtractTablesTest(TestCase):
18
+
19
+ def test_extract_tables_finds_from_clause(self):
20
+ sql = "SELECT * FROM dcim_device WHERE id = 1"
21
+ self.assertEqual(extract_tables(sql), {"dcim_device"})
22
+
23
+ def test_extract_tables_finds_join_clause(self):
24
+ sql = "SELECT * FROM dcim_device JOIN dcim_site ON dcim_device.site_id = dcim_site.id"
25
+ self.assertEqual(extract_tables(sql), {"dcim_device", "dcim_site"})
26
+
27
+ def test_extract_tables_handles_cte(self):
28
+ sql = "WITH cte AS (SELECT * FROM dcim_device) SELECT * FROM cte"
29
+ tables = extract_tables(sql)
30
+ self.assertIn("dcim_device", tables)
31
+ self.assertIn("cte", tables)
32
+
33
+
34
+ @override_settings(PLUGINS_CONFIG=PLUGIN_CONFIG)
35
+ class CheckAccessTest(TestCase):
36
+
37
+ def setUp(self):
38
+ self.superuser = User.objects.create_user(
39
+ "superuser", password="test", is_superuser=True,
40
+ )
41
+ self.staff_user = User.objects.create_user(
42
+ "staffuser", password="test",
43
+ )
44
+ self.regular_user = User.objects.create_user(
45
+ "regular", password="test",
46
+ )
47
+ self.group = Group.objects.create(name="network_team")
48
+
49
+ def test_superuser_can_access_all_tables(self):
50
+ result = _allowed_tables(self.superuser)
51
+ self.assertIs(result, ALL_TABLES)
52
+
53
+ def test_hard_deny_blocks_superuser(self):
54
+ denied = check_access(self.superuser, {"auth_user", "dcim_device"})
55
+ self.assertEqual(denied, {"auth_user"})
56
+
57
+ def test_staff_tier_default_allows_dcim_tables(self):
58
+ TablePermission.objects.create(
59
+ pattern="dcim_", scope=TablePermission.SCOPE_PREFIX,
60
+ require_staff=True, allow=True,
61
+ )
62
+ allowed = _allowed_tables(self.staff_user)
63
+ self.assertIn("dcim_", allowed)
64
+
65
+ def test_regular_user_gets_only_shared_tables(self):
66
+ allowed = _allowed_tables(self.regular_user)
67
+ self.assertEqual(allowed, SHARED_TABLES)
68
+
69
+ def test_group_override_expands_access_for_group_member(self):
70
+ self.regular_user.groups.add(self.group)
71
+ perm = TablePermission.objects.create(
72
+ pattern="ipam_", scope=TablePermission.SCOPE_PREFIX, allow=True,
73
+ )
74
+ perm.groups.add(self.group)
75
+ allowed = _allowed_tables(self.regular_user)
76
+ self.assertIn("ipam_", allowed)
77
+
78
+ def test_explicit_deny_in_table_permission_overrides_allow(self):
79
+ self.staff_user.groups.add(self.group)
80
+ TablePermission.objects.create(
81
+ pattern="dcim_", scope=TablePermission.SCOPE_PREFIX,
82
+ require_staff=True, allow=True,
83
+ )
84
+ deny_perm = TablePermission.objects.create(
85
+ pattern="dcim_", scope=TablePermission.SCOPE_PREFIX,
86
+ allow=False,
87
+ )
88
+ deny_perm.groups.add(self.group)
89
+ allowed = _allowed_tables(self.staff_user)
90
+ self.assertNotIn("dcim_", allowed)
91
+
92
+ def test_wildcard_prefix_matches_all_prefixed_tables(self):
93
+ perm = TablePermission(pattern="dcim_", scope=TablePermission.SCOPE_PREFIX)
94
+ self.assertTrue(perm.matches("dcim_device"))
95
+ self.assertTrue(perm.matches("dcim_site"))
96
+ self.assertFalse(perm.matches("ipam_ipaddress"))
@@ -0,0 +1,41 @@
1
+ from django.contrib.auth import get_user_model
2
+ from django.test import TestCase
3
+
4
+ from netbox_sqlquery.models import SavedQuery
5
+
6
+ User = get_user_model()
7
+
8
+
9
+ class SavedQueryModelAPITest(TestCase):
10
+ """Test SavedQuery visibility logic at the model level."""
11
+
12
+ def setUp(self):
13
+ self.user1 = User.objects.create_user("user1", password="test")
14
+ self.user2 = User.objects.create_user("user2", password="test")
15
+
16
+ self.private_query = SavedQuery.objects.create(
17
+ name="Private", sql="SELECT 1", owner=self.user1,
18
+ visibility=SavedQuery.VISIBILITY_PRIVATE,
19
+ )
20
+ self.global_query = SavedQuery.objects.create(
21
+ name="Global", sql="SELECT 2", owner=self.user1,
22
+ visibility=SavedQuery.VISIBILITY_GLOBAL,
23
+ )
24
+
25
+ def test_list_returns_only_owned_and_global_queries(self):
26
+ visible = SavedQuery.visible_to(self.user2)
27
+ names = list(visible.values_list("name", flat=True))
28
+ self.assertIn("Global", names)
29
+ self.assertNotIn("Private", names)
30
+
31
+ def test_owner_sees_own_private_queries(self):
32
+ visible = SavedQuery.visible_to(self.user1)
33
+ names = list(visible.values_list("name", flat=True))
34
+ self.assertIn("Private", names)
35
+ self.assertIn("Global", names)
36
+
37
+ def test_visibility_choices_are_valid(self):
38
+ valid = dict(SavedQuery.VISIBILITY_CHOICES).keys()
39
+ self.assertIn(SavedQuery.VISIBILITY_PRIVATE, valid)
40
+ self.assertIn(SavedQuery.VISIBILITY_GLOBAL, valid)
41
+ self.assertIn(SavedQuery.VISIBILITY_GLOBAL_EDITABLE, valid)