Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Catalog end-points #1460

Merged
merged 11 commits into from
Aug 21, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions charts/qiskit-serverless/charts/gateway/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 5.1 on 2024-08-15 16:19

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),
),
]
19 changes: 18 additions & 1 deletion gateway/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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)

Expand All @@ -61,11 +62,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,
Expand All @@ -74,7 +92,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="[]")

Expand Down
106 changes: 106 additions & 0 deletions gateway/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +22,7 @@
JobConfig,
RuntimeJob,
DEFAULT_PROGRAM_ENTRYPOINT,
RUN_PROGRAM_PERMISSION,
)

logger = logging.getLogger("gateway.serializers")
Expand Down Expand Up @@ -242,3 +245,106 @@ 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", "icon_url"]


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()
28 changes: 28 additions & 0 deletions gateway/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class Meta(serializers.ProgramSerializer.Meta):
"dependencies",
"provider",
"description",
"documentation_url",
]


Expand Down Expand Up @@ -172,3 +173,30 @@ class RuntimeJobSerializer(serializers.RuntimeJobSerializer):

class Meta(serializers.RuntimeJobSerializer.Meta):
fields = ["job", "runtime_job"]


class ListCatalogSerializer(serializers.ListCatalogSerializer):
"""
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",
]
5 changes: 5 additions & 0 deletions gateway/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 30 additions & 0 deletions gateway/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
76 changes: 76 additions & 0 deletions gateway/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
RunJobSerializer,
RunProgramSerializer,
UploadProgramSerializer,
RetrieveCatalogSerializer,
)

logger = logging.getLogger("gateway")
Expand Down Expand Up @@ -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.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.is_authenticated:
user = request.user
serializer = self.get_serializer_retrieve_catalog(
instance, context={"user": user}
)
return Response(serializer.data)
Loading