В командной строке создаем приложение с названием questions:
python manage.py startapp questions
Модели
Переходим в директорию приложения и наполняем файл models.py:
import bleach
from django.db import models
from django.urls import reverse
from django.conf import settings
from core.utils import slugify, strip_tags
from accounts.models import User
class PublicQuestionsManager(models.Manager):
def get_queryset(self):
return super(PublicQuestionsManager, self).get_queryset().filter(removed__isnull=True)
class Question(models.Model):
title = models.CharField('заголовок', max_length=255)
slug = models.SlugField('слаг', max_length=255, blank=True, editable=False)
summary = models.CharField('резюме', max_length=255, blank=True, editable=False)
answered = models.DateTimeField('отвечен', blank=True, null=True, db_index=True)
removed = models.DateTimeField('убран', blank=True, null=True, db_index=True, editable=False)
created = models.DateTimeField('создан', auto_now_add=True)
updated = models.DateTimeField('обновлен', auto_now_add=True)
objects = models.Manager()
public = PublicQuestionsManager()
class Meta:
verbose_name = 'вопрос'
verbose_name_plural = 'вопросы'
ordering = ['-pk']
def __str__(self):
return self.title
def save(self, *args, **kwargs):
self.slug = slugify(self.title, 255)
super(Question, self).save(*args, **kwargs)
def get_absolute_url(self):
return reverse('questions:question', args=[self.slug, self.pk])
def articles(self):
return Article.public.filter(question=self).select_related()
class PublicArticlesManager(models.Manager):
def get_queryset(self):
return super(PublicArticlesManager, self).get_queryset().filter(removed__isnull=True)
class Article(models.Model):
question = models.ForeignKey(Question, verbose_name='вопрос')
text = models.TextField('текст')
summary = models.CharField('резюме', max_length=255, blank=True, editable=False)
is_question = models.BooleanField('вопрос', default=False, editable=False)
is_answer = models.BooleanField('ответ', default=False)
author = models.ForeignKey(User, verbose_name='автор')
author_ip = models.GenericIPAddressField('ip автора')
removed = models.DateTimeField('убрана', blank=True, null=True, db_index=True)
created = models.DateTimeField('создана', auto_now_add=True)
updated = models.DateTimeField('обновлена', auto_now=True)
objects = models.Manager()
public = PublicArticlesManager()
class Meta:
verbose_name = 'статья'
verbose_name_plural = 'статьи'
ordering = ['pk']
def __str__(self):
return self.summary
def save(self, *args, **kwargs):
if not self.author.is_superuser:
# очистка от всех тегов кроме списка разрешенных, автор, обычный пользователь
self.text = bleach.clean(self.text, tags=settings.QUESTIONS_ALLOWED_TAGS)
# очищаем стрипом текст от всех тегов для резюме, 100 символов
self.summary = strip_tags(self.text, 100)
if not self.is_question and self.author.is_superuser:
self.is_answer = True
# сохраняем статью
super(Article, self).save(*args, **kwargs)
# обновляем вопрос
if self.is_question:
self.question.summary = self.summary
self.question.removed = self.removed
if self.is_answer:
self.question.answered = self.updated
self.question.updated = self.updated
# сохраняем вопрос
self.question.save()
def delete(self, *args, **kwargs):
if self.is_question:
self.question.delete()
super(Article, self).delete(*args, **kwargs)
def get_absolute_url(self):
return '{}#article-{}'.format(self.question.get_absolute_url(), self.pk)
def get_admin_url(self):
return reverse('admin:{}_{}_change'.format(self._meta.app_label, self._meta.model_name), args=[self.pk])
Модель Question не содержит текст вопроса и по сути является темой или контейнером для содержимого вопроса и возможных ответов к нему.
Предназначение полей данной модели мы раскроем позже, когда будем описывать метод сохранения модели статей.
Сортировка осуществляется по идентификатору, последние вопросы — сверху.
Создание слага происходит аналогично, как и при создании приложения Статьи. По-прежнему мы обрабатываем старые ссылки если слаг по каким-либо причинам был изменен. Например, очень часто пользователи при создании вопроса пренебрегают правильным составлением темы вопроса.
Метод articles создан для удобства пользования, поскольку тема вопроса, содержимое и возможные ответы к нему является одним целым.
В методе используется select_related позволяющий нам за один запрос SQL связать статьи с темой вопроса. Например, для формирования ссылок на отдельные ответы без использования дополнительных запросов к базе данных.
Модель Article имеет внешнею связь на модель Question и поля описывающие характеристики и содержимое статьи.
Резюме мы будем использовать в фиде последних вопросов для удобства использования.
Поля is_question и is_answer являются флагами которые однозначно определяют является ли сообщение вопросом или ответом на него.
Для указания авторства статьи мы используем внешнею связь на ранее разработанную модель User.
Поле removed служит одновременно флагом и меткой времени для скрытия сообщения без его физического удаления, если по каким-либо причинам мы посчитаем его некорректным.
Сортировку модели осуществляем по идентификационному номеру, это гарантирует последовательность сообщений в потоке.
И переходим к самому интересному — методу сохранения модели.
Для начала мы проверяем права пользователя, если автор обычный посетитель, то мы проводим очистку сообщения от всех не разрешенных тегов. Список одобренных HTML-тегов мы укажем позже в разделе настроек.
Далее создаем резюме сообщения, функции slugify и strip_tags мы описывали в приложении Статьи.
Затем мы проверяем если сообщение не является вопросом, и автор является супер-пользователем, значит признаем сообщение ответом на поставленный вопрос.
После вызываем стандартный метод сохранения модели.
Вслед за этим над необходимо актуализировать наши денормализованные данные в модели вопросов.
Если сообщение является вопросом, обновляем поле резюме и статус удаления. А если статья является ответом — устанавливает метку времени в поле answered, это позволит нам обращаться непосредственно к характеристикам темы, не создавая при этом дополнительные запросы к модели статей.
Обновляем вручную поле updated после любого изменения или добавления сообщения. Например, позже мы с легкостью сможем определить появились ли новый комментарии после того как автор ресурса дал ответ поэтому вопросу.
После этого мы обязательно должны сохранить наши изменения в модели вопроса.
Метод delete тоже необходимо переопределить. Все дело в том, что если мы, например, удалим сообщение которые являлось вопросом, то соответственно нам необходимо удалить и саму тему со всеми связанными с ней ответами.
Менеджеры
Суть обоих менеджеров очень проста — отфильтровать удаленные вопросы или ответы из общего потока.
Настройки
Для внесения корректив открываем файл settings.py и добавляем приложение questions:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sitemaps',
'ckeditor',
'ckeditor_uploader',
'core.apps.CoreConfig',
'accounts.apps.AccountsConfig',
'questions.apps.QuestionsConfig',
'articles.apps.ArticlesConfig',
]
Затем добавляем список одобренных тегов для пользователя при составлении сообщений.
QUESTIONS_ALLOWED_TAGS = ['p', 'strong', 'em', 'blockquote'] # список разрешенных тегов
Администрирование
Переходим к файлу admin.py и приводим к следующему виду:
from django.contrib import admin
from django.db import models
from ckeditor_uploader.widgets import CKEditorUploadingWidget
from .models import Question, Article
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
list_display = ['title', 'slug', 'answered', 'removed']
list_filter = ['answered', 'removed']
search_fields = ['title', 'slug']
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ['summary', 'removed']
list_filter = ['removed']
search_fields = ['author__username', 'author_ip']
formfield_overrides = {models.TextField: {'widget': CKEditorUploadingWidget}}
Указываем читабельное название приложения редактируя файл apps.py:
from django.apps import AppConfig
class QuestionsConfig(AppConfig):
name = 'questions'
verbose_name = 'Вопросы'
Миграция
Переходим в командную строку и выполняем команды для создания и применения миграции приложения:
python manage.py makemigrations
python manage.py migrate