Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions enterprise_catalog/apps/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
from django.urls import include, path

from enterprise_catalog.apps.api.v1 import urls as v1_urls
from enterprise_catalog.apps.api.v2 import urls as v2_urls


app_name = 'api'
urlpatterns = [
path('v1/', include(v1_urls)),
path('v2/', include(v2_urls)),
]
Empty file.
37 changes: 37 additions & 0 deletions enterprise_catalog/apps/api/v2/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
URL definitions for enterprise catalog API version 2.
"""
from django.urls import path, re_path
from rest_framework.routers import DefaultRouter

from enterprise_catalog.apps.api.v2.views.enterprise_catalog_contains_content_items import (
EnterpriseCatalogContainsContentItemsV2,
)
from enterprise_catalog.apps.api.v2.views.enterprise_catalog_get_content_metadata import (
EnterpriseCatalogGetContentMetadataV2,
)
from enterprise_catalog.apps.api.v2.views.enterprise_customer import (
EnterpriseCustomerViewSetV2,
)


app_name = 'v2'

router = DefaultRouter()
router.register(r'enterprise-catalogs', EnterpriseCatalogContainsContentItemsV2, basename='enterprise-catalog-content-v2')
router.register(r'enterprise-customer', EnterpriseCustomerViewSetV2, basename='enterprise-customer-v2')

urlpatterns = [
re_path(
r'^enterprise-catalogs/(?P<uuid>[\S]+)/get_content_metadata',
EnterpriseCatalogGetContentMetadataV2.as_view({'get': 'get'}),
name='get-content-metadata-v2'
),
path(
'enterprise-customer/<enterprise_uuid>/content-metadata/<content_identifier>/',
EnterpriseCustomerViewSetV2.as_view({'get': 'content_metadata'}),
name='customer-content-metadata-retrieve-v2'
),
]

urlpatterns += router.urls
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""
"""
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework.decorators import action
from rest_framework.response import Response

from enterprise_catalog.apps.api.constants import (
CONTAINS_CONTENT_ITEMS_VIEW_CACHE_TIMEOUT_SECONDS,
)
from enterprise_catalog.apps.api.v1.decorators import (
require_at_least_one_query_parameter,
)
from enterprise_catalog.apps.api.v1.utils import unquote_course_keys
from enterprise_catalog.apps.api.v1.views.enterprise_catalog_contains_content_items import (
EnterpriseCatalogContainsContentItems,
)


class EnterpriseCatalogContainsContentItemsV2(EnterpriseCatalogContainsContentItems):
"""
View to determine if an enterprise catalog contains certain content
"""
@method_decorator(cache_page(CONTAINS_CONTENT_ITEMS_VIEW_CACHE_TIMEOUT_SECONDS))
@method_decorator(require_at_least_one_query_parameter('course_run_ids', 'program_uuids'))
@action(detail=True)
def contains_content_items(self, request, uuid, course_run_ids, program_uuids, **kwargs): # pylint: disable=unused-argument
"""
Returns whether or not the EnterpriseCatalog contains the specified content.

Multiple course_run_ids and/or program_uuids query parameters can be sent to this view to check for their
existence in the specified enterprise catalog.
"""
course_run_ids = unquote_course_keys(course_run_ids)

enterprise_catalog = self.get_object()
contains_content_items = enterprise_catalog.contains_content_keys(
course_run_ids + program_uuids,
include_restricted=True,
)
return Response({'contains_content_items': contains_content_items})
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from enterprise_catalog.apps.api.v1.views.enterprise_catalog_get_content_metadata import EnterpriseCatalogGetContentMetadata


class EnterpriseCatalogGetContentMetadataV2(EnterpriseCatalogGetContentMetadata):
"""
View for retrieving all the content metadata associated with a catalog.
"""
def get_queryset(self, **kwargs):
"""
Returns all of the json of content metadata associated with the catalog.
"""
# Avoids ordering the content metadata by any field on that model to avoid using a temporary table / filesort
queryset = self.enterprise_catalog.content_metadata_with_restricted
content_filter = kwargs.get('content_keys_filter')
if content_filter:
queryset = self.enterprise_catalog.get_matching_content(content_keys=content_filter, include_restricted=True)

