From 9970a6afd617957c09b3b2ee748714bc394f94d7 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Mon, 20 Mar 2017 13:56:31 +0100 Subject: [PATCH] Customizing models, collect all settings in blog.settings, simple documentation notes --- README.md | 9 ++ blog/default_models.py | 267 ++++++++++++++++++++++++++++++++++++++ blog/models.py | 287 ++++------------------------------------- blog/settings.py | 53 ++++++++ 4 files changed, 356 insertions(+), 260 deletions(-) create mode 100644 blog/default_models.py create mode 100644 blog/settings.py diff --git a/README.md b/README.md index 5972304..38bcb8c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,15 @@ See http://docs.wagtail.io - `BLOG_LIMIT_AUTHOR_CHOICES_GROUP` Optionally set this to limit the author field choices based on this Django Group. Otherwise it defaults to check if user is_staff. Set to a tuple to allow multiple groups. - `BLOG_LIMIT_AUTHOR_CHOICES_ADMIN` Set to true if limiting authors to multiple groups and want to add is_staff users as well. +## Customizing models + +All these settings accept a tuple, of `('app.module.ModelName', 'app.ModelName')`. + +- `BLOG_MODEL_PAGE` - Used for blog posts. +- `BLOG_MODEL_INDEX` - The index page type used for lists of blog posts. +- `BLOG_MODEL_CATEGORY` - Category model +- `BLOG_MODEL_TAG` - Model for tags, (default: Proxy of `taggit.Tag`). + # Import from WordPress The import feature requires `django-contrib-comments` and `django-comments-xtd` diff --git a/blog/default_models.py b/blog/default_models.py new file mode 100644 index 0000000..eb942f4 --- /dev/null +++ b/blog/default_models.py @@ -0,0 +1,267 @@ +from django.conf import settings as django_settings +from django.core.exceptions import ValidationError +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.contrib.auth import get_user_model +from django.db import models +from django.db.models import Count, Q +from django.shortcuts import get_object_or_404 +from django.template.defaultfilters import slugify +from django.utils.translation import ugettext_lazy as _ +from wagtail.wagtailcore.fields import RichTextField +from wagtail.wagtailcore.models import Page +from wagtail.wagtailadmin.edit_handlers import ( + FieldPanel, InlinePanel, MultiFieldPanel, FieldRowPanel) +from wagtail.wagtailimages.edit_handlers import ImageChooserPanel +from wagtail.wagtailsnippets.models import register_snippet +from wagtail.wagtailsearch import index +from taggit.models import TaggedItemBase, Tag +from modelcluster.tags import ClusterTaggableManager +from modelcluster.fields import ParentalKey +import datetime + +from . import settings + + +def get_blog_context(context): + """ Get context data useful on all blog related pages """ + context['authors'] = get_user_model().objects.filter( + owned_pages__live=True, + owned_pages__content_type__model='blogpage' + ).annotate(Count('owned_pages')).order_by('-owned_pages__count') + context['all_categories'] = BlogCategory.objects.all() + context['root_categories'] = BlogCategory.objects.filter( + parent=None, + ).prefetch_related( + 'children', + ).annotate( + blog_count=Count('blogpage'), + ) + return context + + +class BlogIndexPage(Page): + @property + def blogs(self): + # Get list of blog pages that are descendants of this page + blogs = BlogPage.objects.descendant_of(self).live() + blogs = blogs.order_by( + '-date' + ).select_related('owner').prefetch_related( + 'tagged_items__tag', + 'categories', + 'categories__category', + ) + return blogs + + def get_context(self, request, tag=None, category=None, author=None, *args, + **kwargs): + context = super(BlogIndexPage, self).get_context( + request, *args, **kwargs) + blogs = self.blogs + + if tag is None: + tag = request.GET.get('tag') + if tag: + blogs = blogs.filter(tags__slug=tag) + if category is None: # Not coming from category_view in views.py + if request.GET.get('category'): + category = get_object_or_404( + BlogCategory, slug=request.GET.get('category')) + if category: + if not request.GET.get('category'): + category = get_object_or_404(BlogCategory, slug=category) + blogs = blogs.filter(categories__category__name=category) + if author: + if isinstance(author, str) and not author.isdigit(): + blogs = blogs.filter(author__username=author) + else: + blogs = blogs.filter(author_id=author) + + # Pagination + page = request.GET.get('page') + page_size = 10 + if settings.PAGINATION_PER_PAGE is not None: + page_size = settings.PAGINATION_PER_PAGE + + if page_size is not None: + paginator = Paginator(blogs, page_size) # Show 10 blogs per page + try: + blogs = paginator.page(page) + except PageNotAnInteger: + blogs = paginator.page(1) + except EmptyPage: + blogs = paginator.page(paginator.num_pages) + + context['blogs'] = blogs + context['category'] = category + context['tag'] = tag + context['author'] = author + context['COMMENTS_APP'] = settings.COMMENTS_APP + context = get_blog_context(context) + + return context + + class Meta: + verbose_name = _('Blog index') + subpage_types = ['blog.BlogPage'] + + +@register_snippet +class BlogCategory(models.Model): + name = models.CharField( + max_length=80, unique=True, verbose_name=_('Category Name')) + slug = models.SlugField(unique=True, max_length=80) + parent = models.ForeignKey( + 'self', blank=True, null=True, related_name="children", + help_text=_( + 'Categories, unlike tags, can have a hierarchy. You might have a ' + 'Jazz category, and under that have children categories for Bebop' + ' and Big Band. Totally optional.') + ) + description = models.CharField(max_length=500, blank=True) + + class Meta: + ordering = ['name'] + verbose_name = _("Blog Category") + verbose_name_plural = _("Blog Categories") + + panels = [ + FieldPanel('name'), + FieldPanel('parent'), + FieldPanel('description'), + ] + + def __str__(self): + return self.name + + def clean(self): + if self.parent: + parent = self.parent + if self.parent == self: + raise ValidationError('Parent category cannot be self.') + if parent.parent and parent.parent == self: + raise ValidationError('Cannot have circular Parents.') + + def save(self, *args, **kwargs): + if not self.slug: + slug = slugify(self.name) + count = BlogCategory.objects.filter(slug=slug).count() + if count > 0: + slug = '{}-{}'.format(slug, count) + self.slug = slug + return super(BlogCategory, self).save(*args, **kwargs) + + +class BlogCategoryBlogPage(models.Model): + category = models.ForeignKey( + settings.MODEL_CATEGORY[1], related_name="+", verbose_name=_('Category')) + page = ParentalKey(settings.MODEL_PAGE[1], related_name='categories') + panels = [ + FieldPanel('category'), + ] + + +class BlogPageTag(TaggedItemBase): + content_object = ParentalKey('BlogPage', related_name='tagged_items') + + +@register_snippet +class BlogTag(Tag): + class Meta: + proxy = True + + +def limit_author_choices(): + """ Limit choices in blog author field based on config settings """ + + if settings.LIMIT_AUTHOR_CHOICES: + if isinstance(settings.LIMIT_AUTHOR_CHOICES, str): + limit = Q(groups__name=settings.LIMIT_AUTHOR_CHOICES) + else: + limit = Q() + for s in settings.LIMIT_AUTHOR_CHOICES: + limit = limit | Q(groups__name=s) + if settings.LIMIT_AUTHOR_CHOICES_ADMIN: + limit = limit | Q(is_staff=True) + else: + limit = {'is_staff': True} + return limit + + +class BlogPage(Page): + body = RichTextField(verbose_name=_('body'), blank=True) + tags = ClusterTaggableManager(through=BlogPageTag, blank=True) + date = models.DateField( + _("Post date"), default=datetime.datetime.today, + help_text=_("This date may be displayed on the blog post. It is not " + "used to schedule posts to go live at a later date.") + ) + header_image = models.ForeignKey( + 'wagtailimages.Image', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='+', + verbose_name=_('Header image') + ) + author = models.ForeignKey( + django_settings.AUTH_USER_MODEL, + blank=True, null=True, + limit_choices_to=limit_author_choices, + verbose_name=_('Author'), + on_delete=models.SET_NULL, + related_name='author_pages', + ) + + search_fields = Page.search_fields + [ + index.SearchField('body'), + ] + blog_categories = models.ManyToManyField( + BlogCategory, through=BlogCategoryBlogPage, blank=True) + + settings_panels = [ + MultiFieldPanel([ + FieldRowPanel([ + FieldPanel('go_live_at'), + FieldPanel('expire_at'), + ], classname="label-above"), + ], 'Scheduled publishing', classname="publishing"), + FieldPanel('date'), + FieldPanel('author'), + ] + + def save_revision(self, *args, **kwargs): + if not self.author: + self.author = self.owner + return super(BlogPage, self).save_revision(*args, **kwargs) + + def get_absolute_url(self): + return self.url + + def get_blog_index(self): + # Find closest ancestor which is a blog index + return self.get_ancestors().type(BlogIndexPage).last() + + def get_context(self, request, *args, **kwargs): + context = super(BlogPage, self).get_context(request, *args, **kwargs) + context['blogs'] = self.get_blog_index().blogindexpage.blogs + context = get_blog_context(context) + context['COMMENTS_APP'] = settings.COMMENTS_APP + return context + + class Meta: + verbose_name = _('Blog page') + verbose_name_plural = _('Blog pages') + + parent_page_types = ['blog.BlogIndexPage'] + + +BlogPage.content_panels = [ + FieldPanel('title', classname="full title"), + MultiFieldPanel([ + FieldPanel('tags'), + InlinePanel('categories', label=_("Categories")), + ], heading="Tags and Categories"), + ImageChooserPanel('header_image'), + FieldPanel('body', classname="full"), +] diff --git a/blog/models.py b/blog/models.py index 9449a64..c436e36 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,268 +1,35 @@ -from django.core.exceptions import ValidationError -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.conf import settings -from django.contrib.auth import get_user_model -from django.db import models -from django.db.models import Count, Q -from django.shortcuts import get_object_or_404 -from django.template.defaultfilters import slugify -from django.utils.translation import ugettext_lazy as _ -from wagtail.wagtailcore.fields import RichTextField -from wagtail.wagtailcore.models import Page -from wagtail.wagtailadmin.edit_handlers import ( - FieldPanel, InlinePanel, MultiFieldPanel, FieldRowPanel) -from wagtail.wagtailimages.edit_handlers import ImageChooserPanel -from wagtail.wagtailsnippets.models import register_snippet -from wagtail.wagtailsearch import index -from taggit.models import TaggedItemBase, Tag -from modelcluster.tags import ClusterTaggableManager -from modelcluster.fields import ParentalKey -import datetime +""" +Customized models. +Credits for this pattern go to django-blog-zinnia and Fantomas42 +""" +from importlib import import_module -COMMENTS_APP = getattr(settings, 'COMMENTS_APP', None) +from django.core.exceptions import ImproperlyConfigured +from . import settings -def get_blog_context(context): - """ Get context data useful on all blog related pages """ - context['authors'] = get_user_model().objects.filter( - owned_pages__live=True, - owned_pages__content_type__model='blogpage' - ).annotate(Count('owned_pages')).order_by('-owned_pages__count') - context['all_categories'] = BlogCategory.objects.all() - context['root_categories'] = BlogCategory.objects.filter( - parent=None, - ).prefetch_related( - 'children', - ).annotate( - blog_count=Count('blogpage'), - ) - return context +def load_model_class(model_path): + """ + Load by import a class by a string path like: + 'module.models.MyModel'. + This mechanism allows extension and customization of + the Entry model class. + """ + dot = model_path.rindex('.') + module_name = model_path[:dot] + class_name = model_path[dot + 1:] + try: + _class = getattr(import_module(module_name), class_name) + return _class + except (ImportError, AttributeError): + raise ImproperlyConfigured('%s cannot be imported' % model_path) -class BlogIndexPage(Page): - @property - def blogs(self): - # Get list of blog pages that are descendants of this page - blogs = BlogPage.objects.descendant_of(self).live() - blogs = blogs.order_by( - '-date' - ).select_related('owner').prefetch_related( - 'tagged_items__tag', - 'categories', - 'categories__category', - ) - return blogs - def get_context(self, request, tag=None, category=None, author=None, *args, - **kwargs): - context = super(BlogIndexPage, self).get_context( - request, *args, **kwargs) - blogs = self.blogs +BlogPage = load_model_class(settings.MODEL_PAGE[0]) +BlogIndexPage = load_model_class(settings.MODEL_INDEX[0]) +BlogTag = load_model_class(settings.MODEL_TAG[0]) +BlogCategory = load_model_class(settings.MODEL_CATEGORY[0]) - if tag is None: - tag = request.GET.get('tag') - if tag: - blogs = blogs.filter(tags__slug=tag) - if category is None: # Not coming from category_view in views.py - if request.GET.get('category'): - category = get_object_or_404( - BlogCategory, slug=request.GET.get('category')) - if category: - if not request.GET.get('category'): - category = get_object_or_404(BlogCategory, slug=category) - blogs = blogs.filter(categories__category__name=category) - if author: - if isinstance(author, str) and not author.isdigit(): - blogs = blogs.filter(author__username=author) - else: - blogs = blogs.filter(author_id=author) - - # Pagination - page = request.GET.get('page') - page_size = 10 - if hasattr(settings, 'BLOG_PAGINATION_PER_PAGE'): - page_size = settings.BLOG_PAGINATION_PER_PAGE - - if page_size is not None: - paginator = Paginator(blogs, page_size) # Show 10 blogs per page - try: - blogs = paginator.page(page) - except PageNotAnInteger: - blogs = paginator.page(1) - except EmptyPage: - blogs = paginator.page(paginator.num_pages) - - context['blogs'] = blogs - context['category'] = category - context['tag'] = tag - context['author'] = author - context['COMMENTS_APP'] = COMMENTS_APP - context = get_blog_context(context) - - return context - - class Meta: - verbose_name = _('Blog index') - subpage_types = ['blog.BlogPage'] - - -@register_snippet -class BlogCategory(models.Model): - name = models.CharField( - max_length=80, unique=True, verbose_name=_('Category Name')) - slug = models.SlugField(unique=True, max_length=80) - parent = models.ForeignKey( - 'self', blank=True, null=True, related_name="children", - help_text=_( - 'Categories, unlike tags, can have a hierarchy. You might have a ' - 'Jazz category, and under that have children categories for Bebop' - ' and Big Band. Totally optional.') - ) - description = models.CharField(max_length=500, blank=True) - - class Meta: - ordering = ['name'] - verbose_name = _("Blog Category") - verbose_name_plural = _("Blog Categories") - - panels = [ - FieldPanel('name'), - FieldPanel('parent'), - FieldPanel('description'), - ] - - def __str__(self): - return self.name - - def clean(self): - if self.parent: - parent = self.parent - if self.parent == self: - raise ValidationError('Parent category cannot be self.') - if parent.parent and parent.parent == self: - raise ValidationError('Cannot have circular Parents.') - - def save(self, *args, **kwargs): - if not self.slug: - slug = slugify(self.name) - count = BlogCategory.objects.filter(slug=slug).count() - if count > 0: - slug = '{}-{}'.format(slug, count) - self.slug = slug - return super(BlogCategory, self).save(*args, **kwargs) - - -class BlogCategoryBlogPage(models.Model): - category = models.ForeignKey( - BlogCategory, related_name="+", verbose_name=_('Category')) - page = ParentalKey('BlogPage', related_name='categories') - panels = [ - FieldPanel('category'), - ] - - -class BlogPageTag(TaggedItemBase): - content_object = ParentalKey('BlogPage', related_name='tagged_items') - - -@register_snippet -class BlogTag(Tag): - class Meta: - proxy = True - - -def limit_author_choices(): - """ Limit choices in blog author field based on config settings """ - LIMIT_AUTHOR_CHOICES = getattr(settings, 'BLOG_LIMIT_AUTHOR_CHOICES_GROUP', None) - if LIMIT_AUTHOR_CHOICES: - if isinstance(LIMIT_AUTHOR_CHOICES, str): - limit = Q(groups__name=LIMIT_AUTHOR_CHOICES) - else: - limit = Q() - for s in LIMIT_AUTHOR_CHOICES: - limit = limit | Q(groups__name=s) - if getattr(settings, 'BLOG_LIMIT_AUTHOR_CHOICES_ADMIN', False): - limit = limit | Q(is_staff=True) - else: - limit = {'is_staff': True} - return limit - - -class BlogPage(Page): - body = RichTextField(verbose_name=_('body'), blank=True) - tags = ClusterTaggableManager(through=BlogPageTag, blank=True) - date = models.DateField( - _("Post date"), default=datetime.datetime.today, - help_text=_("This date may be displayed on the blog post. It is not " - "used to schedule posts to go live at a later date.") - ) - header_image = models.ForeignKey( - 'wagtailimages.Image', - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='+', - verbose_name=_('Header image') - ) - author = models.ForeignKey( - settings.AUTH_USER_MODEL, - blank=True, null=True, - limit_choices_to=limit_author_choices, - verbose_name=_('Author'), - on_delete=models.SET_NULL, - related_name='author_pages', - ) - - search_fields = Page.search_fields + [ - index.SearchField('body'), - ] - blog_categories = models.ManyToManyField( - BlogCategory, through=BlogCategoryBlogPage, blank=True) - - settings_panels = [ - MultiFieldPanel([ - FieldRowPanel([ - FieldPanel('go_live_at'), - FieldPanel('expire_at'), - ], classname="label-above"), - ], 'Scheduled publishing', classname="publishing"), - FieldPanel('date'), - FieldPanel('author'), - ] - - def save_revision(self, *args, **kwargs): - if not self.author: - self.author = self.owner - return super(BlogPage, self).save_revision(*args, **kwargs) - - def get_absolute_url(self): - return self.url - - def get_blog_index(self): - # Find closest ancestor which is a blog index - return self.get_ancestors().type(BlogIndexPage).last() - - def get_context(self, request, *args, **kwargs): - context = super(BlogPage, self).get_context(request, *args, **kwargs) - context['blogs'] = self.get_blog_index().blogindexpage.blogs - context = get_blog_context(context) - context['COMMENTS_APP'] = COMMENTS_APP - return context - - class Meta: - verbose_name = _('Blog page') - verbose_name_plural = _('Blog pages') - - parent_page_types = ['blog.BlogIndexPage'] - - -BlogPage.content_panels = [ - FieldPanel('title', classname="full title"), - MultiFieldPanel([ - FieldPanel('tags'), - InlinePanel('categories', label=_("Categories")), - ], heading="Tags and Categories"), - ImageChooserPanel('header_image'), - FieldPanel('body', classname="full"), -] +from .default_models import BlogCategoryBlogPage, BlogPageTag # NOQA @UnusedImport diff --git a/blog/settings.py b/blog/settings.py new file mode 100644 index 0000000..543df1f --- /dev/null +++ b/blog/settings.py @@ -0,0 +1,53 @@ +from django.conf import settings + +#: App to use for comments. +COMMENTS_APP = getattr(settings, 'COMMENTS_APP', None) + +#: Set to change the number of blogs per page. Set to None to disable (useful if using your own pagination implementation). +PAGINATION_PER_PAGE = getattr(settings, 'BLOG_PAGINATION_PER_PAGE', None) + +#: Optionally set this to limit the author field choices based on this Django Group. Otherwise it defaults to check if user is_staff. Set to a tuple to allow multiple groups. +LIMIT_AUTHOR_CHOICES = getattr(settings, 'BLOG_LIMIT_AUTHOR_CHOICES_GROUP', None) + +#: Set to true if limiting authors to multiple groups and want to add is_staff users as well. +LIMIT_AUTHOR_CHOICES_ADMIN = getattr(settings, 'BLOG_LIMIT_AUTHOR_CHOICES_ADMIN', False) + +#: A tuple of ('app.module.ModelName', 'app.ModelName') +MODEL_PAGE = getattr( + settings, + 'BLOG_MODEL_PAGE', + ( + 'blog.default_models.BlogPage', + 'blog.BlogPage', + ) +) + +#: A tuple of ('app.module.ModelName', 'app.ModelName') +MODEL_INDEX = getattr( + settings, + 'BLOG_MODEL_INDEX', + ( + 'blog.default_models.BlogIndexPage', + 'blog.BlogIndexPage', + ) +) + +#: A tuple of ('app.module.ModelName', 'app.ModelName') +MODEL_CATEGORY = getattr( + settings, + 'BLOG_MODEL_CATEGORY', + ( + 'blog.default_models.BlogCategory', + 'blog.BlogCategory' + ) +) + +#: A tuple of ('app.module.ModelName', 'app.ModelName') +MODEL_TAG = getattr( + settings, + 'BLOG_MODEL_TAG', + ( + 'blog.default_models.BlogTag', + 'blog.BlogTag' + ) +)