Merge pull request #2555 from cpennington/xblock-acid-acceptance-tests
Add acceptance tests of the Acid block in both LMS and Studio
This commit is contained in:
@@ -69,7 +69,8 @@
|
||||
"ENABLE_S3_GRADE_DOWNLOADS": true,
|
||||
"PREVIEW_LMS_BASE": "",
|
||||
"SUBDOMAIN_BRANDING": false,
|
||||
"SUBDOMAIN_COURSE_LISTINGS": false
|
||||
"SUBDOMAIN_COURSE_LISTINGS": false,
|
||||
"ALLOW_ALL_ADVANCED_COMPONENTS": true
|
||||
},
|
||||
"FEEDBACK_SUBMISSION_EMAIL": "",
|
||||
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
|
||||
|
||||
@@ -16,6 +16,7 @@ os.environ['SERVICE_VARIANT'] = 'bok_choy'
|
||||
os.environ['CONFIG_ROOT'] = path(__file__).abspath().dirname() #pylint: disable=E1120
|
||||
|
||||
from .aws import * # pylint: disable=W0401, W0614
|
||||
from xmodule.x_module import prefer_xmodules
|
||||
|
||||
|
||||
######################### Testing overrides ####################################
|
||||
@@ -48,5 +49,8 @@ for log_name, log_level in LOG_OVERRIDES:
|
||||
# Use the auto_auth workflow for creating users and logging them in
|
||||
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
|
||||
|
||||
# Enable XBlocks
|
||||
XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
||||
|
||||
# Unfortunately, we need to use debug mode to serve staticfiles
|
||||
DEBUG = True
|
||||
|
||||
@@ -10,6 +10,7 @@ import static_replace
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import UTC
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from xblock.exceptions import InvalidScopeError
|
||||
from xblock.fragment import Fragment
|
||||
|
||||
from xmodule.seq_module import SequenceModule
|
||||
@@ -199,7 +200,15 @@ def add_staff_debug_info(user, block, view, frag, context): # pylint: disable=u
|
||||
if mstart is not None:
|
||||
is_released = "<font color='red'>Yes!</font>" if (now > mstart) else "<font color='green'>Not yet</font>"
|
||||
|
||||
staff_context = {'fields': [(name, field.read_from(block)) for name, field in block.fields.items()],
|
||||
field_contents = []
|
||||
for name, field in block.fields.items():
|
||||
try:
|
||||
field_contents.append((name, field.read_from(block)))
|
||||
except InvalidScopeError:
|
||||
log.warning("Unable to read field in Staff Debug information", exc_info=True)
|
||||
field_contents.append((name, "WARNING: Unable to read field"))
|
||||
|
||||
staff_context = {'fields': field_contents,
|
||||
'xml_attributes': getattr(block, 'xml_attributes', {}),
|
||||
'location': block.location,
|
||||
'xqa_key': block.xqa_key,
|
||||
|
||||
@@ -1,16 +1,155 @@
|
||||
"""
|
||||
Course Outline page in Studio.
|
||||
"""
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.query import SubQuery
|
||||
from bok_choy.promise import EmptyPromise, fulfill
|
||||
|
||||
from .course_page import CoursePage
|
||||
from .unit import UnitPage
|
||||
|
||||
|
||||
class CourseOutlinePage(CoursePage):
|
||||
class CourseOutlineContainer(object):
|
||||
"""
|
||||
A mixin to a CourseOutline page object that adds the ability to load
|
||||
a child page object by title.
|
||||
|
||||
CHILD_CLASS must be a :class:`CourseOutlineChild` subclass.
|
||||
"""
|
||||
CHILD_CLASS = None
|
||||
|
||||
def child(self, title):
|
||||
return self.CHILD_CLASS(
|
||||
self.browser,
|
||||
self.q(css=self.CHILD_CLASS.BODY_SELECTOR).filter(
|
||||
SubQuery(css=self.CHILD_CLASS.NAME_SELECTOR).filter(text=title)
|
||||
)[0]['data-locator']
|
||||
)
|
||||
|
||||
|
||||
class CourseOutlineChild(PageObject):
|
||||
"""
|
||||
A mixin to a CourseOutline page object that will be used as a child of
|
||||
:class:`CourseOutlineContainer`.
|
||||
"""
|
||||
NAME_SELECTOR = None
|
||||
BODY_SELECTOR = None
|
||||
|
||||
def __init__(self, browser, locator):
|
||||
super(CourseOutlineChild, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Return the display name of this object.
|
||||
"""
|
||||
titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text
|
||||
if titles:
|
||||
return titles[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return "{}(<browser>, {!r})".format(self.__class__.__name__, self.locator)
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular `CourseOutlineChild` context
|
||||
"""
|
||||
return '{}[data-locator="{}"] {}'.format(
|
||||
self.BODY_SELECTOR,
|
||||
self.locator,
|
||||
selector
|
||||
)
|
||||
|
||||
|
||||
class CourseOutlineUnit(CourseOutlineChild):
|
||||
"""
|
||||
PageObject that wraps a unit link on the Studio Course Overview page.
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = '.courseware-unit'
|
||||
NAME_SELECTOR = '.unit-name'
|
||||
|
||||
def go_to(self):
|
||||
"""
|
||||
Open the unit page linked to by this unit link, and return
|
||||
an initialized :class:`.UnitPage` for that unit.
|
||||
"""
|
||||
return UnitPage(self.browser, self.locator).visit()
|
||||
|
||||
|
||||
class CourseOutlineSubsection(CourseOutlineChild, CourseOutlineContainer):
|
||||
"""
|
||||
:class`.PageObject` that wraps a subsection block on the Studio Course Overview page.
|
||||
"""
|
||||
url = None
|
||||
|
||||
BODY_SELECTOR = '.courseware-subsection'
|
||||
NAME_SELECTOR = '.subsection-name-value'
|
||||
CHILD_CLASS = CourseOutlineUnit
|
||||
|
||||
def unit(self, title):
|
||||
"""
|
||||
Return the :class:`.CourseOutlineUnit with the title `title`.
|
||||
"""
|
||||
return self.child(title)
|
||||
|
||||
def toggle_expand(self):
|
||||
"""
|
||||
Toggle the expansion of this subsection.
|
||||
"""
|
||||
self.disable_jquery_animations()
|
||||
|
||||
def subsection_expanded():
|
||||
return all(
|
||||
self.q(css=self._bounded_selector('.new-unit-item'))
|
||||
.map(lambda el: el.visible)
|
||||
.results
|
||||
)
|
||||
|
||||
currently_expanded = subsection_expanded()
|
||||
|
||||
self.css_click(self._bounded_selector('.expand-collapse'))
|
||||
fulfill(EmptyPromise(
|
||||
lambda: subsection_expanded() != currently_expanded,
|
||||
"Check that the subsection {} has been toggled".format(self.locator),
|
||||
))
|
||||
return self
|
||||
|
||||
|
||||
class CourseOutlineSection(CourseOutlineChild, CourseOutlineContainer):
|
||||
"""
|
||||
:class`.PageObject` that wraps a section block on the Studio Course Overview page.
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = '.courseware-section'
|
||||
NAME_SELECTOR = '.section-name-span'
|
||||
CHILD_CLASS = CourseOutlineSubsection
|
||||
|
||||
def subsection(self, title):
|
||||
"""
|
||||
Return the :class:`.CourseOutlineSubsection` with the title `title`.
|
||||
"""
|
||||
return self.child(title)
|
||||
|
||||
|
||||
class CourseOutlinePage(CoursePage, CourseOutlineContainer):
|
||||
"""
|
||||
Course Outline page in Studio.
|
||||
"""
|
||||
|
||||
url_path = "course"
|
||||
CHILD_CLASS = CourseOutlineSection
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-outline')
|
||||
|
||||
def section(self, title):
|
||||
"""
|
||||
Return the :class:`.CourseOutlineSection` with the title `title`.
|
||||
"""
|
||||
return self.child(title)
|
||||
|
||||
@@ -3,6 +3,10 @@ Unit page in Studio
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.query import SubQuery
|
||||
from bok_choy.promise import EmptyPromise, fulfill
|
||||
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class UnitPage(PageObject):
|
||||
@@ -10,5 +14,77 @@ class UnitPage(PageObject):
|
||||
Unit page in Studio
|
||||
"""
|
||||
|
||||
def __init__(self, browser, unit_locator):
|
||||
super(UnitPage, self).__init__(browser)
|
||||
self.unit_locator = unit_locator
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""URL to the static pages UI in a course."""
|
||||
return "{}/unit/{}".format(BASE_URL, self.unit_locator)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-unit')
|
||||
|
||||
def component(self, title):
|
||||
return Component(
|
||||
self.browser,
|
||||
self.q(css=Component.BODY_SELECTOR).filter(
|
||||
SubQuery(css=Component.NAME_SELECTOR).filter(text=title)
|
||||
)[0]['data-locator']
|
||||
)
|
||||
|
||||
|
||||
class Component(PageObject):
|
||||
"""
|
||||
A PageObject representing an XBlock child on the Studio UnitPage (including
|
||||
the editing controls).
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = '.component'
|
||||
NAME_SELECTOR = '.component-header'
|
||||
|
||||
def __init__(self, browser, locator):
|
||||
super(Component, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator))
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular `CourseOutlineChild` context
|
||||
"""
|
||||
return '{}[data-locator="{}"] {}'.format(
|
||||
self.BODY_SELECTOR,
|
||||
self.locator,
|
||||
selector
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
titles = self.css_text(self._bounded_selector(self.NAME_SELECTOR))
|
||||
if titles:
|
||||
return titles[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def preview_selector(self):
|
||||
return self._bounded_selector('.xblock-student_view')
|
||||
|
||||
def edit(self):
|
||||
self.css_click(self._bounded_selector('.edit-button'))
|
||||
fulfill(EmptyPromise(
|
||||
lambda: all(
|
||||
self.q(css=self._bounded_selector('.component-editor'))
|
||||
.map(lambda el: el.visible)
|
||||
.results
|
||||
),
|
||||
"Verify that the editor for component {} has been expanded".format(self.locator)
|
||||
))
|
||||
return self
|
||||
|
||||
@property
|
||||
def editor_selector(self):
|
||||
return self._bounded_selector('.xblock-studio_view')
|
||||
|
||||
0
common/test/acceptance/pages/xblock/__init__.py
Normal file
0
common/test/acceptance/pages/xblock/__init__.py
Normal file
66
common/test/acceptance/pages/xblock/acid.py
Normal file
66
common/test/acceptance/pages/xblock/acid.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
PageObjects related to the AcidBlock
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise, BrokenPromise, fulfill
|
||||
|
||||
|
||||
class AcidView(PageObject):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of the :class:`.AcidBlock`.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def __init__(self, browser, context_selector):
|
||||
"""
|
||||
Args:
|
||||
browser (splinter.browser.Browser): The browser that this page is loaded in.
|
||||
context_selector (str): The selector that identifies where this :class:`.AcidBlock` view
|
||||
is on the page.
|
||||
"""
|
||||
super(AcidView, self).__init__(browser)
|
||||
if isinstance(context_selector, unicode):
|
||||
context_selector = context_selector.encode('utf-8')
|
||||
self.context_selector = context_selector
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return (
|
||||
self.is_css_present('{} .acid-block'.format(self.context_selector)) and
|
||||
self.browser.evaluate_script("$({!r}).data('initialized')".format(self.context_selector))
|
||||
)
|
||||
|
||||
def test_passed(self, test_selector):
|
||||
"""
|
||||
Return whether a particular :class:`.AcidBlock` test passed.
|
||||
"""
|
||||
selector = '{} .acid-block {} .pass'.format(self.context_selector, test_selector)
|
||||
return bool(self.q(css=selector).execute(try_interval=0.1, timeout=3))
|
||||
|
||||
@property
|
||||
def init_fn_passed(self):
|
||||
"""
|
||||
Whether the init-fn test passed in this view of the :class:`.AcidBlock`.
|
||||
"""
|
||||
return self.test_passed('.js-init-run')
|
||||
|
||||
@property
|
||||
def doc_ready_passed(self):
|
||||
"""
|
||||
Whether the document-ready test passed in this view of the :class:`.AcidBlock`.
|
||||
"""
|
||||
return self.test_passed('.document-ready-run')
|
||||
|
||||
def scope_passed(self, scope):
|
||||
return all(
|
||||
self.test_passed('.scope-storage-test.scope-{} {}'.format(scope, test))
|
||||
for test in (
|
||||
".server-storage-test-returned",
|
||||
".server-storage-test-succeeded",
|
||||
".client-storage-test-returned",
|
||||
".client-storage-test-succeeded",
|
||||
)
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return "{}(<browser>, {!r})".format(self.__class__.__name__, self.context_selector)
|
||||
@@ -18,6 +18,7 @@ from ..pages.lms.tab_nav import TabNavPage
|
||||
from ..pages.lms.course_nav import CourseNavPage
|
||||
from ..pages.lms.progress import ProgressPage
|
||||
from ..pages.lms.video import VideoPage
|
||||
from ..pages.xblock.acid import AcidView
|
||||
from ..fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc
|
||||
|
||||
|
||||
@@ -103,11 +104,13 @@ class HighLevelTabTest(UniqueCourseTest):
|
||||
XBlockFixtureDesc('problem', 'Test Problem 1', data=load_data_str('multiple_choice.xml')),
|
||||
XBlockFixtureDesc('problem', 'Test Problem 2', data=load_data_str('formula_problem.xml')),
|
||||
XBlockFixtureDesc('html', 'Test HTML'),
|
||||
)),
|
||||
)
|
||||
),
|
||||
XBlockFixtureDesc('chapter', 'Test Section 2').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 2'),
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 3'),
|
||||
)).install()
|
||||
)
|
||||
).install()
|
||||
|
||||
# Auto-auth register for the course
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
@@ -252,3 +255,48 @@ class VideoTest(UniqueCourseTest):
|
||||
# latency through the ssh tunnel
|
||||
self.assertGreaterEqual(self.video.elapsed_time, 0)
|
||||
self.assertGreaterEqual(self.video.duration, self.video.elapsed_time)
|
||||
|
||||
|
||||
class XBlockAcidTest(UniqueCourseTest):
|
||||
"""
|
||||
Tests that verify that XBlock integration is working correctly
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a unique identifier for the course used in this test.
|
||||
"""
|
||||
# Ensure that the superclass sets up
|
||||
super(XBlockAcidTest, self).setUp()
|
||||
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('acid', 'Acid Block')
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
self.course_info_page = CourseInfoPage(self.browser, self.course_id)
|
||||
self.tab_nav = TabNavPage(self.browser)
|
||||
|
||||
self.course_info_page.visit()
|
||||
self.tab_nav.go_to_tab('Courseware')
|
||||
|
||||
def test_acid_block(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in the lms.
|
||||
"""
|
||||
acid_block = AcidView(self.browser, '.xblock-student_view[data-block-type=acid]')
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.doc_ready_passed)
|
||||
self.assertTrue(acid_block.scope_passed('user_state'))
|
||||
|
||||
@@ -20,7 +20,8 @@ from ..pages.studio.settings_advanced import AdvancedSettingsPage
|
||||
from ..pages.studio.settings_graders import GradingPage
|
||||
from ..pages.studio.signup import SignupPage
|
||||
from ..pages.studio.textbooks import TextbooksPage
|
||||
from ..fixtures.course import CourseFixture
|
||||
from ..pages.xblock.acid import AcidView
|
||||
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
|
||||
from .helpers import UniqueCourseTest
|
||||
|
||||
@@ -107,3 +108,76 @@ class CoursePagesTest(UniqueCourseTest):
|
||||
# Verify that each page is available
|
||||
for page in self.pages:
|
||||
page.visit()
|
||||
|
||||
|
||||
class XBlockAcidTest(WebAppTest):
|
||||
"""
|
||||
Tests that verify that XBlock integration is working correctly
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a unique identifier for the course used in this test.
|
||||
"""
|
||||
# Ensure that the superclass sets up
|
||||
super(XBlockAcidTest, self).setUp()
|
||||
|
||||
# Define a unique course identifier
|
||||
self.course_info = {
|
||||
'org': 'test_org',
|
||||
'number': 'course_' + self.unique_id[:5],
|
||||
'run': 'test_' + self.unique_id,
|
||||
'display_name': 'Test Course ' + self.unique_id
|
||||
}
|
||||
|
||||
self.auth_page = AutoAuthPage(self.browser, staff=True)
|
||||
self.outline = CourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
self.course_id = '{org}.{number}.{run}'.format(**self.course_info)
|
||||
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid', 'Acid Block')
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.auth_page.visit()
|
||||
|
||||
self.outline.visit()
|
||||
unit = self.outline.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit').go_to()
|
||||
self.acid_component = unit.component('Acid Block')
|
||||
|
||||
def test_acid_block_preview(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in studio preview
|
||||
"""
|
||||
acid_block = AcidView(self.browser, self.acid_component.preview_selector)
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.doc_ready_passed)
|
||||
self.assertTrue(acid_block.scope_passed('user_state'))
|
||||
|
||||
def test_acid_block_editor(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in studio preview
|
||||
"""
|
||||
acid_block = AcidView(self.browser, self.acid_component.edit().editor_selector)
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.doc_ready_passed)
|
||||
self.assertTrue(acid_block.scope_passed('content'))
|
||||
self.assertTrue(acid_block.scope_passed('settings'))
|
||||
|
||||
@@ -4,6 +4,7 @@ Settings for bok choy tests
|
||||
|
||||
import os
|
||||
from path import path
|
||||
from xmodule.x_module import prefer_xmodules
|
||||
|
||||
|
||||
CONFIG_ROOT = path(__file__).abspath().dirname() #pylint: disable=E1120
|
||||
@@ -59,5 +60,8 @@ LOG_OVERRIDES = [
|
||||
for log_name, log_level in LOG_OVERRIDES:
|
||||
logging.getLogger(log_name).setLevel(log_level)
|
||||
|
||||
# Enable XBlocks
|
||||
XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
||||
|
||||
# Unfortunately, we need to use debug mode to serve staticfiles
|
||||
DEBUG = True
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
-e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
|
||||
|
||||
# Our libraries:
|
||||
-e git+https://github.com/edx/XBlock.git@3830ee50015b460fad63ff3b71f77bf1a2684195#egg=XBlock
|
||||
-e git+https://github.com/edx/XBlock.git@6d431d786587bd8f3a19a893364914d6e2d6c28f#egg=XBlock
|
||||
-e git+https://github.com/edx/codejail.git@e3d98f9455#egg=codejail
|
||||
-e git+https://github.com/edx/diff-cover.git@v0.2.9#egg=diff_cover
|
||||
-e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool
|
||||
-e git+https://github.com/edx/django-waffle.git@823a102e48#egg=django-waffle
|
||||
-e git+https://github.com/edx/event-tracking.git@f0211d702d#egg=event-tracking
|
||||
-e git+https://github.com/edx/bok-choy.git@v0.1.0#egg=bok_choy
|
||||
-e git+https://github.com/edx/bok-choy.git@v0.2.1#egg=bok_choy
|
||||
-e git+https://github.com/edx-solutions/django-splash.git@15bf143b15714e22fc451ff1b0f8a7a2a9483172#egg=django-splash
|
||||
-e git+https://github.com/edx/acid-block.git@aa95a3c#egg=acid-xblock
|
||||
|
||||
Reference in New Issue
Block a user