return queryset.order_by('catalog_queries')
124 changes: 124 additions & 0 deletions enterprise_catalog/apps/api/v2/views/enterprise_customer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import logging
import uuid

from rest_framework.exceptions import NotFound

from enterprise_catalog.apps.api.v1.serializers import ContentMetadataSerializer
from enterprise_catalog.apps.api.v1.views.enterprise_customer import EnterpriseCustomerViewSet
from enterprise_catalog.apps.catalog.models import EnterpriseCatalog

from django.utils.decorators import method_decorator
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST

from enterprise_catalog.apps.api.v1.decorators import (
require_at_least_one_query_parameter,
)
from enterprise_catalog.apps.api.v1.utils import unquote_course_keys


logger = logging.getLogger(__name__)


class EnterpriseCustomerViewSetV2(EnterpriseCustomerViewSet):
"""
Viewset for operations on enterprise customers.

Although we don't have a specific EnterpriseCustomer model, this viewset handles operations that use an enterprise
identifier to perform operations on their associated catalogs, etc.
"""
@method_decorator(require_at_least_one_query_parameter('course_run_ids', 'program_uuids'))
@action(detail=True)
def contains_content_items(self, request, enterprise_uuid, course_run_ids, program_uuids, **kwargs):
"""
Returns whether or not the specified content is available for the given enterprise.
---
parameters:
- name: course_run_ids
description: Ids of the course runs to check availability of
paramType: query
- name: program_uuids
description: Uuids of the programs to check availability of
paramType: query
- name: get_catalog_list
description: [Old parameter] Return a list of catalogs in which the course / program is present
paramType: query
- name: get_catalogs_containing_specified_content_ids
description: Return a list of catalogs in which the course / program is present
paramType: query
"""
get_catalogs_containing_specified_content_ids = request.GET.get(
'get_catalogs_containing_specified_content_ids', False
)
get_catalog_list = request.GET.get('get_catalog_list', False)
requested_course_or_run_keys = unquote_course_keys(course_run_ids)

try:
uuid.UUID(enterprise_uuid)
except ValueError as exc:
logger.warning(
f"Could not parse catalogs from provided enterprise uuid: {enterprise_uuid}. "
f"Query failed with exception: {exc}"
)
return Response(
f'Error: invalid enterprice customer uuid: "{enterprise_uuid}" provided.',
status=HTTP_400_BAD_REQUEST
)
customer_catalogs = EnterpriseCatalog.objects.filter(enterprise_uuid=enterprise_uuid)

any_catalog_contains_content_items = False
catalogs_that_contain_course = []
for catalog in customer_catalogs:
contains_content_items = catalog.contains_content_keys(requested_course_or_run_keys + program_uuids, include_restricted=True)
if contains_content_items:
any_catalog_contains_content_items = True
if not (get_catalogs_containing_specified_content_ids or get_catalog_list):
# Break as soon as we find a catalog that contains the specified content
break
catalogs_that_contain_course.append(catalog.uuid)

response_data = {
'contains_content_items': any_catalog_contains_content_items,
}
if (get_catalogs_containing_specified_content_ids or get_catalog_list):
response_data['catalog_list'] = catalogs_that_contain_course

return Response(response_data)

def get_metadata_item_serializer(self):
"""
Gets the first matching serialized ContentMetadata for a requested ``content_identifier``
associated with any of a requested ``customer_uuid``'s catalogs.
"""
enterprise_catalogs = list(EnterpriseCatalog.objects.filter(
enterprise_uuid=self.kwargs.get('enterprise_uuid')
))
content_identifier = self.kwargs.get('content_identifier')
serializer_context = {
'skip_customer_fetch': bool(self.request.query_params.get('skip_customer_fetch', '').lower()),
}

