pulp-python 3.28.2__py3-none-any.whl → 3.30.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. pulp_python/app/__init__.py +5 -3
  2. pulp_python/app/management/commands/repair-python-metadata.py +7 -3
  3. pulp_python/app/migrations/0022_pythonblocklistentry.py +48 -0
  4. pulp_python/app/modelresource.py +1 -0
  5. pulp_python/app/models.py +78 -12
  6. pulp_python/app/pypi/serializers.py +7 -5
  7. pulp_python/app/pypi/views.py +23 -24
  8. pulp_python/app/replica.py +3 -2
  9. pulp_python/app/serializers.py +136 -14
  10. pulp_python/app/tasks/publish.py +2 -2
  11. pulp_python/app/tasks/repair.py +4 -2
  12. pulp_python/app/tasks/sync.py +13 -15
  13. pulp_python/app/tasks/upload.py +7 -6
  14. pulp_python/app/urls.py +2 -2
  15. pulp_python/app/utils.py +8 -6
  16. pulp_python/app/viewsets.py +81 -3
  17. pulp_python/pytest_plugin.py +8 -6
  18. pulp_python/tests/functional/api/test_attestations.py +2 -2
  19. pulp_python/tests/functional/api/test_blocklist.py +152 -0
  20. pulp_python/tests/functional/api/test_consume_content.py +0 -1
  21. pulp_python/tests/functional/api/test_crud_content_unit.py +5 -4
  22. pulp_python/tests/functional/api/test_crud_publications.py +6 -5
  23. pulp_python/tests/functional/api/test_crud_remotes.py +3 -2
  24. pulp_python/tests/functional/api/test_domains.py +6 -5
  25. pulp_python/tests/functional/api/test_download_content.py +2 -2
  26. pulp_python/tests/functional/api/test_export_import.py +5 -3
  27. pulp_python/tests/functional/api/test_full_mirror.py +9 -9
  28. pulp_python/tests/functional/api/test_pypi_apis.py +5 -5
  29. pulp_python/tests/functional/api/test_pypi_simple_api.py +2 -2
  30. pulp_python/tests/functional/api/test_rbac.py +5 -4
  31. pulp_python/tests/functional/api/test_repair.py +2 -1
  32. pulp_python/tests/functional/api/test_sync.py +11 -11
  33. pulp_python/tests/functional/api/test_upload.py +5 -3
  34. pulp_python/tests/functional/utils.py +2 -2
  35. {pulp_python-3.28.2.dist-info → pulp_python-3.30.0.dist-info}/METADATA +1 -1
  36. {pulp_python-3.28.2.dist-info → pulp_python-3.30.0.dist-info}/RECORD +40 -38
  37. {pulp_python-3.28.2.dist-info → pulp_python-3.30.0.dist-info}/WHEEL +0 -0
  38. {pulp_python-3.28.2.dist-info → pulp_python-3.30.0.dist-info}/entry_points.txt +0 -0
  39. {pulp_python-3.28.2.dist-info → pulp_python-3.30.0.dist-info}/licenses/LICENSE +0 -0
  40. {pulp_python-3.28.2.dist-info → pulp_python-3.30.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,8 @@
1
+ from gettext import gettext as _
2
+
1
3
  from django.db.models.signals import post_migrate
4
+
2
5
  from pulpcore.plugin import PulpPluginAppConfig
3
- from gettext import gettext as _
4
6
 
5
7
 
6
8
  class PulpPythonPluginAppConfig(PulpPluginAppConfig):
@@ -10,7 +12,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
10
12
 
11
13
  name = "pulp_python.app"
12
14
  label = "python"
13
- version = "3.28.2"
15
+ version = "3.30.0"
14
16
  python_package_name = "pulp-python"
15
17
  domain_compatible = True
16
18
 
@@ -26,7 +28,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
26
28
 
27
29
  # TODO: Remove this when https://github.com/pulp/pulpcore/issues/5500 is resolved
28
30
  def _populate_pypi_access_policies(sender, apps, verbosity, **kwargs):
29
- from pulp_python.app.pypi.views import PyPIView, SimpleView, UploadView, MetadataView
31
+ from pulp_python.app.pypi.views import MetadataView, PyPIView, SimpleView, UploadView
30
32
 
31
33
  try:
32
34
  AccessPolicy = apps.get_model("core", "AccessPolicy")
@@ -1,11 +1,12 @@
1
- import re
2
1
  import os
3
- from django.core.management import BaseCommand, CommandError
2
+ import re
4
3
  from gettext import gettext as _
5
4
 
6
5
  from django.conf import settings
6
+ from django.core.management import BaseCommand, CommandError
7
7
 
8
8
  from pulpcore.plugin.util import extract_pk
9
+
9
10
  from pulp_python.app.models import PythonPackageContent, PythonRepository
10
11
  from pulp_python.app.utils import artifact_to_python_content_data
11
12
 
@@ -78,7 +79,10 @@ class Command(BaseCommand):
78
79
  Management command to repair metadata of PythonPackageContent.
79
80
  """
80
81
 
81
- help = _("Repair the metadata of PythonPackageContent stored in PythonRepositories")
82
+ help = _(
83
+ "[Deprecated] Use the repository `repair_metadata` task instead. "
84
+ "Repair the metadata of PythonPackageContent stored in PythonRepositories."
85
+ )
82
86
 
83
87
  def add_arguments(self, parser):
84
88
  """Set up arguments."""
@@ -0,0 +1,48 @@
1
+ # Generated by Django 5.2.10 on 2026-04-16 14:00
2
+
3
+ import django.db.models.deletion
4
+ import django_lifecycle.mixins
5
+ import pulpcore.app.models.base
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ dependencies = [
12
+ ("python", "0021_pythonrepository_upload_duplicate_filenames"),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="PythonBlocklistEntry",
18
+ fields=[
19
+ (
20
+ "pulp_id",
21
+ models.UUIDField(
22
+ default=pulpcore.app.models.base.pulp_uuid,
23
+ editable=False,
24
+ primary_key=True,
25
+ serialize=False,
26
+ ),
27
+ ),
28
+ ("pulp_created", models.DateTimeField(auto_now_add=True)),
29
+ ("pulp_last_updated", models.DateTimeField(auto_now=True, null=True)),
30
+ ("name", models.TextField(default=None, null=True)),
31
+ ("version", models.TextField(default=None, null=True)),
32
+ ("filename", models.TextField(default=None, null=True)),
33
+ ("added_by", models.TextField(default="")),
34
+ (
35
+ "repository",
36
+ models.ForeignKey(
37
+ on_delete=django.db.models.deletion.CASCADE,
38
+ related_name="blocklist_entries",
39
+ to="python.pythonrepository",
40
+ ),
41
+ ),
42
+ ],
43
+ options={
44
+ "default_related_name": "%(app_label)s_%(model_name)s",
45
+ },
46
+ bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
47
+ ),
48
+ ]
@@ -1,6 +1,7 @@
1
1
  from pulpcore.plugin.importexport import BaseContentResource
2
2
  from pulpcore.plugin.modelresources import RepositoryResource
3
3
  from pulpcore.plugin.util import get_domain
4
+
4
5
  from pulp_python.app.models import (
5
6
  PythonPackageContent,
6
7
  PythonRepository,
pulp_python/app/models.py CHANGED
@@ -1,43 +1,45 @@
1
1
  import hashlib
2
2
  import json
3
3
  from logging import getLogger
4
+ from pathlib import PurePath
4
5
 
5
6
  from aiohttp.web import json_response
7
+ from django.conf import settings
6
8
  from django.contrib.postgres.fields import ArrayField
7
9
  from django.core.exceptions import ObjectDoesNotExist
8
10
  from django.db import models
9
- from django.conf import settings
10
11
  from django_lifecycle import (
11
12
  BEFORE_SAVE,
12
13
  hook,
13
14
  )
14
15
  from rest_framework.serializers import ValidationError
16
+
15
17
  from pulpcore.plugin.models import (
16
18
  AutoAddObjPermsMixin,
19
+ BaseModel,
17
20
  Content,
18
- Publication,
19
21
  Distribution,
22
+ Publication,
20
23
  Remote,
21
24
  Repository,
22
25
  )
26
+ from pulpcore.plugin.repo_version_utils import (
27
+ collect_duplicates,
28
+ remove_duplicates,
29
+ validate_repo_version,
30
+ )
23
31
  from pulpcore.plugin.responses import ArtifactResponse
32
+ from pulpcore.plugin.util import get_domain, get_domain_pk
24
33
 
25
- from pathlib import PurePath
26
34
  from .provenance import Provenance
27
35
  from .utils import (
28
- artifact_to_python_content_data,
36
+ PYPI_LAST_SERIAL,
37
+ PYPI_SERIAL_CONSTANT,
29
38
  artifact_to_metadata_artifact,
39
+ artifact_to_python_content_data,
30
40
  canonicalize_name,
31
41
  python_content_to_json,
32
- PYPI_LAST_SERIAL,
33
- PYPI_SERIAL_CONSTANT,
34
- )
35
- from pulpcore.plugin.repo_version_utils import (
36
- collect_duplicates,
37
- remove_duplicates,
38
- validate_repo_version,
39
42
  )
40
- from pulpcore.plugin.util import get_domain_pk, get_domain
41
43
 
42
44
  log = getLogger(__name__)
43
45
 
@@ -399,9 +401,12 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
399
401
 
400
402
  When allow_package_substitution is False, reject any new version that would implicitly
401
403
  replace existing content with different checksums (content substitution).
404
+
405
+ Also checks newly added content against the repository's blocklist entries.
402
406
  """
403
407
  if not self.allow_package_substitution:
404
408
  self._check_for_package_substitution(new_version)
409
+ self._check_blocklist(new_version)
405
410
  remove_duplicates(new_version)
406
411
  validate_repo_version(new_version)
407
412
 
@@ -418,3 +423,64 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
418
423
  "To allow this, set 'allow_package_substitution' to True on the repository. "
419
424
  f"Conflicting packages: {duplicates}"
420
425
  )
