diff --git a/.gitignore b/.gitignore index b97e04a424..6290093b2b 100644 --- a/.gitignore +++ b/.gitignore @@ -85,4 +85,7 @@ kolibri/plugins/learn/assets/src/demo/ # ignore strange OSX icon files. (Note there are special characters after 'Icon') # see http://stackoverflow.com/a/30755378 -Icon \ No newline at end of file +Icon + +#ignore added files with DS_Store +.DS_Store diff --git a/docs/dev/content.rst b/docs/dev/content.rst new file mode 100644 index 0000000000..2f3bb744cf --- /dev/null +++ b/docs/dev/content.rst @@ -0,0 +1,19 @@ +Content +====================================== + +This is a core module found in ``kolibri/Content``. + +.. toctree:: + :maxdepth: 1 + + content/concepts_and_definitions + content/implementation + content/api_methods + content/api_endpoints + + +Models +------ + +.. automodule:: kolibri.content.models + :members: diff --git a/docs/dev/content/api_endpoints.rst b/docs/dev/content/api_endpoints.rst new file mode 100644 index 0000000000..5af0231d76 --- /dev/null +++ b/docs/dev/content/api_endpoints.rst @@ -0,0 +1,22 @@ +API endpoints +------------- + +request specific content: + + >>> localhost:8000/api/content//contentnode/ + +search content: + + >>> localhost:8000/api/content//contentnode/?search= + +request specific content with specified fields: + + >>> localhost:8000/api/content//contentnode//?fields=pk,title,kind + +request paginated contents + + >>> localhost:8000/api/content//contentnode/?page=6&page_size=10 + +request combines different usages + + >>> localhost:8000/api/content//contentnode/?fields=pk,title,kind,instance_id,description,files&page=6&page_size=10&search=wh \ No newline at end of file diff --git a/docs/dev/content/api_methods.rst b/docs/dev/content/api_methods.rst new file mode 100644 index 0000000000..7792bcc735 --- /dev/null +++ b/docs/dev/content/api_methods.rst @@ -0,0 +1,5 @@ +API Methods +----------- + +.. automodule:: kolibri.content.api + :members: \ No newline at end of file diff --git a/docs/dev/content/concepts_and_definitions.rst b/docs/dev/content/concepts_and_definitions.rst new file mode 100644 index 0000000000..74688bf629 --- /dev/null +++ b/docs/dev/content/concepts_and_definitions.rst @@ -0,0 +1,36 @@ +Concepts and Definitions +======================== + +ContentNode +----------- + +High level abstraction for prepresenting different content kinds, such as Topic, Video, Audio, Exercise, Document, and can be easily extended to support new content kinds. With multiple ContentNode objects, it supports grouping, arranging them in tree structure, and symmetric and asymmetric relationship between two ContentNode objects. + +File +---- + +A Django model that is used to store details about the source file, such as what language it supports, how big is the size, which format the file is and where to find the source file. + +ContentDB Diagram +----------------- +.. image:: ../img/content_distributed_db.png +.. Source: https://www.draw.io/#G0B5xDzmtBJIQlNlEybldiODJqUHM + +**PK = Primary Key +**FK = Foreign Key +**M2M = ManyToManyField + +ContentTag +---------- + +This model is used to establish a filtering system for all ContentNode objects. + + +ChannelMetadata +--------------- + +A Django model in each content database that stores the database readable names, description and author for each channel. + +ChannelMetadataCache +-------------------- +This class stores the channel metadata cached/denormed into the default database. diff --git a/docs/dev/content/implementation.rst b/docs/dev/content/implementation.rst new file mode 100644 index 0000000000..cd83ce2592 --- /dev/null +++ b/docs/dev/content/implementation.rst @@ -0,0 +1,115 @@ +Implementation Details and Workflows +==================================== + +To achieve using separate databases for each channel and being able to switch channels dynamically, the following data structure and utility functions have been implemented. + +ContentDBRoutingMiddleware +-------------------------- + +This middleware will be applied to every request, and will dynamically select a database based on the channel_id. +If a channel ID was included in the URL, it will ensure the appropriate content DB is used for the duration of the request. (Note: `set_active_content_database` is thread-local, so this shouldn't interfere with other parallel requests.) + +For example, this is how the client side dynamically requests data from a specific channel: + + >>> localhost:8000/api/content//contentnode + +this will respond with all the contentnode data stored in database .sqlite3 + + >>> localhost:8000/api/content//contentnode + +this will respond with all the contentnode data stored in database .sqlite3 + +get_active_content_database +--------------------------- + +A utility function to retrieve the temporary thread-local variable that `using_content_database` sets + +set_active_content_database +--------------------------- + +A utility function to set the temporary thread-local variable + +using_content_database +---------------------- + +A decorator and context manager to do queries on a specific content DB. + +Usage as a context manager: + + .. code-block:: python + + from models import ContentNode + + with using_content_database("nalanda"): + objects = ContentNode.objects.all() + return objects.count() + +Usage as a decorator: + + .. code-block:: python + + from models import ContentNode + + @using_content_database('nalanda') + def delete_all_the_nalanda_content(): + ContentNode.objects.all().delete() + +ContentDBRouter +--------------- + +A router that decides what content database to read from based on a thread-local variable. + +ContentNode +----------- + +``ContentNode`` is implemented as a Django model that inherits from two abstract classes, MPTTModel and ContentDatabaseModel. +`django-mptt's MPTTModel `_, which +allows for efficient traversal and querying of the ContentNode tree. +``ContentDatabaseModel`` is used as a marker so that the content_db_router knows to query against the content database only if the model inherits from ContentDatabaseModel. + +The tree structure is established by the ``parent`` field that is a foreign key pointing to another ContentNode object. You can also create a symmetric relationship using the ``related`` field, or an asymmetric field using the ``is_prerequisite`` field. + +File +---- + +The ``File`` model also inherits from ``ContentDatabaseModel``. + +To find where the source file is located, the class method ``get_url`` uses the ``checksum`` field and ``settings.CONTENT_STORAGE_DIR`` to calculate the file path. Every source file is named based on its MD5 hash value (this value is also stored in the ``checksum`` field) and stored in a namespaced folder under the directory specified in ``settings.CONTENT_STORAGE_DIR``. Because it's likely to have thousands of content files, and some filesystems cannot handle a flat folder with a large number of files very well, we create namespaced subfolders to improve the performance. So the eventual file path would look something like: + + ``/home/user/.kolibri/content/storage/9/8/9808fa7c560b9801acccf0f6cf74c3ea.mp4`` + +As you can see, it is fine to store your content files outside of the kolibri project folder as long as you set the ``settings.CONTENT_STORAGE_DIR`` accordingly. + +The front-end will then use the ``extension`` field to decide which content player should be used. When the ``supplementary`` field's value is ``True``, that means this File object isn't necessary and can display the content without it. For example, we will mark caption (subtitle) file as supplementary. + +Content Constants +----------------- + +A Python module that stores constants for the ``kind`` field in ContentNode model and the ``preset`` field and ``extension`` field in File model. + +.. automodule:: kolibri.content.constants.content_kinds +.. automodule:: kolibri.content.constants.extensions +.. automodule:: kolibri.content.constants.presets + +Workflows +--------- + +There are two workflows we currently designed to handle content UI rendering and content playback rendering + +- Content UI Rendering + +1. Start with a ContentNode object. +2. Get the associated File object that has the ``thumbnail`` field being True. +3. Get the thumbnail image by calling this File's ``get_url`` method. +4. Determine the template using the ``kind`` field of this ContentNode object. +5. Renders the template with the thumbnail image. + + +- Content Playback Rendering + +1. Start with a ContentNode object. +2. Retrieve a queryset of associated File objects that are filtered by the preset. +3. Use the ``thumbnail`` field as a filter on this queryset to get the File object and call this File object's ``get_url`` method to get the source file (the thumbnail image) +4. Use the ``supplementary`` field as a filter on this queryset to get the "supplementary" File objects, such as caption (subtitle), and call these File objects' ``get_url`` method to get the source files. +5. Use the ``supplementary`` field as a filter on this queryset to get the essential File object. Call its ``get_url`` method to get the source file and use its ``extension`` field to choose the content player. +6. Play the content. diff --git a/docs/dev/img/content_distributed_db.png b/docs/dev/img/content_distributed_db.png new file mode 100644 index 0000000000..1b7617b8c8 Binary files /dev/null and b/docs/dev/img/content_distributed_db.png differ diff --git a/docs/index.rst b/docs/index.rst index 1323735329..3a30e6f5e3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,6 +6,7 @@ Getting Started user/index dev/index + dev/content cli changelog contributing diff --git a/frontend_build/src/webpack.config.base.js b/frontend_build/src/webpack.config.base.js index d0d5967e19..c22189b31b 100644 --- a/frontend_build/src/webpack.config.base.js +++ b/frontend_build/src/webpack.config.base.js @@ -99,6 +99,7 @@ var config = { resolve: { alias: { 'kolibri_module': path.resolve('kolibri/core/assets/src/kolibri_module'), + 'core-constants': path.resolve('kolibri/core/assets/src/constants'), 'core-base': path.resolve('kolibri/core/assets/src/vue/core-base'), 'nav-bar-item': path.resolve('kolibri/core/assets/src/vue/nav-bar/nav-bar-item'), 'nav-bar-item.styl': path.resolve('kolibri/core/assets/src/vue/nav-bar/nav-bar-item.styl'), diff --git a/kolibri/auth/api.py b/kolibri/auth/api.py index f51afd2481..81d3cd6f83 100644 --- a/kolibri/auth/api.py +++ b/kolibri/auth/api.py @@ -1,8 +1,8 @@ from __future__ import absolute_import, print_function, unicode_literals -from django.contrib.auth import get_user +from django.contrib.auth import authenticate, get_user, login, logout from django.contrib.auth.models import AnonymousUser -from rest_framework import filters, permissions, viewsets +from rest_framework import filters, permissions, status, viewsets from rest_framework.response import Response from .models import Classroom, DeviceOwner, Facility, FacilityUser, LearnerGroup, Membership, Role @@ -106,7 +106,7 @@ def list(self, request): elif type(logged_in_user) is AnonymousUser: return Response(Facility.objects.all().values_list('id', flat=True)) else: - return Response(logged_in_user.facility) + return Response(logged_in_user.facility_id) class ClassroomViewSet(viewsets.ModelViewSet): @@ -123,3 +123,51 @@ class LearnerGroupViewSet(viewsets.ModelViewSet): serializer_class = LearnerGroupSerializer filter_fields = ('parent',) + + +class SessionViewSet(viewsets.ViewSet): + + def create(self, request): + username = request.data.get('username', '') + password = request.data.get('password', '') + facility_id = request.data.get('facility', None) + user = authenticate(username=username, password=password, facility=facility_id) + if user is not None and user.is_active: + # Correct password, and the user is marked "active" + login(request, user) + # Success! + return Response(self.get_session(request)) + else: + # Respond with error + return Response("User credentials invalid!", status=status.HTTP_401_UNAUTHORIZED) + + def destroy(self, request, pk=None): + logout(request) + return Response([]) + + def retrieve(self, request, pk=None): + return Response(self.get_session(request)) + + def get_session(self, request): + user = get_user(request) + if isinstance(user, AnonymousUser): + return {'id': None, 'username': '', 'full_name': '', 'user_id': None, 'facility_id': None, 'kind': 'ANONYMOUS', 'error': '200'} + + session = {'id': 'current', 'username': user.username, + 'full_name': user.full_name, + 'user_id': user.id} + if isinstance(user, DeviceOwner): + session.update({'facility_id': None, 'kind': 'SUPERUSER', 'error': '200'}) + return session + else: + roles = Role.objects.filter(user_id=user.id) + if len(roles) is not 0: + session.update({'facility_id': user.facility_id, 'kind': [], 'error': '200'}) + for role in roles: + if role.kind == 'admin': + session['kind'].append('ADMIN') + else: + session['kind'].append('COACH') + else: + session.update({'facility_id': user.facility_id, 'kind': 'LEARNER', 'error': '200'}) + return session diff --git a/kolibri/auth/api_urls.py b/kolibri/auth/api_urls.py index 2c8daffaa7..ac23b1980e 100644 --- a/kolibri/auth/api_urls.py +++ b/kolibri/auth/api_urls.py @@ -1,7 +1,8 @@ from rest_framework import routers from .api import ( - ClassroomViewSet, CurrentFacilityViewSet, DeviceOwnerViewSet, FacilityUserViewSet, FacilityViewSet, LearnerGroupViewSet, MembershipViewSet, RoleViewSet + ClassroomViewSet, CurrentFacilityViewSet, DeviceOwnerViewSet, FacilityUserViewSet, FacilityViewSet, LearnerGroupViewSet, MembershipViewSet, RoleViewSet, + SessionViewSet ) router = routers.SimpleRouter() @@ -12,6 +13,7 @@ router.register(r'role', RoleViewSet) router.register(r'facility', FacilityViewSet) router.register(r'currentfacility', CurrentFacilityViewSet, base_name='currentfacility') +router.register(r'session', SessionViewSet, base_name='session') router.register(r'classroom', ClassroomViewSet) router.register(r'learnergroup', LearnerGroupViewSet) diff --git a/kolibri/auth/backends.py b/kolibri/auth/backends.py index f206d2ca8e..4b62f8eb65 100644 --- a/kolibri/auth/backends.py +++ b/kolibri/auth/backends.py @@ -21,14 +21,13 @@ def authenticate(self, username=None, password=None, facility=None): :param facility: a Facility :return: A FacilityUser instance if successful, or None if authentication failed. """ - try: - user = FacilityUser.objects.get(username=username, facility=facility) + users = FacilityUser.objects.filter(username=username) + if facility: + users = users.filter(facility=facility) + for user in users: if user.check_password(password): return user - else: - return None - except FacilityUser.DoesNotExist: - return None + return None def get_user(self, user_id): """ @@ -48,7 +47,7 @@ class DeviceOwnerBackend(object): A class that implements authentication for DeviceOwners. """ - def authenticate(self, username=None, password=None): + def authenticate(self, username=None, password=None, **kwargs): """ Authenticates the user if the credentials correspond to a DeviceOwner. diff --git a/kolibri/auth/migrations/0001_initial.py b/kolibri/auth/migrations/0001_initial_redone.py similarity index 84% rename from kolibri/auth/migrations/0001_initial.py rename to kolibri/auth/migrations/0001_initial_redone.py index 980e33629c..aa20b08bfa 100644 --- a/kolibri/auth/migrations/0001_initial.py +++ b/kolibri/auth/migrations/0001_initial_redone.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.1 on 2016-03-10 23:12 +# Generated by Django 1.9.7 on 2016-08-09 17:25 from __future__ import unicode_literals import django.core.validators @@ -25,8 +25,7 @@ class Migration(migrations.Migration): ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('username', models.CharField(help_text='Required. 30 characters or fewer. Letters and digits only.', max_length=30, validators=[django.core.validators.RegexValidator('^\\w+$', 'Enter a valid username. This value may contain only letters and numbers.')], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=60, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=60, verbose_name='last name')), + ('full_name', models.CharField(blank=True, max_length=120, verbose_name='full name')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='date joined')), ], options={ @@ -67,22 +66,30 @@ class Migration(migrations.Migration): ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('username', models.CharField(help_text='Required. 30 characters or fewer. Letters and digits only.', max_length=30, validators=[django.core.validators.RegexValidator('^\\w+$', 'Enter a valid username. This value may contain only letters and numbers.')], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=60, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=60, verbose_name='last name')), + ('full_name', models.CharField(blank=True, max_length=120, verbose_name='full name')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='date joined')), ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kolibriauth.FacilityDataset')), ], ), migrations.CreateModel( - name='Role', + name='Membership', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('kind', models.CharField(choices=[('admin', 'Admin'), ('coach', 'Coach'), ('learner', 'Learner')], max_length=20)), ('collection', mptt.fields.TreeForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kolibriauth.Collection')), ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kolibriauth.FacilityDataset')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kolibriauth.FacilityUser')), ], ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('admin', 'Admin'), ('coach', 'Coach')], max_length=20)), + ('collection', mptt.fields.TreeForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kolibriauth.Collection')), + ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kolibriauth.FacilityDataset')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roles', to='kolibriauth.FacilityUser')), + ], + ), migrations.AddField( model_name='collection', name='dataset', @@ -124,6 +131,10 @@ class Migration(migrations.Migration): name='role', unique_together=set([('user', 'collection', 'kind')]), ), + migrations.AlterUniqueTogether( + name='membership', + unique_together=set([('user', 'collection')]), + ), migrations.AddField( model_name='facilityuser', name='facility', diff --git a/kolibri/auth/migrations/0002_auto_20160318_0557.py b/kolibri/auth/migrations/0002_auto_20160318_0557.py deleted file mode 100644 index efe4b00d2e..0000000000 --- a/kolibri/auth/migrations/0002_auto_20160318_0557.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.1 on 2016-03-18 05:57 -from __future__ import unicode_literals - -import django.db.models.deletion -import mptt.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('kolibriauth', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Membership', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('collection', mptt.fields.TreeForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kolibriauth.Collection')), - ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kolibriauth.FacilityDataset')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kolibriauth.FacilityUser')), - ], - ), - migrations.AlterField( - model_name='role', - name='kind', - field=models.CharField(choices=[(b'admin', 'Admin'), (b'coach', 'Coach')], max_length=20), - ), - migrations.AlterUniqueTogether( - name='membership', - unique_together=set([('user', 'collection')]), - ), - ] diff --git a/kolibri/auth/models.py b/kolibri/auth/models.py index 65ce4c9c15..cf6b623483 100644 --- a/kolibri/auth/models.py +++ b/kolibri/auth/models.py @@ -81,7 +81,7 @@ class AbstractFacilityDataModel(models.Model): such as ``FacilityUsers``, ``Collections``, and other data associated with those users and collections. """ - dataset = models.ForeignKey("FacilityDataset") + dataset = models.ForeignKey(FacilityDataset) class Meta: abstract = True @@ -153,15 +153,11 @@ class Meta: ), ], ) - first_name = models.CharField(_('first name'), max_length=60, blank=True) - last_name = models.CharField(_('last name'), max_length=60, blank=True) + full_name = models.CharField(_('full name'), max_length=120, blank=True) date_joined = models.DateTimeField(_('date joined'), default=timezone.now, editable=False) - def get_full_name(self): - return (self.first_name + " " + self.last_name).strip() - def get_short_name(self): - return self.first_name + return self.full_name.split(' ', 1)[0] def is_member_of(self, coll): """ @@ -502,7 +498,7 @@ def filter_readable(self, queryset): return queryset.none() def __str__(self): - return '"{user}"@"{facility}"'.format(user=self.get_full_name() or self.username, facility=self.facility) + return '"{user}"@"{facility}"'.format(user=self.full_name or self.username, facility=self.facility) class DeviceOwnerManager(models.Manager): @@ -575,7 +571,7 @@ def filter_readable(self, queryset): return queryset def __str__(self): - return self.get_full_name() or self.username + return self.full_name or self.username def has_perm(self, perm, obj=None): # ensure the DeviceOwner has full access to the Django admin @@ -831,6 +827,11 @@ class Facility(Collection): class Meta: proxy = True + @classmethod + def get_default_facility(cls): + # temporary approach to a default facility; later, we can make this more refined + return cls.objects.all().first() + def save(self, *args, **kwargs): if self.parent: raise IntegrityError("Facility must be the root of a collection tree, and cannot have a parent.") diff --git a/kolibri/auth/serializers.py b/kolibri/auth/serializers.py index d472e28dc9..3ff051b176 100644 --- a/kolibri/auth/serializers.py +++ b/kolibri/auth/serializers.py @@ -17,7 +17,7 @@ class FacilityUserSerializer(serializers.ModelSerializer): class Meta: model = FacilityUser extra_kwargs = {'password': {'write_only': True}} - fields = ('id', 'username', 'first_name', 'last_name', 'password', 'facility', 'roles') + fields = ('id', 'username', 'full_name', 'password', 'facility', 'roles') def create(self, validated_data): user = FacilityUser(**validated_data) diff --git a/kolibri/auth/test/test_api.py b/kolibri/auth/test/test_api.py index f656e2c5af..eee5103332 100644 --- a/kolibri/auth/test/test_api.py +++ b/kolibri/auth/test/test_api.py @@ -8,6 +8,7 @@ from rest_framework import status from rest_framework.test import APITestCase as BaseTestCase +from django.contrib.sessions.models import Session from .. import models @@ -226,3 +227,44 @@ def test_creating_facility_user_via_api_sets_password_correctly(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(models.FacilityUser.objects.get(username=new_username).check_password(new_password)) self.assertFalse(models.FacilityUser.objects.get(username=new_username).check_password(bad_password)) + + +class LoginLogoutTestCase(APITestCase): + + def setUp(self): + self.device_owner = DeviceOwnerFactory.create() + self.facility = FacilityFactory.create() + self.user = FacilityUserFactory.create(facility=self.facility) + self.admin = FacilityUserFactory.create(facility=self.facility, password="bar") + self.facility.add_admin(self.admin) + self.cr = ClassroomFactory.create(parent=self.facility) + self.cr.add_coach(self.admin) + + def test_login_and_logout_device_owner(self): + self.client.post(reverse('session-list'), data={"username": self.device_owner.username, "password": DUMMY_PASSWORD}) + sessions = Session.objects.all() + self.assertEqual(len(sessions), 1) + self.client.delete(reverse('session-detail', kwargs={'pk': 'current'})) + self.assertEqual(len(Session.objects.all()), 0) + + def test_login_and_logout_facility_user(self): + self.client.post(reverse('session-list'), data={"username": self.user.username, "password": DUMMY_PASSWORD, "facility": self.facility.id}) + sessions = Session.objects.all() + self.assertEqual(len(sessions), 1) + self.client.delete(reverse('session-detail', kwargs={'pk': 'current'})) + self.assertEqual(len(Session.objects.all()), 0) + + def test_incorrect_credentials_does_not_log_in_user(self): + self.client.post(reverse('session-list'), data={"username": self.user.username, "password": "foo", "facility": self.facility.id}) + sessions = Session.objects.all() + self.assertEqual(len(sessions), 0) + + def test_session_return_admin_and_coach_kind(self): + self.client.post(reverse('session-list'), data={"username": self.admin.username, "password": "bar", "facility": self.facility.id}) + response = self.client.get(reverse('session-detail', kwargs={'pk': 'current'})) + self.assertTrue(response.data['kind'][0], 'ADMIN') + self.assertTrue(response.data['kind'][1], 'COACH') + + def test_session_return_anon_kind(self): + response = self.client.get(reverse('session-detail', kwargs={'pk': 'current'})) + self.assertTrue(response.data['kind'][0], 'ANONYMOUS') diff --git a/kolibri/auth/test/test_backend.py b/kolibri/auth/test/test_backend.py index 0770a0abdb..a0efa9dfdb 100644 --- a/kolibri/auth/test/test_backend.py +++ b/kolibri/auth/test/test_backend.py @@ -55,8 +55,8 @@ def setUp(self): def test_facility_user_authenticated(self): self.assertEqual(self.user, FacilityUserBackend().authenticate(username="Mike", password="foo", facility=self.facility)) - def test_facility_user_authentication_requires_facility(self): - self.assertIsNone(FacilityUserBackend().authenticate(username="Mike", password="foo")) + def test_facility_user_authentication_does_not_require_facility(self): + self.assertEqual(self.user, FacilityUserBackend().authenticate(username="Mike", password="foo")) def test_device_owner_not_authenticated(self): self.assertIsNone(FacilityUserBackend().authenticate(username="Chuck", password="foobar")) diff --git a/kolibri/auth/test/test_users.py b/kolibri/auth/test/test_users.py index 5afb461ac3..91cb92ca9b 100644 --- a/kolibri/auth/test/test_users.py +++ b/kolibri/auth/test/test_users.py @@ -14,8 +14,7 @@ def setUp(self): self.facility = Facility.objects.create() self.user = FacilityUser.objects.create( username="mike", - first_name="Mike", - last_name="Gallaspy", + full_name="Mike Gallaspy", password="###", facility=self.facility, ) @@ -32,6 +31,3 @@ def test_device_owner(self): def test_short_name(self): self.assertEqual(self.user.get_short_name(), "Mike") - - def test_full_name(self): - self.assertEqual(self.user.get_full_name(), "Mike Gallaspy") diff --git a/kolibri/content/api.py b/kolibri/content/api.py index 5e1bf85afb..4eef7faa77 100644 --- a/kolibri/content/api.py +++ b/kolibri/content/api.py @@ -5,10 +5,6 @@ from rest_framework import filters, pagination, viewsets -# from kolibri.logger.models import ContentInteractionLog -# from django.db.models.aggregates import Count - - class ChannelMetadataCacheViewSet(viewsets.ModelViewSet): serializer_class = serializers.ChannelMetadataCacheSerializer diff --git a/kolibri/content/migrations/0002_auto_20160802_1704.py b/kolibri/content/migrations/0002_auto_20160802_1704.py new file mode 100644 index 0000000000..b496efcb1b --- /dev/null +++ b/kolibri/content/migrations/0002_auto_20160802_1704.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-08-02 17:04 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='contentnode', + name='kind', + field=models.CharField(blank=True, choices=[('topic', 'Topic'), ('video', 'Video'), ('audio', 'Audio'), ('exercise', 'Exercise'), ('document', 'Document'), ('image', 'Image')], max_length=200), + ), + migrations.AlterField( + model_name='file', + name='extension', + field=models.CharField(blank=True, choices=[('mp4', 'mp4'), ('vtt', 'vtt'), ('srt', 'srt'), ('mp3', 'mp3'), ('pdf', 'pdf')], max_length=40), + ), + migrations.AlterField( + model_name='file', + name='preset', + field=models.CharField(blank=True, choices=[('high_res_video', 'High resolution video'), ('low_res_video', 'Low resolution video'), ('vector_video', 'Vertor video'), ('thumbnail', 'Thumbnail'), ('thumbnail', 'Thumbnail'), ('caption', 'Caption')], max_length=150), + ), + ] diff --git a/kolibri/content/utils/channels.py b/kolibri/content/utils/channels.py index b5139cc0aa..a3bd7e28f8 100644 --- a/kolibri/content/utils/channels.py +++ b/kolibri/content/utils/channels.py @@ -20,6 +20,7 @@ def get_channel_id_list_from_scanning_content_database_dir(content_database_dir) db_names = [db.split('.sqlite3', 1)[0] for db in db_list] valid_db_names = [name for name in db_names if _is_valid_hex_uuid(name)] invalid_db_names = set(db_names) - set(valid_db_names) - logging.warning("Ignoring databases in content database directory '{directory}' with invalid names: {names}" - .format(directory=content_database_dir, names=invalid_db_names)) + if invalid_db_names: + logging.warning("Ignoring databases in content database directory '{directory}' with invalid names: {names}" + .format(directory=content_database_dir, names=invalid_db_names)) return valid_db_names diff --git a/kolibri/core/assets/src/actions.js b/kolibri/core/assets/src/actions.js deleted file mode 100644 index f206dc25d1..0000000000 --- a/kolibri/core/assets/src/actions.js +++ /dev/null @@ -1,21 +0,0 @@ - -const UserKinds = require('./constants').UserKinds; - -function logIn(store) { - store.dispatch('CORE_SET_SESSION', { - kind: UserKinds.ADMIN, - facility_id: '1', - user_id: '2', - username: 'starchy52', - fullname: 'Mr. Potato Head', - }); -} - -function logOut(store) { - store.dispatch('CORE_CLEAR_SESSION'); -} - -module.exports = { - logIn, - logOut, -}; diff --git a/kolibri/core/assets/src/api-resource.js b/kolibri/core/assets/src/api-resource.js index 838decec11..5b543e2333 100644 --- a/kolibri/core/assets/src/api-resource.js +++ b/kolibri/core/assets/src/api-resource.js @@ -26,8 +26,6 @@ function getCookie(name) { return cookieValue; } -const client = rest.wrap(mime).wrap(csrf, { name: 'X-CSRFToken', - token: getCookie('csrftoken') }).wrap(errorCode); /** Class representing a single API resource object */ class Model { @@ -215,7 +213,7 @@ class Model { set(attributes) { // force IDs to always be strings - this should be changed on the server-side too - if (this.resource.idKey in attributes) { + if (attributes && this.resource.idKey in attributes) { attributes[this.resource.idKey] = String(attributes[this.resource.idKey]); } Object.assign(this.attributes, attributes); @@ -487,7 +485,8 @@ class Resource { } get client() { - return client; + return rest.wrap(mime).wrap(csrf, { name: 'X-CSRFToken', + token: getCookie('csrftoken') }).wrap(errorCode); } } diff --git a/kolibri/core/assets/src/api-resources/index.js b/kolibri/core/assets/src/api-resources/index.js index 5003017f3e..e13992df51 100644 --- a/kolibri/core/assets/src/api-resources/index.js +++ b/kolibri/core/assets/src/api-resources/index.js @@ -5,6 +5,7 @@ module.exports = { LearnerGroupResource: require('./learnerGroup'), MembershipResource: require('./membership'), RoleResource: require('./role'), + SessionResource: require('./session'), DeviceOwnerResource: require('./deviceOwner'), FacilityResource: require('./facility'), ChannelResource: require('./channel'), diff --git a/kolibri/core/assets/src/api-resources/session.js b/kolibri/core/assets/src/api-resources/session.js new file mode 100644 index 0000000000..5c22e14413 --- /dev/null +++ b/kolibri/core/assets/src/api-resources/session.js @@ -0,0 +1,9 @@ +const Resource = require('../api-resource').Resource; + +class SessionResource extends Resource { + static resourceName() { + return 'session'; + } +} + +module.exports = SessionResource; diff --git a/kolibri/core/assets/src/core-actions.js b/kolibri/core/assets/src/core-actions.js new file mode 100644 index 0000000000..2a0c6d7f47 --- /dev/null +++ b/kolibri/core/assets/src/core-actions.js @@ -0,0 +1,62 @@ +function kolibriLogin(store, Kolibri, sessionPayload) { + const SessionResource = Kolibri.resources.SessionResource; + const sessionModel = SessionResource.createModel(sessionPayload); + const sessionPromise = sessionModel.save(sessionPayload); + const UserKinds = require('./constants').UserKinds; + sessionPromise.then((session) => { + store.dispatch('CORE_SET_SESSION', session); + if (session.kind.includes(UserKinds.ADMIN) || session.kind === UserKinds.SUPERUSER) { + store.dispatch('SET_ADMIN_STATUS', true); + } + }).catch((error) => { + // hack to handle invalid credentials + if (error.status.code === 401) { + store.dispatch('HANDLE_WRONG_CREDS', { kind: 'ANONYMOUS', error: '401' }); + } else { + store.dispatch('CORE_SET_ERROR', JSON.stringify(error, null, '\t')); + } + }); +} + +function kolibriLogout(store, Kolibri) { + const SessionResource = Kolibri.resources.SessionResource; + const id = 'current'; + const sessionModel = SessionResource.getModel(id); + const logoutPromise = sessionModel.delete(id); + logoutPromise.then((response) => { + store.dispatch('CORE_CLEAR_SESSION'); + }).catch((error) => { + store.dispatch('CORE_SET_ERROR', JSON.stringify(error, null, '\t')); + }); +} + +function currentLoggedInUser(store, Kolibri) { + const SessionResource = Kolibri.resources.SessionResource; + const id = 'current'; + const sessionModel = SessionResource.getModel(id); + const sessionPromise = sessionModel.fetch(); + const UserKinds = require('./constants').UserKinds; + sessionPromise.then((session) => { + if (session.kind.includes(UserKinds.ADMIN) || session.kind === UserKinds.SUPERUSER) { + store.dispatch('SET_ADMIN_STATUS', true); + } + store.dispatch('CORE_SET_SESSION', session); + }).catch((error) => { + store.dispatch('CORE_SET_ERROR', JSON.stringify(error, null, '\t')); + }); +} + +function togglemodal(store, bool) { + store.dispatch('SET_MODAL_STATE', bool); + if (!bool) { + // Clears the store to clear any error message from login modal + store.dispatch('CORE_CLEAR_SESSION'); + } +} + +module.exports = { + kolibriLogin, + kolibriLogout, + currentLoggedInUser, + togglemodal, +}; diff --git a/kolibri/core/assets/src/core-app/constructor.js b/kolibri/core/assets/src/core-app/constructor.js index 8fa5202710..22a9b5644d 100644 --- a/kolibri/core/assets/src/core-app/constructor.js +++ b/kolibri/core/assets/src/core-app/constructor.js @@ -50,6 +50,9 @@ module.exports = function CoreApp() { this.resources = new ResourceManager(this); const mediator = new Mediator(); + this.constants = require('../constants'); + this.coreActions = require('../core-actions'); + Object.keys(Resources).forEach((resourceClassName) => this.resources.registerResource(resourceClassName, Resources[resourceClassName])); diff --git a/kolibri/core/assets/src/core-store.js b/kolibri/core/assets/src/core-store.js index 5a01cd7a85..60d84a0f33 100644 --- a/kolibri/core/assets/src/core-store.js +++ b/kolibri/core/assets/src/core-store.js @@ -7,16 +7,26 @@ const initialState = { core: { error: '', loading: true, - session: { kind: UserKinds.ANONYMOUS }, + session: { kind: UserKinds.ANONYMOUS, error: '200' }, + login_modal_state: false, + is_admin_or_superuser: false, + fullname: '', }, }; const mutations = { CORE_SET_SESSION(state, value) { state.core.session = value; + state.core.login_modal_state = false; + console.log('state.core.session: ', state.core.session); + }, + // Makes settings for wrong credentials 401 error + HANDLE_WRONG_CREDS(state, value) { + state.core.session = value; }, CORE_CLEAR_SESSION(state) { - state.core.session = { kind: UserKinds.ANONYMOUS }; + state.core.session = { kind: UserKinds.ANONYMOUS, error: '200' }; + state.core.is_admin_or_superuser = false; }, CORE_SET_PAGE_LOADING(state, value) { state.core.loading = value; @@ -24,6 +34,13 @@ const mutations = { CORE_SET_ERROR(state, error) { state.core.error = error; }, + // Handles state of login modal appearance + SET_MODAL_STATE(state, value) { + state.core.login_modal_state = value; + }, + SET_ADMIN_STATUS(state, value) { + state.core.is_admin_or_superuser = value; + }, }; module.exports = { diff --git a/kolibri/core/assets/src/styles/core-global.styl b/kolibri/core/assets/src/styles/core-global.styl index 5eb45d75c0..c612edd7ec 100644 --- a/kolibri/core/assets/src/styles/core-global.styl +++ b/kolibri/core/assets/src/styles/core-global.styl @@ -51,6 +51,8 @@ a &:hover color: $core-action-dark +:focus + outline: $core-action-light 2px solid // https://davidwalsh.name/html5-boilerplate img @@ -88,8 +90,7 @@ button border-color: $core-action-dark &:focus - background:$core-action-light - outline: none + outline: $core-action-light 2px solid // Firefox: Get rid of the inner focus border diff --git a/kolibri/core/assets/src/vue/core-base.vue b/kolibri/core/assets/src/vue/core-base.vue index 459bcb9d45..381983d8ce 100644 --- a/kolibri/core/assets/src/vue/core-base.vue +++ b/kolibri/core/assets/src/vue/core-base.vue @@ -47,6 +47,7 @@ padding-left: $left-margin padding-right: $right-margin padding-bottom: 50px + z-index: -2 @media screen and (max-width: $portrait-breakpoint) padding-left: $card-gutter * 2 padding-right: $card-gutter diff --git a/kolibri/core/assets/src/vue/icon-button.vue b/kolibri/core/assets/src/vue/icon-button.vue index aa6c5d7df4..5e7e79d2eb 100644 --- a/kolibri/core/assets/src/vue/icon-button.vue +++ b/kolibri/core/assets/src/vue/icon-button.vue @@ -1,6 +1,6 @@