-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1004 from thunderstore-io/object-visibility
Implement a straight forward way to query object visibility
- Loading branch information
Showing
15 changed files
with
392 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
32 changes: 32 additions & 0 deletions
32
django/thunderstore/permissions/migrations/0001_initial.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
# Generated by Django 3.1.7 on 2024-01-16 02:10 | ||
|
||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
initial = True | ||
|
||
dependencies = [] | ||
|
||
operations = [ | ||
migrations.CreateModel( | ||
name="VisibilityFlags", | ||
fields=[ | ||
( | ||
"id", | ||
models.BigAutoField( | ||
editable=False, primary_key=True, serialize=False | ||
), | ||
), | ||
("public_list", models.BooleanField(db_index=True)), | ||
("public_detail", models.BooleanField(db_index=True)), | ||
("owner_list", models.BooleanField(db_index=True)), | ||
("owner_detail", models.BooleanField(db_index=True)), | ||
("moderator_list", models.BooleanField(db_index=True)), | ||
("moderator_detail", models.BooleanField(db_index=True)), | ||
("admin_list", models.BooleanField(db_index=True)), | ||
("admin_detail", models.BooleanField(db_index=True)), | ||
], | ||
), | ||
] |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
from django.db import models, transaction | ||
from django.db.models import Q | ||
|
||
from thunderstore.permissions.models import VisibilityFlags | ||
|
||
|
||
class VisibilityQuerySet(models.QuerySet): | ||
def public_list(self): | ||
return self.exclude(visibility__public_list=False) | ||
|
||
def public_detail(self): | ||
return self.exclude(visibility__public_detail=False) | ||
|
||
def visible_list(self, is_owner: bool, is_moderator: bool, is_admin: bool): | ||
filter = Q(visibility__public_list=True) | ||
if is_owner: | ||
filter |= Q(visibility__owner_list=True) | ||
if is_moderator: | ||
filter |= Q(visibility__moderator_list=True) | ||
if is_admin: | ||
filter |= Q(visibility__admin_list=True) | ||
return self.exclude(~filter) | ||
|
||
def visible_detail(self, is_owner: bool, is_moderator: bool, is_admin: bool): | ||
filter = Q(visibility__public_detail=True) | ||
if is_owner: | ||
filter |= Q(visibility__owner_detail=True) | ||
if is_moderator: | ||
filter |= Q(visibility__moderator_detail=True) | ||
if is_admin: | ||
filter |= Q(visibility__admin_detail=True) | ||
return self.exclude(~filter) | ||
|
||
|
||
class VisibilityMixin(models.Model): | ||
objects = VisibilityQuerySet.as_manager() | ||
visibility = models.OneToOneField( | ||
"permissions.VisibilityFlags", | ||
blank=True, | ||
null=True, | ||
on_delete=models.PROTECT, | ||
) | ||
|
||
@transaction.atomic | ||
def save(self, *args, **kwargs): | ||
if not self.pk and not self.visibility: | ||
self.visibility = VisibilityFlags.objects.create_public() | ||
super().save() | ||
|
||
class Meta: | ||
abstract = True |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .visibility import VisibilityFlags |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import itertools | ||
|
||
from django.db import models | ||
|
||
from thunderstore.permissions.models import VisibilityFlags | ||
|
||
FLAG_FIELDS = [ | ||
field.name | ||
for field in VisibilityFlags._meta.get_fields() | ||
if isinstance(field, models.BooleanField) | ||
] | ||
|
||
|
||
def assert_all_visible(visibility: VisibilityFlags): | ||
for field in FLAG_FIELDS: | ||
assert getattr(visibility, field) is True | ||
|
||
|
||
def get_flags_cartesian_product(): | ||
""" | ||
Returns all possible combinations for visibility flag field values to be | ||
used with fixtures. | ||
""" | ||
if len(FLAG_FIELDS) > 10: # pragma: no cover | ||
# Just to make sure we don't accidentally introduce exponential test | ||
# case counts without noticing it. | ||
raise ValueError( | ||
"Excessive amount of visibility flags detected for test fixtures, " | ||
"rework me to not use cartesian product!\n" | ||
f"Would have generated {2**len(FLAG_FIELDS)} tests!" | ||
) | ||
|
||
return ( | ||
dict(zip(FLAG_FIELDS, vals)) | ||
for vals in itertools.product(*((False, True) for _ in range(len(FLAG_FIELDS)))) | ||
) |
39 changes: 39 additions & 0 deletions
39
django/thunderstore/permissions/models/tests/test_visibility.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import pytest | ||
|
||
from thunderstore.permissions.models import VisibilityFlags | ||
from thunderstore.permissions.models.tests._utils import ( | ||
FLAG_FIELDS, | ||
get_flags_cartesian_product, | ||
) | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_visibility_flags_queryset_create_public(): | ||
flags: VisibilityFlags = VisibilityFlags.objects.create_public() | ||
assert flags.public_list is True | ||
assert flags.public_detail is True | ||
assert flags.owner_list is True | ||
assert flags.owner_detail is True | ||
assert flags.moderator_list is True | ||
assert flags.moderator_detail is True | ||
assert flags.admin_list is True | ||
assert flags.admin_detail is True | ||
|
||
# Slight future proofing this test in case new fields are added | ||
for field in FLAG_FIELDS: | ||
assert getattr(flags, field) is True | ||
|
||
|
||
@pytest.mark.django_db | ||
@pytest.mark.parametrize("fields", get_flags_cartesian_product()) | ||
def test_visibility_flags_str(fields): | ||
flags = VisibilityFlags(**fields) | ||
stringified = str(flags) | ||
for name, val in fields.items(): | ||
if val is True: | ||
assert name in stringified | ||
else: | ||
assert name not in stringified | ||
|
||
if not any(fields.values()): | ||
assert stringified == "None" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
from django.db import models | ||
|
||
|
||
class VisibilityFlagsQuerySet(models.QuerySet): | ||
def create_public(self): | ||
return self.create( | ||
public_list=True, | ||
public_detail=True, | ||
owner_list=True, | ||
owner_detail=True, | ||
moderator_list=True, | ||
moderator_detail=True, | ||
admin_list=True, | ||
admin_detail=True, | ||
) | ||
|
||
|
||
class VisibilityFlags(models.Model): | ||
objects = VisibilityFlagsQuerySet.as_manager() | ||
id = models.BigAutoField(primary_key=True, editable=False) | ||
|
||
public_list = models.BooleanField(db_index=True) | ||
public_detail = models.BooleanField(db_index=True) | ||
owner_list = models.BooleanField(db_index=True) | ||
owner_detail = models.BooleanField(db_index=True) | ||
moderator_list = models.BooleanField(db_index=True) | ||
moderator_detail = models.BooleanField(db_index=True) | ||
admin_list = models.BooleanField(db_index=True) | ||
admin_detail = models.BooleanField(db_index=True) | ||
|
||
def __str__(self) -> str: | ||
flag_fields = ( | ||
field.name | ||
for field in self._meta.get_fields() | ||
if isinstance(field, models.BooleanField) and getattr(self, field.name) | ||
) | ||
return ", ".join(flag_fields) or "None" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
from typing import Callable, List | ||
|
||
import pytest | ||
from django.db import DatabaseError, transaction | ||
from django.db.models import QuerySet | ||
|
||
from thunderstore.permissions.mixins import VisibilityMixin | ||
from thunderstore.permissions.models import VisibilityFlags | ||
from thunderstore.permissions.models.tests._utils import ( | ||
assert_all_visible, | ||
get_flags_cartesian_product, | ||
) | ||
from thunderstore.repository.factories import PackageVersionFactory | ||
from thunderstore.repository.models import PackageVersion | ||
from thunderstore.repository.package_formats import PackageFormats | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_visibility_mixin_autocreates_flags(): | ||
a = PackageVersionFactory() | ||
assert a.visibility is not None | ||
assert_all_visible(a.visibility) | ||
|
||
b = PackageVersion.objects.create( | ||
package=a.package, | ||
format_spec=PackageFormats.get_active_format(), | ||
name=a.name, | ||
version_number=f"{a.version_number[:-1]}1", | ||
website_url="", | ||
readme="", | ||
file=a.file, | ||
file_size=a.file_size, | ||
icon=a.icon, | ||
) | ||
assert b.visibility is not None | ||
assert_all_visible(b.visibility) | ||
|
||
c = PackageVersion( | ||
package=a.package, | ||
format_spec=PackageFormats.get_active_format(), | ||
name=a.name, | ||
version_number=f"{a.version_number[:-1]}2", | ||
website_url="", | ||
readme="", | ||
file=a.file, | ||
file_size=a.file_size, | ||
icon=a.icon, | ||
) | ||
assert c.visibility is None | ||
c.save() | ||
assert c.visibility is not None | ||
assert_all_visible(c.visibility) | ||
|
||
|
||
@pytest.fixture(scope="module") | ||
def visibility_objs(django_db_setup, django_db_blocker): | ||
""" | ||
Sets up module-scoped db fixtures for visibility rule testing in order to | ||
avoid re-creating hundreds of objects every test. The downside is that | ||
the db state management has to be manual. | ||
""" | ||
with django_db_blocker.unblock(): | ||
try: | ||
with transaction.atomic(): | ||
# TODO: Use some other model than PackageVersion as it's not | ||
# the most efficient. Also use bulk create to save time. | ||
yield [ | ||
PackageVersionFactory( | ||
visibility=VisibilityFlags.objects.create(**fields) | ||
) | ||
for fields in get_flags_cartesian_product() | ||
] | ||
raise DatabaseError("Forced rollback") | ||
except DatabaseError: | ||
pass | ||
|
||
|
||
def assert_visibility_matches( | ||
objs: List[VisibilityMixin], | ||
expected_visibility: Callable[[VisibilityMixin], bool], | ||
results: QuerySet[VisibilityMixin], | ||
): | ||
for entry in objs: | ||
if expected_visibility(entry): | ||
assert entry in results, f"Expected match missing: {entry.visibility}" | ||
else: | ||
assert entry not in results, f"Unexpected match found: {entry.visibility}" | ||
|
||
|
||
def test_visibility_queryset_public_list(visibility_objs: List[VisibilityMixin]): | ||
def is_public_list(obj: VisibilityMixin) -> bool: | ||
return obj.visibility.public_list | ||
|
||
model_cls = type(visibility_objs[0]) | ||
assert_visibility_matches( | ||
objs=visibility_objs, | ||
expected_visibility=is_public_list, | ||
results=model_cls.objects.public_list(), | ||
) | ||
|
||
|
||
def test_visibility_queryset_public_detail(visibility_objs: List[VisibilityMixin]): | ||
def is_public_detail(obj: VisibilityMixin) -> bool: | ||
return obj.visibility.public_detail | ||
|
||
model_cls = type(visibility_objs[0]) | ||
assert_visibility_matches( | ||
objs=visibility_objs, | ||
expected_visibility=is_public_detail, | ||
results=model_cls.objects.public_detail(), | ||
) | ||
|
||
|
||
@pytest.mark.parametrize("is_owner", (False, True)) | ||
@pytest.mark.parametrize("is_moderator", (False, True)) | ||
@pytest.mark.parametrize("is_admin", (False, True)) | ||
def test_visibility_queryset_visible_list( | ||
visibility_objs: List[VisibilityMixin], | ||
is_owner: bool, | ||
is_moderator: bool, | ||
is_admin: bool, | ||
): | ||
def is_visible(obj: VisibilityMixin) -> bool: | ||
return ( | ||
obj.visibility.public_list is True | ||
or (obj.visibility.owner_list and is_owner) | ||
or (obj.visibility.moderator_list and is_moderator) | ||
or (obj.visibility.admin_list and is_admin) | ||
) | ||
|
||
model_cls = type(visibility_objs[0]) | ||
assert_visibility_matches( | ||
objs=visibility_objs, | ||
expected_visibility=is_visible, | ||
results=model_cls.objects.visible_list(is_owner, is_moderator, is_admin), | ||
) | ||
|
||
|
||
@pytest.mark.parametrize("is_owner", (False, True)) | ||
@pytest.mark.parametrize("is_moderator", (False, True)) | ||
@pytest.mark.parametrize("is_admin", (False, True)) | ||
def test_visibility_queryset_visible_detail( | ||
visibility_objs: List[VisibilityMixin], | ||
is_owner: bool, | ||
is_moderator: bool, | ||
is_admin: bool, | ||
): | ||
def is_visible(obj: VisibilityMixin) -> bool: | ||
return ( | ||
obj.visibility.public_detail is True | ||
or (obj.visibility.owner_detail and is_owner) | ||
or (obj.visibility.moderator_detail and is_moderator) | ||
or (obj.visibility.admin_detail and is_admin) | ||
) | ||
|
||
model_cls = type(visibility_objs[0]) | ||
assert_visibility_matches( | ||
objs=visibility_objs, | ||
expected_visibility=is_visible, | ||
results=model_cls.objects.visible_detail(is_owner, is_moderator, is_admin), | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.