Skip to content

Commit

Permalink
Add PRN support
Browse files Browse the repository at this point in the history
fixes: #5766
  • Loading branch information
gerrod3 committed Sep 24, 2024
1 parent 6a2d120 commit fd666c7
Show file tree
Hide file tree
Showing 21 changed files with 357 additions and 102 deletions.
4 changes: 4 additions & 0 deletions CHANGES/5766.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Introduced new immutable resource identifier: Pulp Resource Name (PRN). All objects within Pulp
will now show their PRN alongside their pulp_href. The PRN can be used in lieu of the pulp_href
in API calls when creating or filtering objects. The PRN of any object has the form of
`prn:app_label.model_label:pulp_id`.
17 changes: 16 additions & 1 deletion docs/dev/learn/subclassing/serializers.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ Most plugins will implement:
: - serializer(s) for plugin specific content type(s), should be subclassed from one of
NoArtifactContentSerializer, SingleArtifactContentSerializer, or
MultipleArtifactContentSerializer, depending on the properties of the content type(s)
- serializer(s) for plugin specific repository(s), should be subclassed from RepositorySerializer
- serializer(s) for plugin specific remote(s), should be subclassed from RemoteSerializer
- serializer(s) for plugin specific publisher(s), should be subclassed from PublisherSerializer
- serializer(s) for plugin specific publication(s), should be subclassed from PublicationSerializer
- serializer(s) for plugin specific distribution(s), should be subclassed from DistributionSerializer

## Adding Fields

