diff --git a/CHANGES/5403.feature b/CHANGES/5403.feature new file mode 100644 index 00000000..a1a0e64f --- /dev/null +++ b/CHANGES/5403.feature @@ -0,0 +1 @@ +Add upload functionality to the file content endpoint. diff --git a/pulp_file/app/serializers.py b/pulp_file/app/serializers.py index 35219444..27258e3a 100644 --- a/pulp_file/app/serializers.py +++ b/pulp_file/app/serializers.py @@ -9,20 +9,20 @@ PublicationDistributionSerializer, PublicationSerializer, RemoteSerializer, - SingleArtifactContentSerializer, + SingleArtifactContentUploadSerializer, ) from .models import FileContent, FileDistribution, FileRemote, FilePublication -class FileContentSerializer(SingleArtifactContentSerializer, ContentChecksumSerializer): +class FileContentSerializer(SingleArtifactContentUploadSerializer, ContentChecksumSerializer): """ Serializer for File Content. """ - def validate(self, data): + def deferred_validate(self, data): """Validate the FileContent data.""" - data = super().validate(data) + data = super().deferred_validate(data) data["digest"] = data["artifact"].sha256 @@ -33,15 +33,18 @@ def validate(self, data): if content.exists(): raise serializers.ValidationError( _( - "There is already a file content with relative path '{path}' and artifact " - "'{artifact}'." - ).format(path=data["relative_path"], artifact=self.initial_data["artifact"]) + "There is already a file content with relative path '{path}' and digest " + "'{digest}'." + ).format(path=data["relative_path"], digest=data["digest"]) ) return data class Meta: - fields = SingleArtifactContentSerializer.Meta.fields + ContentChecksumSerializer.Meta.fields + fields = ( + SingleArtifactContentUploadSerializer.Meta.fields + + ContentChecksumSerializer.Meta.fields + ) model = FileContent diff --git a/pulp_file/app/viewsets.py b/pulp_file/app/viewsets.py index eadcf683..fd60990f 100644 --- a/pulp_file/app/viewsets.py +++ b/pulp_file/app/viewsets.py @@ -10,11 +10,11 @@ from pulpcore.plugin.tasking import enqueue_with_reservation from pulpcore.plugin.viewsets import ( BaseDistributionViewSet, - ContentViewSet, ContentFilter, RemoteViewSet, OperationPostponedResponse, PublicationViewSet, + SingleArtifactContentUploadViewSet, ) from . import tasks @@ -37,7 +37,7 @@ class Meta: fields = ["relative_path", "digest"] -class FileContentViewSet(ContentViewSet): +class FileContentViewSet(SingleArtifactContentUploadViewSet): """ FileContent represents a single file and its metadata, which can be added and removed from diff --git a/pulp_file/tests/functional/api/test_crud_content_unit.py b/pulp_file/tests/functional/api/test_crud_content_unit.py index 8bfbfff9..d5148774 100644 --- a/pulp_file/tests/functional/api/test_crud_content_unit.py +++ b/pulp_file/tests/functional/api/test_crud_content_unit.py @@ -5,11 +5,16 @@ from requests.exceptions import HTTPError from pulp_smash import api, config, utils +from pulp_smash.exceptions import TaskReportError from pulp_smash.pulp3.constants import ARTIFACTS_PATH from pulp_smash.pulp3.utils import delete_orphans from pulp_file.tests.functional.constants import FILE_CONTENT_PATH, FILE_URL -from pulp_file.tests.functional.utils import gen_file_content_attrs, skip_if +from pulp_file.tests.functional.utils import ( + gen_file_content_attrs, + gen_file_content_upload_attrs, + skip_if, +) from pulp_file.tests.functional.utils import set_up_module as setUpModule # noqa:F401 @@ -41,7 +46,9 @@ def tearDownClass(cls): def test_01_create_content_unit(self): """Create content unit.""" attrs = gen_file_content_attrs(self.artifact) - self.content_unit.update(self.client.post(FILE_CONTENT_PATH, attrs)) + call_report = self.client.post(FILE_CONTENT_PATH, data=attrs) + created_resources = next(api.poll_spawned_tasks(self.cfg, call_report))["created_resources"] + self.content_unit.update(self.client.get(created_resources[0])) for key, val in attrs.items(): with self.subTest(key=key): self.assertEqual(self.content_unit[key], val) @@ -98,6 +105,74 @@ def test_04_delete(self): self.assertEqual(exc.exception.response.status_code, 405) +class ContentUnitUploadTestCase(unittest.TestCase): + """CRUD content unit with upload feature. + + This test targets the following issue: + + `Pulp #5403 `_ + """ + + @classmethod + def setUpClass(cls): + """Create class-wide variable.""" + cls.cfg = config.get_config() + delete_orphans(cls.cfg) + cls.content_unit = {} + cls.client = api.Client(cls.cfg, api.smart_handler) + cls.files = {"file": utils.http_get(FILE_URL)} + cls.attrs = gen_file_content_upload_attrs() + + @classmethod + def tearDownClass(cls): + """Clean class-wide variable.""" + delete_orphans(cls.cfg) + + def test_01_create_content_unit(self): + """Create content unit.""" + content_unit = self.client.post(FILE_CONTENT_PATH, data=self.attrs, files=self.files) + self.content_unit.update(content_unit) + for key, val in self.attrs.items(): + with self.subTest(key=key): + self.assertEqual(self.content_unit[key], val) + + @skip_if(bool, "content_unit", False) + def test_02_read_content_unit(self): + """Read a content unit by its href.""" + content_unit = self.client.get(self.content_unit["_href"]) + for key, val in self.content_unit.items(): + with self.subTest(key=key): + self.assertEqual(content_unit[key], val) + + @skip_if(bool, "content_unit", False) + def test_02_read_content_units(self): + """Read a content unit by its relative_path.""" + page = self.client.using_handler(api.json_handler).get( + FILE_CONTENT_PATH, params={"relative_path": self.content_unit["relative_path"]} + ) + self.assertEqual(len(page["results"]), 1) + for key, val in self.content_unit.items(): + with self.subTest(key=key): + self.assertEqual(page["results"][0][key], val) + + @skip_if(bool, "content_unit", False) + def test_03_fail_duplicate_content_unit(self): + """Create content unit.""" + with self.assertRaises(TaskReportError) as exc: + self.client.post(FILE_CONTENT_PATH, data=self.attrs, files=self.files) + self.assertEqual(exc.exception.task["state"], "failed") + error = exc.exception.task["error"] + for key in ("already", "relative", "path", "digest"): + self.assertIn(key, error["description"].lower(), error) + + @skip_if(bool, "content_unit", False) + def test_03_duplicate_content_unit(self): + """Create content unit.""" + attrs = self.attrs.copy() + attrs["relative_path"] = utils.uuid4() + self.client.post(FILE_CONTENT_PATH, data=attrs, files=self.files) + + class DuplicateContentUnit(unittest.TestCase): """Attempt to create a duplicate content unit. @@ -134,11 +209,12 @@ def test_raise_error(self): self.client.post(FILE_CONTENT_PATH, attrs) # using the same attrs used to create the first content unit. - response = api.Client(self.cfg, api.echo_handler).post(FILE_CONTENT_PATH, attrs) - with self.assertRaises(HTTPError): - response.raise_for_status() - for key in ("already", "content", "relative", "path", "artifact"): - self.assertIn(key, response.json()["non_field_errors"][0].lower(), response.json()) + with self.assertRaises(TaskReportError) as exc: + self.client.post(FILE_CONTENT_PATH, attrs) + self.assertEqual(exc.exception.task["state"], "failed") + error = exc.exception.task["error"] + for key in ("already", "relative", "path", "digest"): + self.assertIn(key, error["description"].lower(), error) def test_non_error(self): """Create a duplicate content unit with different relative_path. diff --git a/pulp_file/tests/functional/utils.py b/pulp_file/tests/functional/utils.py index 41cd7395..590e3d56 100644 --- a/pulp_file/tests/functional/utils.py +++ b/pulp_file/tests/functional/utils.py @@ -60,6 +60,15 @@ def gen_file_content_attrs(artifact): return {"artifact": artifact["_href"], "relative_path": utils.uuid4()} +def gen_file_content_upload_attrs(): + """Generate a dict with content unit attributes without artifact for upload. + + :param artifact: A dict of info about the artifact. + :returns: A semi-random dict for use in creating a content unit. + """ + return {"relative_path": utils.uuid4()} + + def populate_pulp(cfg, url=FILE_FIXTURE_MANIFEST_URL): """Add file contents to Pulp.