426
+
427
+ def _check_blocklist(self, new_version):
428
+ """
429
+ Check newly added content in a repository version against the blocklist.
430
+ """
431
+ added_content = PythonPackageContent.objects.filter(
432
+ pk__in=new_version.added().values_list("pk", flat=True)
433
+ ).only("filename", "name_normalized", "version")
434
+ if added_content.exists():
435
+ self.check_blocklist_for_packages(added_content)
436
+
437
+ def check_blocklist_for_packages(self, packages):
438
+ """
439
+ Raise a ValidationError if any of the given packages match a blocklist entry.
440
+ """
441
+ entries = PythonBlocklistEntry.objects.filter(repository=self)
442
+ if not entries.exists():
443
+ return
444
+
445
+ blocked = []
446
+ for pkg in packages:
447
+ for entry in entries:
448
+ if entry.filename and entry.filename == pkg.filename:
449
+ blocked.append(pkg.filename)
450
+ break
451
+ if entry.name == pkg.name_normalized:
452
+ if not entry.version or entry.version == pkg.version:
453
+ blocked.append(pkg.filename)
454
+ break
455
+ if blocked:
456
+ raise ValidationError(
457
+ "Blocklisted packages cannot be added to this repository: {}".format(
458
+ ", ".join(blocked)
459
+ )
460
+ )
461
+
462
+
463
+ class PythonBlocklistEntry(BaseModel):
464
+ """
465
+ An entry in a PythonRepository's package blocklist.
466
+
467
+ Blocklist entries prevent packages from being added to the repository.
468
+ Entries can match by package `name` (all versions), package `name` + `version`,
469
+ or exact `filename`. Exactly one of `name` or `filename` must be provided.
470
+ """
471
+
472
+ name = models.TextField(null=True, default=None)
473
+ version = models.TextField(null=True, default=None)
474
+ filename = models.TextField(null=True, default=None)
475
+ added_by = models.TextField(default="")
476
+ repository = models.ForeignKey(
477
+ PythonRepository, on_delete=models.CASCADE, related_name="blocklist_entries"
478
+ )
479
+
480
+ def __str__(self):
481
+ if self.filename:
482
+ return f"<{self._meta.object_name}: {self.filename}>"
483
+ return f"<{self._meta.object_name}: {self.name} [{self.version or 'all'}]>"
484
+
485
+ class Meta:
486
+ default_related_name = "%(app_label)s_%(model_name)s"
@@ -1,13 +1,15 @@
1
1
  import logging
