diff --git a/lms/djangoapps/reviews/__init__.py b/lms/djangoapps/reviews/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/reviews/apps.py b/lms/djangoapps/reviews/apps.py new file mode 100644 index 000000000000..23f99511de23 --- /dev/null +++ b/lms/djangoapps/reviews/apps.py @@ -0,0 +1,17 @@ +from django.apps import AppConfig +from edx_django_utils.plugins import PluginSettings, PluginURLs +from openedx.core.constants import COURSE_ID_PATTERN +from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType + + +class ReviewsConfig(AppConfig): + name = 'lms.djangoapps.reviews' + plugin_app = { + PluginURLs.CONFIG: { + ProjectType.LMS: { + PluginURLs.NAMESPACE: u'', + PluginURLs.REGEX: u'^courses/{}/reviews/'.format(COURSE_ID_PATTERN), + PluginURLs.RELATIVE_PATH: u'urls', + } + } + } diff --git a/lms/djangoapps/reviews/migrations/__init__.py b/lms/djangoapps/reviews/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/reviews/plugins.py b/lms/djangoapps/reviews/plugins.py new file mode 100644 index 000000000000..62389e7ac731 --- /dev/null +++ b/lms/djangoapps/reviews/plugins.py @@ -0,0 +1,37 @@ +""" +Definition of the course review feature. +""" + +from django.utils.translation import ugettext_noop +from lms.djangoapps.courseware.tabs import EnrolledTab +from xmodule.tabs import TabFragmentViewMixin +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +class ReviewsTab(TabFragmentViewMixin, EnrolledTab): + """ + The representation of the course reviews view type. + """ + name = "reviews" + tab_id = "reviews" + + type = "reviews" + title = ugettext_noop("Reviews") + body_class = "reviews-tab" + is_hideable = False + is_default = True + fragment_view_name = 'lms.djangoapps.reviews.views.ReviewsTabFragmentView' + + @classmethod + def is_enabled(cls, course, user=None): + """Returns true if the reviews feature is enabled in the course. + + Args: + course (CourseDescriptor): the course using the feature + user (User): the user interacting with the course + """ + if not super(ReviewsTab, cls).is_enabled(course, user=user): + return False + + course = CourseOverview.get_from_id(course.id) + return course.allow_review diff --git a/lms/djangoapps/reviews/static/reviews/css/delete.gif b/lms/djangoapps/reviews/static/reviews/css/delete.gif new file mode 100644 index 000000000000..43c6ca8763d7 Binary files /dev/null and b/lms/djangoapps/reviews/static/reviews/css/delete.gif differ diff --git a/lms/djangoapps/reviews/static/reviews/css/rateit.css b/lms/djangoapps/reviews/static/reviews/css/rateit.css new file mode 100644 index 000000000000..48c8cf9feb5d --- /dev/null +++ b/lms/djangoapps/reviews/static/reviews/css/rateit.css @@ -0,0 +1,148 @@ +.rateit { + position: relative; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -o-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-touch-callout: none; + display: flex; + align-items: center; + margin-bottom: 15px; +} + + .rateit .rateit-range { + position: relative; + display: -moz-inline-box; + display: inline-block; + background: url(star.gif); + height: 16px; + outline: none; + } + + .rateit .rateit-range * { + display: block; + } + + /* for IE 6 */ + * html .rateit, * html .rateit .rateit-range { + display: inline; + } + + /* for IE 7 */ + * + html .rateit, * + html .rateit .rateit-range { + display: inline; + } + + .rateit .rateit-hover, .rateit .rateit-selected { + position: absolute; + left: 0; + top: 0; + width: 0; + } + + .rateit .rateit-hover-rtl, .rateit .rateit-selected-rtl { + left: auto; + right: 0; + } + + .rateit .rateit-hover { + background: url(star.gif) left -32px; + color: rgb(239, 197, 41); + } + + .rateit .rateit-hover-rtl { + background-position: right -32px; + } + + .rateit .rateit-selected { + background: url(star.gif) left -16px; + color: rgb(191,66,66); + } + + .rateit .rateit-selected-rtl { + background-position: right -16px; + } + + .rateit .rateit-preset { + background: url(star.gif) left -48px; + color: orange; + } + + .rateit .rateit-preset-rtl { + background: url(star.gif) right -48px; + } + + .rateit button.rateit-reset { + background: url(delete.gif) 0 0; + width: 16px; + height: 16px; + display: -moz-inline-box; + display: inline-block; + float: left; + outline: none; + border: none; + padding: 0; + margin-right: 10px; + } + + .rateit .rateit-reset span { + display: none; + } + + .rateit button.rateit-reset:hover, .rateit button.rateit-reset:focus { + background-position: 0 -16px; + } + + +.rateit-font { + font-size: 24px; + line-height: 1em; +} + + .rateit-font .rateit-range { + background: none; + height: auto; + } + + .rateit-font .rateit-empty { + color: #ccc; + } + + .rateit-font .rateit-range > div, .rateit-font .rateit-range > span { + background: none; + overflow: hidden; + cursor: default; + } + +.rateit.rateit-font .rateit-reset { + font-size: inherit; + background: none; + width: 16px; + height: 16px; + margin-top: 0.2em; + background: gray; + border-radius: 50%; + position: relative; +} + + .rateit.rateit-font .rateit-reset span { + display: block; + font-weight: bold; + color: #fff; + height: 2px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + background: #fff; + width: 70%; + } + + +.rateit.rateit-font .rateit-reset:hover, .rateit.rateit-font button.rateit-reset:focus { + background: #e6574b; /* Old browsers */ + background: radial-gradient(ellipse at center, #e6574b 55%,#f6836b 77%,#f9d3cc 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ + background-position: 0 0; +} diff --git a/lms/djangoapps/reviews/static/reviews/css/star.gif b/lms/djangoapps/reviews/static/reviews/css/star.gif new file mode 100644 index 000000000000..cd147295cadb Binary files /dev/null and b/lms/djangoapps/reviews/static/reviews/css/star.gif differ diff --git a/lms/djangoapps/reviews/static/reviews/js/jquery.rateit.min.js b/lms/djangoapps/reviews/static/reviews/js/jquery.rateit.min.js new file mode 100644 index 000000000000..63a4c7d24e93 --- /dev/null +++ b/lms/djangoapps/reviews/static/reviews/js/jquery.rateit.min.js @@ -0,0 +1,5 @@ +/*! RateIt | v1.1.5 / 03/10/2021 + https://github.com/gjunge/rateit.js | Twitter: @gjunge +*/ +!function(M){function I(e){var t=e.originalEvent.changedTouches[0],a="";switch(e.type){case"touchmove":a="mousemove";break;case"touchend":a="mouseup";break;default:return}var i=document.createEvent("MouseEvent");i.initMouseEvent(a,!0,!0,window,1,t.screenX,t.screenY,t.clientX,t.clientY,!1,!1,!1,!1,0,null),t.target.dispatchEvent(i),e.preventDefault()}M.rateit={aria:{resetLabel:"reset rating",ratingLabel:"rating"}},M.fn.rateit=function(w,N){var y=1,C={},k="init",E=function(e){return e.charAt(0).toUpperCase()+e.substr(1)};if(0===this.length)return this;var e=typeof w;if("object"==e||null==w)C=M.extend({},M.fn.rateit.defaults,w);else{if("string"==e&&"reset"!==w&&void 0===N)return this.data("rateit"+E(w));"string"==e&&(k="setvalue")}return this.each(function(){var r=M(this),n=function(e,t){if(null!=t){var a="aria-value"+("value"==e?"now":e),i=r.find(".rateit-range");null!=i.attr(a)&&i.attr(a,t)}return e="rateit"+E(e),r.data.apply(r,arguments)};if("reset"==w){var e=n("init");for(var t in e)r.data(t,e[t]);if(n("backingfld"))"SELECT"==(a=M(n("backingfld")))[0].nodeName&&"index"===a[0].getAttribute("data-rateit-valuesrc")?a.prop("selectedIndex",n("value")):a.val(n("value")),a.trigger("change"),a[0].min&&(a[0].min=n("min")),a[0].max&&(a[0].max=n("max")),a[0].step&&(a[0].step=n("step"));r.trigger("reset")}r.hasClass("rateit")||r.addClass("rateit");var i="rtl"!=r.css("direction");if("setvalue"==k){if(!n("init"))throw"Can't set value before init";if("readonly"!=w||1!=N||n("readonly")||(r.find(".rateit-range").off(),n("wired",!1)),"value"==w&&(N=null==N?n("min"):Math.max(n("min"),Math.min(n("max"),N))),n("backingfld"))"SELECT"==(a=M(n("backingfld")))[0].nodeName&&"index"===a[0].getAttribute("data-rateit-valuesrc")?"value"==w&&a.prop("selectedIndex",N):"value"==w&&a.val(N),"min"==w&&a[0].min&&(a[0].min=N),"max"==w&&a[0].max&&(a[0].max=N),"step"==w&&a[0].step&&(a[0].step=N);n(w,N)}if(!n("init")){var a;if(n("mode",n("mode")||C.mode),n("icon",n("icon")||C.icon),n("min",isNaN(n("min"))?C.min:n("min")),n("max",isNaN(n("max"))?C.max:n("max")),n("step",n("step")||C.step),n("readonly",void 0!==n("readonly")?n("readonly"):C.readonly),n("resetable",void 0!==n("resetable")?n("resetable"):C.resetable),n("backingfld",n("backingfld")||C.backingfld),n("starwidth",n("starwidth")||C.starwidth),n("starheight",n("starheight")||C.starheight),n("value",Math.max(n("min"),Math.min(n("max"),isNaN(n("value"))?isNaN(C.value)?C.min:C.value:n("value")))),n("ispreset",void 0!==n("ispreset")?n("ispreset"):C.ispreset),n("backingfld"))if(((a=M(n("backingfld")).hide()).attr("disabled")||a.attr("readonly"))&&n("readonly",!0),"INPUT"==a[0].nodeName&&("range"!=a[0].type&&"text"!=a[0].type||(n("min",parseInt(a.attr("min"))||n("min")),n("max",parseInt(a.attr("max"))||n("max")),n("step",parseInt(a.attr("step"))||n("step")))),"SELECT"==a[0].nodeName&&1<{{element}} id="rateit-range-{{index}}" class="rateit-range"'+(1==n("readonly")?"":' tabindex="0"')+' role="slider" aria-label="'+M.rateit.aria.ratingLabel+'" aria-owns="rateit-reset-{{index}}" aria-valuemin="'+n("min")+'" aria-valuemax="'+n("max")+'" aria-valuenow="'+n("value")+'"><{{element}} class="rateit-empty"><{{element}} class="rateit-selected"><{{element}} class="rateit-hover">';r.append(l.replace(/{{index}}/gi,y).replace(/{{element}}/gi,d)),i||(r.find(".rateit-reset").css("float","right"),r.find(".rateit-selected").addClass("rateit-selected-rtl"),r.find(".rateit-hover").addClass("rateit-hover-rtl")),"font"==n("mode")?r.addClass("rateit-font").removeClass("rateit-bg"):r.addClass("rateit-bg").removeClass("rateit-font"),n("init",JSON.parse(JSON.stringify(r.data())))}var o="font"==n("mode");o||r.find(".rateit-selected, .rateit-hover").height(n("starheight"));var u=r.find(".rateit-range");if(o){for(var m=n("icon"),v=n("max")-n("min"),h="",c=0;c *").text(h),n("starwidth",u.width()/(n("max")-n("min")))}else u.width(n("starwidth")*(n("max")-n("min"))).height(n("starheight"));var g="rateit-preset"+(i?"":"-rtl");if(n("ispreset")?r.find(".rateit-selected").addClass(g):r.find(".rateit-selected").removeClass(g),null!=n("value")){var f=(n("value")-n("min"))*n("starwidth");r.find(".rateit-selected").width(f)}var p=r.find(".rateit-reset");!0!==p.data("wired")&&p.on("click",function(e){e.preventDefault(),p.trigger("blur");var t=M.Event("beforereset");if(r.trigger(t),t.isDefaultPrevented())return!1;r.rateit("value",null),r.trigger("reset")}).data("wired",!0);var b=function(e,t){var a=(t.changedTouches?t.changedTouches[0].pageX:t.pageX)-M(e).offset().left;return i||(a=u.width()-a),a>u.width()&&(a=u.width()),a<0&&(a=0),f=Math.ceil(a/n("starwidth")*(1/n("step")))},x=function(e){var t=M.Event("beforerated");return r.trigger(t,[e]),!t.isDefaultPrevented()&&(n("value",e),n("backingfld")&&("SELECT"==a[0].nodeName&&"index"===a[0].getAttribute("data-rateit-valuesrc")?M(n("backingfld")).prop("selectedIndex",e).trigger("change"):M(n("backingfld")).val(e).trigger("change")),n("ispreset")&&(u.find(".rateit-selected").removeClass(g),n("ispreset",!1)),u.find(".rateit-hover").hide(),u.find(".rateit-selected").width(e*n("starwidth")-n("min")*n("starwidth")).show(),r.trigger("hover",[null]).trigger("over",[null]).trigger("rated",[e]),!0)};n("readonly")?p.hide():(n("resetable")||p.hide(),n("wired")||(u.on("touchmove touchend",I),u.on("mousemove",function(e){!function(e){var t=e*n("starwidth")*n("step"),a=u.find(".rateit-hover");if(a.data("width")!=t){u.find(".rateit-selected").hide(),a.width(t).show().data("width",t);var i=[e*n("step")+n("min")];r.trigger("hover",i).trigger("over",i)}}(b(this,e))}),u.on("mouseleave",function(e){u.find(".rateit-hover").hide().width(0).data("width",""),r.trigger("hover",[null]).trigger("over",[null]),u.find(".rateit-selected").show()}),u.on("mouseup",function(e){var t=b(this,e)*n("step")+n("min");x(t),u.trigger("blur")}),u.on("keyup",function(e){38!=e.which&&e.which!=(i?39:37)||x(Math.min(n("value")+n("step"),n("max"))),40!=e.which&&e.which!=(i?37:39)||x(Math.max(n("value")-n("step"),n("min")))}),n("wired",!0)),n("resetable")&&p.show()),u.attr("aria-readonly",n("readonly"))})},M.fn.rateit.defaults={min:0,max:5,step:.5,mode:"bg",icon:"★",starwidth:16,starheight:16,readonly:!1,resetable:!0,ispreset:!1},M(function(){M("div.rateit, span.rateit").rateit()})}(jQuery); +//# sourceMappingURL=jquery.rateit.min.js.map diff --git a/lms/djangoapps/reviews/static/reviews/js/reviews.js b/lms/djangoapps/reviews/static/reviews/js/reviews.js new file mode 100644 index 000000000000..0c49049cc90f --- /dev/null +++ b/lms/djangoapps/reviews/static/reviews/js/reviews.js @@ -0,0 +1,21 @@ + +(function ($) { + $(document).ready(function () { + $('#submitRating').on("click", function() { + let rating = $('#rateit-range-3').attr('aria-valuenow'); + let review = $('#feedbackTextarea').val(); + let url = $('#submitRating').data('url'); + $.ajax({ + type: "POST", + url: url, + data: { + "rating": rating, + "review": review, + }, + success: function (){ + location.reload(); + } + }); + }) + }); +}(jQuery)); diff --git a/lms/djangoapps/reviews/templates/reviews/reviews.html b/lms/djangoapps/reviews/templates/reviews/reviews.html new file mode 100644 index 000000000000..4554a3ceba3e --- /dev/null +++ b/lms/djangoapps/reviews/templates/reviews/reviews.html @@ -0,0 +1,280 @@ +## mako + +<%page expression_filter="h"/> + +<%! +import arrow +import json +from django.utils.translation import ugettext as _ + +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string +from openedx.core.djangolib.markup import HTML + +%> + + +
+
+
+ + ${_("Student Feedback")} + +
+
+
+

${average_rating}

+
+
${_("Overall Course Rating")}
+
+
+
+
+ % for key, value in reversed(rating_percent_dict.items()): +
+
+
+
+
+
+
+ % for point in range(5): + % if point >= key: + + % else: + + % endif + % endfor + ${value}% +
+
+ % endfor +
+
+
+ % if not is_commented: +

${_("Leave your review")}

+
+
${user.username}
+
+ ${_("Rate your experience: ")} +
+
+ +
+
+
+
+ +
+ +
+
+ % endif +

${_("Reviews")}

+ % for review in reviews: +
+
+ +
+
+
${review.user_id.username}
+
+
+ % for point in range(5): + % if point >= review.rating: + + % else: + + % endif + % endfor +
+
${arrow.get(review.created_at).humanize()}
+
+
${review.review}
+
+
+
+ % endfor +
+
+
diff --git a/lms/djangoapps/reviews/urls.py b/lms/djangoapps/reviews/urls.py new file mode 100644 index 000000000000..ab3d3f5a54dd --- /dev/null +++ b/lms/djangoapps/reviews/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls import url +from .views import create_review + +urlpatterns = [ + url(r"^create_review/$", create_review, name='create_review'), +] diff --git a/lms/djangoapps/reviews/views.py b/lms/djangoapps/reviews/views.py new file mode 100644 index 000000000000..700802db2798 --- /dev/null +++ b/lms/djangoapps/reviews/views.py @@ -0,0 +1,100 @@ +from django.db.models import Avg +from django.db import IntegrityError +from django.template.loader import render_to_string +from web_fragments.views import FragmentView +from web_fragments.fragment import Fragment +from opaque_keys.edx.keys import CourseKey +from lms.djangoapps.courseware.courses import get_course_with_access, get_course_overview_with_access +from common.djangoapps.feedback.models import CourseReview +from collections import OrderedDict +from django.contrib.staticfiles.storage import staticfiles_storage +from django.http import JsonResponse +from django.urls import reverse +from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_http_methods + + +class ReviewsTabFragmentView(FragmentView): + """ + Fragment view implementation of the reviews tab. + """ + def render_to_fragment(self, request, course_id=None, **kwargs): + """ + Render the reviews tab to a fragment. + Args: + request: The Django request. + course_id: The id of the course. + Returns: + Fragment: The fragment representing the reviews tab. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, "load", course_key) + reviews = CourseReview.objects.filter(course_id=course_id) + average_rating = round(reviews.aggregate(Avg('rating'))['rating__avg'] or 0.00, 2) + is_commented = CourseReview.objects.filter(course_id=course_id, user_id=request.user).exists() + num_reviews = reviews.count() + rating_percent_dict = OrderedDict() + + for index in range(1, 6): + rating_type_value = reviews.filter(rating=index).count() + + try: + percent = round(rating_type_value * 100 / num_reviews, 2) + except ZeroDivisionError: + percent = 0 + + rating_percent_dict.update({index: percent}) + + context = { + 'course': course, + 'user': request.user, + 'reviews': reviews, + 'average_rating': average_rating, + 'create_review_url': reverse("create_review", kwargs={"course_id": course_id}), + 'is_commented': is_commented, + 'rating_percent_dict': rating_percent_dict + } + html = render_to_string('reviews/reviews.html', context) + fragment = Fragment(html) + fragment.add_javascript_url(staticfiles_storage.url('reviews/js/reviews.js')) + fragment.add_javascript_url(staticfiles_storage.url('reviews/js/jquery.rateit.min.js')) + fragment.add_css_url(staticfiles_storage.url('reviews/css/rateit.css')) + fragment.add_css_url(staticfiles_storage.url('reviews/css/star.gif')) + fragment.add_css_url(staticfiles_storage.url('reviews/css/delete.gif')) + + return fragment + + +@login_required +@require_http_methods(['POST']) +def create_review(request, course_id): + rating = request.POST.get('rating') + review = request.POST.get('review') + course_key = CourseKey.from_string(course_id) + course = get_course_overview_with_access(request.user, "load", course_key) + + try: + rating = float(rating) + except (TypeError, ValueError): + return JsonResponse({'message': 'Set the rating'}, status=400) + + if not rating: + return JsonResponse({'message': 'Set the rating'}, status=400) + + if not review: + return JsonResponse({'message': 'Give a review'}, status=400) + + try: + CourseReview.objects.create( + user_id=request.user, + course_id=course, + rating=rating, + review=review + ) + except IntegrityError: + return JsonResponse({'message': 'Review is already existed'}, status=400) + + return JsonResponse({'message': 'Success'}, status=200) + + + diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index 30008502eb64..c2a58b60a127 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -93,7 +93,7 @@ def __init__(self, org, course_id, run): self.self_paced = None self.learning_info = [] self.instructor_info = [] - self.allow_review = True + self.allow_review = "true" @classmethod def fetch_about_attribute(cls, course_key, attribute): diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 34578a480702..0d3ac3ed96c8 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -250,6 +250,7 @@ xss-utils==0.1.3 # via -r requirements/edx/base.in zipp==1.0.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/paver.txt, importlib-metadata django-ckeditor==6.0.0 django-fs-trumbowyg==0.1.4 +arrow==1.0.3 # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 03bbaf067a22..01723a493a18 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -322,6 +322,7 @@ xblock==1.4.0 # via -r requirements/edx/testing.txt, acid-xblock, cr xmlsec==1.3.9 # via -r requirements/edx/testing.txt, python3-saml xss-utils==0.1.3 # via -r requirements/edx/testing.txt zipp==1.0.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, importlib-metadata, importlib-resources +arrow==1.0.3 # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/setup.py b/setup.py index f21177e1b04d..991051f9f63f 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ "teams = lms.djangoapps.teams.plugins:TeamsTab", "textbooks = lms.djangoapps.courseware.tabs:TextbookTabs", "wiki = lms.djangoapps.course_wiki.tab:WikiTab", + "reviews = lms.djangoapps.reviews.plugins:ReviewsTab", ], "openedx.course_tool": [ "calendar_sync_toggle = openedx.features.calendar_sync.plugins:CalendarSyncToggleTool", @@ -91,6 +92,7 @@ "user_authn = openedx.core.djangoapps.user_authn.apps:UserAuthnConfig", "program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig", "courseware_api = openedx.core.djangoapps.courseware_api.apps:CoursewareAPIConfig", + "reviews = lms.djangoapps.reviews.apps:ReviewsConfig", ], "cms.djangoapp": [ "announcements = openedx.features.announcements.apps:AnnouncementsConfig",