From 4b088ae9300cea773180c4c4db3634cc96111faa Mon Sep 17 00:00:00 2001 From: David <9059044+Tansito@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:11:01 -0400 Subject: [PATCH] Catalog end-points (#1460) * List end-point for catalog * Added retrieve end-point * Improved error handling in the querysets * Public group to settings * Set public group name in the helm * Create swagger documentation for new end-points * Fix linter * Changed author by user following feedback * Fix None error reference * Added comments for reverse * Added url to the provider --- .../charts/gateway/templates/deployment.yaml | 2 + .../charts/gateway/values.yaml | 1 + ...info_program_documentation_url_and_more.py | 46 ++++++ gateway/api/models.py | 20 ++- gateway/api/serializers.py | 105 ++++++++++++++ gateway/api/v1/serializers.py | 41 ++++++ gateway/api/v1/urls.py | 5 + gateway/api/v1/views.py | 30 ++++ gateway/api/views.py | 76 ++++++++++ gateway/main/settings.py | 3 + gateway/tests/api/test_v1_catalog.py | 136 ++++++++++++++++++ gateway/tests/api/test_v1_serializers.py | 9 -- gateway/tests/fixtures/catalog_fixtures.json | 87 +++++++++++ 13 files changed, 551 insertions(+), 10 deletions(-) create mode 100644 gateway/api/migrations/0029_program_additional_info_program_documentation_url_and_more.py create mode 100644 gateway/tests/api/test_v1_catalog.py create mode 100644 gateway/tests/fixtures/catalog_fixtures.json 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 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 new file mode 100644 index 000000000..2ca4e3127 --- /dev/null +++ b/gateway/api/migrations/0029_program_additional_info_program_documentation_url_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1 on 2024-08-20 18:36 + +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="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", + field=models.CharField( + choices=[ + ("GENERIC", "Generic"), + ("APPLICATION", "Application"), + ("CIRCUIT", "Circuit"), + ], + default="GENERIC", + max_length=20, + ), + ), + migrations.AddField( + model_name="provider", + 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 3324609e6..8110d7a91 100644 --- a/gateway/api/models.py +++ b/gateway/api/models.py @@ -51,6 +51,8 @@ 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) @@ -61,11 +63,28 @@ 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) + 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( upload_to=get_upload_path, @@ -74,7 +93,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..c8b93aac3 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,105 @@ 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 + + +class ListCatalogSerializer(serializers.ModelSerializer): + """ + List 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. + """ + user = self.context.get("user", None) + + if user 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=user) + run_permission_criteria = Q(permissions=run_program_permission) + user_groups_with_run_permissions = Group.objects.filter( + user_criteria & run_permission_criteria + ) + + return obj.instances.filter( + id__in=[group.id for group in user_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. + """ + user = self.context.get("user", None) + + if user 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=user) + run_permission_criteria = Q(permissions=run_program_permission) + user_groups_with_run_permissions = Group.objects.filter( + user_criteria & run_permission_criteria + ) + + return obj.instances.filter( + id__in=[group.id for group in user_groups_with_run_permissions] + ).exists() diff --git a/gateway/api/v1/serializers.py b/gateway/api/v1/serializers.py index 889b6fa84..c738134b7 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", ] @@ -172,3 +173,43 @@ class RuntimeJobSerializer(serializers.RuntimeJobSerializer): 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"] + + +class RetrieveCatalogSerializer(serializers.RetrieveCatalogSerializer): + """ + Retrieve Serializer for the Catalog View. + """ + + provider = CatalogProviderSerializer() + + class Meta(serializers.RetrieveCatalogSerializer.Meta): + fields = [ + "id", + "title", + "type", + "description", + "documentation_url", + "provider", + "available", + "additional_info", + ] 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..7edd823d0 100644 --- a/gateway/api/v1/views.py +++ b/gateway/api/v1/views.py @@ -107,3 +107,33 @@ class FilesViewSet(views.FilesViewSet): """ permission_classes = [permissions.IsAuthenticated, IsOwner] + + +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] + + @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) diff --git a/gateway/api/views.py b/gateway/api/views.py index cfc92b5f1..39140dcad 100644 --- a/gateway/api/views.py +++ b/gateway/api/views.py @@ -47,6 +47,7 @@ RunJobSerializer, RunProgramSerializer, UploadProgramSerializer, + RetrieveCatalogSerializer, ) logger = logging.getLogger("gateway") @@ -754,3 +755,78 @@ 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 = settings.PUBLIC_GROUP_NAME + + @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): + """ + 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): + """ + 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 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): + user = None + if request.user and request.user.is_authenticated: + user = request.user + serializer = self.get_serializer( + self.get_queryset(), context={"user": user}, many=True + ) + return Response(serializer.data) + + def retrieve(self, request, pk=None): + """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): + instance = self.get_retrieve_queryset(pk) + if instance is None: + return Response( + {"message": "Qiskit Function not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + user = None + if request.user and request.user.is_authenticated: + user = request.user + serializer = self.get_serializer_retrieve_catalog( + instance, context={"user": user} + ) + return Response(serializer.data) diff --git a/gateway/main/settings.py b/gateway/main/settings.py index c2eef6167..36da4ee17 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/api/test_v1_catalog.py b/gateway/tests/api/test_v1_catalog.py new file mode 100644 index 000000000..89d92080f --- /dev/null +++ b/gateway/tests/api/test_v1_catalog.py @@ -0,0 +1,136 @@ +"""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) + + 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"] + ) + 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.""" + # Reverse: "v1:catalog-detail" makes reference to retrieve view method + 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) + + # Reverse: "v1:catalog-detail" makes reference to retrieve view method + 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) + + # Reverse: "v1:catalog-detail" makes reference to retrieve view method + 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) + + # Reverse: "v1:catalog-detail" makes reference to retrieve view method + 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/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..20a27aeed --- /dev/null +++ b/gateway/tests/fixtures/catalog_fixtures.json @@ -0,0 +1,87 @@ +[ + { + "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", + "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 + ] + } + }, + { + "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": "ibm-q/open/main", + "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", + "url": "https://example", + "icon_url": "https://example", + "created": "2023-02-01T15:30:43.281796Z", + "admin_groups": [105], + "registry": "docker.io/awesome" + } + } +] \ No newline at end of file