diff --git a/cms/envs/common.py b/cms/envs/common.py index 4f0bbefd39a6..2ece719ada53 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -177,6 +177,7 @@ # in sync with the ones in lms/envs/common.py 'ENABLE_DISCUSSION_SERVICE': True, 'ENABLE_TEXTBOOK': True, + 'IS_NOTE_TAB_ENABLED': True, # When True, all courses will be active, regardless of start date # DO NOT SET TO True IN THIS FILE @@ -273,7 +274,7 @@ 'ALLOW_COURSE_RERUNS': True, # Certificates Web/HTML Views - 'CERTIFICATES_HTML_VIEW': False, + 'CERTIFICATES_HTML_VIEW': True, # Teams feature 'ENABLE_TEAMS': True, @@ -1516,6 +1517,15 @@ 'openedx.core.djangoapps.content.learning_sequences.apps.LearningSequencesConfig', 'ratelimitbackend', + + #banner related + 'lms.djangoapps.banner.apps.BannerConfig', + + #Note + 'lms.djangoapps.note.apps.NoteApiConfig', + + # course_block_user related + 'lms.djangoapps.course_block_user.apps.CourseBlockUserConfig' ] diff --git a/cms/envs/production.py b/cms/envs/production.py index 22b20ccf52f2..45499a815aae 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -575,3 +575,4 @@ def get_env_setting(setting): LOGO_URL_PNG = ENV_TOKENS.get('LOGO_URL_PNG', LOGO_URL_PNG) LOGO_TRADEMARK_URL = ENV_TOKENS.get('LOGO_TRADEMARK_URL', LOGO_TRADEMARK_URL) FAVICON_URL = ENV_TOKENS.get('FAVICON_URL', FAVICON_URL) + diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js index 118b65faa76b..f972a1ff4003 100644 --- a/cms/static/js/views/settings/main.js +++ b/cms/static/js/views/settings/main.js @@ -93,12 +93,12 @@ define(['js/views/validation', 'codemirror', 'underscore', 'jquery', 'jquery.ui' //Disable course sale type if already exist or not indexed in discovery if (this.model.get('indexed_in_discovery') && this.model.get('course_sale_type') === null) { this.$el.find('#course-course-sale-type').prop("disabled", false) - this.$el.find('#course-course-price').prop("disabled", false) } else { this.$el.find('#course-course-sale-type').prop("disabled", true) - this.$el.find('#course-course-price').prop("disabled", false) } + //Disable course price + this.$el.find('#course-course-price').prop("disabled", true) }, render: function () { @@ -403,7 +403,7 @@ define(['js/views/validation', 'codemirror', 'underscore', 'jquery', 'jquery.ui' this.$el.find('#course-course-price').prop("disabled", true) } else { - this.$el.find('#course-course-price').prop("disabled", false) + this.$el.find('#course-course-price').prop("disabled", true) } }, updateModel: function (event) { diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index dbe9a36b5fbd..5740c82b3085 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -45,6 +45,14 @@ @python_2_unicode_compatible class CourseMode(models.Model): + + @property + def get_price_string(self): + if self.min_price > 0: + price_string = self.min_price + return "%.2f" % price_string + return "%.2f" % 0.0 + """ We would like to offer a course in a variety of modes. diff --git a/common/djangoapps/feedback/models.py b/common/djangoapps/feedback/models.py index 67b2f662f226..33af34503ea7 100644 --- a/common/djangoapps/feedback/models.py +++ b/common/djangoapps/feedback/models.py @@ -33,4 +33,8 @@ class Meta: fields=['user_id', 'course_id'], ) ] + @classmethod + def is_reviewed(cls, user=None, course_id=None): + reviews = cls.objects.filter(user_id=user, course_id=course_id) + return True if reviews else False diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index c2eb4fc43922..7ed489fd1fee 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -74,7 +74,7 @@ from common.djangoapps.util.db import outer_atomic from common.djangoapps.util.json_request import JsonResponse from xmodule.modulestore.django import modulestore - +from lms.djangoapps.banner.models import Banner log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") @@ -185,7 +185,8 @@ def filter_courses(course): # allow for theme override of the courses list context['courses_list'] = theming_helpers.get_template_path('courses_list.html') - + #fetch banner for courses to show in home page + context['banner_list'] = Banner.objects.filter(platform__in = ['WEB', 'BOTH'], enabled=True) # Insert additional context for use in the template context.update(extra_context) diff --git a/lms/djangoapps/banner/__init__.py b/lms/djangoapps/banner/__init__.py new file mode 100644 index 000000000000..1a62c892efb0 --- /dev/null +++ b/lms/djangoapps/banner/__init__.py @@ -0,0 +1 @@ +default_app_config = 'lms.djangoapps.banner.BannerConfig' diff --git a/lms/djangoapps/banner/admin.py b/lms/djangoapps/banner/admin.py new file mode 100755 index 000000000000..f82d70a263f9 --- /dev/null +++ b/lms/djangoapps/banner/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from .models import Banner + + +# Register your models here. +admin.site.register(Banner) diff --git a/lms/djangoapps/banner/api/__init__.py b/lms/djangoapps/banner/api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/banner/api/serializers.py b/lms/djangoapps/banner/api/serializers.py new file mode 100644 index 000000000000..95ab3be74f54 --- /dev/null +++ b/lms/djangoapps/banner/api/serializers.py @@ -0,0 +1,35 @@ +""" +Serializers for Banner +""" +from rest_framework import serializers +from lms.djangoapps.banner.models import Banner +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from logging import getLogger +log = getLogger(__name__) + +class BannerSerializer(serializers.ModelSerializer): + """ + Serializer for BadgeClass model. + """ + course_over_view = serializers.SlugRelatedField( + read_only=True, + slug_field='display_name' + ) + course_id = serializers.SerializerMethodField('course_id_') + banner_img_url = serializers.SerializerMethodField('banner_img_url_') + + def course_id_(self, obj): + if obj.course_over_view.display_name and obj.course_over_view.display_number_with_default: + return str(CourseOverview.objects.filter(display_name = obj.course_over_view.display_name, display_number_with_default = obj.course_over_view.display_number_with_default).values_list('id', flat=True)[0]) + else: + return None + + def banner_img_url_(self, obj): + if obj: + return str(obj.banner_img_url_txt) + else: + return None + + class Meta(object): + model = Banner + fields = ('course_id', 'course_over_view', 'enabled', 'platform', 'slide_position', 'created_by', 'banner_img_url') diff --git a/lms/djangoapps/banner/api/urls.py b/lms/djangoapps/banner/api/urls.py new file mode 100644 index 000000000000..f62397bdbc43 --- /dev/null +++ b/lms/djangoapps/banner/api/urls.py @@ -0,0 +1,14 @@ +""" +URLs for banner API +""" + + +from django.conf.urls import url + +from .views import BannerApi +from . import views + +urlpatterns = [ + url('^details/$', BannerApi.as_view(), name='banner_api'), + url('^home/$', views.mobile_home_page, name='mobile_api'), +] diff --git a/lms/djangoapps/banner/api/views.py b/lms/djangoapps/banner/api/views.py new file mode 100644 index 000000000000..5c0f4e92e470 --- /dev/null +++ b/lms/djangoapps/banner/api/views.py @@ -0,0 +1,184 @@ +""" +API views for banner +""" + +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from rest_framework.generics import ListAPIView +from rest_framework.exceptions import APIException, NotFound +from lms.djangoapps.banner.models import Banner +from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser +from .serializers import BannerSerializer +from rest_framework import status +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework.permissions import IsAuthenticated +from edx_rest_framework_extensions.paginators import NamespacedPageNumberPagination +from django.core.paginator import InvalidPage +from rest_framework.serializers import ValidationError +from rest_framework.status import HTTP_200_OK, HTTP_500_INTERNAL_SERVER_ERROR + +from django.http import Http404 +from rest_framework.views import APIView +from rest_framework.response import Response + + +class LazyPageNumberPagination(NamespacedPageNumberPagination): + """ + NamespacedPageNumberPagination that works with a LazySequence queryset. + + The paginator cache uses ``@cached_property`` to cache the property values for + count and num_pages. It assumes these won't change, but in the case of a + LazySquence, its count gets updated as we move through it. This class clears + the cached property values before reporting results so they will be recalculated. + + """ + + def get_paginated_response(self, data): + # Clear the cached property values to recalculate the estimated count from the LazySequence + del self.page.paginator.__dict__['count'] + del self.page.paginator.__dict__['num_pages'] + + # Paginate queryset function is using cached number of pages and sometime after + # deleting from cache when we recalculate number of pages are different and it raises + # EmptyPage error while accessing the previous page link. So we are catching that exception + # and raising 404. For more detail checkout PROD-1222 + page_number = self.request.query_params.get(self.page_query_param, 1) + try: + self.page.paginator.validate_number(page_number) + except InvalidPage as exc: + msg = self.invalid_page_message.format( + page_number=page_number, message=str(exc) + ) + self.page.number = self.page.paginator.num_pages + raise NotFound(msg) + + return super(LazyPageNumberPagination, self).get_paginated_response(data) + +@view_auth_classes(is_authenticated=True) +class BannerApi(DeveloperErrorViewMixin, ListAPIView): + + class BannerApiPageNumberPagination(LazyPageNumberPagination): + max_page_size = 100 + + pagination_class = BannerApiPageNumberPagination + serializer_class = BannerSerializer + authentication_classes = ( + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + JwtAuthentication, + ) + permission_classes = (IsAuthenticated,) + + # def get_queryset(self): + # """ + # The query will return 1 to 10 images of banner, platform is both(mobile, web) or mobile alone as of now + # """ + # queryset = Banner.objects.filter(platform__in=['MOBILE', 'BOTH'], enabled=True) + # return queryset + # # raise CustomAPIException("Invalid course ID.", status_code=status.HTTP_404_NOT_FOUND) + + def get(self, request, format=None): + banners = Banner.objects.filter(platform__in=['MOBILE', 'BOTH'], enabled=True) + serializer = BannerSerializer(banners, many=True) + result = {"results":serializer.data} + pagination = {"next":None, "previous": None, "count": 1, "num_pages": 1 if len(serializer.data)<=100 else int(len(serializer.data)/100)} + x = Response({"message": "", "result": result, "pagination":pagination, "status": True, "status_code": 200}) + if x and serializer.data: + return x + elif not serializer.data: + return Response({"message": "No data found", "result": result, "pagination":pagination, "status": True, "status_code": 200}) + else: + return Response({"message": "Error", "result": result, "pagination":pagination, "status": False, "status_code": 400}) + + +class CustomAPIException(ValidationError): + """ + raises API exceptions with custom messages and custom status codes + """ + status_code = status.HTTP_400_BAD_REQUEST + default_code = 'error' + + def __init__(self, detail, status_code=None): + self.detail = detail + if status_code is not None: + self.status_code = status_code + +""" +API views for Mobile Home page +""" + + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from openedx.core.lib.api.authentication import BearerAuthentication +from rest_framework.authentication import SessionAuthentication +import requests +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from logging import getLogger +#from ..decorators import mobile_view +from lms.djangoapps.mobile_api.utils import API_V05, API_V1 +logger = getLogger(__name__) + +@api_view(['GET']) +@authentication_classes((BearerAuthentication, SessionAuthentication, JwtAuthentication)) +@permission_classes([IsAuthenticated]) +def mobile_home_page(request): + home_page_url = {} + base = request.get_host() + bearer_token_from_request = request.META.get('HTTP_AUTHORIZATION') + + url_list = dict() + url_list["banner"] = '/api/banner/details/' + url_list["category"] = '/api/courses/v2/courses/categories/?page=1&page_size=1000' + url_list['recommended_courses'] = '/api/courses/v2/recommended/courses/?page=1&page_size=10' + url_list["most_popular"] = '/api/commerce/v2/courses/?platform_visibility=mobile&ordering=enrollments_count' + url_list["top_rated_courses"] = '/api/commerce/v2/courses/?platform_visibility=mobile&ordering=-ratings' + url_list["free_courses"] = '/api/commerce/v2/courses/?platform_visibility=mobile&sale_type=free' + headers = { + 'Authorization': bearer_token_from_request + } + http = 'http://' + response_obj = {"message": "Authentication Failed ", "net_response_chunk": {}, "status": False, "status_code": 401} + error_flag = True + response_code_list = [] + api_version = 'v1' + if api_version: + try: + for key, api_url in url_list.items(): + actual_request = requests.get(http+base+api_url, headers=headers) + data = actual_request.json() + home_page_url[key] = data + response_code_list.append(data['status_code']) + except Exception as ex: + #dont' expose the specify error internal to system to outside API, Put it in generic manner + logger.error("Error while processing mobile home API - Exception as %s", ex) + response_obj = {"message": "ERROR", "net_response_chunk": {}, "status": False, + "status_code": 500} + error_flag = True + pass + response_final_codes = list(set(response_code_list)) + #stream line 200, 202 and 500 + if len(response_final_codes) == 1 and response_final_codes[0] == 200: + error_flag = False + elif len(response_final_codes) >= 2 and 200 in response_final_codes: + response_obj['status_code'] = status.HTTP_202_ACCEPTED + response_obj['status'] = True + response_obj['net_response_chunk'] = home_page_url if home_page_url else "" + response_obj['message'] = "partial success" + return Response(response_obj) + else: + error_flag = True + + if not error_flag: + response_obj['status_code'] =status.HTTP_200_OK + response_obj['status'] = True + response_obj['net_response_chunk'] = home_page_url if home_page_url else "" + response_obj['message'] = "" + return Response(response_obj) + else: + return Response(response_obj) + else: + obj = {"message": "Wrong API version", "net_response_chunk": {}, "status": False, "status_code": 400} + return Response(obj) diff --git a/lms/djangoapps/banner/apps.py b/lms/djangoapps/banner/apps.py new file mode 100755 index 000000000000..9bd95d8aa711 --- /dev/null +++ b/lms/djangoapps/banner/apps.py @@ -0,0 +1,13 @@ +""" +App Configuration +""" +from django.apps import AppConfig + +class BannerConfig(AppConfig): + name = 'lms.djangoapps.banner' + verbose_name = 'banner' + + def ready(self): + super().ready() + # noinspection PyUnresolvedReferences + import lms.djangoapps.banner.signals diff --git a/lms/djangoapps/banner/migrations/0001_initial.py b/lms/djangoapps/banner/migrations/0001_initial.py new file mode 100644 index 000000000000..8376a98a44b8 --- /dev/null +++ b/lms/djangoapps/banner/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.18 on 2021-04-20 06:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('course_overviews', '0038_merge_20210405_0536'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Banner', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('banner_img_url_txt', models.TextField(blank=True, default='')), + ('banner_img', models.ImageField(upload_to='banner/lms/courses')), + ('enabled', models.BooleanField(default=True)), + ('platform', models.CharField(choices=[('mobile', 'MOBILE'), ('web', 'WEB'), ('both', 'BOTH')], max_length=10)), + ('slide_position', models.IntegerField()), + ('created_time', models.DateTimeField(auto_now_add=True)), + ('updated_time', models.DateTimeField(auto_now=True)), + ('course_over_view', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='course_over_view', to='course_overviews.CourseOverview')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/lms/djangoapps/banner/migrations/0002_auto_20210420_0857.py b/lms/djangoapps/banner/migrations/0002_auto_20210420_0857.py new file mode 100644 index 000000000000..a0301b89b0a8 --- /dev/null +++ b/lms/djangoapps/banner/migrations/0002_auto_20210420_0857.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.18 on 2021-04-20 08:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('banner', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='banner', + name='banner_img', + field=models.ImageField(upload_to='../media/banner/lms/courses'), + ), + ] diff --git a/lms/djangoapps/banner/migrations/0003_auto_20210420_0900.py b/lms/djangoapps/banner/migrations/0003_auto_20210420_0900.py new file mode 100644 index 000000000000..82274b459fd0 --- /dev/null +++ b/lms/djangoapps/banner/migrations/0003_auto_20210420_0900.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.18 on 2021-04-20 09:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('banner', '0002_auto_20210420_0857'), + ] + + operations = [ + migrations.AlterField( + model_name='banner', + name='banner_img', + field=models.ImageField(upload_to='banner/lms/courses'), + ), + ] diff --git a/lms/djangoapps/banner/migrations/0004_auto_20210423_0340.py b/lms/djangoapps/banner/migrations/0004_auto_20210423_0340.py new file mode 100644 index 000000000000..e08ada6df14c --- /dev/null +++ b/lms/djangoapps/banner/migrations/0004_auto_20210423_0340.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.19 on 2021-04-23 03:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('banner', '0003_auto_20210420_0900'), + ] + + operations = [ + migrations.AlterField( + model_name='banner', + name='course_over_view', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_over_view', to='course_overviews.CourseOverview'), + ), + ] diff --git a/lms/djangoapps/banner/migrations/__init__.py b/lms/djangoapps/banner/migrations/__init__.py new file mode 100755 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/banner/models.py b/lms/djangoapps/banner/models.py new file mode 100755 index 000000000000..fd93aa8a289b --- /dev/null +++ b/lms/djangoapps/banner/models.py @@ -0,0 +1,26 @@ +from django.db import models +# Create your models here. +from django.db import models +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from django.contrib.auth.models import User + + +# Create your models here. +class Banner(models.Model): + PLATFORM_CHOICES=(('mobile', 'MOBILE'), ('web', 'WEB'), ('both', 'BOTH')) + course_over_view = models.ForeignKey(CourseOverview, related_name='course_over_view', on_delete=models.CASCADE) + banner_img_url_txt = models.TextField(blank=True, default=u"") + banner_img = models.ImageField(upload_to = 'banner/lms/courses') + enabled = models.BooleanField(default = True) + platform = models.CharField(max_length=10, choices=PLATFORM_CHOICES, ) + slide_position = models.IntegerField() + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + created_time = models.DateTimeField(auto_now_add = True) + updated_time = models.DateTimeField(auto_now=True) + + + + + + + diff --git a/lms/djangoapps/banner/signals.py b/lms/djangoapps/banner/signals.py new file mode 100644 index 000000000000..d15539d02ec6 --- /dev/null +++ b/lms/djangoapps/banner/signals.py @@ -0,0 +1,27 @@ +""" +Signal related to banner + +""" +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import Banner +from logging import getLogger +log = getLogger(__name__) + +@receiver(post_save, sender=Banner) +def capture_image_url(sender, instance, created, **kwargs): + + if created: + instance.banner_img_url_txt= str(instance.banner_img.url) + instance.save() + log.info("******Saved banner url successfully******") + else: + """prevent the loop back to post_save in case of update call + pls refer this https://code.djangoproject.com/ticket/28970 + """ + Banner.objects.filter(pk=instance.id).update(banner_img_url_txt = str(instance.banner_img.url)) + log.info("******Updated banner url/other param successfully******") + + + + diff --git a/lms/djangoapps/banner/tests.py b/lms/djangoapps/banner/tests.py new file mode 100755 index 000000000000..7ce503c2dd97 --- /dev/null +++ b/lms/djangoapps/banner/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/lms/djangoapps/banner/views.py b/lms/djangoapps/banner/views.py new file mode 100755 index 000000000000..91ea44a218fb --- /dev/null +++ b/lms/djangoapps/banner/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index 42343da8842f..31a293135032 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -9,6 +9,7 @@ import logging import six +import urllib.parse from django.conf import settings from django.db.models import Q from django.urls import reverse @@ -70,11 +71,15 @@ def format_certificate_for_user(username, cert): "is_passing": is_passing_status(cert.status), "is_pdf_certificate": bool(cert.download_url), "download_url": ( - cert.download_url or get_certificate_url(cert.user.id, cert.course_id, uuid=cert.verify_uuid, - user_certificate=cert) + get_certificate_url(cert.user.id, cert.course_id, uuid=cert.verify_uuid, user_certificate=cert) if cert.status == CertificateStatuses.downloadable else None ), + "download_pdf_url": ( + urllib.parse.urljoin(settings.MEDIA_URL, cert.download_url) + if cert.status == CertificateStatuses.downloadable and cert.download_url + else "" + ) } except CourseOverview.DoesNotExist: return None diff --git a/lms/djangoapps/certificates/apis/v0/lhub_views.py b/lms/djangoapps/certificates/apis/v0/lhub_views.py index b735601b547b..21cce579c13a 100644 --- a/lms/djangoapps/certificates/apis/v0/lhub_views.py +++ b/lms/djangoapps/certificates/apis/v0/lhub_views.py @@ -82,6 +82,7 @@ def get(self, request, username, course_id): "status": user_cert.get('status'), "is_passing": user_cert.get('is_passing'), "download_url": user_cert.get('download_url'), + "download_pdf_url": user_cert.get('download_pdf_url'), "grade": user_cert.get('grade') } } @@ -133,6 +134,8 @@ def get(self, request, username): * download_url: A string representation of the certificate url. + * download_pdf_url: A string representation of path to certificate pdf file + * grade: A string representation of a float for the user's course grade. **Example GET Response** @@ -153,6 +156,7 @@ def get(self, request, username): "status": "downloadable", "is_passing": true, "download_url": "/certificates/08f0e53458da4fbbb734db84d6181980", + "download_pdf_url": "/media/certificates_pdf/08f0e53458da4fbbb734db84d6181980", "grade": "1.0" }, { @@ -166,6 +170,7 @@ def get(self, request, username): "status": "downloadable", "is_passing": true, "download_url": "/certificates/9e1b971ec5964f6a9d91bd2388237097", + "download_pdf_url": "/media/certificates_pdf/9e1b971ec5964f6a9d91bd2388237097", "grade": "0.0" } ] @@ -185,6 +190,7 @@ def get(self, request, username): 'status': user_cert.get('status'), 'is_passing': user_cert.get('is_passing'), 'download_url': user_cert.get('download_url'), + "download_pdf_url": user_cert.get('download_pdf_url'), 'grade': user_cert.get('grade'), }) return Response( diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 2f9f6175745a..4df5203e80da 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -3,12 +3,16 @@ import json import logging +import pdfkit import random +import urllib.parse from uuid import uuid4 import lxml.html import six from django.conf import settings +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage from django.test.client import RequestFactory from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible @@ -446,6 +450,8 @@ def _generate_cert(self, cert, course, student, grade_contents, template_pdf, ge Generate a certificate for the student. If `generate_pdf` is True, sends a request to XQueue. """ + from lms.djangoapps.certificates.api import get_certificate_url + course_id = six.text_type(course.id) key = make_hashkey(random.random()) @@ -466,6 +472,14 @@ def _generate_cert(self, cert, course, student, grade_contents, template_pdf, ge cert.verify_uuid = uuid4().hex cert.save() + + cert_web_view_url = get_certificate_url(student.id, course.id, uuid=cert.verify_uuid, user_certificate=cert) + full_url = urllib.parse.urljoin(settings.LMS_ROOT_URL, cert_web_view_url) + pdf_data = pdfkit.from_url(full_url, False) + path = default_storage.save(f'certificates_pdf/{cert.verify_uuid}.pdf', ContentFile(pdf_data)) + cert.download_url = path + cert.save() + logging.info(u'certificate generated for user: %s with generate_pdf status: %s', student.username, generate_pdf) diff --git a/lms/djangoapps/commerce/api/v1/models.py b/lms/djangoapps/commerce/api/v1/models.py index 698adb4343cc..926192cce528 100644 --- a/lms/djangoapps/commerce/api/v1/models.py +++ b/lms/djangoapps/commerce/api/v1/models.py @@ -29,6 +29,14 @@ from django.contrib.auth import get_user_model User = get_user_model() +from lms.djangoapps.lhub_ecommerce_offer.models import Offer, Coupon +from decimal import Decimal as D +from datetime import datetime +import pytz + +utc = pytz.UTC + + class Course(object): """ Pseudo-course model used to group CourseMode objects. """ id = None # pylint: disable=invalid-name @@ -166,15 +174,57 @@ def enrollments_count(self): return None + @property + def coupon_applicable(self): + course_id = CourseKey.from_string(six.text_type(self.id)) + current_datetime = str(utc.localize(datetime.now())) + current_datetime = utc.localize(datetime.strptime(current_datetime[:19], '%Y-%m-%d %H:%M:%S')) + if len(self.modes) > 0: + if Coupon.objects.filter(course__pk=course_id).exists(): + coupon = Coupon.objects.filter(course__pk=course_id).first() + start_date = coupon.start_datetime + end_date = coupon.end_datetime + if current_datetime >= start_date and current_datetime < end_date: + return True + else: + return False + return False @property def discount_applicable(self): + course_id = CourseKey.from_string(six.text_type(self.id)) + current_datetime = str(utc.localize(datetime.now())) + current_datetime = utc.localize(datetime.strptime(current_datetime[:19], '%Y-%m-%d %H:%M:%S')) if len(self.modes) > 0: - return self.modes[0].discount_percentage > 0.00 - + if Offer.objects.filter(course__pk=course_id).exists(): + offer = Offer.objects.filter(course__pk=course_id).order_by('-priority', 'id').first() + start_date = offer.start_datetime + end_date = offer.end_datetime + if end_date: + if offer.is_suspended == False and current_datetime >= start_date and current_datetime < end_date: + return True + else: + return False + else: + if offer.is_suspended == False and current_datetime >= start_date: + return True + else: + return False return False + @property + def discount_type(self): + course_id = CourseKey.from_string(six.text_type(self.id)) + if len(self.modes) > 0: + if Offer.objects.filter(course__pk=course_id).exists(): + offer = Offer.objects.filter(course__pk=course_id).order_by('-priority', 'id').first() + if offer.incentive_type == 'Percentage': + return "Percentage" + else: + return "Value" + + @property def currency(self): try: @@ -184,24 +234,160 @@ def currency(self): except: return "S$" - - @property def discounted_price(self): if len(self.modes) > 0: price = float(self.modes[0].min_price) - discounted_price = price - (self.modes[0].discount_percentage/100) * price - return discounted_price + course_id = CourseKey.from_string(six.text_type(self.id)) + current_datetime = str(utc.localize(datetime.now())) + current_datetime = utc.localize(datetime.strptime(current_datetime[:19], '%Y-%m-%d %H:%M:%S')) + + if Offer.objects.filter(course__pk=course_id).exists(): + offer = Offer.objects.filter(course__pk=course_id).order_by('-priority', 'id').first() + start_date = offer.start_datetime + end_date = offer.end_datetime + if end_date: + if offer.is_suspended == False and current_datetime >= start_date and current_datetime < end_date: + incentive_type = offer.incentive_type + incentive_value = float(offer.incentive_value) + + if incentive_type == 'Percentage': + discounted_price = price - (price * (incentive_value/100)) + elif incentive_type == 'Absolute': + discounted_price = price - incentive_value + + return "%.2f" % discounted_price + else: + return "%.2f" % price + else: + if offer.is_suspended == False and current_datetime >= start_date: + incentive_type = offer.incentive_type + incentive_value = float(offer.incentive_value) + + if incentive_type == 'Percentage': + discounted_price = price - (price * (incentive_value/100)) + elif incentive_type == 'Absolute': + discounted_price = price - incentive_value + + return "%.2f" % discounted_price + else: + return "%.2f" % price + + course_mode_price = self.get_paid_mode_price() return course_mode_price + @property + def discounted_price_string(self): + if len(self.modes) > 0: + price = float(self.modes[0].min_price) + course_id = CourseKey.from_string(six.text_type(self.id)) + current_datetime = str(utc.localize(datetime.now())) + current_datetime = utc.localize(datetime.strptime(current_datetime[:19], '%Y-%m-%d %H:%M:%S')) + + if Offer.objects.filter(course__pk=course_id).exists(): + offer = Offer.objects.filter(course__pk=course_id).order_by('-priority', 'id').first() + start_date = offer.start_datetime + end_date = offer.end_datetime + if end_date: + if offer.is_suspended == False and current_datetime >= start_date and current_datetime < end_date: + incentive_type = offer.incentive_type + incentive_value = float(offer.incentive_value) + + if incentive_type == 'Percentage': + discounted_price = price - (price * (incentive_value/100)) + elif incentive_type == 'Absolute': + discounted_price = price - incentive_value + + return "%.2f" % discounted_price + else: + return "%.2f" % price + else: + if offer.is_suspended == False and current_datetime >= start_date: + incentive_type = offer.incentive_type + incentive_value = float(offer.incentive_value) + + if incentive_type == 'Percentage': + discounted_price = price - (price * (incentive_value/100)) + elif incentive_type == 'Absolute': + discounted_price = price - incentive_value + + return "%.2f" % discounted_price + else: + return "%.2f" % price + + + course_mode_price = self.get_paid_mode_price() + return course_mode_price @property def discount_percentage(self): + course_id = CourseKey.from_string(six.text_type(self.id)) if len(self.modes) > 0: - return self.modes[0].discount_percentage + if Offer.objects.filter(course__pk=course_id).exists(): + offer = Offer.objects.filter(course__pk=course_id).order_by('-priority', 'id').first() + self.modes[0].discount_percentage = offer.incentive_value + return self.modes[0].discount_percentage return 0.0 + @property + def discount_percentage_string(self): + course_id = CourseKey.from_string(six.text_type(self.id)) + if len(self.modes) > 0: + if Offer.objects.filter(course__pk=course_id).exists(): + offer = Offer.objects.filter(course__pk=course_id).order_by('-priority', 'id').first() + self.modes[0].discount_percentage = offer.incentive_value + return str("%.2f" % self.modes[0].discount_percentage) + return "%.2f" % 0.0 + + + @property + def coupon_available(self): + course_id = CourseKey.from_string(six.text_type(self.id)) + if len(self.modes) > 0: + if Coupon.objects.filter(course__pk=course_id).exists(): + coupon = Coupon.objects.filter(course__pk=course_id).first() + if coupon: + return True + else: + return False + + + + @property + def coupon_type(self): + course_id = CourseKey.from_string(six.text_type(self.id)) + if len(self.modes) > 0: + if Coupon.objects.filter(course__pk=course_id).exists(): + coupon = Coupon.objects.filter(course__pk=course_id).first() + if coupon.incentive_type == 'Percentage': + return 'Percentage' + else: + return 'Value' + + + @property + def available_vouchers(self): + course_id = CourseKey.from_string(six.text_type(self.id)) + vouchers = [] + current_datetime = str(utc.localize(datetime.now())) + current_datetime = utc.localize(datetime.strptime(current_datetime[:19], '%Y-%m-%d %H:%M:%S')) + if len(self.modes) > 0: + if Coupon.objects.filter(course__pk=course_id).exists(): + vouchers_data = Coupon.objects.filter(course__pk=course_id).values('name', 'coupon_code', 'incentive_type', 'incentive_value', 'is_exclusive') + coupons = Coupon.objects.filter(course__pk=course_id) + for index, coupon in enumerate(coupons): + start_date = coupon.start_datetime + end_date = coupon.end_datetime + + if current_datetime >= start_date and current_datetime < end_date: + vouchers.append(vouchers_data[index]) + return vouchers + else: + return [] + + + @property def sale_type(self): @@ -380,3 +566,55 @@ def iterator(cls,filters=None): #course_modes = CourseMode.objects.order_by('ratings') for course_id, modes in groupby(course_modes, lambda o: o.course_id): yield cls(course_id, list(modes)) + + + +class WebCourse(Course): + + @property + def start_date(self): + """ Return course Date Created. """ + course_id = CourseKey.from_string(six.text_type(self.id)) + + try: + courseoverview = CourseOverview.get_from_id(course_id) + if courseoverview.advertised_start: + start_date = courseoverview.advertised_start.strftime("%b") + " " + str(courseoverview.advertised_start.day) +", " + str(courseoverview.advertised_start.year) + else: + start_date = courseoverview.start.strftime("%b") + " " + str(courseoverview.start.day) + ", " + str(courseoverview.start.year) + logging.info(type(start_date)) + return start_date + + + except CourseOverview.DoesNotExist: + # NOTE (CCB): Ideally, the course modes table should only contain data for courses that exist in + # modulestore. If that is not the case, say for local development/testing, carry on without failure. + log.warning(u'Failed to retrieve CourseOverview for [%s]. Using empty course name.', course_id) + return None + + @property + def organization(self): + """ Return course Date Created. """ + course_id = CourseKey.from_string(six.text_type(self.id)) + + try: + return CourseOverview.get_from_id(course_id).display_org_with_default + except CourseOverview.DoesNotExist: + # NOTE (CCB): Ideally, the course modes table should only contain data for courses that exist in + # modulestore. If that is not the case, say for local development/testing, carry on without failure. + log.warning(u'Failed to retrieve CourseOverview for [%s]. Using empty course name.', course_id) + return None + + @property + def course_number(self): + """ Return course Date Created. """ + course_id = CourseKey.from_string(six.text_type(self.id)) + + try: + return CourseOverview.get_from_id(course_id).display_number_with_default + except CourseOverview.DoesNotExist: + # NOTE (CCB): Ideally, the course modes table should only contain data for courses that exist in + # modulestore. If that is not the case, say for local development/testing, carry on without failure. + log.warning(u'Failed to retrieve CourseOverview for [%s]. Using empty course name.', course_id) + return None + diff --git a/lms/djangoapps/commerce/api/v1/serializers.py b/lms/djangoapps/commerce/api/v1/serializers.py index faf493e18618..b792c5348d71 100644 --- a/lms/djangoapps/commerce/api/v1/serializers.py +++ b/lms/djangoapps/commerce/api/v1/serializers.py @@ -11,6 +11,7 @@ from rest_framework import serializers from common.djangoapps.course_modes.models import CourseMode +from lms.djangoapps.lhub_ecommerce_offer.models import Coupon from xmodule.modulestore.django import modulestore from .models import UNDEFINED, Course @@ -20,6 +21,7 @@ class CourseModeSerializer(serializers.ModelSerializer): """ CourseMode serializer. """ name = serializers.CharField(source='mode_slug') price = serializers.FloatField(source='min_price') + price_string = serializers.CharField(source='get_price_string', required=False) expires = serializers.DateTimeField( source='expiration_datetime', required=False, @@ -35,11 +37,44 @@ def get_identity(self, data): class Meta(object): model = CourseMode - fields = ('name', 'currency', 'price', 'sku', 'bulk_sku', 'expires') + fields = ('name', 'currency', 'price', 'price_string', 'sku', 'bulk_sku', 'expires') # For disambiguating within the drf-yasg swagger schema ref_name = 'commerce.CourseMode' + +class AvailableVouchersSerializer(serializers.ModelSerializer): + """ AvailableVouchers serializer. """ + name = serializers.CharField() + code = serializers.CharField(source="coupon_code") + discount_type = serializers.CharField(source="incentive_type") + discount_value = serializers.DecimalField( + max_digits=12, + decimal_places=2, + source="incentive_value" + ) + allow_combine = serializers.BooleanField(source="is_exclusive") + + def get_identity(self, data): + try: + return data.get('name', None) + except AttributeError: + return None + + class Meta(object): + model = Coupon + fields = ( + 'name', + 'code', + 'discount_type', + 'discount_value', + 'allow_combine', + ) + # For disambiguating within the drf-yasg swagger schema + ref_name = 'lms.Coupon' + + + def validate_course_id(course_id): """ Check that course id is valid and exists in modulestore. @@ -122,7 +157,9 @@ class CourseSerializer(serializers.Serializer): verification_deadline = PossiblyUndefinedDateTimeField(format=None, allow_null=True, required=False) modes = CourseModeSerializer(many=True) discount_applicable = serializers.BooleanField(required=False) + discount_type = serializers.CharField(required=False) discounted_price = serializers.FloatField(required=False) + discounted_price_string = serializers.CharField(required=False) sale_type = serializers.CharField(required=False) subcategory_id = serializers.CharField(required=False) category = serializers.CharField(required=False) @@ -130,12 +167,14 @@ class CourseSerializer(serializers.Serializer): is_premium = serializers.BooleanField(required=False) media = _CourseApiMediaCollectionSerializer(source='*',required=False) discount_percentage = serializers.FloatField(required=False) + discount_percentage_string = serializers.CharField(required=False) allow_review = serializers.BooleanField(required=False) + voucher_applicable = serializers.BooleanField(required=False, source='coupon_applicable') + available_vouchers = AvailableVouchersSerializer(required=False, many=True) class Meta(object): # For disambiguating within the drf-yasg swagger schema ref_name = 'commerce.Course' - def validate(self, attrs): """ Ensure the verification deadline occurs AFTER the course mode enrollment deadlines. """ verification_deadline = attrs.get('verification_deadline', None) @@ -196,6 +235,11 @@ def _new_course_mode_models(modes_data): CourseMode(**modes_dict) for modes_dict in modes_data ] +class WebCourseSerializer(CourseSerializer): + """ Web Course serializer. """ + start_date = serializers.CharField() + organization = serializers.CharField() + course_number = serializers.CharField() class CourseDetailSerializer(serializers.Serializer): """ Course serializer. """ @@ -209,17 +253,23 @@ class CourseDetailSerializer(serializers.Serializer): verification_deadline = PossiblyUndefinedDateTimeField(format=None, allow_null=True, required=False) modes = CourseModeSerializer(many=True) discount_applicable = serializers.BooleanField(required=False) - discounted_price = serializers.FloatField(required=False) + discounted_price_string = serializers.CharField(required=False) + discount_type = serializers.CharField(required=False) + discounted_price = serializers.CharField(required=False) sale_type = serializers.CharField(required=False) subcategory_id = serializers.CharField(required=False) platform_visibility = serializers.CharField(required=False) is_premium = serializers.BooleanField(required=False) media = _CourseApiMediaCollectionSerializer(source='*',required=False) discount_percentage = serializers.FloatField(required=False) + discount_percentage_string = serializers.CharField(required=False) chapter_count = serializers.IntegerField(required=False) description = serializers.CharField(required=False) allow_review = serializers.BooleanField() is_enrolled = serializers.BooleanField(required=False) + own_feedback = serializers.BooleanField(required=False) + voucher_applicable = serializers.BooleanField(required=False) + available_vouchers = AvailableVouchersSerializer(required=False, many=True) class Meta(object): # For disambiguating within the drf-yasg swagger schema @@ -286,8 +336,8 @@ def _new_course_mode_models(modes_data): for modes_dict in modes_data ] - - +class WebCourseDetailSerializer(CourseDetailSerializer): + """ WebCourse serializer. """ class CourseDetailCheckoutSerializer(serializers.Serializer): """ Course serializer. """ @@ -312,6 +362,7 @@ class CourseDetailCheckoutSerializer(serializers.Serializer): description = serializers.CharField(required=False) new_category = serializers.CharField(required=False) organization = serializers.CharField(required=False) + available_vouchers = AvailableVouchersSerializer(many=True) class Meta(object): # For disambiguating within the drf-yasg swagger schema @@ -377,5 +428,5 @@ def _new_course_mode_models(modes_data): CourseMode(**modes_dict) for modes_dict in modes_data ] - - +class WebCourseDetailCheckoutSerializer(CourseDetailCheckoutSerializer): + """ WebCourse serializer. """ diff --git a/lms/djangoapps/commerce/api/v2/urls.py b/lms/djangoapps/commerce/api/v2/urls.py index 04f4af76a2d9..faa9ae8bda53 100644 --- a/lms/djangoapps/commerce/api/v2/urls.py +++ b/lms/djangoapps/commerce/api/v2/urls.py @@ -8,7 +8,8 @@ from . import views COURSE_URLS = ([ - url(r'^$', views.CourseListView.as_view(), name='list'), + url(r'^$', views.CourseListView.as_view(), name='list'),\ + # url(r'^$', views.WebCourseListView.as_view()), ], 'courses') app_name = 'v2' @@ -22,4 +23,5 @@ url(r'^basket-details/(?P[0-9]+)$', views.get_basket_content, name='get_basket_detail'), url(r'^basket_details_mobile/$', views.get_basket_content_mobile, name='get_basket_detail_mobile'), url(r'^update_discount/(?P[\w\-]+)/$', views.update_discount, name='update_discount'), + url(r'^web/courses/$', views.WebCourseListView.as_view(), name = "web-courses") ] diff --git a/lms/djangoapps/commerce/api/v2/views.py b/lms/djangoapps/commerce/api/v2/views.py index d7ab4cc948eb..47edd52ae3ae 100644 --- a/lms/djangoapps/commerce/api/v2/views.py +++ b/lms/djangoapps/commerce/api/v2/views.py @@ -1,16 +1,17 @@ +from lms.djangoapps.lhub_ecommerce_offer.models import Coupon from rest_framework.generics import ListAPIView, RetrieveAPIView from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from openedx.core.lib.api.authentication import BearerAuthentication from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import AllowAny from rest_framework.permissions import IsAuthenticated from .filters import LazyPageNumberPagination from operator import attrgetter -from ..v1.models import Course -from ..v1.serializers import CourseSerializer, CourseDetailSerializer,CourseDetailCheckoutSerializer +from ..v1.models import Course, WebCourse +from ..v1.serializers import CourseSerializer, CourseDetailSerializer,CourseDetailCheckoutSerializer,WebCourseSerializer,WebCourseDetailSerializer,WebCourseDetailCheckoutSerializer import logging log = logging.getLogger(__name__) import json - from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from opaque_keys.edx.keys import CourseKey @@ -25,11 +26,11 @@ from rest_framework.decorators import api_view, authentication_classes, permission_classes from django.http import HttpResponseNotFound, HttpResponseBadRequest from course_modes.models import CourseMode - from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.response import Response from openedx.core.djangoapps.commerce.utils import ecommerce_api_client from django.apps import apps +from common.djangoapps.feedback.models import CourseReview CourseEnrollment = apps.get_model('student', 'CourseEnrollment') @@ -61,7 +62,7 @@ def get_queryset(self): else: request_filters[f] = filter_val.split(',') - courses = list(Course.iterator()) + courses = list(Course.iterator()) platform_only_courses = [] for course in courses: platform = course.platform_visibility @@ -170,6 +171,120 @@ def order_courses(self,course_list, ordering): return course_list +class WebCourseListView(CourseListView): + """ List courses and modes. """ + class CourseListPageNumberPagination(LazyPageNumberPagination): + max_page_size = 100 + authentication_classes = (JwtAuthentication,BearerAuthentication,SessionAuthentication) + permission_classes = ((AllowAny,)) + serializer_class = WebCourseSerializer + pagination_class = CourseListPageNumberPagination + # filter_class = CourseListFilter + # filterset_class = CourseListFilter + # filter_backends = [DjangoFilterBackend] + + def get_queryset(self): + filtered_courses = [] + mobile_courses = [] + filter = False + filters = {"enrollments_count":None,'difficulty_level': None, 'sale_type': None, 'subcategory_id': None, 'platform_visibility' : None , 'category' : None, 'discount_applicable': 'Boolean', 'is_premium': 'Boolean'} + request_filters = {} + for f,val in filters.items(): + filter_val = self.request.query_params.get(f, None) + if filter_val is not None: + filter = True + if val == 'Boolean': + boolean_result = [json.loads(filter_val.split(',')[0].lower())] + request_filters[f] = boolean_result + else: + request_filters[f] = filter_val.split(',') + + courses = list(WebCourse.iterator()) + platform_only_courses = [] + + for course in courses: + platform = course.platform_visibility + if 'platform_visibility' in request_filters.keys(): + request_filters['platform_visibility'].append('both') + if platform == None or platform in request_filters['platform_visibility']: + platform_only_courses.append(course) + else: + if platform == None or platform == "both": + platform_only_courses.append(course) + courses = platform_only_courses + + try: + platform_courses_list = [] + user = self.request.user + user_org = user.user_extra_info.organization + authorization = self.request.META.get('HTTP_AUTHORIZATION') + + if "JWT" not in authorization: + for course in courses: + try: + course_overview = CourseOverview.get_from_id(course.id) + platform = course_overview.platform_visibility + organization = course_overview.organization + if user.is_staff: + platform_courses_list.append(course) + elif user_org == organization or organization == None: + platform_courses_list.append(course) + elif organization == None and user_org == None: + platform_courses_list.append(course) + except: + pass + courses = platform_courses_list + + else: + for course in courses: + try: + course_overview = CourseOverview.get_from_id(course.id) + platform = course_overview.platform_visibility + organization = course_overview.organization + if organization == None: + platform_courses_list.append(course) + except: + pass + courses = platform_courses_list + + except: + platform_courses_list = [] + for course in courses: + try: + course_overview = CourseOverview.get_from_id(course.id) + platform = course_overview.platform_visibility + organization = course_overview.organization + if organization == None: + platform_courses_list.append(course) + except: + pass + courses = platform_courses_list + + ordering_filter=self.request.query_params.get('ordering', None) + if ordering_filter: + ordering_filter_list = ordering_filter.split(',') + courses = self.order_courses(courses, ordering_filter_list) + if filter: + for course in courses: + is_filter = [True if getattr(course,f) in val and val is not None else False for f,val in request_filters.items()] + if all(is_filter): + filtered_courses.append(course) + if self.request.query_params.get('coursename', None): + filtered_courses_list = [] + course_list = filtered_courses if len(filtered_courses) > 0 else courses + for course in course_list: + search_string = self.request.query_params.get('coursename').lower() + if course.name.lower().find(search_string) > -1: #and course.platform_visibility in ['mobile', 'both', 'Mobile', 'Both', None]: + filtered_courses_list.append(course) + + return filtered_courses_list + + if not self.request.query_params.get('coursename', None) and not filter: + return courses + + return filtered_courses + + @view_auth_classes(is_authenticated=True) class CourseDetailView(RetrieveAPIView): serializer_class = CourseDetailSerializer @@ -192,7 +307,9 @@ def get_object(self): course.difficulty_level = course.difficulty_level.capitalize() if course.difficulty_level else "Unknown" course.discount_applicable = course_extra_info.discount_applicable course.discount_percentage = course_extra_info.discount_percentage - course.discounted_price = course_extra_info.discounted_price + course.discount_percentage_string = course_extra_info.discount_percentage_string + course.discounted_price = float(course_extra_info.discounted_price) + course.discounted_price_string = str(course_extra_info.discounted_price) course.currency = course_extra_info.currency course.description = course_overview.short_description course_usage_key = modulestore().make_course_usage_key(course_id) @@ -201,6 +318,54 @@ def get_object(self): course.name = course_overview.display_name course.allow_review = course_overview.allow_review course.is_enrolled = CourseEnrollment.is_enrolled(self.request.user, course_id) + course.own_feedback = CourseReview.is_reviewed(self.request.user, course_id) + course.discount_type = course_extra_info.discount_type + course.voucher_applicable = course_extra_info.coupon_applicable + course.available_vouchers = course_extra_info.available_vouchers + + if len(course_extra_info.modes) == 0: + course.price = 0 + else: + course.price = course_extra_info.modes[0].min_price + return course + except Exception as e: + response = {"status": False, "message":e, "result":None, "status_code": 500} + return response + +@view_auth_classes(is_authenticated=True) +class WebCourseDetailView(CourseDetailView): + serializer_class = WebCourseDetailSerializer + def get_object(self): + course_key = self.kwargs['course_key_string'] + course_id = CourseKey.from_string(course_key) + course = get_course_by_id(course_id) + + try: + course_modes = CourseMode.objects.filter(course_id=course_id) + course.modes = course_modes + course_overview = CourseOverview.get_from_id(course_id) + if course_overview.platform_visibility == "Web": + response = {"status": False, "message":"Course platform doesn't match the requirments", "result":None, "status_code": 404} + course.image_urls = course_overview.image_urls + course_extra_info = Course(course.id,list(course_modes),user=self.request.user) + course.enrollments_count = course_extra_info.enrollments_count + course.ratings = float("{:.2f}".format(course_extra_info.ratings)) + course.comments_count = course_extra_info.comments_count + course.difficulty_level = course.difficulty_level.capitalize() if course.difficulty_level else "Unknown" + course.discount_applicable = course_extra_info.discount_applicable + course.discount_percentage = course_extra_info.discount_percentage + course.discount_percentage_string = course_extra_info.discount_percentage_string + course.discounted_price = float(course_extra_info.discounted_price) + course.discounted_price_string = str(course_extra_info.discounted_price) + course.currency = course_extra_info.currency + course.description = course_overview.short_description + course_usage_key = modulestore().make_course_usage_key(course_id) + response = get_blocks(self.request,course_usage_key,self.request.user,requested_fields=['completion'],block_types_filter='vertical') + course.chapter_count = len(response['blocks']) + course.name = course_overview.display_name + course.allow_review = course_overview.allow_review + course.is_enrolled = CourseEnrollment.is_enrolled(self.request.user, course_id) + course.own_feedback = CourseReview.is_reviewed(self.request.user, course_id) if len(course_extra_info.modes) == 0: course.price = 0 @@ -287,6 +452,7 @@ def get_object(self): course.name = course_overview.display_name course.new_category = course_overview.new_category if course_overview.new_category else "None" course.organization = course.display_org_with_default + course.available_vouchers = course_extra_info.available_vouchers if len(course_extra_info.modes) == 0: course.price = 0 else: @@ -326,7 +492,10 @@ def get_basket_content_mobile(request,id=None): response = api.basket_details.get(id=id) else: response = api.basket_details_mobile.get() + log.info("==============") + log.info(response) if response['status_code'] == 404: return HttpResponseNotFound(response['message']) return Response(response) + diff --git a/lms/djangoapps/course_api/blocks/serializers.py b/lms/djangoapps/course_api/blocks/serializers.py index d8361fa81f63..9e0cb1aebd80 100644 --- a/lms/djangoapps/course_api/blocks/serializers.py +++ b/lms/djangoapps/course_api/blocks/serializers.py @@ -7,8 +7,11 @@ from django.conf import settings from rest_framework import serializers from rest_framework.reverse import reverse - +import logging from lms.djangoapps.course_blocks.transformers.visibility import VisibilityTransformer +from lms.djangoapps.course_block_user.models import CourseBlockUser + +from lms.djangoapps.course_block_user.models import CourseBlockUser from .transformers.block_completion import BlockCompletionTransformer from .transformers.block_counts import BlockCountsTransformer @@ -17,7 +20,7 @@ from .transformers.student_view import StudentViewTransformer from .transformers.extra_fields import ExtraFieldsTransformer - +logger = logging.getLogger(__name__) class SupportedFieldType(object): """ Metadata about fields supported by different transformers @@ -177,6 +180,22 @@ def to_representation(self, block_key): if children: data['children'] = [six.text_type(child) for child in children] + + + try: + if 'descendants' in data: + + x,_ = CourseBlockUser.objects.get_or_create(user=self.context['request'].user, + course_id_block=six.text_type(block_key.block_id), block_mobile_view=data['student_view_url'], descendants=data['descendants']) + else: + y,_ = CourseBlockUser.objects.get_or_create(user=self.context['request'].user, + course_id_block=six.text_type(block_key.block_id), + block_mobile_view=data['student_view_url']) + except Exception as ex: + if 'descendants' in data: + logger.error("*************************ERROR fetching the query in CourseBlockUser for user %s with course_block_id %s and student web view url as %s . descendants as %s *********************", str(self.context['request'].user), str(six.text_type(block_key.block_id)), str(data['student_view_url']), str(data['descendants'])) + else: + logger.error("*************************ERROR fetching the query in CourseBlockUser for user %s with course_block_id %s and student web view url as %s . *********************", str(self.context['request'].user), str(six.text_type(block_key.block_id)), str(data['student_view_url'])) if authorization_denial_reason and authorization_denial_message: data['authorization_denial_reason'] = authorization_denial_reason data['authorization_denial_message'] = authorization_denial_message @@ -205,3 +224,4 @@ def get_blocks(self, structure): six.text_type(block_key): BlockSerializer(block_key, context=self.context).data for block_key in structure } + diff --git a/lms/djangoapps/course_api/mobile_api.py b/lms/djangoapps/course_api/mobile_api.py index 38f16e1685a7..bae2ae1bad1f 100644 --- a/lms/djangoapps/course_api/mobile_api.py +++ b/lms/djangoapps/course_api/mobile_api.py @@ -15,6 +15,7 @@ from lms.djangoapps.courseware.courses import ( get_course_overview_with_access, get_courses, + get_courses_with_extra_info_json, get_permission_for_course_about ) from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -103,7 +104,7 @@ def _filter_by_search(course_queryset, search_term): ) -def list_courses(request, username, org=None, platform=None, filter_=None, search_term=None): +def list_courses(request, username, org=None, platform=None, filter_=None, search_term=None, get_extra_info=False): """ Yield all available courses. @@ -134,7 +135,10 @@ def list_courses(request, username, org=None, platform=None, filter_=None, searc Yield `CourseOverview` objects representing the collection of courses. """ user = get_effective_user(request.user, username) - course_qs = get_courses(user, org=org, platform=platform, filter_=filter_) + if get_extra_info: + course_qs = get_courses_with_extra_info_json(user, org=org, platform=platform, filter_=filter_) + else: + course_qs = get_courses(user, org=org, platform=platform, filter_=filter_) course_qs = _filter_by_search(course_qs, search_term) return course_qs diff --git a/lms/djangoapps/course_api/mobile_serializers.py b/lms/djangoapps/course_api/mobile_serializers.py index b2e8b62cab95..7326f9fde532 100644 --- a/lms/djangoapps/course_api/mobile_serializers.py +++ b/lms/djangoapps/course_api/mobile_serializers.py @@ -3,6 +3,7 @@ """ +from lms.djangoapps.lhub_ecommerce_offer.models import Coupon import six.moves.urllib.error import six.moves.urllib.parse import six.moves.urllib.request @@ -52,6 +53,37 @@ class _CourseApiMediaCollectionSerializer(serializers.Serializer): # pylint: di #course_video = _MediaSerializer(source='*', uri_attribute='course_video_url') image = ImageSerializer(source='image_urls') +class AvailableVouchersSerializer(serializers.ModelSerializer): + """ AvailableVouchers serializer. """ + name = serializers.CharField() + code = serializers.CharField(source="coupon_code") + discount_type = serializers.CharField(source="incentive_type") + discount_value = serializers.DecimalField( + max_digits=12, + decimal_places=2, + source="incentive_value" + ) + allow_combine = serializers.BooleanField(source="is_exclusive") + + def get_identity(self, data): + try: + return data.get('name', None) + except AttributeError: + return None + + class Meta(object): + model = Coupon + fields = ( + 'name', + 'code', + 'discount_type', + 'discount_value', + 'allow_combine', + ) + # For disambiguating within the drf-yasg swagger schema + ref_name = 'lms.Coupon' + + class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-method """ Serializer for Course objects providing minimal data about the course. @@ -76,6 +108,9 @@ class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-meth #mobile_available = serializers.BooleanField() #hidden = serializers.SerializerMethodField() #invitation_only = serializers.BooleanField() + discount_type = serializers.CharField(required=False) + voucher_applicable = serializers.BooleanField(required=False) + available_vouchers = AvailableVouchersSerializer(required=False, many=True) # 'course_id' is a deprecated field, please use 'id' instead. #course_id = serializers.CharField(source='id', read_only=True) diff --git a/lms/djangoapps/course_api/mobile_views.py b/lms/djangoapps/course_api/mobile_views.py index 9638cc277637..470e0e3020f5 100644 --- a/lms/djangoapps/course_api/mobile_views.py +++ b/lms/djangoapps/course_api/mobile_views.py @@ -327,7 +327,8 @@ def get_queryset(self): org=form.cleaned_data['org'], platform='Mobile', filter_=form.cleaned_data['filter_'], - search_term=form.cleaned_data['search_term'] + search_term=form.cleaned_data['search_term'], + get_extra_info=True ) return result @@ -557,7 +558,8 @@ def get_recommended_courses_for_web(request,id=None): course_dict = {'id': six.text_type(course.id), 'org':course.display_org_with_default, 'name': course.display_name, 'image': course.course_image_url ,'code':course.display_number_with_default,'difficulty_level': course.difficulty_level, 'enrollments_count': course.enrollments_count\ ,'ratings': course.ratings, 'comments_count': course.comments_count, 'price': course.price, 'discount_applicable': course.discount_applicable\ - , 'discounted_price': course.discounted_price, 'discount_percentage':course.discount_percentage, 'start': course_start} + , 'discounted_price': course.discounted_price, 'discount_percentage':course.discount_percentage, 'start': course_start, 'discount_type':course.discount_type\ + , 'available_vouchers': course.available_vouchers} course_list.append(course_dict) return Response({'result':course_list}) diff --git a/lms/djangoapps/course_block_user/__init__.py b/lms/djangoapps/course_block_user/__init__.py new file mode 100644 index 000000000000..82ed5d3c5892 --- /dev/null +++ b/lms/djangoapps/course_block_user/__init__.py @@ -0,0 +1 @@ +default_app_config = 'lms.djangoapps.course_block_user.CourseBlockUserConfig' diff --git a/lms/djangoapps/course_block_user/admin.py b/lms/djangoapps/course_block_user/admin.py new file mode 100644 index 000000000000..5339da993970 --- /dev/null +++ b/lms/djangoapps/course_block_user/admin.py @@ -0,0 +1,9 @@ + +# Register your models here. +from django.contrib import admin +from .models import CourseBlockUser + + +# Register your models here. +admin.site.register(CourseBlockUser) + diff --git a/lms/djangoapps/course_block_user/apps.py b/lms/djangoapps/course_block_user/apps.py new file mode 100644 index 000000000000..0a3214ab3a42 --- /dev/null +++ b/lms/djangoapps/course_block_user/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CourseBlockUserConfig(AppConfig): + name = 'lms.djangoapps.course_block_user' + verbose = 'course_block_user' + diff --git a/lms/djangoapps/course_block_user/migrations/0001_initial.py b/lms/djangoapps/course_block_user/migrations/0001_initial.py new file mode 100644 index 000000000000..42d3b2642bf8 --- /dev/null +++ b/lms/djangoapps/course_block_user/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.19 on 2021-05-04 07:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CourseBlockUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('course_id_block', models.CharField(blank=True, default=None, max_length=255, null=True)), + ('block_mobile_view', models.TextField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] + diff --git a/lms/djangoapps/course_block_user/migrations/0002_courseblockuser_descendants.py b/lms/djangoapps/course_block_user/migrations/0002_courseblockuser_descendants.py new file mode 100644 index 000000000000..f3a05755f0ab --- /dev/null +++ b/lms/djangoapps/course_block_user/migrations/0002_courseblockuser_descendants.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.19 on 2021-05-04 10:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_block_user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='courseblockuser', + name='descendants', + field=models.TextField(blank=True, null=True), + ), + ] + diff --git a/lms/djangoapps/course_block_user/migrations/0003_courseblockuser_processed_descendants.py b/lms/djangoapps/course_block_user/migrations/0003_courseblockuser_processed_descendants.py new file mode 100644 index 000000000000..950d3fba35cb --- /dev/null +++ b/lms/djangoapps/course_block_user/migrations/0003_courseblockuser_processed_descendants.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.19 on 2021-05-04 15:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_block_user', '0002_courseblockuser_descendants'), + ] + + operations = [ + migrations.AddField( + model_name='courseblockuser', + name='processed_descendants', + field=models.IntegerField(blank=True, null=True), + ), + ] + diff --git a/lms/djangoapps/course_block_user/migrations/__init__.py b/lms/djangoapps/course_block_user/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/course_block_user/models.py b/lms/djangoapps/course_block_user/models.py new file mode 100644 index 000000000000..6657029f3549 --- /dev/null +++ b/lms/djangoapps/course_block_user/models.py @@ -0,0 +1,19 @@ +from django.db import models + +# Create your models here. +from django.db import models + +# Create your models here. +from django.db import models +from django.contrib.auth.models import User + + +# Create your models here. +class CourseBlockUser(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + course_id_block = models.CharField(max_length=255, null=True, blank=True, default=None) + block_mobile_view = models.TextField(blank=True, null=True) + descendants = models.TextField(blank=True, null=True) + processed_descendants = models.IntegerField(blank=True, null=True) + + diff --git a/lms/djangoapps/course_block_user/tests.py b/lms/djangoapps/course_block_user/tests.py new file mode 100644 index 000000000000..0b4501e258de --- /dev/null +++ b/lms/djangoapps/course_block_user/tests.py @@ -0,0 +1,4 @@ +from django.test import TestCase + +# Create your tests here. + diff --git a/lms/djangoapps/course_block_user/urls.py b/lms/djangoapps/course_block_user/urls.py new file mode 100644 index 000000000000..239d3952e6d7 --- /dev/null +++ b/lms/djangoapps/course_block_user/urls.py @@ -0,0 +1,5 @@ +from django.conf.urls import url + +#from .views import BannerApi +from . import views +urlpatterns = [] diff --git a/lms/djangoapps/course_block_user/views.py b/lms/djangoapps/course_block_user/views.py new file mode 100644 index 000000000000..27cdb63bf2c6 --- /dev/null +++ b/lms/djangoapps/course_block_user/views.py @@ -0,0 +1,4 @@ +from django.shortcuts import render + +# Create your views here. + diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index efaeb173906e..92df24fe9492 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -726,9 +726,16 @@ def get_courses_with_extra_info(user, org=None, filter_=None): course.comments_count = course_extra_info.comments_count course.difficulty_level = course.difficulty_level.capitalize() if course.difficulty_level else "Unknown" course.discount_applicable = course_extra_info.discount_applicable + course.discount_percentage_string = course_extra_info.discount_percentage_string course.discount_percentage = course_extra_info.discount_percentage course.discounted_price = course_extra_info.discounted_price course.currency = course_extra_info.currency + course.discount_type = course_extra_info.discount_type + course.coupon_type = course_extra_info.coupon_type + course.coupon_available = course_extra_info.coupon_available + course.available_vouchers = course_extra_info.available_vouchers + course.coupon_applicable = course_extra_info.coupon_applicable + if len(course_extra_info.modes) == 0: course.price = 0 else: @@ -775,6 +782,15 @@ def get_courses_with_extra_info_json(user, org=None, platform=None, filter_=None course.discount_percentage = course_extra_info.discount_percentage course.discounted_price = course_extra_info.discounted_price course.currency = course_extra_info.currency + course.discount_type = course_extra_info.discount_type + course.coupon_type = course_extra_info.coupon_type + course.coupon_available = course_extra_info.coupon_available + course.coupon_value = course_extra_info.available_vouchers + course.coupon_applicable = course_extra_info.coupon_applicable + course.discount_type = course_extra_info.discount_type + course.voucher_applicable = course_extra_info.coupon_applicable + course.available_vouchers = course_extra_info.available_vouchers + if len(course_extra_info.modes) == 0: course.price = 0 else: @@ -831,6 +847,14 @@ def sort_by_rating(courses): ) return courses +def sort_by_enrollments(courses): + courses = sorted( + courses, + key=lambda course: course.enrollments_count or 0, + reverse=True + ) + return courses + def sort_by_price(courses): courses = sorted( diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index ecb3b1035601..bec4b86080d4 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -7,7 +7,7 @@ import logging from collections import OrderedDict, namedtuple from datetime import datetime - +from custom_reg_form.models import UserExtraInfo import bleach import requests import six @@ -73,6 +73,7 @@ get_studio_url, sort_by_announcement, sort_by_start_date, + sort_by_enrollments, sort_by_rating, sort_by_price, @@ -142,6 +143,8 @@ from ..module_render import get_module, get_module_by_usage_id, get_module_for_descriptor from commerce.api.v1.models import Course from openedx.core.djangoapps.commerce.utils import ecommerce_api_client +from lms.djangoapps.banner.models import Banner +from lms.djangoapps.course_block_user.models import CourseBlockUser log = logging.getLogger("edx.courseware") @@ -263,11 +266,21 @@ def courses(request): """ sort = request.GET.get('sort', '') category_id = request.GET.get('category') + if category_id == "": + category_id = None subcategory_id = request.GET.get('subcategory') + if subcategory_id == "": + subcategory_id = None difficulty_level_id = request.GET.get('difficulty_level') + if difficulty_level_id == "": + difficulty_level_id = None mode = request.GET.get('mode', '') - category = sub_category = difficulty_level = None + # if request.GET.keys() + if mode == "": + mode = None + category = sub_category = difficulty_level = None + show_categorized_view = True courses_list = [] filter_ = None course_discovery_meanings = getattr(settings, 'COURSE_DISCOVERY_MEANINGS', {}) @@ -291,8 +304,12 @@ def courses(request): courses_list = sort_by_rating(courses_list) elif sort == 'price': courses_list = sort_by_price(courses_list) + elif sort == "enrollments": + courses_list = sort_by_enrollments(courses_list) + # else: + # courses_list = sort_by_announcement(courses_list) else: - courses_list = sort_by_announcement(courses_list) + sort = None programs_list = get_programs_with_type(request.site, include_hidden=False) @@ -308,7 +325,8 @@ def courses(request): def filter_courses(course): if course.platform_visibility not in ["Web", "Both", None]: return False - + print('===========123123') + print(course.new_category_id) if category and course.new_category_id != category.id: return False @@ -339,9 +357,27 @@ def filter_courses(course): elif sub_category: selected_category_name = '{} - {}'.format(sub_category.category.name, sub_category.name) + banner_list = Banner.objects.filter(platform__in = ['WEB', 'BOTH'], enabled=True) + + if len(request.GET.keys()) == 0: + + show_categorized_view = True + + elif len(request.GET.keys()) > 0: + if (difficulty_level_id==None) and (sort==None) and (mode==None) and (category_id==None) and (subcategory_id == None): + show_categorized_view = True + else: + show_categorized_view = False + else: + show_categorized_view = False + user_category = None + user_extra_info = UserExtraInfo.objects.filter(user_id=request.user.id).first() + if hasattr(user_extra_info, 'industry_id'): + user_category = Category.objects.filter(id=user_extra_info.industry_id).first().id return render_to_response( "courseware/courses.html", { + 'courses': courses_list, 'course_discovery_meanings': course_discovery_meanings, 'programs_list': programs_list, @@ -351,7 +387,11 @@ def filter_courses(course): 'selected_difficulty_level_id': difficulty_level.id if difficulty_level else '', 'selected_mode': mode, 'sort': sort, - } + 'banner_list': banner_list, + 'show_categorized_view': show_categorized_view, + 'user_industry': user_category, + }, + ) @@ -606,9 +646,9 @@ def get_last_accessed_courseware(course, request, user): configuration_helpers.get_value('COURSE_HOMEPAGE_SHOW_ORG', True) course_title = course.display_number_with_default - course_subtitle = course.display_name_with_default + course_subtitle = course.display_name_with_default if course else None if course_homepage_invert_title: - course_title = course.display_name_with_default + course_title = course.display_name_with_default if course else None course_subtitle = course.display_number_with_default context = { @@ -1766,6 +1806,8 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): course_key = usage_key.course_key requested_view = request.GET.get('view', 'student_view') + next_ = None + previous = None if requested_view != 'student_view': return HttpResponseBadRequest( u"Rendering of the xblock view '{}' is not supported.".format(bleach.clean(requested_view, strip=True)) @@ -1798,6 +1840,59 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user) + path_ = request.get_full_path() + next_qry = None + previous_qry = None + if path_ and request.user.id: + ref_obj = CourseBlockUser.objects.filter(block_mobile_view__icontains = path_, user=request.user) + ref_obj.id = ref_obj[0].id if ref_obj else None + base_url = path_.split("/xblock/") + if ref_obj and ref_obj.id: + next_qry = CourseBlockUser.objects.filter(pk=int(ref_obj.id) + 1) + previous_qry = CourseBlockUser.objects.filter(pk=int(ref_obj.id) - 1) + ref_obj = ref_obj[0] + else: + ref_obj = CourseBlockUser.objects.filter(descendants__icontains=path_, user=request.user) + ref_obj = ref_obj[0] if ref_obj else None + + if ref_obj and ref_obj.descendants: + #loop and split + sub_sections = ref_obj.descendants.strip('[]').split(",") + print('tt', sub_sections) + #process middle + if ref_obj.processed_descendants and ref_obj.processed_descendants+1 < len(sub_sections): + next_ = base_url[0] +'/xblock/'+ (sub_sections[ref_obj.processed_descendants+1]).replace("'", "") + ref_obj.processed_descendants += 1 + else: + # start of black , initial + if len(sub_sections) >=2 and not ref_obj.processed_descendants: + next_ = base_url[0] + '/xblock/'+(sub_sections[1]).replace("'", "") + else: + # start of block , other condition , jump to next major block + next_qry = CourseBlockUser.objects.filter(pk=int(ref_obj.id) + 1) + if next_qry.exists(): + next_ = next_qry[0].block_mobile_view + ref_obj.processed_descendants = 0 + + if ref_obj.processed_descendants and ref_obj.processed_descendants > 0: + + previous = base_url[0]+ '/xblock/'+(sub_sections[ref_obj.processed_descendants-1]).replace("'", "") + ref_obj.processed_descendants += 1 + else: + previous_qry = CourseBlockUser.objects.filter(pk=int(ref_obj.id) - 1) + if not ref_obj.processed_descendants and previous_qry.exists(): + previous = previous_qry[0].block_mobile_view + else: + previous = base_url[0]+ '/xblock/'+(sub_sections[0]).replace("'", "") + ref_obj.processed_descendants = 0 + #no sub section + else: + if next_qry and next_qry.exists(): + next_ = next_qry[0].block_mobile_view + if previous_qry and previous_qry.exists(): + previous = previous_qry[0].block_mobile_view + ref_obj.save() if ref_obj else None + context = { 'fragment': block.render('student_view', context=student_view_context), 'course': course, @@ -1819,7 +1914,10 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): 'is_learning_mfe': is_request_from_learning_mfe(request), 'is_mobile_app': is_request_from_mobile_app(request), 'reset_deadlines_url': reverse(RESET_COURSE_DEADLINES_NAME), + 'next_': next_ if next_ else False, + 'previous': previous if previous else False } + return render_to_response('courseware/courseware-chromeless.html', context) diff --git a/lms/djangoapps/instructor_task/tasks_helper/certs.py b/lms/djangoapps/instructor_task/tasks_helper/certs.py index 04250be8f90c..6d727ebb749b 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/certs.py +++ b/lms/djangoapps/instructor_task/tasks_helper/certs.py @@ -5,6 +5,7 @@ from time import time +from django.core.files.storage import default_storage from django.contrib.auth.models import User from django.db.models import Q @@ -140,6 +141,8 @@ def invalidate_generated_certificates(course_id, enrolled_students, certificate_ status__in=certificate_statuses, ) + [default_storage.delete(certificate.download_url) for certificate in certificates if certificate.download_url] + # Mark generated certificates as 'unavailable' and update download_url, download_uui, verify_uuid and # grade with empty string for each row certificates.update( diff --git a/lms/djangoapps/lhub_ecommerce_offer/__init__.py b/lms/djangoapps/lhub_ecommerce_offer/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/lhub_ecommerce_offer/admin.py b/lms/djangoapps/lhub_ecommerce_offer/admin.py new file mode 100644 index 000000000000..2d7e256722de --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/admin.py @@ -0,0 +1,81 @@ +from django.contrib import admin +from .models import Offer, Coupon +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +import logging + + +# Register your models here. + +class OfferAdmin(admin.ModelAdmin): + """ Admin class for Offer model """ + fields = ( + 'incentive_type', + 'incentive_value', + 'condition_type', + 'condition_value', + 'start_datetime', + 'end_datetime', + 'priority', + 'is_exclusive', + 'associated_ecommerce_offer_id', + 'course', + 'is_suspended' + ) + list_display = [ + 'incentive_type', + 'incentive_value', + 'condition_type', + 'condition_value', + 'start_datetime', + 'end_datetime', + 'priority', + 'is_exclusive', + 'associated_ecommerce_offer_id', + 'courses_sku', + 'is_suspended' + ] + + + def courses_sku(self, obj): + # return "\n".join([a.course_sku for a in obj.CourseOverview.all()]) + # for a in obj.CourseOverview.all(): + pass + + + +class CouponAdmin(admin.ModelAdmin): + """ Coupon class for Offer model """ + fields = ( + 'name', + 'coupon_code', + 'incentive_type', + 'incentive_value', + 'usage', + 'start_datetime', + 'end_datetime', + 'is_exclusive', + 'course', + 'associated_ecommerce_coupon_id', + ) + list_display = [ + 'name', + 'coupon_code', + 'incentive_type', + 'incentive_value', + 'usage', + 'start_datetime', + 'end_datetime', + 'is_exclusive', + 'courses_sku', + 'associated_ecommerce_coupon_id', + ] + + + def courses_sku(self, obj): + # return "\n".join([a.course_sku for a in obj.CourseOverview.all()]) + # for a in obj.CourseOverview.all(): + pass + + +admin.site.register(Offer, OfferAdmin) +admin.site.register(Coupon, CouponAdmin) diff --git a/lms/djangoapps/lhub_ecommerce_offer/apps.py b/lms/djangoapps/lhub_ecommerce_offer/apps.py new file mode 100644 index 000000000000..f34a99f6c6d7 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LhubEcommerceOfferConfig(AppConfig): + name = 'lms.djangoapps.lhub_ecommerce_offer' + verbose_name = 'LHUB Ecommerce Offer' diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/0001_initial.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/0001_initial.py new file mode 100644 index 000000000000..8477c4151e0c --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 2.2.20 on 2021-05-10 08:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('course', models.CharField(max_length=250)), + ], + ), + migrations.CreateModel( + name='Offer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('incentive_type', models.CharField(max_length=250)), + ('incentive_value', models.DecimalField(decimal_places=2, max_digits=12)), + ('condition_type', models.CharField(max_length=250)), + ('condition_value', models.DecimalField(decimal_places=2, max_digits=12)), + ('start_datetime', models.DateTimeField(verbose_name='date published')), + ('end_datetime', models.DateTimeField(verbose_name='date published')), + ('priority', models.CharField(max_length=250)), + ('is_exclusive', models.BooleanField()), + ('associated_ecommerce_offer_id', models.IntegerField()), + ], + ), + ] diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/0002_auto_20210510_1144.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/0002_auto_20210510_1144.py new file mode 100644 index 000000000000..75a5ac1a5137 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/migrations/0002_auto_20210510_1144.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.20 on 2021-05-10 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_overviews', '0039_auto_20210406_1433'), + ('lhub_ecommerce_offer', '0001_initial'), + ] + + operations = [ + migrations.DeleteModel( + name='Course', + ), + migrations.AddField( + model_name='offer', + name='course', + field=models.ManyToManyField(to='course_overviews.CourseOverview'), + ), + ] diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/0003_auto_20210511_1054.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/0003_auto_20210511_1054.py new file mode 100644 index 000000000000..b3a0b90a77df --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/migrations/0003_auto_20210511_1054.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.20 on 2021-05-11 10:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lhub_ecommerce_offer', '0002_auto_20210510_1144'), + ] + + operations = [ + migrations.AlterField( + model_name='offer', + name='priority', + field=models.IntegerField(), + ), + ] diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/0004_offer_is_active.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/0004_offer_is_active.py new file mode 100644 index 000000000000..ec212f292d0f --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/migrations/0004_offer_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.20 on 2021-05-17 05:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lhub_ecommerce_offer', '0003_auto_20210511_1054'), + ] + + operations = [ + migrations.AddField( + model_name='offer', + name='is_active', + field=models.BooleanField(default=False), + ), + ] diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/0005_auto_20210517_0644.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/0005_auto_20210517_0644.py new file mode 100644 index 000000000000..b100652e2a06 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/migrations/0005_auto_20210517_0644.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.20 on 2021-05-17 06:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('lhub_ecommerce_offer', '0004_offer_is_active'), + ] + + operations = [ + migrations.RenameField( + model_name='offer', + old_name='is_active', + new_name='is_suspended', + ), + ] diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/0006_coupon.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/0006_coupon.py new file mode 100644 index 000000000000..6d179cabbe63 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/migrations/0006_coupon.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.20 on 2021-05-17 11:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_overviews', '0039_auto_20210406_1433'), + ('lhub_ecommerce_offer', '0005_auto_20210517_0644'), + ] + + operations = [ + migrations.CreateModel( + name='Coupon', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('coupon_code', models.CharField(max_length=250)), + ('incentive_type', models.CharField(max_length=250)), + ('incentive_value', models.DecimalField(decimal_places=2, max_digits=12)), + ('usage', models.CharField(max_length=250)), + ('start_datetime', models.DateTimeField(verbose_name='start date')), + ('end_datetime', models.DateTimeField(verbose_name='end date')), + ('is_exclusive', models.BooleanField()), + ('associated_ecommerce_coupon_id', models.IntegerField()), + ('course', models.ManyToManyField(to='course_overviews.CourseOverview')), + ], + ), + ] diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/0007_auto_20210520_0738.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/0007_auto_20210520_0738.py new file mode 100644 index 000000000000..090651ed70f9 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/migrations/0007_auto_20210520_0738.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.20 on 2021-05-20 07:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lhub_ecommerce_offer', '0006_coupon'), + ] + + operations = [ + migrations.AlterField( + model_name='offer', + name='end_datetime', + field=models.DateTimeField(null=True, verbose_name='end datetime'), + ), + migrations.AlterField( + model_name='offer', + name='start_datetime', + field=models.DateTimeField(verbose_name='start datetime'), + ), + ] diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/0008_auto_20210520_0919.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/0008_auto_20210520_0919.py new file mode 100644 index 000000000000..2a63d04edc30 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/migrations/0008_auto_20210520_0919.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.20 on 2021-05-20 09:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lhub_ecommerce_offer', '0007_auto_20210520_0738'), + ] + + operations = [ + migrations.AlterField( + model_name='offer', + name='end_datetime', + field=models.DateTimeField(blank=True, null=True, verbose_name='end datetime'), + ), + ] diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/0009_coupon_name.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/0009_coupon_name.py new file mode 100644 index 000000000000..bddde15c6fd4 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/migrations/0009_coupon_name.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.20 on 2021-05-20 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lhub_ecommerce_offer', '0008_auto_20210520_0919'), + ] + + operations = [ + migrations.AddField( + model_name='coupon', + name='name', + field=models.CharField(default='ABC', max_length=250), + preserve_default=False, + ), + ] diff --git a/lms/djangoapps/lhub_ecommerce_offer/migrations/__init__.py b/lms/djangoapps/lhub_ecommerce_offer/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/lhub_ecommerce_offer/models.py b/lms/djangoapps/lhub_ecommerce_offer/models.py new file mode 100644 index 000000000000..91d842526984 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/models.py @@ -0,0 +1,37 @@ +from django.db import models +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + + +class Offer(models.Model): + incentive_type = models.CharField(max_length=250) + incentive_value = models.DecimalField(max_digits=12, decimal_places=2) + condition_type = models.CharField(max_length=250) + condition_value = models.DecimalField(max_digits=12, decimal_places=2) + start_datetime = models.DateTimeField('start datetime') + end_datetime = models.DateTimeField('end datetime', null=True ,blank=True) + priority = models.IntegerField() + is_exclusive = models.BooleanField() + associated_ecommerce_offer_id = models.IntegerField() + course = models.ManyToManyField(CourseOverview) + is_suspended = models.BooleanField(default=False) + + class Meta(object): + app_label = "lhub_ecommerce_offer" + + + +class Coupon(models.Model): + name = models.CharField(max_length=250) + coupon_code = models.CharField(max_length=250) + incentive_type = models.CharField(max_length=250) + incentive_value = models.DecimalField(max_digits=12, decimal_places=2) + usage = models.CharField(max_length=250) + start_datetime = models.DateTimeField('start date') + end_datetime = models.DateTimeField('end date') + is_exclusive = models.BooleanField() + course = models.ManyToManyField(CourseOverview) + associated_ecommerce_coupon_id = models.IntegerField() + + class Meta(object): + app_label = "lhub_ecommerce_offer" diff --git a/lms/djangoapps/lhub_ecommerce_offer/tests.py b/lms/djangoapps/lhub_ecommerce_offer/tests.py new file mode 100644 index 000000000000..7ce503c2dd97 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/lms/djangoapps/lhub_ecommerce_offer/urls.py b/lms/djangoapps/lhub_ecommerce_offer/urls.py new file mode 100644 index 000000000000..6815669e1da9 --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from django.conf.urls import url +from rest_framework.urlpatterns import format_suffix_patterns +from .views import Ecommerce_Offer, Ecommerce_Coupon + + +urlpatterns = [ + # Ecommerce Offer urls + url(r'^add/', Ecommerce_Offer.as_view(), name="lhub_ecommerce_offer"), + url(r'^delete/(?P[0-9]+)/$', Ecommerce_Offer.as_view(), name="lhub_ecommerce_offer_delete"), + + # Ecommerce Coupon urls + url(r'^coupon/add/', Ecommerce_Coupon.as_view(), name="lhub_ecommerce_coupon"), + url(r'^coupon/delete/(?P[0-9]+)/$', Ecommerce_Coupon.as_view(), name="lhub_ecommerce_coupon_delete"), +] + diff --git a/lms/djangoapps/lhub_ecommerce_offer/views.py b/lms/djangoapps/lhub_ecommerce_offer/views.py new file mode 100644 index 000000000000..b7140f5c6abc --- /dev/null +++ b/lms/djangoapps/lhub_ecommerce_offer/views.py @@ -0,0 +1,210 @@ +from logging import info +import logging +from django.shortcuts import render +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from .models import Offer, Coupon +from datetime import datetime +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +class Ecommerce_Offer(APIView): + + def post(self, request): + ecommerce_data = request.data + + # If Offer already exists + # Update it + if Offer.objects.filter(associated_ecommerce_offer_id = ecommerce_data['associated_ecommerce_offer_id']).exists(): + ecommerce_offer = Offer.objects.filter(associated_ecommerce_offer_id = ecommerce_data['associated_ecommerce_offer_id']) + start_datetime_str = ecommerce_data['start_datetime'] + start_datetime = datetime.strptime(start_datetime_str[:19], '%Y-%m-%d %H:%M:%S') + + if ecommerce_data['end_datetime'] != "None": + end_datetime_str = ecommerce_data['end_datetime'] + end_datetime = datetime.strptime(end_datetime_str[:19], '%Y-%m-%d %H:%M:%S') + else: + end_datetime = None + + ecommerce_offer.update( + associated_ecommerce_offer_id = ecommerce_data['associated_ecommerce_offer_id'], + start_datetime = start_datetime, + end_datetime = end_datetime, + priority = ecommerce_data['priority'], + incentive_type = ecommerce_data['incentive_type'], + incentive_value = ecommerce_data['incentive_value'], + condition_type = ecommerce_data['condition_type'], + condition_value = ecommerce_data['condition_value'], + is_exclusive = ecommerce_data['is_exclusive'], + is_suspended = ecommerce_data['is_suspended'], + ) + + + for course in ecommerce_offer[0].course.all(): + # Condition 01: course is present in Offer courses + # and also in the api courses + if str(course.id) in ecommerce_data['courses_id']: + # do nothing + pass + + # Condition 02: course is present in offer courses + # but not in the api courses + elif str(course.id) not in ecommerce_data['courses_id']: + # remove the course from Offer courses + course.offer_set.remove(ecommerce_offer[0]) + + # Condition 03: course is not present in offer courses + # but present in the api courses + for course in ecommerce_data['courses_id']: + if course not in str(ecommerce_offer[0].course.all()): + # add course in the Offer courses + ecommerce_offer[0].course.add(CourseOverview.get_from_id(course)) + + + # Else if Offer does not exist + # Create it + else: + start_datetime_str = ecommerce_data['start_datetime'] + start_datetime = datetime.strptime(start_datetime_str[:19], '%Y-%m-%d %H:%M:%S') + + if ecommerce_data['end_datetime'] != "None": + end_datetime_str = ecommerce_data['end_datetime'] + end_datetime = datetime.strptime(end_datetime_str[:19], '%Y-%m-%d %H:%M:%S') + else: + end_datetime = None + + ecommerce_offer = Offer( + associated_ecommerce_offer_id = ecommerce_data['associated_ecommerce_offer_id'], + start_datetime = start_datetime, + end_datetime = end_datetime, + priority = ecommerce_data['priority'], + incentive_type = ecommerce_data['incentive_type'], + incentive_value = ecommerce_data['incentive_value'], + condition_type = ecommerce_data['condition_type'], + condition_value = ecommerce_data['condition_value'], + is_exclusive = ecommerce_data['is_exclusive'], + is_suspended = ecommerce_data['is_suspended'], + ) + + ecommerce_offer.save() + + for course_id in ecommerce_data['courses_id']: + ecommerce_offer.course.add(CourseOverview.get_from_id(course_id)) + + + return Response({'status': 'Succes'}, status=status.HTTP_200_OK) + + + def delete(self, *args, **kwargs): + + offer_id = self.kwargs.get('offer_id') + try: + offer = Offer.objects.get(associated_ecommerce_offer_id=offer_id) + except Offer.DoesNotExist: + return Response({'status': 'Not Found'}, status=status.HTTP_404_NOT_FOUND) + + offer.delete() + + return Response({'status': 'Succes'}, status=status.HTTP_200_OK) + + + +class Ecommerce_Coupon(APIView): + + def post(self, request): + ecommerce_data = request.data + + # If Coupon already exists + # Update it + if Coupon.objects.filter(associated_ecommerce_coupon_id = ecommerce_data['associated_ecommerce_coupon_id']).exists(): + ecommerce_coupon = Coupon.objects.filter(associated_ecommerce_coupon_id = ecommerce_data['associated_ecommerce_coupon_id']) + start_datetime_str = ecommerce_data['start_datetime'] + start_datetime = datetime.strptime(start_datetime_str[:19], '%Y-%m-%d %H:%M:%S') + + end_datetime_str = ecommerce_data['end_datetime'] + end_datetime = datetime.strptime(end_datetime_str[:19], '%Y-%m-%d %H:%M:%S') + + coupon_code = ecommerce_data['coupon_code'] + coupon_code = coupon_code.upper() + + ecommerce_coupon.update( + associated_ecommerce_coupon_id = ecommerce_data['associated_ecommerce_coupon_id'], + name = ecommerce_data['name'], + coupon_code = coupon_code, + start_datetime = start_datetime, + end_datetime = end_datetime, + incentive_type = ecommerce_data['incentive_type'], + incentive_value = ecommerce_data['incentive_value'], + usage = ecommerce_data['usage'], + is_exclusive = ecommerce_data['is_exclusive'], + ) + + for course in ecommerce_coupon[0].course.all(): + # Condition 01: course is present in coupon courses + # and also in the api courses + if str(course.id) in ecommerce_data['courses_id']: + # do nothing + pass + + # Condition 02: course is present in coupon courses + # but not in the api courses + elif str(course.id) not in ecommerce_data['courses_id']: + # remove the course from coupon courses + course.coupon_set.remove(ecommerce_coupon[0]) + + # Condition 03: course is not present in coupon courses + # but present in the api courses + for course in ecommerce_data['courses_id']: + if course not in str(ecommerce_coupon[0].course.all()): + # add course in the coupon courses + ecommerce_coupon[0].course.add(CourseOverview.get_from_id(course)) + + + # Else if Offer does not exist + # Create it + else: + start_datetime_str = ecommerce_data['start_datetime'] + start_datetime = datetime.strptime(start_datetime_str[:19], '%Y-%m-%d %H:%M:%S') + + end_datetime_str = ecommerce_data['end_datetime'] + end_datetime = datetime.strptime(end_datetime_str[:19], '%Y-%m-%d %H:%M:%S') + + coupon_code = ecommerce_data['coupon_code'] + coupon_code = coupon_code.upper() + + + ecommerce_coupon = Coupon( + associated_ecommerce_coupon_id = ecommerce_data['associated_ecommerce_coupon_id'], + name = ecommerce_data['name'], + coupon_code = coupon_code, + start_datetime = start_datetime, + end_datetime = end_datetime, + incentive_type = ecommerce_data['incentive_type'], + incentive_value = ecommerce_data['incentive_value'], + usage = ecommerce_data['usage'], + is_exclusive = ecommerce_data['is_exclusive'], + ) + + ecommerce_coupon.save() + + for course_id in ecommerce_data['courses_id']: + ecommerce_coupon.course.add(CourseOverview.get_from_id(course_id)) + + + return Response({'status': 'Succes'}, status=status.HTTP_200_OK) + + + def delete(self, *args, **kwargs): + + coupon_id = self.kwargs.get('coupon_id') + try: + coupon = Coupon.objects.get(associated_ecommerce_coupon_id=coupon_id) + except Coupon.DoesNotExist: + return Response({'status': 'Not Found'}, status=status.HTTP_404_NOT_FOUND) + + coupon.delete() + + return Response({'status': 'Succes'}, status=status.HTTP_200_OK) + + diff --git a/lms/djangoapps/lhub_extended_api/urls.py b/lms/djangoapps/lhub_extended_api/urls.py index 59d4fe309675..a574d30cbe75 100644 --- a/lms/djangoapps/lhub_extended_api/urls.py +++ b/lms/djangoapps/lhub_extended_api/urls.py @@ -3,4 +3,5 @@ urlpatterns = [ url(r'^orders$', views.LHUBOrdersHistoryView.as_view()), + url(r'^orders/(?P[-\w]+)$', views.LHUBOrderDetailView.as_view()), ] diff --git a/lms/djangoapps/lhub_extended_api/views.py b/lms/djangoapps/lhub_extended_api/views.py index c9968c2c30f6..cb58525a8423 100644 --- a/lms/djangoapps/lhub_extended_api/views.py +++ b/lms/djangoapps/lhub_extended_api/views.py @@ -1,8 +1,13 @@ +from datetime import datetime +from common.djangoapps.util.date_utils import strftime_localized from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated +from lms.djangoapps.commerce.utils import EcommerceService +from openedx.core.djangoapps.commerce.utils import ecommerce_api_client +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.user_api.accounts.settings_views import get_user_orders from openedx.core.lib.api.authentication import BearerAuthentication @@ -94,6 +99,8 @@ def get(self, request): } ) + [update_order_format(order) for order in user_orders] + return Response( { "message": "", @@ -102,3 +109,149 @@ def get(self, request): "result": user_orders } ) + + +class LHUBOrderDetailView(APIView): + + authentication_classes = (JwtAuthentication, BearerAuthentication, SessionAuthentication) + permission_classes = [IsAuthenticated] + + def get(self, request, order_number): + """ + **Example Request** + + /lhub_extended_api/orders/ + + **Example GET Response** + + { + "message": "", + "status": true, + "status_code": 200, + "result": { + "billing_address": { + "first_name": "staff", + "last_name": "real", + "line1": "qwerty", + "line2": "12", + "postcode": "", + "state": "", + "country": "AG", + "city": "krakow" + }, + "currency": "USD", + "discount": "0", + "lines": [ + { + "title": "Seat in edX Demonstration Course with verified certificate (and ID verification)", + "quantity": 1, + "description": "Seat in edX Demonstration Course with verified certificate (and ID verification)", + "status": "Complete", + "line_price_excl_tax": "149.00", + "unit_price_excl_tax": "149.00", + "product": { + "id": 3, + "url": "http://edx.devstack.ecommerce:18130/api/v2/products/3/", + "structure": "child", + "product_class": "Seat", + "title": "Seat in edX Demonstration Course with verified certificate (and ID verification)", + "price": "149.00", + "expires": "2022-03-29T13:18:02.483296Z", + "is_available_to_buy": true, + "stockrecords": [ + { + "id": 3, + "product": 3, + "partner": 1, + "partner_sku": "8CF08E5", + "price_currency": "USD", + "price_excl_tax": "149.00" + } + ], + "course_id": "course-v1:edX+DemoX+Demo_Course", + "course_image_url": "/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg" + } + } + ], + "number": "EDX-100002", + "payment_processor": "cybersource", + "status": "Complete", + "user": { + "email": "staff@example.com", + "username": "staff" + }, + "vouchers": [], + "payment_method": "1111 Visa", + "order_date": "Apr 14, 2021", + "receipt_url": "http://localhost:18130/checkout/receipt/?order_number=EDX-100002", + "items_total": "149.00", + "subtotal": "149.00", + "gst": "0.0" + } + } + """ + try: + user_order = get_user_order(request.user, order_number) + except: + return Response( + status=400, + data={ + "message": "Error fetching order history", + "status": False, + "status_code": 400, + "result": [] + } + ) + + if user_order: + update_order_format(user_order) + + return Response( + data={ + "message": "", + "status": True, + "status_code": 200, + "result": user_order + } + ) + + +def update_order_format(order): + lines = order.get('lines', []) + for line in lines: + product = line.get('product', {}) + attribute_values = product.pop('attribute_values') + + course_id = next(attribute_value.get('value') for attribute_value in attribute_values if + attribute_value.get('code') == 'course_key') + + course = CourseOverview.objects.filter(id=course_id).first() + product.update({ + "course_id": course_id, + "course_image_url": course.course_image_url if course else '' + }) + + +def get_user_order(user, number): + """Given a user, get the detail of all the orders from the Ecommerce service. + + Args: + user (User): The user to authenticate as when requesting ecommerce. + number (str): The number of specific order + + Returns: + Dict, representing order returned by the Ecommerce service. + """ + order = ecommerce_api_client(user).orders(number).get() + + if order['status'].lower() == 'complete': + date_placed = datetime.strptime(order.pop('date_placed'), "%Y-%m-%dT%H:%M:%SZ") + order.update({ + 'order_date': strftime_localized(date_placed, 'SHORT_DATE'), + 'receipt_url': EcommerceService().get_receipt_page_url(order['number']), + 'items_total': order.pop('total_excl_tax'), + 'subtotal': order.pop('total_incl_tax'), + 'gst': str(order.pop('total_tax')) + }) + + return order diff --git a/lms/djangoapps/lhub_mobile/permissions.py b/lms/djangoapps/lhub_mobile/permissions.py new file mode 100644 index 000000000000..aad2efcc9785 --- /dev/null +++ b/lms/djangoapps/lhub_mobile/permissions.py @@ -0,0 +1,17 @@ +""" Custom API permissions. """ + + +from django.contrib.auth.models import User +from rest_framework.permissions import BasePermission, DjangoModelPermissions + +from openedx.core.lib.api.permissions import ApiKeyHeaderPermission + +from lms.djangoapps.commerce.utils import is_account_activation_requirement_disabled + +class ApiKeyOrModelPermission(BasePermission): + """ Access granted for requests with API key in header, + or made by user with appropriate Django model permissions. """ + def has_permission(self, request, view): + return ApiKeyHeaderPermission().has_permission(request, view) or DjangoModelPermissions().has_permission( + request, view) + diff --git a/lms/djangoapps/lhub_mobile/serializers.py b/lms/djangoapps/lhub_mobile/serializers.py new file mode 100644 index 000000000000..d971f7f6dc34 --- /dev/null +++ b/lms/djangoapps/lhub_mobile/serializers.py @@ -0,0 +1,207 @@ +""" API v1 serializers. """ + + +from datetime import datetime +import logging +import pytz +import six +from django.utils.translation import ugettext as _ +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework import serializers + +from common.djangoapps.course_modes.models import CourseMode +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from xmodule.modulestore.django import modulestore + +from lms.djangoapps.commerce.api.v1.models import UNDEFINED, Course +from openedx.core.lib.api.fields import AbsoluteURLField +logger = logging.getLogger(__name__) + +class CourseModeSerializer(serializers.ModelSerializer): + """ CourseMode serializer. """ + name = serializers.CharField(source='mode_slug') + price = serializers.FloatField(source='min_price') + price_string = serializers.CharField(source='get_price_string', required=False) + expires = serializers.DateTimeField( + source='expiration_datetime', + required=False, + allow_null=True, + format=None + ) + + def get_identity(self, data): + try: + return data.get('name', None) + except AttributeError: + return None + + class Meta(object): + model = CourseMode + fields = ('name', 'currency', 'price', 'price_string', 'sku', 'bulk_sku', 'expires') + # For disambiguating within the drf-yasg swagger schema + ref_name = 'commerce.CourseMode' + + +def validate_course_id(course_id): + """ + Check that course id is valid and exists in modulestore. + """ + try: + course_key = CourseKey.from_string(six.text_type(course_id)) + except InvalidKeyError: + raise serializers.ValidationError( + _(u"{course_id} is not a valid course key.").format( + course_id=course_id + ) + ) + + if not modulestore().has_course(course_key): + raise serializers.ValidationError( + _(u'Course {course_id} does not exist.').format( + course_id=course_id + ) + ) + + +class PossiblyUndefinedDateTimeField(serializers.DateTimeField): + """ + We need a DateTime serializer that can deal with the non-JSON-serializable + UNDEFINED object. + """ + def to_representation(self, value): + if value is UNDEFINED: + return None + return super(PossiblyUndefinedDateTimeField, self).to_representation(value) + + +class _MediaSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Nested serializer to represent a media object. + """ + + def __init__(self, uri_attribute, *args, **kwargs): + super(_MediaSerializer, self).__init__(*args, **kwargs) + self.uri_attribute = uri_attribute + + uri = serializers.SerializerMethodField(source='*') + + def get_uri(self, course_overview): + """ + Get the representation for the media resource's URI + """ + return getattr(course_overview, self.uri_attribute) + +class ImageSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Collection of URLs pointing to images of various sizes. + + The URLs will be absolute URLs with the host set to the host of the current request. If the values to be + serialized are already absolute URLs, they will be unchanged. + """ + raw = AbsoluteURLField() + small = AbsoluteURLField() + large = AbsoluteURLField() + + +class _CourseApiMediaCollectionSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Nested serializer to represent a collection of media objects + """ + #course_image = _MediaSerializer(source='*', uri_attribute='course_image_url') + #course_video = _MediaSerializer(source='*', uri_attribute='course_video_url') + image = ImageSerializer(source='image_urls') + + +class CourseSerializer(serializers.Serializer): + """ Course serializer. """ + id = serializers.CharField(validators=[validate_course_id]) # pylint: disable=invalid-name + name = serializers.CharField(read_only=True) + difficulty_level = serializers.CharField(read_only=True) + comments_count = serializers.IntegerField(read_only=True) + enrollments_count = serializers.IntegerField(read_only=True) + created = serializers.DateTimeField(read_only=True) + ratings = serializers.FloatField(required=False) + verification_deadline = PossiblyUndefinedDateTimeField(format=None, allow_null=True, required=False) + modes = CourseModeSerializer(many=True) + discount_applicable = serializers.BooleanField(required=False) + discounted_price = serializers.FloatField(required=False) + discounted_price_string = serializers.CharField(required=False) + sale_type = serializers.CharField(required=False) + subcategory_id = serializers.CharField(required=False) + category = serializers.CharField(required=False) + platform_visibility = serializers.CharField(required=False) + is_premium = serializers.BooleanField(required=False) + media = _CourseApiMediaCollectionSerializer(source='*',required=False) + discount_percentage = serializers.FloatField(required=False) + discount_percentage_string = serializers.CharField(required=False) + allow_review = serializers.BooleanField(required=False) + + class Meta(object): + # For disambiguating within the drf-yasg swagger schema + ref_name = 'commerce.Course' + + def validate(self, attrs): + """ Ensure the verification deadline occurs AFTER the course mode enrollment deadlines. """ + verification_deadline = attrs.get('verification_deadline', None) + + if verification_deadline: + upgrade_deadline = None + + # Find the earliest upgrade deadline + for mode in attrs['modes']: + expires = mode.get("expiration_datetime") + if expires: + # If we don't already have an upgrade_deadline value, use datetime.max so that we can actually + # complete the comparison. + upgrade_deadline = min(expires, upgrade_deadline or datetime.max.replace(tzinfo=pytz.utc)) + + # In cases where upgrade_deadline is None (e.g. the verified professional mode), allow a verification + # deadline to be set anyway. + if upgrade_deadline is not None and verification_deadline < upgrade_deadline: + raise serializers.ValidationError( + 'Verification deadline must be after the course mode upgrade deadlines.') + + return attrs + + def create(self, validated_data): + """ + Create course modes for a course. + + arguments: + validated_data: The result of self.validate() - a dictionary containing 'id', 'modes', and optionally + a 'verification_deadline` key. + returns: + A ``commerce.api.v1.models.Course`` object. + """ + kwargs = {} + if 'verification_deadline' in validated_data: + kwargs['verification_deadline'] = validated_data['verification_deadline'] + + course = Course( + validated_data["id"], + self._new_course_mode_models(validated_data["modes"]), + **kwargs + ) + course.save() + return course + + def update(self, instance, validated_data): + """Update course modes for an existing course. """ + validated_data["modes"] = self._new_course_mode_models(validated_data["modes"]) + instance.update(validated_data) + instance.save() + + course_overview = CourseOverview.objects.get(id=instance.id) + course_overview.course_price = instance.modes[0].min_price + course_overview.save() + + return instance + + @staticmethod + def _new_course_mode_models(modes_data): + """Convert validated course mode data to CourseMode objects. """ + return [ + CourseMode(**modes_dict) + for modes_dict in modes_data + ] diff --git a/lms/djangoapps/lhub_mobile/urls.py b/lms/djangoapps/lhub_mobile/urls.py index 8e900ed76178..bd20828d9ede 100644 --- a/lms/djangoapps/lhub_mobile/urls.py +++ b/lms/djangoapps/lhub_mobile/urls.py @@ -1,7 +1,7 @@ from django.urls import path -from django.conf.urls import url - -from .views import UserSessionCookieView +from django.conf.urls import include, url +from django.conf import settings +from .views import UserSessionCookieView, CourseRetrieveUpdateView GETVIEW = UserSessionCookieView.as_view({ @@ -9,6 +9,9 @@ }) +COURSE_URLS = ([ + url(r'^{}/$'.format(settings.COURSE_ID_PATTERN), CourseRetrieveUpdateView.as_view(), name='retrieve_update'), +], 'courses') urlpatterns = [ # path('', views.index, name='index'), @@ -17,4 +20,5 @@ GETVIEW, name='get_user_cookie_api' ), + url(r'^courses/', include(COURSE_URLS)), ] diff --git a/lms/djangoapps/lhub_mobile/views.py b/lms/djangoapps/lhub_mobile/views.py index 8cef0a4138c2..50fce5561c1e 100644 --- a/lms/djangoapps/lhub_mobile/views.py +++ b/lms/djangoapps/lhub_mobile/views.py @@ -5,6 +5,17 @@ from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from rest_framework import permissions from .models import MobileUserSessionCookie +from openedx.core.lib.api.mixins import PutAsCreateMixin +from rest_framework.generics import ListAPIView, RetrieveUpdateAPIView +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from openedx.core.lib.api.authentication import BearerAuthentication +from rest_framework.authentication import SessionAuthentication +from .permissions import ApiKeyOrModelPermission +from .serializers import CourseSerializer +from common.djangoapps.course_modes.models import CourseMode +from lms.djangoapps.commerce.api.v1.models import Course +from django.http import Http404 + class UserSessionCookieView(ViewSet): """ @@ -30,3 +41,31 @@ def get(self, request): return Response({'message': "Session Cookie Not Found", 'status': True, 'result':{}, 'status_code':400}) +class CourseRetrieveUpdateView(PutAsCreateMixin, RetrieveUpdateAPIView): + """ Retrieve, update, or create courses/modes. """ + lookup_field = 'id' + lookup_url_kwarg = 'course_id' + model = CourseMode + authentication_classes = (JwtAuthentication, BearerAuthentication, SessionAuthentication,) + permission_classes = (ApiKeyOrModelPermission,) + serializer_class = CourseSerializer + + # Django Rest Framework v3 requires that we provide a queryset. + # Note that we're overriding `get_object()` below to return a `Course` + # rather than a CourseMode, so this isn't really used. + queryset = CourseMode.objects.all() + + def get_object(self, queryset=None): + course_id = self.kwargs.get(self.lookup_url_kwarg) + course = Course.get(course_id) + + if course: + return course + + raise Http404 + + def pre_save(self, obj): + # There is nothing to pre-save. The default behavior changes the Course.id attribute from + # a CourseKey to a string, which is not desired. + pass + diff --git a/lms/djangoapps/lhub_notification/migrations/0002_auto_20210419_1442.py b/lms/djangoapps/lhub_notification/migrations/0002_auto_20210419_1442.py new file mode 100644 index 000000000000..47787ef10267 --- /dev/null +++ b/lms/djangoapps/lhub_notification/migrations/0002_auto_20210419_1442.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.18 on 2021-04-19 14:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lhub_notification', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='days_warning', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('enrollment', 'Enrollment'), ('not_active', 'Not active'), ('first_not_completed', 'First not completed'), ('second_not_completed', 'Second not completed')], max_length=64), + ), + ] diff --git a/lms/djangoapps/lhub_notification/models.py b/lms/djangoapps/lhub_notification/models.py index 9f73e5a8331c..89f85cb35287 100644 --- a/lms/djangoapps/lhub_notification/models.py +++ b/lms/djangoapps/lhub_notification/models.py @@ -1,28 +1,38 @@ +from completion.models import BlockCompletion from django.contrib.auth import get_user_model from django.db import models from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ +from lms.djangoapps.course_blocks.api import get_course_blocks from model_utils.models import TimeStampedModel from common.djangoapps.student.models import EnrollStatusChange from common.djangoapps.student.signals import ENROLL_STATUS_CHANGE from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from xmodule.modulestore.django import modulestore class Notification(TimeStampedModel): ENROLLMENT = 'enrollment' + NOT_ACTIVE = 'not_active' + FIRST_NOT_COMPLETED = 'first_not_completed' + SECOND_NOT_COMPLETED = 'second_not_completed' TYPE_CHOICES = ( (ENROLLMENT, _('Enrollment')), + (NOT_ACTIVE, _('Not active')), + (FIRST_NOT_COMPLETED, _('First not completed')), + (SECOND_NOT_COMPLETED, _('Second not completed')), ) - notification_type = models.CharField(max_length=16, choices=TYPE_CHOICES) + notification_type = models.CharField(max_length=64, choices=TYPE_CHOICES) user = models.ForeignKey(get_user_model(), null=True, on_delete=models.CASCADE, related_name='lhub_notifications') course = models.ForeignKey(CourseOverview, null=True, on_delete=models.CASCADE) title = models.TextField() message = models.TextField() is_read = models.BooleanField(default=False) is_delete = models.BooleanField(default=False) + days_warning = models.IntegerField(null=True, blank=True) class Meta: ordering = ['-created'] @@ -35,6 +45,64 @@ def num_new_notifications(self): is_read=False ).count() + @classmethod + def create_not_active_notification(cls, enrollment, days_warning): + course = enrollment.course + user = enrollment.user + has_progress = BlockCompletion.objects.filter( + user=user, + context_key=course.id + ).exists() + + if not has_progress: + display_name = course.display_name_with_default + title = _(f'You enrolled in {display_name} {days_warning} days ago, start learning today!') + message = _(f'

