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
1011from pypi_attestations import AttestationError
1112from pydantic import TypeAdapter , ValidationError
1213from urllib .parse import urljoin
1314
1415from pulpcore .plugin import models as core_models
1516from pulpcore .plugin import serializers as core_serializers
16- from pulpcore .plugin .util import get_domain , get_prn , get_current_authenticated_user
17+ from pulpcore .plugin .util import get_domain , get_prn , get_current_authenticated_user , reverse
1718
1819from pulp_python .app import models as python_models
20+ from pulp_python .app .utils import canonicalize_name
1921from pulp_python .app .provenance import (
2022 Attestation ,
2123 Provenance ,
@@ -53,6 +55,11 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
5355 default = False ,
5456 required = False ,
5557 )
58+ blocklist_entries_href = serializers .SerializerMethodField (
59+ help_text = _ ("URL to the blocklist entries for this repository." ),
60+ read_only = True ,
61+ )
62+
5663 allow_package_substitution = serializers .BooleanField (
5764 help_text = _ (
5865 "Whether to allow package substitution (replacing existing packages with packages "
@@ -65,10 +72,15 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
6572 required = False ,
6673 )
6774
75+ def get_blocklist_entries_href (self , obj ):
76+ repo_href = reverse ("repositories-python/python-detail" , kwargs = {"pk" : obj .pk })
77+ return f"{ repo_href } blocklist_entries/"
78+
6879 class Meta :
6980 fields = core_serializers .RepositorySerializer .Meta .fields + (
7081 "autopublish" ,
7182 "allow_package_substitution" ,
83+ "blocklist_entries_href" ,
7284 )
7385 model = python_models .PythonRepository
7486
@@ -780,6 +792,115 @@ class Meta:
780792 model = python_models .PythonRemote
781793
782794
795+ class PythonBlocklistEntrySerializer (core_serializers .ModelSerializer ):
796+ """
797+ Serializer for PythonBlocklistEntry.
798+
799+ The `repository` is supplied by the URL (not the request body) and is injected
800+ by the viewset before saving.
801+ """
802+
803+ pulp_href = serializers .SerializerMethodField (
804+ read_only = True ,
805+ help_text = _ ("The URL of this blocklist entry." ),
806+ )
807+ repository = core_serializers .DetailRelatedField (
808+ read_only = True ,
809+ view_name_pattern = r"repositories(-.*/.*)?-detail" ,
810+ help_text = _ ("Repository this blocklist entry belongs to." ),
811+ )
812+ name = serializers .CharField (
813+ required = False ,
814+ allow_null = True ,
815+ default = None ,
816+ help_text = _ (
817+ "Package name to block (for all versions). Compared after PEP 503 normalization. "
818+ "Required when 'filename' is not provided."
819+ ),
820+ )
821+ version = serializers .CharField (
822+ required = False ,
823+ allow_null = True ,
824+ default = None ,
825+ help_text = _ ("Exact version string to block (e.g. '1.0'). Only used when 'name' is set." ),
826+ )
827+ filename = serializers .CharField (
828+ required = False ,
829+ allow_null = True ,
830+ default = None ,
831+ help_text = _ ("Exact filename to block. Required when 'name' is not provided." ),
832+ )
833+ added_by = serializers .CharField (
834+ read_only = True ,
835+ help_text = _ ("PRN of the user who added this blocklist entry." ),
836+ )
837+
838+ def get_pulp_href (self , obj ):
839+ repo_href = reverse ("repositories-python/python-detail" , kwargs = {"pk" : obj .repository_id })
840+ return f"{ repo_href } blocklist_entries/{ obj .pk } /"
841+
842+ def validate (self , data ):
843+ """
844+ Validate that the blocklist entry is well-formed and not a duplicate.
845+ """
846+ name = data .get ("name" )
847+ filename = data .get ("filename" )
848+ version = data .get ("version" )
849+
850+ if version and filename :
851+ raise serializers .ValidationError (_ ("'version' cannot be used with 'filename'." ))
852+ if version and not name :
853+ raise serializers .ValidationError (_ ("'version' requires 'name' to be provided." ))
854+ if bool (name ) == bool (filename ):
855+ raise serializers .ValidationError (
856+ _ ("Exactly one of 'name' or 'filename' must be provided." )
857+ )
858+
859+ if version :
860+ try :
861+ Version (version )
862+ except InvalidVersion :
863+ raise serializers .ValidationError (
864+ {"version" : _ ("'{}' is not a valid version." ).format (version )}
865+ )
866+ if name :
867+ data ["name" ] = canonicalize_name (name )
868+ name = data ["name" ]
869+
870+ repository = self .context .get ("repository" )
871+ if repository :
872+ qs = python_models .PythonBlocklistEntry .objects .filter (repository = repository )
873+ if name and qs .filter (name = name , version = version ).exists ():
874+ raise serializers .ValidationError (
875+ _ ("A blocklist entry with this name and version already exists." )
876+ )
877+ if filename and qs .filter (filename = filename ).exists ():
878+ raise serializers .ValidationError (
879+ _ ("A blocklist entry with this filename already exists." )
880+ )
881+ data ["repository" ] = repository
882+
883+ return data
884+
885+ def create (self , validated_data ):
886+ """
887+ Create a new blocklist entry, recording the authenticated user in `added_by`.
888+ """
889+ user = get_current_authenticated_user ()
890+ validated_data ["added_by" ] = get_prn (user ) if user else ""
891+ return super ().create (validated_data )
892+
893+ class Meta :
894+ fields = core_serializers .ModelSerializer .Meta .fields + (
895+ "repository" ,
896+ "name" ,
897+ "version" ,
898+ "filename" ,
899+ "added_by" ,
900+ )
901+ model = python_models .PythonBlocklistEntry
902+
903+
783904class PythonBanderRemoteSerializer (serializers .Serializer ):
784905 """
785906 A Serializer for the initial step of creating a Python Remote from a Bandersnatch config file
0 commit comments