2
2
  from gettext import gettext as _
3
3
 
4
- from rest_framework import serializers
4
+ from django.db.utils import IntegrityError
5
5
  from pydantic import TypeAdapter, ValidationError
6
- from pulp_python.app.provenance import Attestation
7
- from pulp_python.app.utils import DIST_EXTENSIONS, SUPPORTED_METADATA_VERSIONS
6
+ from rest_framework import serializers
7
+
8
8
  from pulpcore.plugin.models import Artifact
9
9
  from pulpcore.plugin.util import get_domain
10
- from django.db.utils import IntegrityError
10
+
11
+ from pulp_python.app.provenance import Attestation
12
+ from pulp_python.app.utils import DIST_EXTENSIONS, SUPPORTED_METADATA_VERSIONS
11
13
 
12
14
  log = logging.getLogger(__name__)
13
15
 
@@ -110,7 +112,7 @@ class PackageUploadSerializer(serializers.Serializer):
110
112
  attestations = TypeAdapter(list[Attestation]).validate_python(attestations)
111
113
  except ValidationError as e:
112
114
  raise serializers.ValidationError(
113
- {"attestations": _("The uploaded attestations are not valid: {}".format(e))}
115
+ {"attestations": _("The uploaded attestations are not valid: {}").format(e)}
114
116
  )