You enrolled in {display_name} on {enrollment.created.date()}, ' + f'what are you waiting for?

You can browse through the course overview ' + f'and progress through the course by completing chapters and quizzes. ' + f'Completing this course will award you with a Certificate of Achievement!

') + + cls.objects.create( + user=user, + course=course, + notification_type=cls.NOT_ACTIVE, + title=title, + message=message, + days_warning=days_warning + ) + + @classmethod + def create_not_completed_notification(cls, enrollment, days_warning, notification_type): + course = enrollment.course + user = enrollment.user + store = modulestore() + course_usage_key = store.make_course_usage_key(course.id) + block_data = get_course_blocks(user, course_usage_key, include_completion=True) + is_not_completed = False + + for section_key in block_data.get_children(course_usage_key): + if not block_data.get_xblock_field(section_key, 'complete', False): + is_not_completed = True + break + + if is_not_completed: + display_name = course.display_name_with_default + title = _(f'{display_name} will be expiring in {days_warning} days! ' + f'Complete it before your access to the course is removed on {course.end_date.date()}.') + message = _(f'

{display_name} is expiring on {course.end_date.date()}, {days_warning} from today!

' + f'

Complete the course before your access is removed to receive your ' + f'Certificate of Achievement. Once it expires, you will no longer be able to ' + f'access course materials.

