Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGES/6001.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed ``get_viewset_for_model``/``get_view_name_for_model`` to correctly resolve a content type's canonical viewset even when a plugin registers additional read-only, nested viewsets that reuse that model's queryset for their own purposes (e.g. a ``ContentView`` search endpoint). Previously, any such additional nested registration made the model's viewset unresolvable, breaking ``content_summary`` hrefs and master-viewset queryset scoping for that content type.
1 change: 1 addition & 0 deletions CHANGES/6001.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added the ``ContentView`` resource: a named, persistable scope composed of Distributions -- potentially spanning multiple domains -- with full CRUD and RBAC. This lets plugins implement RBAC-respecting, cross-domain search over the content served by those Distributions without querying the database directly or passing raw repository version hrefs on every request.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/6001.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added ``ContentView`` to ``pulpcore.plugin.models`` and ``ContentViewViewSet``/``ContentViewFilter`` to ``pulpcore.plugin.viewsets``, along with ``resolve_content_view_distributions``, ``group_versions_by_domain``, ``scatter_gather``, and ``user_can_view_domain`` in ``pulpcore.plugin.util`` (plus ``with_domain``, now also re-exported there). Together these let a plugin implement its own nested, RBAC-respecting, cross-domain search endpoints under a ``ContentView``.
57 changes: 57 additions & 0 deletions pulpcore/app/migrations/0155_contentview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Generated by Django 5.2.14 on 2026-07-29 00:00

import django.contrib.postgres.fields.hstore
import django.db.models.deletion
import django_lifecycle.mixins
from django.db import migrations, models

import pulpcore.app.models.base
import pulpcore.app.util


class Migration(migrations.Migration):
dependencies = [
("core", "0154_task_api_version"),
]