115
117
 
116
118
  sha256 = data.get("sha256_digest")
@@ -1,60 +1,59 @@
1
1
  import logging
2
-
3
- from rest_framework.viewsets import ViewSet
4
- from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, TemplateHTMLRenderer
5
- from rest_framework.response import Response
6
- from rest_framework.exceptions import NotAcceptable
7
- from django.core.exceptions import ObjectDoesNotExist
8
- from django.shortcuts import redirect
9
- from datetime import datetime, timezone, timedelta
2
+ from datetime import datetime, timedelta, timezone
3
+ from itertools import chain
4
+ from pathlib import PurePath
5
+ from urllib.parse import urljoin, urlparse, urlunsplit
10
6
 
11
7
  from django.contrib.sessions.models import Session
8
+ from django.core.exceptions import ObjectDoesNotExist
12
9
  from django.db import transaction
13
10
  from django.db.utils import DatabaseError
14
11
  from django.http.response import (
15
12
  Http404,
16
- HttpResponseNotFound,
17
- HttpResponseForbidden,
13
+ HttpResponse,
18
14
  HttpResponseBadRequest,
15
+ HttpResponseForbidden,
16
+ HttpResponseNotFound,
19
17
  StreamingHttpResponse,
20
- HttpResponse,
21
18
  )
19
+ from django.shortcuts import redirect
22
20
  from drf_spectacular.utils import extend_schema
23
21
  from dynaconf import settings
24
- from itertools import chain
25
22
  from packaging.utils import canonicalize_name
26
- from urllib.parse import urljoin, urlparse, urlunsplit
27
- from pathlib import PurePath
23
+ from rest_framework.exceptions import NotAcceptable
24
+ from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, TemplateHTMLRenderer
25
+ from rest_framework.response import Response
26
+ from rest_framework.viewsets import ViewSet
28
27
 
29
- from pulpcore.plugin.viewsets import OperationPostponedResponse
30
28
  from pulpcore.plugin.tasking import dispatch
31
29
  from pulpcore.plugin.util import get_domain, get_url
30
+ from pulpcore.plugin.viewsets import OperationPostponedResponse
31
+
32
+ from pulp_python.app import tasks
32
33
  from pulp_python.app.models import (
34
+ PackageProvenance,
33
35
  PythonDistribution,
34
36
  PythonPackageContent,
35
37
  PythonPublication,
36
- PackageProvenance,
37
38
  )
38
39
  from pulp_python.app.pypi.serializers import (
39
- SummarySerializer,
40
40
  PackageMetadataSerializer,
41
41
  PackageUploadSerializer,
42
42
  PackageUploadTaskSerializer,
43
+ SummarySerializer,
43
44
  )
44
45
  from pulp_python.app.utils import (
45
- write_simple_index,
46
- write_simple_index_json,
47
- write_simple_detail,
48
- write_simple_detail_json,
49
- python_content_to_json,
50
46
  PYPI_LAST_SERIAL,
51
47
  PYPI_SERIAL_CONSTANT,
52
48
  get_remote_package_filter,
53
49
  get_remote_simple_page,
50
+ python_content_to_json,
51
+ write_simple_detail,
52
+ write_simple_detail_json,
53
+ write_simple_index,
54
+ write_simple_index_json,
54
55
  )
