12 | {% blocktrans %}A confirmation message has been sent to your
13 | email address. Please, click on the link in the message to confirm
14 | your comment.{% endblocktrans %}
15 |
If you do not wish to post the comment, please ignore this message or report an incident to {{ contact }}. Otherwise click on the link below to confirm the comment.
If you do not wish to post the comment, please ignore this message or report an incident to {{ contact }}. Otherwise click on the link below to confirm the comment.
14 | {% blocktrans %}
15 | Your comment is in moderation.
16 | It has to be reviewed before being published.
17 | Thank you for your patience and understanding.
18 | {% endblocktrans %}
19 |
20 |
21 | {% with content_object_url=comment.content_object.get_absolute_url content_object_str=comment.content_object %}
22 | {% blocktrans %}
23 | Go back to: {{ content_object_str }}
24 | {% endblocktrans %}
25 | {% endwith %}
26 |
27 |
28 |
29 | {% endblock %}
30 |
--------------------------------------------------------------------------------
/django_comments_tree/static/django_comments_tree/js/src/lib.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery';
2 |
3 | export function getCookie(name) {
4 | var cookieValue = null;
5 | if (document.cookie && document.cookie !== '') {
6 | var cookies = document.cookie.split(';');
7 | for (var i = 0; i < cookies.length; i++) {
8 | var cookie = jQuery.trim(cookies[i]);
9 | // Does this cookie string begin with the name we want?
10 | if (cookie.substring(0, name.length + 1) === (name + '=')) {
11 | cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
12 | break;
13 | }
14 | }
15 | }
16 | return cookieValue;
17 | }
18 |
19 | export function csrfSafeMethod(method) {
20 | // these HTTP methods do not require CSRF protection
21 | return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
22 | }
23 |
24 | export function jquery_ajax_setup(csrf_cookie_name) {
25 | $.ajaxSetup({
26 | beforeSend: function(xhr, settings) {
27 | if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
28 | xhr.setRequestHeader("X-CSRFToken", getCookie(csrf_cookie_name));
29 | }
30 | }
31 | });
32 | }
33 |
--------------------------------------------------------------------------------
/django_comments_tree/feeds.py:
--------------------------------------------------------------------------------
1 | from django.contrib.sites.shortcuts import get_current_site
2 | from django.contrib.syndication.views import Feed
3 | from django.utils.translation import ugettext as _
4 |
5 | import django_comments_tree
6 |
7 |
8 | class LatestCommentFeed(Feed):
9 | """Feed of latest comments on the current site."""
10 |
11 | def __call__(self, request, *args, **kwargs):
12 | self.site = get_current_site(request)
13 | return super(LatestCommentFeed, self).__call__(request, *args, **kwargs)
14 |
15 | def title(self):
16 | return _("%(site_name)s comments") % dict(site_name=self.site.name)
17 |
18 | def link(self):
19 | return "https://%s/" % (self.site.domain)
20 |
21 | def description(self):
22 | return _("Latest comments on %(site_name)s") % dict(site_name=self.site.name)
23 |
24 | def items(self):
25 | qs = django_comments_tree.get_model().objects.filter(
26 | site__pk=self.site.pk,
27 | is_public=True,
28 | is_removed=False,
29 | )
30 | return qs.order_by('-submit_date')[:40]
31 |
32 | def item_pubdate(self, item):
33 | return item.submit_date
34 |
--------------------------------------------------------------------------------
/example/fixtures/quotes.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "model": "quotes.quote",
4 | "pk": 1,
5 | "fields": {
6 | "title": "Unusual farewell",
7 | "slug": "unusual-farewell",
8 | "quote": "I don\u2019t know half of you half as well as I should like; and I like less than half of you half as well as you deserve.",
9 | "author": "Bilbo Baggins",
10 | "url_source": "",
11 | "allow_comments": true,
12 | "publish": "2017-04-04T12:59:59Z"
13 | }
14 | },
15 | {
16 | "model": "quotes.quote",
17 | "pk": 2,
18 | "fields": {
19 | "title": "On education",
20 | "slug": "education",
21 | "quote": "The child is capable of developing and giving us tangible proof of the possibility of a better humanity. He has shown us the true process of construction of the human being. We have seen children totally change as they acquire a love for things and as their sense of order, discipline, and self-control develops within them.... The child is both a hope and a promise for mankind.",
22 | "author": "Maria Montessori",
23 | "url_source": "",
24 | "allow_comments": true,
25 | "publish": "2017-04-04T13:00:00Z"
26 | }
27 | }
28 | ]
29 |
--------------------------------------------------------------------------------
/example/simple/templates/base.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 |
3 |
4 |
5 |
6 | {% block title %}django-comments-tree simple demo{% endblock %}
7 |
8 |
18 |
19 |
20 |
16 | {% include "includes/django_comments_tree/comment_content.html" with content=comment.comment %}
17 |
18 | {% endif %}
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2011, Daniel Rus Morales
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24 |
--------------------------------------------------------------------------------
/example/comp/articles/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import django
4 | from django.db import models
5 | from django.urls import reverse
6 | from django.utils import timezone
7 |
8 |
9 | class PublicManager(models.Manager):
10 | """Returns published articles that are not in the future."""
11 |
12 | def published(self):
13 | return self.get_queryset().filter(publish__lte=timezone.now())
14 |
15 |
16 | class Article(models.Model):
17 | """Article, that accepts comments."""
18 |
19 | title = models.CharField('title', max_length=200)
20 | slug = models.SlugField('slug', unique_for_date='publish')
21 | body = models.TextField('body')
22 | allow_comments = models.BooleanField('allow comments', default=True)
23 | publish = models.DateTimeField('publish', default=timezone.now)
24 |
25 | objects = PublicManager()
26 |
27 | class Meta:
28 | db_table = 'comp_articles'
29 | ordering = ('-publish',)
30 |
31 | def __str__(self):
32 | return self.title
33 |
34 | def get_absolute_url(self):
35 | return reverse(
36 | 'articles-article-detail',
37 | kwargs={'year': self.publish.year,
38 | 'month': int(self.publish.strftime('%m').lower()),
39 | 'day': self.publish.day,
40 | 'slug': self.slug})
41 |
--------------------------------------------------------------------------------
/example/custom/templates/comments/preview.html:
--------------------------------------------------------------------------------
1 | {% extends "django_comments_tree/base.html" %}
2 | {% load i18n %}
3 | {% load comments_tree %}
4 |
5 | {% block content %}
6 |
{% trans "Preview your comment:" %}
7 |
8 |
9 |
10 | {% if not comment %}
11 | {% trans "Empty comment." %}
12 | {% else %}
13 |
39 | {% endblock %}
40 |
--------------------------------------------------------------------------------
/example/custom/README.md:
--------------------------------------------------------------------------------
1 | ## Custom example project ##
2 |
3 | The Custom Demo exhibits how to extend django-comments-tree. This demo used the same **articles** app present in the other two demos, plus:
4 |
5 | * A new django application, called `mycomments`, with a model `MyComment` that extends the `django_comments_tree.models.MyComment` model with a field `title`.
6 |
7 | To extend django-comments-tree follow the next steps:
8 |
9 | 1. Set up `COMMENTS_APP` to `django_comments_tree`
10 | 1. Set up `COMMENTS_TREE_MODEL` to the new model class name, for this demo: `mycomments.models.MyComment`
11 | 1. Set up `COMMENTS_TREE_FORM_CLASS` to the new form class name, for this demo: `mycomments.forms.MyCommentForm`
12 | 1. Change the following templates:
13 | * `comments/form.html` to include new fields.
14 | * `comments/preview.html` to preview new fields.
15 | * `django_comments_tree/email_confirmation_request.{txt|html}` to add the new fields to the confirmation request, if it was necessary. This demo overrides them to include the `title` field in the mail.
16 | * `django_comments_tree/comments_tree.html` to show the new field when displaying the comments. If your project doesn't allow nested comments you can use either this template or `comments/list.html`.
17 | * `django_comments_tree/reply.html` to show the new field when displaying the comment the user is replying to.
18 |
--------------------------------------------------------------------------------
/django_comments_tree/migrations/0008_auto_20191027_2147.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.2.6 on 2019-10-28 03:47
2 |
3 | from django.db import migrations
4 |
5 |
6 | def update_association(apps, schema_editor):
7 | """ Update the assoc value on all TreeComments """
8 |
9 | CommentAssociation = apps.get_model('django_comments_tree', 'CommentAssociation')
10 | TreeComment = apps.get_model('django_comments_tree', 'TreeComment')
11 | for root in TreeComment.objects.filter(depth=1).all():
12 | assoc = CommentAssociation.objects.get(root=root)
13 | TreeComment.objects.filter(
14 | path__startswith=root.path,
15 | depth__gte=root.depth).update(assoc=assoc)
16 |
17 |
18 | def reverse_associations(apps, schema_editor):
19 | """ Set associations to null """
20 | TreeComment = apps.get_model('django_comments_tree', 'TreeComment')
21 | for root in TreeComment.objects.filter(depth=1).all():
22 | TreeComment.objects.filter(
23 | path__startswith=root.path,
24 | depth__gte=root.depth).update(assoc=None)
25 |
26 |
27 | class Migration(migrations.Migration):
28 | dependencies = [
29 | ('django_comments_tree', '0007_auto_20191023_1440'),
30 | ]
31 |
32 | operations = [
33 | migrations.RunPython(
34 | update_association,
35 | reverse_associations
36 | ),
37 | ]
38 |
--------------------------------------------------------------------------------
/django_comments_tree/tests/templates/base.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 |
3 |
4 |
5 |
6 | {% block title %}django-comments-tree tutorial{% endblock %}
7 |
8 |
12 |
19 |
20 |
21 |
38 | {% endblock %}
39 |
--------------------------------------------------------------------------------
/django_comments_tree/signals.py:
--------------------------------------------------------------------------------
1 | """
2 | Signals relating to django-comments-tree.
3 | """
4 | from django.dispatch import Signal
5 |
6 | # Sent just after a comment has been verified.
7 | confirmation_received = Signal(providing_args=["comment", "request"])
8 |
9 | # Sent just after a user has muted a comments thread.
10 | comment_thread_muted = Signal(providing_args=["comment", "requests"])
11 |
12 | # Sent just before a comment will be posted (after it's been approved and
13 | # moderated; this can be used to modify the comment (in place) with posting
14 | # details or other such actions. If any receiver returns False the comment will be
15 | # discarded and a 400 response. This signal is sent at more or less
16 | # the same time (just before, actually) as the Comment object's pre-save signal,
17 | # except that the HTTP request is sent along with this signal.
18 | comment_will_be_posted = Signal(providing_args=["comment", "request"])
19 |
20 | # Sent just after a comment was posted. See above for how this differs
21 | # from the Comment object's post-save signal.
22 | comment_was_posted = Signal(providing_args=["comment", "request"])
23 |
24 | # Sent after a comment was "flagged" in some way. Check the flag to see if this
25 | # was a user requesting removal of a comment, a moderator approving/removing a
26 | # comment, or some other custom user flag.
27 | comment_was_flagged = Signal(providing_args=["comment", "flag", "created", "request"])
28 |
29 | # Sent after a comment is `Liked` or `Disliked`
30 | comment_feedback_toggled = Signal(
31 | providing_args=["flag", "comment", "created", "request"]
32 | )
33 |
--------------------------------------------------------------------------------
/example/comp/extra/quotes/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import django
4 | from django.db import models
5 | from django.urls import reverse
6 | from django.utils import timezone
7 |
8 |
9 | from django_comments_tree.moderation import moderator, SpamModerator
10 |
11 |
12 | class PublicManager(models.Manager):
13 | """Returns published quotes that are not in the future."""
14 |
15 | def published(self):
16 | return self.get_queryset().filter(publish__lte=timezone.now())
17 |
18 |
19 | class Quote(models.Model):
20 | """Quote, that accepts comments."""
21 |
22 | title = models.CharField('title', max_length=200)
23 | slug = models.SlugField('slug', unique_for_date='publish')
24 | quote = models.TextField('quote')
25 | author = models.CharField('author', max_length=255)
26 | url_source = models.URLField('url source', blank=True, null=True)
27 | allow_comments = models.BooleanField('allow comments', default=True)
28 | publish = models.DateTimeField('publish', default=timezone.now)
29 |
30 | objects = PublicManager()
31 |
32 | class Meta:
33 | db_table = 'comp_quotes'
34 | ordering = ('-publish',)
35 |
36 | def __str__(self):
37 | return self.title
38 |
39 | def get_absolute_url(self):
40 | return reverse('quotes-quote-detail', kwargs={'slug': self.slug})
41 |
42 |
43 | class QuoteCommentModerator(SpamModerator):
44 | email_notification = True
45 | auto_moderate_field = 'publish'
46 | moderate_after = 365
47 |
48 |
49 | moderator.register(Quote, QuoteCommentModerator)
50 |
--------------------------------------------------------------------------------
/example/custom/templates/django_comments_xtd/reply.html:
--------------------------------------------------------------------------------
1 | {% extends "django_comments_tree/base.html" %}
2 | {% load i18n %}
3 | {% load comments_tree %}
4 |
5 | {% block title %}{% trans "Comment reply" %}{% endblock %}
6 |
7 | {% block header %}
8 | {{ comment.content_object }}
9 | {% endblock %}
10 |
11 | {% block content %}
12 |
48 | {% endblock %}
49 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | ## Example Directory ##
2 |
3 | Contains three example projects:
4 |
5 | 1. Simple
6 | 2. Custom
7 | 3. Comp
8 |
9 | ### Simple ###
10 |
11 | The Simple demo site is a project with an 'articles' application and an 'Article' model whose instances accept comments. It features:
12 |
13 | * Comments have to be confirmed by mail before they hit the database, unless users are authenticated or `COMMENTS_TREE_CONFIRM_EMAIL` is set to False.
14 | * Commenters may request follow up notifications.
15 | * Mute links to allow cancellation of follow-up notifications.
16 |
17 |
18 | ### Custom ###
19 |
20 | The Custom demo exhibits how to extend django-comments-tree. It uses the same **articles** app present in the other demos, plus:
21 |
22 | * A new application, called `mycomments`, with a model `MyComment` that extends the `django_comments_tree.models.MyComment` model with a field `title`.
23 | * Checkout the [custom](https://github.com/sharpertool/django-comments-tree/example/custom/) demo directory and [Customizing django-comments-tree](http://django-comments-tree.readthedocs.io/en/latest/extending.html) in the documentation.
24 |
25 |
26 | ### Comp ###
27 |
28 | The Comp demo implements two apps, each of which contains a model whose instances can received comments:
29 |
30 | 1. App `articles` with the model `Article`
31 | 1. App `quotes` with the model `Quote`
32 |
33 | It features:
34 |
35 | 1. Comments can be nested, and the maximum thread level is established to 2.
36 | 1. Comment confirmation via mail when the users are not authenticated.
37 | 1. Comments hit the database only after they have been confirmed.
38 | 1. Follow up notifications via mail.
39 | 1. Mute links to allow cancellation of follow-up notifications.
40 | 1. Registered users can like/dislike comments and can suggest comments removal.
41 | 1. Registered users can see the list of users that liked/disliked comments.
42 | 1. The homepage presents the last 5 comments posted either to the `articles.Article` or the `quotes.Quote` model.
43 |
--------------------------------------------------------------------------------
/django_comments_tree/tests/models.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from django.db import models
4 | from django.urls import reverse
5 |
6 | from django_comments_tree.moderation import moderator, TreeCommentModerator
7 |
8 |
9 | class PublicManager(models.Manager):
10 | """Returns published articles that are not in the future."""
11 |
12 | def published(self):
13 | return self.get_query_set().filter(publish__lte=datetime.now())
14 |
15 |
16 | class Article(models.Model):
17 | """Article, that accepts comments."""
18 |
19 | title = models.CharField('title', max_length=200)
20 | slug = models.SlugField('slug', unique_for_date='publish')
21 | body = models.TextField('body')
22 | allow_comments = models.BooleanField('allow comments', default=True)
23 | publish = models.DateTimeField('publish', default=datetime.now)
24 |
25 | objects = PublicManager()
26 |
27 | class Meta:
28 | db_table = 'demo_articles'
29 | ordering = ('-publish',)
30 |
31 | def get_absolute_url(self):
32 | return reverse(
33 | 'article-detail',
34 | kwargs={'year': self.publish.year,
35 | 'month': int(self.publish.strftime('%m').lower()),
36 | 'day': self.publish.day,
37 | 'slug': self.slug})
38 |
39 |
40 | class Diary(models.Model):
41 | """Diary, that accepts comments."""
42 | body = models.TextField('body')
43 | allow_comments = models.BooleanField('allow comments', default=True)
44 | publish = models.DateTimeField('publish', default=datetime.now)
45 |
46 | objects = PublicManager()
47 |
48 | class Meta:
49 | db_table = 'demo_diary'
50 | ordering = ('-publish',)
51 |
52 |
53 | class DiaryCommentModerator(TreeCommentModerator):
54 | email_notification = True
55 | enable_field = 'allow_comments'
56 | auto_moderate_field = 'publish'
57 | moderate_after = 2
58 | removal_suggestion_notification = True
59 |
60 |
61 | moderator.register(Diary, DiaryCommentModerator)
62 |
--------------------------------------------------------------------------------
/django_comments_tree/templates/django_comments_tree/comment_list.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% load i18n %}
3 | {% load comments_tree %}
4 |
5 | {% block menu-class-comments %}active{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
10 |
11 |
{% trans "List of comments" %}
12 |
13 |
14 |
15 |
16 |
17 |
18 | {% for comment in object_list %}
19 | {% include "django_comments_tree/comment.html" %}
20 | {% empty %}
21 |
44 | {% endblock %}
45 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. django-comments-tree documentation master file, created by
2 | sphinx-quickstart on Mon Dec 19 19:20:12 2011.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | =====================
7 | django-comments-tree
8 | =====================
9 |
10 | .. module:: django_comments_tree
11 | :synopsis: django-comments-extended.
12 |
13 | .. highlightlang:: html+django
14 |
15 | A Django pluggable application that adds comments to your project. It extends the once official `Django Comments Framework `_ with the following features:
16 |
17 | .. index::
18 | single: Features
19 |
20 | #. Comments model based on `django-treebeard `_ to provide a robust and fast threaded and nested comment structure.
21 | #. Efficiently associate a comment tree with any model through a link table which avoids populating each comment with extra link data.
22 | #. Customizable maximum thread level, either for all models or on a per app.model basis.
23 | #. Optional notifications on follow-up comments via email.
24 | #. Mute links to allow cancellation of follow-up notifications.
25 | #. Comment confirmation via email when users are not authenticated.
26 | #. Comments hit the database only after they have been confirmed.
27 | #. Registered users can like/dislike comments and can suggest comments removal.
28 | #. Template tags to list/render the last N comments posted to any given list of app.model pairs.
29 | #. Emails sent through threads (can be disable to allow other solutions, like a Celery app).
30 | #. Fully functional JavaScript plugin using ReactJS, jQuery, Bootstrap, Remarkable and MD5.
31 |
32 | .. image:: images/cover.png
33 |
34 | Contents
35 | ========
36 |
37 | .. toctree::
38 | :maxdepth: 1
39 |
40 | quickstart
41 | tutorial
42 | example
43 | logic
44 | webapi
45 | javascript
46 | templatetags
47 | migrating
48 | extending
49 | i18n
50 | settings
51 | templates
52 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | # Tox (http://tox.testrun.org/) is a tool for running tests
2 | # in multiple virtualenvs. This configuration file will run the
3 | # test suite on all supported python versions. To use it, "pip install tox"
4 | # and then run "tox" from this directory.
5 | [pytest]
6 | DJANGO_SETTINGS_MODULES=tests.settings
7 | django_find_project = false
8 | python_paths = django_comments_tree
9 | python_files = test_*.py
10 |
11 | [tox]
12 | skipsdist = True
13 | envlist = py{37,38}-django{220,300}
14 |
15 | [travis]
16 | python =
17 | 3.7: py37
18 | 3.8: py38
19 |
20 | [travis:env]
21 | DJANGO =
22 | 2.2: django220
23 | 3.0: django330
24 |
25 | [testenv]
26 | changedir = {toxinidir}/django_comments_tree
27 | commands = pytest {posargs} # -rw --cov-config .coveragerc --cov django_comments_tree
28 | deps =
29 | #-rrequirements.pip
30 | pip
31 | six
32 | docutils
33 | Markdown
34 | django-markup
35 | markdown
36 | django-markdown2
37 | draftjs-exporter
38 | pytest
39 | pytest-cov
40 | pytest-django
41 | selenium
42 | factory_boy
43 | django-treebeard
44 | djangorestframework
45 | django-markupfield
46 | py37-django220: django>=2.2,<2.3
47 | py37-django330: django>=3.0,<3.1
48 | py38-django220: django>=2.2,<2.3
49 | py38-django330: django>=3.0,<3.1
50 | setenv =
51 | PYTHONPATH = {toxinidir}:{toxinidir}
52 | DJANGO_SETTINGS_MODULE=django_comments_tree.tests.settings
53 |
54 | [flake8]
55 | ignore = D203,C901,W503
56 | exclude = .git,.venv3,__pycache__,docs/source/conf.py,old,build,dist,.tox,docs,django_comments_tree/tests,django_comments_tree/migrations
57 | max-complexity = 10
58 | max-line-length = 100
59 |
60 | [testenv:pep8]
61 | show-source = True
62 | commands = {envbindir}/flake8 --ignore C901 --max-line-length=100 --exclude=.tox,docs,django_comments_tree/tests,django_comments_tree/migrations django_comments_tree
63 | # Flake8 only needed when linting.
64 | # Do not care about other dependencies, it's just for linting.
65 | deps = flake8
66 | changedir = {toxinidir}
67 |
68 | [testenv:js]
69 | commands =
70 | npm install --prefix {toxinidir}
71 |
--------------------------------------------------------------------------------
/django_comments_tree/views/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | A few bits of helper functions for comment views.
3 | """
4 |
5 | import textwrap
6 |
7 | from urllib.parse import urlencode
8 |
9 | from django.http import HttpResponseRedirect
10 | from django.shortcuts import render, resolve_url
11 | from django.core.exceptions import ObjectDoesNotExist
12 | from django.utils.http import is_safe_url
13 |
14 | import django_comments_tree
15 |
16 |
17 | def next_redirect(request, fallback, **get_kwargs):
18 | """
19 | Handle the "where should I go next?" part of comment views.
20 |
21 | The next value could be a
22 | ``?next=...`` GET arg or the URL of a given view (``fallback``). See
23 | the view modules for examples.
24 |
25 | Returns an ``HttpResponseRedirect``.
26 | """
27 | next = request.POST.get('next')
28 | if not is_safe_url(url=next, allowed_hosts={request.get_host()}):
29 | next = resolve_url(fallback)
30 |
31 | if get_kwargs:
32 | if '#' in next:
33 | tmp = next.rsplit('#', 1)
34 | next = tmp[0]
35 | anchor = '#' + tmp[1]
36 | else:
37 | anchor = ''
38 |
39 | joiner = ('?' in next) and '&' or '?'
40 | next += joiner + urlencode(get_kwargs) + anchor
41 | return HttpResponseRedirect(next)
42 |
43 |
44 | def confirmation_view(template, doc="Display a confirmation view."):
45 | """
46 | Confirmation view generator for the "comment was
47 | posted/flagged/deleted/approved" views.
48 | """
49 |
50 | def confirmed(request):
51 | comment = None
52 | if 'c' in request.GET:
53 | try:
54 | comment = django_comments_tree.get_model().objects.get(pk=request.GET['c'])
55 | except (ObjectDoesNotExist, ValueError):
56 | pass
57 | return render(request, template, {'comment': comment})
58 |
59 | confirmed.__doc__ = textwrap.dedent("""\
60 | %s
61 |
62 | Templates: :template:`%s``
63 | Context:
64 | comment
65 | The posted comment
66 | """ % (doc, template)
67 | )
68 | return confirmed
69 |
--------------------------------------------------------------------------------
/django_comments_tree/management/commands/populate_tree_comments.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | from django.db import connections
4 | from django.db.utils import ConnectionDoesNotExist, IntegrityError
5 | from django.core.management.base import BaseCommand
6 |
7 | from django_comments_tree.models import TreeComment
8 |
9 |
10 | __all__ = ['Command']
11 |
12 |
13 | class Command(BaseCommand):
14 | help = "Load the treecomment table with valid data from django_comments_tree."
15 |
16 | def add_arguments(self, parser):
17 | parser.add_argument('using', nargs='*', type=str)
18 |
19 | def populate_db(self, cursor):
20 | """
21 | ToDo: More work will be needed to make this transition work
22 | :param cursor:
23 | :return:
24 | """
25 | # for comment in Comment.objects.all():
26 | # sql = ("INSERT INTO %(table)s "
27 | # " ('comment_ptr_id', 'thread_id', 'parent_id',"
28 | # " 'level', 'order', 'followup') "
29 | # "VALUES (%(id)d, %(id)d, %(id)d, 0, 1, 0)")
30 | # cursor.execute(sql % {'table': TreeComment._meta.db_table,
31 | # 'id': comment.id})
32 |
33 | def handle(self, *args, **options):
34 | total = 0
35 | using = options['using'] or ['default']
36 | for db_conn in using:
37 | try:
38 | self.populate_db(connections[db_conn].cursor())
39 | total += TreeComment.objects.using(db_conn).count()
40 | except ConnectionDoesNotExist:
41 | print("DB connection '%s' does not exist." % db_conn)
42 | continue
43 | except IntegrityError:
44 | if db_conn != 'default':
45 | print("Table '%s' (in '%s' DB connection) must be empty."
46 | % (TreeComment._meta.db_table, db_conn))
47 | else:
48 | print("Table '%s' must be empty."
49 | % TreeComment._meta.db_table)
50 | sys.exit(1)
51 | print("Added %d TreeComment object(s)." % total)
52 |
--------------------------------------------------------------------------------
/django_comments_tree/templates/comments/delete.html:
--------------------------------------------------------------------------------
1 | {% extends "django_comments_tree/base.html" %}
2 | {% load i18n %}
3 | {% load comments_tree %}
4 |
5 | {% block title %}{% trans "Remove comment" %}{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
{% trans "Remove this comment?" %}
10 |
11 |
12 |
{% trans "As a moderator you can delete comments. Deleting a comment does not remove it from the site, only prevents your website from showing the text. Click on the remove button to delete the following comment:" %}
48 | {% endblock %}
49 |
--------------------------------------------------------------------------------
/django_comments_tree/render.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from draftjs_exporter import html as htmlexporter
4 | from draftjs_exporter.constants import BLOCK_TYPES, ENTITY_TYPES
5 | from draftjs_exporter.defaults import BLOCK_MAP
6 | from draftjs_exporter.dom import DOM
7 | from django.utils.html import escape, linebreaks, urlize
8 |
9 |
10 | def image(props):
11 | """
12 | Components are simple functions that take `props` as parameter and return DOM elements.
13 | This component creates an image element, with the relevant attributes.
14 |
15 | :param props:
16 | :return:
17 | """
18 | return DOM.create_element('img', {
19 | 'src': props.get('src'),
20 | 'width': props.get('width'),
21 | 'height': props.get('height'),
22 | 'alt': props.get('alt'),
23 | })
24 |
25 |
26 | def blockquote(props):
27 | """
28 | This component uses block data to render a blockquote.
29 | :param props:
30 | :return:
31 | """
32 | block_data = props['block']['data']
33 |
34 | # Here, we want to display the block's content so we pass the
35 | # `children` prop as the last parameter.
36 | return DOM.create_element('blockquote', {
37 | 'cite': block_data.get('cite')
38 | }, props['children'])
39 |
40 |
41 | # https://github.com/springload/draftjs_exporter#configuration
42 | # custom configuration
43 |
44 | _config = {
45 | 'block_map': dict(BLOCK_MAP, **{
46 | BLOCK_TYPES.BLOCKQUOTE: blockquote,
47 | # BLOCK_TYPES.ATOMIC: {'start': '', 'end': ''},
48 | }),
49 | 'entity_decorators': {
50 | # ENTITY_TYPES.LINK: 'link',
51 | ENTITY_TYPES.IMAGE: image,
52 | ENTITY_TYPES.HORIZONTAL_RULE: lambda props: DOM.create_element('hr'),
53 | }
54 | }
55 |
56 |
57 | def render_draftjs(content_data):
58 | try:
59 | cstate = json.loads(content_data)
60 | except json.JSONDecodeError:
61 | # invalid json data
62 | # Should log something...
63 | return ''
64 | renderer = htmlexporter.HTML(_config)
65 | html = renderer.render(cstate)
66 | return html
67 |
68 |
69 | def render_plain(content_data):
70 | return linebreaks(urlize(escape(content_data)))
71 |
--------------------------------------------------------------------------------
/example/comp/README.md:
--------------------------------------------------------------------------------
1 | ## Comp example project ##
2 |
3 | The Comp Demo implements two apps, each of which contains a model whose instances can received comments:
4 |
5 | 1. App `articles` with the model `Article`
6 | 1. App `quotes` with the model `Quote` contained inside the `extra` directory.
7 | ### Features
8 |
9 | 1. Comments can be nested, and the maximum thread level is established to 2.
10 | 1. Comment confirmation via mail when the users are not authenticated.
11 | 1. Comments hit the database only after they have been confirmed.
12 | 1. Follow up notifications via mail.
13 | 1. Mute links to allow cancellation of follow-up notifications.
14 | 1. Registered users can like/dislike comments and can suggest comments removal.
15 | 1. Registered users can see the list of users that liked/disliked comments.
16 | 1. The homepage presents the last 5 comments posted either to the `articles.Article` or the `quotes.Quote` model.
17 |
18 | #### Threaded comments
19 |
20 | The setting `COMMENTS_TREE_MAX_THREAD_LEVEL` is set to 2, meaning that comments may be threaded up to 2 levels below the the first level (internally known as level 0)::
21 |
22 | First comment (level 0)
23 | |-- Comment to "First comment" (level 1)
24 | |-- Comment to "Comment to First comment" (level 2)
25 |
26 | #### `render_treecomment_tree`
27 |
28 | By using the `render_treecomment_tree` templatetag, both, `article_detail.html` and `quote_detail.html`, show the tree of comments posted. `article_detail.html` makes use of the arguments `allow_feedback`, `show_feedback` and `allow_flagging`, while `quote_detail.html` only show the list of comments, with no extra arguments, so users can't flag comments for removal, and neither can submit like/dislike feedback.
29 |
30 | #### `render_last_treecomments`
31 |
32 | The **Last 5 Comments** shown in the block at the rigght uses the templatetag `render_last_treecomments` to show the last 5 comments posted to either `articles.Article` or `quotes.Quote` instances. The templatetag receives the list of pairs `app.model` from which we want to gather comments and shows the given N last instances posted. The templatetag renders the template `django_comments_tree/comment.html` for each comment retrieve.
33 |
--------------------------------------------------------------------------------
/example/fixtures/articles.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "model": "articles.article",
4 | "pk": 1,
5 | "fields": {
6 | "title": "Net Neutrality in Jeopardy",
7 | "slug": "net-neutrality-jeopardy",
8 | "body": "The battle over the future of the Internet is a power grab that pits well-heeled lobbyists, corrupt legislators, phony front groups and the world\u2019s most powerful telecommunications companies against the rest of us \u2014 the millions of Americans who use the Internet every day, in increasingly inventive ways.\r\n\r\nPolicymakers are public officials, and it\u2019s their job to serve the public interest. You can play an important role by helping spread the word about Net Neutrality, meeting face to face with elected officials and urging them to protect the open Internet.\r\n\r\nYou can start by urging the Senate to stand up for Net Neutrality. ",
9 | "allow_comments": true,
10 | "publish": "2012-01-10T14:53:23Z"
11 | }
12 | },
13 | {
14 | "model": "articles.article",
15 | "pk": 2,
16 | "fields": {
17 | "title": "Colonies in space may be only hope, says Hawking",
18 | "slug": "colonies-space-only-hope",
19 | "body": "The human race is likely to be wiped out by a doomsday virus before the Millennium is out, unless we set up colonies in space, Prof Stephen Hawking warns today.\r\n\r\nIn an interview with The Telegraph, Prof Hawking, the world's best known cosmologist, says that biology, rather than physics, presents the biggest challenge to human survival.\r\n\r\n\"Although September 11 was horrible, it didn't threaten the survival of the human race, like nuclear weapons do,\" said the Cambridge University scientist.\r\n\r\n\"In the long term, I am more worried about biology. Nuclear weapons need large facilities, but genetic engineering can be done in a small lab. You can't regulate every lab in the world. The danger is that either by accident or design, we create a virus that destroys us.\r\n\r\n\"I don't think the human race will survive the next thousand years, unless we spread into space. There are too many accidents that can befall life on a single planet. But I'm an optimist. We will reach out to the stars.\"",
20 | "allow_comments": true,
21 | "publish": "2001-11-16T10:20:59Z"
22 | }
23 | }
24 | ]
25 |
--------------------------------------------------------------------------------
/django_comments_tree/utils.py:
--------------------------------------------------------------------------------
1 | # Idea borrowed from Selwin Ong post:
2 | # http://ui.co.id/blog/asynchronous-send_mail-in-django
3 |
4 | import queue as queue # python3
5 |
6 | import threading
7 |
8 | from django.core.mail import EmailMultiAlternatives
9 |
10 | from django_comments_tree.conf import settings
11 |
12 |
13 | mail_sent_queue = queue.Queue()
14 |
15 |
16 | class EmailThread(threading.Thread):
17 | def __init__(self, subject, body, from_email, recipient_list,
18 | fail_silently, html):
19 | self.subject = subject
20 | self.body = body
21 | self.recipient_list = recipient_list
22 | self.from_email = from_email
23 | self.fail_silently = fail_silently
24 | self.html = html
25 | threading.Thread.__init__(self)
26 |
27 | def run(self):
28 | _send_mail(self.subject, self.body, self.from_email,
29 | self.recipient_list, self.fail_silently, self.html)
30 | mail_sent_queue.put(True)
31 |
32 |
33 | def _send_mail(subject, body, from_email, recipient_list,
34 | fail_silently=False, html=None):
35 | msg = EmailMultiAlternatives(subject, body, from_email, recipient_list)
36 | if html:
37 | msg.attach_alternative(html, "text/html")
38 | msg.send(fail_silently)
39 |
40 |
41 | def send_mail(subject, body, from_email, recipient_list,
42 | fail_silently=False, html=None):
43 | if settings.COMMENTS_TREE_THREADED_EMAILS:
44 | EmailThread(subject, body, from_email, recipient_list,
45 | fail_silently, html).start()
46 | else:
47 | _send_mail(subject, body, from_email, recipient_list,
48 | fail_silently, html)
49 |
50 |
51 | def has_app_model_option(comment):
52 | _default = {
53 | 'allow_flagging': False,
54 | 'allow_feedback': False,
55 | 'show_feedback': False
56 | }
57 | # content_type = ContentType.objects.get_for_model(comment.content_object)
58 | content_type = comment.content_type
59 | key = "%s.%s" % (content_type.app_label, content_type.model)
60 | try:
61 | return settings.COMMENTS_TREE_APP_MODEL_OPTIONS[key]
62 | except KeyError:
63 | return settings.COMMENTS_TREE_APP_MODEL_OPTIONS.setdefault(
64 | 'default', _default)
65 |
--------------------------------------------------------------------------------
/compose/postgres/restore.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # stop on errors
4 | set -e
5 |
6 | # we might run into trouble when using the default `postgres` user, e.g. when dropping the postgres
7 | # database in restore.sh. Check that something else is used here
8 | if [ "$POSTGRES_USER" == "postgres" ]
9 | then
10 | echo "restoring as the postgres user is not supported, make sure to set the POSTGRES_USER environment variable"
11 | exit 1
12 | fi
13 |
14 | # export the postgres password so that subsequent commands don't ask for it
15 | export PGPASSWORD=$POSTGRES_PASSWORD
16 |
17 | # check that we have an argument for a filename candidate
18 | if [[ $# -eq 0 ]] ; then
19 | echo 'usage:'
20 | echo ' docker-compose run postgres restore '
21 | echo ''
22 | echo 'to get a list of available backups, run:'
23 | echo ' docker-compose run postgres list-backups'
24 | exit 1
25 | fi
26 |
27 | # set the backupfile variable
28 | # Calculate a default filename
29 | BACKUPFILE=$1
30 | if [[ $(dirname ${BACKUPFILE}) == '.' ]];then
31 | BACKUPFILE=/backups/$(basename ${BACKUPFILE})
32 | BACKUPFILE_LOCAL=/local_backups/$(basename ${BACKUPFILE})
33 | fi
34 |
35 | # check that the file exists
36 | if [[ ! -f "${BACKUPFILE}" ]] && [[ ! -f "${BACKUPFILE_LOCAL}" ]]
37 | then
38 | echo "backup file not found"
39 | echo 'to get a list of available backups, run:'
40 | echo ' docker-compose run postgres list-backups'
41 | exit 1
42 | fi
43 |
44 | # Prefer local version of backup file.
45 | if [[ -f "${BACKUPFILE_LOCAL}" ]]
46 | then
47 | BACKUPFILE=${BACKUPFILE_LOCAL}
48 | fi
49 |
50 | echo "beginning restore from $1"
51 | echo "-------------------------"
52 |
53 | # delete the db
54 | # deleting the db can fail. Spit out a comment if this happens but continue since the db
55 | # is created in the next step
56 | : ${POSTGRES_DB:=$POSTGRES_USER}
57 | echo "deleting old database $POSTGRES_DB"
58 | if dropdb -h postgres -U $POSTGRES_USER $POSTGRES_DB
59 | then echo "deleted $POSTGRES_DB database"
60 | else echo "database $POSTGRES_DB does not exist, continue"
61 | fi
62 |
63 | # create a new database
64 | echo "creating new database $POSTGRES_DB"
65 | createdb -h postgres -U $POSTGRES_USER $POSTGRES_DB -O $POSTGRES_USER
66 |
67 | # restore the database
68 | echo "restoring database $POSTGRES_DB"
69 | psql -h postgres -U $POSTGRES_USER -d $POSTGRES_DB < $BACKUPFILE
70 |
--------------------------------------------------------------------------------
/django_comments_tree/conf/defaults.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 | from django.conf import settings
3 | import markdown
4 | from django_comments_tree.render import render_draftjs, render_plain
5 |
6 | # Default application namespace
7 | COMMENT_URL_NAMESPACE = 'treecomments'
8 |
9 | COMMENT_MAX_LENGTH = 3000
10 |
11 | # Extra key to salt the TreeCommentForm.
12 | COMMENTS_TREE_SALT = b""
13 |
14 | # Whether comment posts should be confirmed by email.
15 | COMMENTS_TREE_CONFIRM_EMAIL = True
16 |
17 | # From email address.
18 | COMMENTS_TREE_FROM_EMAIL = settings.DEFAULT_FROM_EMAIL
19 |
20 | # Contact email address.
21 | COMMENTS_TREE_CONTACT_EMAIL = settings.DEFAULT_FROM_EMAIL
22 |
23 | # Maximum Thread Level.
24 | COMMENTS_TREE_MAX_THREAD_LEVEL = 0
25 |
26 | # Maximum Thread Level per app.model basis.
27 | COMMENTS_TREE_MAX_THREAD_LEVEL_BY_APP_MODEL = {}
28 |
29 | # Default order to list comments in.
30 | COMMENTS_TREE_LIST_ORDER = ('submit_date',)
31 |
32 | # Form class to use.
33 | COMMENTS_TREE_FORM_CLASS = "django_comments_tree.forms.TreeCommentForm"
34 |
35 | # Structured Data.
36 | COMMENTS_TREE_STRUCTURED_DATA_CLASS = "django_comments_tree.models.CommentData"
37 |
38 | # Model to use.
39 | COMMENTS_TREE_MODEL = "django_comments_tree.models.TreeComment"
40 |
41 | # Send HTML emails.
42 | COMMENTS_TREE_SEND_HTML_EMAIL = True
43 |
44 | # Whether to send emails in separate threads or use the regular method.
45 | # Set it to False to use a third-party app like django-celery-email or
46 | # your own celery app.
47 | COMMENTS_TREE_THREADED_EMAILS = True
48 |
49 | # Define what commenting features a pair app_label.model can have.
50 | # TODO: Put django-comments-tree settings under a dictionary, and merge
51 | # COMMENTS_TREE_MAX_THREAD_LEVEL_BY_APP_MODEL with this one.
52 | COMMENTS_TREE_APP_MODEL_OPTIONS = {
53 | 'default': {
54 | 'allow_flagging': False,
55 | 'allow_feedback': False,
56 | 'show_feedback': False,
57 | }
58 | }
59 |
60 |
61 | # Define a function to return the user representation. Used by
62 | # the web API to represent user strings in response objects.
63 | def username(u):
64 | return u.username
65 |
66 |
67 | COMMENTS_TREE_API_USER_REPR = username
68 |
69 | # Set to true to enable Firebase notifications
70 | COMMENTS_TREE_ENABLE_FIREBASE = False
71 |
72 | COMMENTS_TREE_FIREBASE_KEY = None
73 |
74 | # Default types we can use for comments
75 | MARKUP_FIELD_TYPES = (
76 | ('plain', render_plain),
77 | ('markdown', markdown.markdown),
78 | ('draftjs', render_draftjs),
79 | )
80 |
--------------------------------------------------------------------------------
/example/comp/templates/quotes/quote_detail.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% load i18n %}
3 | {% load comments_tree %}
4 |
5 | {% block menu-class-quotes %}active{% endblock %}
6 |
7 | {% block content %}
8 |
100 | The comment you tried to post to this view wasn't saved because something
101 | tampered with the security information in the comment form. The message
102 | above should explain the problem, or you can check the comment
104 | documentation for more help.
105 |
106 |
107 |
108 |
109 |
110 | You're seeing this error because you have DEBUG = True in
111 | your Django settings file. Change that to False, and Django
112 | will display a standard 400 error page.
113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/django_comments_tree/static/django_comments_tree/css/_variables.scss:
--------------------------------------------------------------------------------
1 | // Lumen 4.1.3
2 | // Bootswatch
3 |
4 | //
5 | // Color system
6 | //
7 |
8 | $white: #fff !default;
9 | $gray-100: #f6f6f6 !default;
10 | $gray-200: #f0f0f0 !default;
11 | $gray-300: #dee2e6 !default;
12 | $gray-400: #ced4da !default;
13 | $gray-500: #adb5bd !default;
14 | $gray-600: #999 !default;
15 | $gray-700: #555 !default;
16 | $gray-800: #333 !default;
17 | $gray-900: #222 !default;
18 | $black: #000 !default;
19 |
20 | $blue: rgb(33, 150, 243) !default;
21 | $indigo: #6610f2 !default;
22 | $purple: #6f42c1 !default;
23 | $pink: #e83e8c !default;
24 | $red: #FF4136 !default;
25 | $orange: #fd7e14 !default;
26 | $yellow: #FF851B !default;
27 | $green: #28B62C !default;
28 | $teal: #20c997 !default;
29 | $cyan: #75CAEB !default;
30 |
31 | $primary: $blue !default;
32 | $secondary: $gray-200 !default;
33 | $success: $green !default;
34 | $info: $cyan !default;
35 | $warning: $yellow !default;
36 | $danger: $red !default;
37 | $light: $gray-100 !default;
38 | $dark: $gray-700 !default;
39 |
40 | $yiq-contrasted-threshold: 200 !default;
41 |
42 | $body-color: $gray-800 !default;
43 |
44 | // Fonts
45 |
46 | $font-family-sans-serif: "Oxygen", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !default;
47 |
48 | // $font-size-base: 0.875rem !default;
49 | $font-size-base: 0.925rem !default;
50 | $font-size-sm: ($font-size-base * .800) !default;
51 |
52 | // Dropdowns
53 |
54 | $dropdown-link-color: rgba(0,0,0,.5) !default;
55 |
56 | // Navs
57 |
58 | $nav-tabs-border-color: $gray-200 !default;
59 | $nav-tabs-link-hover-border-color: $nav-tabs-border-color !default;
60 | $nav-tabs-link-active-color: $gray-900 !default;
61 | $nav-tabs-link-active-border-color: $nav-tabs-border-color !default;
62 |
63 | // Pagination
64 |
65 | $pagination-color: $gray-700 !default;
66 | $pagination-bg: $gray-200 !default;
67 |
68 | $pagination-hover-color: $pagination-color !default;
69 | $pagination-hover-bg: $pagination-bg !default;
70 |
71 | $pagination-active-border-color: darken($primary, 5%) !default;
72 |
73 | $pagination-disabled-color: $gray-600 !default;
74 | $pagination-disabled-bg: $pagination-bg !default;
75 |
76 | // Jumbotron
77 |
78 | $jumbotron-bg: #fafafa !default;
79 |
80 | // Modals
81 |
82 | $modal-content-border-color: rgba($black,.1) !default;
83 |
84 | // Close
85 |
86 | $close-color: $white !default;
87 |
--------------------------------------------------------------------------------
/django_comments_tree/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import url
2 | from django.contrib.contenttypes.views import shortcut
3 | from rest_framework.urlpatterns import format_suffix_patterns
4 |
5 | from django_comments_tree import api
6 | from .views.comments import (comment_done, confirm, dislike, dislike_done,
7 | FlagView, like, like_done, mute, post_comment,
8 | reply, sent)
9 | from .views.moderation import (approve, approve_done, delete, delete_done,
10 | flag, flag_done)
11 |
12 | urlpatterns = [
13 | url(r'^sent/$', sent, name='comments-tree-sent'),
14 | url(r'^confirm/(?P[^/]+)/$', confirm,
15 | name='comments-tree-confirm'),
16 | url(r'^mute/(?P[^/]+)/$', mute, name='comments-tree-mute'),
17 | url(r'^reply/(?P[\d]+)/$', reply, name='comments-tree-reply'),
18 |
19 | # Remap comments-flag to check allow-flagging is enabled.
20 | url(r'^flag/(\d+)/$', FlagView.as_view(), name='comments-flag'),
21 | # New flags in addition to those provided by django-contrib-comments.
22 | url(r'^like/(\d+)/$', like, name='comments-tree-like'),
23 | url(r'^liked/$', like_done, name='comments-tree-like-done'),
24 | url(r'^dislike/(\d+)/$', dislike, name='comments-tree-dislike'),
25 | url(r'^disliked/$', dislike_done, name='comments-tree-dislike-done'),
26 |
27 | # API handlers.
28 | url(r'^api/comment/$', api.CommentCreate.as_view(),
29 | name='comments-tree-api-create'),
30 | url(r'^api/(?P\w+[-]{1}\w+)/(?P[-\w]+)/$',
31 | api.CommentList.as_view(), name='comments-tree-api-list'),
32 | url(r'^api/(?P\w+[-]{1}\w+)/(?P[-\w]+)/count/$',
33 | api.CommentCount.as_view(), name='comments-tree-api-count'),
34 | url(r'^api/feedback/$', api.ToggleFeedbackFlag.as_view(),
35 | name='comments-tree-api-feedback'),
36 | url(r'^api/flag/$', api.CreateReportFlag.as_view(),
37 | name='comments-tree-api-flag'),
38 | url(r'^api/flag/(?P\d+)/$', api.RemoveReportFlag.as_view(),
39 | name='comments-tree-api-remove-flag'),
40 | ]
41 |
42 | # Migrated from original django-contrib-comments
43 | urlpatterns += [
44 | url(r'^post/$', post_comment, name='comments-post-comment'),
45 | url(r'^posted/$', comment_done, name='comments-comment-done'),
46 | url(r'^flag/(\d+)/$', flag, name='comments-flag'),
47 | url(r'^flagged/$', flag_done, name='comments-flag-done'),
48 | url(r'^delete/(\d+)/$', delete, name='comments-delete'),
49 | url(r'^deleted/$', delete_done, name='comments-delete-done'),
50 | url(r'^approve/(\d+)/$', approve, name='comments-approve'),
51 | url(r'^approved/$', approve_done, name='comments-approve-done'),
52 |
53 | url(r'^cr/(\d+)/(.+)/$', shortcut, name='comments-url-redirect'),
54 | ]
55 |
56 | urlpatterns = format_suffix_patterns(urlpatterns)
57 |
--------------------------------------------------------------------------------
/example/custom/templates/comments/form.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 | {% load comments_tree %}
3 |
4 |
80 |
--------------------------------------------------------------------------------
/docs/migrating.rst:
--------------------------------------------------------------------------------
1 | .. _ref-migrating:
2 |
3 | ================================
4 | Migrating to django-comments-tree
5 | ================================
6 |
7 | If your project uses django-contrib-comments you can easily plug django-comments-tree to add extra functionalities like comment confirmation by mail, comment threading and follow-up notifications.
8 |
9 | This section describes how to make django-comments-tree take over comments support in a project in which django-contrib-comments tables have received data already.
10 |
11 |
12 | Preparation
13 | ===========
14 |
15 | First of all, install django-comments-tree:
16 |
17 | .. code-block:: bash
18 |
19 | (venv)$ cd mysite
20 | (venv)$ pip install django-comments-tree
21 |
22 | Then edit the settings module and change your :setting:`INSTALLED_APPS` so that django_comments_tree and django_comments are listed in this order. Also change the :setting:`COMMENTS_APP` and add the ``EMAIL_*`` settings to be able to send mail messages:
23 |
24 | .. code-block:: python
25 |
26 | INSTALLED_APPS = [
27 | ...
28 | 'django_comments_tree',
29 | 'django_comments',
30 | ...
31 | ]
32 | ...
33 | COMMENTS_APP = 'django_comments_tree'
34 |
35 | # Either enable sending mail messages to the console:
36 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
37 |
38 | # Or set up the EMAIL_* settings so that Django can send emails:
39 | EMAIL_HOST = "smtp.mail.com"
40 | EMAIL_PORT = "587"
41 | EMAIL_HOST_USER = "alias@mail.com"
42 | EMAIL_HOST_PASSWORD = "yourpassword"
43 | EMAIL_USE_TLS = True
44 | DEFAULT_FROM_EMAIL = "Helpdesk "
45 |
46 |
47 | Edit the urls module of the project and mount django_comments_tree's URLs in the path in which you had django_comments' URLs, django_comments_tree's URLs includes django_comments':
48 |
49 | .. code-block:: python
50 |
51 | from django.conf.urls import include, url
52 |
53 | urlpatterns = [
54 | ...
55 | url(r'^comments/', include('django_comments_tree.urls')),
56 | ...
57 | ]
58 |
59 |
60 | Now create the tables for django-comments-tree:
61 |
62 | .. code-block:: bash
63 |
64 | (venv)$ python manage.py migrate
65 |
66 |
67 | Populate comment data
68 | =====================
69 |
70 | The following step will populate **TreeComment**'s table with data from the **Comment** model. For that purpose you can use the ``populate_xtdcomments`` management command:
71 |
72 | .. code-block:: bash
73 |
74 | (venv)$ python manage.py populate_xtdcomments
75 | Added 3468 TreeComment object(s).
76 |
77 | You can pass as many DB connections as you have defined in :setting:`DATABASES` and the command will run in each of the databases, populating the **TreeComment**'s table with data from the comments table existing in each database.
78 |
79 | Now the project is ready to handle comments with django-comments-tree.
80 |
--------------------------------------------------------------------------------
/django_comments_tree/templates/comments/form.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 | {% load comments_tree %}
3 |
4 |
75 |
--------------------------------------------------------------------------------
/django_comments_tree/tests/test_model_associations.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from textwrap import dedent
3 | from os.path import join, dirname
4 |
5 | from django.db import connection, reset_queries
6 | from django.contrib.contenttypes.models import ContentType
7 | from django.contrib.sites.models import Site
8 | from django.conf import settings
9 | from django.test import TestCase as DjangoTestCase, override_settings
10 |
11 | from django_comments_tree.models import (TreeComment, CommentAssociation,
12 | MaxThreadLevelExceededException)
13 | from django_comments_tree.tests.models import Article, Diary
14 |
15 |
16 | class ArticleBaseTestCase(DjangoTestCase):
17 | def setUp(self):
18 | self.article_1 = Article.objects.create(
19 | title="September", slug="september", body="During September...")
20 | self.article_2 = Article.objects.create(
21 | title="October", slug="october", body="What I did on October...")
22 |
23 | def add_comment(self, root, comment="Just a comment"):
24 | """
25 | Add Two Comments for the article
26 |
27 | root -
28 | comment 1
29 | comment 2
30 | """
31 | child = root.add_child(comment=comment,
32 | submit_date=datetime.now())
33 | #root.refresh_from_db()
34 | return child
35 |
36 | def add_comments(self, root, level=2):
37 | current = root
38 | for lvl in range(level):
39 | current = self.add_comment(current)
40 |
41 |
42 | class CommentAssociationTestCase(ArticleBaseTestCase):
43 | def setUp(self):
44 | super().setUp()
45 | self.article_ct = ContentType.objects.get(app_label="tests",
46 | model="article")
47 |
48 | self.site = Site.objects.get(pk=1)
49 | self.root = TreeComment.objects.get_or_create_root(self.article_1)
50 |
51 | @override_settings(DEBUG=True)
52 | def test_association_is_added(self):
53 | root = self.root
54 | reset_queries()
55 | comment = self.add_comment(root)
56 |
57 | self.assertEqual(comment.assoc, root.commentassociation)
58 |
59 | # Only the insert of comment and update of root depth
60 | self.assertEqual(len(connection.queries), 2)
61 |
62 | @override_settings(DEBUG=True)
63 | def test_association_is_added_at_depth(self):
64 | root = self.root
65 | reset_queries()
66 | comment = self.add_comment(root)
67 | comment2 = self.add_comment(comment)
68 | comment3 = self.add_comment(comment2)
69 |
70 | self.assertEqual(comment2.assoc, root.commentassociation)
71 | self.assertEqual(comment3.assoc, root.commentassociation)
72 |
73 | # Only the insert of comment and update of root depth
74 | self.assertEqual(len(connection.queries), 6)
75 |
76 | def test_association_is_updated_on_change(self):
77 | root = self.root
78 | comment = self.add_comment(root)
79 | comment2 = self.add_comment(comment)
80 | comment3 = self.add_comment(comment2)
81 |
82 | self.assertEqual(comment2.assoc, root.commentassociation)
83 | self.assertEqual(comment3.assoc, root.commentassociation)
84 |
85 |
86 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | from setuptools import setup, find_packages
4 | from setuptools.command.test import test
5 |
6 | version = {}
7 | with open("django_comments_tree/version.py") as fp:
8 | exec(fp.read(), version)
9 |
10 |
11 | def run_tests(*args):
12 | from django_comments_tree.tests import run_tests
13 | errors = run_tests()
14 | if errors:
15 | sys.exit(1)
16 | else:
17 | sys.exit(0)
18 |
19 |
20 | test.run_tests = run_tests
21 |
22 |
23 | setup(
24 | name="django-comments-tree",
25 | version=version['__version__'],
26 | packages=find_packages(exclude=['tests']),
27 | scripts=[],
28 | include_package_data=True,
29 | license="MIT",
30 | description=("Django Comments Framework extension app with django-treebeard "
31 | "support, follow up notifications and email "
32 | "confirmations, as well as real-time comments using Firebase "
33 | "for notifications."),
34 | long_description=("A reusable Django app that uses django-treebeard "
35 | "to create a threaded"
36 | "comments Framework, following up "
37 | "notifications and comments that only hits the "
38 | "database after users confirm them by email."
39 | "Real-time comment updates are also available using "
40 | "Django channels as a notification mechanism of comment updates. "
41 | "Clients can connect to channels for updates, and then query "
42 | "the backend for the actual changes, so that all data is "
43 | "located in the backend database."
44 | ),
45 | author="Ed Henderson",
46 | author_email="ed@sharpertool.com",
47 | maintainer="Ed Henderson",
48 | maintainer_email="ed@sharpertool.com",
49 | keywords="django comments treebeard threaded django-channels websockets",
50 | url="https://github.com/sharpertool/django-comments-tree",
51 | project_urls={
52 | 'Documentation': 'https://django-comments-tree.readthedocs.io/en/latest/',
53 | 'Github': 'https://github.com/sharpertool/django-comments-tree',
54 | 'Original Package': 'https://github.com/danirus/django-comments-xtd',
55 | },
56 | python_requires='>=3.7',
57 | install_requires=[
58 | 'Django>=2.2',
59 | 'django-treebeard>=4.1.0',
60 | 'djangorestframework>=3.6',
61 | 'draftjs_exporter>=2.1.6',
62 | 'django-markupfield>=1.5.1',
63 | 'markdown>=3.1.1',
64 | 'docutils',
65 | 'six',
66 | ],
67 | extras_requires=[
68 | ],
69 | classifiers=[
70 | 'Development Status :: 3 - Alpha',
71 | 'Environment :: Web Environment',
72 | 'Intended Audience :: Developers',
73 | 'License :: OSI Approved :: MIT License',
74 | 'Operating System :: OS Independent',
75 | 'Programming Language :: Python :: 3',
76 | 'Programming Language :: Python :: 3.7',
77 | 'Programming Language :: Python :: 3.8',
78 | 'Framework :: Django',
79 | 'Framework :: Django :: 2.2',
80 | 'Framework :: Django :: 3.0',
81 | 'Natural Language :: English',
82 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary',
83 | ],
84 | test_suite="dummy",
85 | zip_safe=True
86 | )
87 |
--------------------------------------------------------------------------------
/django_comments_tree/templates/django_comments_tree/like.html:
--------------------------------------------------------------------------------
1 | {% extends "django_comments_tree/base.html" %}
2 | {% load i18n %}
3 | {% load comments_tree %}
4 |
5 | {% block title %}{% trans "Confirm your opinion" %}{% endblock %}
6 |
7 | {% block header %}
8 | {{ comment.content_object }}
9 | {% endblock %}
10 |
11 | {% block content %}
12 |
13 |
14 | {% if already_liked_it %}
15 | {% trans "You liked this comment, do you want to change it?" %}
16 | {% else %}
17 | {% trans "Do you like this comment?" %}
18 | {% endif %}
19 |
20 |
21 |
22 |
{% trans "Please, confirm your opinion about the comment." %}
14 | {% if already_disliked_it %}
15 | {% trans "You didn't like this comment, do you want to change it?" %}
16 | {% else %}
17 | {% trans "Do you dislike this comment?" %}
18 | {% endif %}
19 |
20 |
21 |
22 |
{% trans "Please, confirm your opinion about the comment." %}
{{ item.comment.submit_date|localize }} - {% if item.comment.url and not item.comment.is_removed %}{% endif %}{{ item.comment.name }}{% if item.comment.url %}{% endif %}{% if item.comment.user and item.comment.user|has_permission:"django_comments.can_moderate" %} {% trans "moderator" %}{% endif %} ¶
36 | {% include "includes/django_comments_tree/comment_content.html" with content=item.comment.comment %}
37 |
38 | {% if allow_feedback %}
39 | {% include "includes/django_comments_tree/user_feedback.html" %}
40 | {% endif %}
41 | {% if item.comment.allow_thread and not item.comment.is_removed %}
42 | {% if allow_feedback %} • {% endif %}{% trans "Reply" %}
43 | {% endif %}
44 | {% endif %}
45 |
46 | {% if not item.comment.is_removed and item.children %}
47 | {% render_treecomment_tree with comments=item.children %}
48 | {% endif %}
49 |
50 |
51 | {% endfor %}
52 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | django-comments-tree |TravisCI|_
2 | ===================
3 |
4 | .. |TravisCI| image:: https://secure.travis-ci.org/sharpertool/django-comments-tree.png?branch=master
5 | .. _TravisCI: https://travis-ci.org/sharpertool/django-comments-tree
6 |
7 | A Django pluggable application that adds comments to your project.
8 |
9 | .. image:: https://github.com/sharpertool/django-comments-tree/blob/master/docs/images/cover.png
10 |
11 | It extends the once official `django-contrib-comments `_ with the following features:
12 |
13 | #. Comments model based on `django-treebeard `_ to provide a robust and fast threaded and nested comment structure.
14 | #. Efficiently associate a comment tree with any model through a link table which avoids populating each comment with extra link data.
15 | #. Customizable maximum thread level, either for all models or on a per app.model basis.
16 | #. Optional notifications on follow-up comments via email.
17 | #. Mute links to allow cancellation of follow-up notifications.
18 | #. Comment confirmation via email when users are not authenticated.
19 | #. Comments hit the database only after they have been confirmed.
20 | #. Registered users can like/dislike comments and can suggest comments removal.
21 | #. Template tags to list/render the last N comments posted to any given list of app.model pairs.
22 | #. Emails sent through threads (can be disable to allow other solutions, like a Celery app).
23 | #. Fully functional JavaScript plugin using ReactJS, jQuery, Bootstrap, Remarkable and MD5.
24 |
25 | Example sites and tests work under officially Django `supported versions `_:
26 |
27 | * Django 3.0, 2.2
28 | * Python 3.8, 3.7
29 |
30 | Additional Dependencies:
31 |
32 | * django-contrib-comments >=2.0
33 | * djangorestframework >=3.8, <3.9
34 |
35 | Checkout the Docker image `danirus/django-comments-tree-demo `_.
36 |
37 | `Read The Docs `_.
38 |
39 | Why Create a New Package
40 | ===================
41 |
42 | I did not particularly like how the core django-contrib-comments added a GenericForeignKey to each and every comment in order to associate a comment stream with another model. I wanted to have a single place where this association was made.
43 |
44 | I opted to add a model just for linking the comments to other models. This model has a single record for a model -> comment-tree association. The record contains the GenericForeignKey, and a single ForeignKey to the comments root node that starts the comments for that model. This is very flexible, and if the underlying model changes, it is a simple matter to move all comments to a new parent. Treebeard just makes all of this work.
45 |
46 | Treebeard provides robust mechanisms to get the parent, children, siblings, and any other association you might needed from the comment stream. This also makes it much easier to have a very robust tree structure, so nesting, replies, replies to replies, etc. are easy to handle, and very efficient.
47 |
48 | Attribution
49 | ===================
50 | This package is a fork of the excellent work at `django-comments-xtd `_
51 |
52 | I created the fork because I wanted to a comment tree based on MP_node from `django-treebeard `_. I consider this to be a more robust tree implementation. Treebeard suppports multiple root nodes, so each root node can be an entire comment tree.
53 |
54 |
--------------------------------------------------------------------------------
/django_comments_tree/tests/test_model_manager.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from textwrap import dedent
3 | from os.path import join, dirname
4 |
5 | from django.db import connection, reset_queries
6 | from django.contrib.contenttypes.models import ContentType
7 | from django.contrib.sites.models import Site
8 | from django.conf import settings
9 | from django.test import TestCase as DjangoTestCase, override_settings
10 |
11 | from django_comments_tree.models import (TreeComment, CommentAssociation,
12 | MaxThreadLevelExceededException)
13 | from django_comments_tree.tests.models import Article, Diary
14 |
15 | from django_comments_tree.models import (LIKEDIT_FLAG, DISLIKEDIT_FLAG,
16 | TreeCommentFlag)
17 | from django_comments_tree.tests.factories import (ArticleFactory,
18 | UserFactory,
19 | TreeCommentFactory,
20 | TreeCommentFlagFactory)
21 |
22 |
23 | class ManagerTestBase(DjangoTestCase):
24 |
25 | @classmethod
26 | def setUpTestData(cls):
27 | cls.article_1 = ArticleFactory.create()
28 | cls.article_2 = ArticleFactory.create()
29 | cls.user1 = UserFactory.create()
30 | cls.user2 = UserFactory.create()
31 | cls.site = Site.objects.get(pk=1)
32 | cls.root_1 = TreeComment.objects.get_or_create_root(cls.article_1)
33 | cls.root_2 = TreeComment.objects.get_or_create_root(cls.article_2)
34 |
35 | cls.c1list = []
36 | cls.c2list = []
37 | for x in range(10):
38 | cls.c1list.append(cls.root_1.add_child(comment=f"Comment Root1 {x}"))
39 | cls.c2list.append(cls.root_2.add_child(comment=f"Comment Root2 {x}"))
40 |
41 | TreeCommentFlagFactory.create(user=cls.user1,
42 | comment=cls.c1list[0],
43 | flag=LIKEDIT_FLAG)
44 | TreeCommentFlagFactory.create(user=cls.user1,
45 | comment=cls.c1list[1],
46 | flag=DISLIKEDIT_FLAG)
47 | TreeCommentFlagFactory.create(user=cls.user1,
48 | comment=cls.c1list[2],
49 | flag=LIKEDIT_FLAG)
50 | TreeCommentFlagFactory.create(user=cls.user1,
51 | comment=cls.c1list[3],
52 | flag=DISLIKEDIT_FLAG)
53 | TreeCommentFlagFactory.create(user=cls.user1,
54 | comment=cls.c1list[7],
55 | flag=LIKEDIT_FLAG)
56 | TreeCommentFlagFactory.create(user=cls.user1,
57 | comment=cls.c1list[7],
58 | flag=TreeCommentFlag.SUGGEST_REMOVAL)
59 |
60 |
61 |
62 | class TestModelManager(ManagerTestBase):
63 |
64 | def test_user_likes(self):
65 |
66 | result = TreeComment.objects.user_flags_for_model(self.user1,
67 | self.article_1)
68 |
69 | self.assertIsNotNone(result)
70 |
71 | self.assertIn('user', result)
72 | likes = result['liked']
73 | dislikes = result['disliked']
74 | reported = result['reported']
75 | self.assertEqual(len(likes), 3)
76 | self.assertEqual(len(dislikes), 2)
77 | self.assertEqual(len(reported), 1)
78 | self.assertEqual(likes, [self.c1list[0].id, self.c1list[2].id, self.c1list[7].id])
79 |
--------------------------------------------------------------------------------
/django_comments_tree/tests/test_structured_data.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from textwrap import dedent
3 | from os.path import join, dirname
4 |
5 | from django.contrib.contenttypes.models import ContentType
6 | from django.contrib.sites.models import Site
7 | from django.conf import settings
8 | from django.test import TestCase as DjangoTestCase
9 |
10 | from django_comments_tree.models import (TreeComment, CommentAssociation,
11 | MaxThreadLevelExceededException)
12 | from django_comments_tree.tests.models import Article, Diary
13 |
14 |
15 | class ArticleBaseTestCase(DjangoTestCase):
16 | def setUp(self):
17 | self.article_1 = Article.objects.create(
18 | title="September", slug="september", body="During September...")
19 | self.article_2 = Article.objects.create(
20 | title="October", slug="october", body="What I did on October...")
21 |
22 |
23 | class StructuredDataBase(ArticleBaseTestCase):
24 | def setUp(self):
25 | super().setUp()
26 | self.article_ct = ContentType.objects.get(app_label="tests",
27 | model="article")
28 |
29 | self.site1 = Site.objects.get(pk=1)
30 | self.site2 = Site.objects.create(domain='site2.com', name='site2.com')
31 |
32 | self.root_1 = TreeComment.objects.get_or_create_root(self.article_1)
33 | self.root_1_pk = self.root_1.pk
34 |
35 | with open(join(dirname(__file__), 'data/draftjs_raw.json'), 'r') as fp:
36 | self.draft_raw = fp.read()
37 |
38 | def add_comments(self):
39 | """ Add top level comments """
40 | r = self.root_1
41 | r.add_child(comment="just a testing comment")
42 | r.add_child(comment="yet another comment")
43 | r.add_child(comment="and another one")
44 | r.add_child(comment="just a testing comment in site2")
45 |
46 | def add_replies(self):
47 | """ Add comment replies """
48 | children = self.root_1.get_children()
49 | for child in children:
50 | for x in range(3):
51 | child.add_child(comment=f"Reply {x} to {child.id}")
52 |
53 |
54 | class TestWithNoChildren(StructuredDataBase):
55 |
56 | def test_has_no_results(self):
57 | root = self.root_1
58 | qs = root.get_descendants().order_by('submit_date')
59 |
60 | self.assertEqual(qs.count(), 0)
61 |
62 | data = TreeComment.structured_tree_data_for_queryset(qs)
63 |
64 | self.assertIsNotNone(data)
65 | self.assertEqual(['comments'], list(data.keys()))
66 |
67 |
68 | class TestWithChildren(StructuredDataBase):
69 |
70 | def setUp(self):
71 | super().setUp()
72 | self.add_comments()
73 |
74 | def test_structured_result_with_children(self):
75 |
76 | root = self.root_1
77 | qs = root.get_descendants().order_by('submit_date')
78 |
79 | self.assertEqual(qs.count(), 4)
80 |
81 | data = TreeComment.structured_tree_data_for_queryset(qs)
82 |
83 | self.assertIsNotNone(data)
84 |
85 |
86 | class TestWithGrandchildren(StructuredDataBase):
87 |
88 | def setUp(self):
89 | super().setUp()
90 | self.add_comments()
91 | self.add_replies()
92 |
93 | def test_structured_result_with_children(self):
94 |
95 | root = self.root_1
96 | qs = root.get_descendants().order_by('submit_date')
97 |
98 | self.assertEqual(qs.count(), 16)
99 |
100 | data = TreeComment.structured_tree_data_for_queryset(qs)
101 |
102 | self.assertIsNotNone(data)
103 |
--------------------------------------------------------------------------------
24 | {% blocktrans count comment_count=comment_count %} 25 | There is {{ comment_count }} comment below. 26 | {% plural %} 27 | There are {{ comment_count }} comments below. 28 | {% endblocktrans %} 29 |
30 |31 | {% endif %} 32 | 33 | {% if object.allow_comments %} 34 |
Post your comment
36 |comments are disabled for this article
42 | {% endif %} 43 | 44 | {% if comment_count %} 45 |46 |