diff --git a/docs/source/specs/typespec/main.tsp b/docs/source/specs/typespec/main.tsp index 1d9fa07e4..1466d9a9a 100644 --- a/docs/source/specs/typespec/main.tsp +++ b/docs/source/specs/typespec/main.tsp @@ -96,9 +96,16 @@ namespace Workspaces { description?: string = "Description of Workspace A"; } + enum WorkspaceTypes { + "root", + "default", + "standard" + } + model Workspace { @key uuid: UUID; parent_id?: UUID; + type: WorkspaceTypes; ...BasicWorkspace; ...Timestamps; } diff --git a/docs/source/specs/v2/openapi.v2.yaml b/docs/source/specs/v2/openapi.v2.yaml index e0eefbb06..3dc47af0a 100644 --- a/docs/source/specs/v2/openapi.v2.yaml +++ b/docs/source/specs/v2/openapi.v2.yaml @@ -765,6 +765,7 @@ components: type: object required: - uuid + - type - name - created - modified @@ -773,6 +774,8 @@ components: $ref: '#/components/schemas/UUID' parent_id: $ref: '#/components/schemas/UUID' + type: + $ref: '#/components/schemas/Workspaces.WorkspaceTypes' name: type: string description: Workspace A @@ -821,6 +824,12 @@ components: items: $ref: '#/components/schemas/Workspaces.Workspace' description: List of workspaces + Workspaces.WorkspaceTypes: + type: string + enum: + - root + - default + - standard servers: - url: https://console.redhat.com/{basePath} description: Production Server diff --git a/rbac/management/migrations/0052_workspace_type_and_more.py b/rbac/management/migrations/0052_workspace_type_and_more.py new file mode 100644 index 000000000..551ca2738 --- /dev/null +++ b/rbac/management/migrations/0052_workspace_type_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.15 on 2024-10-03 12:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("management", "0051_alter_principal_user_id"), + ] + + operations = [ + migrations.AddField( + model_name="workspace", + name="type", + field=models.CharField( + choices=[ + ("standard", "Standard"), + ("default", "Default"), + ("root", "Root"), + ], + default="standard", + ), + ), + migrations.AddConstraint( + model_name="workspace", + constraint=models.UniqueConstraint( + condition=models.Q(("type__in", ["root", "default"])), + fields=("tenant_id", "type"), + name="unique_default_root_workspace_per_tenant", + ), + ), + ] diff --git a/rbac/management/workspace/model.py b/rbac/management/workspace/model.py index ba1c9e4ee..dfd068c16 100644 --- a/rbac/management/workspace/model.py +++ b/rbac/management/workspace/model.py @@ -18,6 +18,7 @@ from uuid import uuid4 from django.db import models +from django.db.models import Q, UniqueConstraint from django.utils import timezone from management.rbac_fields import AutoDateTimeField @@ -27,17 +28,35 @@ class Workspace(TenantAwareModel): """A workspace.""" + class Types(models.TextChoices): + STANDARD = "standard" + DEFAULT = "default" + ROOT = "root" + name = models.CharField(max_length=255) uuid = models.UUIDField(default=uuid4, editable=False, unique=True, null=False) parent = models.ForeignKey( "self", to_field="uuid", on_delete=models.PROTECT, related_name="children", null=True, blank=True ) description = models.CharField(max_length=255, null=True, blank=True, editable=True) + type = models.CharField(choices=Types.choices, default=Types.STANDARD, null=False) created = models.DateTimeField(default=timezone.now) modified = AutoDateTimeField(default=timezone.now) class Meta: ordering = ["name", "modified"] + constraints = [ + UniqueConstraint( + fields=["tenant_id", "type"], + name="unique_default_root_workspace_per_tenant", + condition=Q(type__in=["root", "default"]), + ) + ] + + def save(self, *args, **kwargs): + """Override save on model to enforce validations.""" + self.full_clean() + super().save(*args, **kwargs) def ancestors(self): """Return a list of ancestors for a Workspace instance.""" diff --git a/rbac/management/workspace/serializer.py b/rbac/management/workspace/serializer.py index b012af190..7eed5cc53 100644 --- a/rbac/management/workspace/serializer.py +++ b/rbac/management/workspace/serializer.py @@ -30,6 +30,7 @@ class WorkspaceSerializer(serializers.ModelSerializer): parent_id = serializers.UUIDField(allow_null=True, required=False) created = serializers.DateTimeField(read_only=True) modified = serializers.DateTimeField(read_only=True) + type = serializers.CharField(read_only=True) class Meta: """Metadata for the serializer.""" @@ -42,6 +43,7 @@ class Meta: "description", "created", "modified", + "type", ) def create(self, validated_data): diff --git a/tests/management/workspace/test_model.py b/tests/management/workspace/test_model.py index dc8178ef0..0cef1c938 100644 --- a/tests/management/workspace/test_model.py +++ b/tests/management/workspace/test_model.py @@ -15,9 +15,11 @@ # along with this program. If not, see . # """Test the workspace model.""" +from api.models import Tenant from management.models import Workspace from tests.identity_request import IdentityRequest +from django.core.exceptions import ValidationError from django.db.models import ProtectedError @@ -47,10 +49,78 @@ def test_delete_fails_when_children(self): self.assertRaises(ProtectedError, parent.delete) def test_ancestors(self): - """Test ancestors on a workspce""" + """Test ancestors on a workspace""" root = Workspace.objects.create(name="Root", tenant=self.tenant, parent=None) level_1 = Workspace.objects.create(name="Level 1", tenant=self.tenant, parent=root) level_2 = Workspace.objects.create(name="Level 2", tenant=self.tenant, parent=level_1) level_3 = Workspace.objects.create(name="Level 3", tenant=self.tenant, parent=level_2) level_4 = Workspace.objects.create(name="Level 4", tenant=self.tenant, parent=level_3) self.assertCountEqual(level_3.ancestors(), [root, level_1, level_2]) + + +class Types(WorkspaceModelTests): + """Test types on a workspace.""" + + def setUp(self): + """Set up the workspace model tests.""" + self.tenant_2 = Tenant.objects.create(tenant_name="foo") + + self.tenant_1_root_workspace = Workspace.objects.create( + name="T1 Root Workspace", tenant=self.tenant, type="root" + ) + self.tenant_1_default_workspace = Workspace.objects.create( + name="T1 Default Workspace", tenant=self.tenant, type="default" + ) + self.tenant_1_standard_workspace = Workspace.objects.create(name="T1 Standard Workspace", tenant=self.tenant) + super().setUp() + + def test_default_value(self): + """Test the default value of a workspace type when not supplied""" + self.assertEqual(self.tenant_1_standard_workspace.type, "standard") + + def test_single_root_per_tenant(self): + """Test tenant can only have one root workspace""" + with self.assertRaises(ValidationError) as assertion: + Workspace.objects.create(name="T1 Root Workspace Number 2", type="root", tenant=self.tenant) + error_messages = assertion.exception.messages + self.assertEqual(len(error_messages), 1) + self.assertIn("unique_default_root_workspace_per_tenant", error_messages[0]) + + def test_single_default_per_tenant(self): + """Test tenant can only have one default workspace""" + with self.assertRaises(ValidationError) as assertion: + Workspace.objects.create(name="T1 Default Workspace Number 2", type="default", tenant=self.tenant) + error_messages = assertion.exception.messages + self.assertEqual(len(error_messages), 1) + self.assertIn("unique_default_root_workspace_per_tenant", error_messages[0]) + + def test_multiple_root_multiple_tenants(self): + """Test that multiple tenants can have more than one root workspace""" + try: + Workspace.objects.create(name="T2 Root Workspace Number 2", type="root", tenant=self.tenant_2) + except ValidationError as e: + self.fail("test_multiple_root_multiple_tenants raised ValidationError unexpectedly") + + def test_multiple_default_multiple_tenants(self): + """Test that multiple tenants can have more than one default workspace""" + try: + Workspace.objects.create(name="T2 Default Workspace Number 2", type="default", tenant=self.tenant_2) + except ValidationError as e: + self.fail("test_multiple_default_multiple_tenants raised ValidationError unexpectedly") + + def test_multiple_standard_per_tenant(self): + """Test tenant can have multiple standard workspaces""" + try: + for n in ["1", "2", "3"]: + Workspace.objects.create(name=f"T1 Standard Workspace Number {n}", type="standard", tenant=self.tenant) + except ValidationError as e: + self.fail("test_multiple_standard_per_tenant raised ValidationError unexpectedly") + + def test_invalid_type(self): + """Test invalid workspace type""" + invalid_type = "foo" + with self.assertRaises(ValidationError) as assertion: + Workspace.objects.create(name="Invalid Type Workspace", type=invalid_type, tenant=self.tenant) + error_messages = assertion.exception.messages + self.assertEqual(len(error_messages), 1) + self.assertEqual(f"Value '{invalid_type}' is not a valid choice.", error_messages[0]) diff --git a/tests/management/workspace/test_serializer.py b/tests/management/workspace/test_serializer.py index 705e7686a..1f7526364 100644 --- a/tests/management/workspace/test_serializer.py +++ b/tests/management/workspace/test_serializer.py @@ -57,6 +57,7 @@ def test_get_workspace_detail_child(self): "parent_id": str(self.parent.uuid), "created": self._format_timestamps(self.child.created), "modified": self._format_timestamps(self.child.modified), + "type": self.child.type, } self.assertDictEqual(serializer.data, expected_data) @@ -71,6 +72,7 @@ def test_get_workspace_detail_parent(self): "parent_id": None, "created": self._format_timestamps(self.parent.created), "modified": self._format_timestamps(self.parent.modified), + "type": self.parent.type, } self.assertDictEqual(serializer.data, expected_data) @@ -88,6 +90,7 @@ def test_get_workspace_detail_with_ancestry(self): "ancestry": [{"name": self.parent.name, "uuid": str(self.parent.uuid), "parent_id": None}], "created": self._format_timestamps(self.child.created), "modified": self._format_timestamps(self.child.modified), + "type": self.child.type, } self.assertDictEqual(serializer.data, expected_data) diff --git a/tests/management/workspace/test_view.py b/tests/management/workspace/test_view.py index b3c2fef89..1771aa3e5 100644 --- a/tests/management/workspace/test_view.py +++ b/tests/management/workspace/test_view.py @@ -62,7 +62,7 @@ def test_create_workspace(self): "name": "New Workspace", "description": "New Workspace - description", "tenant_id": self.tenant.id, - "parent_id": "cbe9822d-cadb-447d-bc80-8bef773c36ea", + "parent_id": self.init_workspace.uuid, } parent_workspace = Workspace.objects.create(**workspace_data) @@ -79,6 +79,7 @@ def test_create_workspace(self): self.assertNotEquals(data.get("created"), "") self.assertNotEquals(data.get("modified"), "") self.assertEquals(data.get("description"), "Workspace") + self.assertEquals(data.get("type"), "standard") self.assertEqual(response.get("content-type"), "application/json") def test_create_workspace_without_parent(self): @@ -96,6 +97,7 @@ def test_create_workspace_without_parent(self): self.assertNotEquals(data.get("created"), "") self.assertNotEquals(data.get("modified"), "") self.assertEquals(data.get("description"), "Workspace") + self.assertEquals(data.get("type"), "standard") self.assertEqual(response.get("content-type"), "application/json") def test_create_workspace_empty_body(self): @@ -179,6 +181,7 @@ def test_update_workspace(self): self.assertIsNotNone(data.get("uuid")) self.assertNotEquals(data.get("created"), "") self.assertNotEquals(data.get("modified"), "") + self.assertEquals(data.get("type"), "standard") self.assertEquals(data.get("description"), "Updated description") update_workspace = Workspace.objects.filter(id=workspace.id).first() @@ -192,7 +195,7 @@ def test_partial_update_workspace_with_put_method(self): "name": "New Workspace", "description": "New Workspace - description", "tenant_id": self.tenant.id, - "parent_id": "cbe9822d-cadb-447d-bc80-8bef773c36ea", + "parent_id": self.init_workspace.uuid, } workspace = Workspace.objects.create(**workspace_data) @@ -220,7 +223,7 @@ def test_update_workspace_same_parent(self): "name": "New Workspace", "description": "New Workspace - description", "tenant_id": self.tenant.id, - "parent_id": "cbe9822d-cadb-447d-bc80-8bef773c36ea", + "parent_id": self.init_workspace.uuid, } parent_workspace = Workspace.objects.create(**parent_workspace_data) @@ -305,6 +308,7 @@ def test_partial_update_workspace(self): self.assertIsNotNone(data.get("uuid")) self.assertNotEquals(data.get("created"), "") self.assertNotEquals(data.get("modified"), "") + self.assertEquals(data.get("type"), "standard") update_workspace = Workspace.objects.filter(id=workspace.id).first() self.assertEquals(update_workspace.name, "Updated name") @@ -395,6 +399,7 @@ def test_get_workspace(self): self.assertNotEquals(data.get("modified"), "") self.assertEqual(response.get("content-type"), "application/json") self.assertEqual(data.get("ancestry"), None) + self.assertEquals(data.get("type"), "standard") self.assertEqual(response.get("content-type"), "application/json") def test_get_workspace_with_ancestry(self): @@ -415,6 +420,7 @@ def test_get_workspace_with_ancestry(self): data.get("ancestry"), [{"name": self.parent_workspace.name, "uuid": str(self.parent_workspace.uuid), "parent_id": None}], ) + self.assertEquals(data.get("type"), "standard") self.assertEqual(response.get("content-type"), "application/json") self.assertEqual(data.get("ancestry"), None) @@ -436,6 +442,7 @@ def test_get_workspace_with_ancestry(self): data.get("ancestry"), [{"name": self.parent_workspace.name, "uuid": str(self.parent_workspace.uuid), "parent_id": None}], ) + self.assertEquals(data.get("type"), "standard") self.assertEqual(response.get("content-type"), "application/json") def test_get_workspace_not_found(self):