Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(visibilities): make visibilities suppressable on n:1 relationships #2303

Merged
merged 1 commit into from
Oct 14, 2024
Merged
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
66 changes: 66 additions & 0 deletions caluma/caluma_core/tests/test_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from django.core.exceptions import ImproperlyConfigured
from django.db.models import CharField, QuerySet

from caluma.caluma_form.schema import Question

from .. import models
from ..types import DjangoObjectType, Node
from ..visibilities import BaseVisibility, Union, filter_queryset_for
Expand Down Expand Up @@ -252,3 +254,67 @@ def filter_queryset_for_custom_node(self, node, queryset, info):
assert queryset.count() == 1
queryset = CustomVisibility().filter_queryset(CustomNode2, FakeModel2.objects, None)
assert queryset.count() == 1


@pytest.mark.xfail(
reason="Didn't find a way to actually test the visibility aware resolver"
)
@pytest.mark.parametrize("suppress, expect_calls", [(True, False), (False, True)])
def test_suppress_visibility(
db,
history_mock,
mocker,
suppress,
expect_calls,
answer_factory,
question_factory,
schema_executor,
): # pragma: no cover
class CustomVisibility(BaseVisibility):
was_called = False

@filter_queryset_for(Question)
def filter_queryset_for_custom_node(self, node, queryset, info):
CustomVisibility.was_called = True
# only care about being called, not actual filtering
return queryset.all()

@property
def suppress_visibilities(self):
return ["Answer.question"] if suppress else []

mocker.patch("caluma.caluma_core.types.Node.visibility_classes", [CustomVisibility])

# Just so we have something to fetch (document and
# question created implicitly)
answer_factory(question__type="integer")

# This should trigger the `question` resolver on the answer, which
# we test the suppressor on
res = schema_executor(
"""
query foo {
allDocuments {
edges {
node {
answers {
edges {
node {
id
... on IntegerAnswer {
question {
slug
}
}
}
}
}
}
}
}
}
"""
)
assert not res.errors

assert expect_calls == CustomVisibility.was_called
27 changes: 27 additions & 0 deletions caluma/caluma_core/visibilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,18 @@ class BaseVisibility(object):
... @filter_queryset_for(Form)
... def filter_queryset_for_form(self, node, queryset, info):
... return queryset.exclude(slug='protected-form')
...
... # Do not trigger visibility when looking up case from workitem
... suppress_visibilities = [
... "WorkItem.case",
... "WorkItem.child_case",
czosel marked this conversation as resolved.
Show resolved Hide resolved
... ]
"""

# Used by the @suppressable_visibility decorator to store
# the *allowed* values for the `suppress_visibilities` property
_suppressable_visibilities = set()

def __init__(self):
queryset_fns = inspect.getmembers(self, lambda m: hasattr(m, "_visibilities"))

Expand All @@ -56,13 +66,30 @@ def __init__(self):
node: fn for _, fn in queryset_fns for node in fn._visibilities
}

self._validate_suppress_visibility()

def _validate_suppress_visibility(self):
requested_suppressors = set(self.suppress_visibilities)
available_suppressors = type(self)._suppressable_visibilities

invalid = requested_suppressors - available_suppressors
if invalid: # pragma: no cover
invalid_str_list = ", ".join(f"`{x}`" for x in (sorted(invalid)))
raise ImproperlyConfigured(
f"`{type(self).__name__}` contains invalid `suppress_visibilities`: "
f"{invalid_str_list}"
)

def filter_queryset(self, node, queryset, info):
for cls in node.mro():
if cls in self._filter_querysets_for:
return self._filter_querysets_for[cls](node, queryset, info)

return queryset

# Default: suppress no visibilities
suppress_visibilities = []


class Any(BaseVisibility):
"""No restrictions, all nodes are exposed."""
Expand Down
15 changes: 15 additions & 0 deletions caluma/caluma_form/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from graphene.types import ObjectType, generic
from graphene_django.rest_framework import serializer_converter

from caluma.utils import suppressable_visibility_resolver

from ..caluma_core.filters import (
CollectionFilterSetFactory,
DjangoFilterConnectionField,
Expand Down Expand Up @@ -147,6 +149,8 @@ class Question(Node, graphene.Interface):
)
source = graphene.Field("caluma.caluma_form.schema.Question")

resolve_source = suppressable_visibility_resolver()

@classmethod
def get_queryset(cls, queryset, info):
queryset = super().get_queryset(queryset, info)
Expand All @@ -166,6 +170,8 @@ def resolve_type(cls, instance, info):
class Option(FormDjangoObjectType):
meta = generic.GenericScalar()

resolve_source = suppressable_visibility_resolver()

class Meta:
model = models.Option
interfaces = (relay.Node,)
Expand Down Expand Up @@ -638,6 +644,8 @@ class Form(FormDjangoObjectType):
)
meta = generic.GenericScalar()

resolve_source = suppressable_visibility_resolver()

class Meta:
model = models.Form
interfaces = (relay.Node,)
Expand Down Expand Up @@ -809,6 +817,8 @@ class Answer(Node, graphene.Interface):
question = graphene.Field(Question, required=True)
meta = generic.GenericScalar(required=True)

resolve_question = suppressable_visibility_resolver()

@classmethod
def resolve_type(cls, instance, info):
return resolve_answer(instance)
Expand Down Expand Up @@ -911,6 +921,11 @@ class Document(FormDjangoObjectType):
modified_content_by_user = graphene.String()
modified_content_by_group = graphene.String()

resolve_form = suppressable_visibility_resolver()
resolve_case = suppressable_visibility_resolver()
resolve_source = suppressable_visibility_resolver()
resolve_work_item = suppressable_visibility_resolver()

class Meta:
model = models.Document
exclude = ("family", "dynamicoption_set")
Expand Down
17 changes: 17 additions & 0 deletions caluma/caluma_workflow/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from graphene.types import generic
from graphene_django.rest_framework import serializer_converter

from caluma.utils import suppressable_visibility_resolver

from ..caluma_core.filters import (
CollectionFilterSetFactory,
DjangoFilterConnectionField,
Expand Down Expand Up @@ -98,6 +100,7 @@ def resolve_type(cls, instance, info):

return TASK_TYPE[instance.type]

resolve_form = suppressable_visibility_resolver()
Meta = InterfaceMetaFactory()


Expand Down Expand Up @@ -206,6 +209,12 @@ class WorkItem(DjangoObjectType):
)
)

resolve_case = suppressable_visibility_resolver()
resolve_child_case = suppressable_visibility_resolver()
resolve_task = suppressable_visibility_resolver()
resolve_document = suppressable_visibility_resolver()
resolve_previous_work_item = suppressable_visibility_resolver()

def resolve_is_redoable(self, *args, **kwargs):
return (
self.status != models.WorkItem.STATUS_READY
Expand Down Expand Up @@ -237,6 +246,14 @@ class Case(DjangoObjectType):
meta = generic.GenericScalar()
status = CaseStatus(required=True)

resolve_document = suppressable_visibility_resolver()

resolve_parent_work_item = suppressable_visibility_resolver()

resolve_family = suppressable_visibility_resolver()

resolve_workflow = suppressable_visibility_resolver()

def resolve_family_work_items(self, info, **args):
return models.WorkItem.objects.filter(case__family=self.family)

Expand Down
77 changes: 77 additions & 0 deletions caluma/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from django.db.models import Model

from caluma.caluma_core.types import Node

log = getLogger(__name__)


Expand Down Expand Up @@ -102,3 +104,78 @@ def wrapper(*args, **kwargs):
signal_handler(*args, **kwargs)

return wrapper


def suppressable_visibility_resolver():
"""Make a resolver that can be suppressed in the visibility layer.

