Commit d265c255 by John Hess

Merge branch 'master' of github.com:MITx/mitx

parents fbce4251 ae681a52
...@@ -27,4 +27,5 @@ lms/lib/comment_client/python ...@@ -27,4 +27,5 @@ lms/lib/comment_client/python
nosetests.xml nosetests.xml
cover_html/ cover_html/
.idea/ .idea/
.redcar/
chromedriver.log chromedriver.log
\ No newline at end of file
10664: Locked by 10664 at Mon Feb 11 14:22:22 -0500 2013
---
cursor_positions: []
files_to_retain: 0
ce76efcea5f0a5b2238364f81d54f1d393853a1a
\ No newline at end of file
...@@ -68,10 +68,10 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ ...@@ -68,10 +68,10 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
save_videosource: function(newsource) { save_videosource: function(newsource) {
// newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string // newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string
// returns the videosource for the preview which iss the key whose speed is closest to 1 // returns the videosource for the preview which iss the key whose speed is closest to 1
if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.set({'intro_video': null}); if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.save({'intro_video': null});
// TODO remove all whitespace w/in string // TODO remove all whitespace w/in string
else { else {
if (this.get('intro_video') !== newsource) this.set('intro_video', newsource); if (this.get('intro_video') !== newsource) this.save('intro_video', newsource);
} }
return this.videosourceSample(); return this.videosourceSample();
......
...@@ -6,6 +6,7 @@ forums, and to the cohort admin views. ...@@ -6,6 +6,7 @@ forums, and to the cohort admin views.
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.http import Http404 from django.http import Http404
import logging import logging
import random
from courseware import courses from courseware import courses
from student.models import get_user_by_username_or_email from student.models import get_user_by_username_or_email
...@@ -65,6 +66,22 @@ def is_commentable_cohorted(course_id, commentable_id): ...@@ -65,6 +66,22 @@ def is_commentable_cohorted(course_id, commentable_id):
return ans return ans
def get_cohorted_commentables(course_id):
"""
Given a course_id return a list of strings representing cohorted commentables
"""
course = courses.get_course_by_id(course_id)
if not course.is_cohorted:
# this is the easy case :)
ans = []
else:
ans = course.cohorted_discussions
return ans
def get_cohort(user, course_id): def get_cohort(user, course_id):
""" """
Given a django User and a course_id, return the user's cohort in that Given a django User and a course_id, return the user's cohort in that
...@@ -96,9 +113,30 @@ def get_cohort(user, course_id): ...@@ -96,9 +113,30 @@ def get_cohort(user, course_id):
group_type=CourseUserGroup.COHORT, group_type=CourseUserGroup.COHORT,
users__id=user.id) users__id=user.id)
except CourseUserGroup.DoesNotExist: except CourseUserGroup.DoesNotExist:
# TODO: add auto-cohorting logic here once we know what that will be. # Didn't find the group. We'll go on to create one if needed.
pass
if not course.auto_cohort:
return None return None
choices = course.auto_cohort_groups
if len(choices) == 0:
# Nowhere to put user
log.warning("Course %s is auto-cohorted, but there are no"
" auto_cohort_groups specified",
course_id)
return None
# Put user in a random group, creating it if needed
group_name = random.choice(choices)
group, created = CourseUserGroup.objects.get_or_create(
course_id=course_id,
group_type=CourseUserGroup.COHORT,
name=group_name)
user.course_groups.add(group)
return group
def get_course_cohorts(course_id): def get_course_cohorts(course_id):
""" """
......
...@@ -47,7 +47,10 @@ class TestCohorts(django.test.TestCase): ...@@ -47,7 +47,10 @@ class TestCohorts(django.test.TestCase):
@staticmethod @staticmethod
def config_course_cohorts(course, discussions, def config_course_cohorts(course, discussions,
cohorted, cohorted_discussions=None): cohorted,
cohorted_discussions=None,
auto_cohort=None,
auto_cohort_groups=None):
""" """
Given a course with no discussion set up, add the discussions and set Given a course with no discussion set up, add the discussions and set
the cohort config appropriately. the cohort config appropriately.
...@@ -59,6 +62,9 @@ class TestCohorts(django.test.TestCase): ...@@ -59,6 +62,9 @@ class TestCohorts(django.test.TestCase):
cohorted: bool. cohorted: bool.
cohorted_discussions: optional list of topic names. If specified, cohorted_discussions: optional list of topic names. If specified,
converts them to use the same ids as topic names. converts them to use the same ids as topic names.
auto_cohort: optional bool.
auto_cohort_groups: optional list of strings
(names of groups to put students into).
Returns: Returns:
Nothing -- modifies course in place. Nothing -- modifies course in place.
...@@ -76,6 +82,12 @@ class TestCohorts(django.test.TestCase): ...@@ -76,6 +82,12 @@ class TestCohorts(django.test.TestCase):
if cohorted_discussions is not None: if cohorted_discussions is not None:
d["cohorted_discussions"] = [to_id(name) d["cohorted_discussions"] = [to_id(name)
for name in cohorted_discussions] for name in cohorted_discussions]
if auto_cohort is not None:
d["auto_cohort"] = auto_cohort
if auto_cohort_groups is not None:
d["auto_cohort_groups"] = auto_cohort_groups
course.metadata["cohort_config"] = d course.metadata["cohort_config"] = d
...@@ -89,12 +101,9 @@ class TestCohorts(django.test.TestCase): ...@@ -89,12 +101,9 @@ class TestCohorts(django.test.TestCase):
def test_get_cohort(self): def test_get_cohort(self):
# Need to fix this, but after we're testing on staging. (Looks like """
# problem is that when get_cohort internally tries to look up the Make sure get_cohort() does the right thing when the course is cohorted
# course.id, it fails, even though we loaded it through the modulestore. """
# Proper fix: give all tests a standard modulestore that uses the test
# dir.
course = modulestore().get_course("edX/toy/2012_Fall") course = modulestore().get_course("edX/toy/2012_Fall")
self.assertEqual(course.id, "edX/toy/2012_Fall") self.assertEqual(course.id, "edX/toy/2012_Fall")
self.assertFalse(course.is_cohorted) self.assertFalse(course.is_cohorted)
...@@ -122,6 +131,54 @@ class TestCohorts(django.test.TestCase): ...@@ -122,6 +131,54 @@ class TestCohorts(django.test.TestCase):
self.assertEquals(get_cohort(other_user, course.id), None, self.assertEquals(get_cohort(other_user, course.id), None,
"other_user shouldn't have a cohort") "other_user shouldn't have a cohort")
def test_auto_cohorting(self):
"""
Make sure get_cohort() does the right thing when the course is auto_cohorted
"""
course = modulestore().get_course("edX/toy/2012_Fall")
self.assertEqual(course.id, "edX/toy/2012_Fall")
self.assertFalse(course.is_cohorted)
user1 = User.objects.create(username="test", email="a@b.com")
user2 = User.objects.create(username="test2", email="a2@b.com")
user3 = User.objects.create(username="test3", email="a3@b.com")
cohort = CourseUserGroup.objects.create(name="TestCohort",
course_id=course.id,
group_type=CourseUserGroup.COHORT)
# user1 manually added to a cohort
cohort.users.add(user1)
# Make the course auto cohorted...
self.config_course_cohorts(course, [], cohorted=True,
auto_cohort=True,
auto_cohort_groups=["AutoGroup"])
self.assertEquals(get_cohort(user1, course.id).id, cohort.id,
"user1 should stay put")
self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup",
"user2 should be auto-cohorted")
# Now make the group list empty
self.config_course_cohorts(course, [], cohorted=True,
auto_cohort=True,
auto_cohort_groups=[])
self.assertEquals(get_cohort(user3, course.id), None,
"No groups->no auto-cohorting")
# Now make it different
self.config_course_cohorts(course, [], cohorted=True,
auto_cohort=True,
auto_cohort_groups=["OtherGroup"])
self.assertEquals(get_cohort(user3, course.id).name, "OtherGroup",
"New list->new group")
self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup",
"user2 should still be in originally placed cohort")
def test_get_course_cohorts(self): def test_get_course_cohorts(self):
course1_id = 'a/b/c' course1_id = 'a/b/c'
......
...@@ -429,6 +429,11 @@ class CapaModule(XModule): ...@@ -429,6 +429,11 @@ class CapaModule(XModule):
# used by conditional module # used by conditional module
return self.attempts > 0 return self.attempts > 0
def is_correct(self):
"""True if full points"""
d = self.get_score()
return d['score'] == d['total']
def answer_available(self): def answer_available(self):
''' '''
Is the user allowed to see an answer? Is the user allowed to see an answer?
...@@ -449,6 +454,9 @@ class CapaModule(XModule): ...@@ -449,6 +454,9 @@ class CapaModule(XModule):
return self.lcp.done return self.lcp.done
elif self.show_answer == 'closed': elif self.show_answer == 'closed':
return self.closed() return self.closed()
elif self.show_answer == 'finished':
return self.closed() or self.is_correct()
elif self.show_answer == 'past_due': elif self.show_answer == 'past_due':
return self.is_past_due() return self.is_past_due()
elif self.show_answer == 'always': elif self.show_answer == 'always':
......
...@@ -379,6 +379,28 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -379,6 +379,28 @@ class CourseDescriptor(SequenceDescriptor):
return bool(config.get("cohorted")) return bool(config.get("cohorted"))
@property @property
def auto_cohort(self):
"""
Return whether the course is auto-cohorted.
"""
if not self.is_cohorted:
return False
return bool(self.metadata.get("cohort_config", {}).get(
"auto_cohort", False))
@property
def auto_cohort_groups(self):
"""
Return the list of groups to put students into. Returns [] if not
specified. Returns specified list even if is_cohorted and/or auto_cohort are
false.
"""
return self.metadata.get("cohort_config", {}).get(
"auto_cohort_groups", [])
@property
def top_level_discussion_topic_ids(self): def top_level_discussion_topic_ids(self):
""" """
Return list of topic ids defined in course policy. Return list of topic ids defined in course policy.
......
...@@ -23,6 +23,15 @@ URL_RE = re.compile(""" ...@@ -23,6 +23,15 @@ URL_RE = re.compile("""
(@(?P<revision>[^/]+))? (@(?P<revision>[^/]+))?
""", re.VERBOSE) """, re.VERBOSE)
MISSING_SLASH_URL_RE = re.compile("""
(?P<tag>[^:]+):/
(?P<org>[^/]+)/
(?P<course>[^/]+)/
(?P<category>[^/]+)/
(?P<name>[^@]+)
(@(?P<revision>[^/]+))?
""", re.VERBOSE)
# TODO (cpennington): We should decide whether we want to expand the # TODO (cpennington): We should decide whether we want to expand the
# list of valid characters in a location # list of valid characters in a location
INVALID_CHARS = re.compile(r"[^\w.-]") INVALID_CHARS = re.compile(r"[^\w.-]")
...@@ -164,9 +173,13 @@ class Location(_LocationBase): ...@@ -164,9 +173,13 @@ class Location(_LocationBase):
if isinstance(location, basestring): if isinstance(location, basestring):
match = URL_RE.match(location) match = URL_RE.match(location)
if match is None: if match is None:
# cdodge:
# check for a dropped slash near the i4x:// element of the location string. This can happen with some
# redirects (e.g. edx.org -> www.edx.org which I think happens in Nginx)
match = MISSING_SLASH_URL_RE.match(location)
if match is None:
log.debug('location is instance of %s but no URL match' % basestring) log.debug('location is instance of %s but no URL match' % basestring)
raise InvalidLocationError(location) raise InvalidLocationError(location)
else:
groups = match.groupdict() groups = match.groupdict()
check_dict(groups) check_dict(groups)
return _LocationBase.__new__(_cls, **groups) return _LocationBase.__new__(_cls, **groups)
......
...@@ -42,6 +42,7 @@ class CapaFactory(object): ...@@ -42,6 +42,7 @@ class CapaFactory(object):
force_save_button=None, force_save_button=None,
attempts=None, attempts=None,
problem_state=None, problem_state=None,
correct=False
): ):
""" """
All parameters are optional, and are added to the created problem if specified. All parameters are optional, and are added to the created problem if specified.
...@@ -58,6 +59,7 @@ class CapaFactory(object): ...@@ -58,6 +59,7 @@ class CapaFactory(object):
module. module.
attempts: also added to instance state. Will be converted to an int. attempts: also added to instance state. Will be converted to an int.
correct: if True, the problem will be initialized to be answered correctly.
""" """
definition = {'data': CapaFactory.sample_problem_xml, } definition = {'data': CapaFactory.sample_problem_xml, }
location = Location(["i4x", "edX", "capa_test", "problem", location = Location(["i4x", "edX", "capa_test", "problem",
...@@ -81,10 +83,19 @@ class CapaFactory(object): ...@@ -81,10 +83,19 @@ class CapaFactory(object):
instance_state_dict = {} instance_state_dict = {}
if problem_state is not None: if problem_state is not None:
instance_state_dict = problem_state instance_state_dict = problem_state
if attempts is not None: if attempts is not None:
# converting to int here because I keep putting "0" and "1" in the tests # converting to int here because I keep putting "0" and "1" in the tests
# since everything else is a string. # since everything else is a string.
instance_state_dict['attempts'] = int(attempts) instance_state_dict['attempts'] = int(attempts)
if correct:
# TODO: make this actually set an answer of 3.14, and mark it correct
#instance_state_dict['student_answers'] = {}
#instance_state_dict['correct_map'] = {}
pass
if len(instance_state_dict) > 0: if len(instance_state_dict) > 0:
instance_state = json.dumps(instance_state_dict) instance_state = json.dumps(instance_state_dict)
else: else:
...@@ -94,13 +105,16 @@ class CapaFactory(object): ...@@ -94,13 +105,16 @@ class CapaFactory(object):
definition, descriptor, definition, descriptor,
instance_state, None, metadata=metadata) instance_state, None, metadata=metadata)
if correct:
# TODO: probably better to actually set the internal state properly, but...
module.get_score = lambda: {'score': 1, 'total': 1}
return module return module
class CapaModuleTest(unittest.TestCase): class CapaModuleTest(unittest.TestCase):
def setUp(self): def setUp(self):
now = datetime.datetime.now() now = datetime.datetime.now()
day_delta = datetime.timedelta(days=1) day_delta = datetime.timedelta(days=1)
...@@ -120,6 +134,18 @@ class CapaModuleTest(unittest.TestCase): ...@@ -120,6 +134,18 @@ class CapaModuleTest(unittest.TestCase):
self.assertNotEqual(module.url_name, other_module.url_name, self.assertNotEqual(module.url_name, other_module.url_name,
"Factory should be creating unique names for each problem") "Factory should be creating unique names for each problem")
def test_correct(self):
"""
Check that the factory creates correct and incorrect problems properly.
"""
module = CapaFactory.create()
self.assertEqual(module.get_score()['score'], 0)
other_module = CapaFactory.create(correct=True)
self.assertEqual(other_module.get_score()['score'], 1)
def test_showanswer_default(self): def test_showanswer_default(self):
""" """
Make sure the show answer logic does the right thing. Make sure the show answer logic does the right thing.
...@@ -178,7 +204,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -178,7 +204,7 @@ class CapaModuleTest(unittest.TestCase):
for everyone--e.g. after due date + grace period. for everyone--e.g. after due date + grace period.
""" """
# can see after attempts used up, even with due date in the future # can't see after attempts used up, even with due date in the future
used_all_attempts = CapaFactory.create(showanswer='past_due', used_all_attempts = CapaFactory.create(showanswer='past_due',
max_attempts="1", max_attempts="1",
attempts="1", attempts="1",
...@@ -209,3 +235,50 @@ class CapaModuleTest(unittest.TestCase): ...@@ -209,3 +235,50 @@ class CapaModuleTest(unittest.TestCase):
due=self.yesterday_str, due=self.yesterday_str,
graceperiod=self.two_day_delta_str) graceperiod=self.two_day_delta_str)
self.assertFalse(still_in_grace.answer_available()) self.assertFalse(still_in_grace.answer_available())
def test_showanswer_finished(self):
"""
With showanswer="finished" should show answer after the problem is closed,
or after the answer is correct.
"""
# can see after attempts used up, even with due date in the future
used_all_attempts = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="1",
due=self.tomorrow_str)
self.assertTrue(used_all_attempts.answer_available())
# can see after due date
past_due_date = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="0",
due=self.yesterday_str)
self.assertTrue(past_due_date.answer_available())
# can't see because attempts left and wrong
attempts_left_open = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="0",
due=self.tomorrow_str)
self.assertFalse(attempts_left_open.answer_available())
# _can_ see because attempts left and right
correct_ans = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="0",
due=self.tomorrow_str,
correct=True)
self.assertTrue(correct_ans.answer_available())
# Can see even though grace period hasn't expired, because have no more
# attempts.
still_in_grace = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="1",
due=self.yesterday_str,
graceperiod=self.two_day_delta_str)
self.assertTrue(still_in_grace.answer_available())
...@@ -39,6 +39,8 @@ if Backbone? ...@@ -39,6 +39,8 @@ if Backbone?
url = DiscussionUtil.urlFor 'threads' url = DiscussionUtil.urlFor 'threads'
when 'followed' when 'followed'
url = DiscussionUtil.urlFor 'followed_threads', options.user_id url = DiscussionUtil.urlFor 'followed_threads', options.user_id
if options['group_id']
data['group_id'] = options['group_id']
data['sort_key'] = sort_options.sort_key || 'date' data['sort_key'] = sort_options.sort_key || 'date'
data['sort_order'] = sort_options.sort_order || 'desc' data['sort_order'] = sort_options.sort_order || 'desc'
DiscussionUtil.safeAjax DiscussionUtil.safeAjax
......
...@@ -70,10 +70,21 @@ if Backbone? ...@@ -70,10 +70,21 @@ if Backbone?
DiscussionUtil.loadRoles(response.roles) DiscussionUtil.loadRoles(response.roles)
allow_anonymous = response.allow_anonymous allow_anonymous = response.allow_anonymous
allow_anonymous_to_peers = response.allow_anonymous_to_peers allow_anonymous_to_peers = response.allow_anonymous_to_peers
cohorts = response.cohorts
# $elem.html("Hide Discussion") # $elem.html("Hide Discussion")
@discussion = new Discussion() @discussion = new Discussion()
@discussion.reset(response.discussion_data, {silent: false}) @discussion.reset(response.discussion_data, {silent: false})
$discussion = $(Mustache.render $("script#_inline_discussion").html(), {'threads':response.discussion_data, 'discussionId': discussionId, 'allow_anonymous_to_peers': allow_anonymous_to_peers, 'allow_anonymous': allow_anonymous})
#use same discussion template but different thread templated
#determined in the coffeescript based on whether or not there's a
#group id
if response.is_cohorted
source = "script#_inline_discussion_cohorted"
else
source = "script#_inline_discussion"
$discussion = $(Mustache.render $(source).html(), {'threads':response.discussion_data, 'discussionId': discussionId, 'allow_anonymous_to_peers': allow_anonymous_to_peers, 'allow_anonymous': allow_anonymous, 'cohorts':cohorts})
if @$('section.discussion').length if @$('section.discussion').length
@$('section.discussion').replaceWith($discussion) @$('section.discussion').replaceWith($discussion)
else else
......
...@@ -9,6 +9,7 @@ if Backbone? ...@@ -9,6 +9,7 @@ if Backbone?
"click .browse-topic-drop-search-input": "ignoreClick" "click .browse-topic-drop-search-input": "ignoreClick"
"click .post-list .list-item a": "threadSelected" "click .post-list .list-item a": "threadSelected"
"click .post-list .more-pages a": "loadMorePages" "click .post-list .more-pages a": "loadMorePages"
"change .cohort-options": "chooseCohort"
'keyup .browse-topic-drop-search-input': DiscussionFilter.filterDrop 'keyup .browse-topic-drop-search-input': DiscussionFilter.filterDrop
initialize: -> initialize: ->
...@@ -128,10 +129,20 @@ if Backbone? ...@@ -128,10 +129,20 @@ if Backbone?
switch @mode switch @mode
when 'search' when 'search'
options.search_text = @current_search options.search_text = @current_search
if @group_id
options.group_id = @group_id
when 'followed' when 'followed'
options.user_id = window.user.id options.user_id = window.user.id
options.group_id = "all"
when 'commentables' when 'commentables'
options.commentable_ids = @discussionIds options.commentable_ids = @discussionIds
if @group_id
options.group_id = @group_id
when 'all'
if @group_id
options.group_id = @group_id
@collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}) @collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy})
renderThread: (thread) => renderThread: (thread) =>
...@@ -263,12 +274,24 @@ if Backbone? ...@@ -263,12 +274,24 @@ if Backbone?
if discussionId == "#all" if discussionId == "#all"
@discussionIds = "" @discussionIds = ""
@$(".post-search-field").val("") @$(".post-search-field").val("")
@$('.cohort').show()
@retrieveAllThreads() @retrieveAllThreads()
else if discussionId == "#following" else if discussionId == "#following"
@retrieveFollowed(event) @retrieveFollowed(event)
@$('.cohort').hide()
else else
discussionIds = _.map item.find(".board-name[data-discussion_id]"), (board) -> $(board).data("discussion_id").id discussionIds = _.map item.find(".board-name[data-discussion_id]"), (board) -> $(board).data("discussion_id").id
@retrieveDiscussions(discussionIds)
if $(event.target).attr('cohorted') == "True"
@retrieveDiscussions(discussionIds, "function(){$('.cohort').show();}")
else
@retrieveDiscussions(discussionIds, "function(){$('.cohort').hide();}")
chooseCohort: (event) ->
@group_id = @$('.cohort-options :selected').val()
@collection.current_page = 0
@collection.reset()
@loadMorePages(event)
retrieveDiscussion: (discussion_id, callback=null) -> retrieveDiscussion: (discussion_id, callback=null) ->
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id) url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id)
......
...@@ -16,6 +16,9 @@ if Backbone? ...@@ -16,6 +16,9 @@ if Backbone?
@$delegateElement = @$local @$delegateElement = @$local
render: -> render: ->
if @model.has('group_id')
@template = DiscussionUtil.getTemplate("_inline_thread_cohorted")
else
@template = DiscussionUtil.getTemplate("_inline_thread") @template = DiscussionUtil.getTemplate("_inline_thread")
if not @model.has('abbreviatedBody') if not @model.has('abbreviatedBody')
......
...@@ -25,6 +25,7 @@ if Backbone? ...@@ -25,6 +25,7 @@ if Backbone?
event.preventDefault() event.preventDefault()
title = @$(".new-post-title").val() title = @$(".new-post-title").val()
body = @$(".new-post-body").find(".wmd-input").val() body = @$(".new-post-body").find(".wmd-input").val()
group = @$(".new-post-group option:selected").attr("value")
# TODO tags: commenting out til we know what to do with them # TODO tags: commenting out til we know what to do with them
#tags = @$(".new-post-tags").val() #tags = @$(".new-post-tags").val()
...@@ -45,6 +46,7 @@ if Backbone? ...@@ -45,6 +46,7 @@ if Backbone?
data: data:
title: title title: title
body: body body: body
group_id: group
# TODO tags: commenting out til we know what to do with them # TODO tags: commenting out til we know what to do with them
#tags: tags #tags: tags
......
...@@ -14,8 +14,14 @@ if Backbone? ...@@ -14,8 +14,14 @@ if Backbone?
@setSelectedTopic() @setSelectedTopic()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body" DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body"
@$(".new-post-tags").tagsInput DiscussionUtil.tagsInputOptions() @$(".new-post-tags").tagsInput DiscussionUtil.tagsInputOptions()
if @$($(".topic_menu li a")[0]).attr('cohorted') != "True"
$('.choose-cohort').hide();
events: events:
"submit .new-post-form": "createPost" "submit .new-post-form": "createPost"
"click .topic_dropdown_button": "toggleTopicDropdown" "click .topic_dropdown_button": "toggleTopicDropdown"
...@@ -65,6 +71,11 @@ if Backbone? ...@@ -65,6 +71,11 @@ if Backbone?
@topicText = @getFullTopicName($target) @topicText = @getFullTopicName($target)
@topicId = $target.data('discussion_id') @topicId = $target.data('discussion_id')
@setSelectedTopic() @setSelectedTopic()
if $target.attr('cohorted') == "True"
$('.choose-cohort').show();
else
$('.choose-cohort').hide();
setSelectedTopic: -> setSelectedTopic: ->
@dropdownButton.html(@fitName(@topicText) + ' <span class="drop-arrow">▾</span>') @dropdownButton.html(@fitName(@topicText) + ' <span class="drop-arrow">▾</span>')
...@@ -116,6 +127,7 @@ if Backbone? ...@@ -116,6 +127,7 @@ if Backbone?
title = @$(".new-post-title").val() title = @$(".new-post-title").val()
body = @$(".new-post-body").find(".wmd-input").val() body = @$(".new-post-body").find(".wmd-input").val()
tags = @$(".new-post-tags").val() tags = @$(".new-post-tags").val()
group = @$(".new-post-group option:selected").attr("value")
anonymous = false || @$("input.discussion-anonymous").is(":checked") anonymous = false || @$("input.discussion-anonymous").is(":checked")
anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked") anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked")
...@@ -137,6 +149,7 @@ if Backbone? ...@@ -137,6 +149,7 @@ if Backbone?
anonymous: anonymous anonymous: anonymous
anonymous_to_peers: anonymous_to_peers anonymous_to_peers: anonymous_to_peers
auto_subscribe: follow auto_subscribe: follow
group_id: group
error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors")) error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors"))
success: (response, textStatus) => success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish # TODO: Move this out of the callback, this makes it feel sluggish
......
...@@ -144,6 +144,7 @@ var CohortManager = (function ($) { ...@@ -144,6 +144,7 @@ var CohortManager = (function ($) {
$(".remove", tr).html('<a href="#">remove</a>') $(".remove", tr).html('<a href="#">remove</a>')
.click(function() { .click(function() {
remove_user_from_cohort(item.username, current_cohort_id, tr); remove_user_from_cohort(item.username, current_cohort_id, tr);
return false;
}); });
detail_users.append(tr); detail_users.append(tr);
...@@ -217,6 +218,7 @@ var CohortManager = (function ($) { ...@@ -217,6 +218,7 @@ var CohortManager = (function ($) {
show_cohorts_button.click(function() { show_cohorts_button.click(function() {
state = state_summary; state = state_summary;
render(); render();
return false;
}); });
add_cohort_input.change(function() { add_cohort_input.change(function() {
...@@ -231,12 +233,14 @@ var CohortManager = (function ($) { ...@@ -231,12 +233,14 @@ var CohortManager = (function ($) {
var add_url = url + '/add'; var add_url = url + '/add';
data = {'name': add_cohort_input.val()} data = {'name': add_cohort_input.val()}
$.post(add_url, data).done(added_cohort); $.post(add_url, data).done(added_cohort);
return false;
}); });
add_members_button.click(function() { add_members_button.click(function() {
var add_url = detail_url + '/add'; var add_url = detail_url + '/add';
data = {'users': users_area.val()} data = {'users': users_area.val()}
$.post(add_url, data).done(added_users); $.post(add_url, data).done(added_users);
return false;
}); });
......
...@@ -277,9 +277,11 @@ Supported fields at the course level: ...@@ -277,9 +277,11 @@ Supported fields at the course level:
* "show_calculator" (value "Yes" if desired) * "show_calculator" (value "Yes" if desired)
* "days_early_for_beta" -- number of days (floating point ok) early that students in the beta-testers group get to see course content. Can also be specified for any other course element, and overrides values set at higher levels. * "days_early_for_beta" -- number of days (floating point ok) early that students in the beta-testers group get to see course content. Can also be specified for any other course element, and overrides values set at higher levels.
* "cohort_config" : dictionary with keys * "cohort_config" : dictionary with keys
- "cohorted" : boolean. Set to true if this course uses student cohorts. If so, all inline discussions are automatically cohorted, and top-level discussion topics are configurable with an optional 'cohorted': bool parameter (with default value false). - "cohorted" : boolean. Set to true if this course uses student cohorts. If so, all inline discussions are automatically cohorted, and top-level discussion topics are configurable via the cohorted_discussions list. Default is not cohorted).
- "cohorted_discussions": list of discussions that should be cohorted. - "cohorted_discussions": list of discussions that should be cohorted. Any not specified in this list are not cohorted.
- ... more to come. ('auto_cohort', how to auto cohort, etc) - "auto_cohort": Truthy.
- "auto_cohort_groups": ["group name 1", "group name 2", ...]
- If cohorted and auto_cohort is true, automatically put each student into a random group from the auto_cohort_groups list, creating the group if needed.
* TODO: there are others * TODO: there are others
......
...@@ -92,22 +92,30 @@ def create_thread(request, course_id, commentable_id): ...@@ -92,22 +92,30 @@ def create_thread(request, course_id, commentable_id):
}) })
user = cc.User.from_django_user(request.user)
#kevinchugh because the new requirement is that all groups will be determined
#by the group id in the request this all goes away
# Cohort the thread if the commentable is cohorted. # Cohort the thread if the commentable is cohorted.
if is_commentable_cohorted(course_id, commentable_id): #if is_commentable_cohorted(course_id, commentable_id):
user_group_id = get_cohort_id(request.user, course_id) # user_group_id = get_cohort_id(user, course_id)
# TODO (vshnayder): once we have more than just cohorts, we'll want to # TODO (vshnayder): once we have more than just cohorts, we'll want to
# change this to a single get_group_for_user_and_commentable function # change this to a single get_group_for_user_and_commentable function
# that can do different things depending on the commentable_id # that can do different things depending on the commentable_id
if cached_has_permission(request.user, "see_all_cohorts", course_id): # if cached_has_permission(request.user, "see_all_cohorts", course_id):
# admins can optionally choose what group to post as # admins can optionally choose what group to post as
group_id = post.get('group_id', user_group_id) # group_id = post.get('group_id', user_group_id)
else: # else:
# regular users always post with their own id. # regular users always post with their own id.
group_id = user_group_id # group_id = user_group_id
group_id = post.get('group_id')
if group_id:
thread.update_attributes(group_id=group_id) thread.update_attributes(group_id=group_id)
log.debug("Saving thread %r", thread.attributes)
thread.save() thread.save()
if post.get('auto_subscribe', 'false').lower() == 'true': if post.get('auto_subscribe', 'false').lower() == 'true':
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
user.follow(thread) user.follow(thread)
......
...@@ -430,7 +430,7 @@ def safe_content(content): ...@@ -430,7 +430,7 @@ def safe_content(content):
'updated_at', 'depth', 'type', 'commentable_id', 'comments_count', 'updated_at', 'depth', 'type', 'commentable_id', 'comments_count',
'at_position_list', 'children', 'highlighted_title', 'highlighted_body', 'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
'courseware_title', 'courseware_url', 'tags', 'unread_comments_count', 'courseware_title', 'courseware_url', 'tags', 'unread_comments_count',
'read', 'read', 'group_id', 'group_name', 'group_string'
] ]
if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False): if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):
......
...@@ -11,12 +11,12 @@ class Thread(models.Model): ...@@ -11,12 +11,12 @@ class Thread(models.Model):
'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id', 'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id',
'created_at', 'updated_at', 'comments_count', 'unread_comments_count', 'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
'at_position_list', 'children', 'type', 'highlighted_title', 'at_position_list', 'children', 'type', 'highlighted_title',
'highlighted_body', 'endorsed', 'read', 'group_id' 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name'
] ]
updatable_fields = [ updatable_fields = [
'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'closed', 'tags', 'user_id', 'commentable_id', 'group_id' 'closed', 'tags', 'user_id', 'commentable_id', 'group_id', 'group_name'
] ]
initializable_fields = updatable_fields initializable_fields = updatable_fields
......
...@@ -215,7 +215,7 @@ def symmath_check(expect, ans, dynamath=None, options=None, debug=None, xml=None ...@@ -215,7 +215,7 @@ def symmath_check(expect, ans, dynamath=None, options=None, debug=None, xml=None
fans = None fans = None
# do a numerical comparison if both expected and answer are numbers # do a numerical comparison if both expected and answer are numbers
if (hasattr(fexpect, 'is_number') and fexpect.is_number and fans if (hasattr(fexpect, 'is_number') and fexpect.is_number
and hasattr(fans, 'is_number') and fans.is_number): and hasattr(fans, 'is_number') and fans.is_number):
if abs(abs(fans - fexpect) / fexpect) < threshold: if abs(abs(fans - fexpect) / fexpect) < threshold:
return {'ok': True, 'msg': msg} return {'ok': True, 'msg': msg}
......
...@@ -169,6 +169,12 @@ body.discussion { ...@@ -169,6 +169,12 @@ body.discussion {
} }
} }
.form-group-label {
display: block;
padding-top: 5px;
color:#fff;
}
.topic_dropdown_button { .topic_dropdown_button {
position: relative; position: relative;
z-index: 1000; z-index: 1000;
...@@ -181,7 +187,7 @@ body.discussion { ...@@ -181,7 +187,7 @@ body.discussion {
.drop-arrow { .drop-arrow {
float: right; float: right;
color: #999; color: #999;
line-height: 36px; line-height: 37px;
} }
} }
...@@ -971,7 +977,8 @@ body.discussion { ...@@ -971,7 +977,8 @@ body.discussion {
} }
.sort-bar { .sort-bar {
height: 27px; height: auto;
min-height: 27px;
border-bottom: 1px solid #a3a3a3; border-bottom: 1px solid #a3a3a3;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0)); @include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
background-color: #aeaeae; background-color: #aeaeae;
...@@ -1020,6 +1027,18 @@ body.discussion { ...@@ -1020,6 +1027,18 @@ body.discussion {
} }
} }
.group-filter-label {
width: 40px;
margin-left:10px;
}
.group-filter-select {
margin: 5px 0px 5px 5px;
width: 80px;
font-size:10px;
background: transparent;
border-color: #ccc;
}
} }
.post-list-wrapper { .post-list-wrapper {
...@@ -1327,6 +1346,8 @@ body.discussion { ...@@ -1327,6 +1346,8 @@ body.discussion {
margin-left: 40px; margin-left: 40px;
} }
.post-tools { .post-tools {
@include clearfix; @include clearfix;
margin-top: 15px; margin-top: 15px;
...@@ -1357,6 +1378,8 @@ body.discussion { ...@@ -1357,6 +1378,8 @@ body.discussion {
margin-bottom: 20px; margin-bottom: 20px;
} }
.responses { .responses {
list-style: none; list-style: none;
margin-top: 40px; margin-top: 40px;
...@@ -2412,3 +2435,11 @@ body.discussion { ...@@ -2412,3 +2435,11 @@ body.discussion {
.discussion-user-threads { .discussion-user-threads {
@extend .discussion-module @extend .discussion-module
} }
.group-visibility-label {
font-size: 12px;
color:#000;
font-style: italic;
background-color:#fff;
}
\ No newline at end of file
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
</%def> </%def>
<%def name="render_entry(entries, entry)"> <%def name="render_entry(entries, entry)">
<li><a href="#"><span class="board-name" data-discussion_id='${json.dumps(entries[entry])}'>${entry}</span></a></li> <li><a href="#"><span class="board-name" data-discussion_id='${json.dumps(entries[entry])}' cohorted = "${entries[entry]['id'] in cohorted_commentables}">${entry}</span></a></li>
</%def> </%def>
<%def name="render_category(categories, category)"> <%def name="render_category(categories, category)">
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
<ul class="browse-topic-drop-menu"> <ul class="browse-topic-drop-menu">
<li> <li>
<a href="#"> <a href="#">
<span class="board-name" data-discussion_id='#all'>All</span> <span class="board-name" data-discussion_id='#all'>Show All Discussions</span>
</a> </a>
</li> </li>
<li> <li>
......
...@@ -12,6 +12,24 @@ ...@@ -12,6 +12,24 @@
%elif course.metadata.get("allow_anonymous_to_peers", False): %elif course.metadata.get("allow_anonymous_to_peers", False):
<input type="checkbox" name="anonymous_to_peers" class="discussion-anonymous-to-peers" id="new-post-anonymous-to-peers"><label for="new-post-anonymous-to-peers">post anonymously to classmates</label> <input type="checkbox" name="anonymous_to_peers" class="discussion-anonymous-to-peers" id="new-post-anonymous-to-peers"><label for="new-post-anonymous-to-peers">post anonymously to classmates</label>
%endif %endif
%if is_course_cohorted:
<div class="form-group-label choose-cohort">
Make visible to:
<select class="group-filter-select new-post-group" name = "group_id">
<option value="">All Groups</option>
%if is_moderator:
%for c in cohorts:
<option value="${c.id}">${c.name}</option>
%endfor
%else:
%if user_cohort:
<option value="${user_cohort}">My Cohort</option>
%endif
%endif
</select>
</div>
%endif
</div> </div>
</div> </div>
<div class="right-column"> <div class="right-column">
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
</%def> </%def>
<%def name="render_entry(entries, entry)"> <%def name="render_entry(entries, entry)">
<li><a href="#" class="topic" data-discussion_id="${entries[entry]['id']}">${entry}</a></li> <li><a href="#" class="topic" data-discussion_id="${entries[entry]['id']}" cohorted = "${entries[entry]['id'] in cohorted_commentables}">${entry}</a></li>
</%def> </%def>
<%def name="render_category(categories, category)"> <%def name="render_category(categories, category)">
...@@ -21,13 +21,14 @@ ...@@ -21,13 +21,14 @@
</li> </li>
</%def> </%def>
<article class="new-post-article"> <article class="new-post-article">
<div class="inner-wrapper"> <div class="inner-wrapper">
<form class="new-post-form"> <form class="new-post-form">
<div class="left-column"> <div class="left-column">
<label>Create new post about:</label> <label>Create new post about:</label>
<div class="form-topic-drop"> <div class="form-topic-drop">
<a href="#" class="topic_dropdown_button">All<span class="drop-arrow"></span></a> <a href="#" class="topic_dropdown_button">Show All Discussions<span class="drop-arrow"></span></a>
<div class="topic_menu_wrapper"> <div class="topic_menu_wrapper">
<div class="topic_menu_search"> <div class="topic_menu_search">
<input type="text" class="form-topic-drop-search-input" placeholder="filter topics"> <input type="text" class="form-topic-drop-search-input" placeholder="filter topics">
...@@ -45,6 +46,24 @@ ...@@ -45,6 +46,24 @@
%elif course.metadata.get("allow_anonymous_to_peers", False): %elif course.metadata.get("allow_anonymous_to_peers", False):
<input type="checkbox" name="anonymous_to_peers" class="discussion-anonymous-to-peers" id="new-post-anonymous-to-peers"><label for="new-post-anonymous-to-peers">post anonymously to classmates</label> <input type="checkbox" name="anonymous_to_peers" class="discussion-anonymous-to-peers" id="new-post-anonymous-to-peers"><label for="new-post-anonymous-to-peers">post anonymously to classmates</label>
%endif %endif
%if is_course_cohorted:
<div class="form-group-label choose-cohort">
Make visible to:
<select class="group-filter-select new-post-group" name = "group_id">
<option value="">All Groups</option>
%if is_moderator:
%for c in cohorts:
<option value="${c.id}">${c.name}</option>
%endfor
%else:
%if user_cohort:
<option value="${user_cohort}">My Cohort</option>
%endif
%endif
</select>
</div>
%endif
</div> </div>
</div> </div>
<div class="right-column"> <div class="right-column">
......
...@@ -4,7 +4,12 @@ ...@@ -4,7 +4,12 @@
<article class="discussion-article" data-id="${discussion_id| h}"> <article class="discussion-article" data-id="${discussion_id| h}">
<a href="#" class="dogear"></a> <a href="#" class="dogear"></a>
<div class="discussion-post"> <div class="discussion-post">
<header> <header>
%if thread['group_id']
<div class="group-visibility-label">This post visible only to group ${cohort_dictionary[thread['group_id']]}. </div>
%endif
<a href="#" class="vote-btn discussion-vote discussion-vote-up"><span class="plus-icon">+</span> <span class='votes-count-number'>${thread['votes']['up_count']}</span></a> <a href="#" class="vote-btn discussion-vote discussion-vote-up"><span class="plus-icon">+</span> <span class='votes-count-number'>${thread['votes']['up_count']}</span></a>
<h1>${thread['title']}</h1> <h1>${thread['title']}</h1>
<p class="posted-details"> <p class="posted-details">
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="browse-search"> <div class="browse-search">
<div class="browse is-open"> <div class="browse is-open">
<a href="#" class="browse-topic-drop-icon"></a> <a href="#" class="browse-topic-drop-icon"></a>
<a href="#" class="browse-topic-drop-btn"><span class="current-board">All</span> <span class="drop-arrow">▾</span></a> <a href="#" class="browse-topic-drop-btn"><span class="current-board">Show All Discussions</span> <span class="drop-arrow">▾</span></a>
</div> </div>
<%include file="_filter_dropdown.html" /> <%include file="_filter_dropdown.html" />
<div class="search"> <div class="search">
...@@ -18,6 +18,17 @@ ...@@ -18,6 +18,17 @@
<li><a href="#" data-sort="votes">votes</a></li> <li><a href="#" data-sort="votes">votes</a></li>
<li><a href="#" data-sort="comments">comments</a></li> <li><a href="#" data-sort="comments">comments</a></li>
</ul> </ul>
%if is_course_cohorted and is_moderator:
<span class="group-filter-label cohort">Show:</span>
<select class="group-filter-select cohort-options cohort">
<option value="all">View All</option>
%for c in cohorts:
<option value="${c.id}">View as ${c.name}</option>
%endfor
</select>
%endif
</div> </div>
<div class="post-list-wrapper"> <div class="post-list-wrapper">
<ul class="post-list"> <ul class="post-list">
......
...@@ -26,6 +26,10 @@ ...@@ -26,6 +26,10 @@
<div class="discussion-post"> <div class="discussion-post">
<div><a href="javascript:void(0)" class="dogear action-follow" data-tooltip="follow"></a></div> <div><a href="javascript:void(0)" class="dogear action-follow" data-tooltip="follow"></a></div>
<header> <header>
${"<% if (obj.group_id) { %>"}
<div class="group-visibility-label">${"<%- obj.group_string%>"}</div>
${"<% } %>"}
<a href="#" class="vote-btn discussion-vote discussion-vote-up" data-role="discussion-vote" data-tooltip="vote"><span class="plus-icon">+</span> <span class='votes-count-number'>${'<%- votes["up_count"] %>'}</span></a> <a href="#" class="vote-btn discussion-vote discussion-vote-up" data-role="discussion-vote" data-tooltip="vote"><span class="plus-icon">+</span> <span class='votes-count-number'>${'<%- votes["up_count"] %>'}</span></a>
<h1>${'<%- title %>'}</h1> <h1>${'<%- title %>'}</h1>
<p class="posted-details"> <p class="posted-details">
......
<div class="discussion-content local{{#content.roles}} role-{{name}}{{/content.roles}}"> <div class="discussion-content local{{#content.roles}} role-{{name}}{{/content.roles}}">
CONTENT MUSTACHE
<div class="discussion-content-wrapper"> <div class="discussion-content-wrapper">
<div class="discussion-votes"> <div class="discussion-votes">
<a class="discussion-vote discussion-vote-up" href="javascript:void(0)" value="up">&#9650;</a> <a class="discussion-vote discussion-vote-up" href="javascript:void(0)" value="up">&#9650;</a>
......
<section class="discussion" data-discussion-id="{{discussionId}}"> <section class="discussion" data-discussion-id="{{discussionId}}">
<article class="new-post-article"> <article class="new-post-article">
<span class="topic" data-discussion-id="{{discussionId}}" /> <span class="topic" data-discussion-id="{{discussionId}}" />
<div class="inner-wrapper"> <div class="inner-wrapper">
......
<section class="discussion" data-discussion-id="{{discussionId}}">
<article class="new-post-article">
<span class="topic" data-discussion-id="{{discussionId}}" />
<div class="inner-wrapper">
<div class="new-post-form-errors">
</div>
<form class="new-post-form">
<div class="form-row">
<input type="text" class="new-post-title" name="title" placeholder="Title">
</div>
<div class="form-row">
<div class="new-post-body" name="body" placeholder="Enter your question or comment&hellip;"></div>
<!---<div class="new-post-preview"><span class="new-post-preview-label">Preview</span></div>-->
</div>
{{! TODO tags: Getting rid of tags for now. }}
{{!<div class="form-row">}}
{{! <input type="text" class="new-post-tags" name="tags" placeholder="Tags">}}
{{!</div>}}
<input type="submit" class="submit" value="Add post">
<a href="#" class="new-post-cancel">Cancel</a>
<div class="options">
<input type="checkbox" name="follow" class="discussion-follow" class="discussion-follow" id="new-post-follow" checked><label for="new-post-follow">follow this post</label>
<br>
{{#allow_anonymous}}
<input type="checkbox" name="anonymous" class="discussion-anonymous" id="new-post-anonymous"><label for="new-post-anonymous">post anonymously</label>
{{/allow_anonymous}}
{{#allow_anonymous_to_peers}}
<input type="checkbox" name="anonymous" class="discussion-anonymous-to-peers" id="new-post-anonymous-to-peers"><label for="new-post-anonymous-to-peers">post anonymously to classmates</label>
{{/allow_anonymous_to_peers}}
<div class="form-group-label choose-cohort">
Make visible to:
<select class="group-filter-select new-post-group" name = "group_id">
{{#cohorts}}
<option value="{{id}}">{{name}}</option>
{{/cohorts}}
</select>
</div>
</div>
</form>
</div>
</article>
<section class="threads">
{{#threads}}
<article class="discussion-thread" id="thread_{{id}}">
</article>
{{/threads}}
</section>
<section class="pagination">
</section>
</section>
<article class="discussion-article" data-id="{{id}}"> <article class="discussion-article" data-id="{{id}}">
<div class="thread-content-wrapper"></div> <div class="thread-content-wrapper"></div>
<ol class="responses post-extended-content"> <ol class="responses post-extended-content">
......
<article class="discussion-article" data-id="{{id}}">
<div class="group-visibility-label">{{group_string}}</div>
<div class="thread-content-wrapper"></div>
<ol class="responses post-extended-content">
<li class="loading"><div class="loading-animation"></div></li>
</ol>
<form class="local discussion-reply-new post-extended-content" data-id="{{id}}">
<h4>Post a response:</h4>
<ul class="discussion-errors"></ul>
<div class="reply-body" data-id="{{id}}"></div>
<div class="reply-post-control">
<a class="discussion-submit-post control-button" href="#">Submit</a>
</div>
</form>
<div class="local post-tools">
<a href="javascript:void(0)" class="expand-post">View discussion</a>
<a href="javascript:void(0)" class="collapse-post">Hide discussion</a>
</div>
</article>
\ No newline at end of file
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