Source code for fairdm.core.utils

"""Helpers for core records: polymorphic-aware permissions and fieldsets."""

UUID_RE_PATTERN = r"^(?P<uuid>[pdsmea-zA-Z0-9_-]{22})/$"
"""A regex the matches the uuid of a core data object (project, sample, measurement, etc.) and captures it in a named group 'uuid'."""

CORE_PERMISSIONS = [
    ("add_contributor", "Can add contributors"),
    ("modify_contributor", "Can modify contributors"),
    ("modify_metadata", "Can modify metadata"),
]


[docs] def get_non_polymorphic_instance(obj): """Return an object re-fetched through its polymorphic base's non-polymorphic manager. Gated on ``type_of`` directly, not on ``polymorphic_model_marker``: every polymorphic model carries the marker, but only ``Sample``, ``Measurement`` and ``Contributor`` declare ``type_of``. A portal-defined polymorphic model that is none of those would otherwise raise ``AttributeError`` here rather than being left alone. Args: obj: The record to re-fetch. Returns: The non-polymorphic instance, or ``obj`` itself when it declares no ``type_of``. """ base_class = getattr(obj, "type_of", None) if base_class is None: return obj return base_class.objects.non_polymorphic().get(pk=obj.pk)
[docs] def get_permission_target(obj, perm): """Return the object a permission check or grant should actually target. A polymorphic subclass instance (e.g. ``RockSample``) carries its own app label and content type, so guardian either raises ``WrongAppError`` or silently misses a stored row when the permission is declared on the polymorphic base instead. Normalising to the base fixes that, but only when the base is the one that actually owns the permission being checked. Doing it unconditionally would retarget every polymorphic record's content type, including one whose own subclass owns the permission: an ``Organization`` normalised to ``Contributor`` would orphan every permission ever assigned to it. Gated on the object, not on the permission string: guardian only compares app labels when the permission carries one (``"." in perm``), so a gate keyed on that would never fire for an unqualified permission and the failure would become a silent denial instead of an error. Gated on ``type_of`` directly, not on ``polymorphic_model_marker``: a portal-defined polymorphic model that declares no ``type_of`` would otherwise raise ``AttributeError`` inside an authentication backend rather than being left alone. Args: obj: The record the permission is checked or granted on, or ``None``. perm: The permission codename, with or without an app label. Returns: The object to pass to guardian: ``obj`` itself or its non-polymorphic base instance. """ if obj is None: return obj base_class = getattr(obj, "type_of", None) if base_class is None or type(obj) is base_class: return obj from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType codename = perm.rsplit(".", 1)[-1] base_content_type = ContentType.objects.get_for_model(base_class) if not Permission.objects.filter( content_type=base_content_type, codename=codename ).exists(): return obj return get_non_polymorphic_instance(obj)
[docs] def assign_perm(perm, user_or_group, obj): """Assign a permission, normalising a polymorphic instance first. A backend takes no part in granting a right. ``guardian.shortcuts.assign_perm`` resolves the object's own content type directly, so a permission declared on a polymorphic base (e.g. ``change_sample``) could not be stored against a subclass instance. Uses the same gate as the check side, so the two never disagree about which records they cover. Args: perm: The permission to assign. user_or_group: The user or group receiving the permission. obj: The record the permission applies to. Returns: Whatever ``guardian.shortcuts.assign_perm`` returns. """ from guardian.shortcuts import assign_perm as guardian_assign_perm return guardian_assign_perm(perm, user_or_group, get_permission_target(obj, perm))
[docs] def remove_perm(perm, user_or_group, obj): """Remove a permission, with the same normalisation as :func:`assign_perm`. Args: perm: The permission to remove. user_or_group: The user or group losing the permission. obj: The record the permission applies to. Returns: Whatever ``guardian.shortcuts.remove_perm`` returns. """ from guardian.shortcuts import remove_perm as guardian_remove_perm return guardian_remove_perm(perm, user_or_group, get_permission_target(obj, perm))
[docs] def get_perms(user_or_group, obj): """List every permission a user or group holds on an object. Merges rows stored against the object's own content type with rows stored against its polymorphic base, because :func:`assign_perm` may have written to either depending on which one owns the permission, and there is no single permission here to gate the choice on. Args: user_or_group: The user or group to look up. obj: The record to look up permissions on. Returns: The sorted permission codenames. """ from guardian.shortcuts import get_perms as guardian_get_perms perms = set(guardian_get_perms(user_or_group, obj)) base_class = getattr(obj, "type_of", None) if obj is not None else None if base_class is not None and type(obj) is not base_class: perms |= set( guardian_get_perms(user_or_group, get_non_polymorphic_instance(obj)) ) return sorted(perms)
[docs] def get_objects_for_user(user, perm, klass, **kwargs): """List the objects a user holds a permission for, allowing for polymorphic subclasses. ``guardian.shortcuts.get_objects_for_user`` derives its content-type filter from the permission's own app label and model name, so a permission built from a subclass (e.g. ``"demo.view_rocksample"``) finds nothing when the grant is filed under the polymorphic base's content type (``sample.view_sample``). Handing it the base-model permission alongside a subclass queryset raises ``MixedContentTypeError``. This recomputes the permission against the base model, resolves matching primary keys there, and narrows the caller's own queryset by them. That is safe because a polymorphic subclass shares its primary key with its base. Args: user: The user to look up. perm: The permission, in ``app_label.codename`` form. klass: The model or queryset to filter. **kwargs: Keyword arguments passed to ``guardian.shortcuts.get_objects_for_user``. Returns: The queryset of objects the user holds the permission for. """ from guardian.shortcuts import get_objects_for_user as guardian_get_objects_for_user queryset = klass if hasattr(klass, "model") else klass._default_manager.all() model = queryset.model base_class = getattr(model, "type_of", None) if base_class is None or base_class is model: return guardian_get_objects_for_user(user, perm, queryset, **kwargs) _app_label, codename = perm.split(".", 1) action = codename.rsplit(f"_{model._meta.model_name}", 1)[0] base_perm = f"{base_class._meta.app_label}.{action}_{base_class._meta.model_name}" allowed_pks = guardian_get_objects_for_user( user, base_perm, base_class._default_manager.all(), **kwargs ).values_list("pk", flat=True) return queryset.filter(pk__in=allowed_pks)
[docs] def model_class_inheritance_to_fieldsets(obj_or_class): """Group a sample model's own fields into fieldsets, one per class in its inheritance chain. Each field appears once, under the first class in the chain that declares it, and the bookkeeping fields every sample carries (``id``, ``local_id``, ``image`` and the like) are left out. The most derived class's fields are then folded into the first group, and that last group is dropped. Args: obj_or_class: A sample instance or sample model class. Returns: A list of ``(name, {"fields": [...]})`` pairs. ``name`` is the class's verbose name, or ``None`` for ``Sample`` itself. """ from .models import Sample klass = obj_or_class if isinstance(obj_or_class, type) else obj_or_class.__class__ declared_fields = { "id", "local_id", "sample_ptr", "polymorphic_ctype", "created", "modified", "options", "path", "depth", "numchild", "image", } result = [] for base in reversed(klass.__mro__): if hasattr(base, "_meta") and issubclass(base, Sample): declared_in_base = [] for field in base._meta.local_fields: if field.name not in declared_fields: declared_fields.add(field.name) declared_in_base.append(field.name) if declared_in_base: name = base._meta.verbose_name if base != Sample else None result.append((name, {"fields": declared_in_base})) first_group = result[0][1] last_group = result[-1][1] first_group["fields"] += last_group["fields"] del result[-1] return result