Expand All @@ -34,6 +36,19 @@ class Meta:
model = FileContent
```

!!! note

When inheriting from `pulpcore.plugin.serializers.ModelSerializer`, or one of its many subclasses,
you should include the fields of that serializer in your own. Important fields it provides include
`pulp_created`, `pulp_last_updated`, and `prn`.

See `pulpcore.plugin.serializers.ContentGuardSerializer` for an example:
```python
class Meta:
model = models.ContentGuard
fields = ModelSerializer.Meta.fields + ("name", "description")
```

### Help Text

The REST APIs of Pulp Core and each plugin are automatically documented using swagger. Each field's
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ModelSerializer,
NestedIdentityField,
NestedRelatedField,
PRNField,
RelatedField,
RelatedResourceField,
SetLabelSerializer,
Expand Down
38 changes: 30 additions & 8 deletions pulpcore/app/serializers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
get_request_without_query_params,
get_domain,
reverse,
get_prn,
resolve_prn,
)


Expand All @@ -61,14 +63,22 @@ def _patched_reverse(viewname, request=None, args=None, kwargs=None, **extra):
return reverse


class HrefFieldMixin:
"""A mixin to configure related fields to generate relative hrefs."""
class HrefPrnFieldMixin:
"""A mixin to configure related fields to generate relative hrefs and accept PRNs."""

def get_url(self, obj, view_name, request, *args, **kwargs):
# Use the Pulp reverse method to display relative hrefs.
self.reverse = _reverse(obj)
return super().get_url(obj, view_name, request, *args, **kwargs)

def to_internal_value(self, data):
# Properly also handle PRNs as values by converting them to URLs first
if data.startswith("prn:"):
model, pk = resolve_prn(data)
obj = model(pk=pk) if self.use_pk_only_optimization() else model.objects.get(pk=pk)
data = self.get_url(obj, self.view_name, self.context.get("request"), None)
return super().to_internal_value(data)


class _MatchingRegexViewName(object):
"""This is a helper class to help defining object matching rules for master-detail.
Expand All @@ -90,7 +100,7 @@ def __eq__(self, other):
return re.fullmatch(self.pattern, other) is not None


class _DetailFieldMixin(HrefFieldMixin):
class _DetailFieldMixin(HrefPrnFieldMixin):
"""Mixin class containing code common to DetailIdentityField and DetailRelatedField"""

def __init__(self, view_name=None, view_name_pattern=None, **kwargs):
Expand Down Expand Up @@ -132,7 +142,7 @@ def get_url(self, obj, view_name, request, *args, **kwargs):


class IdentityField(
HrefFieldMixin,
HrefPrnFieldMixin,
serializers.HyperlinkedIdentityField,
):
"""IdentityField for use in the pulp_href field of non-Master/Detail Serializers.
Expand All @@ -142,7 +152,7 @@ class IdentityField(


class RelatedField(
HrefFieldMixin,
HrefPrnFieldMixin,
serializers.HyperlinkedRelatedField,
):
"""RelatedField when relating to non-Master/Detail models
Expand All @@ -151,6 +161,17 @@ class RelatedField(
"""


class PRNField(serializers.StringRelatedField):
"""A special IdentityField that shows any object's PRN."""

def __init__(self, **kwargs):
kwargs["source"] = "*"
super().__init__(**kwargs)

def to_representation(self, value):
return get_prn(instance=value)


PKObject = namedtuple("PKObject", ["pk"])
PKDomainObject = namedtuple("PKDomainObject", ["pk", "pulp_domain"])

Expand Down Expand Up @@ -255,15 +276,15 @@ class to get the relevant `view_name`.
return False


class NestedIdentityField(HrefFieldMixin, NestedHyperlinkedIdentityField):
class NestedIdentityField(HrefPrnFieldMixin, NestedHyperlinkedIdentityField):
"""NestedIdentityField for use with nested resources.
When using this field in a serializer, it serializes the resource as a relative URL.
"""


class NestedRelatedField(
HrefFieldMixin,
HrefPrnFieldMixin,
NestedHyperlinkedRelatedField,
):
"""NestedRelatedField for use when relating to nested resources.
Expand Down Expand Up @@ -417,8 +438,9 @@ class ModelSerializer(
exclude_arg_name = "exclude_fields"

class Meta:
fields = ("pulp_href", "pulp_created", "pulp_last_updated")
fields = ("pulp_href", "prn", "pulp_created", "pulp_last_updated")

prn = PRNField(help_text=_("The Pulp Resource Name (PRN)."))
pulp_created = serializers.DateTimeField(help_text=_("Timestamp of creation."), read_only=True)
pulp_last_updated = serializers.DateTimeField(
help_text=_(
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ class Meta:
model = models.TaskGroup
fields = (
"pulp_href",
"prn",
"description",
"all_tasks_dispatched",
"waiting",
Expand Down
12 changes: 9 additions & 3 deletions pulpcore/app/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ModelSerializer,
HiddenFieldsMixin,
RelatedField,
PRNField,
)
from pulpcore.app.util import (
get_viewset_for_model,
Expand Down Expand Up @@ -86,16 +87,18 @@ class UserGroupSerializer(serializers.ModelSerializer):

name = serializers.CharField(help_text=_("Name."), max_length=150)
pulp_href = IdentityField(view_name="groups-detail")
prn = PRNField()

class Meta:
model = Group
fields = ("name", "pulp_href")
fields = ("name", "pulp_href", "prn")


class UserSerializer(serializers.ModelSerializer, HiddenFieldsMixin):
"""Serializer for User."""

pulp_href = IdentityField(view_name="users-detail")
prn = PRNField()
id = serializers.IntegerField(read_only=True)
username = serializers.CharField(
help_text=_("Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."),
Expand Down Expand Up @@ -138,6 +141,7 @@ class Meta:
model = User
fields = (
"pulp_href",
"prn",
"id",
"username",
"password",
Expand All @@ -160,10 +164,11 @@ class GroupUserSerializer(ValidateFieldsMixin, serializers.ModelSerializer):
max_length=150,
)
pulp_href = IdentityField(view_name="users-detail")
prn = PRNField()

class Meta:
model = User
fields = ("username", "pulp_href")
fields = ("username", "pulp_href", "prn")


class GroupSerializer(ValidateFieldsMixin, serializers.ModelSerializer):
Expand All @@ -176,10 +181,11 @@ class GroupSerializer(ValidateFieldsMixin, serializers.ModelSerializer):
max_length=150,
validators=[UniqueValidator(queryset=Group.objects.all())],
)
prn = PRNField()

class Meta:
model = Group
fields = ("name", "pulp_href", "id")
fields = ("name", "pulp_href", "prn", "id")


class RoleSerializer(ModelSerializer):
Expand Down
63 changes: 56 additions & 7 deletions pulpcore/app/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
from contextlib import ExitStack
from contextvars import ContextVar
from datetime import timedelta
from uuid import UUID

from django.apps import apps
from django.conf import settings
from django.db import connection
from django.db.models import Model, Sum
Expand Down Expand Up @@ -120,12 +122,51 @@ def get_prn(instance=None, uri=None):
return f"prn:{instance._meta.label_lower}:{instance.pk}"


def extract_pk(uri):
def resolve_prn(prn):
"""
Resolve a resource URI to a simple PK value.
Resolve a PRN to its model and pk.
Provides a means to resolve an href passed in a POST body to a primary key.
Doesn't assume anything about whether the resource corresponding to the URI
Args:
prn (str): The PRN to resolve.
Returns:
model_pk_tuple (tuple): A tuple of the model class and pk of the PRN
Raises:
rest_framework.exceptions.ValidationError: on invalid PRN.
"""
if not prn.startswith("prn:"):
raise ValidationError(_("PRN must start with 'prn:': {}").format(prn))
split = prn.split(":")
if len(split) != 3:
raise ValidationError(
_("PRN must be of the form 'prn:app_label.model_label:pk': {}").format(prn)
)
p, full_model_label, pk = split
try:
model = apps.get_model(full_model_label)
except LookupError:
raise ValidationError(_("Model {} does not exist").format(full_model_label))
except ValueError:
raise ValidationError(
_("The model must be in the form of 'app_label.model_label': {}").format(
full_model_label
)
)
try:
UUID(pk, version=4)
except ValueError:
raise ValidationError(_("PK invalid: {}").format(pk))

return model, pk


def extract_pk(uri, only_prn=False):
"""
Resolve a resource URI or PRN to a simple PK value.
Provides a means to resolve an href/prn passed in a POST body to a primary key.
Doesn't assume anything about whether the resource corresponding to the URI/PRN
passed in actually exists.
Note:
Expand All @@ -134,14 +175,22 @@ def extract_pk(uri):
RepositoryVersion PK is present within the URI.
Args:
uri (str): A resource URI.
uri (str): A resource URI/PRN.
only_prn (bool): Ensure passed in value is only a valid PRN
Returns:
primary_key (uuid.uuid4): The primary key of the resource extracted from the URI.
primary_key (uuid.uuid4): The primary key of the resource extracted from the URI/PRN.
Raises:
rest_framework.exceptions.ValidationError: on invalid URI.
rest_framework.exceptions.ValidationError: on invalid URI/PRN.
"""
if uri.startswith("prn:"):
prn = uri.split(":")
if len(prn) != 3:
raise ValidationError(_("PRN not valid: {p}").format(p=prn))
return prn[2]
elif only_prn:
raise ValidationError(_("Not a valid PRN: {p}, must start with 'prn:'").format(p=uri))
try:
match = resolve(urlparse(uri).path)
except Resolver404:
Expand Down
2 changes: 1 addition & 1 deletion pulpcore/app/viewsets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
SigningServiceViewSet,
)
from .custom_filters import (
RepoVersionHrefFilter,
RepoVersionHrefPrnFilter,
RepositoryVersionFilter,
)
from .domain import DomainViewSet
Expand Down
40 changes: 24 additions & 16 deletions pulpcore/app/viewsets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
SetLabelSerializer,
UnsetLabelSerializer,
)
from pulpcore.app.util import get_viewset_for_model
from pulpcore.app.util import get_viewset_for_model, resolve_prn
from pulpcore.tasking.tasks import dispatch

# These should be used to prevent duplication and keep things consistent
Expand Down Expand Up @@ -158,35 +158,43 @@ def get_resource_model(uri):
@staticmethod
def get_resource(uri, model=None):
"""
Resolve a resource URI to an instance of the resource.
Resolve a resource URI/PRN to an instance of the resource.
Provides a means to resolve an href passed in a POST body to an
Provides a means to resolve an href/prn passed in a POST body to an
instance of the resource.
Args:
uri (str): A resource URI.
uri (str): A resource URI/PRN.
model (django.models.Model): A model class. If not provided, the method automatically
determines the used model from the resource URI.
determines the used model from the resource URI/PRN.
Returns:
django.models.Model: The resource fetched from the DB.
Raises:
rest_framework.exceptions.ValidationError: on invalid URI or resource not found.
rest_framework.exceptions.ValidationError: on invalid URI/PRN or resource not found.
"""
try:
match = resolve(urlparse(uri).path)
except Resolver404:
raise DRFValidationError(detail=_("URI not valid: {u}").format(u=uri))

if model is None:
model = match.func.cls.queryset.model
found_kwargs = {}
if uri.startswith("prn:"):
m, pk = resolve_prn(uri)
if model is None:
model = m
found_kwargs["pk"] = pk
else:
try:
match = resolve(urlparse(uri).path)
except Resolver404:
raise DRFValidationError(detail=_("URI not valid: {u}").format(u=uri))
else:
if model is None:
model = match.func.cls.queryset.model
found_kwargs = match.kwargs

if "pk" in match.kwargs:
kwargs = {"pk": match.kwargs["pk"]}
if "pk" in found_kwargs:
kwargs = {"pk": found_kwargs["pk"]}
else:
kwargs = {}
for key, value in match.kwargs.items():
for key, value in found_kwargs.items():
if key.endswith("_pk"):
kwargs["{}__pk".format(key[:-3])] = value
elif key == "pulp_domain":
Expand Down
Loading

0 comments on commit fd666c7

Please sign in to comment.