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

Course templates api #36021

Closed
wants to merge 4 commits into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@
VideoUploadSerializer,
VideoUsageSerializer,
)
from .course_templates import CourseSerializer, CourseMetadataSerializer
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from rest_framework import serializers

class CourseMetadataSerializer(serializers.Serializer):
course_id = serializers.CharField()
title = serializers.CharField()
description = serializers.CharField()
thumbnail = serializers.URLField()
active = serializers.BooleanField()

class CourseSerializer(serializers.Serializer):
courses_name = serializers.CharField()
zip_url = serializers.URLField()
metadata = CourseMetadataSerializer()
9 changes: 4 additions & 5 deletions cms/djangoapps/contentstore/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
CourseDetailsView,
CourseTeamView,
CourseTextbooksView,
CourseTemplatesListView,
CourseIndexView,
CourseGradingView,
CourseGroupConfigurationsView,
CourseRerunView,
CourseSettingsView,
CourseVideosView,
CourseWaffleFlagsView,
HomePageView,
HomePageCoursesView,
HomePageLibrariesView,
Expand Down Expand Up @@ -133,11 +133,10 @@
name="container_vertical"
),
re_path(
fr'^course_waffle_flags(?:/{COURSE_ID_PATTERN})?$',
CourseWaffleFlagsView.as_view(),
name="course_waffle_flags"
fr'^course_templates/{settings.COURSE_ID_PATTERN}$',
CourseTemplatesListView.as_view(),
name="course_templates_api"
),

# Authoring API
# Do not use under v1 yet (Nov. 23). The Authoring API is still experimental and the v0 versions should be used
]
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/rest_api/v1/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from .course_details import CourseDetailsView
from .course_index import CourseIndexView
from .course_rerun import CourseRerunView
from .course_waffle_flags import CourseWaffleFlagsView
from .course_team import CourseTeamView
from .course_templates import CourseTemplatesListView
from .grading import CourseGradingView
from .group_configurations import CourseGroupConfigurationsView
from .help_urls import HelpUrlsView
Expand Down
102 changes: 102 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/views/course_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.exceptions import NotFound
from opaque_keys.edx.keys import CourseKey # Import CourseKey if using edX
from django.conf import settings
import requests
from openedx.core.lib.api.view_utils import (
DeveloperErrorViewMixin,
view_auth_classes,
)
from rest_framework.request import Request
from ..serializers import CourseSerializer, CourseMetadataSerializer


# @view_auth_classes(is_authenticated=True)
class CourseTemplatesListView(DeveloperErrorViewMixin, APIView):
"""
API endpoint to fetch and return course data from a GitHub repository.

This view dynamically fetches course data from a specified GitHub repository
and returns it in a structured JSON format. It processes directories and files
in the repository to extract course names, ZIP URLs, and metadata files.

Example URL:
/api/courses/<course_key_string>/

Query Parameters:
course_key_string (str): The course key in the format `org+course+run`.

Example Response:
[
{
"courses_name": "AI Courses",
"zip_url": "https://raw.githubusercontent.com/awais786/courses/main/edly/AI%20Courses/course._Rnm_t%20(1).tar.gz",
"metadata": {
"course_id": "course-v1:edX+DemoX+T2024",
"title": "Introduction to Open edX",
"description": "Learn the fundamentals of the Open edX platform, including how to create and manage courses.",
"thumbnail": "https://discover.ilmx.org/wp-content/uploads/2024/01/Course-image-2.webp",
"active": true
}
}
]

Raises:
NotFound: If there is an error fetching data from the repository.

"""
def get(self, request: Request, course_id: str):
"""
Handle GET requests to fetch course data.

Args:
request: The HTTP request object.
course_id (str): The course id.

Returns:
Response: A structured JSON response containing course data.

"""
try:
# Extract organization from course key
course_key = CourseKey.from_string(course_id)
organization = course_key.org

# GitHub repository details. It should come from settings.
templates_repo_url = f"https://api.github.com/repos/awais786/courses/contents/{organization}"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

it will come from settings


# Fetch data from GitHub
data = fetch_contents(templates_repo_url)
courses = []
for directory in data:
course_data = {'courses_name': directory["name"]}
contents = fetch_contents(directory["url"]) # Assume directory contains URL to course contents

for item in contents:
if item['name'].endswith('.tar.gz'): # Check if file is a ZIP file
course_data['zip_url'] = item['download_url']
elif item['name'].endswith('.json'): # Check if file is a JSON metadata file
course_data['metadata'] = fetch_contents(item['download_url'])

courses.append(course_data)

# Serialize and return the data
serializer = CourseSerializer(courses, many=True)
return Response(serializer.data)

except Exception as err:
raise NotFound(f"Error fetching course data: {str(err)}")