55
56
 
56
- from pulp_python.app import tasks
57
-
58
57
  log = logging.getLogger(__name__)
59
58
 
60
59
  ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
@@ -1,10 +1,11 @@
1
- from pulpcore.plugin.replica import Replicator
2
-
3
1
  from pulp_glue.python.context import (
4
2
  PulpPythonDistributionContext,
5
3
  PulpPythonPublicationContext,
6
4
  PulpPythonRepositoryContext,
7
5
  )
6
+
7
+ from pulpcore.plugin.replica import Replicator
8
+
8
9
  from pulp_python.app.models import PythonDistribution, PythonRemote, PythonRepository
9
10
  from pulp_python.app.tasks import sync as python_sync
10
11
 
@@ -2,31 +2,34 @@ import logging
2
2
  import os
3
3
  import tempfile
4
4
  from gettext import gettext as _
5
+ from urllib.parse import urljoin
6
+
5
7
  from django.conf import settings
6
8
  from django.db.utils import IntegrityError
7
9
  from drf_spectacular.utils import extend_schema_serializer
8
10
  from packaging.requirements import Requirement
9
- from rest_framework import serializers
10
- from pypi_attestations import AttestationError
11
+ from packaging.version import InvalidVersion, Version
11
12
  from pydantic import TypeAdapter, ValidationError
12
- from urllib.parse import urljoin
13
+ from pypi_attestations import AttestationError
14
+ from rest_framework import serializers
13
15
 
14
16
  from pulpcore.plugin import models as core_models
15
17
  from pulpcore.plugin import serializers as core_serializers
16
- from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user
18
+ from pulpcore.plugin.util import get_current_authenticated_user, get_domain, get_prn, reverse
17
19
 
18
20
  from pulp_python.app import models as python_models
19
21
  from pulp_python.app.provenance import (
22
+ AnyPublisher,
20
23
  Attestation,
24
+ AttestationBundle,
21
25
  Provenance,
22
26
  verify_provenance,
23
- AttestationBundle,
24
- AnyPublisher,
25
27
  )
26
28
  from pulp_python.app.utils import (
27
29
  DIST_EXTENSIONS,
28
30
  artifact_to_metadata_artifact,
29
31
  artifact_to_python_content_data,
32
+ canonicalize_name,
30
33
  get_project_metadata_from_file,
31
34
  parse_project_metadata,
32
35
  )
@@ -53,6 +56,11 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
53
56
  default=False,
54
57
  required=False,
55
58
  )
59
+ blocklist_entries_href = serializers.SerializerMethodField(
60
+ help_text=_("URL to the blocklist entries for this repository."),
61
+ read_only=True,
62
+ )
63
+
56
64
  allow_package_substitution = serializers.BooleanField(
57
65
  help_text=_(
58
66
  "Whether to allow package substitution (replacing existing packages with packages "
@@ -65,10 +73,15 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
65
73
  required=False,
66
74
  )
67
75
 
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
+
68
80
  class Meta:
69
81
  fields = core_serializers.RepositorySerializer.Meta.fields + (
70
82
  "autopublish",
71
83
  "allow_package_substitution",
84
+ "blocklist_entries_href",
72
85
  )
73
86
  model = python_models.PythonRepository
74
87
 
@@ -226,7 +239,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
226
239
  required=False,
227
240
  allow_blank=True,
228
241
  help_text=_(
229
- "The maintainer's name at a minimum; " "additional contact information may be provided."
242
+ "The maintainer's name at a minimum; additional contact information may be provided."
230
243
  ),
231
244
  )
