diff --git a/cms/envs/common.py b/cms/envs/common.py index 2d64c56313..face8e67d9 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -823,6 +823,9 @@ INSTALLED_APPS = ( # Theming 'openedx.core.djangoapps.theming', + # Site configuration for theming and behavioral modification + 'openedx.core.djangoapps.site_configuration', + # comment common 'django_comment_common', diff --git a/lms/envs/common.py b/lms/envs/common.py index 7ca0b16846..a43e93ff3f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1829,6 +1829,9 @@ INSTALLED_APPS = ( # Theming 'openedx.core.djangoapps.theming', + # Site configuration for theming and behavioral modification + 'openedx.core.djangoapps.site_configuration', + # Our courseware 'courseware', 'student', diff --git a/openedx/core/djangoapps/site_configuration/__init__.py b/openedx/core/djangoapps/site_configuration/__init__.py new file mode 100644 index 0000000000..f470c9b9eb --- /dev/null +++ b/openedx/core/djangoapps/site_configuration/__init__.py @@ -0,0 +1,26 @@ +""" +This app is used for creating/updating site's configuration. This app encapsulate configuration related logic for sites +and provides a way for sites to override default/system behavioural or presentation logic. + +Models: + SiteConfiguration (models.Model): + This model contains configuration for a site and can be used to override OpenEdx configurations. + + Fields: + site (OneToOneField): one to one field relating each configuration to a single site + values (JSONField): json field to store configurations for a site + Usage: + configuration of each site would be available as `configuration` attribute to django site. + If you want to access current site's configuration simply access it as `request.site.configuration`. + + SiteConfigurationHistory (TimeStampedModel): + This model keeps a track of all the changes made to SiteConfiguration with time stamps. + + Fields: + site (ForeignKey): foreign-key to django Site + values (JSONField): json field to store configurations for a site + Usage: + configuration history of each site would be available as `configuration_histories` attribute to django site. + If you want to access a list of current site's configuration history simply access it as + `request.site.configuration_histories.all()`. +""" diff --git a/openedx/core/djangoapps/site_configuration/migrations/0001_initial.py b/openedx/core/djangoapps/site_configuration/migrations/0001_initial.py new file mode 100644 index 0000000000..4625239b75 --- /dev/null +++ b/openedx/core/djangoapps/site_configuration/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='SiteConfiguration', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('values', jsonfield.fields.JSONField(blank=True)), + ('site', models.OneToOneField(related_name='configuration', to='sites.Site')), + ], + ), + migrations.CreateModel( + name='SiteConfigurationHistory', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('values', jsonfield.fields.JSONField(blank=True)), + ('site', models.ForeignKey(related_name='configuration_histories', to='sites.Site')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + ] diff --git a/openedx/core/djangoapps/site_configuration/migrations/__init__.py b/openedx/core/djangoapps/site_configuration/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/site_configuration/models.py b/openedx/core/djangoapps/site_configuration/models.py new file mode 100644 index 0000000000..e2885369aa --- /dev/null +++ b/openedx/core/djangoapps/site_configuration/models.py @@ -0,0 +1,77 @@ +""" +Django models for site configurations. +""" +import collections + +from django.db import models +from django.contrib.sites.models import Site +from django.db.models.signals import post_save +from django.dispatch import receiver + +from django_extensions.db.models import TimeStampedModel +from jsonfield.fields import JSONField + + +class SiteConfiguration(models.Model): + """ + Model for storing site configuration. These configuration override OpenEdx configurations and settings. + e.g. You can override site name, logo image, favicon etc. using site configuration. + + Fields: + site (OneToOneField): one to one field relating each configuration to a single site + values (JSONField): json field to store configurations for a site + """ + site = models.OneToOneField(Site, related_name='configuration') + values = JSONField( + null=False, + blank=True, + load_kwargs={'object_pairs_hook': collections.OrderedDict} + ) + + def __unicode__(self): + return u"".format(site=self.site) + + def __repr__(self): + return self.__unicode__() + + +class SiteConfigurationHistory(TimeStampedModel): + """ + This is an archive table for SiteConfiguration, so that we can maintain a history of + changes. Note that the site field is not unique in this model, compared to SiteConfiguration. + + Fields: + site (ForeignKey): foreign-key to django Site + values (JSONField): json field to store configurations for a site + """ + site = models.ForeignKey(Site, related_name='configuration_histories') + values = JSONField( + null=False, + blank=True, + load_kwargs={'object_pairs_hook': collections.OrderedDict} + ) + + def __unicode__(self): + return u"".format( + modified=self.modified, + site=self.site, + ) + + def __repr__(self): + return self.__unicode__() + + +@receiver(post_save, sender=SiteConfiguration) +def update_site_configuration_history(sender, instance, **kwargs): # pylint: disable=unused-argument + """ + Add site configuration changes to site configuration history. + + Args: + sender: sender of the signal i.e. SiteConfiguration model + instance: SiteConfiguration instance associated with the current signal + **kwargs: extra key word arguments + """ + SiteConfigurationHistory.objects.create( + site=instance.site, + values=instance.values, + ) diff --git a/openedx/core/djangoapps/site_configuration/tests/__init__.py b/openedx/core/djangoapps/site_configuration/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/site_configuration/tests/factories.py b/openedx/core/djangoapps/site_configuration/tests/factories.py new file mode 100644 index 0000000000..061b2925ff --- /dev/null +++ b/openedx/core/djangoapps/site_configuration/tests/factories.py @@ -0,0 +1,16 @@ +""" +Model factories for unit testing views or models. +""" +from factory.django import DjangoModelFactory + +from openedx.core.djangoapps.site_configuration.models import SiteConfiguration + + +class SiteConfigurationFactory(DjangoModelFactory): + """ + Factory class for SiteConfiguration model + """ + class Meta(object): + model = SiteConfiguration + + values = {} diff --git a/openedx/core/djangoapps/site_configuration/tests/test_models.py b/openedx/core/djangoapps/site_configuration/tests/test_models.py new file mode 100644 index 0000000000..510b8d5174 --- /dev/null +++ b/openedx/core/djangoapps/site_configuration/tests/test_models.py @@ -0,0 +1,93 @@ +""" +Tests for site configuration's django models. +""" + +from django.test import TestCase +from django.db import IntegrityError, transaction +from django.contrib.sites.models import Site + +from openedx.core.djangoapps.site_configuration.models import SiteConfigurationHistory +from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory + + +class SiteConfigurationTests(TestCase): + """ + Tests for SiteConfiguration and its signals/receivers. + """ + domain = 'site_configuration_post_save_receiver_example.com' + name = 'site_configuration_post_save_receiver_example' + + @classmethod + def setUpClass(cls): + super(SiteConfigurationTests, cls).setUpClass() + cls.site, _ = Site.objects.get_or_create(domain=cls.domain, name=cls.domain) + + def test_site_configuration_post_save_receiver(self): + """ + Test that and entry is added to SiteConfigurationHistory model each time a new + SiteConfiguration is added. + """ + # add SiteConfiguration to database + site_configuration = SiteConfigurationFactory.create( + site=self.site, + ) + + # Verify an entry to SiteConfigurationHistory was added. + site_configuration_history = SiteConfigurationHistory.objects.filter( + site=site_configuration.site, + ).all() + + # Make sure an entry (and only one entry) is saved for SiteConfiguration + self.assertEqual(len(site_configuration_history), 1) + + def test_site_configuration_post_update_receiver(self): + """ + Test that and entry is added to SiteConfigurationHistory each time a + SiteConfiguration is updated. + """ + # add SiteConfiguration to database + site_configuration = SiteConfigurationFactory.create( + site=self.site, + ) + + site_configuration.values = {'test': 'test'} + site_configuration.save() + + # Verify an entry to SiteConfigurationHistory was added. + site_configuration_history = SiteConfigurationHistory.objects.filter( + site=site_configuration.site, + ).all() + + # Make sure two entries (one for save and one for update) are saved for SiteConfiguration + self.assertEqual(len(site_configuration_history), 2) + + def test_no_entry_is_saved_for_errors(self): + """ + Test that and entry is not added to SiteConfigurationHistory if there is an error while + saving SiteConfiguration. + """ + # add SiteConfiguration to database + site_configuration = SiteConfigurationFactory.create( + site=self.site, + ) + + # Verify an entry to SiteConfigurationHistory was added. + site_configuration_history = SiteConfigurationHistory.objects.filter( + site=site_configuration.site, + ).all() + + # Make sure entry is saved if there is no error + self.assertEqual(len(site_configuration_history), 1) + + with transaction.atomic(): + with self.assertRaises(IntegrityError): + # try to add a duplicate entry + site_configuration = SiteConfigurationFactory.create( + site=self.site, + ) + site_configuration_history = SiteConfigurationHistory.objects.filter( + site=site_configuration.site, + ).all() + + # Make sure no entry is saved if there an error + self.assertEqual(len(site_configuration_history), 1) diff --git a/openedx/core/djangoapps/theming/models.py b/openedx/core/djangoapps/theming/models.py index d3e70189de..04e736c7f5 100644 --- a/openedx/core/djangoapps/theming/models.py +++ b/openedx/core/djangoapps/theming/models.py @@ -1,5 +1,5 @@ """ -Comprehensive Theme related models. +Django models supporting the Comprehensive Theming subsystem """ from django.db import models from django.contrib.sites.models import Site