From 6d646ea328862a1dda7c9c8e5d289863e9f19487 Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Fri, 9 Aug 2024 15:04:09 -0400 Subject: [PATCH 01/11] List end-point for catalog --- ...ionalinfo_program_type_provider_iconurl.py | 36 ++++++++ gateway/api/models.py | 18 +++- gateway/api/serializers.py | 53 ++++++++++++ gateway/api/v1/serializers.py | 9 ++ gateway/api/v1/urls.py | 5 ++ gateway/api/v1/views.py | 10 +++ gateway/api/views.py | 40 +++++++++ gateway/tests/api/test_v1_catalog.py | 59 +++++++++++++ gateway/tests/api/test_v1_serializers.py | 9 -- gateway/tests/fixtures/catalog_fixtures.json | 85 +++++++++++++++++++ 10 files changed, 314 insertions(+), 10 deletions(-) create mode 100644 gateway/api/migrations/0029_program_additionalinfo_program_type_provider_iconurl.py create mode 100644 gateway/tests/api/test_v1_catalog.py create mode 100644 gateway/tests/fixtures/catalog_fixtures.json diff --git a/gateway/api/migrations/0029_program_additionalinfo_program_type_provider_iconurl.py b/gateway/api/migrations/0029_program_additionalinfo_program_type_provider_iconurl.py new file mode 100644 index 000000000..3457f6e2f --- /dev/null +++ b/gateway/api/migrations/0029_program_additionalinfo_program_type_provider_iconurl.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1 on 2024-08-14 21:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0028_remove_provider_admin_group_provider_admin_groups"), + ] + + operations = [ + migrations.AddField( + model_name="program", + name="additionalInfo", + field=models.TextField(blank=True, default="{}", null=True), + ), + migrations.AddField( + model_name="program", + name="type", + field=models.CharField( + choices=[ + ("GENERIC", "Generic"), + ("APPLICATION", "Application"), + ("CIRCUIT", "Circuit"), + ], + default="GENERIC", + max_length=20, + ), + ), + migrations.AddField( + model_name="provider", + name="iconUrl", + field=models.TextField(blank=True, default=None, null=True), + ), + ] diff --git a/gateway/api/models.py b/gateway/api/models.py index 3324609e6..3ca133396 100644 --- a/gateway/api/models.py +++ b/gateway/api/models.py @@ -51,6 +51,7 @@ class Provider(models.Model): updated = models.DateTimeField(auto_now=True, null=True) name = models.CharField(max_length=255, db_index=True, unique=True) + iconUrl = models.TextField(null=True, blank=True, default=None) registry = models.CharField(max_length=255, null=True, blank=True, default=None) admin_groups = models.ManyToManyField(Group) @@ -61,11 +62,27 @@ def __str__(self): class Program(ExportModelOperationsMixin("program"), models.Model): """Program model.""" + GENERIC = "GENERIC" + APPLICATION = "APPLICATION" + CIRCUIT = "CIRCUIT" + PROGRAM_TYPES = [ + (GENERIC, "Generic"), + (APPLICATION, "Application"), + (CIRCUIT, "Circuit"), + ] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) created = models.DateTimeField(auto_now_add=True, editable=False) title = models.CharField(max_length=255, db_index=True) + type = models.CharField( + max_length=20, + choices=PROGRAM_TYPES, + default=GENERIC, + ) description = models.TextField(null=True, blank=True) + additionalInfo = models.TextField(null=True, blank=True, default="{}") + entrypoint = models.CharField(max_length=255, default=DEFAULT_PROGRAM_ENTRYPOINT) artifact = models.FileField( upload_to=get_upload_path, @@ -74,7 +91,6 @@ class Program(ExportModelOperationsMixin("program"), models.Model): validators=[FileExtensionValidator(allowed_extensions=["tar"])], ) image = models.CharField(max_length=511, null=True, blank=True) - env_vars = models.TextField(null=False, blank=True, default="{}") dependencies = models.TextField(null=False, blank=True, default="[]") diff --git a/gateway/api/serializers.py b/gateway/api/serializers.py index 19500659b..91421255d 100644 --- a/gateway/api/serializers.py +++ b/gateway/api/serializers.py @@ -10,6 +10,8 @@ import logging from typing import Tuple, Union from django.conf import settings +from django.contrib.auth.models import Group, Permission +from django.db.models import Q from rest_framework import serializers from api.utils import build_env_variables, encrypt_env_vars @@ -20,6 +22,7 @@ JobConfig, RuntimeJob, DEFAULT_PROGRAM_ENTRYPOINT, + RUN_PROGRAM_PERMISSION, ) logger = logging.getLogger("gateway.serializers") @@ -242,3 +245,53 @@ class RuntimeJobSerializer(serializers.ModelSerializer): class Meta: model = RuntimeJob + + +class CatalogProviderSerializer(serializers.ModelSerializer): + """ + Serializer for the Provider model in the Catalog View. + """ + + class Meta: + model = Provider + fields = ["name", "iconUrl"] + + +class ListCatalogSerializer(serializers.ModelSerializer): + """ + Serializer for the Catalog View. + """ + + provider = CatalogProviderSerializer() + available = serializers.SerializerMethodField() + + class Meta: + model = Program + + def get_available(self, obj): + """ + This method populates available field. + If the user has RUN PERMISSION in any of its groups + available field will be True. If not, will be False. + """ + author = self.context.get("author", None) + + if author is None: + logger.debug( + "User not authenticated in ListCatalogSerializer return available to False" + ) + return False + + # This will be refactorize it when we implement repository architecture + # pylint: disable=duplicate-code + run_program_permission = Permission.objects.get(codename=RUN_PROGRAM_PERMISSION) + + user_criteria = Q(user=author) + run_permission_criteria = Q(permissions=run_program_permission) + author_groups_with_run_permissions = Group.objects.filter( + user_criteria & run_permission_criteria + ) + + return obj.instances.filter( + id__in=[group.id for group in author_groups_with_run_permissions] + ).exists() diff --git a/gateway/api/v1/serializers.py b/gateway/api/v1/serializers.py index 889b6fa84..8eca2991d 100644 --- a/gateway/api/v1/serializers.py +++ b/gateway/api/v1/serializers.py @@ -172,3 +172,12 @@ class RuntimeJobSerializer(serializers.RuntimeJobSerializer): class Meta(serializers.RuntimeJobSerializer.Meta): fields = ["job", "runtime_job"] + + +class ListCatalogSerializer(serializers.ListCatalogSerializer): + """ + Serializer for the Catalog View. + """ + + class Meta(serializers.ListCatalogSerializer.Meta): + fields = ["id", "title", "type", "description", "provider", "available"] diff --git a/gateway/api/v1/urls.py b/gateway/api/v1/urls.py index 3aebcb304..b8be7e2f0 100644 --- a/gateway/api/v1/urls.py +++ b/gateway/api/v1/urls.py @@ -19,5 +19,10 @@ router.register( r"files", v1_views.FilesViewSet, basename=v1_views.FilesViewSet.BASE_NAME ) +router.register( + r"catalog", + v1_views.CatalogViewSet, + basename=v1_views.CatalogViewSet.BASE_NAME, +) urlpatterns = router.urls diff --git a/gateway/api/v1/views.py b/gateway/api/v1/views.py index 86ccc0084..03ac9ca7d 100644 --- a/gateway/api/v1/views.py +++ b/gateway/api/v1/views.py @@ -107,3 +107,13 @@ class FilesViewSet(views.FilesViewSet): """ permission_classes = [permissions.IsAuthenticated, IsOwner] + + +class CatalogViewSet(views.CatalogViewSet): + """ + Quantum function view set first version. Use ProgramSerializer V1. + """ + + serializer_class = v1_serializers.ListCatalogSerializer + pagination_class = None + permission_classes = [permissions.IsAuthenticatedOrReadOnly] diff --git a/gateway/api/views.py b/gateway/api/views.py index cfc92b5f1..7b4025380 100644 --- a/gateway/api/views.py +++ b/gateway/api/views.py @@ -754,3 +754,43 @@ def upload(self, request): # pylint: disable=invalid-name destination.write(chunk) return Response({"message": file_path}) return Response("server error", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class CatalogViewSet(viewsets.GenericViewSet): + """ViewSet to handle requests from IQP for the catalog page. + + This ViewSet contains public end-points to retrieve information. + """ + + BASE_NAME = "catalog" + PUBLIC_GROUP_NAME = "public" # "ibm-q/open/main" + + def get_queryset(self): + # try: + public_group = Group.objects.get(name=self.PUBLIC_GROUP_NAME) + # except: + return Program.objects.filter(instances=public_group).distinct() + + def list(self, request): + """List programs:""" + tracer = trace.get_tracer("gateway.tracer") + ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) + with tracer.start_as_current_span("gateway.iqp_catalog.list", context=ctx): + author = None + if request.user.is_authenticated: + author = request.user + serializer = self.get_serializer( + self.get_queryset(), context={"author": author}, many=True + ) + + return Response(serializer.data) + + # def retrieve(self, request, pk=None): # pylint: disable=unused-argument + # """Get program:""" + # tracer = trace.get_tracer("gateway.tracer") + # ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) + # with tracer.start_as_current_span("gateway.iqp_catalog.retrieve", context=ctx): + # # make a check to ensure that the model is public + # instance = self.get_object() + # serializer = self.get_serializer(instance) + # return Response(serializer.data) diff --git a/gateway/tests/api/test_v1_catalog.py b/gateway/tests/api/test_v1_catalog.py new file mode 100644 index 000000000..ea81f82a4 --- /dev/null +++ b/gateway/tests/api/test_v1_catalog.py @@ -0,0 +1,59 @@ +"""Tests catalog APIs.""" + +from django.contrib.auth import models +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from api.models import Program + + +class TestCatalogApi(APITestCase): + """TestCatalogApi.""" + + fixtures = ["tests/fixtures/catalog_fixtures.json"] + + def test_catalog_list_non_auth_user(self): + """Tests catalog list non-authenticated.""" + url = reverse("v1:catalog-list") + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + public_function = response.data[0] + self.assertEqual(public_function.get("available"), False) + self.assertEqual(public_function.get("title"), "Public-Function") + self.assertEqual(public_function.get("type"), Program.APPLICATION) + + def test_catalog_list_with_auth_user_without_run_permission(self): + """Tests catalog list authenticated without run permission.""" + user = models.User.objects.get(username="test_user") + self.client.force_authenticate(user=user) + + url = reverse("v1:catalog-list") + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + public_function = response.data[0] + self.assertEqual(public_function.get("available"), False) + self.assertEqual(public_function.get("title"), "Public-Function") + self.assertEqual(public_function.get("type"), Program.APPLICATION) + + def test_catalog_list_with_auth_user_with_run_permission(self): + """Tests catalog list authenticated with run permission.""" + user = models.User.objects.get(username="test_user_2") + self.client.force_authenticate(user=user) + + url = reverse("v1:catalog-list") + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + public_function = response.data[0] + self.assertEqual(public_function.get("available"), True) + self.assertEqual(public_function.get("title"), "Public-Function") + self.assertEqual(public_function.get("type"), Program.APPLICATION) diff --git a/gateway/tests/api/test_v1_serializers.py b/gateway/tests/api/test_v1_serializers.py index 304e0068f..0bba2fbf9 100644 --- a/gateway/tests/api/test_v1_serializers.py +++ b/gateway/tests/api/test_v1_serializers.py @@ -261,9 +261,6 @@ def test_upload_program_serializer_with_only_title(self): def test_upload_program_serializer_allowed_dependencies(self): """Tests dependency allowlist.""" - - print("TEST: Program succeeds if all dependencies are allowlisted") - path_to_resource_artifact = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", @@ -299,9 +296,6 @@ def test_upload_program_serializer_allowed_dependencies(self): def test_upload_program_serializer_blocked_dependency(self): """Tests dependency allowlist.""" - - print("TEST: Upload fails if dependency isn't allowlisted") - path_to_resource_artifact = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", @@ -332,9 +326,6 @@ def test_upload_program_serializer_blocked_dependency(self): def test_upload_program_serializer_dependency_bad_version(self): """Tests dependency allowlist.""" - - print("TEST: Upload fails if dependency version isn't allowlisted") - path_to_resource_artifact = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", diff --git a/gateway/tests/fixtures/catalog_fixtures.json b/gateway/tests/fixtures/catalog_fixtures.json new file mode 100644 index 000000000..a1c46bc98 --- /dev/null +++ b/gateway/tests/fixtures/catalog_fixtures.json @@ -0,0 +1,85 @@ +[ + { + "model": "auth.user", + "pk": 1, + "fields": { + "email": "test_user@email.com", + "username": "test_user", + "password": "pbkdf2_sha256$390000$kcex1rxhZg6VVJYkx71cBX$e4ns0xDykbO6Dz6j4nZ4uNusqkB9GVpojyegPv5/9KM=", + "is_active": true, + "groups": [ + 100 + ] + } + }, + { + "model": "auth.user", + "pk": 2, + "fields": { + "email": "test_user_2@email.com", + "username": "test_user_2", + "password": "pbkdf2_sha256$390000$kcex1rxhZg6VVJYkx71cBX$e4ns0xDykbO6Dz6j4nZ4uNusqkB9GVpojyegPv5/9KM=", + "is_active": true, + "groups": [ + 105 + ] + } + }, + { + "model": "api.program", + "pk": "1a7947f9-6ae8-4e3d-ac1e-e7d608deec82", + "fields": { + "created": "2023-02-01T15:30:43.281796Z", + "title": "Public-Function", + "type": "APPLICATION", + "image": "icr.io/awesome-namespace/awesome-title", + "author": 2, + "provider": "bfe8aa6a-2127-4123-bf57-5b547293cbea", + "instances": [ + 100, + 105 + ] + } + }, + { + "model": "api.program", + "pk": "032946b1-29f1-49c0-89dc-c4f24e85859a", + "fields": { + "created": "2023-02-01T15:30:43.281796Z", + "title": "Private-Function", + "image": "icr.io/awesome-namespace/awesome-title", + "author": 2, + "provider": "bfe8aa6a-2127-4123-bf57-5b547293cbea", + "instances": [ + 105 + ] + } + }, + { + "model": "auth.group", + "pk": 100, + "fields": { + "name": "public", + "permissions": [60] + } + }, + { + "model": "auth.group", + "pk": 105, + "fields": { + "name": "admin-group", + "permissions": [60, 61] + } + }, + { + "model": "api.provider", + "pk": "bfe8aa6a-2127-4123-bf57-5b547293cbea", + "fields": { + "name": "default", + "iconUrl": "https://example", + "created": "2023-02-01T15:30:43.281796Z", + "admin_groups": [105], + "registry": "docker.io/awesome" + } + } +] \ No newline at end of file From a23497d8c13f49c86794b2636cbfaf271e3ff445 Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:18:12 -0400 Subject: [PATCH 02/11] Added retrieve end-point --- ...nfo_program_documentation_url_and_more.py} | 11 ++- gateway/api/models.py | 5 +- gateway/api/serializers.py | 57 ++++++++++++++- gateway/api/v1/serializers.py | 21 +++++- gateway/api/v1/views.py | 4 ++ gateway/api/views.py | 46 +++++++++--- gateway/tests/api/test_v1_catalog.py | 72 +++++++++++++++++++ gateway/tests/fixtures/catalog_fixtures.json | 3 +- 8 files changed, 199 insertions(+), 20 deletions(-) rename gateway/api/migrations/{0029_program_additionalinfo_program_type_provider_iconurl.py => 0029_program_additional_info_program_documentation_url_and_more.py} (75%) diff --git a/gateway/api/migrations/0029_program_additionalinfo_program_type_provider_iconurl.py b/gateway/api/migrations/0029_program_additional_info_program_documentation_url_and_more.py similarity index 75% rename from gateway/api/migrations/0029_program_additionalinfo_program_type_provider_iconurl.py rename to gateway/api/migrations/0029_program_additional_info_program_documentation_url_and_more.py index 3457f6e2f..afd2ef813 100644 --- a/gateway/api/migrations/0029_program_additionalinfo_program_type_provider_iconurl.py +++ b/gateway/api/migrations/0029_program_additional_info_program_documentation_url_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1 on 2024-08-14 21:05 +# Generated by Django 5.1 on 2024-08-15 16:19 from django.db import migrations, models @@ -12,9 +12,14 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name="program", - name="additionalInfo", + name="additional_info", field=models.TextField(blank=True, default="{}", null=True), ), + migrations.AddField( + model_name="program", + name="documentation_url", + field=models.TextField(blank=True, default=None, null=True), + ), migrations.AddField( model_name="program", name="type", @@ -30,7 +35,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name="provider", - name="iconUrl", + name="icon_url", field=models.TextField(blank=True, default=None, null=True), ), ] diff --git a/gateway/api/models.py b/gateway/api/models.py index 3ca133396..3ff6b4a81 100644 --- a/gateway/api/models.py +++ b/gateway/api/models.py @@ -51,7 +51,7 @@ class Provider(models.Model): updated = models.DateTimeField(auto_now=True, null=True) name = models.CharField(max_length=255, db_index=True, unique=True) - iconUrl = models.TextField(null=True, blank=True, default=None) + icon_url = models.TextField(null=True, blank=True, default=None) registry = models.CharField(max_length=255, null=True, blank=True, default=None) admin_groups = models.ManyToManyField(Group) @@ -81,7 +81,8 @@ class Program(ExportModelOperationsMixin("program"), models.Model): default=GENERIC, ) description = models.TextField(null=True, blank=True) - additionalInfo = models.TextField(null=True, blank=True, default="{}") + documentation_url = models.TextField(null=True, blank=True, default=None) + additional_info = models.TextField(null=True, blank=True, default="{}") entrypoint = models.CharField(max_length=255, default=DEFAULT_PROGRAM_ENTRYPOINT) artifact = models.FileField( diff --git a/gateway/api/serializers.py b/gateway/api/serializers.py index 91421255d..9581da84e 100644 --- a/gateway/api/serializers.py +++ b/gateway/api/serializers.py @@ -254,12 +254,12 @@ class CatalogProviderSerializer(serializers.ModelSerializer): class Meta: model = Provider - fields = ["name", "iconUrl"] + fields = ["name", "icon_url"] class ListCatalogSerializer(serializers.ModelSerializer): """ - Serializer for the Catalog View. + List Serializer for the Catalog View. """ provider = CatalogProviderSerializer() @@ -295,3 +295,56 @@ def get_available(self, obj): return obj.instances.filter( id__in=[group.id for group in author_groups_with_run_permissions] ).exists() + + +class RetrieveCatalogSerializer(serializers.ModelSerializer): + """ + Retrieve Serializer for the Catalog View. + """ + + provider = CatalogProviderSerializer() + available = serializers.SerializerMethodField() + + class Meta: + model = Program + + def to_representation(self, instance): + representation = super().to_representation(instance) + + json_additional_info = {} + if instance.additional_info is not None: + try: + json_additional_info = json.loads(instance.additional_info) + except json.decoder.JSONDecodeError: + logger.error("JSONDecodeError loading instance.additional_info") + + representation["additional_info"] = json_additional_info + return representation + + def get_available(self, obj): + """ + This method populates available field. + If the user has RUN PERMISSION in any of its groups + available field will be True. If not, will be False. + """ + author = self.context.get("author", None) + + if author is None: + logger.debug( + "User not authenticated in ListCatalogSerializer return available to False" + ) + return False + + # This will be refactorize it when we implement repository architecture + # pylint: disable=duplicate-code + run_program_permission = Permission.objects.get(codename=RUN_PROGRAM_PERMISSION) + + user_criteria = Q(user=author) + run_permission_criteria = Q(permissions=run_program_permission) + author_groups_with_run_permissions = Group.objects.filter( + user_criteria & run_permission_criteria + ) + + return obj.instances.filter( + id__in=[group.id for group in author_groups_with_run_permissions] + ).exists() diff --git a/gateway/api/v1/serializers.py b/gateway/api/v1/serializers.py index 8eca2991d..841a5d7cd 100644 --- a/gateway/api/v1/serializers.py +++ b/gateway/api/v1/serializers.py @@ -26,6 +26,7 @@ class Meta(serializers.ProgramSerializer.Meta): "dependencies", "provider", "description", + "documentation_url", ] @@ -176,8 +177,26 @@ class Meta(serializers.RuntimeJobSerializer.Meta): class ListCatalogSerializer(serializers.ListCatalogSerializer): """ - Serializer for the Catalog View. + List Serializer for the Catalog View. """ class Meta(serializers.ListCatalogSerializer.Meta): fields = ["id", "title", "type", "description", "provider", "available"] + + +class RetrieveCatalogSerializer(serializers.RetrieveCatalogSerializer): + """ + Retrieve Serializer for the Catalog View. + """ + + class Meta(serializers.RetrieveCatalogSerializer.Meta): + fields = [ + "id", + "title", + "type", + "description", + "documentation_url", + "provider", + "available", + "additional_info", + ] diff --git a/gateway/api/v1/views.py b/gateway/api/v1/views.py index 03ac9ca7d..42731a6b5 100644 --- a/gateway/api/v1/views.py +++ b/gateway/api/v1/views.py @@ -114,6 +114,10 @@ class CatalogViewSet(views.CatalogViewSet): Quantum function view set first version. Use ProgramSerializer V1. """ + @staticmethod + def get_serializer_retrieve_catalog(*args, **kwargs): + return v1_serializers.RetrieveCatalogSerializer(*args, **kwargs) + serializer_class = v1_serializers.ListCatalogSerializer pagination_class = None permission_classes = [permissions.IsAuthenticatedOrReadOnly] diff --git a/gateway/api/views.py b/gateway/api/views.py index 7b4025380..de0e8d165 100644 --- a/gateway/api/views.py +++ b/gateway/api/views.py @@ -47,6 +47,7 @@ RunJobSerializer, RunProgramSerializer, UploadProgramSerializer, + RetrieveCatalogSerializer, ) logger = logging.getLogger("gateway") @@ -765,32 +766,55 @@ class CatalogViewSet(viewsets.GenericViewSet): BASE_NAME = "catalog" PUBLIC_GROUP_NAME = "public" # "ibm-q/open/main" + @staticmethod + def get_serializer_retrieve_catalog(*args, **kwargs): + """ + This method returns Retrieve Catalog serializer to be used in Catalog ViewSet. + """ + + return RetrieveCatalogSerializer(*args, **kwargs) + def get_queryset(self): # try: public_group = Group.objects.get(name=self.PUBLIC_GROUP_NAME) # except: return Program.objects.filter(instances=public_group).distinct() + def get_retrieve_queryset(self, pk): + # try: + public_group = Group.objects.get(name=self.PUBLIC_GROUP_NAME) + # except: + return Program.objects.filter(id=pk, instances=public_group).first() + def list(self, request): """List programs:""" tracer = trace.get_tracer("gateway.tracer") ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) - with tracer.start_as_current_span("gateway.iqp_catalog.list", context=ctx): + with tracer.start_as_current_span("gateway.catalog.list", context=ctx): author = None if request.user.is_authenticated: author = request.user serializer = self.get_serializer( self.get_queryset(), context={"author": author}, many=True ) - return Response(serializer.data) - # def retrieve(self, request, pk=None): # pylint: disable=unused-argument - # """Get program:""" - # tracer = trace.get_tracer("gateway.tracer") - # ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) - # with tracer.start_as_current_span("gateway.iqp_catalog.retrieve", context=ctx): - # # make a check to ensure that the model is public - # instance = self.get_object() - # serializer = self.get_serializer(instance) - # return Response(serializer.data) + def retrieve(self, request, pk=None): + """Get program:""" + tracer = trace.get_tracer("gateway.tracer") + ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) + with tracer.start_as_current_span("gateway.catalog.retrieve", context=ctx): + instance = self.get_retrieve_queryset(pk) + if instance is None: + return Response( + {"message": "Qiskit Function not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + author = None + if request.user.is_authenticated: + author = request.user + serializer = self.get_serializer_retrieve_catalog( + instance, context={"author": author} + ) + return Response(serializer.data) diff --git a/gateway/tests/api/test_v1_catalog.py b/gateway/tests/api/test_v1_catalog.py index ea81f82a4..cd7e6802a 100644 --- a/gateway/tests/api/test_v1_catalog.py +++ b/gateway/tests/api/test_v1_catalog.py @@ -57,3 +57,75 @@ def test_catalog_list_with_auth_user_with_run_permission(self): self.assertEqual(public_function.get("available"), True) self.assertEqual(public_function.get("title"), "Public-Function") self.assertEqual(public_function.get("type"), Program.APPLICATION) + + def test_catalog_retrieve_non_auth_user(self): + """Tests catalog retrieve non-authenticated.""" + url = reverse( + "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec82"] + ) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + public_function = response.data + self.assertEqual(public_function.get("available"), False) + self.assertEqual(public_function.get("title"), "Public-Function") + self.assertEqual(public_function.get("type"), Program.APPLICATION) + self.assertTrue(isinstance(public_function.get("additional_info"), dict)) + + def test_catalog_404_retrieve_non_auth_user(self): + """Tests catalog retrieve a non-existent function as non-authenticated.""" + url = reverse( + "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec83"] + ) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_catalog_404_retrieve_auth_user(self): + """Tests catalog retrieve a non-existent function as authenticated.""" + user = models.User.objects.get(username="test_user") + self.client.force_authenticate(user=user) + + url = reverse( + "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec83"] + ) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_catalog_retrieve_with_auth_user_without_run_permission(self): + """Tests catalog retrieve as authenticated without run permission.""" + user = models.User.objects.get(username="test_user") + self.client.force_authenticate(user=user) + + url = reverse( + "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec82"] + ) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + public_function = response.data + self.assertEqual(public_function.get("available"), False) + self.assertEqual(public_function.get("title"), "Public-Function") + self.assertEqual(public_function.get("type"), Program.APPLICATION) + self.assertTrue(isinstance(public_function.get("additional_info"), dict)) + + def test_catalog_retrieve_with_auth_user_with_run_permission(self): + """Tests catalog retrieve as authenticated with run permission.""" + user = models.User.objects.get(username="test_user_2") + self.client.force_authenticate(user=user) + + url = reverse( + "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec82"] + ) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + public_function = response.data + self.assertEqual(public_function.get("available"), True) + self.assertEqual(public_function.get("title"), "Public-Function") + self.assertEqual(public_function.get("type"), Program.APPLICATION) + self.assertTrue(isinstance(public_function.get("additional_info"), dict)) diff --git a/gateway/tests/fixtures/catalog_fixtures.json b/gateway/tests/fixtures/catalog_fixtures.json index a1c46bc98..2bb1f8286 100644 --- a/gateway/tests/fixtures/catalog_fixtures.json +++ b/gateway/tests/fixtures/catalog_fixtures.json @@ -35,6 +35,7 @@ "image": "icr.io/awesome-namespace/awesome-title", "author": 2, "provider": "bfe8aa6a-2127-4123-bf57-5b547293cbea", + "additional_info": "{\"request_access_url\":\"an url to ask for access to that function\",\"features_and_benefits\":[{\"title\":\"short description of the benefit\",\"description\":\"detailed explanation\"}],\"tutorials\":[{\"category\":\"tutorial category\",\"name\":\"tutorial name\",\"url\":\"tutorial url\"}],\"papers\":[{\"title\":\"paper title\",\"authors\":[{\"name\":\"author name\",\"location\":\"where the author is placed\",\"profession\":\"author main activity\"}],\"url\":\"link to the full text\",\"published_at\":\"2024\",\"arxiv_id\":\"arXiv:2408.01264\"}],\"testimonials\":[{\"title\":\"highlights of the testimonial\",\"authors\":[{\"name\":\"author name\",\"location\":\"where the author is placed\",\"profession\":\"author main activity\"}],\"description\":\"testimonial full text\"}]}", "instances": [ 100, 105 @@ -76,7 +77,7 @@ "pk": "bfe8aa6a-2127-4123-bf57-5b547293cbea", "fields": { "name": "default", - "iconUrl": "https://example", + "icon_url": "https://example", "created": "2023-02-01T15:30:43.281796Z", "admin_groups": [105], "registry": "docker.io/awesome" From 8a3ea55d0f51af7984e7b7c93ec33dd98264c6a9 Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:52:58 -0400 Subject: [PATCH 03/11] Improved error handling in the querysets --- gateway/api/views.py | 28 ++++++++++++++++++++-------- gateway/tests/api/test_v1_catalog.py | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/gateway/api/views.py b/gateway/api/views.py index de0e8d165..b975d6a14 100644 --- a/gateway/api/views.py +++ b/gateway/api/views.py @@ -775,19 +775,31 @@ def get_serializer_retrieve_catalog(*args, **kwargs): return RetrieveCatalogSerializer(*args, **kwargs) def get_queryset(self): - # try: - public_group = Group.objects.get(name=self.PUBLIC_GROUP_NAME) - # except: + """ + QuerySet to list public programs in the catalog + """ + public_group = Group.objects.filter(name=self.PUBLIC_GROUP_NAME).first() + + if public_group is None: + logger.error("Public group [%s] does not exist.", self.PUBLIC_GROUP_NAME) + return [] + return Program.objects.filter(instances=public_group).distinct() def get_retrieve_queryset(self, pk): - # try: - public_group = Group.objects.get(name=self.PUBLIC_GROUP_NAME) - # except: + """ + QuerySet to retrieve a specifc public programs in the catalog + """ + public_group = Group.objects.filter(name=self.PUBLIC_GROUP_NAME).first() + + if public_group is None: + logger.error("Public group [%s] does not exist.", self.PUBLIC_GROUP_NAME) + return [] + return Program.objects.filter(id=pk, instances=public_group).first() def list(self, request): - """List programs:""" + """List public programs in the catalog:""" tracer = trace.get_tracer("gateway.tracer") ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) with tracer.start_as_current_span("gateway.catalog.list", context=ctx): @@ -800,7 +812,7 @@ def list(self, request): return Response(serializer.data) def retrieve(self, request, pk=None): - """Get program:""" + """Get a specific program in the catalog:""" tracer = trace.get_tracer("gateway.tracer") ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) with tracer.start_as_current_span("gateway.catalog.retrieve", context=ctx): diff --git a/gateway/tests/api/test_v1_catalog.py b/gateway/tests/api/test_v1_catalog.py index cd7e6802a..22f682043 100644 --- a/gateway/tests/api/test_v1_catalog.py +++ b/gateway/tests/api/test_v1_catalog.py @@ -86,7 +86,7 @@ def test_catalog_404_retrieve_auth_user(self): """Tests catalog retrieve a non-existent function as authenticated.""" user = models.User.objects.get(username="test_user") self.client.force_authenticate(user=user) - + url = reverse( "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec83"] ) From 79703c3037c2655872451471d76462c0d3dcb70a Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:58:41 -0400 Subject: [PATCH 04/11] Public group to settings --- gateway/api/views.py | 2 +- gateway/main/settings.py | 3 +++ gateway/tests/fixtures/catalog_fixtures.json | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/gateway/api/views.py b/gateway/api/views.py index b975d6a14..932b65e0c 100644 --- a/gateway/api/views.py +++ b/gateway/api/views.py @@ -764,7 +764,7 @@ class CatalogViewSet(viewsets.GenericViewSet): """ BASE_NAME = "catalog" - PUBLIC_GROUP_NAME = "public" # "ibm-q/open/main" + PUBLIC_GROUP_NAME = settings.PUBLIC_GROUP_NAME @staticmethod def get_serializer_retrieve_catalog(*args, **kwargs): diff --git a/gateway/main/settings.py b/gateway/main/settings.py index c2eef6167..894a76ec5 100644 --- a/gateway/main/settings.py +++ b/gateway/main/settings.py @@ -391,3 +391,6 @@ "FUNCTIONS_PERMISSIONS", "{}", ) + +# Public group name +PUBLIC_GROUP_NAME = os.environ.get("PUBLIC_GROUP_NAME", "ibm-q/open/main") diff --git a/gateway/tests/fixtures/catalog_fixtures.json b/gateway/tests/fixtures/catalog_fixtures.json index 2bb1f8286..9424f0242 100644 --- a/gateway/tests/fixtures/catalog_fixtures.json +++ b/gateway/tests/fixtures/catalog_fixtures.json @@ -60,7 +60,7 @@ "model": "auth.group", "pk": 100, "fields": { - "name": "public", + "name": "ibm-q/open/main", "permissions": [60] } }, From 299d572c03327343f486366c97329e145cc2c099 Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Thu, 15 Aug 2024 16:18:10 -0400 Subject: [PATCH 05/11] Set public group name in the helm --- .../qiskit-serverless/charts/gateway/templates/deployment.yaml | 2 ++ charts/qiskit-serverless/charts/gateway/values.yaml | 1 + 2 files changed, 3 insertions(+) diff --git a/charts/qiskit-serverless/charts/gateway/templates/deployment.yaml b/charts/qiskit-serverless/charts/gateway/templates/deployment.yaml index 4c45b3eee..7eec9b26e 100644 --- a/charts/qiskit-serverless/charts/gateway/templates/deployment.yaml +++ b/charts/qiskit-serverless/charts/gateway/templates/deployment.yaml @@ -187,6 +187,8 @@ spec: value: "{{ .Values.tasks.providersConfiguration }}" - name: FUNCTIONS_PERMISSIONS value: "{{ .Values.tasks.functionsPermissions }}" + - name: PUBLIC_GROUP_NAME + value: {{ .Values.application.publicGroupName }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/charts/qiskit-serverless/charts/gateway/values.yaml b/charts/qiskit-serverless/charts/gateway/values.yaml index d065d8868..431b0a107 100644 --- a/charts/qiskit-serverless/charts/gateway/values.yaml +++ b/charts/qiskit-serverless/charts/gateway/values.yaml @@ -51,6 +51,7 @@ application: url: "https://auth.quantum-computing.ibm.com/api" iqpQcon: url: "https://api-qcon.quantum.ibm.com/api" + publicGroupName: "ibm-q/open/main" cos: claimName: gateway-claim From bd54c7ebd82805e5c87a21a739ba1c429d5f621e Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Thu, 15 Aug 2024 17:15:04 -0400 Subject: [PATCH 06/11] Create swagger documentation for new end-points --- gateway/api/v1/views.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/gateway/api/v1/views.py b/gateway/api/v1/views.py index 42731a6b5..2b3dbebed 100644 --- a/gateway/api/v1/views.py +++ b/gateway/api/v1/views.py @@ -121,3 +121,17 @@ def get_serializer_retrieve_catalog(*args, **kwargs): serializer_class = v1_serializers.ListCatalogSerializer pagination_class = None permission_classes = [permissions.IsAuthenticatedOrReadOnly] + + @swagger_auto_schema( + operation_description="List public functions for catalog", + responses={status.HTTP_200_OK: v1_serializers.ListCatalogSerializer(many=True)}, + ) + def list(self, request): + return super().list(request) + + @swagger_auto_schema( + operation_description="Get a specific public function for catalog", + responses={status.HTTP_200_OK: v1_serializers.RetrieveCatalogSerializer(many=False)}, + ) + def retrieve(self, request, pk=None): + return super().retrieve(request, pk) \ No newline at end of file From 617c4b91c090e25e00213192de3fa7f4128c2bd0 Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Fri, 16 Aug 2024 11:07:22 -0400 Subject: [PATCH 07/11] Fix linter --- gateway/api/v1/views.py | 8 +++++--- gateway/main/settings.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/gateway/api/v1/views.py b/gateway/api/v1/views.py index 2b3dbebed..7edd823d0 100644 --- a/gateway/api/v1/views.py +++ b/gateway/api/v1/views.py @@ -128,10 +128,12 @@ def get_serializer_retrieve_catalog(*args, **kwargs): ) def list(self, request): return super().list(request) - + @swagger_auto_schema( operation_description="Get a specific public function for catalog", - responses={status.HTTP_200_OK: v1_serializers.RetrieveCatalogSerializer(many=False)}, + responses={ + status.HTTP_200_OK: v1_serializers.RetrieveCatalogSerializer(many=False) + }, ) def retrieve(self, request, pk=None): - return super().retrieve(request, pk) \ No newline at end of file + return super().retrieve(request, pk) diff --git a/gateway/main/settings.py b/gateway/main/settings.py index 894a76ec5..36da4ee17 100644 --- a/gateway/main/settings.py +++ b/gateway/main/settings.py @@ -393,4 +393,4 @@ ) # Public group name -PUBLIC_GROUP_NAME = os.environ.get("PUBLIC_GROUP_NAME", "ibm-q/open/main") +PUBLIC_GROUP_NAME = os.environ.get("PUBLIC_GROUP_NAME", "ibm-q/open/main") From 1c057301ec0c23f735f9bf04ca9ef66ca2696851 Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Fri, 16 Aug 2024 15:26:34 -0400 Subject: [PATCH 08/11] Changed author by user following feedback --- gateway/api/serializers.py | 20 ++++++++++---------- gateway/api/views.py | 12 ++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/gateway/api/serializers.py b/gateway/api/serializers.py index 9581da84e..0d11edeb5 100644 --- a/gateway/api/serializers.py +++ b/gateway/api/serializers.py @@ -274,9 +274,9 @@ def get_available(self, obj): If the user has RUN PERMISSION in any of its groups available field will be True. If not, will be False. """ - author = self.context.get("author", None) + user = self.context.get("user", None) - if author is None: + if user is None: logger.debug( "User not authenticated in ListCatalogSerializer return available to False" ) @@ -286,14 +286,14 @@ def get_available(self, obj): # pylint: disable=duplicate-code run_program_permission = Permission.objects.get(codename=RUN_PROGRAM_PERMISSION) - user_criteria = Q(user=author) + user_criteria = Q(user=user) run_permission_criteria = Q(permissions=run_program_permission) - author_groups_with_run_permissions = Group.objects.filter( + user_groups_with_run_permissions = Group.objects.filter( user_criteria & run_permission_criteria ) return obj.instances.filter( - id__in=[group.id for group in author_groups_with_run_permissions] + id__in=[group.id for group in user_groups_with_run_permissions] ).exists() @@ -327,9 +327,9 @@ def get_available(self, obj): If the user has RUN PERMISSION in any of its groups available field will be True. If not, will be False. """ - author = self.context.get("author", None) + user = self.context.get("user", None) - if author is None: + if user is None: logger.debug( "User not authenticated in ListCatalogSerializer return available to False" ) @@ -339,12 +339,12 @@ def get_available(self, obj): # pylint: disable=duplicate-code run_program_permission = Permission.objects.get(codename=RUN_PROGRAM_PERMISSION) - user_criteria = Q(user=author) + user_criteria = Q(user=user) run_permission_criteria = Q(permissions=run_program_permission) - author_groups_with_run_permissions = Group.objects.filter( + user_groups_with_run_permissions = Group.objects.filter( user_criteria & run_permission_criteria ) return obj.instances.filter( - id__in=[group.id for group in author_groups_with_run_permissions] + id__in=[group.id for group in user_groups_with_run_permissions] ).exists() diff --git a/gateway/api/views.py b/gateway/api/views.py index 932b65e0c..dfd1be42c 100644 --- a/gateway/api/views.py +++ b/gateway/api/views.py @@ -803,11 +803,11 @@ def list(self, request): tracer = trace.get_tracer("gateway.tracer") ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) with tracer.start_as_current_span("gateway.catalog.list", context=ctx): - author = None + user = None if request.user.is_authenticated: - author = request.user + user = request.user serializer = self.get_serializer( - self.get_queryset(), context={"author": author}, many=True + self.get_queryset(), context={"user": user}, many=True ) return Response(serializer.data) @@ -823,10 +823,10 @@ def retrieve(self, request, pk=None): status=status.HTTP_404_NOT_FOUND, ) - author = None + user = None if request.user.is_authenticated: - author = request.user + user = request.user serializer = self.get_serializer_retrieve_catalog( - instance, context={"author": author} + instance, context={"user": user} ) return Response(serializer.data) From 847915951d2157ed238980e2753d6e4422c95614 Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:06:21 -0400 Subject: [PATCH 09/11] Fix None error reference --- gateway/api/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/api/views.py b/gateway/api/views.py index dfd1be42c..39140dcad 100644 --- a/gateway/api/views.py +++ b/gateway/api/views.py @@ -804,7 +804,7 @@ def list(self, request): ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) with tracer.start_as_current_span("gateway.catalog.list", context=ctx): user = None - if request.user.is_authenticated: + if request.user and request.user.is_authenticated: user = request.user serializer = self.get_serializer( self.get_queryset(), context={"user": user}, many=True @@ -824,7 +824,7 @@ def retrieve(self, request, pk=None): ) user = None - if request.user.is_authenticated: + if request.user and request.user.is_authenticated: user = request.user serializer = self.get_serializer_retrieve_catalog( instance, context={"user": user} From 615142c189ec9898a79117898da82e744dc54bbc Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Mon, 19 Aug 2024 16:20:59 -0400 Subject: [PATCH 10/11] Added comments for reverse --- gateway/tests/api/test_v1_catalog.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gateway/tests/api/test_v1_catalog.py b/gateway/tests/api/test_v1_catalog.py index 22f682043..89d92080f 100644 --- a/gateway/tests/api/test_v1_catalog.py +++ b/gateway/tests/api/test_v1_catalog.py @@ -60,6 +60,7 @@ def test_catalog_list_with_auth_user_with_run_permission(self): def test_catalog_retrieve_non_auth_user(self): """Tests catalog retrieve non-authenticated.""" + # Reverse: "v1:catalog-detail" makes reference to retrieve view method url = reverse( "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec82"] ) @@ -75,6 +76,7 @@ def test_catalog_retrieve_non_auth_user(self): def test_catalog_404_retrieve_non_auth_user(self): """Tests catalog retrieve a non-existent function as non-authenticated.""" + # Reverse: "v1:catalog-detail" makes reference to retrieve view method url = reverse( "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec83"] ) @@ -87,6 +89,7 @@ def test_catalog_404_retrieve_auth_user(self): user = models.User.objects.get(username="test_user") self.client.force_authenticate(user=user) + # Reverse: "v1:catalog-detail" makes reference to retrieve view method url = reverse( "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec83"] ) @@ -99,6 +102,7 @@ def test_catalog_retrieve_with_auth_user_without_run_permission(self): user = models.User.objects.get(username="test_user") self.client.force_authenticate(user=user) + # Reverse: "v1:catalog-detail" makes reference to retrieve view method url = reverse( "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec82"] ) @@ -117,6 +121,7 @@ def test_catalog_retrieve_with_auth_user_with_run_permission(self): user = models.User.objects.get(username="test_user_2") self.client.force_authenticate(user=user) + # Reverse: "v1:catalog-detail" makes reference to retrieve view method url = reverse( "v1:catalog-detail", args=["1a7947f9-6ae8-4e3d-ac1e-e7d608deec82"] ) From b3d91e60c908a26e45d8cfcdf2df1fbb0c02b6e9 Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:44:16 -0400 Subject: [PATCH 11/11] Added url to the provider --- ...ional_info_program_documentation_url_and_more.py | 7 ++++++- gateway/api/models.py | 1 + gateway/api/serializers.py | 1 - gateway/api/v1/serializers.py | 13 +++++++++++++ gateway/tests/fixtures/catalog_fixtures.json | 1 + 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/gateway/api/migrations/0029_program_additional_info_program_documentation_url_and_more.py b/gateway/api/migrations/0029_program_additional_info_program_documentation_url_and_more.py index afd2ef813..2ca4e3127 100644 --- a/gateway/api/migrations/0029_program_additional_info_program_documentation_url_and_more.py +++ b/gateway/api/migrations/0029_program_additional_info_program_documentation_url_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1 on 2024-08-15 16:19 +# Generated by Django 5.1 on 2024-08-20 18:36 from django.db import migrations, models @@ -38,4 +38,9 @@ class Migration(migrations.Migration): name="icon_url", field=models.TextField(blank=True, default=None, null=True), ), + migrations.AddField( + model_name="provider", + name="url", + field=models.TextField(blank=True, default=None, null=True), + ), ] diff --git a/gateway/api/models.py b/gateway/api/models.py index 3ff6b4a81..8110d7a91 100644 --- a/gateway/api/models.py +++ b/gateway/api/models.py @@ -51,6 +51,7 @@ class Provider(models.Model): updated = models.DateTimeField(auto_now=True, null=True) name = models.CharField(max_length=255, db_index=True, unique=True) + url = models.TextField(null=True, blank=True, default=None) icon_url = models.TextField(null=True, blank=True, default=None) registry = models.CharField(max_length=255, null=True, blank=True, default=None) admin_groups = models.ManyToManyField(Group) diff --git a/gateway/api/serializers.py b/gateway/api/serializers.py index 0d11edeb5..c8b93aac3 100644 --- a/gateway/api/serializers.py +++ b/gateway/api/serializers.py @@ -254,7 +254,6 @@ class CatalogProviderSerializer(serializers.ModelSerializer): class Meta: model = Provider - fields = ["name", "icon_url"] class ListCatalogSerializer(serializers.ModelSerializer): diff --git a/gateway/api/v1/serializers.py b/gateway/api/v1/serializers.py index 841a5d7cd..c738134b7 100644 --- a/gateway/api/v1/serializers.py +++ b/gateway/api/v1/serializers.py @@ -175,11 +175,22 @@ class Meta(serializers.RuntimeJobSerializer.Meta): fields = ["job", "runtime_job"] +class CatalogProviderSerializer(serializers.CatalogProviderSerializer): + """ + Serializer for the Provider model in the Catalog View. + """ + + class Meta(serializers.CatalogProviderSerializer.Meta): + fields = ["name", "url", "icon_url"] + + class ListCatalogSerializer(serializers.ListCatalogSerializer): """ List Serializer for the Catalog View. """ + provider = CatalogProviderSerializer() + class Meta(serializers.ListCatalogSerializer.Meta): fields = ["id", "title", "type", "description", "provider", "available"] @@ -189,6 +200,8 @@ class RetrieveCatalogSerializer(serializers.RetrieveCatalogSerializer): Retrieve Serializer for the Catalog View. """ + provider = CatalogProviderSerializer() + class Meta(serializers.RetrieveCatalogSerializer.Meta): fields = [ "id", diff --git a/gateway/tests/fixtures/catalog_fixtures.json b/gateway/tests/fixtures/catalog_fixtures.json index 9424f0242..20a27aeed 100644 --- a/gateway/tests/fixtures/catalog_fixtures.json +++ b/gateway/tests/fixtures/catalog_fixtures.json @@ -77,6 +77,7 @@ "pk": "bfe8aa6a-2127-4123-bf57-5b547293cbea", "fields": { "name": "default", + "url": "https://example", "icon_url": "https://example", "created": "2023-02-01T15:30:43.281796Z", "admin_groups": [105],