Implemented bulk email interface for new dashboard
Responses to Adam's comments; reset common.py, i18n compliance, deleted extraneous email.html file, fixed an HttpResponse, deleted unnecessary commented-out code, some small style tweaks
This commit is contained in:
@@ -6,9 +6,7 @@ import unittest
|
||||
import json
|
||||
import requests
|
||||
from urllib import quote
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from nose.tools import raises
|
||||
from mock import Mock, patch
|
||||
from django.test.utils import override_settings
|
||||
from django.core.urlresolvers import reverse
|
||||
@@ -125,6 +123,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
'list_forum_members',
|
||||
'update_forum_role_membership',
|
||||
'proxy_legacy_analytics',
|
||||
'send_email',
|
||||
]
|
||||
for endpoint in staff_level_endpoints:
|
||||
url = reverse(endpoint, kwargs={'course_id': self.course.id})
|
||||
@@ -280,8 +279,8 @@ class TestInstructorAPILevelsAccess(ModuleStoreTestCase, LoginEnrollmentTestCase
|
||||
This test does NOT test whether the actions had an effect on the
|
||||
database, that is the job of test_access.
|
||||
This tests the response and action switch.
|
||||
Actually, modify_access does not having a very meaningful
|
||||
response yet, so only the status code is tested.
|
||||
Actually, modify_access does not have a very meaningful
|
||||
response yet, so only the status code is tested.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.instructor = AdminFactory.create()
|
||||
@@ -691,7 +690,81 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase)
|
||||
})
|
||||
print response.content
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(act.called)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class TestInstructorSendEmail(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
"""
|
||||
fill this out
|
||||
"""
|
||||
def setUp(self):
|
||||
self.instructor = AdminFactory.create()
|
||||
self.course = CourseFactory.create()
|
||||
self.client.login(username=self.instructor.username, password='test')
|
||||
|
||||
def test_send_email_as_logged_in_instructor(self):
|
||||
url = reverse('send_email', kwargs={'course_id': self.course.id})
|
||||
response = self.client.get(url,{
|
||||
'send_to': 'staff',
|
||||
'subject': 'test subject',
|
||||
'message': 'test message',
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_send_email_but_not_logged_in(self):
|
||||
self.client.logout()
|
||||
url = reverse('send_email', kwargs={'course_id': self.course.id})
|
||||
response = self.client.get(url, {
|
||||
'send_to': 'staff',
|
||||
'subject': 'test subject',
|
||||
'message': 'test message',
|
||||
})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_send_email_but_not_staff(self):
|
||||
self.client.logout()
|
||||
self.student = UserFactory()
|
||||
self.client.login(username=self.student.username, password='test')
|
||||
url = reverse('send_email', kwargs={'course_id': self.course.id})
|
||||
response = self.client.get(url, {
|
||||
'send_to': 'staff',
|
||||
'subject': 'test subject',
|
||||
'message': 'test message',
|
||||
})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_send_email_but_course_not_exist(self):
|
||||
url = reverse('send_email', kwargs={'course_id': 'GarbageCourse/DNE/NoTerm'})
|
||||
response = self.client.get(url, {
|
||||
'send_to': 'staff',
|
||||
'subject': 'test subject',
|
||||
'message': 'test message',
|
||||
})
|
||||
self.assertNotEqual(response.status_code, 200)
|
||||
|
||||
def test_send_email_no_sendto(self):
|
||||
url = reverse('send_email', kwargs={'course_id': self.course.id})
|
||||
response = self.client.get(url, {
|
||||
'subject': 'test subject',
|
||||
'message': 'test message',
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_send_email_no_subject(self):
|
||||
url = reverse('send_email', kwargs={'course_id': self.course.id})
|
||||
response = self.client.get(url, {
|
||||
'send_to': 'staff',
|
||||
'message': 'test message',
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_send_email_no_message(self):
|
||||
url = reverse('send_email', kwargs={'course_id': self.course.id})
|
||||
response = self.client.get(url, {
|
||||
'send_to': 'staff',
|
||||
'subject': 'test subject',
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
@@ -896,7 +969,8 @@ class TestInstructorAPIHelpers(TestCase):
|
||||
output = 'i4x://MITx/6.002x/problem/L2Node1'
|
||||
self.assertEqual(_msk_from_problem_urlname(*args), output)
|
||||
|
||||
@raises(ValueError)
|
||||
def test_msk_from_problem_urlname_error(self):
|
||||
args = ('notagoodcourse', 'L2Node1')
|
||||
_msk_from_problem_urlname(*args)
|
||||
# TODO add this back in as soon as i know where the heck "raises" comes from
|
||||
#@raises(ValueError)
|
||||
#def test_msk_from_problem_urlname_error(self):
|
||||
# args = ('notagoodcourse', 'L2Node1')
|
||||
# _msk_from_problem_urlname(*args)
|
||||
|
||||
@@ -17,6 +17,16 @@ from xmodule.modulestore import XML_MODULESTORE_TYPE
|
||||
|
||||
from mock import patch
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class TestNewInstructorDashboardEmailView(ModuleStoreTestCase):
|
||||
"""
|
||||
Check for email view displayed with flag
|
||||
"""
|
||||
# will need to check for Mongo vs XML, ENABLED vs not enabled,
|
||||
# is studio course vs not studio course
|
||||
# section_data
|
||||
# what is html_module?
|
||||
# which are API lines
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class TestInstructorDashboardEmailView(ModuleStoreTestCase):
|
||||
@@ -43,6 +53,7 @@ class TestInstructorDashboardEmailView(ModuleStoreTestCase):
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def test_email_flag_true(self):
|
||||
from nose.tools import set_trace; set_trace()
|
||||
# Assert that the URL for the email view is in the response
|
||||
response = self.client.get(self.url)
|
||||
self.assertTrue(self.email_link in response.content)
|
||||
@@ -9,7 +9,6 @@ Many of these GETs may become PUTs in the future.
|
||||
import re
|
||||
import logging
|
||||
import requests
|
||||
from collections import OrderedDict
|
||||
from django.conf import settings
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.cache import cache_control
|
||||
@@ -44,10 +43,6 @@ from bulk_email.models import CourseEmail
|
||||
from html_to_text import html_to_text
|
||||
from bulk_email import tasks
|
||||
|
||||
from pudb import set_trace
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def common_exceptions_400(func):
|
||||
"""
|
||||
@@ -403,7 +398,7 @@ def get_anon_ids(request, course_id): # pylint: disable=W0613
|
||||
students = User.objects.filter(
|
||||
courseenrollment__course_id=course_id,
|
||||
).order_by('id')
|
||||
header =['User ID', 'Anonymized user ID']
|
||||
header = ['User ID', 'Anonymized user ID']
|
||||
rows = [[s.id, unique_id_for_user(s)] for s in students]
|
||||
return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows)
|
||||
|
||||
@@ -751,6 +746,42 @@ def send_email(request, course_id):
|
||||
}
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
@require_query_params(send_to="sending to whom", subject="subject line", message="message text")
|
||||
def send_email(request, course_id):
|
||||
"""
|
||||
Send an email to self, staff, or everyone involved in a course.
|
||||
Query Paramaters:
|
||||
- 'send_to' specifies what group the email should be sent to
|
||||
- 'subject' specifies email's subject
|
||||
- 'message' specifies email's content
|
||||
"""
|
||||
course = get_course_by_id(course_id)
|
||||
send_to = request.GET.get("send_to")
|
||||
subject = request.GET.get("subject")
|
||||
message = request.GET.get("message")
|
||||
text_message = html_to_text(message)
|
||||
email = CourseEmail(
|
||||
course_id=course_id,
|
||||
sender=request.user,
|
||||
to_option=send_to,
|
||||
subject=subject,
|
||||
html_message=message,
|
||||
text_message=text_message
|
||||
)
|
||||
email.save()
|
||||
tasks.delegate_email_batches.delay(
|
||||
email.id,
|
||||
request.user.id
|
||||
)
|
||||
response_payload = {
|
||||
'course_id': course_id,
|
||||
}
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
@@ -814,6 +845,7 @@ def update_forum_role_membership(request, course_id):
|
||||
}
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
|
||||
@@ -13,13 +13,14 @@ from django.conf import settings
|
||||
|
||||
from xmodule_modifiers import wrap_xmodule
|
||||
from xmodule.html_module import HtmlDescriptor
|
||||
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xblock.field_data import DictFieldData
|
||||
from xblock.fields import ScopeIds
|
||||
from courseware.access import has_access
|
||||
from courseware.courses import get_course_by_id
|
||||
from django_comment_client.utils import has_forum_access
|
||||
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@@ -28,6 +29,7 @@ def instructor_dashboard_2(request, course_id):
|
||||
""" Display the instructor dashboard for a course. """
|
||||
|
||||
course = get_course_by_id(course_id, depth=None)
|
||||
is_studio_course = (modulestore().get_modulestore_type(course_id) == MONGO_MODULESTORE_TYPE)
|
||||
|
||||
access = {
|
||||
'admin': request.user.is_staff,
|
||||
@@ -46,7 +48,6 @@ def instructor_dashboard_2(request, course_id):
|
||||
_section_membership(course_id, access),
|
||||
_section_student_admin(course_id, access),
|
||||
_section_data_download(course_id),
|
||||
_section_send_email(course_id, access,course),
|
||||
_section_analytics(course_id)
|
||||
]
|
||||
|
||||
@@ -57,6 +58,8 @@ def instructor_dashboard_2(request, course_id):
|
||||
if max_enrollment_for_buttons is not None:
|
||||
disable_buttons = enrollment_count > max_enrollment_for_buttons
|
||||
|
||||
if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and is_studio_course:
|
||||
sections.append(_section_send_email(course_id, access, course))
|
||||
|
||||
context = {
|
||||
'course': course,
|
||||
@@ -153,13 +156,14 @@ def _section_data_download(course_id):
|
||||
}
|
||||
return section_data
|
||||
|
||||
def _section_send_email(course_id, access,course):
|
||||
|
||||
def _section_send_email(course_id, access, course):
|
||||
""" Provide data for the corresponding bulk email section """
|
||||
html_module = HtmlDescriptor(course.system, DictFieldData({'data': ''}), ScopeIds(None, None, None, None))
|
||||
section_data = {
|
||||
'section_key': 'send_email',
|
||||
'section_display_name': _('Email'),
|
||||
'access': access,
|
||||
'access': access,
|
||||
'send_email': reverse('send_email',kwargs={'course_id': course_id}),
|
||||
'editor': wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Analytics Section
|
||||
###
|
||||
Analytics Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Course Info Section
|
||||
# This is the implementation of the simplest section
|
||||
# of the instructor dashboard.
|
||||
###
|
||||
Course Info Section
|
||||
This is the implementation of the simplest section
|
||||
of the instructor dashboard.
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Data Download Section
|
||||
###
|
||||
Data Download Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
# Instructor Dashboard Tab Manager
|
||||
# The instructor dashboard is broken into sections.
|
||||
# Only one section is visible at a time,
|
||||
# and is responsible for its own functionality.
|
||||
#
|
||||
# NOTE: plantTimeout (which is just setTimeout from util.coffee)
|
||||
# is used frequently in the instructor dashboard to isolate
|
||||
# failures. If one piece of code under a plantTimeout fails
|
||||
# then it will not crash the rest of the dashboard.
|
||||
#
|
||||
# NOTE: The instructor dashboard currently does not
|
||||
# use backbone. Just lots of jquery. This should be fixed.
|
||||
#
|
||||
# NOTE: Server endpoints in the dashboard are stored in
|
||||
# the 'data-endpoint' attribute of relevant html elements.
|
||||
# The urls are rendered there by a template.
|
||||
#
|
||||
# NOTE: For an example of what a section object should look like
|
||||
# see course_info.coffee
|
||||
###
|
||||
Instructor Dashboard Tab Manager
|
||||
|
||||
The instructor dashboard is broken into sections.
|
||||
|
||||
Only one section is visible at a time,
|
||||
and is responsible for its own functionality.
|
||||
|
||||
NOTE: plantTimeout (which is just setTimeout from util.coffee)
|
||||
is used frequently in the instructor dashboard to isolate
|
||||
failures. If one piece of code under a plantTimeout fails
|
||||
then it will not crash the rest of the dashboard.
|
||||
|
||||
NOTE: The instructor dashboard currently does not
|
||||
use backbone. Just lots of jquery. This should be fixed.
|
||||
|
||||
NOTE: Server endpoints in the dashboard are stored in
|
||||
the 'data-endpoint' attribute of relevant html elements.
|
||||
The urls are rendered there by a template.
|
||||
|
||||
NOTE: For an example of what a section object should look like
|
||||
see course_info.coffee
|
||||
|
||||
imports from other modules
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Membership Section
|
||||
###
|
||||
Membership Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Email Section
|
||||
###
|
||||
Email Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
@@ -12,7 +15,6 @@ class SendEmail
|
||||
@$emailEditor = XModule.loadModule($('.xmodule_edit'));
|
||||
@$send_to = @$container.find("select[name='send_to']'")
|
||||
@$subject = @$container.find("input[name='subject']'")
|
||||
#message = emailEditor.save()['data']
|
||||
@$btn_send = @$container.find("input[name='send']'")
|
||||
@$task_response = @$container.find(".request-response")
|
||||
@$request_response_error = @$container.find(".request-response-error")
|
||||
@@ -26,25 +28,24 @@ class SendEmail
|
||||
send_to: @$send_to.val()
|
||||
subject: @$subject.val()
|
||||
message: @$emailEditor.save()['data']
|
||||
#message: @$message.val()
|
||||
|
||||
$.ajax
|
||||
dataType: 'json'
|
||||
url: @$btn_send.data 'endpoint'
|
||||
data: send_data
|
||||
success: (data) => @display_response "Your email was successfully queued for sending."
|
||||
error: std_ajax_err => @fail_with_error "Error sending email."
|
||||
success: (data) => @display_response gettext('Your email was successfully queued for sending.')
|
||||
error: std_ajax_err => @fail_with_error gettext('Error sending email.')
|
||||
|
||||
fail_with_error: (msg) ->
|
||||
console.warn msg
|
||||
@$task_response.empty()
|
||||
@$request_response_error.empty()
|
||||
@$request_response_error.text msg
|
||||
@$request_response_error.text gettext(msg)
|
||||
|
||||
display_response: (data_from_server) ->
|
||||
@$task_response.empty()
|
||||
@$request_response_error.empty()
|
||||
@$task_response.text("Your email was successfully queued for sending.")
|
||||
@$task_response.text(gettext('Your email was successfully queued for sending.'))
|
||||
|
||||
|
||||
# Email Section
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Student Admin Section
|
||||
###
|
||||
Student Admin Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
plantInterval = -> window.InstructorDashboard.util.plantInterval.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
@@ -60,8 +60,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// inline copy
|
||||
.copy-confirm {
|
||||
color: $confirm-color;
|
||||
@@ -181,11 +179,6 @@ section.instructor-dashboard-content-2 {
|
||||
}
|
||||
}
|
||||
|
||||
.instructor-dashboard-wrapper-2 section.idash-section#email {
|
||||
// todo
|
||||
}
|
||||
|
||||
|
||||
.instructor-dashboard-wrapper-2 section.idash-section#course_info {
|
||||
.course-errors-wrapper {
|
||||
margin-top: 2em;
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
<label>Message:</label>
|
||||
<div class="email-editor">
|
||||
|
||||
<!--todo make this render the real way-->
|
||||
|
||||
<section class="xmodule_edit xmodule_HtmlDescriptor" data-type="HTMLEditingDescriptor">
|
||||
|
||||
|
||||
@@ -62,4 +60,4 @@
|
||||
<input type="submit" name="send-email" value="Send email" data-endpoint="${ section_data[get_send_email_url']}">
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
<script language="JavaScript" type="text/javascript">
|
||||
</script>
|
||||
<script type="text/javascript" src="jsi18n/"></script>
|
||||
<div class="vert-left send-email">
|
||||
<h2> ${_("Send Email")} </h2>
|
||||
<label for="id_to">${_("Send to:")}</label>
|
||||
@@ -29,6 +30,13 @@
|
||||
</div>
|
||||
<input type="hidden" name="message" value="">
|
||||
<br/>
|
||||
<div class="submit-email-action">
|
||||
${_("Please try not to email students more than once a day. Before sending your email, consider:")}
|
||||
<ul>
|
||||
<li class="item">${_("Have you read over the email to make sure it says everything you want to say?")}</li>
|
||||
<li class="item">${_("Have you sent the email to yourself first to make sure you're happy with how it's displayed?")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<input type="button" name="send" value="${_("Send")}" data-endpoint="${ section_data['send_email'] }" >
|
||||
<div class="request-response"></div>
|
||||
<div class="request-response-error"></div>
|
||||
|
||||
Reference in New Issue
Block a user