diff --git a/migrations/versions/0aa283fddc29_replace_pro_supported_fields.py b/migrations/versions/0aa283fddc29_replace_pro_supported_fields.py new file mode 100644 index 0000000..2710881 --- /dev/null +++ b/migrations/versions/0aa283fddc29_replace_pro_supported_fields.py @@ -0,0 +1,73 @@ +"""Replace pro_supported with esm_pro_supported and break_bug_pro_supported. +Revision ID: 0aa283fddc29 +Revises: 20260322_01 +Create Date: 2026-04-13 17:23:20.174684 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '0aa283fddc29' +down_revision: Union[str, None] = '20260322_01' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + 'versions', + sa.Column('esm_pro_supported', sa.JSON(), nullable=False), + ) + op.add_column( + 'versions', + sa.Column('break_bug_pro_supported', sa.JSON(), nullable=False), + ) + op.add_column( + 'versions', + sa.Column( + 'is_hidden', + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + op.drop_column('versions', 'pro_supported') + op.drop_constraint( + "ck_deployments_artifact_type", "deployments", type_="check" + ) + op.create_check_constraint( + "ck_deployments_artifact_type", + "deployments", + "artifact_type IN ('snap', 'deb', 'charm', 'image', 'rock')", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + 'versions', + sa.Column( + 'pro_supported', + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=False, + ), + ) + op.drop_column('versions', 'is_hidden') + op.drop_column('versions', 'break_bug_pro_supported') + op.drop_column('versions', 'esm_pro_supported') + op.drop_constraint( + "ck_deployments_artifact_type", "deployments", type_="check" + ) + op.create_check_constraint( + "ck_deployments_artifact_type", + "deployments", + "artifact_type IN ('snap', 'deb', 'charm', 'image', 'rock', 'other')", + ) + # ### end Alembic commands ### diff --git a/tests/fixtures/models.py b/tests/fixtures/models.py index 6ab3026..2fbfa24 100644 --- a/tests/fixtures/models.py +++ b/tests/fixtures/models.py @@ -31,10 +31,12 @@ def make_version(product, deployment, **overrides): "architecture": ["amd64"], "release_date": {"date": "2020-01-01"}, "supported": future_date, - "pro_supported": future_date, + "esm_pro_supported": future_date, + "break_bug_pro_supported": future_date, "legacy_supported": future_date, "upgrade_path": None, "compatible_ubuntu_lts": None, + "is_hidden": False, } return Version(**{**defaults, **overrides}) diff --git a/tests/helpers.py b/tests/helpers.py index 7fdc447..5958f83 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,7 +1,7 @@ from tests.fixtures.models import make_deployment, make_product, make_version -def _add_product_with_version(db, product_slug, lifecycle_overrides): +def _add_product_with_version(db, product_slug, lifecycle_overrides, is_hidden=False): product = make_product( slug=product_slug, name=f"Product {product_slug}", @@ -15,6 +15,7 @@ def _add_product_with_version(db, product_slug, lifecycle_overrides): product, deployment, release=f"{product_slug}-1.0.0", + is_hidden=is_hidden, **lifecycle_overrides, ) db.session.add(product) diff --git a/tests/test_create_product_deployment_version.py b/tests/test_create_product_deployment_version.py new file mode 100644 index 0000000..ee57f16 --- /dev/null +++ b/tests/test_create_product_deployment_version.py @@ -0,0 +1,300 @@ +import unittest + +from tests import BaseTestCase + + +class TestCreateProductDeploymentVersion(BaseTestCase): + def test_create_product_deployment_version_returns_201(self): + """POST /products// with valid body returns 201.""" + response = self.client.post( + "/products/test-product/test-deployment", + json={ + "release": "2.0.0", + "architecture": ["amd64"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2026-12-31"}, + "esm_pro_supported": {"date": "2027-12-31"}, + "break_bug_pro_supported": {"date": "2028-12-31"}, + "legacy_supported": {"notes": "until further notice"}, + "upgrade_path": ["1.0.0"], + "compatible_ubuntu_lts": [ + { + "version": "22.04", + "compatible_components": ["component-a"], + } + ], + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 201) + self.assertEqual(payload["parent_product"], "test-product") + self.assertEqual(payload["parent_deployment"], "test-deployment") + self.assertEqual(payload["release"], "2.0.0") + self.assertEqual(payload["architecture"], ["amd64"]) + self.assertEqual(payload["is_hidden"], False) + + def test_create_product_deployment_version_hidden_excluded_by_default(self): + """A version created with is_hidden=True is excluded from GET by default.""" + self.client.post( + "/products/test-product/test-deployment", + json={ + "release": "hidden-1.0.0", + "architecture": ["amd64"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2099-01-01"}, + "esm_pro_supported": {"date": "2099-01-01"}, + "break_bug_pro_supported": {"date": "2099-01-01"}, + "legacy_supported": {"notes": "until further notice"}, + "is_hidden": True, + }, + ) + + response = self.client.get("/products/test-product/test-deployment") + payload = response.get_json() + + releases = [v["release"] for v in payload["versions"]] + self.assertNotIn("hidden-1.0.0", releases) + + def test_create_product_deployment_version_hidden_included_with_param(self): + """A version created with is_hidden=True is included when include_hidden=true.""" + self.client.post( + "/products/test-product/test-deployment", + json={ + "release": "hidden-2.0.0", + "architecture": ["amd64"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2099-01-01"}, + "esm_pro_supported": {"date": "2099-01-01"}, + "break_bug_pro_supported": {"date": "2099-01-01"}, + "legacy_supported": {"notes": "until further notice"}, + "is_hidden": True, + }, + ) + + response = self.client.get( + "/products/test-product/test-deployment?include_hidden=true" + ) + payload = response.get_json() + + releases = [v["release"] for v in payload["versions"]] + self.assertIn("hidden-2.0.0", releases) + + def test_create_product_deployment_version_invalid_body_returns_400(self): + """Invalid request body returns 400 with error details.""" + response = self.client.post( + "/products/test-product/test-deployment", + json={ + "release": "2.0.2", + "architecture": ["invalid-arch"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2026-12-31"}, + "esm_pro_supported": {"date": "2027-12-31"}, + "break_bug_pro_supported": {"date": "2028-12-31"}, + "legacy_supported": {"notes": "until further notice"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 400) + self.assertIn("error", payload) + self.assertEqual(payload["error"]["message"], "Invalid request.") + self.assertIn("details", payload["error"]) + + def test_create_product_deployment_version_missing_release_returns_400(self): + """POST without release returns 400 with error.details.""" + response = self.client.post( + "/products/test-product/test-deployment", + json={ + "architecture": ["amd64"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2026-12-31"}, + "esm_pro_supported": {"date": "2027-12-31"}, + "break_bug_pro_supported": {"date": "2028-12-31"}, + "legacy_supported": {"notes": "until further notice"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 400) + self.assertIn("error", payload) + self.assertIn("details", payload["error"]) + + def test_create_product_deployment_version_whitespace_only_release_returns_400(self): + """POST with whitespace-only release returns 400 with error.details.""" + response = self.client.post( + "/products/test-product/test-deployment", + json={ + "release": " ", + "architecture": ["amd64"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2026-12-31"}, + "esm_pro_supported": {"date": "2027-12-31"}, + "break_bug_pro_supported": {"date": "2028-12-31"}, + "legacy_supported": {"notes": "until further notice"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 400) + self.assertIn("error", payload) + self.assertIn("details", payload["error"]) + + def test_create_product_deployment_version_missing_required_field_returns_400(self): + """POST without a required lifecycle field returns 400 with error.details.""" + response = self.client.post( + "/products/test-product/test-deployment", + json={ + "release": "3.0.0", + "architecture": ["amd64"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2026-12-31"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 400) + self.assertIn("error", payload) + self.assertIn("details", payload["error"]) + + def test_create_product_deployment_version_lifecycle_date_before_release_date_returns_400(self): + """POST with a lifecycle date before release_date returns 400 with field details.""" + response = self.client.post( + "/products/test-product/test-deployment", + json={ + "release": "4.0.0", + "architecture": ["amd64"], + "release_date": {"date": "2028-01-01"}, + "supported": {"date": "2027-01-01"}, + "esm_pro_supported": {"date": "2029-01-01"}, + "break_bug_pro_supported": {"date": "2029-01-01"}, + "legacy_supported": {"notes": "until further notice"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 400) + self.assertIn("error", payload) + self.assertIn("details", payload["error"]) + self.assertIn("supported", payload["error"]["details"]) + + def test_create_product_deployment_version_notes_only_lifecycle_not_checked_against_release_date(self): + """POST with notes-only lifecycle fields is not subject to date ordering validation.""" + response = self.client.post( + "/products/test-product/test-deployment", + json={ + "release": "5.0.0", + "architecture": ["amd64"], + "release_date": {"date": "2028-01-01"}, + "supported": {"notes": "until further notice"}, + "esm_pro_supported": {"notes": "until further notice"}, + "break_bug_pro_supported": {"notes": "until further notice"}, + "legacy_supported": {"notes": "until further notice"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 201) + + def test_create_product_deployment_version_invalid_date_format_returns_400(self): + """POST with an invalid date string in a DateOrNote field returns 400.""" + response = self.client.post( + "/products/test-product/test-deployment", + json={ + "release": "6.0.0", + "architecture": ["amd64"], + "release_date": {"date": "2026-06-01"}, + "supported": {"date": "not-a-real-date"}, + "esm_pro_supported": {"date": "2028-06-01"}, + "break_bug_pro_supported": {"date": "2029-06-01"}, + "legacy_supported": {"notes": "until further notice"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 400) + self.assertIn("error", payload) + self.assertIn("details", payload["error"]) + + def test_create_product_deployment_version_product_not_found_returns_404(self): + """Unknown product returns 404 with identifying details.""" + response = self.client.post( + "/products/does-not-exist/test-deployment", + json={ + "release": "2.0.3", + "architecture": ["amd64"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2026-12-31"}, + "esm_pro_supported": {"date": "2027-12-31"}, + "break_bug_pro_supported": {"date": "2028-12-31"}, + "legacy_supported": {"notes": "until further notice"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 404) + self.assertIn("error", payload) + self.assertEqual(payload["error"]["message"], "Product not found.") + self.assertEqual( + payload["error"]["details"], + {"product_slug": "does-not-exist"}, + ) + + def test_create_product_deployment_version_deployment_not_found_returns_404(self): + """Unknown deployment returns 404 with identifying details.""" + response = self.client.post( + "/products/test-product/does-not-exist", + json={ + "release": "2.0.4", + "architecture": ["amd64"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2026-12-31"}, + "esm_pro_supported": {"date": "2027-12-31"}, + "break_bug_pro_supported": {"date": "2028-12-31"}, + "legacy_supported": {"notes": "until further notice"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 404) + self.assertIn("error", payload) + self.assertEqual(payload["error"]["message"], "Deployment not found.") + self.assertEqual( + payload["error"]["details"], + { + "product_slug": "test-product", + "deployment_slug": "does-not-exist", + }, + ) + + def test_create_product_deployment_version_conflict_returns_409(self): + """Duplicate release for deployment returns 409 with details.""" + response = self.client.post( + "/products/test-product/test-deployment", + json={ + "release": "1.0.0", + "architecture": ["amd64"], + "release_date": {"date": "2026-01-01"}, + "supported": {"date": "2026-12-31"}, + "esm_pro_supported": {"date": "2027-12-31"}, + "break_bug_pro_supported": {"date": "2028-12-31"}, + "legacy_supported": {"notes": "until further notice"}, + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 409) + self.assertIn("error", payload) + self.assertEqual(payload["error"]["message"], "Version already exists.") + self.assertEqual( + payload["error"]["details"], + { + "product_slug": "test-product", + "deployment_slug": "test-deployment", + "release": "1.0.0", + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_get_product.py b/tests/test_get_product.py index f0315dc..bff43e8 100644 --- a/tests/test_get_product.py +++ b/tests/test_get_product.py @@ -35,7 +35,8 @@ def test_get_product_excludes_expired_by_default(self): "single-expired-default", { "supported": {"date": "2000-01-01"}, - "pro_supported": {"date": "2000-01-01"}, + "esm_pro_supported": {"date": "2000-01-01"}, + "break_bug_pro_supported": {"date": "2000-01-01"}, "legacy_supported": {"date": "2000-01-01"}, }, ) @@ -54,7 +55,8 @@ def test_get_product_include_expired_true(self): "single-expired-true", { "supported": {"date": "2000-01-01"}, - "pro_supported": {"date": "2000-01-01"}, + "esm_pro_supported": {"date": "2000-01-01"}, + "break_bug_pro_supported": {"date": "2000-01-01"}, "legacy_supported": {"date": "2000-01-01"}, }, ) @@ -81,6 +83,50 @@ def test_get_product_invalid_include_expired_returns_400_with_error_shape(self): self.assertIn("details", payload["error"]) self.assertIn("include_expired", payload["error"]["details"]) + def test_get_product_hidden_excluded_by_default(self): + """Hidden versions are excluded from a product by default.""" + _add_product_with_version( + self.db, + "single-hidden-default", + { + "supported": {"date": "2099-01-01"}, + "esm_pro_supported": {"date": "2099-01-01"}, + "break_bug_pro_supported": {"date": "2099-01-01"}, + "legacy_supported": {"date": "2099-01-01"}, + }, + is_hidden=True, + ) + + response = self.client.get("/products/single-hidden-default") + payload = response.get_json() + + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["slug"], "single-hidden-default") + self.assertEqual(payload["deployments"], []) + + def test_get_product_include_hidden_true(self): + """Hidden versions are included when include_hidden=true.""" + _add_product_with_version( + self.db, + "single-hidden-true", + { + "supported": {"date": "2099-01-01"}, + "esm_pro_supported": {"date": "2099-01-01"}, + "break_bug_pro_supported": {"date": "2099-01-01"}, + "legacy_supported": {"date": "2099-01-01"}, + }, + is_hidden=True, + ) + + response = self.client.get( + "/products/single-hidden-true?include_hidden=true" + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(payload["deployments"]), 1) + self.assertEqual(len(payload["deployments"][0]["versions"]), 1) + def test_get_product_not_found_returns_404(self): """Unknown product slug returns 404 with product_slug detail.""" response = self.client.get("/products/does-not-exist") diff --git a/tests/test_get_product_deployment.py b/tests/test_get_product_deployment.py index 3a4eb8c..588da41 100644 --- a/tests/test_get_product_deployment.py +++ b/tests/test_get_product_deployment.py @@ -36,7 +36,8 @@ def test_get_product_deployment_excludes_expired_by_default(self): "deployment-expired-default", { "supported": {"date": "2000-01-01"}, - "pro_supported": {"date": "2000-01-01"}, + "esm_pro_supported": {"date": "2000-01-01"}, + "break_bug_pro_supported": {"date": "2000-01-01"}, "legacy_supported": {"date": "2000-01-01"}, }, ) @@ -58,7 +59,8 @@ def test_get_product_deployment_include_expired_true(self): "deployment-expired-true", { "supported": {"date": "2000-01-01"}, - "pro_supported": {"date": "2000-01-01"}, + "esm_pro_supported": {"date": "2000-01-01"}, + "break_bug_pro_supported": {"date": "2000-01-01"}, "legacy_supported": {"date": "2000-01-01"}, }, ) @@ -85,6 +87,53 @@ def test_get_product_deployment_invalid_include_expired_returns_400_with_error_s self.assertIn("details", payload["error"]) self.assertIn("include_expired", payload["error"]["details"]) + def test_get_product_deployment_hidden_excluded_by_default(self): + """Hidden versions are excluded from a deployment by default.""" + _add_product_with_version( + self.db, + "deployment-hidden-default", + { + "supported": {"date": "2099-01-01"}, + "esm_pro_supported": {"date": "2099-01-01"}, + "break_bug_pro_supported": {"date": "2099-01-01"}, + "legacy_supported": {"date": "2099-01-01"}, + }, + is_hidden=True, + ) + + response = self.client.get( + "/products/deployment-hidden-default/" + "deployment-hidden-default-deployment" + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["slug"], "deployment-hidden-default-deployment") + self.assertEqual(payload["versions"], []) + + def test_get_product_deployment_include_hidden_true(self): + """Hidden versions are included in a deployment when include_hidden=true.""" + _add_product_with_version( + self.db, + "deployment-hidden-true", + { + "supported": {"date": "2099-01-01"}, + "esm_pro_supported": {"date": "2099-01-01"}, + "break_bug_pro_supported": {"date": "2099-01-01"}, + "legacy_supported": {"date": "2099-01-01"}, + }, + is_hidden=True, + ) + + response = self.client.get( + "/products/deployment-hidden-true/" + "deployment-hidden-true-deployment?include_hidden=true" + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(payload["versions"]), 1) + def test_get_product_deployment_not_found_returns_404(self): """Unknown deployment returns 404 with identifying details.""" response = self.client.get( diff --git a/tests/test_get_product_deployment_version.py b/tests/test_get_product_deployment_version.py index 8bb749b..4c1693d 100644 --- a/tests/test_get_product_deployment_version.py +++ b/tests/test_get_product_deployment_version.py @@ -25,7 +25,8 @@ def test_get_product_deployment_version_response_shape(self): self.assertIn("architecture", payload) self.assertIn("release_date", payload) self.assertIn("supported", payload) - self.assertIn("pro_supported", payload) + self.assertIn("esm_pro_supported", payload) + self.assertIn("break_bug_pro_supported", payload) self.assertIn("legacy_supported", payload) def test_get_product_deployment_version_not_found_returns_404(self): diff --git a/tests/test_get_products.py b/tests/test_get_products.py index de8b936..4d95fe7 100644 --- a/tests/test_get_products.py +++ b/tests/test_get_products.py @@ -37,7 +37,8 @@ def test_get_products_excludes_expired_by_default(self): "expired-default", { "supported": {"date": "2000-01-01"}, - "pro_supported": {"date": "2000-01-01"}, + "esm_pro_supported": {"date": "2000-01-01"}, + "break_bug_pro_supported": {"date": "2000-01-01"}, "legacy_supported": {"date": "2000-01-01"}, }, ) @@ -56,7 +57,8 @@ def test_get_products_include_expired_true(self): "expired-true", { "supported": {"date": "2000-01-01"}, - "pro_supported": {"date": "2000-01-01"}, + "esm_pro_supported": {"date": "2000-01-01"}, + "break_bug_pro_supported": {"date": "2000-01-01"}, "legacy_supported": {"date": "2000-01-01"}, }, ) @@ -74,7 +76,8 @@ def test_get_products_notes_with_until_not_filtered(self): "notes-until", { "supported": {"notes": "Supported until further notice"}, - "pro_supported": {"notes": "deprecated"}, + "esm_pro_supported": {"notes": "deprecated"}, + "break_bug_pro_supported": {"notes": "deprecated"}, "legacy_supported": {"notes": "deprecated"}, }, ) @@ -92,7 +95,8 @@ def test_get_products_notes_without_until_filtered(self): "notes-no-until", { "supported": {"notes": "deprecated"}, - "pro_supported": {"notes": "deprecated"}, + "esm_pro_supported": {"notes": "deprecated"}, + "break_bug_pro_supported": {"notes": "deprecated"}, "legacy_supported": {"notes": "deprecated"}, }, ) @@ -103,6 +107,46 @@ def test_get_products_notes_without_until_filtered(self): self.assertNotIn("notes-no-until", slugs) + def test_get_products_hidden_excluded_by_default(self): + """Hidden versions cause their product to be excluded by default.""" + _add_product_with_version( + self.db, + "hidden-default", + { + "supported": {"date": "2099-01-01"}, + "esm_pro_supported": {"date": "2099-01-01"}, + "break_bug_pro_supported": {"date": "2099-01-01"}, + "legacy_supported": {"date": "2099-01-01"}, + }, + is_hidden=True, + ) + + response = self.client.get("/products") + payload = response.get_json() + slugs = _extract_slugs(payload) + + self.assertNotIn("hidden-default", slugs) + + def test_get_products_include_hidden_true(self): + """Hidden products are included when include_hidden=true.""" + _add_product_with_version( + self.db, + "hidden-included", + { + "supported": {"date": "2099-01-01"}, + "esm_pro_supported": {"date": "2099-01-01"}, + "break_bug_pro_supported": {"date": "2099-01-01"}, + "legacy_supported": {"date": "2099-01-01"}, + }, + is_hidden=True, + ) + + response = self.client.get("/products?include_hidden=true") + payload = response.get_json() + slugs = _extract_slugs(payload) + + self.assertIn("hidden-included", slugs) + def test_get_products_invalid_include_expired_returns_400(self): """An invalid include_expired value returns 400 with the correct error shape.""" response = self.client.get("/products?include_expired=not-a-bool") diff --git a/webapp/app.py b/webapp/app.py index 288dfac..627acbc 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -54,6 +54,11 @@ view_func=views.update_product_deployment, methods=["PUT"], ) +app.add_url_rule( + "/products//", + view_func=views.create_version, + methods=["POST"], +) app.add_url_rule( ( "/products//" diff --git a/webapp/constants.py b/webapp/constants.py index 9999c2d..c3f9c98 100644 --- a/webapp/constants.py +++ b/webapp/constants.py @@ -4,7 +4,6 @@ "charm", "image", "rock", - "other", ] ARCHITECTURES = ["amd64", "arm64", "armhf", "ppc64el", "s390x", "riscv64"] diff --git a/webapp/helpers.py b/webapp/helpers.py index 6fa8a01..96a5305 100644 --- a/webapp/helpers.py +++ b/webapp/helpers.py @@ -54,7 +54,8 @@ def is_version_active(version: Any) -> bool: lifecycle_fields = [ version.supported, - version.pro_supported, + version.esm_pro_supported, + version.break_bug_pro_supported, version.legacy_supported, ] @@ -63,16 +64,24 @@ def is_version_active(version: Any) -> bool: ) -def filter_product_versions(product: Any) -> Any: +def filter_product_versions( + product: Any, + include_expired: bool = False, + include_hidden: bool = False, +) -> Any: """ - Return a product-like object containing only active + Return a product-like object containing only visible deployments and versions. """ filtered_deployments = [] for deployment in product.deployments: - filtered_deployment = filter_deployment_versions(deployment) + filtered_deployment = filter_deployment_versions( + deployment, + include_expired=include_expired, + include_hidden=include_hidden, + ) if filtered_deployment.versions: filtered_deployments.append(filtered_deployment) @@ -84,13 +93,18 @@ def filter_product_versions(product: Any) -> Any: ) -def filter_deployment_versions(deployment: Any) -> Any: - """Return a deployment-like object containing only active versions.""" +def filter_deployment_versions( + deployment: Any, + include_expired: bool = False, + include_hidden: bool = False, +) -> Any: + """Return a deployment-like object containing only visible versions.""" - active_versions = [ + versions = [ version for version in deployment.versions - if is_version_active(version) + if (include_expired or is_version_active(version)) + and (include_hidden or not version.is_hidden) ] return SimpleNamespace( @@ -98,12 +112,12 @@ def filter_deployment_versions(deployment: Any) -> Any: parent_product=deployment.parent_product, name=deployment.name, artifact_type=deployment.artifact_type, - versions=active_versions, + versions=versions, ) -def is_product_active(product: Any) -> bool: - """Return True if the product has at least one active version.""" +def is_product_active(product: Any, include_hidden: bool = False) -> bool: + """Return True if the product has at least one active, visible version.""" versions = [ version @@ -114,4 +128,8 @@ def is_product_active(product: Any) -> bool: if not versions: return True - return any(is_version_active(version) for version in versions) + return any( + is_version_active(version) + and (include_hidden or not version.is_hidden) + for version in versions + ) diff --git a/webapp/models.py b/webapp/models.py index 1964d6e..a4575b5 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -69,10 +69,12 @@ class Version(db.Model): architecture = db.Column(db.JSON, nullable=False) release_date = db.Column(db.JSON, nullable=False) supported = db.Column(db.JSON, nullable=False) - pro_supported = db.Column(db.JSON, nullable=False) + esm_pro_supported = db.Column(db.JSON, nullable=False) + break_bug_pro_supported = db.Column(db.JSON, nullable=False) legacy_supported = db.Column(db.JSON, nullable=False) upgrade_path = db.Column(db.JSON, nullable=True) compatible_ubuntu_lts = db.Column(db.JSON, nullable=True) + is_hidden = db.Column(db.Boolean, nullable=False, default=False) deployment = db.relationship("Deployment", back_populates="versions") diff --git a/webapp/schemas.py b/webapp/schemas.py index 22cd974..3eccd51 100644 --- a/webapp/schemas.py +++ b/webapp/schemas.py @@ -1,8 +1,11 @@ +from datetime import date as date_type + from marshmallow import ( Schema, ValidationError, fields, post_load, + validates, validates_schema, ) from marshmallow.validate import OneOf, Length @@ -30,6 +33,17 @@ class DateOrNoteSchema(Schema): date = fields.String(required=False, allow_none=True) notes = fields.String(required=False, allow_none=True) + @validates("date") + def validate_date_format(self, value): + if value is None: + return + try: + date_type.fromisoformat(value) + except (TypeError, ValueError): + raise ValidationError( + "Must be a valid ISO 8601 date, e.g. '2027-04-30'." + ) + @validates_schema def validate_date_or_note(self, data, **kwargs): if not data.get("date") and not data.get("notes"): @@ -87,7 +101,8 @@ class VersionSchema(Schema): ) release_date = fields.Nested(DateOrNoteSchema, required=True) supported = fields.Nested(DateOrNoteSchema, required=True) - pro_supported = fields.Nested(DateOrNoteSchema, required=True) + esm_pro_supported = fields.Nested(DateOrNoteSchema, required=True) + break_bug_pro_supported = fields.Nested(DateOrNoteSchema, required=True) legacy_supported = fields.Nested(DateOrNoteSchema, required=True) upgrade_path = fields.List( fields.String(), required=False, allow_none=True @@ -97,10 +112,12 @@ class VersionSchema(Schema): required=False, allow_none=True, ) + is_hidden = fields.Boolean(dump_only=False, load_default=False) class GetProductsQuerySchema(Schema): include_expired = fields.Boolean(load_default=False) + include_hidden = fields.Boolean(load_default=False) class CreateDeploymentBodySchema(Schema): @@ -143,6 +160,75 @@ class CreateProductDeploymentBodySchema(NormalizeNameMixin, Schema): ) +class CreateVersionBodySchema(Schema): + """ + Schema for POST /products// request body. + """ + + release = fields.String(required=True) + architecture = fields.List( + fields.String(validate=OneOf(ARCHITECTURES)), + required=True, + allow_none=False, + validate=Length(min=1), + ) + release_date = fields.Nested(DateOrNoteSchema, required=True) + supported = fields.Nested(DateOrNoteSchema, required=True) + esm_pro_supported = fields.Nested(DateOrNoteSchema, required=True) + break_bug_pro_supported = fields.Nested(DateOrNoteSchema, required=True) + legacy_supported = fields.Nested(DateOrNoteSchema, required=True) + upgrade_path = fields.List( + fields.String(), required=False, allow_none=True + ) + compatible_ubuntu_lts = fields.List( + fields.Nested(CompatibleLTSSchema), + required=False, + allow_none=True, + ) + is_hidden = fields.Boolean(required=False, load_default=False) + + @post_load + def normalize_fields(self, data, **kwargs): + if "release" in data: + stripped_release = data["release"].strip() + if not stripped_release: + raise ValidationError( + "Release must not be blank.", + field_name="release", + ) + data["release"] = stripped_release + return data + + @validates_schema + def validate_dates_after_release(self, data, **kwargs): + release_date_field = data.get("release_date", {}) + release_date_str = release_date_field.get("date") + if not release_date_str: + return + + release_date = date_type.fromisoformat(release_date_str) + + lifecycle_fields = { + "supported": data.get("supported", {}), + "esm_pro_supported": data.get("esm_pro_supported", {}), + "break_bug_pro_supported": data.get("break_bug_pro_supported", {}), + "legacy_supported": data.get("legacy_supported", {}), + } + + for field_name, field_value in lifecycle_fields.items(): + if not isinstance(field_value, dict): + continue + lifecycle_date_str = field_value.get("date") + if not lifecycle_date_str: + continue + lifecycle_date = date_type.fromisoformat(lifecycle_date_str) + if lifecycle_date < release_date: + raise ValidationError( + "Must not be before release_date.", + field_name=field_name, + ) + + class UpdateProductDeploymentBodySchema(NormalizeNameMixin, Schema): """ Schema for PUT /products// request body. diff --git a/webapp/views.py b/webapp/views.py index 1ca1c2d..84f65f2 100644 --- a/webapp/views.py +++ b/webapp/views.py @@ -10,6 +10,7 @@ ) from webapp.models import Deployment, Product, Version from webapp.schemas import ( + CreateVersionBodySchema, CreateProductDeploymentBodySchema, CreateProductBodySchema, DeploymentSchema, @@ -22,17 +23,29 @@ @use_kwargs(GetProductsQuerySchema, location="query") -def get_products(include_expired): +def get_products(include_expired, include_hidden): products = Product.query.options( joinedload(Product.deployments).joinedload(Deployment.versions) ).all() if not include_expired: - products = [p for p in products if is_product_active(p)] - return {"products": ProductSchema(many=True).dump(products)}, 200 + products = [ + p + for p in products + if is_product_active(p, include_hidden=include_hidden) + ] + filtered = [ + filter_product_versions( + p, + include_expired=include_expired, + include_hidden=include_hidden, + ) + for p in products + ] + return {"products": ProductSchema(many=True).dump(filtered)}, 200 @use_kwargs(GetProductsQuerySchema, location="query") -def get_product(product_slug, include_expired): +def get_product(product_slug, include_expired, include_hidden): product = ( Product.query.options( joinedload(Product.deployments).joinedload(Deployment.versions) @@ -49,14 +62,19 @@ def get_product(product_slug, include_expired): } }, 404 - if not include_expired: - product = filter_product_versions(product) + product = filter_product_versions( + product, + include_expired=include_expired, + include_hidden=include_hidden, + ) return ProductSchema().dump(product), 200 @use_kwargs(GetProductsQuerySchema, location="query") -def get_product_deployment(product_slug, deployment_slug, include_expired): +def get_product_deployment( + product_slug, deployment_slug, include_expired, include_hidden +): deployment = ( Deployment.query.options(joinedload(Deployment.versions)) .filter_by( @@ -77,8 +95,11 @@ def get_product_deployment(product_slug, deployment_slug, include_expired): } }, 404 - if not include_expired: - deployment = filter_deployment_versions(deployment) + deployment = filter_deployment_versions( + deployment, + include_expired=include_expired, + include_hidden=include_hidden, + ) return DeploymentSchema().dump(deployment), 200 @@ -194,6 +215,92 @@ def create_product_deployment(product_slug, name, artifact_type): return DeploymentSchema().dump(deployment), 201 +@use_kwargs(CreateVersionBodySchema, location="json") +def create_version( + product_slug, + deployment_slug, + release, + architecture, + release_date, + supported, + esm_pro_supported, + break_bug_pro_supported, + legacy_supported, + upgrade_path=None, + compatible_ubuntu_lts=None, + is_hidden=False, +): + product = Product.query.filter_by(slug=product_slug).one_or_none() + if product is None: + return { + "error": { + "message": "Product not found.", + "details": {"product_slug": product_slug}, + } + }, 404 + + deployment = Deployment.query.filter_by( + parent_product=product_slug, + slug=deployment_slug, + ).one_or_none() + if deployment is None: + return { + "error": { + "message": "Deployment not found.", + "details": { + "product_slug": product_slug, + "deployment_slug": deployment_slug, + }, + } + }, 404 + + existing_version = Version.query.filter_by( + parent_product=product_slug, + parent_deployment=deployment_slug, + release=release, + ).one_or_none() + if existing_version is not None: + return { + "error": { + "message": "Version already exists.", + "details": { + "product_slug": product_slug, + "deployment_slug": deployment_slug, + "release": release, + }, + } + }, 409 + + version = Version( + parent_product=product_slug, + parent_deployment=deployment_slug, + release=release, + architecture=architecture, + release_date=release_date, + supported=supported, + esm_pro_supported=esm_pro_supported, + break_bug_pro_supported=break_bug_pro_supported, + legacy_supported=legacy_supported, + upgrade_path=upgrade_path, + compatible_ubuntu_lts=compatible_ubuntu_lts, + is_hidden=is_hidden, + ) + db.session.add(version) + + try: + db.session.commit() + except Exception: + db.session.remove() + return { + "error": { + "message": "Internal server error.", + "details": {}, + } + }, 500 + + return VersionSchema().dump(version), 201 + + @use_kwargs(UpdateProductBodySchema, location="json") def update_product(product_slug, name): product = (