Commit e0c1f170 by Clinton Blackburn Committed by Clinton Blackburn

Initial course app creation and list view migration

- Created Backbone app/router
- Updated list view to operate via new router

XCOM-309, XCOM-337
parent eec2bfbc
......@@ -23,6 +23,8 @@
"text": "~2.0.14",
"backbone.paginator": "~2.0.2",
"moment": "~2.10.3",
"underscore.string": "~3.1.1"
"underscore.string": "~3.1.1",
"backbone-super": "~1.0.4",
"backbone-route-filter": "~0.1.2"
}
}
......@@ -21,7 +21,7 @@
name: 'js/config'
},
{
name: 'js/pages/course_list_page',
name: 'js/apps/course_admin_app',
exclude: ['js/common']
},
{
......
from django.conf import settings
def core(_request):
return {
'platform_name': settings.PLATFORM_NAME
}
from django.test import TestCase, override_settings, RequestFactory
from ecommerce.core.context_processors import core
PLATFORM_NAME = 'Test Platform'
class CoreContextProcessorTests(TestCase):
@override_settings(PLATFORM_NAME=PLATFORM_NAME)
def test_core(self):
request = RequestFactory().get('/')
self.assertDictEqual(core(request), {'platform_name': PLATFORM_NAME})
......@@ -37,8 +37,8 @@ class CourseMigrationViewTests(UserMixin, TestCase):
self.assertEqual(response.status_code, 200)
class CourseListViewTests(UserMixin, TestCase):
path = reverse('courses:list')
class CourseAppViewTests(UserMixin, TestCase):
path = reverse('courses:app', args=[''])
def test_login_required(self):
""" Users are required to login before accessing the view. """
......
......@@ -3,10 +3,11 @@ from django.conf.urls import patterns, url
from ecommerce.core.constants import COURSE_ID_PATTERN
from ecommerce.courses import views
urlpatterns = patterns(
'',
url(r'^$', views.CourseListView.as_view(), name='list'),
url(r'^migrate/$', views.CourseMigrationView.as_view(), name='migrate'),
url(r'^{}/$'.format(COURSE_ID_PATTERN), views.CourseDetailView.as_view(), name='detail'),
# Declare all paths above this line to avoid dropping into the Course Admin Tool (which does its own routing)
url(r'^(.*)$', views.CourseAppView.as_view(), name='app'),
)
......@@ -6,23 +6,24 @@ from django.contrib.auth.decorators import login_required
from django.core.management import call_command
from django.http import Http404, HttpResponse
from django.utils.decorators import method_decorator
from django.views.generic import View, ListView, TemplateView
from django.views.generic import View, TemplateView
from ecommerce.courses.models import Course
logger = logging.getLogger(__name__)
class CourseListView(ListView):
model = Course
context_object_name = 'courses'
class StaffOnlyMixin(object):
@method_decorator(login_required)
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise Http404
return super(CourseListView, self).dispatch(request, *args, **kwargs)
return super(StaffOnlyMixin, self).dispatch(request, *args, **kwargs)
class CourseAppView(StaffOnlyMixin, TemplateView):
template_name = 'courses/course_app.html'
class CourseDetailView(TemplateView):
......
from rest_framework import pagination
class PageNumberPagination(pagination.PageNumberPagination):
page_size_query_param = 'page_size'
# NOTE (CCB): This is a hack, necessary until the frontend
# can properly follow our paginated lists.
max_page_size = 10000
......@@ -164,6 +164,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
'oscar.apps.checkout.context_processors.checkout',
'oscar.apps.customer.notifications.context_processors.notifications',
'oscar.core.context_processors.metadata',
'ecommerce.core.context_processors.core',
)
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders
......@@ -398,7 +399,7 @@ REST_FRAMEWORK = {
'ecommerce.extensions.api.authentication.BearerAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'DEFAULT_PAGINATION_CLASS': 'ecommerce.extensions.api.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
'DEFAULT_THROTTLE_CLASSES': (
'rest_framework.throttling.UserRateThrottle',
......
require([
'backbone',
'routers/course_router'
],
function (Backbone,
CourseRouter) {
'use strict';
var navigate,
courseApp;
/**
* Navigate to a new page within the app.
*
* Attempts to open the link in a new tab/window behave as the user expects, however the app
* and data will be reloaded in the new tab/window.
*
* @param {Event} event - Event being handled.
* @returns {boolean} - Indicates if event handling succeeded (always true).
*/
navigate = function (event) {
var url = $(this).attr('href').replace(courseApp.root, '');
// Handle the cases where the user wants to open the link in a new tab/window.
if (event.ctrlKey || event.shiftKey || event.metaKey || event.which == 2) {
return true;
}
// We'll take it from here...
event.preventDefault();
// Process the navigation in the app/router.
if (url === Backbone.history.getFragment() && url === '') {
// Note: We must call the index directly since Backbone does not support routing to the same route.
courseApp.index();
} else {
courseApp.navigate(url, {trigger: true});
}
};
$(function () {
var $app = $('#app');
// Let's start the show!
courseApp = new CourseRouter({$el: $app});
courseApp.start();
// Handle navbar clicks.
$('a.navbar-brand').on('click', navigate);
});
}
);
define([
'underscore',
'collections/drf_pageable_collection',
'models/course_model'
], function (_, DrfPageableCollection, CourseModel) {
'underscore',
'collections/drf_pageable_collection',
'models/course_model'
],
function (_,
DrfPageableCollection,
CourseModel) {
'use strict';
return DrfPageableCollection.extend({
model: CourseModel,
url: '/api/v2/courses/',
});
});
return DrfPageableCollection.extend({
model: CourseModel,
url: '/api/v2/courses/'
});
}
);
......@@ -7,8 +7,13 @@ define([
'use strict';
return Backbone.PageableCollection.extend({
queryParams: {
pageSize: 'page_size'
},
state: {
pageSize: 20
// TODO Replace this collection with something that works properly with our API.
pageSize: 10000
},
parseRecords: function (resp, options) {
......
require([
'jquery',
'backbone',
'bootstrap',
'bootstrap_accessibility',
'underscore'
], function () {
});
'jquery',
'backbone',
'bootstrap',
'bootstrap_accessibility',
'underscore'
],
function () {
}
);
......@@ -3,6 +3,8 @@ require.config({
paths: {
'backbone': 'bower_components/backbone/backbone',
'backbone.paginator': 'bower_components/backbone.paginator/lib/backbone.paginator',
'backbone.route-filter': 'bower_components/backbone-route-filter/backbone-route-filter',
'backbone.super': 'bower_components/backbone-super/backbone-super/backbone-super',
'bootstrap': 'bower_components/bootstrap-sass/assets/javascripts/bootstrap',
'bootstrap_accessibility': 'bower_components/bootstrapaccessibilityplugin/plugins/js/bootstrap-accessibility',
'collections': 'js/collections',
......@@ -12,7 +14,9 @@ require.config({
'jquery-cookie': 'bower_components/jquery-cookie/jquery.cookie',
'models': 'js/models',
'moment': 'bower_components/moment/moment',
'pages': 'js/pages',
'requirejs': 'bower_components/requirejs/require',
'routers': 'js/routers',
'templates': 'templates',
'text': 'bower_components/text/text',
'underscore': 'bower_components/underscore/underscore',
......
......@@ -4,7 +4,10 @@ define([
'collections/product_collection',
'models/course_seat_model'
],
function (Backbone, _, ProductCollection, CourseSeatModel) {
function (Backbone,
_,
ProductCollection,
CourseSeatModel) {
'use strict';
return Backbone.Model.extend({
......
require([
define([
'collections/course_collection',
'views/course_list_view'
'views/course_list_view',
'pages/page'
],
function (CourseCollection, CourseListView) {
function (CourseCollection,
CourseListView,
Page) {
'use strict';
return new CourseListView({
collection: new CourseCollection()
});
return Page.extend({
title: 'Courses',
initialize: function () {
this.collection = new CourseCollection();
this.view = new CourseListView({collection: this.collection});
this.listenTo(this.collection, 'reset', this.render);
this.collection.fetch({reset: true});
}
});
}
);
define(['backbone',
'backbone.super'],
function (Backbone,
BackboneSuper) {
'use strict';
/***
* Base Page class.
*/
var Page = Backbone.View.extend({
/**
* Document title set during rendering.
*
* This can either be a string or a function that accepts this
* instance and returns a string.
*/
title: null,
/**
* Initializes this view and any models, collections, and/or nested views.
*
* Inheriting classes MUST override this method.
*/
initialize: function () {
},
/**
* Removes the nested view before removing this view.
*/
remove: function () {
if (this.view) {
this.view.remove();
this.view = null;
}
return this._super();
},
/**
* Updates the browser window's title.
*/
renderTitle: function () {
var title = _.result(this, 'title');
if (title) {
document.title = title;
}
},
/**
* Renders the nested view.
*/
renderNestedView: function () {
this.view.render();
this.$el.html(this.view.el);
},
/**
* Renders this Page, specifically the title and nested view.
* @returns {Page} current instance
*/
render: function () {
this.renderTitle();
this.renderNestedView();
return this;
}
});
return Page;
}
);
define([
'backbone',
'backbone.super',
'pages/course_list_page'
],
function (Backbone,
BackboneSuper,
CourseListPage) {
'use strict';
return Backbone.Router.extend({
// Keeps track of the page/view currently on display
currentView: null,
// Base/root path of the app
root: '/courses/',
routes: {
'(/)': 'index',
'*path': 'notFound'
},
// Filter(s) called before routes are executed. If the filters return a truthy value
// the route will be executed; otherwise, the route will not be executed.
before: {
'*any': 'clearView'
},
/**
* Setup special routes.
*
* @param {Object} options - Data used to initialize the router. This should include a key, $el, that
* refers to a jQuery Element where the pages will be rendered.
*/
initialize: function (options) {
// This is where views will be rendered
this.$el = options.$el;
},
/**
* Starts the router.
*/
start: function () {
Backbone.history.start({pushState: true, root: this.root});
return this;
},
/**
* Removes the current view.
*/
clearView: function () {
if (this.currentView) {
this.currentView.remove();
this.currentView = null;
}
return this;
},
/**
* 404 page
* @param {String} path - Invalid path.
*/
notFound: function (path) {
// TODO Render something!
alert(path + ' is invalid.');
},
/**
* Display a list of all courses in the system.
*/
index: function () {
var page = new CourseListPage();
this.currentView = page;
this.$el.html(page.el);
}
});
}
);
......@@ -26,7 +26,7 @@ if (isBrowser) {
// you can automatically get the test files using karma's configs
for (var file in window.__karma__.files) {
if (/spec\.js$/.test(file)) {
if (/js\/test\/specs\/.*spec\.js$/.test(file)) {
specs.push(file);
}
}
......
......@@ -2,74 +2,53 @@ define([
'jquery',
'views/course_list_view',
'collections/course_collection'
],
function ($, CourseListView, CourseCollection) {
describe('course list view', function () {
var view,
collection,
defaultCourses,
renderInterval;
beforeEach(function (done) {
defaultCourses = {
"id": "edX/DemoX.1/2014",
"name": "DemoX",
"last_edited": "2015-06-16T19:14:34Z"
],
function ($,
CourseListView,
CourseCollection) {
'use strict';
describe('course list view', function () {
var view,
collection,
courses = [
{
id: 'edX/DemoX.1/2014',
name: 'DemoX',
last_edited: '2015-06-16T19:14:34Z',
type: 'honor'
},
{
"id": "edX/victor101/Victor_s_Test_Course",
"name": "Victor's Test Course",
"last_edited": "2015-06-16T19:42:55Z"
};
collection = new CourseCollection();
spyOn(collection, 'fetch').and.callFake(function () {
collection.set(defaultCourses);
});
// Set up the environment
setFixtures('<div id="course-list-view"></div>');
view = new CourseListView({
collection: collection
});
// Wait till the DOM is rendered before continuing
renderInterval = setInterval(function () {
if (view.$el.html()) {
clearInterval(renderInterval);
done();
}
}, 100);
});
it('should change the default filter placeholder to a custom string', function () {
expect(view.$el.find('#courseTable_filter input').attr('placeholder')).toBe('Filter by org or course ID');
});
it('should adjust the style of the filter textbox', function () {
var $tableInput = view.$el.find('#courseTable_filter input');
expect($tableInput.hasClass('field-input input-text')).toBeTruthy();
expect($tableInput.hasClass('form-control input-sm')).toBeFalsy();
});
it('should populate the table based on the course collection', function () {
id: 'edX/victor101/Victor_s_Test_Course',
name: 'Victor\'s Test Course',
last_edited: '2015-06-16T19:42:55Z',
type: 'professional'
}
];
beforeEach(function () {
collection = new CourseCollection();
collection.set(courses);
view = new CourseListView({collection: collection}).render();
});
var table = $('#courseTable').DataTable();
tableData = table.data();
it('should change the default filter placeholder to a custom string', function () {
expect(view.$el.find('#courseTable_filter input[type=search]').attr('placeholder')).toBe('Search...');
});
expect(tableData.data().length).toBe(collection.length);
it('should adjust the style of the filter textbox', function () {
var $tableInput = view.$el.find('#courseTable_filter input');
});
expect($tableInput.hasClass('field-input input-text')).toBeTruthy();
expect($tableInput.hasClass('form-control input-sm')).toBeFalsy();
});
it('should populate the table based on the course collection', function () {
var tableData = view.$el.find('#courseTable').DataTable().data();
expect(tableData.data().length).toBe(collection.length);
});
});
}
);
define([
'jquery',
'backbone',
'underscore',
'underscore.string',
'backbone',
'moment',
'text!templates/course_list.html',
'dataTablesBootstrap'
],
function ($, _, _s, Backbone, moment, courseListViewTemplate) {
],
function ($,
Backbone,
_,
_s,
moment,
courseListViewTemplate) {
'use strict';
return Backbone.View.extend({
el: '#course-list-view',
className: 'course-list-view',
template: _.template(courseListViewTemplate),
initialize: function (options) {
initialize: function () {
this.listenTo(this.collection, 'add remove change', this.render);
this.collection.fetch();
},
renderCourseTable: function () {
var tableData = [],
filterPlaceholder = gettext('Filter by org or course ID'),
filterPlaceholder = gettext('Search...'),
$emptyLabel = '<label class="sr">' + filterPlaceholder + '</label>';
this.collection.each(function (value) {
tableData.push(
{
id: value.get('id'),
type: value.get('type'),
name: value.get('name'),
last_edited: moment(value.get('last_edited')).format('MMMM DD, YYYY, h:mm A')
}
......@@ -43,26 +47,48 @@ define([
this.$el.find('#courseTable').DataTable({
autoWidth: false,
data: tableData,
info: false,
paging: false,
info: true,
paging: true,
oLanguage: {
oPaginate: {
sNext: gettext('Next'),
sPrevious: gettext('Previous')
},
// Translators: _START_, _END_, and _TOTAL_ are placeholders. Do NOT translate them.
sInfo: gettext("Displaying _START_ to _END_ of _TOTAL_ courses"),
// Translators: _MAX_ is a placeholder. Do NOT translate it.
sInfoFiltered: gettext('(filtered from _MAX_ total courses)'),
// Translators: _MENU_ is a placeholder. Do NOT translate it.
sLengthMenu: gettext('Display _MENU_ courses'),
sSearch: ''
},
order: [[0, 'asc']],
columns: [
{
title: gettext('ID'),
data: 'id',
title: gettext('Course'),
data: 'name',
fnCreatedCell: function (nTd, sData, oData, iRow, iCol) {
$(nTd).html(_s.sprintf('<a href=\'/courses/%s\'>%s</a>', oData.id, oData.id));
$(nTd).html(_s.sprintf('<a href="/courses/%s/" class="course-name">%s</a><div class="course-id">%s</div>', oData.id, oData.name, oData.id));
}
},
{
title: gettext('Name'),
data: 'name'
title: gettext('Course Type'),
data: 'type',
fnCreatedCell: function (nTd, sData, oData, iRow, iCol) {
$(nTd).html(_s.capitalize(oData.type));
}
},
{
title: gettext('Last Edited'),
data: 'last_edited'
},
{
data: 'id',
visible: false,
searchable: true
}
]
});
......
......@@ -2,20 +2,34 @@
// --------------------
html {
font-size:16px;
font-size: 16px;
}
a {
&:hover,
&:focus {
text-decoration: none;
}
&:hover,
&:focus {
text-decoration: none;
}
}
.container {
background-color: white;
background-color: $container-bg;
}
.sr {
@extend .sr-only;
@extend .sr-only;
}
.page-header {
.hd-1 {
margin: 0;
}
}
.breadcrumb {
> li {
+ li:before {
content: "#{$breadcrumb-separator} ";
}
}
}
......@@ -24,8 +24,10 @@
// --------------------
@import '../components/buttons';
@import '../components/navbar';
@import '../components/footer';
// views
// --------------------
@import '../views/credit';
@import '../views/course_admin';
@import '../views/course_detail';
html,
body {
height: 100%;
/* The html and body elements cannot have any padding or margin. */
}
/* Set the fixed height of the footer here */
footer.footer {
height: $footer-height;
margin-top: $footer-margin;
border-top: $navbar-border-bottom-width solid palette(secondary, base);
padding-top: 8px;
background-color: $footer-bg;
.container{
background-color: transparent;
}
}
......@@ -2,16 +2,18 @@
// --------------------------------------------------
.nav {
.nav-link {
&:hover,
&:focus {
outline:inherit;
border-bottom-color:transparent;
}
.nav-link {
&:hover,
&:focus {
outline: inherit;
border-bottom-color: transparent;
}
}
}
.navbar {
margin-bottom: 0;
// Remove default Bootstrap navbar border styling
border: none;
border-radius: 0;
......@@ -20,8 +22,24 @@
border-bottom: $navbar-border-bottom-width solid palette(primary, accent);
// Vertically center the logo
.navbar-brand .navbar-brand-logo {
@include center-vertically;
.navbar-brand {
&:active,
&:focus,
&:hover {
border: none;
}
.navbar-brand-logo {
@include center-vertically;
}
.navbar-brand-app {
@include center-vertically;
top: 0;
display: inline-block;
color: palette(primary, accent);
font-weight: 600;
}
}
}
......@@ -65,13 +83,13 @@
}
.dropdown-menu {
.nav-link {
// Disables default link behavior of pattern library on menu items
transition:none;
&:hover,
&:focus {
border-bottom-color:transparent;
}
.nav-link {
// Disables default link behavior of pattern library on menu items
transition: none;
&:hover,
&:focus {
border-bottom-color: transparent;
}
}
}
......@@ -21,4 +21,5 @@
-webkit-transform: translateY(-50%);
-ms-transform: translateY(-50%);
transform: translateY(-50%);
vertical-align: middle;
}
......@@ -20,10 +20,18 @@ $border-radius-large: 3px;
$border-radius-small: 3px;
// typography
$font-family-sans-serif: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif;
$font-family-sans-serif: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
$font-family-monospace: 'Bitstream Vera Sans Mono', Consolas, Courier, monospace;
// navbar
$navbar-default-bg: white;
$navbar-height: 74px;
$navbar-border-bottom-width: 4px;
// Footer
$footer-bg: $body-bg;
$footer-height: 37px;
$footer-margin: $footer-height;
// Miscellaneous
$container-bg: white;
#app {
padding-bottom: spacing-vertical(x-small);
.container {
padding-bottom: spacing-vertical(small);
}
#courseTable {
.course-name {
font-weight: bold;
}
}
}
<div class="page-header">
<h1 class="hd-1 emphasized">
<%- gettext('Courses') %>
<button class="btn btn-primary btn-small"><%- gettext('Add New Course') %></button>
<%- gettext('Courses') %>
<div class="pull-right">
<button class="btn btn-primary btn-small"><%- gettext('Add New Course') %></button>
</div>
</h1>
</div>
......
{% extends 'edx/base.html' %}
{% load core_extras %}
{% load i18n %}
{% load staticfiles %}
{% block title %}{% trans "Courses" %}{% endblock %}
{% block navbar %}
<nav class="navbar navbar-default" aria-label="Account">
<div class="container">
<!-- Brand and toggle get grouped for better mobile display -->
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse"
data-target="#main-navbar-collapse" aria-expanded="false">
<span class="sr-only">{% trans "Toggle navigation" %}</span>
<span aria-hidden="true" class="icon-bar"></span>
<span aria-hidden="true" class="icon-bar"></span>
<span aria-hidden="true" class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/courses/">
<div class="navbar-brand-logo" alt="{% settings_value 'PLATFORM_NAME' %}"></div>
<div class="navbar-brand-app">{% trans "Course Administration" %}</div>
</a>
</div>
<div class="collapse navbar-collapse" id="main-navbar-collapse">
<ul class="nav navbar-nav navbar-right">
{% if user.is_authenticated %}
<li class="btn-group user-menu">
<button type="button" class="btn btn-default hidden-xs main-btn nav-button"
onclick="window.open('{% settings_value 'LMS_DASHBOARD_URL' %}');">
<i class="icon fa fa-home" aria-hidden="true"></i>
<span class="sr-only">{% trans "Dashboard for:" %}</span>
{{ user.username }}
</button>
<button type="button" class="btn btn-default dropdown-toggle hidden-xs nav-button"
data-toggle="dropdown"
aria-haspopup="true">
<span class="caret"></span>
<span class="sr-only">{% trans "Toggle Dropdown" %}</span>
</button>
<ul class="dropdown-menu" aria-expanded="false">
{% include "courses/menu_options.html" %}
</ul>
{% include "courses/menu_options.html" with additional_class="visible-xs" %}
</li>
{% else %}
<a class="btn btn-primary navbar-btn hidden-xs" href="{% url 'login' %}">{% trans "Login" %}</a>
<li class="visible-xs"><a class="nav-link" href="{% url 'login' %}">{% trans "Login" %}</a></li>
</a>
{% endif %}
</ul>
</div>
</div>
</nav>
{% endblock navbar %}
{% block content %}
<div id="app" class="container"></div>
{% endblock %}
{% block footer %}
<footer class="footer">
<div class="container">
<div class="row">
<div class="col-xs-12 text-right">
<em>{% blocktrans %}{{ platform_name }} Course Administration Tool{% endblocktrans %}</em>
</div>
</div>
</div>
</footer>
{% endblock footer %}
{% block javascript %}
<script src="{% static 'js/apps/course_admin_app.js' %}"></script>
{% endblock %}
{% extends 'edx/base.html' %}
{% load staticfiles %}
{% load i18n %}
{% block title %}{% trans "Courses" %}{% endblock %}
{% block content %}
<div class="container" id="course-list-view"></div>
{% endblock %}
{% block javascript %}
<script src="{% static 'js/pages/course_list_page.js' %}"></script>
{% endblock %}
{% load core_extras %}
{% load i18n %}
<li class="{{ additional_class }}"><a class="nav-link" href="{% settings_value 'LMS_DASHBOARD_URL' %}">{% trans "Student Dashboard" %}</a></li>
<li class="{{ additional_class }}"><a class="nav-link" href="{% url 'courses:list' %}">{% trans "Course Admin Tool" %}</a></li>
<li class="{{ additional_class }}"><a class="nav-link" href="{% url 'dashboard:index' %}">{% trans "E-Commerce Dashboard" %}</a></li>
<li class="{{ additional_class }}">
<a class="nav-link" href="{% settings_value 'LMS_DASHBOARD_URL' %}">{% trans "Student Dashboard" %}</a>
</li>
<li class="{{ additional_class }}">
<a class="nav-link" href="{% url 'courses:app' '' %}">{% trans "Course Admin Tool" %}</a>
</li>
<li class="{{ additional_class }}"
><a class="nav-link" href="{% url 'dashboard:index' %}">{% trans "E-Commerce Dashboard" %}</a>
</li>
<li class="divider {{ additional_class }}"></li>
<li class="{{ additional_class }}"><a class="nav-link" href="{% url 'logout' %}">{% trans "Sign Out" %}</a></li>
......@@ -79,6 +79,9 @@
{% block content %}
{% endblock content %}
{% block footer %}
{% endblock footer %}
{# Translation support for JavaScript strings. #}
<script type="text/javascript" src="{% url 'django.views.i18n.javascript_catalog' %}"></script>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment