66from django .db .utils import IntegrityError
77from drf_spectacular .utils import extend_schema_serializer
88from packaging .requirements import Requirement
9+ from packaging .version import Version , InvalidVersion
910from rest_framework import serializers
11+ from rest_framework_nested .relations import NestedHyperlinkedIdentityField
12+ from rest_framework_nested .serializers import NestedHyperlinkedModelSerializer
1013from pypi_attestations import AttestationError
1114from pydantic import TypeAdapter , ValidationError
1215from urllib .parse import urljoin
1316
1417from pulpcore .plugin import models as core_models
1518from pulpcore .plugin import serializers as core_serializers
16- from pulpcore .plugin .util import get_domain , get_prn , get_current_authenticated_user
19+ from pulpcore .plugin .util import get_domain , get_prn , get_current_authenticated_user , reverse
1720
1821from pulp_python .app import models as python_models
1922from pulp_python .app .provenance import (
@@ -53,6 +56,11 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
5356 default = False ,
5457 required = False ,
5558 )
59+ blocklist_entries_href = serializers .SerializerMethodField (
60+ help_text = _ ("URL to the blocklist entries for this repository." ),
61+ read_only = True ,
62+ )
63+
5664 allow_package_substitution = serializers .BooleanField (
5765 help_text = _ (
5866 "Whether to allow package substitution (replacing existing packages with packages "
@@ -65,10 +73,15 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
6573 required = False ,
6674 )
6775
76+ def get_blocklist_entries_href (self , obj ):
77+ repo_href = reverse ("repositories-python/python-detail" , kwargs = {"pk" : obj .pk })
78+ return f"{ repo_href } blocklist_entries/"
79+
6880 class Meta :
6981 fields = core_serializers .RepositorySerializer .Meta .fields + (
7082 "autopublish" ,
7183 "allow_package_substitution" ,
84+ "blocklist_entries_href" ,
7285 )
7386 model = python_models .PythonRepository
7487
@@ -780,6 +793,117 @@ class Meta:
780793 model = python_models .PythonRemote
781794
782795
796+ class _NestedIdentityField (NestedHyperlinkedIdentityField ):
797+ """
798+ NestedHyperlinkedIdentityField that uses pulpcore's reverse for relative URLs.
799+ Mimics NestedIdentityField from pulpcore, which is not exposed via the plugin API.
800+ """
801+
802+ def get_url (self , obj , view_name , request , * args , ** kwargs ):
803+ self .reverse = reverse
804+ return super ().get_url (obj , view_name , request , * args , ** kwargs )
805+
806+
807+ class PythonBlocklistEntrySerializer (
808+ core_serializers .ModelSerializer , NestedHyperlinkedModelSerializer
809+ ):
810+ """
811+ Serializer for PythonBlocklistEntry.
812+
813+ The `repository` is supplied by the URL (not the request body) and is injected
814+ by the viewset before saving.
815+ """
816+
817+ pulp_href = _NestedIdentityField (
818+ view_name = "blocklist_entries-detail" ,
819+ parent_lookup_kwargs = {"repository_pk" : "repository__pk" },
820+ )
821+ repository = core_serializers .DetailRelatedField (
822+ read_only = True ,
823+ view_name_pattern = r"repositories(-.*/.*)?-detail" ,
824+ help_text = _ ("Repository this blocklist entry belongs to." ),
825+ )
826+ name = serializers .CharField (
827+ required = False ,
828+ allow_null = True ,
829+ default = None ,
830+ help_text = _ (
831+ "Package name to block (for all versions). Compared after PEP 503 normalization. "
832+ "Required when 'filename' is not provided."
833+ ),
834+ )
835+ version = serializers .CharField (
836+ required = False ,
837+ allow_null = True ,
838+ default = None ,
839+ help_text = _ ("Exact version string to block (e.g. '1.0'). Only used when 'name' is set." ),
840+ )
841+ filename = serializers .CharField (
842+ required = False ,
843+ allow_null = True ,
844+ default = None ,
845+ help_text = _ ("Exact filename to block. Required when 'name' is not provided." ),
846+ )
847+ added_by = serializers .CharField (read_only = True )
848+
849+ def validate (self , data ):
850+ """
851+ Validate that the blocklist entry is well-formed and not a duplicate.
852+ """
853+ name = data .get ("name" )
854+ filename = data .get ("filename" )
855+ version = data .get ("version" )
856+
857+ if version and filename :
858+ raise serializers .ValidationError (_ ("'version' cannot be used with 'filename'." ))
859+ if version and not name :
860+ raise serializers .ValidationError (_ ("'version' requires 'name' to be provided." ))
861+ if name and filename :
862+ raise serializers .ValidationError (_ ("'name' and 'filename' are mutually exclusive." ))
863+ if not name and not filename :
864+ raise serializers .ValidationError (_ ("Either 'name' or 'filename' must be provided." ))
865+
866+ if version :
867+ try :
868+ Version (version )
869+ except InvalidVersion :
870+ raise serializers .ValidationError (
871+ {"version" : _ ("'{}' is not a valid version." ).format (version )}
872+ )
873+
874+ repository = self .context .get ("repository" )
875+ if repository :
876+ qs = python_models .PythonBlocklistEntry .objects .filter (repository = repository )
877+ if name and qs .filter (name = name , version = version ).exists ():
878+ raise serializers .ValidationError (
879+ _ ("A blocklist entry with this name and version already exists." )
880+ )
881+ if filename and qs .filter (filename = filename ).exists ():
882+ raise serializers .ValidationError (
883+ _ ("A blocklist entry with this filename already exists." )
884+ )
885+
886+ return data
887+
888+ def create (self , validated_data ):
889+ """
890+ Create a new blocklist entry, recording the authenticated user in `added_by`.
891+ """
892+ user = get_current_authenticated_user ()
893+ validated_data ["added_by" ] = get_prn (user ) if user else ""
894+ return super ().create (validated_data )
895+
896+ class Meta :
897+ fields = core_serializers .ModelSerializer .Meta .fields + (
898+ "repository" ,
899+ "name" ,
900+ "version" ,
901+ "filename" ,
902+ "added_by" ,
903+ )
904+ model = python_models .PythonBlocklistEntry
905+
906+
783907class PythonBanderRemoteSerializer (serializers .Serializer ):
784908 """
785909 A Serializer for the initial step of creating a Python Remote from a Bandersnatch config file
0 commit comments