operations = [
migrations.CreateModel(
name="ContentView",
fields=[
(
"pulp_id",
models.UUIDField(
default=pulpcore.app.models.base.pulp_uuid,
editable=False,
primary_key=True,
serialize=False,
),
),
("pulp_created", models.DateTimeField(auto_now_add=True)),
("pulp_last_updated", models.DateTimeField(auto_now=True, null=True)),
("name", models.TextField(db_index=True)),
("description", models.TextField(null=True)),
("pulp_labels", django.contrib.postgres.fields.hstore.HStoreField(default=dict)),
(
"distributions",
models.ManyToManyField(related_name="content_views", to="core.distribution"),
),
(
"pulp_domain",
models.ForeignKey(
default=pulpcore.app.util.get_domain_pk,
on_delete=django.db.models.deletion.PROTECT,
to="core.domain",
),
),
],
options={
"abstract": False,
"permissions": [
("manage_roles_contentview", "Can manage role assignments on content view")
],
"unique_together": {("name", "pulp_domain")},
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
]
6 changes: 6 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
Group,
)

# Must be imported before any module that imports `pulpcore.plugin.models` (which re-exports
# ContentView), e.g. `.replica` below -- otherwise that triggers a circular import back into
# this partially-initialized module.
from .content_view import ContentView

from .domain import Domain

from .acs import AlternateContentSource, AlternateContentSourcePath
Expand Down Expand Up @@ -166,6 +171,7 @@
"GroupProgressReport",
"ProgressReport",
"UpstreamPulp",
"ContentView",
"OpenPGPDistribution",
"OpenPGPKeyring",
"OpenPGPPublicKey",
Expand Down
52 changes: 52 additions & 0 deletions pulpcore/app/models/content_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
Check `Plugin Writer's Guide`_ for more details.

Plugin Writer's Guide:
https://pulpproject.org/pulpcore/docs/dev/learn/plugin-concepts/
"""

from django.contrib.postgres.fields import HStoreField
from django.db import models

from pulpcore.app.models import AutoAddObjPermsMixin, BaseModel
from pulpcore.app.util import get_domain_pk


class ContentView(BaseModel, AutoAddObjPermsMixin):
"""
A named, persistable scope composed of Distributions, searchable across domains.

A ContentView lets API clients search across the content served by many Distributions --
which may span domains other than the ContentView's own -- without passing raw lists of
repository version hrefs on every request, and without bypassing Pulp's RBAC by querying
the database directly. Each linked Distribution already carries version-tracking semantics
(it can point to a Repository to track its latest version, a pinned RepositoryVersion, or a
Publication), so the ContentView itself only needs to store *which* Distributions are in
scope; resolving them to concrete RepositoryVersions happens at query time.

Fields:
name (models.TextField): The content view's name, unique within its domain.
description (models.TextField): Optional human-readable description.
pulp_labels (HStoreField): Dictionary of string values.

Relations:
pulp_domain (models.ForeignKey): The domain this ContentView is stored in. Standard
domain-scoped resource: read/update/delete is governed by RBAC on the ContentView
itself, same as any other Pulp resource.
distributions (models.ManyToManyField): Distributions this ContentView searches across.
These may belong to any domain the referencing user has read access to at the time
they are added -- not just the ContentView's own domain -- which is what makes
cross-domain search possible.
"""

name = models.TextField(db_index=True)
description = models.TextField(null=True)
pulp_labels = HStoreField(default=dict)
pulp_domain = models.ForeignKey("Domain", default=get_domain_pk, on_delete=models.PROTECT)
distributions = models.ManyToManyField("Distribution", related_name="content_views")

class Meta:
unique_together = ("name", "pulp_domain")
permissions = [
("manage_roles_contentview", "Can manage role assignments on content view"),
]
4 changes: 4 additions & 0 deletions pulpcore/app/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
SigningServiceSerializer,
SingleArtifactContentSerializer,
)
from .content_view import (
ContentViewDistributionStatusSerializer,
ContentViewSerializer,
)
from .domain import DomainSerializer, DomainBackendMigratorSerializer
from .exporter import (
ExporterSerializer,
Expand Down
106 changes: 106 additions & 0 deletions pulpcore/app/serializers/content_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from gettext import gettext as _

from rest_framework import serializers

from pulpcore.app import models
from pulpcore.app.serializers import (
DetailRelatedField,
DomainUniqueValidator,
IdentityField,
ModelSerializer,
RepositoryVersionRelatedField,
pulp_labels_validator,
)
from pulpcore.app.util_content_view import resolve_content_view_distributions


class ContentViewDistributionStatusSerializer(serializers.Serializer):
"""Per-distribution resolution status, shown on the ContentView detail/list endpoints."""

distribution = DetailRelatedField(
read_only=True,
view_name_pattern=r"distributions(-.*/.*)?-detail",
help_text=_("The distribution this status entry describes."),
)
domain = serializers.CharField(
source="domain.name", help_text=_("The name of the domain the distribution belongs to.")
)
status = serializers.ChoiceField(
choices=["ok", "no_domain_access", "no_version"],
help_text=_(
"'ok' if the distribution currently resolves to a repository version the caller can "
"search; 'no_domain_access' if the caller does not (or no longer) have read access "
"to the distribution's domain; 'no_version' if the distribution or the repository "
"version/publication it pointed to has been deleted."
),
)
repository_version = RepositoryVersionRelatedField(
read_only=True,
allow_null=True,
queryset=None,
help_text=_("The repository version currently resolved for this distribution, if any."),
)


class ContentViewSerializer(ModelSerializer):
"""
Serializer for a ContentView -- a named, persistable scope composed of Distributions that
may span multiple domains, used to search across their content without exposing raw
repository version hrefs on every request.
"""

# Distributions referenced by a ContentView may legitimately live in a domain other than
# the ContentView's own -- that's the entire point of this resource -- so the default
# same-domain cross-field validation (ValidateFieldsMixin.check_cross_domains) must not
# apply here.
CHECK_SAME_DOMAIN = False

pulp_href = IdentityField(view_name="content-views-detail")

name = serializers.CharField(
help_text=_("A unique name for this content view."),
validators=[DomainUniqueValidator(queryset=models.ContentView.objects.all())],
)
description = serializers.CharField(
help_text=_("An optional description of this content view."),
required=False,
allow_null=True,
)
pulp_labels = serializers.HStoreField(required=False, validators=[pulp_labels_validator])
distributions = DetailRelatedField(
many=True,
required=False,
queryset=models.Distribution.objects.all(),
view_name_pattern=r"distributions(-.*/.*)?-detail",
help_text=_(
"Distributions this content view searches across. May reference distributions "
"belonging to any domain the user has read access to, not just this content view's "
"own domain."
),
)
distributions_status = serializers.SerializerMethodField(
help_text=_(
"Per-distribution resolution status: whether each linked distribution's domain is "
"currently accessible and whether it resolves to a repository version."
)
)

def get_distributions_status(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None) if request else None
if user is None:
return []
resolutions = resolve_content_view_distributions(obj, user)
return ContentViewDistributionStatusSerializer(
resolutions, many=True, context=self.context
).data

class Meta:
model = models.ContentView
fields = ModelSerializer.Meta.fields + (
"name",
"description",
"pulp_labels",
"distributions",
"distributions_status",
)
12 changes: 10 additions & 2 deletions pulpcore/app/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,16 @@ def get_viewset_for_model(model_obj, ignore_error=False):
# go through the viewset registry to find the viewset for the passed-in model
for app in pulp_plugin_configs():
for model, viewsets in app.named_viewsets.items():
# There may be multiple viewsets for a model. In this
# case, we can't reverse the mapping.
# There may be multiple viewsets for a model, e.g. a plugin may register an
# additional read-only, nested viewset that reuses an existing content type's
# queryset for its own purposes (a ContentView search endpoint, for example),
# without intending to compete for that model's canonical viewset. Such viewsets
# are always nested (they declare a parent_viewset), so if excluding them leaves
# exactly one candidate, that candidate is unambiguously the canonical viewset.
if len(viewsets) > 1:
non_nested = [vs for vs in viewsets if getattr(vs, "parent_viewset", None) is None]
if len(non_nested) == 1:
viewsets = non_nested
if len(viewsets) == 1:
viewset = viewsets[0]
_model_viewset_cache.setdefault(model, viewset)
Expand Down
Loading
Loading