232
245
  maintainer_email = serializers.CharField(
@@ -375,7 +388,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
375
388
  else:
376
389
  attestations = TypeAdapter(list[Attestation]).validate_python(value)
377
390
  except ValidationError as e:
378
- raise serializers.ValidationError(_("Invalid attestations: {}".format(e)))
391
+ raise serializers.ValidationError(_("Invalid attestations: {}").format(e))
379
392
  return attestations
380
393
 
381
394
  def handle_attestations(self, filename, sha256, attestations, offline=True):
@@ -388,7 +401,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
388
401
  verify_provenance(filename, sha256, provenance, offline=offline)
389
402
  except AttestationError as e:
390
403
  raise serializers.ValidationError(
391
- {"attestations": _("Attestations failed verification: {}".format(e))}
404
+ {"attestations": _("Attestations failed verification: {}").format(e)}
392
405
  )
393
406
  return provenance.model_dump(mode="json")
394
407
 
@@ -643,13 +656,13 @@ class PackageProvenanceSerializer(core_serializers.NoArtifactContentUploadSerial
643
656
  data["provenance"] = provenance.model_dump(mode="json")
644
657
  except ValidationError as e:
645
658
  raise serializers.ValidationError(
646
- _("The uploaded provenance is not valid: {}".format(e))
659
+ _("The uploaded provenance is not valid: {}").format(e)
647
660
  )
648
661
  if data.pop("verify"):
649
662
  try:
650
663
  verify_provenance(data["package"].filename, data["package"].sha256, provenance)
651
664
  except AttestationError as e:
652
- raise serializers.ValidationError(_("Provenance verification failed: {}".format(e)))
665
+ raise serializers.ValidationError(_("Provenance verification failed: {}").format(e))
653
666
  return data
654
667
 
655
668
  def retrieve(self, validated_data):
@@ -717,7 +730,7 @@ class PythonRemoteSerializer(core_serializers.RemoteSerializer):
717
730
  package_types = MultipleChoiceArrayField(
718
731
  required=False,
719
732
  help_text=_(
720
- "The package types to sync for Python content. Leave blank to get every" "package type."
733
+ "The package types to sync for Python content. Leave blank to get everypackage type."
721
734
  ),
722
735
  choices=python_models.PACKAGE_TYPES,
723
736
  default=list,
@@ -752,7 +765,7 @@ class PythonRemoteSerializer(core_serializers.RemoteSerializer):
752
765
  Requirement(pkg)
753
766
  except ValueError as ve:
754
767
  raise serializers.ValidationError(
755
- _("includes specifier {} is invalid. {}".format(pkg, ve))
768
+ _("includes specifier {} is invalid. {}").format(pkg, ve)
756
769
  )
757
770
  return value
758
771
 
@@ -763,7 +776,7 @@ class PythonRemoteSerializer(core_serializers.RemoteSerializer):
763
776
  Requirement(pkg)
764
777
  except ValueError as ve:
765
778
  raise serializers.ValidationError(
766
- _("excludes specifier {} is invalid. {}".format(pkg, ve))
779
+ _("excludes specifier {} is invalid. {}").format(pkg, ve)
767
780
  )
768
781
  return value
769
782
 
@@ -780,6 +793,115 @@ class PythonRemoteSerializer(core_serializers.RemoteSerializer):
780
793
  model = python_models.PythonRemote
781
794
 
782
795
 
796
+ class PythonBlocklistEntrySerializer(core_serializers.ModelSerializer):
797
+ """
798
+ Serializer for PythonBlocklistEntry.
799
+
800
+ The `repository` is supplied by the URL (not the request body) and is injected
801
+ by the viewset before saving.
802
+ """
803
+
804
+ pulp_href = serializers.SerializerMethodField(
805
+ read_only=True,
806
+ help_text=_("The URL of this blocklist entry."),
807
+ )
808
+ repository = core_serializers.DetailRelatedField(
809
+ read_only=True,
810
+ view_name_pattern=r"repositories(-.*/.*)?-detail",
811
+ help_text=_("Repository this blocklist entry belongs to."),
812
+ )
813
+ name = serializers.CharField(
814
+ required=False,
815
+ allow_null=True,
816
+ default=None,
817
+ help_text=_(
818
+ "Package name to block (for all versions). Compared after PEP 503 normalization. "
819
+ "Required when 'filename' is not provided."
820
+ ),
821
+ )
822
+ version = serializers.CharField(
823
+ required=False,
824
+ allow_null=True,
825
+ default=None,
826
+ help_text=_("Exact version string to block (e.g. '1.0'). Only used when 'name' is set."),
827
+ )
828
+ filename = serializers.CharField(
829
+ required=False,
830
+ allow_null=True,
831
+ default=None,
832
+ help_text=_("Exact filename to block. Required when 'name' is not provided."),
833
+ )
834
+ added_by = serializers.CharField(
835
+ read_only=True,
836
+ help_text=_("PRN of the user who added this blocklist entry."),
837
+ )
838
+
839
+ def get_pulp_href(self, obj):
840
+ repo_href = reverse("repositories-python/python-detail", kwargs={"pk": obj.repository_id})
841
+ return f"{repo_href}blocklist_entries/{obj.pk}/"
842
+
843
+ def validate(self, data):
844
+ """
845
+ Validate that the blocklist entry is well-formed and not a duplicate.
846
+ """
847
+ name = data.get("name")
848
+ filename = data.get("filename")
849
+ version = data.get("version")
850
+
851
+ if version and filename:
852
+ raise serializers.ValidationError(_("'version' cannot be used with 'filename'."))
853
+ if version and not name:
854
+ raise serializers.ValidationError(_("'version' requires 'name' to be provided."))
855
+ if bool(name) == bool(filename):
856
+ raise serializers.ValidationError(
857
+ _("Exactly one of 'name' or 'filename' must be provided.")
858
+ )
859
+
860
+ if version:
861
+ try:
862
+ Version(version)
863
+ except InvalidVersion:
864
+ raise serializers.ValidationError(
865
+ {"version": _("'{}' is not a valid version.").format(version)}
866
+ )
867
+ if name:
868
+ data["name"] = canonicalize_name(name)
869
+ name = data["name"]
870
+
871
+ repository = self.context.get("repository")
872
+ if repository:
873
+ qs = python_models.PythonBlocklistEntry.objects.filter(repository=repository)
874
+ if name and qs.filter(name=name, version=version).exists():
875
+ raise serializers.ValidationError(
876
+ _("A blocklist entry with this name and version already exists.")
877
+ )
878
+ if filename and qs.filter(filename=filename).exists():
879
+ raise serializers.ValidationError(
880
+ _("A blocklist entry with this filename already exists.")
881
+ )
882
+ data["repository"] = repository
883
+
884
+ return data
885
+
886
+ def create(self, validated_data):
887
+ """
888
+ Create a new blocklist entry, recording the authenticated user in `added_by`.
889
+ """
890
+ user = get_current_authenticated_user()
891
+ validated_data["added_by"] = get_prn(user) if user else ""
892
+ return super().create(validated_data)
893
+
894
+ class Meta:
895
+ fields = core_serializers.ModelSerializer.Meta.fields + (
896
+ "repository",
897
+ "name",
898
+ "version",
899
+ "filename",
900
+ "added_by",
901
+ )
902
+ model = python_models.PythonBlocklistEntry
903
+
904
+
783
905
  class PythonBanderRemoteSerializer(serializers.Serializer):
784
906
  """
785
907
  A Serializer for the initial step of creating a Python Remote from a Bandersnatch config file
@@ -1,6 +1,6 @@
1
- from gettext import gettext as _
2
1
  import logging
3
2
  import os
3
+ from gettext import gettext as _
4
4
 
5
5
  from django.core.files import File
6
6
  from packaging.utils import canonicalize_name
@@ -10,7 +10,7 @@ from pulpcore.plugin.util import get_domain
10
10
 
11
11
  from pulp_python.app import models as python_models
12
12
  from pulp_python.app.serializers import PythonPublicationSerializer
13
- from pulp_python.app.utils import write_simple_index, write_simple_detail
13
+ from pulp_python.app.utils import write_simple_detail, write_simple_index
14
14
 
15
15
  log = logging.getLogger(__name__)
16
16
 
@@ -7,6 +7,10 @@ from uuid import UUID
7
7
 
8
8
  from django.db.models import Prefetch
9
9
  from django.db.models.query import QuerySet
10
+
11
+ from pulpcore.plugin.models import ContentArtifact, ProgressReport
12
+ from pulpcore.plugin.util import get_domain
13
+
10
14
  from pulp_python.app.models import PythonPackageContent, PythonRepository
11
15
  from pulp_python.app.utils import (
12
16
  artifact_to_python_content_data,
@@ -16,8 +20,6 @@ from pulp_python.app.utils import (
16
20
  metadata_content_to_artifact,
17
21
  parse_metadata,
18
22
  )
19
- from pulpcore.plugin.models import ContentArtifact, ProgressReport
20
- from pulpcore.plugin.util import get_domain
21
23
 
22
24
  log = logging.getLogger(__name__)
23
25