From 1779f3b175657d87e1ccd81109e685ef4f44e4aa Mon Sep 17 00:00:00 2001 From: Alexander Saprykin Date: Wed, 6 Mar 2024 16:01:45 +0100 Subject: [PATCH] Implement organization repositories API New API endpoins added: * `GET {API_PREFIX}/_ui/v1/organizations/{organization_id}/repositories/` * `GET {API_PREFIX}/_ui/v1/organizations/{organization_id}/repositories/{repository_id}/` * `POST {API_PREFIX}/_ui/v1/organizations/{organization_id}/repositories/{repository_id}/` * `DELETE {API_PREFIX}/_ui/v1/organizations/{organization_id}/repositories/{repository_id}/` No-Issue --- galaxy_ng/app/access_control/access_policy.py | 4 + .../access_control/statements/standalone.py | 15 +++- galaxy_ng/app/api/resource_api.py | 5 ++ galaxy_ng/app/api/ui/serializers/__init__.py | 66 +++++++---------- .../app/api/ui/serializers/organization.py | 18 +++++ galaxy_ng/app/api/ui/urls.py | 31 ++++++-- galaxy_ng/app/api/ui/viewsets/__init__.py | 73 ++++++++----------- galaxy_ng/app/api/ui/viewsets/organization.py | 43 +++++++++++ .../migrations/0051_organizationrepository.py | 43 +++++++++++ galaxy_ng/app/models/__init__.py | 3 +- galaxy_ng/app/models/organization.py | 8 ++ galaxy_ng/app/urls.py | 8 +- .../integration/api/test_organization.py | 62 ++++++++++++++++ 13 files changed, 285 insertions(+), 94 deletions(-) create mode 100644 galaxy_ng/app/api/ui/serializers/organization.py create mode 100644 galaxy_ng/app/api/ui/viewsets/organization.py create mode 100644 galaxy_ng/app/migrations/0051_organizationrepository.py create mode 100644 galaxy_ng/tests/integration/api/test_organization.py diff --git a/galaxy_ng/app/access_control/access_policy.py b/galaxy_ng/app/access_control/access_policy.py index b2d91c4443..c7ee28b47f 100644 --- a/galaxy_ng/app/access_control/access_policy.py +++ b/galaxy_ng/app/access_control/access_policy.py @@ -820,3 +820,7 @@ def is_namespace_owner(self, request, viewset, action): return True return False + + +class OrganizationResourceAccessPolicy(AccessPolicyBase): + NAME = "OrganizationResource" diff --git a/galaxy_ng/app/access_control/statements/standalone.py b/galaxy_ng/app/access_control/statements/standalone.py index c77a7ada3a..370936f3b8 100644 --- a/galaxy_ng/app/access_control/statements/standalone.py +++ b/galaxy_ng/app/access_control/statements/standalone.py @@ -259,7 +259,7 @@ 'TagViewSet': [ { "action": ["list", "retrieve"], - "principal": "authenticated", + "principal ": "authenticated", "effect": "allow", }, ], @@ -408,6 +408,19 @@ "condition": "has_distro_permission:container.change_containerdistribution" }, ], + + "OrganizationResource": [ + { + "action": ["create", "destroy"], + "principal": "admin", + "effect": "allow", + }, + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + }, + ] } STANDALONE_STATEMENTS.update(LEGACY_STATEMENTS) diff --git a/galaxy_ng/app/api/resource_api.py b/galaxy_ng/app/api/resource_api.py index 7a49cd54aa..985d585c6d 100644 --- a/galaxy_ng/app/api/resource_api.py +++ b/galaxy_ng/app/api/resource_api.py @@ -28,4 +28,9 @@ class APIConfig(ServiceAPIConfig): shared_resource=SharedResource(serializer=OrganizationType, is_provider=False), name_field="name", ), + ResourceConfig( + models.Organization, + shared_resource=SharedResource(serializer=OrganizationType, is_provider=False), + name_field="name", + ) ) diff --git a/galaxy_ng/app/api/ui/serializers/__init__.py b/galaxy_ng/app/api/ui/serializers/__init__.py index bd58c14ae6..e37b73ee9e 100644 --- a/galaxy_ng/app/api/ui/serializers/__init__.py +++ b/galaxy_ng/app/api/ui/serializers/__init__.py @@ -4,64 +4,52 @@ from .collection import ( CollectionDetailSerializer, CollectionListSerializer, - CollectionVersionSerializer, - CollectionVersionDetailSerializer, CollectionVersionBaseSerializer, + CollectionVersionDetailSerializer, + CollectionVersionSerializer, ) +from .distribution import DistributionSerializer +from .execution_environment import ContainerRegistryRemoteSerializer, ContainerRemoteSerializer from .imports import ( ImportTaskDetailSerializer, ImportTaskListSerializer, ) - -from .user import ( - UserSerializer, - CurrentUserSerializer, -) - +from .organization import OrganizationRepositorySerializer +from .search import SearchResultsSerializer from .synclist import ( - SyncListSerializer, SyncListCollectionSummarySerializer, + SyncListSerializer, ) - -from .distribution import ( - DistributionSerializer -) - -from .execution_environment import ( - ContainerRegistryRemoteSerializer, - ContainerRemoteSerializer -) - -from .search import ( - SearchResultsSerializer +from .user import ( + CurrentUserSerializer, + UserSerializer, ) __all__ = ( # auth - 'LoginSerializer', + "LoginSerializer", # collection - 'CollectionDetailSerializer', - 'CollectionListSerializer', - 'CollectionVersionSerializer', - 'CollectionVersionDetailSerializer', - 'CollectionVersionBaseSerializer', + "CollectionDetailSerializer", + "CollectionListSerializer", + "CollectionVersionSerializer", + "CollectionVersionDetailSerializer", + "CollectionVersionBaseSerializer", # imports - 'ImportTaskDetailSerializer', - 'ImportTaskListSerializer', + "ImportTaskDetailSerializer", + "ImportTaskListSerializer", # current_user - 'CurrentUserSerializer', + "CurrentUserSerializer", # user - 'UserSerializer', + "UserSerializer", # synclist - 'SyncListSerializer', - 'SyncListCollectionSummarySerializer', + "SyncListSerializer", + "SyncListCollectionSummarySerializer", # distribution - 'DistributionSerializer', + "DistributionSerializer", # container - 'ContainerRepositorySerializer', - 'ContainerRepositoryImageSerializer', - 'ContainerRegistryRemoteSerializer', - 'ContainerRemoteSerializer', + "ContainerRegistryRemoteSerializer", + "ContainerRemoteSerializer", # Search - 'SearchResultsSerializer', + "SearchResultsSerializer", + "OrganizationRepositorySerializer", ) diff --git a/galaxy_ng/app/api/ui/serializers/organization.py b/galaxy_ng/app/api/ui/serializers/organization.py new file mode 100644 index 0000000000..e8d0ca3a6d --- /dev/null +++ b/galaxy_ng/app/api/ui/serializers/organization.py @@ -0,0 +1,18 @@ +from rest_framework.serializers import ModelSerializer + +from galaxy_ng.app import models + + +class OrganizationRepositorySerializer(ModelSerializer): + class Meta: + model = models.OrganizationRepository + fields = ("repository",) + + +class OrganizationRepositoryCreateSerializer(ModelSerializer): + class Meta: + model = models.OrganizationRepository + fields = ("repository", "organization") + extra_kwargs = { + "organization": {"write_only": True}, + } diff --git a/galaxy_ng/app/api/ui/urls.py b/galaxy_ng/app/api/ui/urls.py index 7bab7674bd..42f5c6638e 100644 --- a/galaxy_ng/app/api/ui/urls.py +++ b/galaxy_ng/app/api/ui/urls.py @@ -12,28 +12,45 @@ router.register('my-namespaces', viewsets.MyNamespaceViewSet, basename='my-namespaces') router.register('my-synclists', viewsets.MySyncListViewSet, basename='my-synclists') router.register('users', viewsets.UserViewSet, basename='users') -router.register('collection-versions', - viewsets.CollectionVersionViewSet, basename='collection-versions') router.register( - 'imports/collections', - viewsets.CollectionImportViewSet, - basename='collection-imports', + 'collection-versions', viewsets.CollectionVersionViewSet, basename='collection-versions' +) +router.register( + 'imports/collections', viewsets.CollectionImportViewSet, basename='collection-imports' ) router.register('tags', viewsets.TagsViewSet, basename='tags') router.register('synclists', viewsets.SyncListViewSet, basename='synclists') router.register('remotes', viewsets.CollectionRemoteViewSet, basename='remotes') router.register('distributions', viewsets.DistributionViewSet, basename='distributions') router.register('my-distributions', viewsets.MyDistributionViewSet, basename='my-distributions') - router.register('tags/collections', viewsets.CollectionsTagsViewSet, basename='collections-tags') router.register('tags/roles', viewsets.RolesTagsViewSet, basename='roles-tags') +# organization_router = routers.SimpleRouter() +# organization_router.register( +# 'repositories', viewsets.OrganizationRepositoryViewSet, basename='organization-repository' +# ) auth_views = [ path("login/", views.LoginView.as_view(), name="auth-login"), path("logout/", views.LogoutView.as_view(), name="auth-logout"), ] +organization_paths = [ + path( + "repositories/", + viewsets.OrganizationRepositoryViewSet.as_view({"get": "list"}), + name="organization-repository-list", + ), + path( + "repositories//", + viewsets.OrganizationRepositoryViewSet.as_view( + {"get": "retrieve", "post": "create", "delete": "destroy"} + ), + name="organization-repository-detail", + ), +] + container_paths = [ path( "registries//", @@ -151,7 +168,6 @@ paths = [ path('', include(router.urls)), - path('auth/', include(auth_views)), path("settings/", views.SettingsView.as_view(), name="settings"), path("landing-page/", views.LandingPageView.as_view(), name="landing-page"), @@ -169,6 +185,7 @@ viewsets.CollectionViewSet.as_view({'get': 'retrieve'}), name='collections-detail' ), + path("organizations//", include(organization_paths)), # NOTE: Using path instead of SimpleRouter because SimpleRouter expects retrieve # to look up values with an ID path( diff --git a/galaxy_ng/app/api/ui/viewsets/__init__.py b/galaxy_ng/app/api/ui/viewsets/__init__.py index 50e7deac92..1321ed179a 100644 --- a/galaxy_ng/app/api/ui/viewsets/__init__.py +++ b/galaxy_ng/app/api/ui/viewsets/__init__.py @@ -1,50 +1,41 @@ -from .namespace import ( - NamespaceViewSet, -) - from .collection import ( - CollectionViewSet, - CollectionVersionViewSet, CollectionImportViewSet, - CollectionRemoteViewSet + CollectionRemoteViewSet, + CollectionVersionViewSet, + CollectionViewSet, ) +from .distribution import DistributionViewSet, MyDistributionViewSet +from .execution_environment import ContainerRegistryRemoteViewSet, ContainerRemoteViewSet +from .group import GroupUserViewSet, GroupViewSet from .my_namespace import MyNamespaceViewSet from .my_synclist import MySyncListViewSet -from .tags import ( - TagsViewSet, - CollectionsTagsViewSet, - RolesTagsViewSet -) -from .user import UserViewSet, CurrentUserViewSet -from .synclist import SyncListViewSet +from .namespace import NamespaceViewSet +from .organization import OrganizationRepositoryViewSet from .root import APIRootView -from .group import GroupViewSet, GroupUserViewSet -from .distribution import DistributionViewSet, MyDistributionViewSet -from .execution_environment import ( - ContainerRegistryRemoteViewSet, - ContainerRemoteViewSet -) +from .synclist import SyncListViewSet +from .tags import CollectionsTagsViewSet, RolesTagsViewSet, TagsViewSet +from .user import CurrentUserViewSet, UserViewSet __all__ = ( - 'NamespaceViewSet', - 'MyNamespaceViewSet', - 'MySyncListViewSet', - 'CollectionViewSet', - 'CollectionVersionViewSet', - 'CollectionImportViewSet', - 'CollectionRemoteViewSet', - 'TagsViewSet', - 'CollectionsTagsViewSet', - 'RolesTagsViewSet', - 'CurrentUserViewSet', - 'UserViewSet', - 'SyncListViewSet', - 'APIRootView', - 'GroupViewSet', - 'GroupUserViewSet', - 'DistributionViewSet', - 'MyDistributionViewSet', - 'ContainerRegistryRemoteViewSet', - 'ContainerRemoteViewSet', - 'ContainerTagViewset' + "NamespaceViewSet", + "MyNamespaceViewSet", + "MySyncListViewSet", + "CollectionViewSet", + "CollectionVersionViewSet", + "CollectionImportViewSet", + "CollectionRemoteViewSet", + "TagsViewSet", + "CollectionsTagsViewSet", + "RolesTagsViewSet", + "CurrentUserViewSet", + "UserViewSet", + "SyncListViewSet", + "APIRootView", + "GroupViewSet", + "GroupUserViewSet", + "DistributionViewSet", + "MyDistributionViewSet", + "ContainerRegistryRemoteViewSet", + "ContainerRemoteViewSet", + "OrganizationRepositoryViewSet", ) diff --git a/galaxy_ng/app/api/ui/viewsets/organization.py b/galaxy_ng/app/api/ui/viewsets/organization.py new file mode 100644 index 0000000000..e5691f4c77 --- /dev/null +++ b/galaxy_ng/app/api/ui/viewsets/organization.py @@ -0,0 +1,43 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import mixins, status +from rest_framework.exceptions import NotFound +from rest_framework.response import Response + +from galaxy_ng.app import models +from galaxy_ng.app.access_control import access_policy +from galaxy_ng.app.api import base as api_base +from galaxy_ng.app.api.ui.serializers.organization import ( + OrganizationRepositorySerializer, + OrganizationRepositoryCreateSerializer, +) + + +class OrganizationRepositoryViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + api_base.GenericViewSet +): + permission_classes = [access_policy.OrganizationResourceAccessPolicy] + lookup_field = 'repository_id' + + def get_queryset(self): + org_id = self.kwargs["organization_id"] + org_exists = models.Organization.objects.filter(id=org_id).exists() + if not org_exists: + raise NotFound(_("Organization {} does not exist.").format(org_id)) + return models.OrganizationRepository.objects.filter(organization_id=org_id) + + def get_serializer_class(self): + if self.action == "create": + return OrganizationRepositoryCreateSerializer + return OrganizationRepositorySerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data={ + "repository": self.kwargs["repository_id"], + "organization": self.kwargs["organization_id"], + }) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/galaxy_ng/app/migrations/0051_organizationrepository.py b/galaxy_ng/app/migrations/0051_organizationrepository.py new file mode 100644 index 0000000000..bb9896d2cc --- /dev/null +++ b/galaxy_ng/app/migrations/0051_organizationrepository.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.10 on 2024-02-19 14:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ansible", "0055_alter_collectionversion_version_alter_role_version"), + ("galaxy", "0050_organization_data"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationRepository", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="galaxy.organization", + ), + ), + ( + "repository", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to="ansible.ansiblerepository", + ), + ), + ], + ), + ] diff --git a/galaxy_ng/app/models/__init__.py b/galaxy_ng/app/models/__init__.py index 57fc33abc8..328ccd0f28 100644 --- a/galaxy_ng/app/models/__init__.py +++ b/galaxy_ng/app/models/__init__.py @@ -10,7 +10,7 @@ ContainerRegistryRepos, ) from .namespace import Namespace, NamespaceLink -from .organization import Organization, Team +from .organization import Organization, OrganizationRepository, Team from .synclist import SyncList __all__ = ( @@ -34,6 +34,7 @@ "NamespaceLink", # organization "Organization", + "OrganizationRepository", "Team", # synclist "SyncList", diff --git a/galaxy_ng/app/models/organization.py b/galaxy_ng/app/models/organization.py index d127319889..9f839d9631 100644 --- a/galaxy_ng/app/models/organization.py +++ b/galaxy_ng/app/models/organization.py @@ -8,6 +8,7 @@ from django.dispatch import receiver from django_lifecycle import AFTER_UPDATE, BEFORE_CREATE, LifecycleModelMixin, hook from pulpcore.plugin.models import Group as PulpGroup +from pulp_ansible.app.models import AnsibleRepository from galaxy_ng.app.models.auth import Group @@ -84,3 +85,10 @@ def _create_related_team(sender, instance, created, **kwargs): organization=Organization.objects.get_default(), group=instance, ) + + +class OrganizationRepository(models.Model): + """A joint model between organizations and ansible repositories.""" + + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + repository = models.OneToOneField(AnsibleRepository, on_delete=models.CASCADE) diff --git a/galaxy_ng/app/urls.py b/galaxy_ng/app/urls.py index 05dbdf94f0..c75847aa10 100644 --- a/galaxy_ng/app/urls.py +++ b/galaxy_ng/app/urls.py @@ -1,15 +1,13 @@ +from ansible_base.resource_registry import urls as resource_api_urls from django.conf import settings from django.urls import re_path as url from django.shortcuts import redirect from django.urls import include, path -from . import views +from galaxy_ng.app import views from galaxy_ng.app.api import urls as api_urls from galaxy_ng.ui import urls as ui_urls -from ansible_base.resource_registry.urls import ( - urlpatterns as resource_api_urls, -) from drf_spectacular.views import ( SpectacularJSONAPIView, @@ -48,8 +46,8 @@ SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui", ), + path(f"{API_PATH_PREFIX}/", include(resource_api_urls)), path("healthz", views.health_view), - path(f"{API_PATH_PREFIX}", include(resource_api_urls)) ] if settings.get("API_ROOT") != "/pulp/": diff --git a/galaxy_ng/tests/integration/api/test_organization.py b/galaxy_ng/tests/integration/api/test_organization.py new file mode 100644 index 0000000000..a0853c885c --- /dev/null +++ b/galaxy_ng/tests/integration/api/test_organization.py @@ -0,0 +1,62 @@ +from galaxykit.repositories import create_repository, delete_repository + +from galaxy_ng.tests.integration.utils.tools import generate_random_string + + +def get_pulp_id_hack(resource): + return resource["pulp_href"].rsplit("/", maxsplit=2)[-2] + + +def generate_random_name(prefix='test', length=8): + name = generate_random_string(length) + return f"{prefix}-{name}" + + +def create_organization(client, name, description=''): + response = client.post('service-index/resources/', body={ + "resource_type": "shared.organization", + "resource_data": { + "name": name, + "description": description, + } + }) + return response + + +def delete_organization(client, ansible_id): + client.delete(f'service-index/resources/{ansible_id}/', parse_json=False) + + +# GET {API_PREFIX}/_ui/v1/organizations/{organization_id}/repositories/ +# GET {API_PREFIX}/_ui/v1/organizations/{organization_id}/repositories/{repository_id}/ +# POST {API_PREFIX}/_ui/v1/organizations/{organization_id}/repositories/{repository_id}/ +# DELETE {API_PREFIX}/_ui/v1/organizations/{organization_id}/repositories/{repository_id}/ +def test_organization_repository_api(galaxy_client): + client = galaxy_client("admin") + + repository = create_repository(client, generate_random_name("test-org-repo")) + repository_id = get_pulp_id_hack(repository) + + organization = create_organization(client, generate_random_name("test-org-repo")) + organization_id = organization["object_id"] + + response = client.get(f"_ui/v1/organizations/{organization_id}/repositories/") + assert response["data"] == [] + + org_repo_binding = client.post( + f"_ui/v1/organizations/{organization_id}/repositories/{repository_id}/", body={} + ) + + response = client.get(f"_ui/v1/organizations/{organization_id}/repositories/") + assert response["data"] == [org_repo_binding] + + client.delete( + f"_ui/v1/organizations/{organization_id}/repositories/{repository_id}/", + parse_json=False, + ) + + response = client.get(f"_ui/v1/organizations/{organization_id}/repositories/") + assert response["data"] == [] + + delete_organization(client, organization["ansible_id"]) + delete_repository(client, repository["name"])