From 182ae7aeb04b92ba0fcb25480a008aab87b4d821 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 21 Jul 2014 15:54:32 -0400 Subject: [PATCH] Add endorsement info to marked answers in forum Co-authored-by: jsa --- .../thread_response_show_view_spec.coffee | 67 ++++++++++++++- .../views/thread_response_show_view.coffee | 2 +- .../django_comment_client/base/views.py | 85 +++++++++++-------- .../django_comment_client/forum/views.py | 12 +-- lms/djangoapps/django_comment_client/utils.py | 34 +++++++- lms/lib/comment_client/comment.py | 4 +- lms/lib/comment_client/models.py | 4 +- lms/static/sass/discussion/_discussion.scss | 3 +- .../discussion/_underscore_templates.html | 23 ++++- 9 files changed, 178 insertions(+), 56 deletions(-) diff --git a/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee b/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee index 21b0c300f0..c261e085f4 100644 --- a/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee @@ -3,14 +3,38 @@ describe "ThreadResponseShowView", -> DiscussionSpecHelper.setUpGlobals() setFixtures( """ -
- - 0 votes (click to vote) + + +
""" ) + @thread = new Thread({"thread_type": "discussion"}) @commentData = { id: "dummy", user_id: "567", @@ -21,9 +45,15 @@ describe "ThreadResponseShowView", -> votes: {up_count: "42"} } @comment = new Comment(@commentData) + @comment.set("thread", @thread) @view = new ThreadResponseShowView({ model: @comment }) @view.setElement($(".discussion-post")) + # Avoid unnecessary boilerplate + spyOn(ThreadResponseShowView.prototype, "convertMath") + + @view.render() + it "renders the vote correctly", -> DiscussionViewSpecHelper.checkRenderVote(@view, @comment) @@ -38,3 +68,32 @@ describe "ThreadResponseShowView", -> it "vote button activates on appropriate events", -> DiscussionViewSpecHelper.checkVoteButtonEvents(@view) + + it "renders endorsement correctly for a marked answer in a question thread", -> + endorsement = { + "username": "test_endorser", + "time": new Date().toISOString() + } + @thread.set("thread_type", "question") + @comment.set({ + "endorsed": true, + "endorsement": endorsement + }) + @view.render() + expect(@view.$(".posted-details").text()).toMatch( + "marked as answer less than a minute ago by " + endorsement.username + ) + + it "renders anonymous endorsement correctly for a marked answer in a question thread", -> + endorsement = { + "username": null, + "time": new Date().toISOString() + } + @thread.set("thread_type", "question") + @comment.set({ + "endorsed": true, + "endorsement": endorsement + }) + @view.render() + expect(@view.$(".posted-details").text()).toMatch("marked as answer less than a minute ago") + expect(@view.$(".posted-details").text()).not.toMatch(" by ") diff --git a/common/static/coffee/src/discussion/views/thread_response_show_view.coffee b/common/static/coffee/src/discussion/views/thread_response_show_view.coffee index 312b660ff6..66e3923153 100644 --- a/common/static/coffee/src/discussion/views/thread_response_show_view.coffee +++ b/common/static/coffee/src/discussion/views/thread_response_show_view.coffee @@ -29,7 +29,7 @@ if Backbone? @renderVote() @renderAttrs() @renderFlagged() - @$el.find(".posted-details").timeago() + @$el.find(".posted-details .timeago").timeago() @convertMath() @markAsStaff() @ diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index c1e15b6392..3f1f40b577 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -53,11 +53,11 @@ def permitted(fn): return wrapper -def ajax_content_response(request, course_id, content): +def ajax_content_response(request, course_key, content): user_info = cc.User.from_django_user(request.user).to_dict() - annotated_content_info = get_annotated_content_info(course_id, content, request.user, user_info) + annotated_content_info = get_annotated_content_info(course_key, content, request.user, user_info) return JsonResponse({ - 'content': safe_content(content), + 'content': safe_content(content, course_key), 'annotated_content_info': annotated_content_info, }) @@ -71,8 +71,8 @@ def create_thread(request, course_id, commentable_id): """ log.debug("Creating new thread in %r, id %r", course_id, commentable_id) - course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) - course = get_course_with_access(request.user, 'load', course_id) + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course = get_course_with_access(request.user, 'load', course_key) post = request.POST if course.allow_anonymous: @@ -94,7 +94,7 @@ def create_thread(request, course_id, commentable_id): anonymous=anonymous, anonymous_to_peers=anonymous_to_peers, commentable_id=commentable_id, - course_id=course_id.to_deprecated_string(), + course_id=course_key.to_deprecated_string(), user_id=request.user.id, body=post["body"], title=post["title"] @@ -107,13 +107,13 @@ def create_thread(request, course_id, commentable_id): #not anymore, only for admins # Cohort the thread if the commentable is cohorted. - if is_commentable_cohorted(course_id, commentable_id): - user_group_id = get_cohort_id(user, course_id) + if is_commentable_cohorted(course_key, commentable_id): + user_group_id = get_cohort_id(user, course_key) # 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 # 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_key): # admins can optionally choose what group to post as group_id = post.get('group_id', user_group_id) else: @@ -135,9 +135,9 @@ def create_thread(request, course_id, commentable_id): data = thread.to_dict() add_courseware_context([data], course) if request.is_ajax(): - return ajax_content_response(request, course_id, data) + return ajax_content_response(request, course_key, data) else: - return JsonResponse(safe_content(data)) + return JsonResponse(safe_content(data, course_key)) @require_POST @@ -151,19 +151,20 @@ def update_thread(request, course_id, thread_id): return JsonError(_("Title can't be empty")) if 'body' not in request.POST or not request.POST['body'].strip(): return JsonError(_("Body can't be empty")) + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) thread = cc.Thread.find(thread_id) thread.body = request.POST["body"] thread.title = request.POST["title"] thread.save() if request.is_ajax(): - return ajax_content_response(request, SlashSeparatedCourseKey.from_deprecated_string(course_id), thread.to_dict()) + return ajax_content_response(request, course_key, thread.to_dict()) else: - return JsonResponse(safe_content(thread.to_dict())) + return JsonResponse(safe_content(thread.to_dict(), course_key)) def _create_comment(request, course_key, thread_id=None, parent_id=None): """ - given a course_id, thread_id, and parent_id, create a comment, + given a course_key, thread_id, and parent_id, create a comment, called from create_comment to do the actual creation """ assert isinstance(course_key, CourseKey) @@ -199,7 +200,7 @@ def _create_comment(request, course_key, thread_id=None, parent_id=None): if request.is_ajax(): return ajax_content_response(request, course_key, comment.to_dict()) else: - return JsonResponse(safe_content(comment.to_dict())) + return JsonResponse(safe_content(comment.to_dict(), course.id)) @require_POST @@ -224,9 +225,10 @@ def delete_thread(request, course_id, thread_id): given a course_id and thread_id, delete this thread this is ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) thread = cc.Thread.find(thread_id) thread.delete() - return JsonResponse(safe_content(thread.to_dict())) + return JsonResponse(safe_content(thread.to_dict(), course_key)) @require_POST @@ -237,15 +239,16 @@ def update_comment(request, course_id, comment_id): given a course_id and comment_id, update the comment with payload attributes handles static and ajax submissions """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) comment = cc.Comment.find(comment_id) if 'body' not in request.POST or not request.POST['body'].strip(): return JsonError(_("Body can't be empty")) comment.body = request.POST["body"] comment.save() if request.is_ajax(): - return ajax_content_response(request, SlashSeparatedCourseKey.from_deprecated_string(course_id), comment.to_dict()) + return ajax_content_response(request, course_key, comment.to_dict()) else: - return JsonResponse(safe_content(comment.to_dict())) + return JsonResponse(safe_content(comment.to_dict(), course_key)) @require_POST @@ -256,10 +259,12 @@ def endorse_comment(request, course_id, comment_id): given a course_id and comment_id, toggle the endorsement of this comment, ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) comment = cc.Comment.find(comment_id) comment.endorsed = request.POST.get('endorsed', 'false').lower() == 'true' + comment.endorsement_user_id = request.user.id comment.save() - return JsonResponse(safe_content(comment.to_dict())) + return JsonResponse(safe_content(comment.to_dict(), course_key)) @require_POST @@ -270,13 +275,14 @@ def openclose_thread(request, course_id, thread_id): given a course_id and thread_id, toggle the status of this thread ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) thread = cc.Thread.find(thread_id) thread.closed = request.POST.get('closed', 'false').lower() == 'true' thread.save() thread = thread.to_dict() return JsonResponse({ - 'content': safe_content(thread), - 'ability': get_ability(SlashSeparatedCourseKey.from_deprecated_string(course_id), thread, request.user), + 'content': safe_content(thread, course_key), + 'ability': get_ability(course_key, thread, request.user), }) @@ -302,9 +308,10 @@ def delete_comment(request, course_id, comment_id): given a course_id and comment_id delete this comment ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) comment = cc.Comment.find(comment_id) comment.delete() - return JsonResponse(safe_content(comment.to_dict())) + return JsonResponse(safe_content(comment.to_dict(), course_key)) @require_POST @@ -314,10 +321,11 @@ def vote_for_comment(request, course_id, comment_id, value): """ given a course_id and comment_id, """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) user = cc.User.from_django_user(request.user) comment = cc.Comment.find(comment_id) user.vote(comment, value) - return JsonResponse(safe_content(comment.to_dict())) + return JsonResponse(safe_content(comment.to_dict(), course_key)) @require_POST @@ -328,10 +336,11 @@ def undo_vote_for_comment(request, course_id, comment_id): given a course id and comment id, remove vote ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) user = cc.User.from_django_user(request.user) comment = cc.Comment.find(comment_id) user.unvote(comment) - return JsonResponse(safe_content(comment.to_dict())) + return JsonResponse(safe_content(comment.to_dict(), course_key)) @require_POST @@ -342,10 +351,11 @@ def vote_for_thread(request, course_id, thread_id, value): given a course id and thread id vote for this thread ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) user.vote(thread, value) - return JsonResponse(safe_content(thread.to_dict())) + return JsonResponse(safe_content(thread.to_dict(), course_key)) @require_POST @@ -356,10 +366,11 @@ def flag_abuse_for_thread(request, course_id, thread_id): given a course_id and thread_id flag this thread for abuse ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) thread.flagAbuse(user, thread) - return JsonResponse(safe_content(thread.to_dict())) + return JsonResponse(safe_content(thread.to_dict(), course_key)) @require_POST @@ -371,12 +382,12 @@ def un_flag_abuse_for_thread(request, course_id, thread_id): ajax only """ user = cc.User.from_django_user(request.user) - course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) - course = get_course_by_id(course_id) + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course = get_course_by_id(course_key) thread = cc.Thread.find(thread_id) - remove_all = cached_has_permission(request.user, 'openclose_thread', course_id) or has_access(request.user, 'staff', course) + remove_all = cached_has_permission(request.user, 'openclose_thread', course_key) or has_access(request.user, 'staff', course) thread.unFlagAbuse(user, thread, remove_all) - return JsonResponse(safe_content(thread.to_dict())) + return JsonResponse(safe_content(thread.to_dict(), course_key)) @require_POST @@ -387,10 +398,11 @@ def flag_abuse_for_comment(request, course_id, comment_id): given a course and comment id, flag comment for abuse ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) user = cc.User.from_django_user(request.user) comment = cc.Comment.find(comment_id) comment.flagAbuse(user, comment) - return JsonResponse(safe_content(comment.to_dict())) + return JsonResponse(safe_content(comment.to_dict(), course_key)) @require_POST @@ -407,7 +419,7 @@ def un_flag_abuse_for_comment(request, course_id, comment_id): remove_all = cached_has_permission(request.user, 'openclose_thread', course_key) or has_access(request.user, 'staff', course) comment = cc.Comment.find(comment_id) comment.unFlagAbuse(user, comment, remove_all) - return JsonResponse(safe_content(comment.to_dict())) + return JsonResponse(safe_content(comment.to_dict(), course_key)) @require_POST @@ -418,10 +430,11 @@ def undo_vote_for_thread(request, course_id, thread_id): given a course id and thread id, remove users vote for thread ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) user.unvote(thread) - return JsonResponse(safe_content(thread.to_dict())) + return JsonResponse(safe_content(thread.to_dict(), course_key)) @require_POST @@ -432,10 +445,11 @@ def pin_thread(request, course_id, thread_id): given a course id and thread id, pin this thread ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) thread.pin(user, thread_id) - return JsonResponse(safe_content(thread.to_dict())) + return JsonResponse(safe_content(thread.to_dict(), course_key)) @require_POST @@ -446,10 +460,11 @@ def un_pin_thread(request, course_id, thread_id): given a course id and thread id, remove pin from this thread ajax only """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) thread.un_pin(user, thread_id) - return JsonResponse(safe_content(thread.to_dict())) + return JsonResponse(safe_content(thread.to_dict(), course_key)) @require_POST diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index fd3c84feb1..8cef0b6ea8 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -150,7 +150,7 @@ def inline_discussion(request, course_id, discussion_id): annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info) is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) return utils.JsonResponse({ - 'discussion_data': [utils.safe_content(thread, is_staff) for thread in threads], + 'discussion_data': [utils.safe_content(thread, course_id, is_staff) for thread in threads], 'user_info': user_info, 'annotated_content_info': annotated_content_info, 'page': query_params['page'], @@ -173,7 +173,7 @@ def forum_form_discussion(request, course_id): try: unsafethreads, query_params = get_threads(request, course_id) # This might process a search query is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) - threads = [utils.safe_content(thread, is_staff) for thread in unsafethreads] + threads = [utils.safe_content(thread, course_id, is_staff) for thread in unsafethreads] except cc.utils.CommentClientMaintenanceError: log.warning("Forum is in maintenance mode") return render_to_response('discussion/maintenance.html', {}) @@ -253,7 +253,7 @@ def single_thread(request, course_id, discussion_id, thread_id): if request.is_ajax(): with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"): annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info) - content = utils.safe_content(thread.to_dict(), is_staff) + content = utils.safe_content(thread.to_dict(), course_id, is_staff) with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"): add_courseware_context([content], course) return utils.JsonResponse({ @@ -276,7 +276,7 @@ def single_thread(request, course_id, discussion_id, thread_id): if not "pinned" in thread: thread["pinned"] = False - threads = [utils.safe_content(thread, is_staff) for thread in threads] + threads = [utils.safe_content(thread, course_id, is_staff) for thread in threads] with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"): annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info) @@ -335,7 +335,7 @@ def user_profile(request, course_id, user_id): if request.is_ajax(): is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) return utils.JsonResponse({ - 'discussion_data': [utils.safe_content(thread, is_staff) for thread in threads], + 'discussion_data': [utils.safe_content(thread, course_id, is_staff) for thread in threads], 'page': query_params['page'], 'num_pages': query_params['num_pages'], 'annotated_content_info': _attr_safe_json(annotated_content_info), @@ -386,7 +386,7 @@ def followed_threads(request, course_id, user_id): is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) return utils.JsonResponse({ 'annotated_content_info': annotated_content_info, - 'discussion_data': [utils.safe_content(thread, is_staff) for thread in threads], + 'discussion_data': [utils.safe_content(thread, course_id, is_staff) for thread in threads], 'page': query_params['page'], 'num_pages': query_params['num_pages'], }) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 9f4065b42b..b31caadf25 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -9,7 +9,7 @@ from django.db import connection from django.http import HttpResponse from django.utils import simplejson from django_comment_common.models import Role, FORUM_ROLE_STUDENT -from django_comment_client.permissions import check_permissions_by_view +from django_comment_client.permissions import check_permissions_by_view, cached_has_permission from edxmako import lookup_template import pystache_custom as pystache @@ -365,7 +365,7 @@ def add_courseware_context(content_list, course): content.update({"courseware_url": url, "courseware_title": title}) -def safe_content(content, is_staff=False): +def safe_content(content, course_id, is_staff=False): fields = [ 'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers', 'endorsed', 'parent_id', 'thread_id', 'votes', 'closed', 'created_at', @@ -375,14 +375,40 @@ def safe_content(content, is_staff=False): 'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers', 'stats', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type', 'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total', + 'endorsement', ] if (content.get('anonymous') is False) and ((content.get('anonymous_to_peers') is False) or is_staff): fields += ['username', 'user_id'] + content = strip_none(extract(content, fields)) + + if content.get("endorsement"): + endorsement = content["endorsement"] + endorser = None + if endorsement["user_id"]: + try: + endorser = User.objects.get(pk=endorsement["user_id"]) + except User.DoesNotExist: + log.error("User ID {0} in endorsement for comment {1} but not in our DB.".format( + content.get('user_id'), + content.get('id')) + ) + + # Only reveal endorser if requester can see author or if endorser is staff + if ( + endorser and + ("username" in fields or cached_has_permission(endorser, "endorse_comment", course_id)) + ): + endorsement["username"] = endorser.username + else: + del endorsement["user_id"] + for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]: if child_content_key in content: - safe_children = [safe_content(child) for child in content[child_content_key]] + safe_children = [ + safe_content(child, course_id, is_staff) for child in content[child_content_key] + ] content[child_content_key] = safe_children - return strip_none(extract(content, fields)) + return content diff --git a/lms/lib/comment_client/comment.py b/lms/lib/comment_client/comment.py index 3082b1dcc8..febb533b33 100644 --- a/lms/lib/comment_client/comment.py +++ b/lms/lib/comment_client/comment.py @@ -11,12 +11,12 @@ class Comment(models.Model): 'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id', 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', - 'type', 'commentable_id', 'abuse_flaggers' + 'type', 'commentable_id', 'abuse_flaggers', 'endorsement', ] updatable_fields = [ 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed', - 'user_id', 'endorsed' + 'user_id', 'endorsed', 'endorsement_user_id', ] initializable_fields = updatable_fields diff --git a/lms/lib/comment_client/models.py b/lms/lib/comment_client/models.py index 5a9eb740f3..555fca883a 100644 --- a/lms/lib/comment_client/models.py +++ b/lms/lib/comment_client/models.py @@ -35,7 +35,7 @@ class Model(object): return self.__getattr__(name) def __setattr__(self, name, value): - if name == 'attributes' or name not in self.accessible_fields: + if name == 'attributes' or name not in (self.accessible_fields + self.updatable_fields): super(Model, self).__setattr__(name, value) else: self.attributes[name] = value @@ -46,7 +46,7 @@ class Model(object): return self.attributes.get(key) def __setitem__(self, key, value): - if key not in self.accessible_fields: + if key not in (self.accessible_fields + self.updatable_fields): raise KeyError("Field {0} does not exist".format(key)) self.attributes.__setitem__(key, value) diff --git a/lms/static/sass/discussion/_discussion.scss b/lms/static/sass/discussion/_discussion.scss index 26d2d5a087..d05e9b9674 100644 --- a/lms/static/sass/discussion/_discussion.scss +++ b/lms/static/sass/discussion/_discussion.scss @@ -441,7 +441,8 @@ body.discussion { font-weight: 700; } - span { + .timeago, .top-post-status { + color: inherit; font-style: italic; } } diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index bd090b7f74..a3aa6ab622 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -159,7 +159,28 @@ ${"<% } else { %>"} ${_('anonymous')} ${"<% } %>"} -

${'<%- created_at %>'}

+

+ ${'<%= created_at %>'} + <% + js_block = u""" + interpolate( + endorsement.username ? "{user_fmt_str}" : "{anon_fmt_str}", + {{ + 'time_ago': '' + endorsement.time + '', + 'user': endorsement.username + }}, + true + )""".format( + ## Translators: time_ago is a placeholder for a fuzzy, relative timestamp + ## like "4 hours ago" or "about a month ago" + user_fmt_str=escapejs(_("marked as answer %(time_ago)s by %(user)s")), + ## Translators: time_ago is a placeholder for a fuzzy, relative timestamp + ## like "4 hours ago" or "about a month ago" + anon_fmt_str=escapejs(_("marked as answer %(time_ago)s")), + ) + %> + ${"<% if (thread.get('thread_type') == 'question' && obj.endorsement) { %> - <%="}${js_block}${"%><% } %>"} +

${"<%- body %>"}