') + + cls.objects.create( + user=user, + course=course, + notification_type=notification_type, + title=title, + message=message, + days_warning=days_warning + ) + @receiver(ENROLL_STATUS_CHANGE) def create_notification_on_enrollment(sender, event=None, user=None, course_id=None, @@ -49,13 +117,11 @@ def create_notification_on_enrollment(sender, event=None, user=None, course_id=N return display_name = course.display_name_with_default - title = u'You are enrolled in {}'.format(display_name) - message = u'

Congratulations! You can now start learning your new course {}

' \ - u'

You can browse through the course overview and progress through ' \ - u'the course by completing chapters and quizzes. ' \ - u'Completing this course will award you with a Certificate of Achievement!

'.format( - display_name, - ) + title = _(f'You are enrolled in {display_name}') + message = _(f'

Congratulations! You can now start learning your new course {display_name}

' + f'

You can browse through the course overview and progress through ' + f'the course by completing chapters and quizzes. ' + f'Completing this course will award you with a Certificate of Achievement!

') Notification.objects.create( user=user, diff --git a/lms/djangoapps/lhub_notification/serializers.py b/lms/djangoapps/lhub_notification/serializers.py index 4cd01e1d8f5c..27468544ee8b 100644 --- a/lms/djangoapps/lhub_notification/serializers.py +++ b/lms/djangoapps/lhub_notification/serializers.py @@ -6,15 +6,19 @@ class NotificationSerializer(serializers.ModelSerializer): num_new_notifications = serializers.SerializerMethodField() course_url = serializers.SerializerMethodField() + course_id = serializers.SerializerMethodField() class Meta: model = Notification fields = ['id', 'title', 'message', 'is_read', 'course_url', 'num_new_notifications', - 'notification_type', 'created'] - read_only_fields = ('title', 'message', 'notification_type', 'created') + 'notification_type', 'created', 'days_warning', 'course_id'] + read_only_fields = ('title', 'message', 'notification_type', 'created', 'days_warning') def get_course_url(self, obj): return reverse('course_root', kwargs={'course_id': obj.course_id}) if obj.course else '' def get_num_new_notifications(self, obj): return obj.num_new_notifications + + def get_course_id(self, obj): + return str(obj.course_id) diff --git a/lms/djangoapps/lhub_notification/tasks.py b/lms/djangoapps/lhub_notification/tasks.py new file mode 100644 index 000000000000..d494dacbad27 --- /dev/null +++ b/lms/djangoapps/lhub_notification/tasks.py @@ -0,0 +1,194 @@ +from celery.schedules import crontab +from celery.task import periodic_task +from datetime import datetime, timedelta +from django.conf import settings +from lms.djangoapps.lhub_notification.models import Notification +from openedx.core.djangoapps.site_configuration.models import SiteConfiguration +from student.models import CourseEnrollment + + +cron_not_active_notification_settings = { + 'minute': '0', + 'hour': '1', + 'day_of_month': '*', + 'day_of_week': '*', + 'month_of_year': '*', +} + + +@periodic_task(run_every=crontab(**cron_not_active_notification_settings)) +def create_not_active_notification(): + days_warning = 3 + + try: + main_site_conf = SiteConfiguration.objects.get(site__id=settings.SITE_ID) + except SiteConfiguration.DoesNotExist: + pass + else: + days_warning = main_site_conf.get_value('NOT_ACTIVE_DAYS', days_warning) + + microsites_conf = SiteConfiguration.objects.exclude(site__id=settings.SITE_ID) + exclude_organisations = [] + + for configuration in microsites_conf: + course_org_list = configuration.get_value('course_org_filter') + + if not course_org_list: + continue + + if not isinstance(course_org_list, list): + course_org_list = [course_org_list] + + exclude_organisations.extend(course_org_list) + microsite_days_warning = configuration.get_value('NOT_ACTIVE_DAYS', days_warning) + microsite_target_date = datetime.now() - timedelta(days=microsite_days_warning) + microsite_day_start = microsite_target_date.replace(hour=0, minute=0, second=0, microsecond=0) + microsite_day_end = microsite_day_start + timedelta(days=1) + enrolled_users_data = CourseEnrollment.objects.filter( + is_active=True, + created__range=(microsite_day_start, microsite_day_end), + course__org__in=course_org_list + ).select_related('user', 'course') + + [Notification.create_not_active_notification(enrollment, microsite_days_warning) + for enrollment in enrolled_users_data] + + target_date = datetime.now() - timedelta(days=days_warning) + day_start = target_date.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + enrolled_users_data = CourseEnrollment.objects.filter( + is_active=True, + created__range=(day_start, day_end), + ).exclude( + course__org__in=exclude_organisations + ).select_related('user', 'course') + + [Notification.create_not_active_notification(enrollment, days_warning) for enrollment in enrolled_users_data] + + +cron_first_not_completed_notification_settings = { + 'minute': '0', + 'hour': '2', + 'day_of_month': '*', + 'day_of_week': '*', + 'month_of_year': '*', +} + + +@periodic_task(run_every=crontab(**cron_first_not_completed_notification_settings)) +def create_first_not_completed_notification(): + days_warning = 7 + notification_type = Notification.FIRST_NOT_COMPLETED + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + try: + main_site_conf = SiteConfiguration.objects.get(site__id=settings.SITE_ID) + except SiteConfiguration.DoesNotExist: + pass + else: + days_warning = main_site_conf.get_value('FIRST_NOT_COMPLETED_DAYS', days_warning) + + microsites_conf = SiteConfiguration.objects.exclude(site__id=settings.SITE_ID) + exclude_organisations = [] + + for configuration in microsites_conf: + course_org_list = configuration.get_value('course_org_filter') + + if not course_org_list: + continue + + if not isinstance(course_org_list, list): + course_org_list = [course_org_list] + + exclude_organisations.extend(course_org_list) + microsite_days_warning = configuration.get_value('FIRST_NOT_COMPLETED_DAYS', days_warning) + microsite_target_date = today + timedelta(days=microsite_days_warning) + day_start = microsite_target_date.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + + enrolled_users_data = CourseEnrollment.objects.filter( + is_active=True, + course__end_date__range=(day_start, day_end), + course__org__in=course_org_list + ).select_related('user', 'course') + + [Notification.create_not_completed_notification(enrollment, microsite_days_warning, notification_type) + for enrollment in enrolled_users_data] + + target_date = today + timedelta(days=days_warning) + day_start = target_date.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + + enrolled_users_data = CourseEnrollment.objects.filter( + is_active=True, + course__end_date__range=(day_start, day_end) + ).exclude( + course__org__in=exclude_organisations + ).select_related('user', 'course') + + [Notification.create_not_completed_notification(enrollment, days_warning, notification_type) + for enrollment in enrolled_users_data] + + +cron_second_not_completed_notification_settings = { + 'minute': '0', + 'hour': '3', + 'day_of_month': '*', + 'day_of_week': '*', + 'month_of_year': '*', +} + + +@periodic_task(run_every=crontab(**cron_second_not_completed_notification_settings)) +def create_second_not_completed_notification(): + days_warning = 3 + notification_type = Notification.SECOND_NOT_COMPLETED + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + try: + main_site_conf = SiteConfiguration.objects.get(site__id=settings.SITE_ID) + except SiteConfiguration.DoesNotExist: + pass + else: + days_warning = main_site_conf.get_value('SECOND_NOT_COMPLETED_DAYS', days_warning) + + microsites_conf = SiteConfiguration.objects.exclude(site__id=settings.SITE_ID) + exclude_organisations = [] + + for configuration in microsites_conf: + course_org_list = configuration.get_value('course_org_filter') + + if not course_org_list: + continue + + if not isinstance(course_org_list, list): + course_org_list = [course_org_list] + + exclude_organisations.extend(course_org_list) + microsite_days_warning = configuration.get_value('SECOND_NOT_COMPLETED_DAYS', days_warning) + microsite_target_date = today + timedelta(days=microsite_days_warning) + day_start = microsite_target_date.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + + enrolled_users_data = CourseEnrollment.objects.filter( + is_active=True, + course__end_date__range=(day_start, day_end), + course__org__in=course_org_list + ).select_related('user', 'course') + + [Notification.create_not_completed_notification(enrollment, microsite_days_warning, notification_type) + for enrollment in enrolled_users_data] + + target_date = today + timedelta(days=days_warning) + day_start = target_date.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + + enrolled_users_data = CourseEnrollment.objects.filter( + is_active=True, + course__end_date__range=(day_start, day_end) + ).exclude( + course__org__in=exclude_organisations + ).select_related('user', 'course') + + [Notification.create_not_completed_notification(enrollment, days_warning, notification_type) + for enrollment in enrolled_users_data] diff --git a/lms/djangoapps/lhub_notification/views.py b/lms/djangoapps/lhub_notification/views.py index 63284b92e56e..d8eb4cc23b75 100644 --- a/lms/djangoapps/lhub_notification/views.py +++ b/lms/djangoapps/lhub_notification/views.py @@ -5,8 +5,10 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework import mixins from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import GenericViewSet +from rest_framework.response import Response from openedx.core.lib.api.authentication import BearerAuthentication from common.djangoapps.edxmako.shortcuts import render_to_response @@ -34,6 +36,79 @@ def perform_destroy(self, instance): instance.is_delete = True instance.save() + def list(self, request, *args, **kwargs): + response = super().list(request, *args, **kwargs) + response.data.update({ + 'message': '', + 'status': True, + 'status_code': 200 + }) + response.data.update({ + 'result': { + 'pagination': { + 'next': response.data.pop('next'), + 'previous': response.data.pop('previous'), + 'count': response.data.pop('count'), + 'num_pages': response.data.pop('num_pages'), + 'current_page': response.data.pop('current_page'), + 'start': response.data.pop('start') + }, + 'results': response.data.pop('results') + } + }) + return response + + def retrieve(self, request, *args, **kwargs): + response = super().retrieve(request, *args, **kwargs) + return Response(data={ + "message": "", + "status": True, + "status_code": 200, + "result": response.data + }) + + @action(methods=['post'], detail=False, url_path='mark-selected-read') + def mark_selected_read(self, request, *args, **kwargs): + ids = request.data.get('ids', []) + Notification.objects.filter(id__in=ids).update(is_read=True) + return Response( + status=200, + data={ + "message": "", + "status": True, + "status_code": 200, + "result": {} + } + ) + + @action(methods=['post'], detail=False, url_path='mark-selected-unread') + def mark_selected_unread(self, request, *args, **kwargs): + ids = request.data.get('ids', []) + Notification.objects.filter(id__in=ids).update(is_read=False) + return Response( + status=200, + data={ + "message": "", + "status": True, + "status_code": 200, + "result": {} + } + ) + + @action(methods=['post'], detail=False, url_path='selected-delete') + def selected_delete(self, request, *args, **kwargs): + ids = request.data.get('ids', []) + Notification.objects.filter(id__in=ids).delete() + return Response( + status=200, + data={ + "message": "", + "status": True, + "status_code": 200, + "result": {} + } + ) + class NotificationListView(ListView): paginate_by = 10 diff --git a/lms/djangoapps/mobile_api/course_info/lhub_views.py b/lms/djangoapps/mobile_api/course_info/lhub_views.py new file mode 100644 index 000000000000..c0c18ee85c88 --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/lhub_views.py @@ -0,0 +1,57 @@ +from rest_framework.response import Response +from lms.djangoapps.courseware.courses import get_course_info_section_module +from lms.djangoapps.mobile_api.course_info.views import apply_wrappers_to_content, CourseHandoutsList +from lms.djangoapps.mobile_api.decorators import mobile_course_access, mobile_view + + +@mobile_view() +class LHUBCourseHandoutsList(CourseHandoutsList): + """ + **Use Case** + + Get the HTML for course handouts. + + **Example Request** + + GET /api/mobile/v1/course_info/{course_id}/lhub/handouts + + **Response Values** + + If the request is successful, the request returns an HTTP 200 "OK" + response along with the following value. + + * handouts_html: The HTML for course handouts. + """ + + @mobile_course_access() + def list(self, request, course, *args, **kwargs): + course_handouts_module = get_course_info_section_module(request, request.user, course, 'handouts') + if course_handouts_module: + if course_handouts_module.data == "
    ": + handouts_html = None + else: + handouts_html = apply_wrappers_to_content(course_handouts_module.data, course_handouts_module, request) + return Response( + status=200, + data={ + "message": "", + "status": True, + "status_code": 200, + "result": { + 'handouts_html': handouts_html + } + } + ) + else: + # course_handouts_module could be None if there are no handouts + return Response( + status=200, + data={ + "message": "", + "status": True, + "status_code": 200, + "result": { + 'handouts_html': None + } + } + ) diff --git a/lms/djangoapps/mobile_api/course_info/urls.py b/lms/djangoapps/mobile_api/course_info/urls.py index 55c3a3ded322..3937229e4167 100644 --- a/lms/djangoapps/mobile_api/course_info/urls.py +++ b/lms/djangoapps/mobile_api/course_info/urls.py @@ -7,6 +7,7 @@ from django.conf.urls import url from .views import CourseHandoutsList, CourseUpdatesList +from lms.djangoapps.mobile_api.course_info.lhub_views import LHUBCourseHandoutsList urlpatterns = [ url( @@ -19,4 +20,9 @@ CourseUpdatesList.as_view(), name='course-updates-list' ), + url( + r'^{}/lhub/handouts$'.format(settings.COURSE_ID_PATTERN), + LHUBCourseHandoutsList.as_view(), + name='lhub-course-handouts-list' + ), ] diff --git a/lms/djangoapps/mobile_api/home/__init__.py b/lms/djangoapps/mobile_api/home/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/home/urls.py b/lms/djangoapps/mobile_api/home/urls.py new file mode 100644 index 000000000000..548a42a22cdb --- /dev/null +++ b/lms/djangoapps/mobile_api/home/urls.py @@ -0,0 +1,14 @@ +""" +URLs for Mobile Home API +""" + + +from django.conf.urls import url + +from lms.djangoapps.mobile_api.home import views + +urlpatterns = [ + + url('^details/$', views.mobile_home_page, name='mobile_home_details') +] + diff --git a/lms/djangoapps/mobile_api/home/views.py b/lms/djangoapps/mobile_api/home/views.py new file mode 100644 index 000000000000..e674ccfd3cc1 --- /dev/null +++ b/lms/djangoapps/mobile_api/home/views.py @@ -0,0 +1,79 @@ +""" +API views for Mobile Home page +""" + + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from openedx.core.lib.api.authentication import BearerAuthentication +from rest_framework.authentication import SessionAuthentication +import requests +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from logging import getLogger +from ..decorators import mobile_view +from lms.djangoapps.mobile_api.utils import API_V05, API_V1 +logger = getLogger(__name__) + +@api_view(['GET']) +@authentication_classes((BearerAuthentication, SessionAuthentication, JwtAuthentication)) +@permission_classes([IsAuthenticated]) +def mobile_home_page(request, api_version): + home_page_url = {} + base = request.get_host() + bearer_token_from_request = request.META.get('HTTP_AUTHORIZATION') + + url_list = dict() + url_list["banner"] = '/api/banner/details/' + url_list["category"] = '/api/courses/v2/courses/categories/?page=1&page_size=1000' + url_list['recommended_courses'] = '/api/courses/v2/recommended/courses/?page=1&page_size=10' + url_list["most_popular"] = '/api/commerce/v2/courses/?platform_visibility=mobile&ordering=enrollments_count' + url_list["top_rated_courses"] = '/api/commerce/v2/courses/?platform_visibility=mobile&ordering=enrollments_count' + url_list["free_courses"] = '/api/commerce/v2/courses/?platform_visibility=mobile&sale_type=free' + headers = { + 'Authorization': bearer_token_from_request + } + http = 'http://' + response_obj = {"message": "Authentication Failed ", "net_response_chunk": {}, "status": False, "status_code": 401} + error_flag = True + response_code_list = [] + if api_version and api_version == API_V1: + try: + for key, api_url in url_list.items(): + actual_request = requests.get(http+base+api_url, headers=headers) + data = actual_request.json() + home_page_url[key] = data + response_code_list.append(data['status_code']) + except Exception as ex: + #dont' expose the specify error internal to system to outside API, Put it in generic manner + logger.error("Error while processing mobile home API - Exception as %s", ex) + response_obj = {"message": "ERROR", "net_response_chunk": {}, "status": False, + "status_code": 500} + error_flag = True + pass + response_final_codes = list(set(response_code_list)) + #stream line 200, 202 and 500 + if len(response_final_codes) == 1 and response_final_codes[0] == 200: + error_flag = False + elif len(response_final_codes) >= 2 and 200 in response_final_codes: + response_obj['status_code'] = status.HTTP_202_ACCEPTED + response_obj['status'] = True + response_obj['net_response_chunk'] = home_page_url if home_page_url else "" + response_obj['message'] = "partial success" + return Response(response_obj) + else: + error_flag = True + + if not error_flag: + response_obj['status_code'] =status.HTTP_200_OK + response_obj['status'] = True + response_obj['net_response_chunk'] = home_page_url if home_page_url else "" + response_obj['message'] = "" + return Response(response_obj) + else: + return Response(response_obj) + else: + obj = {"message": "Wrong API version", "net_response_chunk": {}, "status": False, "status_code": 400} + return Response(obj) + diff --git a/lms/djangoapps/mobile_api/urls.py b/lms/djangoapps/mobile_api/urls.py index a6fec8dd978c..82a442bb5fe5 100644 --- a/lms/djangoapps/mobile_api/urls.py +++ b/lms/djangoapps/mobile_api/urls.py @@ -11,4 +11,5 @@ url(r'^users/', include('lms.djangoapps.mobile_api.users.urls')), url(r'^my_user_info', my_user_info, name='user-info'), url(r'^course_info/', include('lms.djangoapps.mobile_api.course_info.urls')), + url(r'^home/', include('lms.djangoapps.mobile_api.home.urls')), ] diff --git a/lms/djangoapps/note/apps.py b/lms/djangoapps/note/apps.py index 6031716b87f0..cf8cc1bb2d5c 100644 --- a/lms/djangoapps/note/apps.py +++ b/lms/djangoapps/note/apps.py @@ -1,5 +1,16 @@ 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 NoteApiConfig(AppConfig): name = 'lms.djangoapps.note' + #plugin_app = { + # PluginURLs.CONFIG: { + # ProjectType.LMS: { + # PluginURLs.NAMESPACE: u'', + # PluginURLs.REGEX: u'^courses/{}/note/'.format(COURSE_ID_PATTERN), + # PluginURLs.RELATIVE_PATH: u'urls', + # } + # } + #} diff --git a/lms/djangoapps/note/tabs.py b/lms/djangoapps/note/tabs.py new file mode 100644 index 000000000000..8c4c2a9fdf5e --- /dev/null +++ b/lms/djangoapps/note/tabs.py @@ -0,0 +1,28 @@ +#from lms.djangoapps.note.config.waffle import use_bootstrap_flag_enabled +from django.utils.translation import ugettext_noop +from lms.djangoapps.courseware.tabs import CourseTab +from django.conf import settings + + +class NoteTab(CourseTab): + """ + The representation of the course teams view type. + """ + type = "note" + name = "note" + title = ugettext_noop("Note") + view_name = "note_list" + is_default = True + tab_id = "note" + is_hideable = True + + @classmethod + def is_enabled(cls, course, user=None): + return settings.FEATURES.get('IS_NOTE_TAB_ENABLED', False) + + @property + def uses_bootstrap(self): + """ + Returns true if this tab is rendered with Bootstrap. + """ + return use_bootstrap_flag_enabled() diff --git a/lms/djangoapps/note/views.py b/lms/djangoapps/note/views.py index c9d728975dc9..0f6cc1c35824 100644 --- a/lms/djangoapps/note/views.py +++ b/lms/djangoapps/note/views.py @@ -181,10 +181,10 @@ def put(self, request, id): elif is_public == "true" or is_public == "True": is_public = True image1 = request.data['image1'] - #image1 = resize_image(image1) + # image1 = resize_image(image1) image2 = request.data['image2'] - #image2 =resize_image(image2) + # image2 =resize_image(image2) image3 = request.data['image3'] #image3 = resize_image(image3) @@ -307,22 +307,22 @@ def create(self, request): is_public = True image1 = request.data['image1'] - #image1 = resize_image(image1) + image1 = resize_image(image1) image2 = request.data['image2'] - #image2 =resize_image(image2) + image2 =resize_image(image2) image3 = request.data['image3'] - #image3 = resize_image(image3) + image3 = resize_image(image3) image4 = request.data['image4'] - #image4 = resize_image(image4) + image4 = resize_image(image4) image5 = request.data['image5'] - #image5 = resize_image(image5) + image5 = resize_image(image5) image6 = request.data['image6'] - #image6 = resize_image(image6) + image6 = resize_image(image6) data = [ { diff --git a/lms/djangoapps/reviews/templates/reviews/reviews.html b/lms/djangoapps/reviews/templates/reviews/reviews.html index 4554a3ceba3e..30e054b8e58d 100644 --- a/lms/djangoapps/reviews/templates/reviews/reviews.html +++ b/lms/djangoapps/reviews/templates/reviews/reviews.html @@ -9,6 +9,7 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import HTML +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user %>
    @@ -10,12 +414,88 @@
      ## limiting the course number by using HOMEPAGE_COURSE_MAX as the maximum number of courses %for course in courses[:homepage_course_max]: -
    • + + %endfor
    + %if user.is_authenticated: +
    + + + +
    +

    Recommended Courses for You

    + View All +
    +
    + + + + +
    +
    +%endif +
    +
    +

    Most Popular Courses

    + View All +
    + +
    + + + + +
    +
    +
    +
    +

    Top Rated Courses

    + View All +
    +
    + + + + +
    +
    +
    +
    +

    Free Courses

    + View All +
    +
    + + + + +
    +
    + + +
    + ## in case there are courses that are not shown on the homepage, a 'View all Courses' link should appear % if homepage_course_max and len(courses) > homepage_course_max:
    @@ -26,3 +506,798 @@
    + + + diff --git a/lms/templates/courseware/course_about_sidebar_header.html b/lms/templates/courseware/course_about_sidebar_header.html index 2980b9fbca81..fb5e10e2d57d 100644 --- a/lms/templates/courseware/course_about_sidebar_header.html +++ b/lms/templates/courseware/course_about_sidebar_header.html @@ -31,10 +31,12 @@ url=u"{protocol}://{domain}{path}".format( protocol=site_protocol, domain=site_domain, - path=reverse('about_course', args=[text_type(course.id)]) + path=urllib.parse.quote_plus( + reverse('about_course', args=[text_type(course.id)]) + ), ) - ).replace(u" ", u"+") - tweet_action = u"http://twitter.com/intent/tweet?text={tweet_text}".format(tweet_text=six.moves.urllib.parse.quote_plus(tweet_text.encode('UTF-8'))) + ).replace(u" ", u"%20") + tweet_action = u"http://twitter.com/intent/tweet?text={tweet_text}".format(tweet_text=tweet_text) facebook_text = _("I just enrolled in {number} {title}: {url}").format( number=course.number, @@ -65,14 +67,16 @@ url=u"{protocol}://{domain}{path}".format( protocol=site_protocol, domain=site_domain, - path=reverse('about_course', args=[text_type(course.id)]), + path=urllib.parse.quote_plus( + reverse('about_course', args=[text_type(course.id)]), + ), ) ).replace(u" ", u"%20") email_subject = _("Take a course with {platform} online").format(platform=platform_name) email_link = u"mailto:?subject={subject}&body={body}".format( - subject=six.moves.urllib.parse.quote_plus(email_subject.encode('UTF-8')), - body=six.moves.urllib.parse.quote_plus(email_body.encode('UTF-8')) + subject=email_subject, + body=email_body ) %>
    + % endif + % if next_: + + % endif ${HTML(fragment.body_html())}
    diff --git a/lms/templates/index.html b/lms/templates/index.html index 09c77faf7cef..6f5d99eebe1d 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -10,7 +10,6 @@
    -
    @@ -38,7 +37,7 @@ <%include file="index_promo_video.html" />
    -
    + <%include file="${courses_list}" />
    diff --git a/lms/templates/instructor/instructor_dashboard_2/membership.html b/lms/templates/instructor/instructor_dashboard_2/membership.html index 7945827b6734..7af942e02a32 100644 --- a/lms/templates/instructor/instructor_dashboard_2/membership.html +++ b/lms/templates/instructor/instructor_dashboard_2/membership.html @@ -5,12 +5,51 @@ from django.utils.translation import pgettext from openedx.core.djangolib.markup import HTML, Text %> +
    ${_("Batch Enrollment")}
    @@ -61,7 +100,7 @@
    - +
    diff --git a/lms/templates/lhub/notification_block.underscore b/lms/templates/lhub/notification_block.underscore index e4cad5b3e1c4..7673c099be99 100644 --- a/lms/templates/lhub/notification_block.underscore +++ b/lms/templates/lhub/notification_block.underscore @@ -1,5 +1,5 @@ <% _.each(notifications, function(notification) { %> -
    +

    <%= notification.title %>

    > diff --git a/lms/templates/lhub_notification/notification_list.html b/lms/templates/lhub_notification/notification_list.html index a814f0ede38e..d181bec91697 100644 --- a/lms/templates/lhub_notification/notification_list.html +++ b/lms/templates/lhub_notification/notification_list.html @@ -34,6 +34,7 @@ vertical-align: top; font-weight: 700; margin-bottom: 10px; + color: #0a0a0a !important; } .notification-list-item-date { @@ -64,6 +65,8 @@ margin-right: 30px; font-size: 35px; line-height: 70px; + margin-left: 10px; + background: #ffffff; } .lhub-notifications-list li .notification-list-item { @@ -86,11 +89,13 @@ text-align: right; width: 200px; min-width: 200px; + margin-right: 10px; } .notification-list-actions a { display: block; padding-bottom: 15px; + color: #0a0a0a !important; } .lhub-pagination { @@ -125,6 +130,21 @@ border: 1px solid #1a345c; text-decoration: none; } + +.lhub-notifications-checkbox { + margin-left: 10px; + padding: inherit; +} + +.lhub-notifications-multiple-actions { + margin-top: 10px; +} + +.js-delete-all { + background: #f23d3d !important; + border-color: #bf3434 !important; + box-shadow: none !important; +}
    @@ -133,7 +153,11 @@

    ${_("Notifications")}

      % for notification in notification_list: -
    • +
    • +
      + +
      @@ -144,19 +168,24 @@

      ${_("Notifications")}

      ${notification.created.strftime('%d/%m/%Y')}
    % endfor +
    + + + +
    % if is_paginated: diff --git a/lms/templates/note/note_add.html b/lms/templates/note/note_add.html new file mode 100644 index 000000000000..423d392e389e --- /dev/null +++ b/lms/templates/note/note_add.html @@ -0,0 +1,226 @@ +## mako +<%! from django.utils.translation import ugettext as _ %> +<%namespace name='static' file='/static_content.html' /> +<%inherit file="/main.html" /> +<%block name="bodyclass">view-new_tab_type is-in-course course +<%block name="pagetitle">${_("Note")} +<%block name="headextra"> + <%static:css group='style-course' /> + +<%include file="/courseware/course_navigation.html" args="active_page='note_tab_type'" /> + + + +
    + +
    + +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    + +
    +
    + add +
    +
    + + Cancel +
    + + + + + + +
    + + diff --git a/lms/templates/note/note_edit.html b/lms/templates/note/note_edit.html new file mode 100644 index 000000000000..e21bb9c75e04 --- /dev/null +++ b/lms/templates/note/note_edit.html @@ -0,0 +1,246 @@ +## mako +<%! from django.utils.translation import ugettext as _ %> +<%namespace name='static' file='/static_content.html' /> +<%inherit file="/main.html" /> +<%block name="bodyclass">view-new_tab_type is-in-course course +<%block name="pagetitle">${_("Note")} +<%block name="headextra"> + <%static:css group='style-course' /> + +<%include file="/courseware/course_navigation.html" args="active_page='note_tab_type'" /> + + + +
    + +
    + +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    + + +
    + % for idx, image in enumerate(images): +
    remove
    + % endfor +
    + + add +
    +
    + + + Cancel +
    + + + + + + +
    + + + + diff --git a/lms/templates/note/note_home.html b/lms/templates/note/note_home.html new file mode 100644 index 000000000000..095b79032030 --- /dev/null +++ b/lms/templates/note/note_home.html @@ -0,0 +1,91 @@ +## mako +<%! from django.utils.translation import ugettext as _ %> +<%namespace name='static' file='/static_content.html'/> +<%inherit file="/main.html" /> +<%block name="bodyclass">view-new_tab_type is-in-course course +<%block name="pagetitle">${_("Note")} +<%block name="headextra"> +<%static:css group='style-course'/> + +<%include file="/courseware/course_navigation.html" args="active_page='note'" /> + + + +
    + Own Notes +Peer Notes + + % if len(notes) > 0: + + + + + + + + + + + % for note in notes: + + + + + + + % endfor + +
    TitleDate CreatedVisibility
    ${note.title}${note.get_date()}${ note.is_public == 1 and 'Public' or 'Private' } +
    + Edit + +
    +
    + % else: +
    + +
    +

    No notes in ${course.display_name} course

    +
    +
    + % endif + + diff --git a/lms/templates/note/peer_detail.html b/lms/templates/note/peer_detail.html new file mode 100644 index 000000000000..9ffa78180fc2 --- /dev/null +++ b/lms/templates/note/peer_detail.html @@ -0,0 +1,190 @@ +## mako +<%! from django.utils.translation import ugettext as _ %> +<%namespace name='static' file='/static_content.html' /> +<%inherit file="/main.html" /> +<%block name="bodyclass">view-new_tab_type is-in-course course +<%block name="pagetitle">${_("Note")} +<%block name="headextra"> + <%static:css group='style-course' /> + +<%static:css group='style-vendor'/> +<%static:css group='style-vendor-tinymce-content'/> +<%static:css group='style-vendor-tinymce-skin'/> +<%include file="/courseware/course_navigation.html" args="active_page='note_tab_type'" /> + + + + +
    +Own Notes +Peer Notes +
    + +
    +
    +

    ${note.title}

    +
    +
    +

    ${note.description}

    +
    + % for idx, image in enumerate(images): +
    + % endfor +
    +
    + +
    +
    + + + + diff --git a/lms/templates/note/peer_note.html b/lms/templates/note/peer_note.html new file mode 100644 index 000000000000..46314332b513 --- /dev/null +++ b/lms/templates/note/peer_note.html @@ -0,0 +1,94 @@ +## mako +<%! +from django.utils.translation import ugettext as _ +from django.urls import reverse +%> +<%! from django.shortcuts import redirect %> +<%namespace name='static' file='/static_content.html'/> +<%inherit file="/main.html" /> +<%block name="bodyclass">view-new_tab_type is-in-course course +<%block name="pagetitle">${_("Note")} +<%block name="headextra"> +<%static:css group='style-course'/> + +<%include file="/courseware/course_navigation.html" args="active_page='note'" /> + + + + +
    +Own Notes +Peer Notes + + + % if len(notes) > 0: + + + + + + + + + + + % for note in notes: + + + + + + % endfor + +
    TitleDate Created
    ${note.title}${note.get_date()} +
    + Detail +
    +
    + % else: +
    + +
    +

    No notes in ${course.display_name} course

    +
    +
    + % endif +
    diff --git a/lms/urls.py b/lms/urls.py index ce814302e061..5718d9cee6b4 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -209,12 +209,21 @@ # LHUB MOBILE API url(r'^api/lhub_mobile/', include('lms.djangoapps.lhub_mobile.urls')), url(r'^lhub/', include('lms.djangoapps.lhub_notification.urls')), + + ] urlpatterns += [ url(r'^lhub_extended_api/', include('lms.djangoapps.lhub_extended_api.urls')), ] +urlpatterns += [ + url(r'^lhub_ecommerce_offer/', include('lms.djangoapps.lhub_ecommerce_offer.urls')), +] + + + + if settings.FEATURES.get('ENABLE_MOBILE_REST_API'): urlpatterns += [ url(r'^api/mobile/(?Pv(1|0.5))/', include('lms.djangoapps.mobile_api.urls')), @@ -832,6 +841,16 @@ url(r'api/note/', include(('lms.djangoapps.note.urls', 'lms.djangoapps.note'), namespace='note')), ] +if settings.FEATURES.get('IS_NOTE_TAB_ENABLED'): + urlpatterns += ( + url( + r'^courses/{}/note/'.format( + settings.COURSE_ID_PATTERN, + ), + include('lms.djangoapps.note.urls'), + name='note', + ), + ) # Embargo if settings.FEATURES.get('EMBARGO'): urlpatterns += [ @@ -1027,3 +1046,14 @@ urlpatterns += [ url(r'^api/course_experience/', include('openedx.features.course_experience.api.v1.urls')), ] + +#Banner API +urlpatterns += [ + url(r'^api/banner/', include('lms.djangoapps.banner.api.urls')), + +] + +#Course Block User API +urlpatterns += [ + url(r'^/course_block_user/', include('lms.djangoapps.course_block_user.urls')), +] diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index 8ded681ab41b..bc28acf8b488 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -146,8 +146,8 @@ def populate(cls, course_descriptor): course_details.platform_visibility = course_descriptor.platform_visibility course_details.premium = course_descriptor.premium course_details.course_sale_type = course_descriptor.course_sale_type - course_details.course_price = course_descriptor.course_price CourseOverview = apps.get_model('course_overviews', 'CourseOverview') + course_details.course_price = CourseOverview.get_from_id(course_key).course_price course_details.indexed_in_discovery = CourseOverview.get_from_id(course_key).indexed_in_discovery course_details.published_in_ecommerce = CourseOverview.get_from_id(course_key).published_in_ecommerce course_details.self_paced = course_descriptor.self_paced diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 27ea1bdc1f6e..6b7c04bfa759 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -287,6 +287,9 @@ def get(self, request): """ GET /api/user/v1/me """ + token_ = request.META.get('HTTP_AUTHORIZATION') + if "JWT" in token_: + return Response({'username': None}) return Response({'username': request.user.username}) def list(self, request): diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 32d3570dcdb0..cd4ace9a24f6 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -252,6 +252,7 @@ zipp==1.0.0 # via -c requirements/edx/../constraints.txt, -r requi django-ckeditor==6.0.0 django-fs-trumbowyg==0.1.4 arrow==1.0.3 +pdfkit==0.6.1 # 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 e035ddebf0f3..13be9429d794 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -324,6 +324,7 @@ 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 +pdfkit==0.6.1 # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/setup.py b/setup.py index 991051f9f63f..884b42b15ca2 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ "textbooks = lms.djangoapps.courseware.tabs:TextbookTabs", "wiki = lms.djangoapps.course_wiki.tab:WikiTab", "reviews = lms.djangoapps.reviews.plugins:ReviewsTab", + "note = lms.djangoapps.note.tabs:NoteTab", ], "openedx.course_tool": [ "calendar_sync_toggle = openedx.features.calendar_sync.plugins:CalendarSyncToggleTool", @@ -93,6 +94,7 @@ "program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig", "courseware_api = openedx.core.djangoapps.courseware_api.apps:CoursewareAPIConfig", "reviews = lms.djangoapps.reviews.apps:ReviewsConfig", + "note = lms.djangoapps.note.apps:NoteApiConfig", ], "cms.djangoapp": [ "announcements = openedx.features.announcements.apps:AnnouncementsConfig", diff --git a/themes/lhub/lms/static/js/custom_basket.js b/themes/lhub/lms/static/js/custom_basket.js index c1233cde2ed4..3a47cdb0ddb1 100644 --- a/themes/lhub/lms/static/js/custom_basket.js +++ b/themes/lhub/lms/static/js/custom_basket.js @@ -1,388 +1,447 @@ $(document).ready(function() { -(async ()=>{ -await show_basket(); -get_recommended_courses(); -onclick_select(); -})(); - -add_checkout_function(); -}); - -// converts price to 2 decimal points e.g 2.1 to 2.10 -function append_decimal(price){ - return (price).toFixed(2); -} - -function show_basket() -{ -$('#loader-sec').css('display', '') -return $.ajax({ -type:"GET", -url: "/api/commerce/v2/basket-details/", -data: JSON.stringify({ -"products":[{"sku":$('#course_sku').val()}], -csrfmiddlewaretoken: $('#web_csrf_token').val() -}), -contentType: "application/json; charset=utf-8", -success: function(response){ -var course_id = ''; -if (response['status_code'] == 200) -{ -$('.wish-list').empty() -response['result']['basket_total'] = append_decimal(response['result']['basket_total']) -for (var i = 0; i < response['result']['products'].length; i++) -{ -for (var j=0; j< response['result']['products'][i].length; j++) -{ -var course_details = {} - -if (response['result']['products'][i][j]['code'] == "course_details") -{ -course_details = response['result']['products'][i][j] -if (course_details['discount_applicable'] == true) -{ -course_details['discounted_price'] = append_decimal(course_details['discounted_price']) -} -else -{ -course_details['price'] = append_decimal(course_details['price']) -} -} -if(response['result']['products'][i][j]['code'] == "course_key") -{ -course_id = response['result']['products'][i][j]['value'] -} -} -b = `
    - -
    -
    -
    -Sample -
    -
    -
    -
    -
    -
    `+course_details['title']+`
    -

    `+course_details['organization']+`

    -

    `+course_details['category']+`

    -
    -
    -
    -
    -
    ` -if (course_details['discount_applicable'] == true) -{ -b+=`
    ` -b+=`

    S$`+course_details['discounted_price']+`

    ` -b+=`

    S$`+course_details['price']+`

    ` -b+=`
    ` -} -else -{ -b+=`
    ` -b+=`

    ` -b+=`

    S$`+course_details['price']+`

    ` -b+=`
    ` -} -b+=` -
    -
    - -
    -
    -
    -
    -
    - - -
    ` -$('.wish-list').append(b) -} -add_remove_click_function(); -} -$('.list-group').empty(); -$('.list-group').append(`
  1. Sub TotalS$`+response['result']['basket_total']+`
  2. `) -$('.list-group').append(`
  3. -
  4. -`) - - -//$('.list-group').append(`
  5. -//
    -//Tax (7% GST) -//
    -//S$18.90 -//
  6. `) - - - -$('.list-group').append(`
  7. -
    -Total -
    -S$`+response['result']['basket_total']+` -
  8. -`) -$('#btn-checkout').attr("disabled", false) -$('#loader-sec').css('display', 'none') - - - -//else if (response['status_code'] == 500) -//{ -//alert(response['message']); -//} - -}, -error: function(data) { -} -}) - - - -} - - - -function add_remove_click_function() -{ -$(".btn-remove").click(function(){ - -$('#loader-sec').css('display', '') -$.ajax({ -type:"POST", -url: "/api/stripe/basket/remove_item/", // + "?course_id=course-v1:edx+cs8789+2021-t1", -data: { -'course_id':$(this).attr('data-courseid'), -csrfmiddlewaretoken: $('#web_csrf_token').val() -}, -//datatype:"json", -//jsonp: "jsonp", -//contentType: "application/json; charset=utf-8", -success: function(response){ -if (response['status_code'] == 200) -{show_basket();} -} -}); - -}); - -} - -function add_checkout_function() -{ - -$("#btn-checkout").click(function(){ - -$('#loader-sec').css('display', '') -var selected_skus = $(".form-check-input:checked").map(function () { -return {'sku':$(this).data('sku')} -}).get();; -$.ajax({ -type:"POST", -url: "/api/stripe/basket/buy_now/", -data: JSON.stringify({ -'products':selected_skus, -csrfmiddlewaretoken: $('#web_csrf_token').val() -}), -contentType: "application/json", -success: function(response){ -if (response['status_code'] == 200) -{ -$('#loader-sec').css('display', 'none') -window.location.href = $('#ecommerce_url').val() + "/checkout/card-selection" -} - -else -{ -$('#loader-sec').css('display', 'none') -alert(response['message']); -} - -} -}); - -}); - -} - -function append_decimal_point(price){ - str_price = price.toString() - split_price = str_price.split(".") - if(split_price.length === 2){ - last_decimal_point = split_price[split_price.length-1] - if(!(last_decimal_point.length >= 2)){ - str_price = str_price.concat("0") - return str_price + (async ()=>{ + await show_basket(); + var tax_percent = 0; + var is_basket_empty = true; + get_recommended_courses(); + onclick_select(); + })(); + + add_checkout_function(); + }); + + // converts price to 2 decimal points e.g 2.1 to 2.10 + function append_decimal(price){ + return (price).toFixed(2); + } + + function show_basket() + { + $('#loader-sec').css('display', '') + return $.ajax({ + type:"GET", + url: "/api/commerce/v2/basket-details/", + data: JSON.stringify({ + "products":[{"sku":$('#course_sku').val()}], + csrfmiddlewaretoken: $('#web_csrf_token').val() + }), + contentType: "application/json; charset=utf-8", + success: function(response){ + var course_id = ''; + if (response['status_code'] == 200) + { + $('.wish-list').empty() + response['result']['basket_total'] = append_decimal(response['result']['basket_total']); + if (response['result']['products'].length > 0) + { + is_basket_empty = false; + } + else + { + is_basket_empty = true; + } + + + for (var i = 0; i < response['result']['products'].length; i++) + { + + for (var j=0; j< response['result']['products'][i].length; j++) + { + var course_details = {} + + if (response['result']['products'][i][j]['code'] == "course_details") + { + course_details = response['result']['products'][i][j] + if (course_details['discount_applicable'] == true) + { + course_details['discounted_price'] = append_decimal(course_details['discounted_price']) + } + else + { + course_details['price'] = append_decimal(course_details['price']) + } + } + if(response['result']['products'][i][j]['code'] == "course_key") + { + course_id = response['result']['products'][i][j]['value'] + } + } + b = `
    + +
    +
    +
    + Sample +
    +
    +
    +
    +
    +
    `+course_details['title']+`
    +

    `+course_details['organization']+`

    +

    `+course_details['category']+`

    +
    +
    +
    +
    +
    ` + if (course_details['discount_applicable'] == true) + { + b+=`
    ` + b+=`

    S$`+course_details['discounted_price']+`

    ` + b+=`

    S$`+course_details['price']+`

    ` + b+=`
    ` + } + else + { + b+=`
    ` + b+=`

    ` + b+=`

    S$`+course_details['price']+`

    ` + b+=`
    ` + } + b+=` +
    +
    + +
    +
    +
    +
    +
    + + +
    ` + $('.wish-list').append(b) + } + add_remove_click_function(); + } + var sub_total = response['result']['basket_total_excl_tax']; + sub_total = append_decimal_point(sub_total) + $('.list-group').empty(); + $('.list-group').append(`
  9. Sub TotalS$`+sub_total+`
  10. `) + $('.list-group').append(`
  11. +
  12. + `) + + tax_percent = response['result']['tax_percent'] + var tax = response['result']['tax'] + tax = append_decimal_point(tax) + $('.list-group').append(`
  13. +
    + GST `+tax_percent+`% +
    + S$`+tax+` +
  14. `) + + + + $('.list-group').append(`
  15. +
    + Total +
    + S$`+response['result']['basket_total']+` +
  16. + `) + if (is_basket_empty == true) + { + $('#btn-checkout').attr("disabled", true) + + } + else + { + $('#btn-checkout').attr("disabled", false) + } + $('#loader-sec').css('display', 'none') + //else if (response['status_code'] == 500) + //{ + //alert(response['message']); + //} + + }, + error: function(data) { + } + }) + + + + } + + + + function add_remove_click_function() + { + $(".btn-remove").click(function(){ + + $('#loader-sec').css('display', '') + $.ajax({ + type:"POST", + url: "/api/stripe/basket/remove_item/", // + "?course_id=course-v1:edx+cs8789+2021-t1", + data: { + 'course_id':$(this).attr('data-courseid'), + csrfmiddlewaretoken: $('#web_csrf_token').val() + }, + //datatype:"json", + //jsonp: "jsonp", + //contentType: "application/json; charset=utf-8", + success: function(response){ + if (response['status_code'] == 200) + { + show_basket();} + } + }); + + }); + + } + + function add_checkout_function() + { + + $("#btn-checkout").click(function(){ + + $('#loader-sec').css('display', '') + var selected_skus = $(".form-check-input:checked").map(function () { + return {'sku':$(this).data('sku')} + }).get();; + $.ajax({ + type:"POST", + url: "/api/stripe/basket/buy_now/", + data: JSON.stringify({ + 'products':selected_skus, + csrfmiddlewaretoken: $('#web_csrf_token').val() + }), + contentType: "application/json", + success: function(response){ + if (response['status_code'] == 200) + { + $('#loader-sec').css('display', 'none') + window.location.href = $('#ecommerce_url').val() + "/basket" + } + + else + { + $('#loader-sec').css('display', 'none') + alert(response['message']); + } + + } + }); + + }); + + } + + function append_decimal_point(price){ + str_price = price.toString() + split_price = str_price.split(".") + if(split_price.length === 2){ + last_decimal_point = split_price[split_price.length-1] + if(!(last_decimal_point.length >= 2)){ + str_price = str_price.concat("0") + return str_price + } + else{ + return price.toFixed(2) + } } else{ - return price.toFixed(2) + return str_price.concat(".00") } } + function onclick_select() + + { + //$('#loader-sec').css('display', '') + + $(".form-check-input").change(function(){ + + $('#loader-sec').css('display', '') + //Set timeout is needed because without this, execution is so fast that user will not be able to know that cart total has been updated + setTimeout( + function() + { + var selected = $(".form-check-input:checked") + var cart_total = 0.00; + for (var x=0; x 0 ? cart_list.children().find('.price-set').find('p')[0] : cart_list.children().find('.price-set').find('p')[1] + + var price_text = $(price_elements).text(); + var price = price_text.substring(2, price_text.length); + var float_price = parseFloat(price); + cart_total += float_price + } + var currency = 'S$' + var two_decimal_price = append_decimal_point(cart_total) + var total = currency.concat(two_decimal_price) + var tax = two_decimal_price * tax_percent/100 + var tax = tax.toFixed(2) + var updated_cart_total = ((+two_decimal_price) + (+tax)).toFixed(2) + var updated_cart_total = currency.concat(updated_cart_total) + var tax = currency.concat(tax) + + $("#cart_total").text(updated_cart_total) + $("#sub_total").text(total) + $("#total_tax").text(tax) + if (selected.length == 0) + { + $('#btn-checkout').attr("disabled", true) + } + else + { + $('#btn-checkout').attr("disabled", false) + } + + $('#loader-sec').css('display', 'none') + }, 1000); + + }); + + + } + + + + + + + + function get_recommended_courses() + { + + $.ajax({ + type:"GET", + url: "/api/courses/v2/web_recommended/courses/", + //data: JSON.stringify({ + //"products":[{"sku":$('#course_sku').val()}], + //csrfmiddlewaretoken: $('#web_csrf_token').val() + //}), + contentType: "application/json; charset=utf-8", + success: function(response){ + if (response['status_code'] == 200) + { + $('#heading_recommended_courses').css('display','') + $('.courses-listing').empty() + for (var i=0; i <=2; i++) + { + course_id = response['result']['result'][i]['id'] + course_org = response['result']['result'][i]['org'] + course_code = response['result']['result'][i]['code'] + course_name = response['result']['result'][i]['name'] + course_image = response['result']['result'][i]['image'] + course_difficulty_level = response['result']['result'][i]['difficulty_level'] + course_enrollments_count = response['result']['result'][i]['enrollments_count'] + course_ratings = response['result']['result'][i]['ratings'] + course_ratings = course_ratings !== null ? course_ratings : "None" + course_comments_count = response['result']['result'][i]['comments_count'] + + course_start = response['result']['result'][i]['start'] + course_discount_applicable = response['result']['result'][i]['discount_applicable'] + course_price = response['result']['result'][i]['price'] + course_discounted_price = response['result']['result'][i]['discounted_price'] + course_discount_percentage = response['result']['result'][i]['discount_percentage'] + course_discount_type = response['result']['result'][i]['discount_type'] + + + + var course = `
  17. +
    + +
    +
    + `+course_name+` + +
    +
    +
    + +
    +
  18. + ` + $('.courses-listing').append(course) + } + } + } + }); + + } diff --git a/themes/lhub/lms/templates/commerce/basket-detail.html b/themes/lhub/lms/templates/commerce/basket-detail.html index a14a4de32001..9945dab06d88 100644 --- a/themes/lhub/lms/templates/commerce/basket-detail.html +++ b/themes/lhub/lms/templates/commerce/basket-detail.html @@ -95,13 +95,6 @@ color: #7f7f7f; } -.courses-container .courses .course .course-info .course-date { -padding: 4px 0px 0px 0px; -margin-top: 12px; -color: #7f7f7f; -font-size: 16px; -} - span.course-title, span.course-code, span.course-organization { @@ -295,13 +288,6 @@ color: #7f7f7f; } -.courses-container .courses .course .course-info .course-date { -padding: 4px 0px 0px 0px; -margin-top: 12px; -color: #7f7f7f; -font-size: 16px; -} - span.course-title, span.course-code, span.course-organization { @@ -499,7 +485,7 @@ padding: 4px 0px 0px 0px; margin-top: 12px; color: #7f7f7f; -font-size: 16px; +font-size: 13px; } span.course-title, @@ -761,6 +747,22 @@ align-items: center; } +.coupon_details { + position: absolute; + right: 14px; + bottom: 14px; +} +.coupon_details p { + display: flex; + margin: 0px; + line-height: 1.4; + justify-content: flex-end; +} +.coupon_details span.coupen_code_value { + font-size: 12px; + color: #ed9800; + font-weight: 600; +} diff --git a/themes/lhub/lms/templates/course.html b/themes/lhub/lms/templates/course.html index 5f3a053dc55c..897cfc2172d6 100644 --- a/themes/lhub/lms/templates/course.html +++ b/themes/lhub/lms/templates/course.html @@ -69,7 +69,7 @@ padding: 4px 0px 0px 0px; margin-top: 12px; color: #7f7f7f; -font-size: 16px; +font-size: 13px; } span.course-title, @@ -180,6 +180,23 @@ color: #249a56; font-weight: 800 !important; } +.coupon_details span.coupen_code_value { + font-size: 12px; + color: #ed9800; + font-weight: 600; +} +.coupon_details { + position: absolute; + right: 14px; + bottom: 14px; +} + +.coupon_details p { + display: flex; + margin: 0px; + line-height: 1.4; + justify-content: flex-end; +}
    @@ -203,14 +220,17 @@

      - -
    • Discount Percentage: ${format(course.discount_percentage, ".2f")}%
    • - % if course.discounted_price == 0: + % if course.discount_type == 'Percentage': +
    • Discount Percentage: ${format(course.discount_percentage, ".2f")}%
    • + % else: +
    • Discount: -${format(course.discount_percentage, ".2f")}
    • + % endif + % if course.discounted_price == 0:
    • Price: S$${format(course.price, ".2f")}
    • Discounted Price: Free % else:
    • Price: S$${format(course.price, ".2f")}
    • -
    • Discounted Price: S$${format(course.discounted_price, ".2f")} +
    • Discounted Price: S$${course.discounted_price} % endif
    @@ -219,7 +239,7 @@

      - + % if course.price == 0:
    • Price: Free
    • % else: @@ -228,9 +248,21 @@

    - - - % endif +% endif + + % if course.coupon_applicable: +
    + % for voucher in course.available_vouchers: + % if voucher["incentive_type"] == 'Percentage': +

    ${voucher["coupon_code"]} -${voucher["incentive_value"]}%

    + % endif + % if voucher["incentive_type"] == 'Absolute': +

    ${voucher["coupon_code"]} -S$${voucher["incentive_value"]}

    + % endif + % endfor +
    + % endif +

    <% if course.start is not None: @@ -244,6 +276,7 @@

    % endif

    +
    • ${course.display_org_with_default}
    • @@ -254,6 +287,7 @@

    • ${_("Starts")}:
    • % endif

    +

    diff --git a/themes/lhub/lms/templates/courseware/course_about.html b/themes/lhub/lms/templates/courseware/course_about.html index 7610f0e3a339..b3708368d1d8 100644 --- a/themes/lhub/lms/templates/courseware/course_about.html +++ b/themes/lhub/lms/templates/courseware/course_about.html @@ -456,7 +456,7 @@

    MITOpenCourseware

    contentType: "application/json", success:function(response){ if(response['status_code'] == 200){ - window.location.replace("${settings.ECOMMERCE_PUBLIC_URL_ROOT}/checkout/card-selection/"); + window.location.replace("${settings.ECOMMERCE_PUBLIC_URL_ROOT}/basket/"); } else{ alert(response['message']) diff --git a/themes/lhub/lms/templates/courseware/course_about_sidebar_header.html b/themes/lhub/lms/templates/courseware/course_about_sidebar_header.html new file mode 100644 index 000000000000..fb5e10e2d57d --- /dev/null +++ b/themes/lhub/lms/templates/courseware/course_about_sidebar_header.html @@ -0,0 +1,93 @@ +<%page expression_filter="h"/> +<%namespace name='static' file='../static_content.html'/> +<%! +import six +import urllib + +from django.utils.translation import ugettext as _ +from django.urls import reverse +from django.conf import settings +from six import text_type +%> + +
    + % if static.get_value('course_about_show_social_links', True): + + % endif +
    diff --git a/themes/lhub/lms/templates/courseware/courses.html b/themes/lhub/lms/templates/courseware/courses.html index 4153a8996c5a..07fb00327af9 100644 --- a/themes/lhub/lms/templates/courseware/courses.html +++ b/themes/lhub/lms/templates/courseware/courses.html @@ -3,6 +3,7 @@ import json from django.utils.translation import ugettext as _ from openedx.core.djangolib.js_utils import js_escaped_string, dump_js_escaped_json + from django.urls import reverse %> <%inherit file="../main.html" /> <% @@ -30,6 +31,11 @@ % endif <%block name="pagetitle">${_("Courses")} @@ -102,6 +482,59 @@
    + % if course_discovery_enabled:
    + + + diff --git a/themes/lhub/lms/templates/footer.html b/themes/lhub/lms/templates/footer.html index 4bd559019ab0..9de4e1a3e556 100644 --- a/themes/lhub/lms/templates/footer.html +++ b/themes/lhub/lms/templates/footer.html @@ -58,7 +58,7 @@
    -
    % endif + +
    % endif diff --git a/themes/lhub/lms/templates/header/header.html b/themes/lhub/lms/templates/header/header.html index ba89f6154ff8..5691ad77c6fc 100644 --- a/themes/lhub/lms/templates/header/header.html +++ b/themes/lhub/lms/templates/header/header.html @@ -120,6 +120,10 @@ % endif
    + + + + % if course: diff --git a/themes/lhub/lms/templates/header/navbar-authenticated.html b/themes/lhub/lms/templates/header/navbar-authenticated.html index a3b4622bbc3f..a13814d5245c 100644 --- a/themes/lhub/lms/templates/header/navbar-authenticated.html +++ b/themes/lhub/lms/templates/header/navbar-authenticated.html @@ -269,6 +269,14 @@ top: 15px; } +.first_not_completed, .second_not_completed { + background: #f2ad85; +} + +.not_active { + background: #f0d38b; +} + .js-notifications a { display: block; height: auto !important; diff --git a/themes/lhub/lms/templates/index_overlay.html b/themes/lhub/lms/templates/index_overlay.html index 7eba1ea45117..3023889772ab 100644 --- a/themes/lhub/lms/templates/index_overlay.html +++ b/themes/lhub/lms/templates/index_overlay.html @@ -1,40 +1,66 @@ -<%page expression_filter="h"/> +<%page expression_filter="h" /> <%! from django.utils.translation import ugettext as _ from openedx.core.djangolib.markup import HTML, Text +from django.urls import reverse %> - - -

    Skill Up And Be Ready for Your Future Career

    -## Translators: 'Open edX' is a registered trademark, please keep this untranslated. See http://open.edx.org for more information. -