diff --git a/HISTORY.rst b/HISTORY.rst index a0b80e5..7a4ac5b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -15,6 +15,7 @@ History * Removed meta-mixin compatibility code * Changed slug size to 255 chars * Fixed pagination setting in list views +* Added API to set default sites if user has permission only for a subset of sites 0.7.0 (2016-03-19) ++++++++++++++++++ diff --git a/README.rst b/README.rst index e7421d2..a5e57dc 100644 --- a/README.rst +++ b/README.rst @@ -340,6 +340,28 @@ To add the blog Sitemap, add the following code to the project ``urls.py``:: }), ) +Multisite ++++++++++ + +django CMS blog provides full support for multisite setups. + +Each blog post can be assigned to none, one or more sites: if no site is selected, then +it's visible on all sites. + +This is matched with and API that allows to restrict users to only be able to edit +blog posts only for some sites. + +To implement this API, you must add a ``get_sites`` method on the user model which +returns a queryset of sites the user is allowed to add posts to. + +Example:: + + class CustomUser(AbstractUser): + sites = models.ManyToManyField('sites.Site') + + def get_sites(self): + return self.sites + django CMS 3.2+ Wizard ++++++++++++++++++++++ diff --git a/cms_helper.py b/cms_helper.py index 3495133..564b22b 100755 --- a/cms_helper.py +++ b/cms_helper.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, unicode_literals +import os + from tempfile import mkdtemp @@ -20,7 +22,6 @@ HELPER_SETTINGS = dict( 'taggit', 'taggit_autosuggest', 'aldryn_apphooks_config', - 'tests.test_utils', 'aldryn_search', ], LANGUAGE_CODE='en', @@ -91,7 +92,7 @@ HELPER_SETTINGS = dict( SITE_ID=1, HAYSTACK_CONNECTIONS={ 'default': {} - } + }, ) try: @@ -124,9 +125,9 @@ try: 'ROUTING': 'knocker.routing.channel_routing', }, } - except ImportError: pass +os.environ['AUTH_USER_MODEL'] = 'tests.test_utils.CustomUser' def run(): diff --git a/djangocms_blog/admin.py b/djangocms_blog/admin.py index 10c2030..5057bdf 100755 --- a/djangocms_blog/admin.py +++ b/djangocms_blog/admin.py @@ -75,6 +75,7 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin, app_config_values = { 'default_published': 'publish' } + _sites = None def get_urls(self): """ @@ -90,6 +91,7 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin, def publish_post(self, request, pk): """ Admin view to publish a single post + :param request: request :param pk: primary key of the post to publish :return: Redirect to the post itself (if found) or fallback urls @@ -116,9 +118,47 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin, field.max_length = 70 return field + def has_restricted_sites(self, request): + """ + Whether the current user has permission on one site only + + :param request: current request + :return: boolean: user has permission on only one site + """ + sites = self.get_restricted_sites(request) + return sites and sites.count() == 1 + + def get_restricted_sites(self, request): + """ + The sites on which the user has permission on. + + To return the permissions, the method check for the ``get_sites`` + method on the user instance (e.g.: ``return request.user.get_sites()``) + which must return the queryset of enabled sites. + If the attribute does not exists, the user is considered enabled + for all the websites. + + :param request: current request + :return: boolean or a queryset of available sites + """ + if self._sites is None: + try: + self._sites = request.user.get_sites() + except AttributeError: # pragma: no cover + self._sites = False + return self._sites + + def _set_config_defaults(self, request, form, obj=None): + form = super(PostAdmin, self)._set_config_defaults(request, form, obj) + sites = self.get_restricted_sites(request) + if 'sites' in form.base_fields and sites.exists(): + form.base_fields['sites'].queryset = self.get_restricted_sites(request).all() + return form + def get_fieldsets(self, request, obj=None): """ Customize the fieldsets according to the app settings + :param request: request :param obj: post :return: fieldsets configuration @@ -142,7 +182,7 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin, fsets[0][1]['fields'].append('abstract') if not get_setting('USE_PLACEHOLDER'): fsets[0][1]['fields'].append('post_text') - if get_setting('MULTISITE'): + if get_setting('MULTISITE') and not self.has_restricted_sites(request): fsets[1][1]['fields'][0].append('sites') if request.user.is_superuser: fsets[1][1]['fields'][0].append('author') @@ -158,6 +198,19 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin, obj._set_default_author(request.user) super(PostAdmin, self).save_model(request, obj, form, change) + def get_queryset(self, request): + qs = super(PostAdmin, self).get_queryset(request) + sites = self.get_restricted_sites(request) + if sites.exists(): + qs = qs.filter(sites__in=sites.all()) + return qs + + def save_related(self, request, form, formsets, change): + super(PostAdmin, self).save_related(request, form, formsets, change) + obj = form.instance + sites = self.get_restricted_sites(request) + obj.sites = sites.all() + class Media: css = { 'all': ('%sdjangocms_blog/css/%s' % (settings.STATIC_URL, 'djangocms_blog_admin.css'),) diff --git a/tests/test_models.py b/tests/test_models.py index e6708b0..a30a274 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -164,12 +164,16 @@ class AdminTest(BaseTest): fsets = post_admin.get_fieldsets(request) self.assertFalse('sites' in fsets[1][1]['fields'][0]) - request = self.get_page_request('/', self.user, r'/en/blog/?app_config=%s' % self.app_config_1.pk, edit=False) + request = self.get_page_request( + '/', self.user, r'/en/blog/?app_config=%s' % self.app_config_1.pk, edit=False + ) fsets = post_admin.get_fieldsets(request) self.assertTrue('author' in fsets[1][1]['fields'][0]) with self.login_user_context(self.user): - request = self.get_request('/', 'en', user=self.user, path=r'/en/blog/?app_config=%s' % self.app_config_1.pk) + request = self.get_request( + '/', 'en', user=self.user, path=r'/en/blog/?app_config=%s' % self.app_config_1.pk + ) msg_mid = MessageMiddleware() msg_mid.process_request(request) post_admin = admin.site._registry[Post] @@ -177,6 +181,43 @@ class AdminTest(BaseTest): self.assertContains(response, '' % ( self.category_1.pk, self.category_1.safe_translation_getter('name', language_code='en') )) + self.assertContains(response, 'id="id_sites" name="sites"') + + self.user.sites.add(self.site_1) + with self.login_user_context(self.user): + request = self.get_request('/', 'en', user=self.user, + path=r'/en/blog/?app_config=%s' % self.app_config_1.pk) + msg_mid = MessageMiddleware() + msg_mid.process_request(request) + post_admin = admin.site._registry[Post] + post_admin._sites = None + response = post_admin.add_view(request) + response.render() + self.assertNotContains(response, 'id="id_sites" name="sites"') + post_admin._sites = None + self.user.sites.clear() + + def test_admin_queryset(self): + posts = self.get_posts() + posts[0].sites.add(self.site_1) + posts[1].sites.add(self.site_2) + + request = self.get_request('/', 'en', user=self.user, + path=r'/en/blog/?app_config=%s' % self.app_config_1.pk) + post_admin = admin.site._registry[Post] + post_admin._sites = None + qs = post_admin.get_queryset(request) + self.assertEqual(qs.count(), 4) + + self.user.sites.add(self.site_2) + request = self.get_request('/', 'en', user=self.user, + path=r'/en/blog/?app_config=%s' % self.app_config_1.pk) + post_admin = admin.site._registry[Post] + post_admin._sites = None + qs = post_admin.get_queryset(request) + self.assertEqual(qs.count(), 1) + self.assertEqual(qs[0], posts[1]) + self.user.sites.clear() def test_admin_auto_author(self): pages = self.get_pages() @@ -235,17 +276,26 @@ class AdminTest(BaseTest): post_admin = admin.site._registry[Post] request = self.get_page_request('/', self.user_normal, r'/en/blog/?app_config=%s' % self.app_config_1.pk) + post_admin._sites = None fsets = post_admin.get_fieldsets(request) self.assertFalse('author' in fsets[1][1]['fields'][0]) + self.assertTrue('sites' in fsets[1][1]['fields'][0]) + post_admin._sites = None def filter_function(fs, request, obj=None): if request.user == self.user_normal: fs[1][1]['fields'][0].append('author') return fs + self.user_normal.sites.add(self.site_1) + request = self.get_page_request('/', self.user_normal, r'/en/blog/?app_config=%s' % self.app_config_1.pk) + post_admin._sites = None with self.settings(BLOG_ADMIN_POST_FIELDSET_FILTER=filter_function): fsets = post_admin.get_fieldsets(request) self.assertTrue('author' in fsets[1][1]['fields'][0]) + self.assertFalse('sites' in fsets[1][1]['fields'][0]) + post_admin._sites = None + self.user_normal.sites.clear() def test_admin_post_text(self): pages = self.get_pages() diff --git a/tests/test_utils/admin.py b/tests/test_utils/admin.py new file mode 100644 index 0000000..3efd5fa --- /dev/null +++ b/tests/test_utils/admin.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import ugettext_lazy as _ + +from .models import CustomUser + + +class CustomUserAdmin(UserAdmin): + model = CustomUser + + + fieldsets = ( + (None, {'fields': ('username', 'password')}), + (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), + (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', + 'groups', 'user_permissions')}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + (_('Sites'), {'fields': ('sites',)}) + ) + +admin.site.register(CustomUser, CustomUserAdmin) diff --git a/tests/test_utils/migrations/0001_initial.py b/tests/test_utils/migrations/0001_initial.py new file mode 100644 index 0000000..be4824e --- /dev/null +++ b/tests/test_utils/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.core.validators +import django.utils.timezone +import django.contrib.auth.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0006_require_contenttypes_0002'), + ('sites', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')), + ('password', models.CharField(verbose_name='password', max_length=128)), + ('last_login', models.DateTimeField(verbose_name='last login', null=True, blank=True)), + ('is_superuser', models.BooleanField(verbose_name='superuser status', help_text='Designates that this user has all permissions without explicitly assigning them.', default=False)), + ('username', models.CharField(help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, verbose_name='username', validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')], max_length=30, error_messages={'unique': 'A user with that username already exists.'})), + ('first_name', models.CharField(verbose_name='first name', max_length=30, blank=True)), + ('last_name', models.CharField(verbose_name='last name', max_length=30, blank=True)), + ('email', models.EmailField(verbose_name='email address', max_length=254, blank=True)), + ('is_staff', models.BooleanField(verbose_name='staff status', help_text='Designates whether the user can log into this admin site.', default=False)), + ('is_active', models.BooleanField(verbose_name='active', help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', default=True)), + ('date_joined', models.DateTimeField(verbose_name='date joined', default=django.utils.timezone.now)), + ('groups', models.ManyToManyField(help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_query_name='user', related_name='user_set', verbose_name='groups', blank=True, to='auth.Group')), + ('sites', models.ManyToManyField(to='sites.Site')), + ('user_permissions', models.ManyToManyField(help_text='Specific permissions for this user.', related_query_name='user', related_name='user_set', verbose_name='user permissions', blank=True, to='auth.Permission')), + ], + options={ + 'verbose_name': 'user', + 'abstract': False, + 'verbose_name_plural': 'users', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/tests/test_utils/migrations/__init__.py b/tests/test_utils/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils/models.py b/tests/test_utils/models.py index 40a96af..dbc63c0 100644 --- a/tests/test_utils/models.py +++ b/tests/test_utils/models.py @@ -1 +1,10 @@ # -*- coding: utf-8 -*- +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class CustomUser(AbstractUser): + sites = models.ManyToManyField('sites.Site') + + def get_sites(self): + return self.sites diff --git a/tests/test_utils/south_migrations/0001_initial.py b/tests/test_utils/south_migrations/0001_initial.py new file mode 100644 index 0000000..7d5f61d --- /dev/null +++ b/tests/test_utils/south_migrations/0001_initial.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CustomUser' + db.create_table('test_utils_customuser', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('password', self.gf('django.db.models.fields.CharField')(max_length=128)), + ('last_login', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), + ('is_superuser', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('username', self.gf('django.db.models.fields.CharField')(unique=True, max_length=30)), + ('first_name', self.gf('django.db.models.fields.CharField')(blank=True, max_length=30)), + ('last_name', self.gf('django.db.models.fields.CharField')(blank=True, max_length=30)), + ('email', self.gf('django.db.models.fields.EmailField')(blank=True, max_length=75)), + ('is_staff', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('is_active', self.gf('django.db.models.fields.BooleanField')(default=True)), + ('date_joined', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), + )) + db.send_create_signal('test_utils', ['CustomUser']) + + # Adding M2M table for field groups on 'CustomUser' + m2m_table_name = db.shorten_name('test_utils_customuser_groups') + db.create_table(m2m_table_name, ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('customuser', models.ForeignKey(orm['test_utils.customuser'], null=False)), + ('group', models.ForeignKey(orm['auth.group'], null=False)) + )) + db.create_unique(m2m_table_name, ['customuser_id', 'group_id']) + + # Adding M2M table for field user_permissions on 'CustomUser' + m2m_table_name = db.shorten_name('test_utils_customuser_user_permissions') + db.create_table(m2m_table_name, ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('customuser', models.ForeignKey(orm['test_utils.customuser'], null=False)), + ('permission', models.ForeignKey(orm['auth.permission'], null=False)) + )) + db.create_unique(m2m_table_name, ['customuser_id', 'permission_id']) + + # Adding M2M table for field sites on 'CustomUser' + m2m_table_name = db.shorten_name('test_utils_customuser_sites') + db.create_table(m2m_table_name, ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('customuser', models.ForeignKey(orm['test_utils.customuser'], null=False)), + ('site', models.ForeignKey(orm['sites.site'], null=False)) + )) + db.create_unique(m2m_table_name, ['customuser_id', 'site_id']) + + + def backwards(self, orm): + # Deleting model 'CustomUser' + db.delete_table('test_utils_customuser') + + # Removing M2M table for field groups on 'CustomUser' + db.delete_table(db.shorten_name('test_utils_customuser_groups')) + + # Removing M2M table for field user_permissions on 'CustomUser' + db.delete_table(db.shorten_name('test_utils_customuser_user_permissions')) + + # Removing M2M table for field sites on 'CustomUser' + db.delete_table(db.shorten_name('test_utils_customuser_sites')) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'blank': 'True', 'to': "orm['auth.Permission']"}) + }, + 'auth.permission': { + 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission', 'ordering': "('content_type__app_label', 'content_type__model', 'codename')"}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'", 'ordering': "('name',)"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'sites.site': { + 'Meta': {'object_name': 'Site', 'db_table': "'django_site'", 'ordering': "('domain',)"}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'test_utils.customuser': { + 'Meta': {'object_name': 'CustomUser'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'max_length': '75'}), + 'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'blank': 'True', 'to': "orm['auth.Group']", 'related_name': "'user_set'"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'sites': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['sites.Site']"}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'blank': 'True', 'to': "orm['auth.Permission']", 'related_name': "'user_set'"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + } + } + + complete_apps = ['test_utils'] \ No newline at end of file diff --git a/tests/test_utils/south_migrations/__init__.py b/tests/test_utils/south_migrations/__init__.py new file mode 100644 index 0000000..87fe366 --- /dev/null +++ b/tests/test_utils/south_migrations/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from django.utils.translation import ugettext_lazy as _