try:
# Search for matching metadata if the value of the requested
# identifier is a valid UUID.
content_uuid = uuid.UUID(content_identifier)
for catalog in enterprise_catalogs:
content_with_uuid = catalog.content_metadata_with_restricted.filter(content_uuid=content_uuid)
if content_with_uuid:
return ContentMetadataSerializer(
content_with_uuid.first(),
context={'enterprise_catalog': catalog, **serializer_context},
)
except ValueError:
# Otherwise, search for matching metadata as a content key
for catalog in enterprise_catalogs:
content_with_key = catalog.get_matching_content(content_keys=[content_identifier], include_restricted=True)
if content_with_key:
return ContentMetadataSerializer(
content_with_key.first(),
context={'enterprise_catalog': catalog, **serializer_context},
)
# If we've made it here without finding a matching ContentMetadata record,
# assume no matching record exists and raise a 404.
raise NotFound(detail='No matching content in any catalog for this customer')
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Generated by Django 4.2.16 on 2024-10-01 19:40

import collections
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import jsonfield.encoder
import jsonfield.fields
import model_utils.fields


class Migration(migrations.Migration):

dependencies = [
('catalog', '0039_alter_catalogquery_unique_together_and_more'),
]

operations = [
migrations.CreateModel(
name='RestrictedCourseMetadata',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('content_uuid', models.UUIDField(blank=True, help_text='The UUID that represents a piece of content. This value is usually a secondary identifier to content_key in the enterprise environment.', null=True, verbose_name='Content UUID')),
('content_type', models.CharField(choices=[('course', 'Course'), ('courserun', 'Course Run'), ('program', 'Program'), ('learnerpathway', 'Learner Pathway')], max_length=255)),
('parent_content_key', models.CharField(blank=True, db_index=True, help_text="The key represents this content's parent. For example for course_runs content their parent course key.", max_length=255, null=True)),
('_json_metadata', jsonfield.fields.JSONField(blank=True, default={}, dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'indent': 4, 'separators': (',', ':')}, help_text="The metadata about a particular piece content as retrieved from the discovery service's search/all endpoint results, specified as a JSON object.", load_kwargs={'object_pairs_hook': collections.OrderedDict}, null=True)),
('content_key', models.CharField(help_text='The key that represents a piece of content, such as a course, course run, or program.', max_length=255)),
],
options={
'verbose_name': 'Restricted Content Metadata',
'verbose_name_plural': 'Restricted Content Metadata',
},
),
migrations.RenameField(
model_name='contentmetadata',
old_name='json_metadata',
new_name='_json_metadata',
),
Comment on lines +36 to +40

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what makes it so that the name of the field in the database doesn't actually change, but the reference to it in django is renamed.

migrations.AlterField(
model_name='catalogquery',
name='content_filter',
field=jsonfield.fields.JSONField(default=dict, dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'ensure_ascii': False, 'indent': 4, 'separators': (',', ':')}, help_text="Query parameters which will be used to filter the discovery service's search/all endpoint results, specified as a JSON object.", load_kwargs={'object_pairs_hook': collections.OrderedDict}),
),
migrations.DeleteModel(
name='HistoricalContentMetadata',
),
migrations.AddField(
model_name='restrictedcoursemetadata',
name='catalog_query',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='restricted_content_metadata', to='catalog.catalogquery'),
),
migrations.AlterUniqueTogether(
name='restrictedcoursemetadata',
unique_together={('content_key', 'catalog_query')},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2024-10-01 20:01

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('catalog', '0040_restrictedcoursemetadata_and_more'),
]

operations = [
migrations.AddField(
model_name='contentmetadata',
name='is_restricted_run',
field=models.BooleanField(default=False, help_text='If true, cause this run to be included in various v2 endpoints.'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 4.2.16 on 2024-10-01 20:34

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('catalog', '0041_contentmetadata_is_restricted_run'),
]

operations = [
migrations.AddField(
model_name='restrictedcoursemetadata',
name='unrestricted_parent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='restricted_courses', to='catalog.contentmetadata'),
),
]
Loading