Merge pull request #14452 from edx/ayeshabaig/YONK-513
[YONK-513]: Add feature flag which allows for disabling of account cr…
This commit is contained in:
@@ -14,9 +14,9 @@ from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from contentstore.models import PushNotificationConfig
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from contentstore.tests.utils import parse_json, user, registration, AjaxEnabledTestClient
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
@@ -302,6 +302,34 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
# re-request, and we should get a redirect to login page
|
||||
self.assertRedirects(resp, settings.LOGIN_REDIRECT_URL + '?next=/home/')
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {"ALLOW_PUBLIC_ACCOUNT_CREATION": False})
|
||||
def test_signup_button_index_page(self):
|
||||
"""
|
||||
Navigate to the home page and check the Sign Up button is hidden when ALLOW_PUBLIC_ACCOUNT_CREATION flag
|
||||
is turned off
|
||||
"""
|
||||
response = self.client.get(reverse('homepage'))
|
||||
self.assertNotIn('<a class="action action-signup" href="/signup">Sign Up</a>', response.content)
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {"ALLOW_PUBLIC_ACCOUNT_CREATION": False})
|
||||
def test_signup_button_login_page(self):
|
||||
"""
|
||||
Navigate to the login page and check the Sign Up button is hidden when ALLOW_PUBLIC_ACCOUNT_CREATION flag
|
||||
is turned off
|
||||
"""
|
||||
response = self.client.get(reverse('login'))
|
||||
self.assertNotIn('<a class="action action-signup" href="/signup">Sign Up</a>', response.content)
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {"ALLOW_PUBLIC_ACCOUNT_CREATION": False})
|
||||
def test_signup_link_login_page(self):
|
||||
"""
|
||||
Navigate to the login page and check the Sign Up link is hidden when ALLOW_PUBLIC_ACCOUNT_CREATION flag
|
||||
is turned off
|
||||
"""
|
||||
response = self.client.get(reverse('login'))
|
||||
self.assertNotIn('<a href="/signup" class="action action-signin">Don't have a Studio Account? Sign up!</a>',
|
||||
response.content)
|
||||
|
||||
|
||||
class ForumTestCase(CourseTestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -222,6 +222,9 @@ FEATURES = {
|
||||
|
||||
# Set this to False to facilitate cleaning up invalid xml from your modulestore.
|
||||
'ENABLE_XBLOCK_XML_VALIDATION': True,
|
||||
|
||||
# Allow public account creation
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION': True,
|
||||
}
|
||||
|
||||
ENABLE_JASMINE = False
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="base.html" />
|
||||
<%def name="online_help_token()"><% return "login" %></%def>
|
||||
@@ -15,7 +16,9 @@ from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
<section class="content">
|
||||
<header>
|
||||
<h1 class="title title-1">${_("Sign In to {studio_name}").format(studio_name=settings.STUDIO_NAME)}</h1>
|
||||
<a href="${reverse('signup')}" class="action action-signin">${_("Don't have a {studio_name} Account? Sign up!").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
|
||||
% if static.get_value('ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION')):
|
||||
<a href="${reverse('signup')}" class="action action-signin">${_("Don't have a {studio_name} Account? Sign up!").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
|
||||
% endif
|
||||
</header>
|
||||
|
||||
<article class="content-primary" role="main">
|
||||
|
||||
@@ -230,9 +230,11 @@
|
||||
<li class="nav-item nav-not-signedin-help">
|
||||
<a href="${get_online_help_info(online_help_token)['doc_url']}" title="${_('Contextual Online Help')}" target="_blank">${_("Help")}</a>
|
||||
</li>
|
||||
<li class="nav-item nav-not-signedin-signup">
|
||||
<a class="action action-signup" href="${reverse('signup')}">${_("Sign Up")}</a>
|
||||
</li>
|
||||
% if static.get_value('ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION')):
|
||||
<li class="nav-item nav-not-signedin-signup">
|
||||
<a class="action action-signup" href="${reverse('signup')}">${_("Sign Up")}</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="nav-item nav-not-signedin-signin">
|
||||
<a class="action action-signin" href="${reverse('login')}">${_("Sign In")}</a>
|
||||
</li>
|
||||
|
||||
@@ -9,7 +9,7 @@ from student.models import anonymous_id_for_user, CourseEnrollment, UserProfile
|
||||
from util.testing import UrlResetMixin
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from mock import patch
|
||||
from mock import patch, Mock
|
||||
import ddt
|
||||
import json
|
||||
|
||||
@@ -261,6 +261,14 @@ class AutoAuthEnabledTestCase(AutoAuthTestCase):
|
||||
|
||||
return response
|
||||
|
||||
@patch("openedx.core.djangoapps.site_configuration.helpers.get_value", Mock(return_value=False))
|
||||
def test_create_account_not_allowed(self):
|
||||
"""
|
||||
Test case to check user creation is forbidden when ALLOW_PUBLIC_ACCOUNT_CREATION feature flag is turned off
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
class AutoAuthDisabledTestCase(AutoAuthTestCase):
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import unittest
|
||||
|
||||
import ddt
|
||||
from mock import patch
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User, AnonymousUser
|
||||
from django.core.urlresolvers import reverse
|
||||
@@ -404,6 +405,14 @@ class TestCreateAccount(TestCase):
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_CREATED_AT)
|
||||
)
|
||||
|
||||
@patch("openedx.core.djangoapps.site_configuration.helpers.get_value", mock.Mock(return_value=False))
|
||||
def test_create_account_not_allowed(self):
|
||||
"""
|
||||
Test case to check user creation is forbidden when ALLOW_PUBLIC_ACCOUNT_CREATION feature flag is turned off
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestCreateAccountValidation(TestCase):
|
||||
|
||||
@@ -23,6 +23,7 @@ from django.contrib.auth.views import password_reset_confirm
|
||||
from django.contrib import messages
|
||||
from django.core.context_processors import csrf
|
||||
from django.core import mail
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch, reverse_lazy
|
||||
from django.core.validators import validate_email, ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
@@ -1549,6 +1550,13 @@ def _do_create_account(form, custom_form=None):
|
||||
|
||||
Note: this function is also used for creating test users.
|
||||
"""
|
||||
# Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
|
||||
if not configuration_helpers.get_value(
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION',
|
||||
settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
|
||||
):
|
||||
raise PermissionDenied()
|
||||
|
||||
errors = {}
|
||||
errors.update(form.errors)
|
||||
if custom_form:
|
||||
@@ -1970,6 +1978,13 @@ def create_account(request, post_override=None):
|
||||
JSON call to create new edX account.
|
||||
Used by form in signup_modal.html, which is included into navigation.html
|
||||
"""
|
||||
# Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
|
||||
if not configuration_helpers.get_value(
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION',
|
||||
settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
|
||||
):
|
||||
return HttpResponseForbidden(_("Account creation not allowed."))
|
||||
|
||||
warnings.warn("Please use RegistrationView instead.", DeprecationWarning)
|
||||
|
||||
try:
|
||||
@@ -2074,6 +2089,8 @@ def auto_auth(request):
|
||||
user.save()
|
||||
profile = UserProfile.objects.get(user=user)
|
||||
reg = Registration.objects.get(user=user)
|
||||
except PermissionDenied:
|
||||
return HttpResponseForbidden(_("Account creation not allowed."))
|
||||
|
||||
# Set the user's global staff bit
|
||||
if is_staff is not None:
|
||||
|
||||
@@ -40,6 +40,7 @@ from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
|
||||
from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account
|
||||
from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH
|
||||
from openedx.core.djangolib.js_utils import dump_js_escaped_json
|
||||
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
||||
from student.tests.factories import UserFactory
|
||||
from student_account.views import account_settings_context, get_user_orders
|
||||
@@ -735,3 +736,30 @@ class MicrositeLogistrationTests(TestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
self.assertNotIn('<div id="login-and-registration-container"', resp.content)
|
||||
|
||||
|
||||
class AccountCreationTestCaseWithSiteOverrides(SiteMixin, TestCase):
|
||||
"""
|
||||
Test cases for Feature flag ALLOW_PUBLIC_ACCOUNT_CREATION which when
|
||||
turned off disables the account creation options in lms
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up the tests"""
|
||||
super(AccountCreationTestCaseWithSiteOverrides, self).setUp()
|
||||
|
||||
# Set the feature flag ALLOW_PUBLIC_ACCOUNT_CREATION to False
|
||||
self.site_configuration_values = {
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION': False
|
||||
}
|
||||
self.site_domain = 'testserver1.com'
|
||||
self.set_up_site(self.site_domain, self.site_configuration_values)
|
||||
|
||||
def test_register_option_login_page(self):
|
||||
"""
|
||||
Navigate to the login page and check the Register option is hidden when
|
||||
ALLOW_PUBLIC_ACCOUNT_CREATION flag is turned off
|
||||
"""
|
||||
response = self.client.get(reverse('signin_user'))
|
||||
self.assertNotIn('<a class="btn-neutral" href="/register?next=%2Fdashboard">Register</a>',
|
||||
response.content)
|
||||
|
||||
@@ -124,6 +124,8 @@ def login_and_registration_form(request, initial_mode="login"):
|
||||
'login_form_desc': json.loads(form_descriptions['login']),
|
||||
'registration_form_desc': json.loads(form_descriptions['registration']),
|
||||
'password_reset_form_desc': json.loads(form_descriptions['password_reset']),
|
||||
'account_creation_allowed': configuration_helpers.get_value(
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True))
|
||||
},
|
||||
'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in header
|
||||
'responsive': True,
|
||||
|
||||
@@ -368,6 +368,9 @@ FEATURES = {
|
||||
|
||||
# Set this to False to facilitate cleaning up invalid xml from your modulestore.
|
||||
'ENABLE_XBLOCK_XML_VALIDATION': True,
|
||||
|
||||
# Allow public account creation
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION': True,
|
||||
}
|
||||
|
||||
# Ignore static asset files on import which match this pattern
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
),
|
||||
THIRD_PARTY_COMPLETE_URL = '/auth/complete/provider/';
|
||||
|
||||
var ajaxSpyAndInitialize = function(that, mode, nextUrl, finishAuthUrl) {
|
||||
var ajaxSpyAndInitialize = function(that, mode, nextUrl, finishAuthUrl, createAccountOption) {
|
||||
var options = {
|
||||
initial_mode: mode,
|
||||
third_party_auth: {
|
||||
@@ -66,7 +66,8 @@
|
||||
platform_name: 'edX',
|
||||
login_form_desc: FORM_DESCRIPTION,
|
||||
registration_form_desc: FORM_DESCRIPTION,
|
||||
password_reset_form_desc: FORM_DESCRIPTION
|
||||
password_reset_form_desc: FORM_DESCRIPTION,
|
||||
account_creation_allowed: createAccountOption
|
||||
},
|
||||
$logistrationElement = $('#login-and-registration-container');
|
||||
|
||||
@@ -225,6 +226,20 @@
|
||||
// Expect that we ignore the external URL and redirect to the dashboard
|
||||
expect(view.redirect).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
|
||||
it('hides create an account section', function() {
|
||||
ajaxSpyAndInitialize(this, 'login', '', '', false);
|
||||
|
||||
// Expect the Create an account section is hidden
|
||||
expect((view.$el.find('.toggle-form')).length).toEqual(0);
|
||||
});
|
||||
|
||||
it('shows create an account section', function() {
|
||||
ajaxSpyAndInitialize(this, 'login', '', '', true);
|
||||
|
||||
// Expect the Create an account section is visible
|
||||
expect((view.$el.find('.toggle-form')).length).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
|
||||
this.platformName = options.platform_name;
|
||||
this.supportURL = options.support_link;
|
||||
this.createAccountOption = options.account_creation_allowed;
|
||||
|
||||
// The login view listens for 'sync' events from the reset model
|
||||
this.resetModel = new PasswordResetModel({}, {
|
||||
@@ -119,7 +120,8 @@
|
||||
resetModel: this.resetModel,
|
||||
thirdPartyAuth: this.thirdPartyAuth,
|
||||
platformName: this.platformName,
|
||||
supportURL: this.supportURL
|
||||
supportURL: this.supportURL,
|
||||
createAccountOption: this.createAccountOption
|
||||
});
|
||||
|
||||
// Listen for 'password-help' event to toggle sub-views
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
this.platformName = data.platformName;
|
||||
this.resetModel = data.resetModel;
|
||||
this.supportURL = data.supportURL;
|
||||
this.createAccountOption = data.createAccountOption;
|
||||
|
||||
this.listenTo(this.model, 'sync', this.saveSuccess);
|
||||
this.listenTo(this.resetModel, 'sync', this.resetEmail);
|
||||
@@ -53,7 +54,8 @@
|
||||
currentProvider: this.currentProvider,
|
||||
providers: this.providers,
|
||||
hasSecondaryProviders: this.hasSecondaryProviders,
|
||||
platformName: this.platformName
|
||||
platformName: this.platformName,
|
||||
createAccountOption: this.createAccountOption
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ site_status_msg = get_site_status_msg(course_id)
|
||||
<li class="item nav-global-04">
|
||||
<a class="btn-neutral" href="${reverse('course-specific-register', args=[course.id.to_deprecated_string()])}">${_("Register")}</a>
|
||||
</li>
|
||||
% else:
|
||||
% elif static.get_value('ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION')):
|
||||
<li class="item nav-global-04">
|
||||
<a class="btn-neutral" href="/register${login_query()}">${_("Register")}</a>
|
||||
</li>
|
||||
|
||||
@@ -53,11 +53,13 @@
|
||||
<% } %>
|
||||
</form>
|
||||
|
||||
<div class="toggle-form">
|
||||
<div class="section-title">
|
||||
<h2>
|
||||
<span class="text"><%- _.sprintf( gettext("New to %(platformName)s?"), context ) %></span>
|
||||
</h2>
|
||||
<% if ( context.createAccountOption !== false ) { %>
|
||||
<div class="toggle-form">
|
||||
<div class="section-title">
|
||||
<h2>
|
||||
<span class="text"><%- _.sprintf( gettext("New to %(platformName)s?"), context ) %></span>
|
||||
</h2>
|
||||
</div>
|
||||
<button class="nav-btn form-toggle" data-type="register"><%- gettext("Create an account") %></button>
|
||||
</div>
|
||||
<button class="nav-btn form-toggle" data-type="register"><%- gettext("Create an account") %></button>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
@@ -8,6 +8,7 @@ from pytz import UTC
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.conf import settings
|
||||
from django.core.validators import validate_email, validate_slug, ValidationError
|
||||
from django.http import HttpResponseForbidden
|
||||
from openedx.core.djangoapps.user_api.preferences.api import update_user_preferences
|
||||
from openedx.core.djangoapps.user_api.errors import PreferenceValidationError
|
||||
|
||||
@@ -292,6 +293,13 @@ def create_account(username, password, email):
|
||||
AccountPasswordInvalid
|
||||
UserAPIInternalError: the operation failed due to an unexpected error.
|
||||
"""
|
||||
# Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
|
||||
if not configuration_helpers.get_value(
|
||||
'ALLOW_PUBLIC_ACCOUNT_CREATION',
|
||||
settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
|
||||
):
|
||||
return HttpResponseForbidden(_("Account creation not allowed."))
|
||||
|
||||
# Validate the username, password, and email
|
||||
# This will raise an exception if any of these are not in a valid format.
|
||||
_validate_username(username)
|
||||
|
||||
@@ -442,3 +442,11 @@ class AccountCreationActivationAndPasswordChangeTest(TestCase):
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@patch("openedx.core.djangoapps.site_configuration.helpers.get_value", Mock(return_value=False))
|
||||
def test_create_account_not_allowed(self):
|
||||
"""
|
||||
Test case to check user creation is forbidden when ALLOW_PUBLIC_ACCOUNT_CREATION feature flag is turned off
|
||||
"""
|
||||
response = create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -19,6 +19,7 @@ from pytz import common_timezones_set, UTC
|
||||
from social.apps.django_app.default.models import UserSocialAuth
|
||||
|
||||
from django_comment_common import models
|
||||
from openedx.core.djangoapps.site_configuration.helpers import get_value
|
||||
from openedx.core.lib.api.test_utils import ApiTestCase, TEST_API_KEY
|
||||
from openedx.core.lib.time_zone_utils import get_display_time_zone
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
|
||||
@@ -1774,6 +1775,24 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
|
||||
|
||||
self.assertContains(response, 'Kosovo')
|
||||
|
||||
def test_create_account_not_allowed(self):
|
||||
"""
|
||||
Test case to check user creation is forbidden when ALLOW_PUBLIC_ACCOUNT_CREATION feature flag is turned off
|
||||
"""
|
||||
def _side_effect_for_get_value(value, default=None):
|
||||
"""
|
||||
returns a side_effect with given return value for a given value
|
||||
"""
|
||||
if value == 'ALLOW_PUBLIC_ACCOUNT_CREATION':
|
||||
return False
|
||||
else:
|
||||
return get_value(value, default)
|
||||
|
||||
with mock.patch('openedx.core.djangoapps.site_configuration.helpers.get_value') as mock_get_value:
|
||||
mock_get_value.side_effect = _side_effect_for_get_value
|
||||
response = self.client.post(self.url, {"email": self.EMAIL, "username": self.USERNAME})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
@httpretty.activate
|
||||
@ddt.ddt
|
||||
|
||||
@@ -4,9 +4,9 @@ import copy
|
||||
from opaque_keys import InvalidKeyError
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, HttpResponseForbidden
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.exceptions import ImproperlyConfigured, NON_FIELD_ERRORS, ValidationError
|
||||
from django.core.exceptions import ImproperlyConfigured, NON_FIELD_ERRORS, ValidationError, PermissionDenied
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect, csrf_exempt
|
||||
@@ -302,6 +302,7 @@ class RegistrationView(APIView):
|
||||
HttpResponse: 400 if the request is not valid.
|
||||
HttpResponse: 409 if an account with the given username or email
|
||||
address already exists
|
||||
HttpResponse: 403 operation not allowed
|
||||
"""
|
||||
data = request.POST.copy()
|
||||
|
||||
@@ -352,6 +353,8 @@ class RegistrationView(APIView):
|
||||
for field, error_list in err.message_dict.items()
|
||||
}
|
||||
return JsonResponse(errors, status=400)
|
||||
except PermissionDenied:
|
||||
return HttpResponseForbidden(_("Account creation not allowed."))
|
||||
|
||||
response = JsonResponse({"success": True})
|
||||
set_logged_in_cookies(request, response, user)
|
||||
|
||||
@@ -135,7 +135,7 @@ site_status_msg = get_site_status_msg(course_id)
|
||||
<div class="item nav-courseware-02">
|
||||
<a class="btn-neutral btn-register" href="${reverse('course-specific-register', args=[course.id.to_deprecated_string()])}">${_("Register")}</a>
|
||||
</div>
|
||||
% else:
|
||||
% elif static.get_value('ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION')):
|
||||
<div class="item nav-courseware-02">
|
||||
<a class="btn-neutral btn-register" href="/register">${_("Register")}</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user