Skip to content

Commit

Permalink
Merge pull request #1004 from thunderstore-io/object-visibility
Browse files Browse the repository at this point in the history
Implement a straight forward way to query object visibility
  • Loading branch information
MythicManiac authored Jan 16, 2024
2 parents 37cca3a + ae1f55d commit dfebcc0
Show file tree
Hide file tree
Showing 15 changed files with 392 additions and 3 deletions.
6 changes: 5 additions & 1 deletion django/django_extrafields/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ def __get__(self, *args, **kwargs):
return None


class SafeOneToOneOrField(models.OneToOneField):
class SafeOneToOneField(models.OneToOneField):
"""
Same as OneToOneField but returns None instead of raising an exception if
the relation doesn't exist.
"""

related_accessor_class = SafeReverseOnetoOneDescriptor


# Typo fix backwards compat
SafeOneToOneOrField = SafeOneToOneField
1 change: 1 addition & 0 deletions django/thunderstore/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ def load_db_certs():
"thunderstore.storage",
"thunderstore.metrics",
"thunderstore.moderation",
"thunderstore.permissions",
]
)

Expand Down
Empty file.
32 changes: 32 additions & 0 deletions django/thunderstore/permissions/migrations/0001_initial.py
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.
51 changes: 51 additions & 0 deletions django/thunderstore/permissions/mixins.py
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
1 change: 1 addition & 0 deletions django/thunderstore/permissions/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .visibility import VisibilityFlags
Empty file.
36 changes: 36 additions & 0 deletions django/thunderstore/permissions/models/tests/_utils.py
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 django/thunderstore/permissions/models/tests/test_visibility.py
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"
37 changes: 37 additions & 0 deletions django/thunderstore/permissions/models/visibility.py
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"
161 changes: 161 additions & 0 deletions django/thunderstore/permissions/tests/test_mixins.py
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),
)
1 change: 1 addition & 0 deletions django/thunderstore/repository/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from factory.django import DjangoModelFactory

from thunderstore.core.factories import UserFactory
from thunderstore.permissions.models import VisibilityFlags

from ..usermedia.factories import UserMediaFactory
from ..wiki.factories import WikiFactory
Expand Down
Loading

0 comments on commit dfebcc0

Please sign in to comment.