The visibility layer may cause additional load on the database, so
in cases where it's not needed, the visibility classes can choose to
skip filtering where it's not absolutely needed.

For example, if the visibility of a child case is always dependent
on the associated work item, you can disable enforcing visibility
on that relationship.

Example in the GraphQL schema:

>>> class Form(FormDjangoObjectType):
>>> ...
>>> resolve_child_case = suppressable_visibility_resolver()

Example in the visibility class:

>>> class MyCustomVisibility(BaseVisibility):
>>> @filter_queryset_for(Case)
>>> def filter_queryset_for_case(self, node, queryset, info):
>>> # do the filtering as usual
>>> ...
>>> ...
>>> suppress_visibilities = [
>>> "WorkItem.child_case",
>>> ...
>>> ]

With the above setting, when looking up child cases from work items,
the visibility layer would not be run, and the child case would always
be shown (if the workitem can be shown, of course).

You may also define it as a `@property` on the visibility class. Note
however that it's initialized on app startup, you can't change it during
runtime and make suppression work in some cases and not in others.
"""

# Avoid circular imports
from caluma.caluma_core.visibilities import BaseVisibility

# TODO: This is currently untested (as in unit test), because
# the checks are done at startup (where the schema is initialized)
# and we can't swap out the visibility class during test runs
# to validate the behavour as we'd usually do.
class SuppressableResolver:
def __call__(self, inner_self, *args, **kwargs):
return getattr(inner_self, self.prop)

@property
def _bypass_get_queryset(self): # pragma: no cover
"""Tell Graphene to bypass get_queryset()."""
# If any visibility class tells us to bypass the
# queryset of this property, we return True
return any(
self.qualname in vis_class().suppress_visibilities
for vis_class in Node.visibility_classes
)

def __repr__(self): # pragma: no cover
return f"SuppressableResolver({self.qualname})"

def __set_name__(self, owner, name):
# Magic. This is called by Python after setting the attribute on
# the node class, and we use it to figure out which resolver we're
# building.
self.name = owner.__name__
self.prop = name.replace("resolve_", "")
self.qualname = f"{self.name}.{self.prop}"
BaseVisibility._suppressable_visibilities.add(self.qualname)

return SuppressableResolver()
Loading