def fetch_contents(url):
headers = {
"Authorization": f"token {settings.GITHUB_TOKEN_COURSE_TEMPLATES}",
"Accept": "application/vnd.github.v3+json",
}
try:
response = requests.get(url, headers=headers)
response.raise_for_status() # Raise error for 4xx/5xx responses
return response.json()
except Exception as err:
return JsonResponseBadRequest({"error": err.message})
22 changes: 22 additions & 0 deletions cms/djangoapps/contentstore/views/import_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
import os
import re
import requests
import shutil
from wsgiref.util import FileWrapper

Expand All @@ -24,6 +25,9 @@
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_GET, require_http_methods
from django.shortcuts import render
from django.urls import reverse

from edx_django_utils.monitoring import set_custom_attribute, set_custom_attributes_for_course_key
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryLocator
Expand All @@ -32,6 +36,7 @@
from user_tasks.conf import settings as user_tasks_settings
from user_tasks.models import UserTaskArtifact, UserTaskStatus

from cms.djangoapps.contentstore.utils import reverse_course_url
from common.djangoapps.edxmako.shortcuts import render_to_response
from common.djangoapps.student.auth import has_course_author_access
from common.djangoapps.util.json_request import JsonResponse
Expand Down Expand Up @@ -465,3 +470,20 @@ def _latest_task_status(request, course_key_string, view_func=None):
for status_filter in STATUS_FILTERS:
task_status = status_filter().filter_queryset(request, task_status, view_func)
return task_status.order_by('-created').first()


def course_templates(request, course_key_string):
courselike_key = CourseKey.from_string(course_key_string)
organization = courselike_key.org
successful_url = f"http://localhost:18010/api/contentstore/v1/course_templates/{course_key_string}"

courses = []
resp = requests.get(successful_url)
if resp.status_code == 200:
courses = json.loads(resp._content.decode('utf-8'))

return render(request, 'course_templates.html', {
'upload_zip_endpoint': successful_url,
'courses': courses,
'organization': organization
})
90 changes: 90 additions & 0 deletions cms/templates/course_templates.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ organization }} Templates List</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}

.courses-container {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
}

.course-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
max-width: 300px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
text-align: center;
background-color: #fff;
}

.course-thumbnail img {
max-width: 100%;
height: auto;
border-radius: 8px;
}

.course-info {
margin-top: 12px;
}

.course-name {
font-size: 1.2em;
margin-bottom: 8px;
}

.course-id {
color: #555;
font-size: 0.9em;
margin-bottom: 8px;
}

.course-title {
font-size: 1.1em;
font-weight: bold;
margin-bottom: 8px;
}

.course-description {
font-size: 0.95em;
color: #666;
}

.page-header {
text-align: center; /* Center the text horizontally */
margin: 20px 0; /* Add some spacing above and below the header */
}
</style>
</head>
<body>

<div class="page-header">
<h2>{{ organization }} Templates List</h2>
</div>

<div class="courses-container">
{% for course in courses %}
<div class="course-card">
<div class="course-thumbnail">
<img src="{{ course.metadata.thumbnail }}" alt="{{ course.metadata.title }}" width="354" height="218" />
</div>
<div class="course-info">
<h3 class="course-name">{{ course.courses_name }}</h3>
<p class="course-id">Course ID: {{ course.metadata.course_id }}</p>
<h4 class="course-title">{{ course.metadata.title }}</h4>
<p class="course-description">{{ course.metadata.description }}</p>
</div>
</div>
{% endfor %}
</div>
</body>
</html>
3 changes: 3 additions & 0 deletions cms/templates/widgets/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ <h3 class="title"><span class="label">${_("Tools")}</span> <span class="icon fa
<a href="${reverse('export_git', kwargs=dict(course_key_string=str(course_key)))}">${_("Export to Git")}</a>
</li>
% endif
<li class="nav-item nav-course-tools-export-git">
<a href="${reverse('course_templates', args=[str(course_key)])}">${_("Course Templates")}</a>
</li>
<li class="nav-item nav-course-tools-checklists">
<a href="${checklists_url}">${_("Checklists")}</a>
</li>
Expand Down
3 changes: 3 additions & 0 deletions cms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance
from openedx.core.djangoapps.password_policy.forms import PasswordPolicyAwareAdminAuthForm
from openedx.core import toggles as core_toggles
from cms.djangoapps.contentstore.views.import_export import course_templates


django_autodiscover()
Expand Down Expand Up @@ -359,4 +360,6 @@
urlpatterns += [
re_path('^authoring-api/ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
re_path('^authoring-api/schema/', SpectacularAPIView.as_view(), name='schema'),
re_path(fr'^course_templates/{settings.COURSE_KEY_PATTERN}?$', course_templates, name='course_templates'),

]
Loading