Commit 32a98e4f by Calen Pennington

Merge pull request #383 from MITx/feature/victor/course-grade-detail

Feature/victor/course grade detail
parents 63e545cb 1879e6ab
##
## A script to create some dummy users
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
from student.models import UserProfile, CourseEnrollment
from student.views import _do_create_account, get_random_post_override
def create(n, course_id):
"""Create n users, enrolling them in course_id if it's not None"""
for i in range(n):
(user, user_profile, _) = _do_create_account(get_random_post_override())
if course_id is not None:
CourseEnrollment.objects.create(user=user, course_id=course_id)
class Command(BaseCommand):
help = """Create N new users, with random parameters.
Usage: create_random_users.py N [course_id_to_enroll_in].
Examples:
create_random_users.py 1
create_random_users.py 10 MITx/6.002x/2012_Fall
create_random_users.py 100 HarvardX/CS50x/2012
"""
def handle(self, *args, **options):
if len(args) < 1 or len(args) > 2:
print Command.help
return
n = int(args[0])
course_id = args[1] if len(args) == 2 else None
create(n, course_id)
......@@ -278,6 +278,58 @@ def change_setting(request):
return HttpResponse(json.dumps({'success': True,
'location': up.location, }))
def _do_create_account(post_vars):
"""
Given cleaned post variables, create the User and UserProfile objects, as well as the
registration for this user.
Returns a tuple (User, UserProfile, Registration).
Note: this function is also used for creating test users.
"""
user = User(username=post_vars['username'],
email=post_vars['email'],
is_active=False)
user.set_password(post_vars['password'])
registration = Registration()
# TODO: Rearrange so that if part of the process fails, the whole process fails.
# Right now, we can have e.g. no registration e-mail sent out and a zombie account
try:
user.save()
except IntegrityError:
# Figure out the cause of the integrity error
if len(User.objects.filter(username=post_vars['username'])) > 0:
js['value'] = "An account with this username already exists."
js['field'] = 'username'
return HttpResponse(json.dumps(js))
if len(User.objects.filter(email=post_vars['email'])) > 0:
js['value'] = "An account with this e-mail already exists."
js['field'] = 'email'
return HttpResponse(json.dumps(js))
raise
registration.register(user)
profile = UserProfile(user=user)
profile.name = post_vars['name']
profile.level_of_education = post_vars.get('level_of_education')
profile.gender = post_vars.get('gender')
profile.mailing_address = post_vars.get('mailing_address')
profile.goals = post_vars.get('goals')
try:
profile.year_of_birth = int(post_vars['year_of_birth'])
except (ValueError, KeyError):
profile.year_of_birth = None # If they give us garbage, just ignore it instead
# of asking them to put an integer.
try:
profile.save()
except Exception:
log.exception("UserProfile creation failed for user {0}.".format(user.id))
return (user, profile, registration)
@ensure_csrf_cookie
def create_account(request, post_override=None):
......@@ -349,50 +401,11 @@ def create_account(request, post_override=None):
js['field'] = 'username'
return HttpResponse(json.dumps(js))
u = User(username=post_vars['username'],
email=post_vars['email'],
is_active=False)
u.set_password(post_vars['password'])
r = Registration()
# TODO: Rearrange so that if part of the process fails, the whole process fails.
# Right now, we can have e.g. no registration e-mail sent out and a zombie account
try:
u.save()
except IntegrityError:
# Figure out the cause of the integrity error
if len(User.objects.filter(username=post_vars['username'])) > 0:
js['value'] = "An account with this username already exists."
js['field'] = 'username'
return HttpResponse(json.dumps(js))
if len(User.objects.filter(email=post_vars['email'])) > 0:
js['value'] = "An account with this e-mail already exists."
js['field'] = 'email'
return HttpResponse(json.dumps(js))
raise
r.register(u)
up = UserProfile(user=u)
up.name = post_vars['name']
up.level_of_education = post_vars.get('level_of_education')
up.gender = post_vars.get('gender')
up.mailing_address = post_vars.get('mailing_address')
up.goals = post_vars.get('goals')
try:
up.year_of_birth = int(post_vars['year_of_birth'])
except (ValueError, KeyError):
up.year_of_birth = None # If they give us garbage, just ignore it instead
# of asking them to put an integer.
try:
up.save()
except Exception:
log.exception("UserProfile creation failed for user {0}.".format(u.id))
# Ok, looks like everything is legit. Create the account.
(user, profile, registration) = _do_create_account(post_vars)
d = {'name': post_vars['name'],
'key': r.activation_key,
'key': registration.activation_key,
}
# composes activation email
......@@ -404,10 +417,11 @@ def create_account(request, post_override=None):
try:
if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
dest_addr = settings.MITX_FEATURES['REROUTE_ACTIVATION_EMAIL']
message = "Activation for %s (%s): %s\n" % (u, u.email, up.name) + '-' * 80 + '\n\n' + message
message = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) +
'-' * 80 + '\n\n' + message)
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [dest_addr], fail_silently=False)
elif not settings.GENERATE_RANDOM_USER_CREDENTIALS:
res = u.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
except:
log.exception(sys.exc_info())
js['value'] = 'Could not send activation e-mail.'
......@@ -437,24 +451,30 @@ def create_account(request, post_override=None):
return HttpResponse(json.dumps(js), mimetype="application/json")
def create_random_account(create_account_function):
def get_random_post_override():
"""
Return a dictionary suitable for passing to post_vars of _do_create_account or post_override
of create_account, with random user info.
"""
def id_generator(size=6, chars=string.ascii_uppercase + string.ascii_lowercase + string.digits):
return ''.join(random.choice(chars) for x in range(size))
def inner_create_random_account(request):
post_override = {'username': "random_" + id_generator(),
return {'username': "random_" + id_generator(),
'email': id_generator(size=10, chars=string.ascii_lowercase) + "_dummy_test@mitx.mit.edu",
'password': id_generator(),
'location': id_generator(size=5, chars=string.ascii_uppercase),
'name': id_generator(size=5, chars=string.ascii_lowercase) + " " + id_generator(size=7, chars=string.ascii_lowercase),
'name': (id_generator(size=5, chars=string.ascii_lowercase) + " " +
id_generator(size=7, chars=string.ascii_lowercase)),
'honor_code': u'true',
'terms_of_service': u'true', }
return create_account_function(request, post_override=post_override)
def create_random_account(create_account_function):
def inner_create_random_account(request):
return create_account_function(request, post_override=get_random_post_override())
return inner_create_random_account
# TODO (vshnayder): do we need GENERATE_RANDOM_USER_CREDENTIALS for anything?
if settings.GENERATE_RANDOM_USER_CREDENTIALS:
create_account = create_random_account(create_account)
......@@ -520,7 +540,7 @@ def reactivation_email(request):
subject = ''.join(subject.splitlines())
message = render_to_string('reactivation_email.txt', d)
res = u.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
return HttpResponse(json.dumps({'success': True}))
......
......@@ -11,18 +11,19 @@ DEFAULT = "_DEFAULT_GROUP"
def group_from_value(groups, v):
''' Given group: (('a',0.3),('b',0.4),('c',0.3)) And random value
"""
Given group: (('a', 0.3), ('b', 0.4), ('c', 0.3)) and random value v
in [0,1], return the associated group (in the above case, return
'a' if v<0.3, 'b' if 0.3<=v<0.7, and 'c' if v>0.7
'''
'a' if v < 0.3, 'b' if 0.3 <= v < 0.7, and 'c' if v > 0.7
"""
sum = 0
for (g, p) in groups:
sum = sum + p
if sum > v:
return g
# Round off errors might cause us to run to the end of the list
# If the do, return the last element
# Round off errors might cause us to run to the end of the list.
# If the do, return the last element.
return g
......
......@@ -145,6 +145,8 @@ def has_staff_access_to_course(user, course):
'''
Returns True if the given user has staff access to the course.
This means that user is in the staff_* group, or is an overall admin.
TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course
(e.g. staff in 2012 is different from 2013, but maybe some people always have access)
course is the course field of the location being accessed.
'''
......@@ -156,13 +158,18 @@ def has_staff_access_to_course(user, course):
# note this is the Auth group, not UserTestGroup
user_groups = [x[1] for x in user.groups.values_list()]
staff_group = course_staff_group_name(course)
log.debug('course %s, staff_group %s, user %s, groups %s' % (
course, staff_group, user, user_groups))
if staff_group in user_groups:
return True
return False
def has_access_to_course(user,course):
def has_staff_access_to_course_id(user, course_id):
"""Helper method that takes a course_id instead of a course name"""
loc = CourseDescriptor.id_to_location(course_id)
return has_staff_access_to_course(user, loc.course)
def has_access_to_course(user, course):
'''course is the .course element of a location'''
if course.metadata.get('ispublic'):
return True
return has_staff_access_to_course(user,course)
......
# Compute grades using real division, with no integer truncation
from __future__ import division
import random
import logging
......@@ -179,7 +182,7 @@ def progress_summary(student, course, grader, student_module_cache):
def get_score(user, problem, student_module_cache):
"""
Return the score for a user on a problem
Return the score for a user on a problem, as a tuple (correct, total).
user: a Student object
problem: an XModule
......
......@@ -13,8 +13,9 @@ from django.core.urlresolvers import reverse
from mock import patch, Mock
from override_settings import override_settings
from django.contrib.auth.models import User
from django.contrib.auth.models import User, Group
from student.models import Registration
from courseware.courses import course_staff_group_name
from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django
......@@ -88,6 +89,13 @@ class ActivateLoginTestCase(TestCase):
self.assertTrue(data['success'])
return resp
def logout(self):
'''Logout, check that it worked.'''
resp = self.client.get(reverse('logout'), {})
# should redirect
self.assertEqual(resp.status_code, 302)
return resp
def _create_account(self, username, email, pw):
'''Try to create an account. No error checking'''
resp = self.client.post('/create_account', {
......@@ -131,12 +139,16 @@ class ActivateLoginTestCase(TestCase):
'''The setup function does all the work'''
pass
def test_logout(self):
'''Setup function does login'''
self.logout()
class PageLoader(ActivateLoginTestCase):
''' Base class that adds a function to load all pages in a modulestore '''
def enroll(self, course):
"""Enroll the currently logged-in user, and check that it worked."""
resp = self.client.post('/change_enrollment', {
'enrollment_action': 'enroll',
'course_id': course.id,
......@@ -193,7 +205,99 @@ class TestCoursesLoadTestCase(PageLoader):
self.check_pages_load('full', TEST_DATA_DIR, modulestore())
# ========= TODO: check ajax interaction here too?
@override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
class TestInstructorAuth(PageLoader):
"""Check that authentication works properly"""
# NOTE: setUpClass() runs before override_settings takes effect, so
# can't do imports there without manually hacking settings.
def setUp(self):
xmodule.modulestore.django._MODULESTORES = {}
modulestore().collection.drop()
import_from_xml(modulestore(), TEST_DATA_DIR, ['toy'])
import_from_xml(modulestore(), TEST_DATA_DIR, ['full'])
courses = modulestore().get_courses()
# get the two courses sorted out
courses.sort(key=lambda c: c.location.course)
[self.full, self.toy] = courses
# Create two accounts
self.student = 'view@test.com'
self.instructor = 'view2@test.com'
self.password = 'foo'
self.create_account('u1', self.student, self.password)
self.create_account('u2', self.instructor, self.password)
self.activate_user(self.student)
self.activate_user(self.instructor)
def check_for_get_code(self, code, url):
resp = self.client.get(url)
# HACK: workaround the bug that returns 200 instead of 404.
# TODO (vshnayder): once we're returning 404s, get rid of this if.
if code != 404:
self.assertEqual(resp.status_code, code)
else:
# look for "page not found" instead of the status code
self.assertTrue(resp.content.lower().find('page not found') != -1)
def test_instructor_page(self):
"Make sure only instructors can load it"
# First, try with an enrolled student
self.login(self.student, self.password)
# shouldn't work before enroll
self.check_for_get_code(302, reverse('courseware', kwargs={'course_id': self.toy.id}))
self.enroll(self.toy)
self.enroll(self.full)
# should work now
self.check_for_get_code(200, reverse('courseware', kwargs={'course_id': self.toy.id}))
def instructor_urls(course):
"list of urls that only instructors/staff should be able to see"
urls = [reverse(name, kwargs={'course_id': course.id}) for name in (
'instructor_dashboard',
'gradebook',
'grade_summary',)]
urls.append(reverse('student_profile', kwargs={'course_id': course.id,
'student_id': user(self.student).id}))
return urls
# shouldn't be able to get to the instructor pages
for url in instructor_urls(self.toy) + instructor_urls(self.full):
print 'checking for 404 on {}'.format(url)
self.check_for_get_code(404, url)
# Make the instructor staff in the toy course
group_name = course_staff_group_name(self.toy)
g = Group.objects.create(name=group_name)
g.user_set.add(user(self.instructor))
self.logout()
self.login(self.instructor, self.password)
# Now should be able to get to the toy course, but not the full course
for url in instructor_urls(self.toy):
print 'checking for 200 on {}'.format(url)
self.check_for_get_code(200, url)
for url in instructor_urls(self.full):
print 'checking for 404 on {}'.format(url)
self.check_for_get_code(404, url)
# now also make the instructor staff
u = user(self.instructor)
u.is_staff = True
u.save()
# and now should be able to load both
for url in instructor_urls(self.toy) + instructor_urls(self.full):
print 'checking for 200 on {}'.format(url)
self.check_for_get_code(200, url)
@override_settings(MODULESTORE=REAL_DATA_MODULESTORE)
......
......@@ -27,7 +27,8 @@ from xmodule.course_module import CourseDescriptor
from util.cache import cache, cache_if_anonymous
from student.models import UserTestGroup, CourseEnrollment
from courseware import grades
from courseware.courses import check_course, get_courses_by_university
from courseware.courses import (check_course, get_courses_by_university,
has_staff_access_to_course_id)
log = logging.getLogger("mitx.courseware")
......@@ -35,6 +36,9 @@ log = logging.getLogger("mitx.courseware")
template_imports = {'urllib': urllib}
def user_groups(user):
"""
TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately.
"""
if not user.is_authenticated():
return []
......@@ -64,64 +68,6 @@ def courses(request):
universities = get_courses_by_university(request.user)
return render_to_response("courses.html", {'universities': universities})
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def gradebook(request, course_id):
if 'course_admin' not in user_groups(request.user):
raise Http404
course = check_course(course_id)
student_objects = User.objects.all()[:100]
student_info = []
#TODO: Only select students who are in the course
for student in student_objects:
student_info.append({
'username': student.username,
'id': student.id,
'email': student.email,
'grade_summary': grades.grade(student, request, course),
'realname': UserProfile.objects.get(user=student).name
})
return render_to_response('gradebook.html', {'students': student_info, 'course': course})
@login_required
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def profile(request, course_id, student_id=None):
''' User profile. Show username, location, etc, as well as grades .
We need to allow the user to change some of these settings .'''
course = check_course(course_id)
if student_id is None:
student = request.user
else:
if 'course_admin' not in user_groups(request.user):
raise Http404
student = User.objects.get(id=int(student_id))
user_info = UserProfile.objects.get(user=student)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, course)
course_module = get_module(request.user, request, course.location, student_module_cache)
courseware_summary = grades.progress_summary(student, course_module, course.grader, student_module_cache)
grade_summary = grades.grade(request.user, request, course, student_module_cache)
context = {'name': user_info.name,
'username': student.username,
'location': user_info.location,
'language': user_info.language,
'email': student.email,
'course': course,
'csrf': csrf(request)['csrf_token'],
'courseware_summary' : courseware_summary,
'grade_summary' : grade_summary
}
context.update()
return render_to_response('profile.html', context)
def render_accordion(request, course, chapter, section):
''' Draws navigation bar. Takes current position in accordion as
......@@ -296,3 +242,104 @@ def university_profile(request, org_id):
template_file = "university_profile/{0}.html".format(org_id).lower()
return render_to_response(template_file, context)
@login_required
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def profile(request, course_id, student_id=None):
""" User profile. Show username, location, etc, as well as grades .
We need to allow the user to change some of these settings.
Course staff are allowed to see the profiles of students in their class.
"""
course = check_course(course_id)
if student_id is None or student_id == request.user.id:
# always allowed to see your own profile
student = request.user
else:
# Requesting access to a different student's profile
if not has_staff_access_to_course_id(request.user, course_id):
raise Http404
student = User.objects.get(id=int(student_id))
user_info = UserProfile.objects.get(user=student)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, course)
course_module = get_module(request.user, request, course.location, student_module_cache)
courseware_summary = grades.progress_summary(student, course_module, course.grader, student_module_cache)
grade_summary = grades.grade(request.user, request, course, student_module_cache)
context = {'name': user_info.name,
'username': student.username,
'location': user_info.location,
'language': user_info.language,
'email': student.email,
'course': course,
'csrf': csrf(request)['csrf_token'],
'courseware_summary' : courseware_summary,
'grade_summary' : grade_summary
}
context.update()
return render_to_response('profile.html', context)
# ======== Instructor views =============================================================================
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def gradebook(request, course_id):
"""
Show the gradebook for this course:
- only displayed to course staff
- shows students who are enrolled.
"""
if not has_staff_access_to_course_id(request.user, course_id):
raise Http404
course = check_course(course_id)
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
# TODO (vshnayder): implement pagination.
enrolled_students = enrolled_students[:1000] # HACK!
student_info = [{'username': student.username,
'id': student.id,
'email': student.email,
'grade_summary': grades.grade(student, request, course),
'realname': UserProfile.objects.get(user=student).name
}
for student in enrolled_students]
return render_to_response('gradebook.html', {'students': student_info,
'course': course, 'course_id': course_id})
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def grade_summary(request, course_id):
"""Display the grade summary for a course."""
if not has_staff_access_to_course_id(request.user, course_id):
raise Http404
course = check_course(course_id)
# For now, just a static page
context = {'course': course }
return render_to_response('grade_summary.html', context)
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def instructor_dashboard(request, course_id):
"""Display the instructor dashboard for a course."""
if not has_staff_access_to_course_id(request.user, course_id):
raise Http404
course = check_course(course_id)
# For now, just a static page
context = {'course': course }
return render_to_response('instructor_dashboard.html', context)
......@@ -25,6 +25,7 @@ SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
# Nose Test Runner
INSTALLED_APPS += ('django_nose',)
NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html',
# '-v', '--pdb', # When really stuck, uncomment to start debugger on error
'--cover-inclusive', '--cover-html-dir',
os.environ.get('NOSE_COVER_HTML_DIR', 'cover_html')]
for app in os.listdir(PROJECT_ROOT / 'djangoapps'):
......
......@@ -7,6 +7,7 @@ def url_class(url):
return ""
%>
<%! from django.core.urlresolvers import reverse %>
<%! from courseware.courses import has_staff_access_to_course_id %>
<nav class="${active_page} course-material">
<div class="inner-wrapper">
......@@ -27,6 +28,10 @@ def url_class(url):
% if user.is_authenticated():
<li class="profile"><a href="${reverse('profile', args=[course.id])}" class="${url_class('profile')}">Profile</a></li>
% endif
% if has_staff_access_to_course_id(user, course.id):
<li class="instructor"><a href="${reverse('instructor_dashboard', args=[course.id])}" class="${url_class('instructor')}">Instructor</a></li>
% endif
</ol>
</div>
</nav>
\ No newline at end of file
<%inherit file="main.html" />
<%! from django.core.urlresolvers import reverse %>
<%namespace name='static' file='static_content.html'/>
<%include file="course_navigation.html" args="active_page=''" />
<section class="container">
<div class="gradebook-summary-wrapper">
<section class="gradebook-summary-content">
<h1>Grade summary</h1>
<p>Not implemented yet</p>
</section>
</div>
</section>
<%inherit file="main.html" />
<%! from django.core.urlresolvers import reverse %>
<%namespace name='static' file='static_content.html'/>
<%block name="js_extra">
......@@ -42,24 +43,27 @@
<th>Total</th>
</tr>
<%def name="percent_data(percentage)">
<%def name="percent_data(fraction)">
<%
letter_grade = 'None'
if percentage > 0:
if fraction > 0:
letter_grade = 'F'
for grade in ['A', 'B', 'C']:
if percentage >= course.grade_cutoffs[grade]:
if fraction >= course.grade_cutoffs[grade]:
letter_grade = grade
break
data_class = "grade_" + letter_grade
%>
<td class="${data_class}" data-percent="${percentage}">${ "{0:.0%}".format( percentage ) }</td>
<td class="${data_class}" data-percent="${fraction}">${ "{0:.0f}".format( 100 * fraction ) }</td>
</%def>
%for student in students:
<tr>
<td><a href="/profile/${student['id']}/">${student['username']}</a></td>
<td><a href="${reverse('student_profile',
kwargs={'course_id' : course_id,
'student_id': student['id']})}">
${student['username']}</a></td>
%for section in student['grade_summary']['section_breakdown']:
${percent_data( section['percent'] )}
%endfor
......
<%inherit file="main.html" />
<%! from django.core.urlresolvers import reverse %>
<%namespace name='static' file='static_content.html'/>
<%block name="headextra">
<%static:css group='course'/>
</%block>
<%include file="course_navigation.html" args="active_page='instructor'" />
<section class="container">
<div class="instructor-dashboard-wrapper">
<section class="instructor-dashboard-content">
<h1>Instructor Dashboard</h1>
<p>
<a href="${reverse('gradebook', kwargs={'course_id': course.id})}">Gradebook</a>
<p>
<a href="${reverse('grade_summary', kwargs={'course_id': course.id})}">Grade summary</a>
</section>
</div>
</section>
......@@ -85,7 +85,6 @@ urlpatterns = ('',
(r'^pressrelease$', 'django.views.generic.simple.redirect_to', {'url': '/press/uc-berkeley-joins-edx'}),
(r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}),
# TODO: These urls no longer work. They need to be updated before they are re-enabled
......@@ -136,12 +135,18 @@ if settings.COURSEWARE_ENABLED:
'courseware.views.index', name="courseware_section"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/profile$',
'courseware.views.profile', name="profile"),
# Takes optional student_id for instructor use--shows profile as that student sees it.
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/profile/(?P<student_id>[^/]*)/$',
'courseware.views.profile'),
'courseware.views.profile', name="student_profile"),
# For the instructor
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor$',
'courseware.views.instructor_dashboard', name="instructor_dashboard"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$',
'courseware.views.gradebook'),
'courseware.views.gradebook', name='gradebook'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/grade_summary$',
'courseware.views.grade_summary', name='grade_summary'),
)
# Multicourse wiki
......
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