Move banner REVMI-54
Moves banner into the content instead of at the top. Adds a course expiration fragment to the course home page, the content, the progress page, and the discussion page.
This commit is contained in:
committed by
Calen Pennington
parent
7e5ed2df4b
commit
ac29017d88
@@ -73,6 +73,7 @@ from openedx.core.lib.xblock_utils import (
|
||||
is_xblock_aside,
|
||||
get_aside_from_xblock,
|
||||
)
|
||||
from openedx.features.course_duration_limits.access import course_expiration_wrapper
|
||||
from student.models import anonymous_id_for_user, user_by_anonymous_id
|
||||
from student.roles import CourseBetaTesterRole
|
||||
from track import contexts
|
||||
@@ -729,6 +730,7 @@ def get_module_system_for_user(
|
||||
))
|
||||
|
||||
block_wrappers.append(partial(display_access_messages, user))
|
||||
block_wrappers.append(partial(course_expiration_wrapper, user))
|
||||
|
||||
if settings.FEATURES.get('DISPLAY_DEBUG_INFO_TO_STAFF'):
|
||||
if is_masquerading_as_specific_student(user, course_id):
|
||||
|
||||
@@ -10,6 +10,7 @@ from HTMLParser import HTMLParser
|
||||
from urllib import quote, urlencode
|
||||
from uuid import uuid4
|
||||
from crum import set_current_request
|
||||
from markupsafe import escape
|
||||
|
||||
from completion.test_utils import CompletionWaffleTestMixin
|
||||
import ddt
|
||||
@@ -214,8 +215,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
|
||||
NUM_PROBLEMS = 20
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.mongo, 10, 178),
|
||||
(ModuleStoreEnum.Type.split, 4, 172),
|
||||
(ModuleStoreEnum.Type.mongo, 10, 179),
|
||||
(ModuleStoreEnum.Type.split, 4, 173),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
|
||||
@@ -2753,6 +2754,7 @@ class TestIndexViewWithCourseDurationLimits(ModuleStoreTestCase):
|
||||
with self.store.bulk_operations(self.course.id):
|
||||
self.chapter = ItemFactory.create(parent=self.course, category="chapter")
|
||||
self.sequential = ItemFactory.create(parent=self.chapter, category='sequential')
|
||||
self.vertical = ItemFactory.create(parent=self.sequential, category="vertical")
|
||||
|
||||
CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
|
||||
|
||||
@@ -2775,7 +2777,12 @@ class TestIndexViewWithCourseDurationLimits(ModuleStoreTestCase):
|
||||
)
|
||||
)
|
||||
bannerText = get_expiration_banner_text(self.user, self.course)
|
||||
self.assertContains(response, bannerText, html=True)
|
||||
# Banner is XBlock wrapper, so it is escaped in raw response. Since
|
||||
# it's escaped, ignoring the whitespace with assertContains doesn't
|
||||
# work. Instead we remove all whitespace to verify content is correct.
|
||||
bannerText_no_spaces = escape(bannerText).replace(' ', '')
|
||||
response_no_spaces = response.content.decode('utf-8').replace(' ', '')
|
||||
self.assertIn(bannerText_no_spaces, response_no_spaces)
|
||||
|
||||
def test_index_without_course_duration_limits(self):
|
||||
"""
|
||||
|
||||
@@ -35,7 +35,6 @@ from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.features.course_duration_limits.access import register_course_expired_message
|
||||
from openedx.features.course_experience import (
|
||||
COURSE_OUTLINE_PAGE_FLAG, default_course_url_name, COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
|
||||
)
|
||||
@@ -146,7 +145,6 @@ class CoursewareIndex(View):
|
||||
|
||||
self.is_staff = has_access(request.user, 'staff', self.course)
|
||||
self._setup_masquerade_for_effective_user()
|
||||
register_course_expired_message(request, self.course)
|
||||
|
||||
return self.render(request)
|
||||
except Exception as exception: # pylint: disable=broad-except
|
||||
|
||||
@@ -86,7 +86,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.features.course_duration_limits.access import register_course_expired_message
|
||||
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
|
||||
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, course_home_url_name
|
||||
from openedx.features.course_experience.course_tools import CourseToolsPluginManager
|
||||
from openedx.features.course_experience.views.course_dates import CourseDatesFragmentView
|
||||
@@ -505,7 +505,6 @@ class CourseTabView(EdxFragmentView):
|
||||
# Show warnings if the user has limited access
|
||||
# Must come after masquerading on creation of page context
|
||||
self.register_user_access_warning_messages(request, course_key)
|
||||
register_course_expired_message(request, course)
|
||||
|
||||
set_custom_metrics_for_course_key(course_key)
|
||||
return super(CourseTabView, self).get(request, course=course, page_context=page_context, **kwargs)
|
||||
@@ -984,7 +983,7 @@ def _progress(request, course_key, student_id):
|
||||
# checking certificate generation configuration
|
||||
enrollment_mode, _ = CourseEnrollment.enrollment_mode_for_user(student, course_key)
|
||||
|
||||
register_course_expired_message(request, course)
|
||||
course_expiration_fragment = generate_course_expired_fragment(student, course)
|
||||
|
||||
context = {
|
||||
'course': course,
|
||||
@@ -996,6 +995,7 @@ def _progress(request, course_key, student_id):
|
||||
'supports_preview_menu': True,
|
||||
'student': student,
|
||||
'credit_course_requirements': _credit_course_requirements(course_key, student),
|
||||
'course_expiration_fragment': course_expiration_fragment,
|
||||
}
|
||||
if certs_api.get_active_web_certificate(course):
|
||||
context['certificate_data'] = _get_cert_data(student, course, enrollment_mode, course_grade)
|
||||
|
||||
@@ -41,6 +41,9 @@ from openedx.core.djangolib.markup import HTML
|
||||
<div class="forum-search"></div>
|
||||
</div>
|
||||
</header>
|
||||
% if course_expiration_fragment:
|
||||
${HTML(course_expiration_fragment.content)}
|
||||
% endif
|
||||
<div class="page-content"
|
||||
% if getattr(course, 'language'):
|
||||
lang="${course.language}"
|
||||
|
||||
@@ -45,6 +45,7 @@ from django_comment_client.utils import (
|
||||
from django_comment_common.models import CourseDiscussionSettings
|
||||
from django_comment_common.utils import ThreadContext, get_course_discussion_settings, set_course_discussion_settings
|
||||
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
|
||||
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
|
||||
from student.models import CourseEnrollment
|
||||
from util.json_request import JsonResponse, expect_json
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -718,6 +719,10 @@ class DiscussionBoardFragmentView(EdxFragmentView):
|
||||
else None
|
||||
)
|
||||
context = _create_discussion_board_context(request, base_context, thread=thread)
|
||||
course_expiration_fragment = generate_course_expired_fragment(request.user, context['course'])
|
||||
context.update({
|
||||
'course_expiration_fragment': course_expiration_fragment,
|
||||
})
|
||||
if profile_page_context:
|
||||
# EDUCATOR-2119: styles are hard to reconcile if the profile page isn't also a fragment
|
||||
html = render_to_string('discussion/discussion_profile_page.html', profile_page_context)
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
@import 'features/journals';
|
||||
@import 'features/_unsupported-browser-alert';
|
||||
@import 'features/content-type-gating';
|
||||
@import 'features/course-duration-limits';
|
||||
|
||||
// search
|
||||
@import 'search/search';
|
||||
|
||||
@@ -24,6 +24,8 @@ $static-path: '../..';
|
||||
@import 'features/course-search';
|
||||
@import 'features/course-sock';
|
||||
@import 'features/course-upgrade-message';
|
||||
@import 'features/course-duration-limits';
|
||||
|
||||
|
||||
// Individual Pages
|
||||
@import "views/program-marketing-page";
|
||||
|
||||
33
lms/static/sass/features/_course-duration-limits.scss
Normal file
33
lms/static/sass/features/_course-duration-limits.scss
Normal file
@@ -0,0 +1,33 @@
|
||||
.course-expiration-message {
|
||||
background-color: #d9edf7;
|
||||
border: 1px solid darken(#d9edf7, 7%);
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
color: #31708f;
|
||||
line-height: 1.5;
|
||||
margin: $baseline auto;
|
||||
padding: 20px;
|
||||
|
||||
a:not(.btn) {
|
||||
color: #245269;
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover{
|
||||
color: darken(#245269, 7%);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
& + .page-content{
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
font-size: 44px !important;
|
||||
}
|
||||
}
|
||||
.course-content .course-expiration-message{
|
||||
max-width: $text-width-readability-max;
|
||||
}
|
||||
#discussion-container .course-expiration-message {
|
||||
margin: $baseline 40px;
|
||||
}
|
||||
@@ -63,7 +63,9 @@ username = get_enterprise_learner_generic_name(request) or student.username
|
||||
<h2 class="hd hd-2 progress-certificates-title">
|
||||
${_("Course Progress for Student '{username}' ({email})").format(username=username, email=student.email)}
|
||||
</h2>
|
||||
|
||||
% if course_expiration_fragment:
|
||||
${HTML(course_expiration_fragment.content)}
|
||||
% endif
|
||||
<div class="wrapper-msg wrapper-auto-cert">
|
||||
<div id="errors-info" class="errors-info"></div>
|
||||
<%block name="certificate_block">
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import get_language, ugettext as _
|
||||
|
||||
from student.models import CourseEnrollment
|
||||
from util.date_utils import DEFAULT_SHORT_DATE_FORMAT, strftime_localized
|
||||
from util.date_utils import strftime_localized
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.courseware.access_response import AccessError
|
||||
@@ -18,9 +18,9 @@ from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_lin
|
||||
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_specific_student
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_run_details
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
|
||||
from web_fragments.fragment import Fragment
|
||||
|
||||
MIN_DURATION = timedelta(weeks=4)
|
||||
MAX_DURATION = timedelta(weeks=12)
|
||||
@@ -112,28 +112,25 @@ def check_course_expired(user, course):
|
||||
return ACCESS_GRANTED
|
||||
|
||||
|
||||
def register_course_expired_message(request, course):
|
||||
def generate_course_expired_message(user, course):
|
||||
"""
|
||||
Add a banner notifying the user of the user course expiration date if it exists.
|
||||
Generate the message for the user course expiration date if it exists.
|
||||
"""
|
||||
if not CourseDurationLimitConfig.enabled_for_enrollment(user=request.user, course_key=course.id):
|
||||
if not CourseDurationLimitConfig.enabled_for_enrollment(user=user, course_key=course.id):
|
||||
return
|
||||
|
||||
expiration_date = get_user_course_expiration_date(request.user, course)
|
||||
expiration_date = get_user_course_expiration_date(user, course)
|
||||
if not expiration_date:
|
||||
return
|
||||
|
||||
if is_masquerading_as_specific_student(request.user, course.id) and timezone.now() > expiration_date:
|
||||
if is_masquerading_as_specific_student(user, course.id) and timezone.now() > expiration_date:
|
||||
upgrade_message = _('This learner does not have access to this course. '
|
||||
'Their access expired on {expiration_date}.')
|
||||
PageLevelMessages.register_warning_message(
|
||||
request,
|
||||
HTML(upgrade_message).format(
|
||||
expiration_date=strftime_localized(expiration_date, '%b. %-d, %Y')
|
||||
)
|
||||
return HTML(upgrade_message).format(
|
||||
expiration_date=strftime_localized(expiration_date, '%b. %-d, %Y')
|
||||
)
|
||||
else:
|
||||
enrollment = CourseEnrollment.get_enrollment(request.user, course.id)
|
||||
enrollment = CourseEnrollment.get_enrollment(user, course.id)
|
||||
if enrollment is None:
|
||||
return
|
||||
|
||||
@@ -169,30 +166,58 @@ def register_course_expired_message(request, course):
|
||||
else:
|
||||
formatted_upgrade_deadline = strftime_localized(upgrade_deadline, '%b. %-d, %Y')
|
||||
|
||||
PageLevelMessages.register_info_message(
|
||||
request,
|
||||
Text(full_message).format(
|
||||
a_open=HTML('<a href="{upgrade_link}">').format(
|
||||
upgrade_link=verified_upgrade_deadline_link(user=request.user, course=course)
|
||||
),
|
||||
sronly_span_open=HTML('<span class="sr-only">'),
|
||||
span_close=HTML('</span>'),
|
||||
a_close=HTML('</a>'),
|
||||
expiration_date=formatted_expiration_date,
|
||||
strong_open=HTML('<strong>'),
|
||||
strong_close=HTML('</strong>'),
|
||||
line_break=HTML('<br>'),
|
||||
upgrade_deadline=formatted_upgrade_deadline
|
||||
)
|
||||
return HTML(full_message).format(
|
||||
a_open=HTML('<a href="{upgrade_link}">').format(
|
||||
upgrade_link=verified_upgrade_deadline_link(user=user, course=course)
|
||||
),
|
||||
sronly_span_open=HTML('<span class="sr-only">'),
|
||||
span_close=HTML('</span>'),
|
||||
a_close=HTML('</a>'),
|
||||
expiration_date=formatted_expiration_date,
|
||||
strong_open=HTML('<strong>'),
|
||||
strong_close=HTML('</strong>'),
|
||||
line_break=HTML('<br>'),
|
||||
upgrade_deadline=formatted_upgrade_deadline
|
||||
)
|
||||
|
||||
else:
|
||||
PageLevelMessages.register_info_message(
|
||||
request,
|
||||
Text(full_message).format(
|
||||
span_close=HTML('</span>'),
|
||||
expiration_date=formatted_expiration_date,
|
||||
strong_open=HTML('<strong>'),
|
||||
strong_close=HTML('</strong>'),
|
||||
line_break=HTML('<br>'),
|
||||
)
|
||||
return HTML(full_message).format(
|
||||
span_close=HTML('</span>'),
|
||||
expiration_date=formatted_expiration_date,
|
||||
strong_open=HTML('<strong>'),
|
||||
strong_close=HTML('</strong>'),
|
||||
line_break=HTML('<br>'),
|
||||
)
|
||||
|
||||
|
||||
def generate_course_expired_fragment(user, course):
|
||||
message = generate_course_expired_message(user, course)
|
||||
if message:
|
||||
return Fragment(HTML(u"""\
|
||||
<div class="course-expiration-message">{}</div>
|
||||
""").format(message))
|
||||
|
||||
|
||||
def course_expiration_wrapper(user, block, view, frag, context): # pylint: disable=W0613
|
||||
"""
|
||||
An XBlock wrapper that prepends a message to the beginning of a vertical if
|
||||
a user's course is about to expire.
|
||||
"""
|
||||
if block.category != "vertical":
|
||||
return frag
|
||||
|
||||
course = CourseOverview.get_from_id(block.course_id)
|
||||
course_expiration_fragment = generate_course_expired_fragment(user, course)
|
||||
|
||||
if not course_expiration_fragment:
|
||||
return frag
|
||||
|
||||
# Course content must be escaped to render correctly due to the way the
|
||||
# way the XBlock rendering works. Transforming the safe markup to unicode
|
||||
# escapes correctly.
|
||||
course_expiration_fragment.content = unicode(course_expiration_fragment.content)
|
||||
|
||||
course_expiration_fragment.add_content(frag.content)
|
||||
course_expiration_fragment.add_fragment_resources(frag)
|
||||
|
||||
return course_expiration_fragment
|
||||
|
||||
@@ -12,7 +12,7 @@ from mock import patch
|
||||
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
||||
from openedx.features.course_duration_limits.access import (
|
||||
register_course_expired_message,
|
||||
generate_course_expired_message,
|
||||
get_user_course_expiration_date,
|
||||
)
|
||||
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
|
||||
@@ -31,7 +31,6 @@ class TestAccess(CacheIsolationTestCase):
|
||||
CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=UTC))
|
||||
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
|
||||
|
||||
@patch('openedx.features.course_duration_limits.access.PageLevelMessages')
|
||||
@ddt.data(
|
||||
*itertools.product(
|
||||
['en-us', 'es-419'],
|
||||
@@ -39,7 +38,7 @@ class TestAccess(CacheIsolationTestCase):
|
||||
)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_register_course_expired_message(self, language, offsets, mock_messages):
|
||||
def test_generate_course_expired_message(self, language, offsets):
|
||||
now = timezone.now()
|
||||
schedule_offset, course_offset = offsets
|
||||
|
||||
@@ -78,19 +77,11 @@ class TestAccess(CacheIsolationTestCase):
|
||||
enrollment=enrollment,
|
||||
upgrade_deadline=schedule_upgrade_deadline,
|
||||
)
|
||||
request = RequestFactory().get('/courseware')
|
||||
request.user = enrollment.user
|
||||
|
||||
duration_limit_upgrade_deadline = get_user_course_expiration_date(enrollment.user, enrollment.course)
|
||||
self.assertIsNotNone(duration_limit_upgrade_deadline)
|
||||
|
||||
register_course_expired_message(request, enrollment.course)
|
||||
self.assertEqual(
|
||||
mock_messages.register_info_message.call_count,
|
||||
1
|
||||
)
|
||||
|
||||
message = str(mock_messages.register_info_message.call_args[0][1])
|
||||
message = generate_course_expired_message(enrollment.user, enrollment.course)
|
||||
|
||||
self.assertIn(format_date(duration_limit_upgrade_deadline), message)
|
||||
|
||||
|
||||
@@ -81,6 +81,9 @@ from openedx.features.portfolio_project import INCLUDE_PORTFOLIO_UPSELL_MODAL
|
||||
</header>
|
||||
<div class="page-content">
|
||||
<div class="page-content-main">
|
||||
% if course_expiration_fragment:
|
||||
${HTML(course_expiration_fragment.content)}
|
||||
% endif
|
||||
% if course_home_message_fragment:
|
||||
${HTML(course_home_message_fragment.body_html())}
|
||||
% endif
|
||||
|
||||
@@ -64,7 +64,7 @@ TEST_PASSWORD = 'test'
|
||||
TEST_CHAPTER_NAME = 'Test Chapter'
|
||||
TEST_COURSE_TOOLS = 'Course Tools'
|
||||
TEST_COURSE_TODAY = 'Today is'
|
||||
TEST_BANNER_CLASS = '<div class="page-banner">'
|
||||
TEST_BANNER_CLASS = '<div class="course-expiration-message">'
|
||||
TEST_WELCOME_MESSAGE = '<h2>Welcome!</h2>'
|
||||
TEST_UPDATE_MESSAGE = '<h2>Test Update!</h2>'
|
||||
TEST_COURSE_UPDATES_TOOL = '/course/updates">'
|
||||
@@ -688,7 +688,6 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
|
||||
response = self.client.get(url)
|
||||
bannerText = get_expiration_banner_text(user, self.course)
|
||||
self.assertContains(response, bannerText, html=True)
|
||||
self.assertContains(response, TEST_BANNER_CLASS)
|
||||
|
||||
# Verify that enrolled users are not shown the course expiration banner if content gating is disabled
|
||||
config.enabled = False
|
||||
|
||||
@@ -130,7 +130,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
|
||||
|
||||
# Fetch the view and verify that the query counts haven't changed
|
||||
# TODO: decrease query count as part of REVO-28
|
||||
with self.assertNumQueries(56, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with self.assertNumQueries(53, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with check_mongo_calls(4):
|
||||
url = course_updates_url(self.course)
|
||||
self.client.get(url)
|
||||
|
||||
@@ -26,6 +26,7 @@ from lms.djangoapps.courseware.views.views import CourseTabView
|
||||
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
|
||||
from openedx.core.djangoapps.util.maintenance_banner import add_maintenance_banner
|
||||
from openedx.features.course_experience.course_tools import CourseToolsPluginManager
|
||||
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
|
||||
from student.models import CourseEnrollment
|
||||
from util.views import ensure_valid_course_key
|
||||
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC_OUTLINE, COURSE_VISIBILITY_PUBLIC
|
||||
@@ -132,6 +133,7 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
outline_fragment = None
|
||||
update_message_fragment = None
|
||||
course_sock_fragment = None
|
||||
course_expiration_fragment = None
|
||||
has_visited_course = None
|
||||
resume_course_url = None
|
||||
handouts_html = None
|
||||
@@ -151,6 +153,7 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
course_sock_fragment = CourseSockFragmentView().render_to_fragment(request, course=course, **kwargs)
|
||||
has_visited_course, resume_course_url = self._get_resume_course_info(request, course_id)
|
||||
handouts_html = self._get_course_handouts(request, course)
|
||||
course_expiration_fragment = generate_course_expired_fragment(request.user, course)
|
||||
elif allow_public_outline or allow_public:
|
||||
outline_fragment = CourseOutlineFragmentView().render_to_fragment(
|
||||
request, course_id=course_id, user_is_enrolled=False, **kwargs
|
||||
@@ -198,6 +201,7 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
'outline_fragment': outline_fragment,
|
||||
'handouts_html': handouts_html,
|
||||
'course_home_message_fragment': course_home_message_fragment,
|
||||
'course_expiration_fragment': course_expiration_fragment,
|
||||
'has_visited_course': has_visited_course,
|
||||
'resume_course_url': resume_course_url,
|
||||
'course_tools': course_tools,
|
||||
|
||||
Reference in New Issue
Block a user