diff --git a/cms/envs/aws_appsembler.py b/cms/envs/aws_appsembler.py index 751a3fffe2d8..c5172f15bf25 100644 --- a/cms/envs/aws_appsembler.py +++ b/cms/envs/aws_appsembler.py @@ -89,3 +89,9 @@ del DATABASES['appsembler_usage'] CUSTOM_SSO_FIELDS_SYNC = ENV_TOKENS.get('CUSTOM_SSO_FIELDS_SYNC', {}) + +# set media values for SCORM upload +MEDIA_ROOT = '/edx/var/edxapp/media' +MEDIA_URL = '/media/' + +HTTPS = 'on' if ENV_TOKENS.get('BASE_SCHEME', 'https').lower() == 'https' else 'off' diff --git a/cms/static/js/views/settings/advanced.js b/cms/static/js/views/settings/advanced.js index e982dde5e637..2473dae37587 100644 --- a/cms/static/js/views/settings/advanced.js +++ b/cms/static/js/views/settings/advanced.js @@ -14,19 +14,24 @@ define(['js/views/validation', // Model class is CMS.Models.Settings.Advanced events: { 'focus :input': 'focusInput', - 'blur :input': 'blurInput' + 'blur :input': 'blurInput', + 'change select': 'clearValidationErrors', + 'change select': 'selectSetField' // TODO enable/disable save based on validation (currently enabled whenever there are changes) }, - initialize: function() { - this.template = HtmlUtils.template( - $('#advanced_entry-tpl').text() - ); + initialize : function() { + this.base_template = HtmlUtils.template( + $("#advanced_entry-tpl").text() + ); + this.options_template = HtmlUtils.template( + $("#advanced_option_entry-tpl").text() + ); this.listenTo(this.model, 'invalid', this.handleValidationError); this.render(); }, render: function() { // catch potential outside call before template loaded - if (!this.template) return this; + if (!this.base_template) return this; var listEle$ = this.$el.find('.course-advanced-policy-list'); listEle$.empty(); @@ -150,9 +155,11 @@ define(['js/views/validation', }); }, renderTemplate: function(key, model) { + var tmpl = (model.values) ? this.options_template : this.base_template; var newKeyId = _.uniqueId('policy_key_'), - newEle = this.template({key: key, display_name: model.display_name, help: model.help, + newEle = tmpl({key: key, display_name: model.display_name, help: model.help, value: JSON.stringify(model.value, null, 4), deprecated: model.deprecated, + options: model.values, keyUniqueId: newKeyId, valueUniqueId: _.uniqueId('policy_value_')}); this.fieldToSelectorMap[key] = newKeyId; @@ -164,7 +171,21 @@ define(['js/views/validation', }, blurInput: function(event) { $(event.target).prev().removeClass('is-focused'); + }, + selectSetField : function(event) { + var self = this; + var key = this.selectorToField[event.currentTarget.id]; + var newVal = $(event.currentTarget).val(); + var JSONValue = JSON.parse(newVal); + var modelVal = self.model.get(key); + modelVal.value = JSONValue; + self.model.set(key, modelVal); + var message = gettext("Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented."); + self.showNotificationBar(message, + _.bind(self.saveView, self), + _.bind(self.revertView, self)); } + }); return AdvancedView; diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index c30efb88ecb5..729ff04e214d 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -129,6 +129,11 @@ from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangoapps.catalog.utils import get_programs_data +# try to import appsembler fork of edx-organizations (if it's installed) +try: + from organizations.models import UserOrganizationMapping +except ImportError: + pass log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") @@ -1857,13 +1862,21 @@ def create_account_with_params(request, params): else: registration.activate() _enroll_user_in_pending_courses(user) # Enroll student in any pending courses + + #if using custom Appsembler backend from edx-organizations + if u'organizations.backends.OrganizationMemberBackend' in settings.AUTHENTICATION_BACKENDS: + organization = request.site.organizations.first() + if organization: + UserOrganizationMapping.objects.get_or_create(user=user, organization=organization, is_active=False) # Immediately after a user creates an account, we log them in. They are only # logged in until they close the browser. They can't log in again until they click # the activation link from the email. new_user = authenticate(username=user.username, password=params['password']) - login(request, new_user) - request.session.set_expiry(0) + + if not settings.APPSEMBLER_FEATURES.get('SKIP_LOGIN_AFTER_REGISTRATION', False): + login(request, new_user) + request.session.set_expiry(0) try: record_registration_attributions(request, new_user) diff --git a/lms/djangoapps/appsembler_api/apidocs.md b/lms/djangoapps/appsembler_api/apidocs.md index e926b33347ea..6f3912cc5cfa 100644 --- a/lms/djangoapps/appsembler_api/apidocs.md +++ b/lms/djangoapps/appsembler_api/apidocs.md @@ -175,6 +175,71 @@ Cache-Control: no-cache } ``` +### Update user account + +This endpoint allows to update a user account. Receives a lookup parameter and N optional parameters which are all the attributes that needs to be updated. +The endpoint can update email, all available profile fields and also has support for [registration extension form fields](https://github.com/open-craft/custom-form-app). + +* URL: `/appsembler_api/v0/accounts/update_user +* Method: `POST` +* Data Params + * Required: + * `user_lookup` # can be username or email + * Optional: + * `email` # user's email + * `name` # user full name + * `country` # country iso code, ex: `ES`, `UY`, `US` + * `gender` # user gender, accepted values `m`, `f` or `o` + * `level_of_education` # user education, accepted values `p`, `m`, `b`, `a`, `hs`, `jhs`, `el`, `none` or `other`, + * `year_of_birth` # four digit year as string + * `city` # text + * `mailing_address` # long text + * `language` # language iso code, ex 'ES', 'EN' + * `goals` # long text + * `bio` # text + +You also can send extended profile form fiels, but that depends on every installation, you'll need to find the [registration extension form app fork](https://github.com/open-craft/custom-form-app) that is installed on the instance, and check the field names, and accepted values. + +* Success Response + * Code: 200 + * Content: Success message and list of updated fields and values +``` +{ + "success": "The following fields has been updated: name=Doe, John, country=ES" +} +``` +* Error Responses: + * Code: 404 NOT FOUND + * Reason: User not exists + +* Error Responses: + * Code: 400 NOT FOUND + * Reason: No user lookup parameter sent + +* Example call: +``` +POST /appsembler_api/v0/accounts/update_user +Host: example.com +Content-Type: application/json +Authorization: Bearer cbf6a5de322cf6a4323c957a882xy1s321c954b86 +Cache-Control: no-cache +{ + "user_lookup": "staff@example.com", + "emai": "new_staff@example.com", + "name": "Staff New Name", + "country": "US", + "gender": "f", + "level_of_education": "m", + "year_of_birth": "2000", + "city": "Montevideo", + "mailing_address": "A streen and a number 2345 FL, USA", + "language": "es", + "goals": "To be famous", + "bio": "I'm not famous yet" + "district": "101815" # a custom form field +} +``` + ### Check Existing Username This endpoint is a tool to check if an user exists given the username. diff --git a/lms/djangoapps/appsembler_api/urls.py b/lms/djangoapps/appsembler_api/urls.py index 6f55ad8e99a6..b21604bd0b60 100644 --- a/lms/djangoapps/appsembler_api/urls.py +++ b/lms/djangoapps/appsembler_api/urls.py @@ -9,6 +9,7 @@ url(r'^accounts/user_without_password', views.CreateUserAccountWithoutPasswordView.as_view(), name="create_user_account_without_password_api"), url(r'^accounts/create', views.CreateUserAccountView.as_view(), name="create_user_account_api"), url(r'^accounts/connect', views.UserAccountConnect.as_view(), name="user_account_connect_api"), + url(r'^accounts/update_user', views.UpdateUserAccount.as_view(), name="user_account_update_user"), url(r'^accounts/get-user/(?P[\w.+-]+)', views.GetUserAccountView.as_view(), name="get_user_account_api"), # bulk enrollment API diff --git a/lms/djangoapps/appsembler_api/views.py b/lms/djangoapps/appsembler_api/views.py index b0ce661bfa3d..9d6d1969ed3d 100644 --- a/lms/djangoapps/appsembler_api/views.py +++ b/lms/djangoapps/appsembler_api/views.py @@ -9,6 +9,7 @@ from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.http import Http404 +from django.db.models import Q from django.core.validators import validate_email from rest_framework.views import APIView @@ -26,9 +27,10 @@ IsStaffOrOwner, ApiKeyHeaderPermissionIsAuthenticated ) +from student.forms import get_registration_extension_form from student.views import create_account_with_params from student.models import CourseEnrollment, EnrollmentClosedError, \ - CourseFullError, AlreadyEnrolledError + CourseFullError, AlreadyEnrolledError, UserProfile from course_modes.models import CourseMode from courseware.courses import get_course_by_id @@ -241,6 +243,107 @@ def post(self, request): return response +class UpdateUserAccount(APIView): + """ HTTP endpoint for updating and user account """ + + authentication_classes = OAuth2AuthenticationAllowInactiveUser, + permission_classes = IsStaffOrOwner, + + def post(self, request): + """ + This endpoint allows to change user attributes including email, profile + attributes and extended profile fields. Receives one mandatory param + user_lookup that can be an email or username to lookup the user to + update and the rest of parameters are option. Any attribute to update + must be sent in key:val JSON format. + + URL: /appsembler_api/v0/accounts/update_user + Arguments: + request (HttpRequest) + JSON (application/json) + { + "user_lookup": email or username to lookup the user to update, + # mandatory ex: "staff4@example.com" or "staff4" + + "email": "staff@example.com", + "bio": "this is my bio", + "country": "BR" + } + Returns: + HttpResponse: 200 on success, {"success ": "list of updated params"} + HttpResponse: 404 if the doesn't exists + HttpResponse: 400 Incorrect parameters, basically if username or + email parameter is not sent + """ + data = request.data + + if data['user_lookup'].strip() == "": + errors = {"lookup_error": "No user lookup has been provided"} + return Response(errors, status=400) + + user = User.objects.filter( + Q(username=data['user_lookup']) | Q(email=data['user_lookup']) + ) + + if user: + user = user[0] + else: + errors = { + "user_not_found": "The user for the Given username or email doesn't exists" + } + return Response(errors, status=404) + + updated_fields = {} + + # update email + if 'email' in data and data['email'] != user.email: + user_exists = check_account_exists(email=data['email']) + if user_exists: + errors = {"integrity_error": "the user email you're trying to set already belongs to another user"} + return Response(errors, status=400) + + user.email = data['email'] + user.save() + updated_fields.update({'email': data['email']}) + + # update profile fields + profile_fields = [ + "name", "level_of_education", "gender", "mailing_address", "city", + "country", "goals", "bio", "year_of_birth", "language" + ] + + profile_fields_to_update = {} + for field in profile_fields: + if field in data: + profile_fields_to_update[field] = data[field] + + if len(profile_fields_to_update): + UserProfile.objects.filter(user=user).update(**profile_fields_to_update) + updated_fields.update(profile_fields_to_update) + + # If there is an exension form fields installed update them too + custom_profile_fields_to_update = {} + custom_form = get_registration_extension_form() + + if custom_form is not None: + for custom_field in custom_form.fields: + if custom_field in data: + custom_profile_fields_to_update[custom_field] = data[custom_field] + updated_fields.update(custom_profile_fields_to_update) + + if len(custom_profile_fields_to_update): + custom_form.Meta.model.objects.filter(user=user).update( + **custom_profile_fields_to_update) + + return Response( + {"success": "The following fields has been updated: {}".format( + ', '.join( + '{}={}'.format(f, v) for f, v in updated_fields.items()) + ) + }, + status=200) + + class GetUserAccountView(APIView): authentication_classes = OAuth2AuthenticationAllowInactiveUser, permission_classes = IsStaffOrOwner, @@ -260,7 +363,6 @@ def get(self, request, username): """ try: account_settings = User.objects.select_related('profile').get(username=username) - print account_settings except User.DoesNotExist: return Response( status=status.HTTP_404_NOT_FOUND diff --git a/lms/djangoapps/badges/backends/badgr.py b/lms/djangoapps/badges/backends/badgr.py index d40efcf1c340..f01bb3bd6a6b 100644 --- a/lms/djangoapps/badges/backends/badgr.py +++ b/lms/djangoapps/badges/backends/badgr.py @@ -1,13 +1,14 @@ """ Badge Awarding backend for Badgr-Server. """ -import hashlib import logging import mimetypes import requests from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError from lazy import lazy from requests.packages.urllib3.exceptions import HTTPError @@ -57,20 +58,6 @@ def _assertion_url(self, slug): """ return "{}/assertions".format(self._badge_url(slug)) - def _slugify(self, badge_class): - """ - Get a compatible badge slug from the specification. - """ - slug = badge_class.issuing_component + badge_class.slug - if badge_class.issuing_component and badge_class.course_id: - # Make this unique to the course, and down to 64 characters. - # We don't do this to badges without issuing_component set for backwards compatibility. - slug = hashlib.sha256(slug + unicode(badge_class.course_id)).hexdigest() - if len(slug) > MAX_SLUG_LENGTH: - # Will be 64 characters. - slug = hashlib.sha256(slug).hexdigest() - return slug - def _log_if_raised(self, response, data): """ Log server response if there was an error. @@ -102,10 +89,14 @@ def _create_badge(self, badge_class): u"Filename was: {}".format(image.name) ) files = {'image': (image.name, image, content_type)} + try: # TODO: eventually we should pass both + URLValidator(badge_class.criteria) + criteria_type = 'criteria_url' + except ValidationError: + criteria_type = 'criteria_text' data = { 'name': badge_class.display_name, - 'criteria': badge_class.criteria, - 'slug': self._slugify(badge_class), + criteria_type: badge_class.criteria, 'description': badge_class.description, } result = requests.post( @@ -142,7 +133,7 @@ def _create_assertion(self, badge_class, user, evidence_url): 'evidence': evidence_url, } response = requests.post( - self._assertion_url(self._slugify(badge_class)), headers=self._get_headers(), data=data, + self._assertion_url(badge_class.slug), headers=self._get_headers(), data=data, timeout=settings.BADGR_TIMEOUT ) self._log_if_raised(response, data) @@ -166,7 +157,7 @@ def _ensure_badge_created(self, badge_class): """ Verify a badge has been created for this badge class, and create it if not. """ - slug = self._slugify(badge_class) + slug = badge_class.slug if slug in BadgrBackend.badges: return response = requests.get(self._badge_url(slug), headers=self._get_headers(), timeout=settings.BADGR_TIMEOUT) diff --git a/lms/djangoapps/badges/events/course_complete.py b/lms/djangoapps/badges/events/course_complete.py index 6f531b2e6461..e5397b01c33c 100644 --- a/lms/djangoapps/badges/events/course_complete.py +++ b/lms/djangoapps/badges/events/course_complete.py @@ -1,11 +1,9 @@ """ Helper functions for the course complete event that was originally included with the Badging MVP. """ -import hashlib import logging from django.core.urlresolvers import reverse -from django.template.defaultfilters import slugify from django.utils.translation import ugettext_lazy as _ from badges.models import CourseCompleteImageConfiguration, BadgeClass, BadgeAssertion @@ -19,21 +17,6 @@ # migrations. Please check the badge migrations when changing any of these functions. -def course_slug(course_key, mode): - """ - Legacy: Not to be used as a model for constructing badge slugs. Included for compatibility with the original badge - type, awarded on course completion. - - Slug ought to be deterministic and limited in size so it's not too big for Badgr. - - Badgr's max slug length is 255. - """ - # Seven digits should be enough to realistically avoid collisions. That's what git services use. - digest = hashlib.sha256(u"{}{}".format(unicode(course_key), unicode(mode))).hexdigest()[:7] - base_slug = slugify(unicode(course_key) + u'_{}_'.format(mode))[:248] - return base_slug + digest - - def badge_description(course, mode): """ Returns a description for the earned badge. @@ -85,8 +68,6 @@ def get_completion_badge(course_id, user): if not course.issue_badges: return None return BadgeClass.get_badge_class( - slug=course_slug(course_id, mode), - issuing_component='', criteria=criteria(course_id), description=badge_description(course, mode), course_id=course_id, diff --git a/lms/djangoapps/badges/events/course_meta.py b/lms/djangoapps/badges/events/course_meta.py index e5bbb99c338d..fa49bc0830c1 100644 --- a/lms/djangoapps/badges/events/course_meta.py +++ b/lms/djangoapps/badges/events/course_meta.py @@ -5,6 +5,7 @@ from badges.models import CourseEventBadgesConfiguration, BadgeClass from badges.utils import requires_badges_enabled +from badges.events.course_complete import evidence_url def award_badge(config, count, user): @@ -23,7 +24,7 @@ def award_badge(config, count, user): if not slug: return badge_class = BadgeClass.get_badge_class( - slug=slug, issuing_component='openedx__course', create=False, + slug=slug, create=False, ) if not badge_class: return @@ -71,11 +72,24 @@ def course_group_check(user, course_key): course_id__in=keys, ) if len(certs) == len(keys): - awards.append(slug) + # course_complete Assertions are not working correctly + # yet with Badgr.io, while course group is working. + # so we use course groups with a single course, + # in which case we can provide an evidence URL + # to the HTML cert for the one coursee + if len(keys) == 1: + evidence = evidence_url(user.id, course_key) + awards.append((slug, evidence)) + else: + awards.append(slug) - for slug in awards: + for award in awards: badge_class = BadgeClass.get_badge_class( - slug=slug, issuing_component='openedx__course', create=False, + slug=award[0], create=False, ) if badge_class and not badge_class.get_for_user(user): - badge_class.award(user) + if award[1]: + badge_class.award(user, evidence_url=award[1]) + else: + badge_class.award(user) + diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index 484e7cd11064..bb84cf1d5e3f 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -48,28 +48,28 @@ class BadgeClass(models.Model): """ Specifies a badge class to be registered with a backend. """ - slug = models.SlugField(max_length=255, validators=[validate_lowercase]) + slug = models.SlugField(max_length=255, unique=True) issuing_component = models.SlugField(max_length=50, default='', blank=True, validators=[validate_lowercase]) display_name = models.CharField(max_length=255) course_id = CourseKeyField(max_length=255, blank=True, default=None) description = models.TextField() - criteria = models.TextField() - # Mode a badge was awarded for. Included for legacy/migration purposes. + criteria = models.TextField() # TODO: Badgr and Open Badges spec can take both text and url criteria mode = models.CharField(max_length=100, default='', blank=True) image = models.ImageField(upload_to='badge_classes', validators=[validate_badge_image]) def __unicode__(self): - return u"".format( - slug=self.slug, issuing_component=self.issuing_component + return u"".format( + slug=self.slug, issuing_component=self.issuing_component, + course_id = unicode(self.course_id), mode=self.mode ) @classmethod def get_badge_class( - cls, slug, issuing_component, display_name=None, description=None, criteria=None, image_file_handle=None, + cls, slug=None, issuing_component=None, display_name=None, description=None, criteria=None, image_file_handle=None, mode='', course_id=None, create=True ): """ - Looks up a badge class by its slug, issuing component, and course_id and returns it should it exist. + Looks up a badge class by its slug, or combination of mode and course_id and returns it should it exist. If it does not exist, and create is True, creates it according to the arguments. Otherwise, returns None. The expectation is that an XBlock or platform developer should not need to concern themselves with whether @@ -77,14 +77,18 @@ def get_badge_class( and it will 'do the right thing'. It should be the exception, rather than the common case, that a badge class would need to be looked up without also being created were it missing. """ - slug = slug.lower() - issuing_component = issuing_component.lower() if course_id and not modulestore().get_course(course_id).issue_badges: raise CourseBadgesDisabledError("This course does not have badges enabled.") if not course_id: course_id = CourseKeyField.Empty try: - return cls.objects.get(slug=slug, issuing_component=issuing_component, course_id=course_id) + if slug: + return cls.objects.get(slug=slug) + else: + if mode: + return cls.objects.get(mode=mode, course_id=course_id) + else: # allow setting a BadgeClass with no mode, can be used for all modes + return cls.objects.get(course_id=course_id) except cls.DoesNotExist: if not create: return None @@ -123,17 +127,15 @@ def award(self, user, evidence_url=None): """ return self.backend.award(self, user, evidence_url=evidence_url) - def save(self, **kwargs): - """ - Slugs must always be lowercase. - """ - self.slug = self.slug and self.slug.lower() - self.issuing_component = self.issuing_component and self.issuing_component.lower() - super(BadgeClass, self).save(**kwargs) + # def save(self, **kwargs): + # #""" + # # Slugs must always be lowercase. + # #""" + # super(BadgeClass, self).save(**kwargs) class Meta(object): app_label = "badges" - unique_together = (('slug', 'issuing_component', 'course_id'),) + unique_together = (('mode', 'course_id'),) verbose_name_plural = "Badge Classes" diff --git a/lms/envs/aws_appsembler.py b/lms/envs/aws_appsembler.py index 8c3709a6bfc9..8a82cf2ef401 100644 --- a/lms/envs/aws_appsembler.py +++ b/lms/envs/aws_appsembler.py @@ -114,3 +114,21 @@ CORS_REPLACE_HTTPS_REFERER = True CUSTOM_SSO_FIELDS_SYNC = ENV_TOKENS.get('CUSTOM_SSO_FIELDS_SYNC', {}) + +HTTPS = 'on' if ENV_TOKENS.get('BASE_SCHEME', 'https').lower() == 'https' else 'off' + +#configure auth backends +if 'LMS_AUTHENTICATION_BACKENDS' in APPSEMBLER_FEATURES.keys(): + #default behavior is to replace the existing backends with those in APPSEMBLER_FEATURES + AUTHENTICATION_BACKENDS = tuple(APPSEMBLER_FEATURES['LMS_AUTHENTICATION_BACKENDS']) + +#attempt to import model from our custom fork of edx-organizations +# if it works, then also add the middleware +try: + from organizations.models import UserOrganizationMapping + MIDDLEWARE_CLASSES += ( + 'organizations.middleware.OrganizationMiddleware', + ) +except ImportError: + pass + diff --git a/lms/envs/devstack_appsembler.py b/lms/envs/devstack_appsembler.py index 8c092bd5ad1d..801237b6eb89 100644 --- a/lms/envs/devstack_appsembler.py +++ b/lms/envs/devstack_appsembler.py @@ -105,3 +105,20 @@ CUSTOM_SSO_FIELDS_SYNC = ENV_TOKENS.get('CUSTOM_SSO_FIELDS_SYNC', {}) # to allow to run python-saml with custom port SP_SAML_RESTRICT_MODE = False + +#configure auth backends +if 'LMS_AUTHENTICATION_BACKENDS' in APPSEMBLER_FEATURES.keys(): + #default behavior is to replace the existing backends with those in APPSEMBLER_FEATURES + AUTHENTICATION_BACKENDS = tuple(APPSEMBLER_FEATURES['LMS_AUTHENTICATION_BACKENDS']) + +#attempt to import model from our custom fork of edx-organizations +# if it works, then also add the middleware +try: + from organizations.models import UserOrganizationMapping + MIDDLEWARE_CLASSES += ( + 'organizations.middleware.OrganizationMiddleware', + ) +except ImportError: + pass + + diff --git a/openedx/core/djangoapps/appsembler/external_courses/tasks.py b/openedx/core/djangoapps/appsembler/external_courses/tasks.py index 6d3e3aca717f..3ddf184e20ce 100644 --- a/openedx/core/djangoapps/appsembler/external_courses/tasks.py +++ b/openedx/core/djangoapps/appsembler/external_courses/tasks.py @@ -16,7 +16,7 @@ log = logging.getLogger(__name__) -@task(name='openedx.core.djangoapps.external_courses.fetch_courses') +@task(name='openedx.core.djangoapps.appsembler.external_courses.tasks.fetch_courses') def fetch_courses(): num_changed = 0 @@ -125,4 +125,4 @@ def is_credit_eligible(course_run): if seat['type'] == 'credit': return True - return False \ No newline at end of file + return False