├── .coveragerc ├── .coveralls.yml ├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat ├── quickstart.rst ├── reference │ └── settings.rst ├── releases.rst └── topics │ ├── akismet.rst │ ├── captcha.rst │ ├── email.rst │ ├── form_layout.rst │ ├── gdpr.rst │ ├── ipaddress.rst │ ├── moderate.rst │ ├── templates.rst │ └── threaded_comments.rst ├── example ├── __init__.py ├── article │ ├── __init__.py │ ├── admin.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ ├── factories.py │ │ ├── test_admin.py │ │ ├── test_forms.py │ │ ├── test_moderation.py │ │ └── test_views.py │ ├── urls.py │ └── views.py ├── frontend │ ├── __init__.py │ ├── static │ │ └── vendor │ │ │ ├── bootstrap.css │ │ │ ├── bootstrap.scss │ │ │ └── jquery-2.2.0.min.js │ └── templates │ │ ├── article │ │ ├── details.html │ │ ├── list.html │ │ └── list_full.html │ │ ├── base.html │ │ └── comments │ │ └── base.html ├── manage.py ├── requirements.txt ├── settings.py └── urls.py ├── fluent_comments ├── __init__.py ├── admin.py ├── akismet.py ├── apps.py ├── appsettings.py ├── email.py ├── forms │ ├── __init__.py │ ├── _captcha.py │ ├── base.py │ ├── captcha.py │ ├── compact.py │ ├── default.py │ ├── helper.py │ └── recaptcha.py ├── locale │ ├── cs │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── nl │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── sk │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── zh │ │ └── LC_MESSAGES │ │ └── django.po ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── moderation.py ├── receivers.py ├── static │ └── fluent_comments │ │ ├── css │ │ └── ajaxcomments.css │ │ ├── img │ │ └── ajax-wait.gif │ │ └── js │ │ └── ajaxcomments.js ├── templates │ ├── comments │ │ ├── comment.html │ │ ├── comment_notification_email.html │ │ ├── comment_notification_email.txt │ │ ├── deleted.html │ │ ├── flagged.html │ │ ├── form.html │ │ ├── list.html │ │ ├── posted.html │ │ └── preview.html │ └── fluent_comments │ │ └── templatetags │ │ ├── ajax_comment_tags.html │ │ ├── flat_list.html │ │ └── threaded_list.html ├── templatetags │ ├── __init__.py │ └── fluent_comments_tags.py ├── tests │ ├── __init__.py │ ├── test_utils.py │ └── utils.py ├── urls.py ├── utils.py └── views.py ├── pyproject.toml ├── runtests.sh ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = fluent_comments* 3 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-pro 2 | repo_token: NYefOmqPlep2cS55RbgiFrLekn2y6JYpW 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.mo 4 | *.db 5 | *.egg-info/ 6 | .project 7 | .idea/ 8 | .pydevproject 9 | .idea/workspace.xml 10 | .DS_Store 11 | build/ 12 | dist/ 13 | docs/_build/ 14 | .tox/ 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | cache: pip 4 | python: 5 | - '3.8' 6 | env: 7 | - PACKAGES="Django~=2.2" 8 | - PACKAGES="Django~=2.2 django-threadedcomments>=1.2" 9 | - PACKAGES="Django~=3.0" 10 | - PACKAGES="Django~=3.0 django-threadedcomments>=1.2" 11 | - PACKAGES="Django~=3.1" 12 | - PACKAGES="Django~=3.1 django-threadedcomments>=1.2" 13 | - PACKAGES="Django~=3.2" 14 | - PACKAGES="Django~=3.2 django-threadedcomments>=1.2" 15 | - PACKAGES='https://github.com/django/django/archive/master.tar.gz' 16 | matrix: 17 | allow_failures: 18 | - env: PACKAGES='https://github.com/django/django/archive/master.tar.gz' 19 | before_install: 20 | - pip install codecov 21 | install: 22 | - pip install -U pip wheel 23 | - pip install $PACKAGES -e . 24 | script: 25 | - coverage run example/manage.py test 26 | after_success: 27 | - codecov 28 | notifications: 29 | irc: 30 | channels: 31 | - irc.freenode.org#django-fluent 32 | template: 33 | - '%{repository}#%{build_number} (%{commit}) %{message} -- %{build_url}' 34 | skip_join: true 35 | email: 36 | recipients: 37 | - travis@edoburu.nl 38 | on_success: never 39 | on_failure: always 40 | slack: 41 | secure: UQYHU07XN2kSAea1wFMMm9OsDJdavodhQsE8oT3SjxdXCbfBe/M1HXE8lbY2sZetWNiyQrZfodICDxhquQsNnUQ9IwMiSkWyofdT7rFA/2txPtf8bCnVMb0j0qJL+eKhFLBdtAEhi3PmuD2DqxcQxyz81enwtCk3vq8hzBEZu4I= 42 | on_success: never 43 | on_failure: always 44 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Original authors: 2 | 3 | - Diederik van der Boor (@vdboor) 4 | 5 | Contributions by: 6 | 7 | - Addy Yeow (@ayeowch) 8 | - Andy Kish (@Kobold) 9 | - Dmitry Kirillov (@kirill-the-terrible) 10 | - James Pic (@jpic) 11 | - Joshua Jonah (@joshuajonah) 12 | - Mickaël (@mickael9) 13 | - Petr Dlouhý (@PetrDlouhy) 14 | - Philippe Luickx (@philippeluickx) 15 | - Matej Badin (@matejbadin) 16 | - Roberto Cáceres (@rcaceres) 17 | - Ross Crawford-d'Heureuse (@rosscdh) 18 | - Tomas Babej (@tbabej) 19 | - @baffolobill 20 | - @bee-keeper 21 | - @rchrd2 22 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Version 3.0 (2021-05-11) 2 | ------------------------ 3 | 4 | * Added Django 3 compatibility. 5 | * Added HTML email support (``FLUENT_COMMENTS_MULTIPART_EMAILS = True`` setting) 6 | * Fix duplicated comment forms in threaded response. 7 | * Drop Django 1.8, 1.9 and 1.10 compatibility. 8 | * Drop Python 2 support. 9 | 10 | 11 | Version 2.1 (2018-08-27) 12 | ------------------------ 13 | 14 | * Make sure comment moderation is always active. 15 | 16 | * Added a "default moderator" for the models that are not registered via ``moderate_model()``. 17 | * The default moderator is configurable via ``FLUENT_COMMENTS_DEFAULT_MODERATOR``. 18 | * Spam filtering works, but "auto close/moderate after" support needs a registration via ``moderate_model()``. 19 | 20 | * Added simple captcha support. 21 | * Added "no captcha" reCAPTCHA2 support. 22 | * Add new default ``FLUENT_COMMENTS_AKISMET_ACTION=auto`` option that 23 | completely discards comments when Akismet classifies as definitive spam. 24 | * Fixed using ``force_text()`` to generate the content object title for email. 25 | * Fixed showing HTML in the comments admin. 26 | * Fixed showing the preview multiple times for threaded comments. 27 | * Included ``form.is_preview`` flag. 28 | 29 | 30 | Version 2.0.2 (2018-05-08) 31 | -------------------------- 32 | 33 | * Fixed comment moderation when django-threadedcomments_ was used. 34 | 35 | 36 | Version 2.0.1 (2018-05-04) 37 | -------------------------- 38 | 39 | * Fixed migration file. 40 | * Fixed missing Dutch translations. 41 | * Improved default form button labels. 42 | 43 | 44 | Version 2.0 (2018-01-22) 45 | ------------------------ 46 | 47 | * Added Django 2.0 support. 48 | * Dropped Django 1.5 / 1.6 / 1.7 support. 49 | * Dropped Python 2.6 support. 50 | * Dropped django.contrib.comments_ support. 51 | 52 | 53 | Version 1.4.3 (2017-08-16) 54 | -------------------------- 55 | 56 | * Fixed the IP-address reported in the email notification, 57 | the database records stored the actual correct value. 58 | * Fixed missing ``request`` variable in templates. 59 | * Fixed wrapping of the ``ThreadedComment`` model by the ``FluentComment`` proxy model too. 60 | 61 | 62 | Version 1.4.2 (2017-07-08) 63 | -------------------------- 64 | 65 | * Fixed Django 1.11 appearance of compact labels; e-mail and URL field didn't receive a placeholder anymore. 66 | * Fixed HTML position of the hidden ``parent`` field. 67 | * Enforce python-akismet_ >= 0.3 for Python 3 compatibility. 68 | 69 | 70 | Version 1.4.1 (2017-02-06) 71 | -------------------------- 72 | 73 | * Fixed compatibility with django_comments_ 1.8. 74 | 75 | 76 | Version 1.4 (2017-02-03) 77 | ------------------------ 78 | 79 | * Added ``fluent_comments.forms.CompactLabelsCommentForm`` style for ``FLUENT_COMMENTS_FORM_CLASS``. 80 | * Added ``FLUENT_COMMENTS_MODERATE_BAD_WORDS`` setting, to auto moderate on profanity or spammy words. 81 | * Added ``FLUENT_COMMENTS_AKISMET_ACTION = "soft_delete"`` to auto-remove spammy comments. This is now the new default too. 82 | * Exposed all form styles through ``fluent_comments.forms`` now. 83 | * Fixed ``is_superuser`` check in moderation. 84 | * Fixed ``blog_language`` parameter for Akismet. 85 | 86 | 87 | Version 1.3 (2017-01-02) 88 | ------------------------ 89 | 90 | * Added Akismet support for Python 3, via python-akismet_. 91 | * Added field reordering support, via the ``FLUENT_COMMENTS_FIELD_ORDER`` setting. 92 | * Added form class swapping, through the ``FLUENT_COMMENTS_FORM_CLASS`` setting. 93 | * Added new compact-form style, enable using:: 94 | 95 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.CompactCommentForm' 96 | FLUENT_COMMENTS_COMPACT_FIELDS = ('name', 'email', 'url') 97 | 98 | * Added template blocks to override ``comments/form.html`` via ``comments/app_name/app_label/form.html``. 99 | * Added support for ``app_name/app_label`` template overrides to our ``comments/comment.html`` template. 100 | 101 | 102 | Version 1.2.2 (2016-08-29) 103 | -------------------------- 104 | 105 | * Allow non-integer primary key 106 | * Added Slovak translation 107 | 108 | 109 | Version 1.2.1 (2016-05-23) 110 | -------------------------- 111 | 112 | * Fixed error handling in JavaScript when server reports an error. 113 | 114 | 115 | Version 1.2 (2015-02-03) 116 | ------------------------ 117 | 118 | * Fixed Django 1.9 support. 119 | 120 | 121 | Version 1.1 (2015-12-28) 122 | ------------------------ 123 | 124 | * Fix Django 1.9 issue with imports. 125 | * Fix error in the admin for non-existing objects. 126 | * Fix Python 3 installation error (dropped Akismet_ requirement). 127 | * Drop Django 1.4 compatibility (in the templates). 128 | 129 | 130 | Version 1.0.5 (2015-10-17) 131 | -------------------------- 132 | 133 | * Fix Django 1.9 issue with importing models in ``__init__.py``. 134 | * Fix django-threadedcomments_ 1.0.1 support 135 | 136 | 137 | Version 1.0.4 (2015-10-01) 138 | -------------------------- 139 | 140 | * Fixed ``get_comments_model()`` import. 141 | 142 | 143 | Version 1.0.3 (2015-09-01) 144 | -------------------------- 145 | 146 | * Fix support for ``TEMPLATE_STRING_IF_INVALID``, avoid parsing the "for" argument in ``{% ajax_comment_tags for object %}``. 147 | * Look for the correct ``#id_parent`` node (in case there are multiple) 148 | * Improve Bootstrap 3 appearance (template can be overwritten). 149 | 150 | Version 1.0.2 151 | ------------- 152 | 153 | * Fixed packaging bug 154 | 155 | Version 1.0.1 156 | ------------- 157 | 158 | * Fix app registry errors in Django 1.7 159 | * Fix security hash formatting errors on bad requests. 160 | 161 | Version 1.0.0 162 | ------------- 163 | 164 | * Added Django 1.8 support, can use either the django_comments_ or the django.contrib.comments_ package now. 165 | * Fixed Python 3 issue in the admin 166 | * Fixed unicode support in for subject of notification email 167 | 168 | Released as 1.0b1 169 | ~~~~~~~~~~~~~~~~~ 170 | 171 | * Fixed ajax-comment-busy check 172 | * Fixed clearing the whole container on adding comment 173 | 174 | Released as 1.0a2 175 | ~~~~~~~~~~~~~~~~~ 176 | 177 | * Fix installation at Python 2.6 178 | 179 | Released as 1.0a1 180 | ~~~~~~~~~~~~~~~~~ 181 | 182 | * Added support for Python 3 (with the exception of Akismet_ support). 183 | * Added support for multiple comment area's in the same page. 184 | 185 | **NOTE:** any custom templates need to be updated, to 186 | use the new ``id``, ``class`` and ``data-object-id`` attributes. 187 | 188 | 189 | Version 0.9.2 190 | ------------- 191 | 192 | * Fix errors in Ajax view, due to a ``json`` variable name conflict 193 | * Fix support for old jQuery and new jQuery (.on vs .live) 194 | * Fix running the example project with Django 1.5 195 | * Fix error messages in ``post_comment_ajax`` view. 196 | * Fix empty user name column in the admin list. 197 | * Fix undesired "reply" link in the preview while using django-threadedcomments_. 198 | * Fix HTML layout of newly added threaded comments. 199 | * Fix Python 3 support 200 | 201 | 202 | Version 0.9.1 203 | ------------- 204 | 205 | * Fix running at Django 1.6 alpha 1 206 | 207 | 208 | Version 0.9 209 | ----------- 210 | 211 | * Full support for django-threadedcomments_ out of the box. 212 | * Fix CSS class for primary submit button, is now ``.btn-primary``. 213 | 214 | 215 | Version 0.8.0 216 | ------------- 217 | 218 | First public release 219 | 220 | * Ajax-based preview and posting of comments 221 | * Configurable form layouts using django-crispy-forms_ and settings to exclude fields. 222 | * Comment moderation, using Akismet_ integration and auto-closing after N days. 223 | * E-mail notification to the site managers of new comments. 224 | * Rudimentary support for django-threadedcomments_ 225 | 226 | .. _Akismet: https://pypi.python.org/pypi/akismet 227 | .. _python-akismet: https://pypi.python.org/pypi/python-akismet 228 | .. _django_comments: https://github.com/django/django-contrib-comments 229 | .. _django.contrib.comments: https://docs.djangoproject.com/en/1.7/ref/contrib/comments/ 230 | .. _django-crispy-forms: http://django-crispy-forms.readthedocs.org 231 | .. _django-threadedcomments: https://github.com/HonzaKral/django-threadedcomments.git 232 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include README.rst 3 | include LICENSE 4 | recursive-include fluent_comments/locale *.po *.mo 5 | recursive-include fluent_comments/templates *.html *.txt 6 | recursive-include fluent_comments/static *.css *.gif *.js 7 | global-exclude .DS_Store 8 | global-exclude Thumbs.db 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-fluent-comments 2 | ====================== 3 | 4 | .. image:: https://travis-ci.org/django-fluent/django-fluent-comments.svg?branch=master 5 | :target: http://travis-ci.org/django-fluent/django-fluent-comments 6 | .. image:: https://img.shields.io/pypi/v/django-fluent-comments.svg 7 | :target: https://pypi.python.org/pypi/django-fluent-comments/ 8 | .. image:: https://img.shields.io/pypi/l/django-fluent-comments.svg 9 | :target: https://pypi.python.org/pypi/django-fluent-comments/ 10 | .. image:: https://img.shields.io/codecov/c/github/django-fluent/django-fluent-comments/master.svg 11 | :target: https://codecov.io/github/django-fluent/django-fluent-comments?branch=master 12 | 13 | The *django-fluent-comments* module enhances the default appearance of 14 | the django_comments_ application to be directly usable in web sites. 15 | 16 | Features: 17 | 18 | * Ajax-based preview and posting of comments 19 | * Configurable and flexible form layouts. 20 | * Comment moderation, with auto-closing / auto-moderation after N days. 21 | * E-mail notification to the site managers of new comments. 22 | * Optional threaded comments support via django-threadedcomments_. 23 | * Optional Akismet_ integration for spam detection. 24 | * Optional reCAPTCHA2 support via django-recaptcha_ or django-nocaptcha-recaptcha_. 25 | * Optional simple captcha support via django-simple-captcha_. 26 | 27 | The application is designed to be plug&play; 28 | installing it should already give a better comment layout. 29 | 30 | Installation 31 | ============ 32 | 33 | First install the module and django_comments, preferably in a virtual environment:: 34 | 35 | pip install django-fluent-comments 36 | 37 | Configuration 38 | ------------- 39 | 40 | Please follow the documentation at https://django-fluent-comments.readthedocs.io/ 41 | 42 | 43 | Contributing 44 | ============ 45 | 46 | This module is designed to be generic, and easy to plug into your site. 47 | In case there is anything you didn't like about it, or think it's not 48 | flexible enough, please let us know. We'd love to improve it! 49 | 50 | If you have any other valuable contribution, suggestion or idea, 51 | please let us know as well because we will look into it. 52 | Pull requests are welcome too. :-) 53 | 54 | 55 | .. _django_comments: https://github.com/django/django-contrib-comments 56 | .. _django-crispy-forms: http://django-crispy-forms.readthedocs.org/ 57 | .. _django-nocaptcha-recaptcha: https://github.com/ImaginaryLandscape/django-nocaptcha-recaptcha 58 | .. _django-recaptcha: https://github.com/praekelt/django-recaptcha 59 | .. _django-simple-captcha: https://github.com/mbi/django-simple-captcha 60 | .. _django-threadedcomments: https://github.com/HonzaKral/django-threadedcomments.git 61 | .. _Akismet: http://akismet.com 62 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = django-fluent-comments 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = "django-fluent-comments" 23 | copyright = "2021, Diederik van der Boor" 24 | author = "Diederik van der Boor" 25 | 26 | # The short X.Y version 27 | version = "3.0" 28 | # The full version, including alpha/beta/rc tags 29 | release = "3.0" 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | "sphinx.ext.autodoc", 43 | "sphinx.ext.intersphinx", 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ["_templates"] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = ".rst" 54 | 55 | # The master toctree document. 56 | master_doc = "index" 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | # 61 | # This is also used if you do content translation via gettext catalogs. 62 | # Usually you set "language" from the command line for these cases. 63 | language = None 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path . 68 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = "sphinx" 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | # html_theme = 'alabaster' 80 | 81 | # Theme options are theme-specific and customize the look and feel of a theme 82 | # further. For a list of options available for each theme, see the 83 | # documentation. 84 | # 85 | # html_theme_options = {} 86 | 87 | # Add any paths that contain custom static files (such as style sheets) here, 88 | # relative to this directory. They are copied after the builtin static files, 89 | # so a file named "default.css" will overwrite the builtin "default.css". 90 | html_static_path = ["_static"] 91 | 92 | # Custom sidebar templates, must be a dictionary that maps document names 93 | # to template names. 94 | # 95 | # The default sidebars (for documents that don't match any pattern) are 96 | # defined by theme itself. Builtin themes are using these templates by 97 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 98 | # 'searchbox.html']``. 99 | # 100 | # html_sidebars = {} 101 | 102 | 103 | # -- Options for HTMLHelp output --------------------------------------------- 104 | 105 | # Output file base name for HTML help builder. 106 | htmlhelp_basename = "django-fluent-commentsdoc" 107 | 108 | 109 | # -- Options for LaTeX output ------------------------------------------------ 110 | 111 | latex_elements = { 112 | # The paper size ('letterpaper' or 'a4paper'). 113 | # 114 | # 'papersize': 'letterpaper', 115 | # The font size ('10pt', '11pt' or '12pt'). 116 | # 117 | # 'pointsize': '10pt', 118 | # Additional stuff for the LaTeX preamble. 119 | # 120 | # 'preamble': '', 121 | # Latex figure (float) alignment 122 | # 123 | # 'figure_align': 'htbp', 124 | } 125 | 126 | # Grouping the document tree into LaTeX files. List of tuples 127 | # (source start file, target name, title, 128 | # author, documentclass [howto, manual, or own class]). 129 | latex_documents = [ 130 | ( 131 | master_doc, 132 | "django-fluent-comments.tex", 133 | "django-fluent-comments Documentation", 134 | "Diederik van der Boor", 135 | "manual", 136 | ), 137 | ] 138 | 139 | 140 | # -- Options for manual page output ------------------------------------------ 141 | 142 | # One entry per manual page. List of tuples 143 | # (source start file, name, description, authors, manual section). 144 | man_pages = [ 145 | (master_doc, "django-fluent-comments", "django-fluent-comments Documentation", [author], 1) 146 | ] 147 | 148 | 149 | # -- Options for Texinfo output ---------------------------------------------- 150 | 151 | # Grouping the document tree into Texinfo files. List of tuples 152 | # (source start file, target name, title, author, 153 | # dir menu entry, description, category) 154 | texinfo_documents = [ 155 | ( 156 | master_doc, 157 | "django-fluent-comments", 158 | "django-fluent-comments Documentation", 159 | author, 160 | "django-fluent-comments", 161 | "One line description of project.", 162 | "Miscellaneous", 163 | ), 164 | ] 165 | 166 | 167 | # -- Extension configuration ------------------------------------------------- 168 | 169 | # -- Options for intersphinx extension --------------------------------------- 170 | 171 | # Example configuration for intersphinx: refer to the Python standard library. 172 | intersphinx_mapping = {"https://docs.python.org/": None} 173 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-fluent-comments documentation master file, created by 2 | sphinx-quickstart on Mon Jun 4 23:21:38 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-fluent-comments's documentation! 7 | ================================================== 8 | 9 | The *django-fluent-comments* module enhances the default appearance of 10 | the django_comments_ application to be directly usable in web sites. 11 | 12 | Features: 13 | 14 | * Ajax-based preview and posting of comments 15 | * Configurable and flexible form layouts. 16 | * Comment moderation, with auto-closing / auto-moderation after N days. 17 | * E-mail notification to the site managers of new comments. 18 | * Optional threaded comments support via django-threadedcomments_. 19 | * Optional Akismet_ integration for spam detection. 20 | * Optional reCAPTCHA2 support via django-recaptcha_ or django-nocaptcha-recaptcha_. 21 | * Optional simple captcha support via django-simple-captcha_. 22 | 23 | The application is designed to be plug&play; 24 | installing it should already give a better comment layout. 25 | 26 | .. toctree:: 27 | :maxdepth: 1 28 | :caption: Contents: 29 | 30 | quickstart 31 | topics/form_layout 32 | topics/templates 33 | topics/captcha 34 | topics/akismet 35 | topics/email 36 | topics/moderate 37 | topics/threaded_comments 38 | topics/ipaddress 39 | topics/gdpr 40 | reference/settings 41 | releases 42 | 43 | 44 | Indices and tables 45 | ================== 46 | 47 | * :ref:`genindex` 48 | * :ref:`modindex` 49 | * :ref:`search` 50 | 51 | .. _django_comments: https://github.com/django/django-contrib-comments 52 | .. _django-crispy-forms: http://django-crispy-forms.readthedocs.org/ 53 | .. _django-nocaptcha-recaptcha: https://github.com/ImaginaryLandscape/django-nocaptcha-recaptcha 54 | .. _django-recaptcha: https://github.com/praekelt/django-recaptcha 55 | .. _django-simple-captcha: https://github.com/mbi/django-simple-captcha 56 | .. _django-threadedcomments: https://github.com/HonzaKral/django-threadedcomments.git 57 | .. _Akismet: http://akismet.com 58 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=django-fluent-comments 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | First install the module and django_comments, preferably in a virtual environment:: 5 | 6 | pip install django-fluent-comments 7 | 8 | Configuration 9 | ------------- 10 | 11 | To use comments, the following settings are required: 12 | 13 | .. code-block:: python 14 | 15 | INSTALLED_APPS += ( 16 | 'fluent_comments', # must be before django_comments 17 | 'crispy_forms', 18 | 'django_comments', 19 | 'django.contrib.sites', 20 | ) 21 | 22 | CRISPY_TEMPLATE_PACK = 'bootstrap3' 23 | 24 | COMMENTS_APP = 'fluent_comments' 25 | 26 | Add the following in ``urls.py``: 27 | 28 | .. code-block:: python 29 | 30 | urlpatterns += patterns('', 31 | url(r'^blog/comments/', include('fluent_comments.urls')), 32 | ) 33 | 34 | The database can be created afterwards: 35 | 36 | .. code-block:: bash 37 | 38 | ./manage.py migrate 39 | 40 | Usage in the page 41 | ----------------- 42 | 43 | Provide a template that displays the comments for the ``object`` and includes the required static files: 44 | 45 | .. code-block:: html+django 46 | 47 | {% load comments static %} 48 | 49 | 50 | 51 | 52 | {% render_comment_list for object %} 53 | {% render_comment_form for object %} 54 | 55 | .. note:: 56 | 57 | When using the comment module via django-fluent-contents_ or django-fluent-blogs_, 58 | this step can be omitted. 59 | 60 | Template for non-ajax pages 61 | --------------------------- 62 | 63 | The templates which django_comments_ renders use a single base template for all layouts. 64 | This template is empty by default since it's only serves as a placeholder. 65 | To complete the configuration of the comments module, create a ``comments/base.html`` file 66 | that maps the template blocks onto your website base template. For example: 67 | 68 | .. code-block:: html+django 69 | 70 | {% extends "mysite/base.html" %}{% load i18n %} 71 | 72 | {% block meta-title %}{% block title %}{% trans "Responses for page" %}{% endblock %}{% endblock %} 73 | 74 | {% block main %} 75 |
76 | {% block content %}{% endblock %} 77 |
78 | {% endblock %} 79 | 80 | In this example, the base template has a ``meta-title`` and ``main`` block, 81 | which contain the ``content`` and ``title`` blocks that django_comments_ needs to see. 82 | This application also outputs an ``extrahead`` block for a meta-refresh tag. 83 | The ``extrahead`` block can be included in the site base template directly, 84 | so it doesn't have to be included in the ``comments/base.html`` file. 85 | 86 | .. _django_comments: https://github.com/django/django-contrib-comments 87 | .. _django-fluent-blogs: https://github.com/django-fluent/django-fluent-blogs 88 | .. _django-fluent-contents: https://github.com/django-fluent/django-fluent-contents 89 | -------------------------------------------------------------------------------- /docs/reference/settings.rst: -------------------------------------------------------------------------------- 1 | Configuration reference 2 | ======================= 3 | 4 | The default settings are: 5 | 6 | .. code-block:: python 7 | 8 | AKISMET_API_KEY = None 9 | AKISMET_BLOG_URL = None 10 | AKISMET_IS_TEST = False 11 | 12 | CRISPY_TEMPLATE_PACK = 'bootstrap' 13 | 14 | FLUENT_COMMENTS_REPLACE_ADMIN = True 15 | 16 | # Akismet spam fighting 17 | FLUENT_CONTENTS_USE_AKISMET = bool(AKISMET_API_KEY) 18 | FLUENT_COMMENTS_AKISMET_ACTION = 'soft_delete' 19 | 20 | # Moderation 21 | FLUENT_COMMENTS_DEFAULT_MODERATOR = 'default' 22 | FLUENT_COMMENTS_CLOSE_AFTER_DAYS = None 23 | FLUENT_COMMENTS_MODERATE_BAD_WORDS = () 24 | FLUENT_COMMENTS_MODERATE_AFTER_DAYS = None 25 | FLUENT_COMMENTS_USE_EMAIL_NOTIFICATION = True 26 | FLUENT_COMMENTS_MULTIPART_EMAILS = False 27 | 28 | # Form layouts 29 | FLUENT_COMMENTS_FIELD_ORDER = () 30 | FLUENT_COMMENTS_EXCLUDE_FIELDS = () 31 | FLUENT_COMMENTS_FORM_CLASS = None 32 | FLUENT_COMMENTS_FORM_CSS_CLASS = 'comments-form form-horizontal' 33 | FLUENT_COMMENTS_LABEL_CSS_CLASS = 'col-sm-2' 34 | FLUENT_COMMENTS_FIELD_CSS_CLASS = 'col-sm-10' 35 | 36 | # Compact style settings 37 | FLUENT_COMMENTS_COMPACT_FIELDS = ('name', 'email', 'url') 38 | FLUENT_COMMENTS_COMPACT_GRID_SIZE = 12 39 | FLUENT_COMMENTS_COMPACT_COLUMN_CSS_CLASS = "col-sm-{size}" 40 | 41 | 42 | .. _FLUENT_COMMENTS_FORM_CLASS: 43 | 44 | FLUENT_COMMENTS_FORM_CLASS 45 | -------------------------- 46 | 47 | Defines a dotted Python path to the form class to use. 48 | The built-in options include: 49 | 50 | Standard forms: 51 | 52 | * ``fluent_comments.forms.DefaultCommentForm`` The standard form. 53 | * ``fluent_comments.forms.CompactLabelsCommentForm`` A form where labels are hidden. 54 | * ``fluent_comments.forms.CompactCommentForm`` A compact row 55 | 56 | Variations with reCAPTCHA v2: 57 | 58 | * ``fluent_comments.forms.recaptcha.DefaultCommentForm`` 59 | * ``fluent_comments.forms.recaptcha.CompactLabelsCommentForm`` 60 | * ``fluent_comments.forms.recaptcha.CompactCommentForm`` 61 | 62 | Variations wiwth a simple self-hosted captcha: 63 | 64 | * ``fluent_comments.forms.captcha.DefaultCommentForm`` 65 | * ``fluent_comments.forms.captcha.CompactLabelsCommentForm`` 66 | * ``fluent_comments.forms.captcha.CompactCommentForm`` 67 | 68 | 69 | .. _FLUENT_COMMENTS_AKISMET_ACTION: 70 | 71 | FLUENT_COMMENTS_AKISMET_ACTION 72 | ------------------------------ 73 | 74 | What to do when spam is detected, see :ref:`akismet_usage`. 75 | 76 | 77 | .. _FLUENT_COMMENTS_FIELD_ORDER: 78 | 79 | FLUENT_COMMENTS_FIELD_ORDER 80 | --------------------------- 81 | 82 | Defines the field ordering, see :ref:`field-order`. 83 | -------------------------------------------------------------------------------- /docs/releases.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | .. include:: ../CHANGES.rst 5 | -------------------------------------------------------------------------------- /docs/topics/akismet.rst: -------------------------------------------------------------------------------- 1 | .. _akismet_usage: 2 | 3 | Akismet spam detection 4 | ====================== 5 | 6 | Akismet_ is used out of the box when the ``AKISMET_API_KEY`` setting is defined: 7 | 8 | .. code-block:: python 9 | 10 | AKISMET_API_KEY = "your-api-key" 11 | 12 | This can also be enabled explicitly: 13 | 14 | .. code-block:: python 15 | 16 | FLUENT_CONTENTS_USE_AKISMET = True # Enabled by default when AKISMET_API_KEY is set. 17 | 18 | The following settings are optional: 19 | 20 | .. code-block:: python 21 | 22 | AKISMET_BLOG_URL = "http://example.com" # Optional, to override auto detection 23 | AKISMET_IS_TEST = False # Enable to make test runs 24 | 25 | When spam is detected, the default behavior depends on the spam score. 26 | Obvious spam is discarded with an HTTP 400 response, while possible spam is marked for moderation. 27 | 28 | The :ref:`FLUENT_COMMENTS_AKISMET_ACTION` setting can be one of these values: 29 | 30 | * ``auto`` chooses between ``moderate``, ``soft_delete`` and ``delete`` based on the spam score. 31 | * ``moderate`` will always mark the comment for moderation. 32 | * ``soft_delete`` will mark the comment as moderated + removed, but it can still be seen in the admin. 33 | * ``delete`` will outright reject posting the comment and respond with a HTTP 400 Bad Request. 34 | 35 | .. tip:: 36 | 37 | By default, Akismet will not report any post from the Django superuser as spam. 38 | Comments with the name "viagra-test-123" will always be flagged as spam. 39 | 40 | .. warning:: 41 | 42 | Akismet is a third party service by Automattic. 43 | Note that :doc:`GDPR Compliance ` is next-to-impossible with this service. 44 | 45 | .. _Akismet: http://akismet.com 46 | -------------------------------------------------------------------------------- /docs/topics/captcha.rst: -------------------------------------------------------------------------------- 1 | Captcha support 2 | =============== 3 | 4 | Users can be required to enter a captcha. 5 | 6 | This is done by changing the :ref:`FLUENT_COMMENTS_FORM_CLASS` setting. 7 | 8 | .. note:: 9 | 10 | When :ref:`FLUENT_COMMENTS_FIELD_ORDER` is configured, also include the ``"captcha"`` field! 11 | 12 | Using django-recaptcha 13 | ---------------------- 14 | 15 | django-recaptcha_ provides "no captcha" reCAPTCHA v2 support. 16 | Choose one of the form layout classes: 17 | 18 | .. code-block:: python 19 | 20 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.recaptcha.DefaultCommentForm' # default 21 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.recaptcha.CompactLabelsCommentForm' # no labels 22 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.recaptcha.CompactCommentForm' # compact row 23 | 24 | And configure it's settings: 25 | 26 | .. code-block:: python 27 | 28 | RECAPTCHA_PUBLIC_KEY = "the Google provided site_key" 29 | RECAPTCHA_PRIVATE_KEY = "the Google provided secret_key" 30 | 31 | NOCAPTCHA = True # Important! Required to get "no captcha" reCAPTCHA v2 32 | 33 | INSTALLED_APPS += ( 34 | 'captcha', 35 | ) 36 | 37 | Using django-nocaptcha-recaptcha 38 | --------------------------------- 39 | 40 | django-nocaptcha-recaptcha_ also provides "no captcha" reCAPTCHA v2 support. 41 | The same form classes are used, as the correct imports are detected at startup: 42 | 43 | .. code-block:: python 44 | 45 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.recaptcha.DefaultCommentForm' # default 46 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.recaptcha.CompactLabelsCommentForm' # no labels 47 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.recaptcha.CompactCommentForm' # compact row 48 | 49 | It's settings differ slightly: 50 | 51 | .. code-block:: python 52 | 53 | NORECAPTCHA_SITE_KEY = "the Google provided site_key" 54 | NORECAPTCHA_SECRET_KEY = "the Google provided secret_key" 55 | 56 | INSTALLED_APPS += ( 57 | 'nocaptcha_recaptcha', 58 | ) 59 | 60 | Using django-simple-captcha 61 | --------------------------- 62 | 63 | django-simple-captcha_ provides a simple local captcha test. 64 | It does not require external services, but it can be easier to break. 65 | 66 | .. code-block:: python 67 | 68 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.captcha.DefaultCommentForm' # default 69 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.captcha.CompactLabelsCommentForm' # no labels 70 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.captcha.CompactCommentForm' # compact row 71 | 72 | And configure the app: 73 | 74 | .. code-block:: python 75 | 76 | CAPTCHA_NOISE_FUNCTIONS = () 77 | CAPTCHA_FONT_SIZE = 30 78 | CAPTCHA_LETTER_ROTATION = (-10,10) 79 | 80 | INSTALLED_APPS += ( 81 | 'captcha', 82 | ) 83 | 84 | .. warning:: 85 | 86 | Note that both django-simple-captcha_ and django-recaptcha_ use the same "captcha" module name. 87 | These packages can't be installed together. 88 | 89 | 90 | .. _django-nocaptcha-recaptcha: https://github.com/ImaginaryLandscape/django-nocaptcha-recaptcha 91 | .. _django-recaptcha: https://github.com/praekelt/django-recaptcha 92 | .. _django-simple-captcha: https://github.com/mbi/django-simple-captcha 93 | -------------------------------------------------------------------------------- /docs/topics/email.rst: -------------------------------------------------------------------------------- 1 | E-mail notification 2 | =================== 3 | 4 | By default, the ``MANAGERS`` of a Django site will receive an e-mail notification of new comments. 5 | This feature can be enabled or disabled using: 6 | 7 | .. code-block:: python 8 | 9 | FLUENT_COMMENTS_USE_EMAIL_NOTIFICATION = False 10 | 11 | By default, plain-text e-mail messages are generated using the template ``comments/comment_notification_email.txt``. 12 | 13 | Multi-part (HTML) e-mails are supported using the template ``comments/comment_notification_email.html``. To enabled 14 | multi-part e-mails, set: 15 | 16 | .. code-block:: python 17 | 18 | FLUENT_COMMENTS_MULTIPART_EMAILS = True 19 | 20 | In addition to the standard django-comments_ package, the ``request`` and ``site`` fields 21 | are available in the template context data. This allows generating absolute URLs to the site. 22 | 23 | .. _django-comments: https://github.com/django/django-contrib-comments 24 | -------------------------------------------------------------------------------- /docs/topics/form_layout.rst: -------------------------------------------------------------------------------- 1 | Changing the form layout 2 | ======================== 3 | 4 | Form layouts generally differ across web sites, hence this application doesn't dictate a specific form layout. 5 | Instead, this application uses django-crispy-forms_ which allows configuration of the form appearance. 6 | 7 | The defaults are set to Bootstrap 3 layouts, but can be changed. 8 | 9 | For example, use: 10 | 11 | .. code-block:: python 12 | 13 | CRISPY_TEMPLATE_PACK = 'bootstrap4' 14 | 15 | 16 | Using a different form class 17 | ---------------------------- 18 | 19 | By choosing a different form class, the form layout can be redefined at once: 20 | 21 | The default is: 22 | 23 | .. code-block:: python 24 | 25 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.DefaultCommentForm' 26 | 27 | FLUENT_COMMENTS_FORM_CSS_CLASS = 'comments-form form-horizontal' 28 | FLUENT_COMMENTS_LABEL_CSS_CLASS = 'col-sm-2' 29 | FLUENT_COMMENTS_FIELD_CSS_CLASS = 'col-sm-10' 30 | 31 | You can replace the labels with placeholders using: 32 | 33 | .. code-block:: python 34 | 35 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.CompactLabelsCommentForm' 36 | 37 | Or place some fields at a single row: 38 | 39 | .. code-block:: python 40 | 41 | FLUENT_COMMENTS_FORM_CLASS = 'fluent_comments.forms.CompactCommentForm' 42 | 43 | # Optional settings for the compact style: 44 | FLUENT_COMMENTS_COMPACT_FIELDS = ('name', 'email', 'url') 45 | FLUENT_COMMENTS_COMPACT_GRID_SIZE = 12 46 | FLUENT_COMMENTS_COMPACT_COLUMN_CSS_CLASS = "col-sm-{size}" 47 | 48 | .. _field-order: 49 | 50 | Changing the field order 51 | ------------------------ 52 | 53 | The default is: 54 | 55 | .. code-block:: python 56 | 57 | FLUENT_COMMENTS_FIELD_ORDER = ('name', 'email', 'url', 'comment') 58 | 59 | For a more modern look, consider placing the comment first: 60 | 61 | .. code-block:: python 62 | 63 | FLUENT_COMMENTS_FIELD_ORDER = ('comment', 'name', 'email', 'url') 64 | 65 | 66 | Hiding form fields 67 | ------------------ 68 | 69 | Form fields can be hidden using the following settings: 70 | 71 | .. code-block:: python 72 | 73 | FLUENT_COMMENTS_EXCLUDE_FIELDS = ('name', 'email', 'url') 74 | 75 | When :doc:`django-threadedcomments ` are used, the ``title`` field can also be removed. 76 | 77 | .. note:: 78 | 79 | Omitting fields from ``FLUENT_COMMENTS_FIELD_ORDER`` has the same effect. 80 | 81 | 82 | Using a custom form class 83 | ------------------------- 84 | 85 | When the settings above don't provide the layout you need, 86 | you can define a custom form class entirely: 87 | 88 | .. code-block:: python 89 | 90 | from fluent_comments.forms import CompactLabelsCommentForm 91 | 92 | # Or for recaptcha as base, import: 93 | from fluent_comments.forms.recaptcha import CompactCommentForm 94 | 95 | 96 | class CommentForm(CompactLabelsCommentForm): 97 | """ 98 | The comment form to use 99 | """ 100 | 101 | def __init__(self, *args, **kwargs): 102 | super().__init__(*args, **kwargs) 103 | self.fields['url'].label = _("Website") # Changed the label 104 | self.fields['email'].label = _("Email address (will not be published)") 105 | 106 | And use that class in the ``FLUENT_COMMENTS_FORM_CLASS`` setting. 107 | The ``helper`` attribute defines how the layout is constructed by django-crispy-forms_, 108 | and should be redefined the change the field ordering or appearance. 109 | 110 | 111 | Switching form templates 112 | ------------------------ 113 | 114 | By default, the forms can be rendered with 2 well known CSS frameworks: 115 | 116 | * `Bootstrap`_ The default template pack. The popular simple and flexible HTML, CSS, and Javascript for user interfaces from Twitter. 117 | * `Uni-form`_ Nice looking, well structured, highly customizable, accessible and usable forms. 118 | 119 | The ``CRISPY_TEMPLATE_PACK`` setting can be used to switch between both layouts. 120 | For more information, see the django-crispy-forms_ documentation. 121 | 122 | Both CSS frameworks have a wide range of themes available, which should give a good head-start to have a good form layout. 123 | In fact, we would encourage to adopt django-crispy-forms_ for all your applications to have a consistent layout across all your Django forms. 124 | 125 | If your form CSS framework is not supported, you can create a template pack 126 | for it and submit a pull request to the django-crispy-forms_ authors for inclusion. 127 | 128 | 129 | 130 | .. _`Bootstrap`: http://twitter.github.com/bootstrap/index.html 131 | .. _`Uni-form`: http://sprawsm.com/uni-form 132 | 133 | 134 | .. _django-crispy-forms: http://django-crispy-forms.readthedocs.org/ 135 | -------------------------------------------------------------------------------- /docs/topics/gdpr.rst: -------------------------------------------------------------------------------- 1 | Privacy concerns (GDPR) 2 | ======================= 3 | 4 | Comment support needs to consider the General Data Protection Regulation (GDPR) 5 | when when you serve European customers. Any personal data (email address, IP-address) 6 | should only be stored as long as this is truely needed, and it must be clear whom it's shared with. 7 | 8 | .. tip:: 9 | 10 | For a simple introduction, see https://premium.wpmudev.org/blog/gdpr-compliance/ 11 | 12 | The Django comments model also stores the email address and IP-address of the commenter, 13 | which counts as personal information a user should give consent for. Consider running 14 | a background task that removes the IP-address or email address after a certain period. 15 | 16 | Concerns for third-party services 17 | --------------------------------- 18 | 19 | When using :doc:`Akismet `, the comment data and IP-address is passed to the servers of Akismet_. 20 | 21 | In case you update templates to display user avatars using Gravatar_, this this also 22 | provides privacy-sensitive information to a third party. Gravatar acts like a tracking-pixel, 23 | noticing every place you visit. It also makes your user's email address public. 24 | While the URL field is encoded as MD5, Gravatar doesn't use salted hashes so the 25 | data can be easily reverse engineered back to real user accounts. 26 | 27 | .. seealso:: 28 | 29 | For more information, read: 30 | 31 | * https://meta.stackexchange.com/questions/21117/is-using-gravatar-a-security-risk 32 | * https://webapps.stackexchange.com/questions/9973/is-it-safe-to-use-gravatar/30605#30605 33 | * http://onemansblog.com/2007/02/02/protect-your-privacy-delete-internet-usage-tracks/comment-page-1/#comment-46204 34 | * https://www.wordfence.com/blog/2016/12/gravatar-advisory-protect-email-address-identity/ 35 | 36 | .. _Akismet: https://akismet.com 37 | .. _Gravatar: https://gravatar.com 38 | -------------------------------------------------------------------------------- /docs/topics/ipaddress.rst: -------------------------------------------------------------------------------- 1 | IP-Address detection 2 | ==================== 3 | 4 | This package stores the remote IP of the visitor in the model, 5 | and passes it to :doc:`Akismet ` for spam detection. 6 | The IP Address is read from the ``REMOTE_ADDR`` meta field. 7 | In case your site is behind a HTTP proxy (e.g. using Gunicorn or a load balancer), 8 | this would make all comments appear to be posted from the load balancer IP. 9 | 10 | The best and most secure way to fix this, is using WsgiUnproxy_ middleware in your ``wsgi.py``: 11 | 12 | .. code-block:: python 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | from django.conf import settings 16 | from wsgiunproxy import unproxy 17 | 18 | application = get_wsgi_application() 19 | application = unproxy(trusted_proxies=settings.TRUSTED_X_FORWARDED_FOR_IPS)(application) 20 | 21 | In your ``settings.py``, you can define which hosts may pass the ``X-Forwarded-For`` 22 | header in the HTTP request. For example: 23 | 24 | .. code-block:: python 25 | 26 | TRUSTED_X_FORWARDED_FOR_IPS = ( 27 | '11.22.33.44', 28 | '192.168.0.1', 29 | ) 30 | 31 | .. warning:: 32 | 33 | Please don't try to read ``HTTP_X_FORWARDED_FOR`` blindly with a fallback to ``HTTP_REMOTE_ADDR``. 34 | These headers could be provided by hackers, effectively circumventing your IP-address checks. 35 | Use WsgiUnproxy_ instead, which protects against maliciously injected headers. 36 | 37 | Amazon Web Services Support 38 | --------------------------- 39 | 40 | For AWS hosting, there is also `wsgi-aws-unproxy`_ 41 | which does the same for all CloudFront IP addresses. 42 | 43 | IP-Subnet filtering 44 | ------------------- 45 | 46 | Use the ``netaddr`` package to trust a full IP-block, e.g. for Kubernetes Ingress: 47 | 48 | .. code-block:: python 49 | 50 | from django.core.wsgi import get_wsgi_application 51 | from netaddr import IPNetwork 52 | from wsgiunproxy import unproxy 53 | 54 | application = get_wsgi_application() 55 | application = unproxy(trusted_proxies=IPNetwork('10.0.0.0/8'))(application) 56 | 57 | .. _Akismet: http://akismet.com 58 | .. _WsgiUnproxy: https://pypi.python.org/pypi/WsgiUnproxy 59 | .. _wsgi-aws-unproxy: https://github.com/LabD/wsgi-aws-unproxy 60 | -------------------------------------------------------------------------------- /docs/topics/moderate.rst: -------------------------------------------------------------------------------- 1 | Auto comment moderation 2 | ======================= 3 | 4 | By default, any comment receives moderation from the "default moderator" 5 | This ensures random comments also receive :doc:`akismet ` checks, 6 | bad word filtering and send :doc:`email notifications `. 7 | 8 | Some moderation features require more knowledge of the model. 9 | This includes: 10 | 11 | * Toggling an "enable comments" checkbox on the model. 12 | * Auto-closing comments after X days since the publication of the article. 13 | * Auto-moderating comments after X days since the publication of the article. 14 | 15 | Comment moderation can be enabled for the specific models using: 16 | 17 | .. code-block:: python 18 | 19 | from fluent_comments.moderation import moderate_model 20 | from myblog.models import BlogPost 21 | 22 | moderate_model(BlogPost, 23 | publication_date_field='publication_date', 24 | enable_comments_field='enable_comments', 25 | ) 26 | 27 | This code can be placed in a ``models.py`` file. 28 | The provided field names are optional. By providing the field names, 29 | the comments can be auto-moderated or auto-closed after a number of days since the publication date. 30 | 31 | The following settings are available for comment moderation: 32 | 33 | .. code-block:: python 34 | 35 | FLUENT_COMMENTS_CLOSE_AFTER_DAYS = None # Auto-close comments after N days 36 | FLUENT_COMMENTS_MODERATE_AFTER_DAYS = None # Auto-moderate comments after N days. 37 | 38 | The default moderator 39 | --------------------- 40 | 41 | The default moderator is configurable using: 42 | 43 | .. code-block:: python 44 | 45 | FLUENT_COMMENTS_DEFAULT_MODERATOR = 'default' 46 | 47 | Possible values are: 48 | 49 | * ``default`` installs the standard moderator that all packages use. 50 | * ``deny`` will reject all comments placed on models which don't have an explicit moderator registered with ``moderate_model()``. 51 | * ``None`` will accept all comments, as if there is no default moderator installed. 52 | * A dotted Python path will import this class. 53 | 54 | When using a custom moderator class, consider inheriting 55 | ``fluent_comments.moderation.FluentCommentsModerator`` 56 | to preserve the email notification feature. 57 | -------------------------------------------------------------------------------- /docs/topics/templates.rst: -------------------------------------------------------------------------------- 1 | Using custom comment templates 2 | ============================== 3 | 4 | Besides the standard templates of django-comments_, this package provides 5 | a ``comments/comment.html`` template to render a single comment. 6 | 7 | It's default looks like: 8 | 9 | .. code-block:: html+django 10 | 11 | {% load i18n %} 12 | 13 | {% block comment_item %} 14 | {% if preview %}

{% trans "Preview of your comment" %}

{% endif %} 15 |

16 | {% block comment_title %} 17 | {% if comment.url %}{% endif %} 18 | {% if comment.name %}{{ comment.name }}{% else %}{% trans "Anonymous" %}{% endif %}{% comment %} 19 | {% endcomment %}{% if comment.url %}{% endif %} 20 | {% blocktrans with submit_date=comment.submit_date %}on {{ submit_date }}{% endblocktrans %} 21 | {% if not comment.is_public %}({% trans "moderated" %}){% endif %} 22 | {% if USE_THREADEDCOMMENTS and not preview %}{% trans "reply" %}{% endif %} 23 | {% endblock %} 24 |

25 | 26 |
{{ comment.comment|linebreaks }}
27 | {% endblock %} 28 | 29 | 30 | .. note:: 31 | 32 | The ``id="comment-preview"``, ``data-comment-id`` fields are required for proper JavaScript actions. 33 | The div id should be ``id="c{{ comment.id }}"``, because ``Comment.get_absolute_url()`` points to it. 34 | 35 | Adding a Bootstrap 4 layout, including Gravatar_ would look like: 36 | 37 | .. code-block:: html+django 38 | 39 | {% load i18n gravatar %} 40 | 41 |
42 | {% if preview %}

{% trans "Preview of your comment" %}

{% endif %} 43 |
44 | {% gravatar comment.email css_class='user-image' %} 45 |
46 |

47 | {% block comment_title %} 48 | {% if comment.url %}{% endif %} 49 | {% if comment.name %}{{ comment.name }}{% else %}{% trans "Anonymous" %}{% endif %}{% comment %} 50 | {% endcomment %}{% if comment.url %}{% endif %} 51 | {% if not comment.is_public %}({% trans "moderated" %}){% endif %} 52 | {% if comment.user_id and comment.user_id == comment.content_object.author_id %}[{% trans "author" %}]{% endif %} 53 | {% endblock %} 54 |

55 | 56 |
{{ comment.comment|linebreaks }}
57 |
58 | {% if USE_THREADEDCOMMENTS and not preview %}{% trans "reply" %}{% endif %} 59 | {{ comment.submit_date }} 60 |
61 |
62 |
63 |
64 | 65 | .. warning:: 66 | 67 | While extremely popular, Gravatar_ is a huge privacy risk, 68 | as it acts like a tracking-pixel for all your users. 69 | It also exposes email addresses as the MD5 hashes can be reverse engineerd. 70 | See the :doc:`GDPR ` notes for more information. 71 | 72 | Customize date time formatting 73 | ------------------------------ 74 | 75 | To override the displayed date format, the template doesn't have to be overwritten. 76 | Instead, define ``DATETIME_FORMAT`` in a locale file. Define the following setting: 77 | 78 | .. code-block:: python 79 | 80 | FORMAT_MODULE_PATH = 'settings.locale' 81 | 82 | Then create :samp:`settings/locale/{XY}/formats.py` with: 83 | 84 | .. code-block:: python 85 | 86 | DATETIME_FORMAT = '...' 87 | 88 | This should give you consistent dates across all views. 89 | 90 | 91 | .. _django-comments: https://github.com/django/django-contrib-comments 92 | .. _Gravatar: https://gravatar.com 93 | -------------------------------------------------------------------------------- /docs/topics/threaded_comments.rst: -------------------------------------------------------------------------------- 1 | Adding threaded comments 2 | ======================== 3 | 4 | This package has build-in support for django-threadedcomments_ in this module. 5 | It can be enabled using the following settings: 6 | 7 | .. code-block:: python 8 | 9 | INSTALLED_APPS += ( 10 | 'threadedcomments', 11 | ) 12 | 13 | COMMENTS_APP = 'fluent_comments' 14 | 15 | And make sure the intermediate ``ThreadedComment`` model is available and filled with data:: 16 | 17 | ./manage.py migrate 18 | ./manage.py migrate_comments 19 | 20 | The templates and admin interface adapt themselves automatically 21 | to show the threaded comments. 22 | 23 | .. _django-threadedcomments: https://github.com/HonzaKral/django-threadedcomments 24 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-fluent/django-fluent-comments/5d53578544b5b5ecd8c34719672fd43668fb2dd4/example/__init__.py -------------------------------------------------------------------------------- /example/article/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-fluent/django-fluent-comments/5d53578544b5b5ecd8c34719672fd43668fb2dd4/example/article/__init__.py -------------------------------------------------------------------------------- /example/article/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.forms import ModelForm 3 | from django.utils.timezone import now 4 | from article.models import Article 5 | 6 | 7 | class ArticleAdminForm(ModelForm): 8 | def __init__(self, *args, **kwargs): 9 | super().__init__(*args, **kwargs) 10 | self.fields[ 11 | "publication_date" 12 | ].required = False # The admin's .save() method fills in a default. 13 | 14 | 15 | class ArticleAdmin(admin.ModelAdmin): 16 | prepopulated_fields = {"slug": ("title",)} 17 | form = ArticleAdminForm 18 | 19 | fieldsets = ( 20 | ( 21 | None, 22 | {"fields": ("title", "slug")}, 23 | ), 24 | ( 25 | "Contents", 26 | {"fields": ("content",)}, 27 | ), 28 | ( 29 | "Publication settings", 30 | {"fields": ("publication_date", "enable_comments")}, 31 | ), 32 | ) 33 | 34 | def save_model(self, request, obj, form, change): 35 | if not obj.publication_date: 36 | # auto_now_add makes the field uneditable. 37 | # a default in the model fills the field before the post is written (too early) 38 | obj.publication_date = now() 39 | obj.save() 40 | 41 | 42 | admin.site.register(Article, ArticleAdmin) 43 | -------------------------------------------------------------------------------- /example/article/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.2 on 2021-05-11 10:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Article", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 20 | ), 21 | ), 22 | ("title", models.CharField(max_length=200, verbose_name="Title")), 23 | ("slug", models.SlugField(unique=True, verbose_name="Slug")), 24 | ("content", models.TextField(verbose_name="Content")), 25 | ("publication_date", models.DateTimeField(verbose_name="Publication date")), 26 | ( 27 | "enable_comments", 28 | models.BooleanField(default=True, verbose_name="Enable comments"), 29 | ), 30 | ], 31 | options={ 32 | "verbose_name": "Article", 33 | "verbose_name_plural": "Articles", 34 | }, 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /example/article/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-fluent/django-fluent-comments/5d53578544b5b5ecd8c34719672fd43668fb2dd4/example/article/migrations/__init__.py -------------------------------------------------------------------------------- /example/article/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | 4 | from fluent_comments.moderation import moderate_model, comments_are_open, comments_are_moderated 5 | from fluent_comments.models import get_comments_for_model, CommentsRelation 6 | 7 | 8 | class Article(models.Model): 9 | title = models.CharField("Title", max_length=200) 10 | slug = models.SlugField("Slug", unique=True) 11 | content = models.TextField("Content") 12 | 13 | publication_date = models.DateTimeField("Publication date") 14 | enable_comments = models.BooleanField("Enable comments", default=True) 15 | 16 | # Optional reverse relation, allow ORM querying: 17 | comments_set = CommentsRelation() 18 | 19 | class Meta: 20 | verbose_name = "Article" 21 | verbose_name_plural = "Articles" 22 | 23 | def __str__(self): 24 | return self.title 25 | 26 | def get_absolute_url(self): 27 | return reverse("article-details", kwargs={"slug": self.slug}) 28 | 29 | # Optional, give direct access to moderation info via the model: 30 | comments = property(get_comments_for_model) 31 | comments_are_open = property(comments_are_open) 32 | comments_are_moderated = property(comments_are_moderated) 33 | 34 | 35 | # Give the generic app support for moderation by django-fluent-comments: 36 | moderate_model( 37 | Article, publication_date_field="publication_date", enable_comments_field="enable_comments", 38 | ) 39 | -------------------------------------------------------------------------------- /example/article/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-fluent/django-fluent-comments/5d53578544b5b5ecd8c34719672fd43668fb2dd4/example/article/tests/__init__.py -------------------------------------------------------------------------------- /example/article/tests/factories.py: -------------------------------------------------------------------------------- 1 | from random import random 2 | 3 | from article.models import Article 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.contrib.sites.models import Site 6 | from django.utils.timezone import now 7 | from django_comments import get_model as get_comment_model 8 | 9 | 10 | def create_article(**kwargs): 11 | """ 12 | Create an article, with default parameters 13 | """ 14 | defaults = dict( 15 | title="Testing article", 16 | slug="testing-article" + str(random()), 17 | content="This is testing article", 18 | publication_date=now(), 19 | enable_comments=True, 20 | ) 21 | defaults.update(kwargs) 22 | return Article.objects.create(**defaults) 23 | 24 | 25 | def create_comment(comment_model=None, article=None, user=None, **kwargs): 26 | """ 27 | Create a new comment. 28 | """ 29 | if article is None: 30 | article = create_article() 31 | 32 | article_ctype = ContentType.objects.get_for_model(article) 33 | defaults = dict( 34 | user=user, 35 | user_name="Test-Name", 36 | user_email="test@example.com", 37 | user_url="http://example.com", 38 | comment="Test-Comment", 39 | submit_date=now(), 40 | site=Site.objects.get_current(), 41 | ip_address="127.0.0.1", 42 | is_public=True, 43 | is_removed=False, 44 | ) 45 | defaults.update(kwargs) 46 | 47 | Comment = comment_model or get_comment_model() 48 | return Comment.objects.create(content_type=article_ctype, object_pk=article.pk, **defaults,) 49 | -------------------------------------------------------------------------------- /example/article/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.contrib.auth.models import User 3 | from django.test import TestCase 4 | 5 | from article.tests import factories 6 | 7 | 8 | class AdminCommentsTests(TestCase): 9 | def test_admin_comments_access(self): 10 | """ 11 | See that the admin renders 12 | """ 13 | admin = User.objects.create_superuser("admin2", "admin@example.com", "secret") 14 | comment = factories.create_comment(user_name="Test-Name") 15 | 16 | self.client.login(username=admin.username, password="secret") 17 | response = self.client.get(reverse("admin:fluent_comments_fluentcomment_changelist")) 18 | self.assertContains(response, ">Test-Name<", status_code=200) 19 | -------------------------------------------------------------------------------- /example/article/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import fluent_comments 2 | from article.tests import factories 3 | from fluent_comments.tests.utils import override_appsettings 4 | from crispy_forms.layout import Row 5 | from django.test import TestCase 6 | 7 | from fluent_comments import appsettings 8 | from fluent_comments.forms.compact import CompactCommentForm 9 | 10 | 11 | class FormTests(TestCase): 12 | @override_appsettings( 13 | FLUENT_COMMENTS_FORM_CLASS="fluent_comments.forms.compact.CompactCommentForm", 14 | FLUENT_COMMENTS_FIELD_ORDER=(), 15 | FLUENT_COMMENTS_COMPACT_FIELDS=("name", "email"), 16 | ) 17 | def test_form_class(self): 18 | """ 19 | Test how overriding the form class works. 20 | """ 21 | form_class = fluent_comments.get_form() 22 | self.assertIs(form_class, CompactCommentForm) 23 | 24 | article = factories.create_article() 25 | form = form_class(article) 26 | 27 | if appsettings.USE_THREADEDCOMMENTS: 28 | self.assertEqual( 29 | [f.name for f in form.visible_fields()], 30 | ["name", "email", "url", "title", "comment", "honeypot"], 31 | ) 32 | self.assertEqual(form.helper.layout.fields[3], "security_hash") 33 | self.assertIsInstance(form.helper.layout.fields[4], Row) 34 | self.assertEqual(form.helper.layout.fields[6], "comment") 35 | self.assertEqual(form.helper.layout.fields[7], "honeypot") 36 | else: 37 | self.assertEqual( 38 | [f.name for f in form.visible_fields()], 39 | ["name", "email", "url", "comment", "honeypot"], 40 | ) 41 | self.assertEqual(form.helper.layout.fields[3], "security_hash") 42 | self.assertIsInstance(form.helper.layout.fields[4], Row) 43 | self.assertEqual(form.helper.layout.fields[5], "comment") 44 | self.assertEqual(form.helper.layout.fields[6], "honeypot") 45 | 46 | @override_appsettings( 47 | FLUENT_COMMENTS_FIELD_ORDER=("comment", "name", "email", "url"), 48 | FLUENT_COMMENTS_COMPACT_FIELDS=("name", "email"), 49 | ) 50 | def test_compact_ordering1(self): 51 | """ 52 | Test how field ordering works. 53 | """ 54 | article = factories.create_article() 55 | form = CompactCommentForm(article) 56 | self.assertEqual( 57 | [f.name for f in form.visible_fields()], 58 | ["comment", "name", "email", "url", "honeypot"], 59 | ) 60 | if appsettings.USE_THREADEDCOMMENTS: 61 | self.assertEqual( 62 | list(form.fields.keys()), 63 | [ 64 | "content_type", 65 | "object_pk", 66 | "timestamp", 67 | "security_hash", 68 | "parent", 69 | "comment", 70 | "name", 71 | "email", 72 | "url", 73 | "honeypot", 74 | ], 75 | ) 76 | 77 | self.assertEqual(form.helper.layout.fields[3], "security_hash") 78 | self.assertEqual(form.helper.layout.fields[5], "comment") 79 | self.assertIsInstance(form.helper.layout.fields[6], Row) 80 | self.assertEqual(form.helper.layout.fields[7], "honeypot") 81 | else: 82 | self.assertEqual( 83 | list(form.fields.keys()), 84 | [ 85 | "content_type", 86 | "object_pk", 87 | "timestamp", 88 | "security_hash", 89 | "comment", 90 | "name", 91 | "email", 92 | "url", 93 | "honeypot", 94 | ], 95 | ) 96 | 97 | self.assertEqual(form.helper.layout.fields[3], "security_hash") 98 | self.assertEqual(form.helper.layout.fields[4], "comment") 99 | self.assertIsInstance(form.helper.layout.fields[5], Row) 100 | self.assertEqual(form.helper.layout.fields[6], "honeypot") 101 | -------------------------------------------------------------------------------- /example/article/tests/test_moderation.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from akismet import Akismet 4 | from django.test import RequestFactory 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | from django.utils.timezone import now 8 | from fluent_comments import appsettings 9 | from fluent_comments.moderation import FluentCommentsModerator, get_model_moderator 10 | from unittest.mock import patch 11 | 12 | from article.models import Article 13 | from article.tests import factories 14 | from fluent_comments.tests.utils import MockedResponse, override_appsettings 15 | 16 | 17 | class ModerationTests(TestCase): 18 | """ 19 | Testing moderation utils 20 | """ 21 | 22 | def test_get_model_moderator(self, *mocks): 23 | """ 24 | See if the moderator was registered. 25 | """ 26 | moderator = get_model_moderator(Article) 27 | self.assertIsNotNone(moderator) 28 | 29 | @override_appsettings( 30 | FLUENT_CONTENTS_USE_AKISMET=False, 31 | FLUENT_COMMENTS_MODERATE_BAD_WORDS=("viagra",), 32 | ) 33 | def test_bad_words(self, *mocks): 34 | """ 35 | Test moderation on bad words. 36 | """ 37 | request = RequestFactory().post(reverse("comments-post-comment-ajax")) 38 | article = factories.create_article() 39 | comment = factories.create_comment(article=article, comment="Testing:viagra!!") 40 | moderator = get_model_moderator(Article) # type: FluentCommentsModerator 41 | 42 | self.assertTrue(moderator.moderate_bad_words) # see that settings are correctly patched 43 | self.assertTrue(moderator.moderate(comment, article, request), "bad_words should reject") 44 | 45 | comment.comment = "Just normal words" 46 | self.assertFalse( 47 | moderator.moderate(comment, article, request), "bad_words should not trigger" 48 | ) 49 | 50 | @override_appsettings( 51 | FLUENT_CONTENTS_USE_AKISMET=False, 52 | ) 53 | def test_moderator_no_akismet(self, *mocks): 54 | """ 55 | Testing moderation without akismet 56 | """ 57 | request = RequestFactory().post(reverse("comments-post-comment-ajax")) 58 | article = factories.create_article() 59 | comment = factories.create_comment(article=article) 60 | moderator = get_model_moderator(Article) # type: FluentCommentsModerator 61 | 62 | self.assertTrue(article.enable_comments) 63 | self.assertFalse(moderator.akismet_check) 64 | self.assertTrue( 65 | moderator.allow(comment, article, request), "no akismet, comment should be allowed" 66 | ) 67 | self.assertFalse( 68 | moderator.moderate(comment, article, request), 69 | "no akismet, comment should not be moderated", 70 | ) 71 | 72 | @override_appsettings( 73 | AKISMET_API_KEY="FOOBAR", 74 | FLUENT_COMMENTS_AKISMET_ACTION="auto", 75 | FLUENT_CONTENTS_USE_AKISMET=True, 76 | ) 77 | def test_akismet_auto(self, *mocks): 78 | """ 79 | Test an akismet call 80 | """ 81 | request = RequestFactory().post(reverse("comments-post-comment-ajax")) 82 | article = factories.create_article() 83 | comment = factories.create_comment(article=article, user_name="viagra-test-123") 84 | moderator = get_model_moderator(Article) # type: FluentCommentsModerator 85 | 86 | self.assertTrue(article.enable_comments) 87 | self.assertTrue(moderator.akismet_check) # see that settings are correctly patched 88 | 89 | with patch.object(Akismet, "_request", return_value=MockedResponse(True, definitive=True)): 90 | self.assertFalse(moderator.allow(comment, article, request), "akismet should reject") 91 | self.assertTrue(moderator.moderate(comment, article, request), "akismet should reject") 92 | self.assertTrue(comment.is_removed) 93 | 94 | @override_appsettings( 95 | AKISMET_API_KEY="FOOBAR", 96 | FLUENT_COMMENTS_AKISMET_ACTION="moderate", 97 | FLUENT_CONTENTS_USE_AKISMET=True, 98 | ) 99 | def test_akismet_moderate(self, *mocks): 100 | """ 101 | Test an akismet call 102 | """ 103 | request = RequestFactory().post(reverse("comments-post-comment-ajax")) 104 | article = factories.create_article() 105 | comment = factories.create_comment(article=article, user_name="viagra-test-123") 106 | moderator = get_model_moderator(Article) # type: FluentCommentsModerator 107 | 108 | self.assertTrue(article.enable_comments) 109 | self.assertTrue(moderator.akismet_check) # see that settings are correctly patched 110 | 111 | with patch.object(Akismet, "_request", return_value=MockedResponse(True)): 112 | self.assertTrue(moderator.allow(comment, article, request)) 113 | self.assertTrue(moderator.moderate(comment, article, request), "akismet should reject") 114 | self.assertFalse(comment.is_removed) 115 | 116 | @override_appsettings( 117 | AKISMET_API_KEY="FOOBAR", 118 | FLUENT_COMMENTS_AKISMET_ACTION="soft_delete", 119 | FLUENT_CONTENTS_USE_AKISMET=True, 120 | ) 121 | def test_akismet_soft_delete(self, *mocks): 122 | """ 123 | Test an akismet call 124 | """ 125 | request = RequestFactory().post(reverse("comments-post-comment-ajax")) 126 | article = factories.create_article() 127 | comment = factories.create_comment(article=article, user_name="viagra-test-123") 128 | moderator = get_model_moderator(Article) # type: FluentCommentsModerator 129 | 130 | self.assertTrue(article.enable_comments) 131 | self.assertTrue(moderator.akismet_check) # see that settings are correctly patched 132 | 133 | with patch.object(Akismet, "_request", return_value=MockedResponse(True)): 134 | self.assertTrue(moderator.allow(comment, article, request)) 135 | self.assertTrue(moderator.moderate(comment, article, request), "akismet should reject") 136 | self.assertTrue(comment.is_removed) 137 | 138 | @override_appsettings( 139 | AKISMET_API_KEY="FOOBAR", 140 | FLUENT_COMMENTS_AKISMET_ACTION="delete", 141 | FLUENT_CONTENTS_USE_AKISMET=True, 142 | ) 143 | def test_akismet_delete(self, *mocks): 144 | """ 145 | Test an akismet call 146 | """ 147 | request = RequestFactory().post(reverse("comments-post-comment-ajax")) 148 | article = factories.create_article() 149 | comment = factories.create_comment(article=article, user_name="viagra-test-123") 150 | moderator = get_model_moderator(Article) # type: FluentCommentsModerator 151 | 152 | self.assertTrue(article.enable_comments) 153 | self.assertTrue(moderator.akismet_check) # see that settings are correctly patched 154 | 155 | with patch.object(Akismet, "_request", return_value=MockedResponse(True)): 156 | self.assertFalse(moderator.allow(comment, article, request)) 157 | self.assertTrue(moderator.moderate(comment, article, request), "akismet should reject") 158 | self.assertTrue(comment.is_removed) 159 | 160 | @override_appsettings(FLUENT_COMMENTS_CLOSE_AFTER_DAYS=10) 161 | def test_comments_are_open(self): 162 | """ 163 | Test that comments can auto close. 164 | """ 165 | self.assertTrue(Article().comments_are_open, "article should be open for comments") 166 | self.assertFalse( 167 | Article(enable_comments=False).comments_are_open, "article comments should close" 168 | ) 169 | 170 | # Test ranges 171 | days = appsettings.FLUENT_COMMENTS_CLOSE_AFTER_DAYS 172 | self.assertTrue( 173 | Article(publication_date=now() - timedelta(days=days - 1)).comments_are_open 174 | ) 175 | self.assertFalse(Article(publication_date=now() - timedelta(days=days)).comments_are_open) 176 | self.assertFalse( 177 | Article(publication_date=now() - timedelta(days=days + 1)).comments_are_open 178 | ) 179 | 180 | @override_appsettings(FLUENT_COMMENTS_MODERATE_AFTER_DAYS=10) 181 | def test_comments_are_moderated(self): 182 | """ 183 | Test that moderation auto enables. 184 | """ 185 | self.assertFalse(Article().comments_are_moderated, "comment should not be moderated yet") 186 | self.assertTrue( 187 | Article(publication_date=datetime.min).comments_are_moderated, 188 | "old comment should be moderated", 189 | ) 190 | 191 | # Test ranges 192 | days = appsettings.FLUENT_COMMENTS_MODERATE_AFTER_DAYS 193 | self.assertFalse( 194 | Article(publication_date=now() - timedelta(days=days - 1)).comments_are_moderated 195 | ) 196 | self.assertTrue( 197 | Article(publication_date=now() - timedelta(days=days)).comments_are_moderated 198 | ) 199 | self.assertTrue( 200 | Article(publication_date=now() - timedelta(days=days + 1)).comments_are_moderated 201 | ) 202 | -------------------------------------------------------------------------------- /example/article/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | 4 | from akismet import Akismet 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | from django_comments import get_model as get_comment_model, signals 8 | from django_comments.forms import CommentForm 9 | from unittest.mock import patch 10 | 11 | from article.models import Article 12 | from article.tests import factories 13 | from fluent_comments.moderation import get_model_moderator 14 | from fluent_comments.tests.utils import MockedResponse, override_appsettings 15 | 16 | 17 | class ViewTests(TestCase): 18 | def test_get_article_with_comment(self): 19 | """ 20 | See if the comment renders 21 | """ 22 | article = factories.create_article() 23 | comment = factories.create_comment(article=article, comment="Test-Comment") 24 | 25 | response = self.client.get(reverse("article-details", kwargs={"slug": article.slug})) 26 | self.assertContains(response, "Test-Comment", status_code=200) 27 | 28 | def test_comment_post(self): 29 | """ 30 | Make an ajax post. 31 | """ 32 | content_type = "article.article" 33 | timestamp = str(int(time.time())) 34 | article = factories.create_article() 35 | 36 | form = CommentForm(article) 37 | security_hash = form.generate_security_hash(content_type, str(article.pk), timestamp) 38 | post_data = { 39 | "content_type": content_type, 40 | "object_pk": article.pk, 41 | "name": "Testing name", 42 | "email": "test@email.com", 43 | "comment": "Testing comment", 44 | "timestamp": timestamp, 45 | "security_hash": security_hash, 46 | } 47 | url = reverse("comments-post-comment-ajax") 48 | response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") 49 | self.assertContains(response, "Testing comment", status_code=200) 50 | self.assertEqual(response.status_code, 200) 51 | 52 | json_response = json.loads(response.content.decode("utf-8")) 53 | self.assertTrue(json_response["success"]) 54 | self.assertEqual(json_response["errors"], {}) 55 | self.assertIn("Testing name", json_response["html"]) 56 | 57 | @override_appsettings( 58 | AKISMET_API_KEY="FOOBAR", 59 | FLUENT_COMMENTS_AKISMET_ACTION="soft_delete", 60 | FLUENT_CONTENTS_USE_AKISMET=True, 61 | ) 62 | def test_comment_post_moderated(self): 63 | """ 64 | See that soft delete works properly. 65 | """ 66 | # Double check preconditions for moderation 67 | self.assertIsNotNone(get_model_moderator(Article)) 68 | self.assertTrue(len(signals.comment_will_be_posted.receivers)) 69 | self.assertEqual( 70 | id(get_comment_model()), signals.comment_will_be_posted.receivers[0][0][1] 71 | ) 72 | 73 | content_type = "article.article" 74 | timestamp = str(int(time.time())) 75 | article = factories.create_article() 76 | 77 | form = CommentForm(article) 78 | security_hash = form.generate_security_hash(content_type, str(article.pk), timestamp) 79 | post_data = { 80 | "content_type": content_type, 81 | "object_pk": article.pk, 82 | "name": "Testing name", 83 | "email": "test@email.com", 84 | "comment": "Testing comment", 85 | "timestamp": timestamp, 86 | "security_hash": security_hash, 87 | } 88 | 89 | for url, is_ajax in [ 90 | (reverse("comments-post-comment-ajax"), True), 91 | (reverse("comments-post-comment"), False), 92 | ]: 93 | with patch.object(Akismet, "_request", return_value=MockedResponse(True)) as m: 94 | response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") 95 | self.assertEqual(m.call_count, 1, "Moderator not called by " + url) 96 | 97 | if is_ajax: 98 | self.assertContains(response, "Testing comment", status_code=200) 99 | self.assertEqual(response.status_code, 200) 100 | 101 | json_response = json.loads(response.content.decode("utf-8")) 102 | self.assertTrue(json_response["success"]) 103 | self.assertEqual(json_response["errors"], {}) 104 | else: 105 | self.assertRedirects(response, reverse("comments-comment-done") + "?c=1") 106 | 107 | comment = get_comment_model().objects.filter(user_email="test@email.com")[0] 108 | self.assertFalse(comment.is_public, "Not moderated by " + url) 109 | self.assertTrue(comment.is_removed) 110 | 111 | def test_comment_post_missing(self): 112 | """ 113 | Make an ajax post. 114 | """ 115 | content_type = "article.article" 116 | timestamp = str(int(time.time())) 117 | article = factories.create_article() 118 | 119 | form = CommentForm(article) 120 | security_hash = form.generate_security_hash(content_type, str(article.pk), timestamp) 121 | post_data = { 122 | "content_type": content_type, 123 | "object_pk": article.pk, 124 | "timestamp": timestamp, 125 | "security_hash": security_hash, 126 | } 127 | url = reverse("comments-post-comment-ajax") 128 | response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") 129 | self.assertEqual(response.status_code, 200) 130 | 131 | json_response = json.loads(response.content.decode("utf-8")) 132 | self.assertFalse(json_response["success"]) 133 | self.assertEqual(set(json_response["errors"].keys()), set(["name", "email", "comment"])) 134 | 135 | def test_comment_post_bad_requests(self): 136 | """ 137 | See how error handling works on bad requests 138 | """ 139 | content_type = "article.article" 140 | timestamp = str(int(time.time())) 141 | article = factories.create_article() 142 | 143 | form = CommentForm(article) 144 | security_hash = form.generate_security_hash(content_type, str(article.pk), timestamp) 145 | correct_data = { 146 | "content_type": content_type, 147 | "object_pk": article.pk, 148 | "timestamp": timestamp, 149 | "security_hash": security_hash, 150 | } 151 | url = reverse("comments-post-comment-ajax") 152 | headers = dict(HTTP_X_REQUESTED_WITH="XMLHttpRequest") 153 | 154 | # No data 155 | self.assertEqual(self.client.post(url, {}, **headers).status_code, 400) 156 | 157 | # invalid pk 158 | post_data = correct_data.copy() 159 | post_data["object_pk"] = 999 160 | self.assertEqual(self.client.post(url, post_data, **headers).status_code, 400) 161 | 162 | # invalid content type 163 | post_data = correct_data.copy() 164 | post_data["content_type"] = "article.foo" 165 | self.assertEqual(self.client.post(url, post_data, **headers).status_code, 400) 166 | 167 | # invalid security hash 168 | post_data = correct_data.copy() 169 | post_data["timestamp"] = 0 170 | self.assertEqual(self.client.post(url, post_data, **headers).status_code, 400) 171 | -------------------------------------------------------------------------------- /example/article/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from article.views import ArticleListView, ArticleFullListView, ArticleDetailView 3 | 4 | urlpatterns = [ 5 | path(r"detail//", ArticleDetailView.as_view(), name="article-details"), 6 | path(r"full/", ArticleFullListView.as_view(), name="article-full-list"), 7 | path("", ArticleListView.as_view(), name="article-list"), 8 | ] 9 | -------------------------------------------------------------------------------- /example/article/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import ListView, DetailView 2 | from article.models import Article 3 | 4 | 5 | class ArticleListView(ListView): 6 | model = Article 7 | template_name = "article/list.html" 8 | 9 | 10 | class ArticleFullListView(ArticleListView): 11 | template_name = "article/list_full.html" 12 | 13 | 14 | class ArticleDetailView(DetailView): 15 | model = Article 16 | template_name = "article/details.html" 17 | -------------------------------------------------------------------------------- /example/frontend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-fluent/django-fluent-comments/5d53578544b5b5ecd8c34719672fd43668fb2dd4/example/frontend/__init__.py -------------------------------------------------------------------------------- /example/frontend/static/vendor/bootstrap.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.5 (http://getbootstrap.com) 3 | * Copyright 2011-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | @import "bootstrap/variables"; 8 | 9 | // Core variables and mixins 10 | @import "bootstrap/variables"; 11 | @import "bootstrap/mixins"; 12 | 13 | // Reset and dependencies 14 | @import "bootstrap/normalize"; 15 | @import "bootstrap/print"; 16 | //@import "bootstrap/glyphicons"; 17 | 18 | // Core CSS 19 | @import "bootstrap/scaffolding"; 20 | @import "bootstrap/type"; 21 | @import "bootstrap/code"; 22 | @import "bootstrap/grid"; 23 | @import "bootstrap/tables"; 24 | @import "bootstrap/forms"; 25 | @import "bootstrap/buttons"; 26 | 27 | // Components 28 | @import "bootstrap/component-animations"; 29 | //@import "bootstrap/dropdowns"; 30 | //@import "bootstrap/button-groups"; 31 | @import "bootstrap/navs"; 32 | @import "bootstrap/navbar"; 33 | @import "bootstrap/breadcrumbs"; 34 | //@import "bootstrap/pagination"; 35 | //@import "bootstrap/pager"; 36 | //@import "bootstrap/labels"; 37 | //@import "bootstrap/badges"; 38 | //@import "bootstrap/jumbotron"; 39 | //@import "bootstrap/thumbnails"; 40 | //@import "bootstrap/alerts"; 41 | //@import "bootstrap/progress-bars"; 42 | //@import "bootstrap/media"; 43 | //@import "bootstrap/list-group"; 44 | //@import "bootstrap/panels"; 45 | //@import "bootstrap/responsive-embed"; 46 | //@import "bootstrap/wells"; 47 | //@import "bootstrap/close"; 48 | 49 | // Components w/ JavaScript 50 | //@import "bootstrap/modals"; 51 | //@import "bootstrap/tooltip"; 52 | //@import "bootstrap/popovers"; 53 | //@import "bootstrap/carousel"; 54 | 55 | // Utility classes 56 | @import "bootstrap/utilities"; 57 | @import "bootstrap/responsive-utilities"; 58 | -------------------------------------------------------------------------------- /example/frontend/templates/article/details.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %}{% load i18n comments fluent_comments_tags %} 2 | 3 | {% block headtitle %}{{ article.title }}{% endblock %} 4 | 5 | {% block extrahead %}{{ block.super }} 6 | 7 | {% endblock %} 8 | 9 | {% block scripts %}{{ block.super }} 10 | 11 | {% endblock %} 12 | 13 | {% block main %} 14 |

{{ article.title }}

15 | 16 | {{ article.content|linebreaks }} 17 | 18 | {% comment %} 19 | 20 | The minimal requirement to add comments is simply this: 21 | 22 |
23 | {% render_comment_list for article %} 24 | {% render_comment_form for article %} 25 |
26 | 27 | However, to have a nice invitation to post comments, 28 | you can use the following example below. 29 | 30 | {% endcomment %} 31 | 32 | {% get_comment_count for article as comments_count %} 33 | 34 |
35 | {% if comments_count %} 36 |

{% blocktrans with entry_title=article.title count comments_count=comments_count %}{{ comments_count }} comment to {{ entry_title }}{% plural %}{{ comments_count }} comments to {{ entry_title }}{% endblocktrans %}

37 | {% render_comment_list for object %} 38 | 39 | {% if not article|comments_are_open %} 40 |

{% trans "Comments are closed." %}

41 | {% endif %} 42 | {% else %} 43 | {# no comments yet, invite #} 44 | {% if article|comments_are_open %} 45 |

{% trans "Leave a reply" %}

46 | 47 | {# include the empty list, so the
is there for Ajax code #} 48 | {% render_comment_list for object %} 49 | 50 | {% endif %} 51 | {% endif %} 52 |
53 | 54 | {% if article|comments_are_open %} 55 |
56 | {% render_comment_form for object %} 57 |
58 | {% endif %} 59 | 60 | 61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /example/frontend/templates/article/list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %}{% load i18n comments %} 2 | 3 | {% block headtitle %}Articles{% endblock %} 4 | 5 | {% block main %} 6 |

List of articles

7 | 8 | {% if object_list %} 9 |
    10 | {% for article in object_list %} 11 | {% get_comment_count for article as comments_count %} 12 |
  • {{ article }} ({{ comments_count }} comments)
  • 13 | {% endfor %} 14 |
15 | {% else %} 16 |

No articles posted

17 | {% endif %} 18 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /example/frontend/templates/article/list_full.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %}{% load i18n comments %} 2 | 3 | {% block headtitle %}Articles{% endblock %} 4 | 5 | {% block extrahead %}{{ block.super }} 6 | 7 | {% endblock %} 8 | 9 | {% block scripts %}{{ block.super }} 10 | 11 | {% endblock %} 12 | 13 | {% block main %} 14 |

List of articles (with comments)

15 | 16 | {% if object_list %} 17 |
18 | {% for article in object_list %} 19 |
20 |

{{ article }}

21 | 22 | {% render_comment_list for article %} 23 | {% render_comment_form for article %} 24 |
25 | {% endfor %} 26 |
27 | {% else %} 28 |

No articles posted

29 | {% endif %} 30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /example/frontend/templates/base.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | 3 | This is the base template of the example project. 4 | 5 | {% endcomment %} 6 | 7 | 8 | 9 | 10 | {% block headtitle %}{% endblock %} 11 | 12 | {% block extrahead %}{% endblock %} 13 | {% block scripts %} 14 | 15 | {% endblock %} 16 | 17 | 18 | 19 |
20 | 21 | 36 | 37 |
38 |
39 | {% block main %}{% endblock %} 40 |
41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /example/frontend/templates/comments/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %}{% load i18n %} 2 | {% comment %} 3 | 4 | This template only exists to support the non-Ajax pages. Those are: 5 | - links to the moderation options in the email. 6 | - Pages for visitors with JavaScript disabled. 7 | 8 | Those pages are rendered with "comments/base.html" as base template. 9 | 10 | This file simply maps those blocks (headtitle, extrahead and content) 11 | to the blocks that are found in the site theme base template. 12 | See for example: 13 | 14 | http://127.0.0.1:8000/comments/posted/ 15 | http://127.0.0.1:8000/comments/flag/1/ 16 | http://127.0.0.1:8000/comments/flagged/ 17 | http://127.0.0.1:8000/comments/delete/1/ 18 | http://127.0.0.1:8000/comments/deleted/ 19 | http://127.0.0.1:8000/comments/approve/1/ 20 | http://127.0.0.1:8000/comments/approved/ 21 | 22 | {% endcomment %} 23 | 24 | {% block headtitle %}{% block title %}{% trans "Responses for page" %}{% endblock %} - Example CMS{% endblock %} 25 | 26 | {% block main %} 27 |

Comments

28 | 29 |
30 | {% block content %}{% endblock %} 31 |
32 | {% endblock %} 33 | 34 | {# NOTE that the base template also needs to have an 'extrahead' block, or map it here like 'main' and 'headtitle' are mapped. #} 35 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if len(sys.argv) > 1 and sys.argv[1] == "test": 6 | # Same effect as python -Wd when run via coverage: 7 | import warnings 8 | 9 | warnings.simplefilter("always", DeprecationWarning) 10 | 11 | if __name__ == "__main__": 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 13 | 14 | from django.core.management import execute_from_command_line 15 | 16 | # Allow starting the app without installing the module. 17 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) 18 | 19 | execute_from_command_line(sys.argv) 20 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3.2.2 2 | 3 | # Base setup requirements 4 | django-crispy-forms>=1.11.2 5 | django-tag-parser>=3.2 6 | django-contrib-comments>=2.1.0 7 | 8 | akismet>=1.1 9 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for example project. 2 | from os.path import join, dirname, realpath 3 | import django 4 | 5 | # Add parent path, 6 | # Allow starting the app without installing the module. 7 | import sys 8 | 9 | sys.path.insert(0, dirname(dirname(realpath(__file__)))) 10 | 11 | DEBUG = True 12 | 13 | ADMINS = ( 14 | # ('Your Name', 'your_email@example.com'), 15 | ) 16 | 17 | MANAGERS = ADMINS 18 | # database engine 19 | DATABASES = { 20 | "default": { 21 | "ENGINE": "django.db.backends.sqlite3", 22 | "NAME": dirname(__file__) + "/demo.db", 23 | } 24 | } 25 | 26 | TIME_ZONE = "Europe/Amsterdam" 27 | LANGUAGE_CODE = "en-us" 28 | SITE_ID = 1 29 | 30 | USE_I18N = True 31 | USE_L10N = True 32 | 33 | MEDIA_ROOT = join(dirname(__file__), "media") 34 | MEDIA_URL = "/media/" 35 | STATIC_ROOT = join(dirname(__file__), "static") 36 | STATIC_URL = "/static/" 37 | 38 | STATICFILES_DIRS = () 39 | STATICFILES_FINDERS = ( 40 | "django.contrib.staticfiles.finders.FileSystemFinder", 41 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 42 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 43 | ) 44 | 45 | # Make this unique, and don't share it with anybody. 46 | SECRET_KEY = "-#@bi6bue%#1j)6+4b&#i0g-*xro@%f@_#zwv=2-g_@n3n_kj5" 47 | 48 | TEMPLATES = [ 49 | { 50 | "BACKEND": "django.template.backends.django.DjangoTemplates", 51 | "APP_DIRS": True, 52 | "OPTIONS": { 53 | "debug": DEBUG, 54 | "context_processors": [ 55 | "django.contrib.auth.context_processors.auth", 56 | "django.template.context_processors.request", 57 | "django.template.context_processors.static", 58 | "django.contrib.messages.context_processors.messages", 59 | ], 60 | }, 61 | }, 62 | ] 63 | 64 | 65 | MIDDLEWARE = ( 66 | "django.middleware.common.CommonMiddleware", 67 | "django.contrib.sessions.middleware.SessionMiddleware", 68 | "django.middleware.csrf.CsrfViewMiddleware", 69 | "django.contrib.auth.middleware.AuthenticationMiddleware", 70 | "django.contrib.messages.middleware.MessageMiddleware", 71 | ) 72 | 73 | if django.VERSION < (1, 10): 74 | MIDDLEWARE_CLASSES = MIDDLEWARE 75 | 76 | TEST_RUNNER = "django.test.runner.DiscoverRunner" 77 | 78 | ROOT_URLCONF = "urls" 79 | 80 | INSTALLED_APPS = ( 81 | "django.contrib.auth", 82 | "django.contrib.contenttypes", 83 | "django.contrib.sessions", 84 | "django.contrib.sites", 85 | "django.contrib.messages", 86 | "django.contrib.staticfiles", 87 | "django.contrib.admin", 88 | # Site theme 89 | "frontend", 90 | # Example app 91 | "article", 92 | # Required modules 93 | "crispy_forms", 94 | "fluent_comments", 95 | "django_comments", 96 | ) 97 | 98 | try: 99 | import threadedcomments 100 | except ImportError: 101 | pass 102 | else: 103 | print("Using django-threadedcomments, added to INSTALLED_APPS") 104 | INSTALLED_APPS += ("threadedcomments",) 105 | 106 | LOGGING = { 107 | "version": 1, 108 | "disable_existing_loggers": False, 109 | "handlers": {"mail_admins": {"level": "ERROR", "class": "django.utils.log.AdminEmailHandler"}}, 110 | "loggers": { 111 | "django.request": { 112 | "handlers": ["mail_admins"], 113 | "level": "ERROR", 114 | "propagate": True, 115 | }, 116 | }, 117 | } 118 | 119 | CRISPY_TEMPLATE_PACK = "bootstrap3" 120 | 121 | # fluent-comments settings: 122 | COMMENTS_APP = "fluent_comments" 123 | 124 | FLUENT_COMMENTS_USE_EMAIL_MODERATION = True 125 | FLUENT_COMMENTS_MODERATE_AFTER_DAYS = 14 126 | FLUENT_COMMENTS_CLOSE_AFTER_DAYS = 60 127 | FLUENT_COMMENTS_AKISMET_ACTION = "moderate" 128 | 129 | AKISMET_API_KEY = None # Add your Akismet key here to enable Akismet support 130 | AKISMET_IS_TEST = True # for development/example apps. 131 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | import article.urls 2 | import fluent_comments.urls 3 | 4 | from django.contrib import admin 5 | from django.views.generic import RedirectView 6 | from django.urls import include, path 7 | 8 | admin.autodiscover() 9 | 10 | urlpatterns = [ 11 | path("admin/", admin.site.urls), 12 | path("comments/", include(fluent_comments.urls)), 13 | path("articles/", include(article.urls)), 14 | path("", RedirectView.as_view(url="articles/", permanent=False)), 15 | ] 16 | -------------------------------------------------------------------------------- /fluent_comments/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | API for :ref:`custom-comment-app-api` 3 | """ 4 | default_app_config = "fluent_comments.apps.FluentCommentsApp" 5 | 6 | form_class = None 7 | model_class = None 8 | 9 | # following PEP 440 10 | __version__ = "3.0" 11 | 12 | 13 | def get_model(): 14 | """ 15 | Return the model to use for commenting. 16 | """ 17 | global model_class 18 | if model_class is None: 19 | from fluent_comments.models import FluentComment 20 | 21 | # Our proxy model that performs select_related('user') for the comments 22 | model_class = FluentComment 23 | 24 | return model_class 25 | 26 | 27 | def get_form(): 28 | """ 29 | Return the form to use for commenting. 30 | """ 31 | global form_class 32 | from fluent_comments import appsettings 33 | 34 | if form_class is None: 35 | if appsettings.FLUENT_COMMENTS_FORM_CLASS: 36 | from django.utils.module_loading import import_string 37 | 38 | form_class = import_string(appsettings.FLUENT_COMMENTS_FORM_CLASS) 39 | else: 40 | from fluent_comments.forms import FluentCommentForm 41 | 42 | form_class = FluentCommentForm 43 | 44 | return form_class 45 | -------------------------------------------------------------------------------- /fluent_comments/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.contrib.admin.widgets import AdminTextInputWidget 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.utils.encoding import force_str 6 | from django.utils.html import escape, format_html 7 | from django.utils.translation import gettext_lazy as _ 8 | from fluent_comments import appsettings 9 | from django_comments import get_model as get_comments_model 10 | 11 | 12 | # Ensure the admin app is loaded, 13 | # so the model is unregistered here, and not loaded twice. 14 | if appsettings.USE_THREADEDCOMMENTS: 15 | # Avoid getting weird situations where both comment apps are loaded in the admin. 16 | if not hasattr(settings, "COMMENTS_APP") or settings.COMMENTS_APP == "comments": 17 | raise ImproperlyConfigured("To use 'threadedcomments', specify the COMMENTS_APP as well") 18 | 19 | from threadedcomments.admin import ThreadedCommentsAdmin as CommentsAdminBase 20 | else: 21 | from django_comments.admin import CommentsAdmin as CommentsAdminBase 22 | 23 | 24 | class FluentCommentsAdmin(CommentsAdminBase): 25 | """ 26 | Updated admin screen for the comments model. 27 | 28 | The ability to add a comment is removed here, the admin screen can only be used for managing comments. 29 | Adding comments can happen at the frontend instead. 30 | 31 | The fieldsets are more logically organized, and the generic relation is a readonly field instead of a massive pulldown + textarea. 32 | The class supports both the standard ``django_comments`` and the ``threadedcomments`` applications. 33 | """ 34 | 35 | fieldsets = [ 36 | ( 37 | _("Content"), 38 | { 39 | "fields": ( 40 | "object_link", 41 | "user_name", 42 | "user_email", 43 | "user_url", 44 | "comment", 45 | "submit_date", 46 | ) 47 | }, 48 | ), 49 | (_("Account information"), {"fields": ("user", "ip_address")}), 50 | (_("Moderation"), {"fields": ("is_public", "is_removed")}), 51 | ] 52 | 53 | list_display = ( 54 | "user_name_col", 55 | "object_link", 56 | "ip_address", 57 | "submit_date", 58 | "is_public", 59 | "is_removed", 60 | ) 61 | readonly_fields = ( 62 | "object_link", 63 | "user", 64 | "ip_address", 65 | "submit_date", 66 | ) 67 | 68 | # Adjust the fieldsets for threaded comments 69 | if appsettings.USE_THREADEDCOMMENTS: 70 | fieldsets[0][1]["fields"] = ( 71 | "object_link", 72 | "user_name", 73 | "user_email", 74 | "user_url", 75 | "title", 76 | "comment", 77 | "submit_date", 78 | ) # add title field. 79 | fieldsets.insert(2, (_("Hierarchy"), {"fields": ("parent",)})) 80 | raw_id_fields = ("parent",) 81 | 82 | def get_queryset(self, request): 83 | return super().get_queryset(request).select_related("user") 84 | 85 | def object_link(self, comment): 86 | try: 87 | object = comment.content_object 88 | except AttributeError: 89 | return "" 90 | 91 | if not object: 92 | return "" 93 | 94 | title = force_str(object) 95 | if hasattr(object, "get_absolute_url"): 96 | return format_html(u'{1}', object.get_absolute_url(), title) 97 | else: 98 | return title 99 | 100 | object_link.short_description = _("Page") 101 | object_link.allow_tags = True 102 | 103 | def user_name_col(self, comment): 104 | if comment.user_name: 105 | return comment.user_name 106 | elif comment.user_id: 107 | return force_str(comment.user) 108 | else: 109 | return None 110 | 111 | user_name_col.short_description = _("user's name") 112 | 113 | def has_add_permission(self, request): 114 | return False 115 | 116 | def formfield_for_dbfield(self, db_field, **kwargs): 117 | if db_field.name == "title": 118 | kwargs["widget"] = AdminTextInputWidget 119 | return super().formfield_for_dbfield(db_field, **kwargs) 120 | 121 | 122 | # Replace the old admin screen. 123 | if appsettings.FLUENT_COMMENTS_REPLACE_ADMIN: 124 | CommentModel = get_comments_model() 125 | try: 126 | admin.site.unregister(CommentModel) 127 | except admin.sites.NotRegistered as e: 128 | pass 129 | 130 | admin.site.register(CommentModel, FluentCommentsAdmin) 131 | -------------------------------------------------------------------------------- /fluent_comments/akismet.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin 2 | 3 | from akismet import Akismet, SpamStatus 4 | from django.contrib.sites.shortcuts import get_current_site 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.utils.encoding import smart_str 7 | 8 | import fluent_comments 9 | from fluent_comments import appsettings 10 | 11 | 12 | def akismet_check(comment, content_object, request): 13 | """ 14 | Connects to Akismet and evaluates to True if Akismet marks this comment as spam. 15 | 16 | :rtype: akismet.SpamStatus 17 | """ 18 | # Return previously cached response 19 | akismet_result = getattr(comment, "_akismet_result_", None) 20 | if akismet_result is not None: 21 | return akismet_result 22 | 23 | # Get Akismet data 24 | AKISMET_API_KEY = appsettings.AKISMET_API_KEY 25 | if not AKISMET_API_KEY: 26 | raise ImproperlyConfigured( 27 | "You must set AKISMET_API_KEY to use comment moderation with Akismet." 28 | ) 29 | 30 | current_domain = get_current_site(request).domain 31 | auto_blog_url = "{0}://{1}/".format(request.is_secure() and "https" or "http", current_domain) 32 | blog_url = appsettings.AKISMET_BLOG_URL or auto_blog_url 33 | 34 | akismet = Akismet( 35 | AKISMET_API_KEY, 36 | blog=blog_url, 37 | is_test=int(bool(appsettings.AKISMET_IS_TEST)), 38 | application_user_agent="django-fluent-comments/{0}".format(fluent_comments.__version__), 39 | ) 40 | 41 | akismet_data = _get_akismet_data(blog_url, comment, content_object, request) 42 | akismet_result = akismet.check(**akismet_data) # raises AkismetServerError when key is invalid 43 | setattr(comment, "_akismet_result_", akismet_result) 44 | return akismet_result 45 | 46 | 47 | def _get_akismet_data(blog_url, comment, content_object, request): 48 | # Field documentation: 49 | # http://akismet.com/development/api/#comment-check 50 | data = { 51 | # Comment info 52 | "permalink": urljoin(blog_url, content_object.get_absolute_url()), 53 | # see http://blog.akismet.com/2012/06/19/pro-tip-tell-us-your-comment_type/ 54 | "comment_type": "comment", # comment, trackback, pingback 55 | "comment_author": getattr(comment, "name", ""), 56 | "comment_author_email": getattr(comment, "email", ""), 57 | "comment_author_url": getattr(comment, "url", ""), 58 | "comment_content": smart_str(comment.comment), 59 | "comment_date": comment.submit_date, 60 | # Request info 61 | "referrer": request.META.get("HTTP_REFERER", ""), 62 | "user_agent": request.META.get("HTTP_USER_AGENT", ""), 63 | "user_ip": comment.ip_address, 64 | } 65 | 66 | if comment.user_id and comment.user.is_superuser: 67 | data["user_role"] = "administrator" # always passes test 68 | 69 | # If the language is known, provide it. 70 | language = _get_article_language(content_object) 71 | if language: 72 | data["blog_lang"] = language 73 | 74 | return data 75 | 76 | 77 | def _get_article_language(article): 78 | try: 79 | # django-parler uses this attribute 80 | return article.get_current_language() 81 | except AttributeError: 82 | pass 83 | 84 | try: 85 | return article.language_code 86 | except AttributeError: 87 | pass 88 | 89 | return None 90 | -------------------------------------------------------------------------------- /fluent_comments/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class FluentCommentsApp(AppConfig): 6 | name = "fluent_comments" 7 | verbose_name = _("Comments") 8 | 9 | def ready(self): 10 | # This installs the comment_will_be_posted signal 11 | import fluent_comments.receivers # noqa 12 | -------------------------------------------------------------------------------- /fluent_comments/appsettings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | AKISMET_API_KEY = getattr(settings, "AKISMET_API_KEY", None) 5 | AKISMET_BLOG_URL = getattr( 6 | settings, "AKISMET_BLOG_URL", None 7 | ) # Optional, to override auto detection 8 | AKISMET_IS_TEST = getattr(settings, "AKISMET_IS_TEST", False) # Enable in case of testing 9 | 10 | CRISPY_TEMPLATE_PACK = getattr(settings, "CRISPY_TEMPLATE_PACK", "bootstrap") 11 | 12 | USE_THREADEDCOMMENTS = "threadedcomments" in settings.INSTALLED_APPS 13 | 14 | FLUENT_COMMENTS_REPLACE_ADMIN = getattr(settings, "FLUENT_COMMENTS_REPLACE_ADMIN", True) 15 | 16 | FLUENT_CONTENTS_USE_AKISMET = getattr( 17 | settings, "FLUENT_CONTENTS_USE_AKISMET", bool(AKISMET_API_KEY) 18 | ) # enable when an API key is set. 19 | FLUENT_COMMENTS_DEFAULT_MODERATOR = getattr( 20 | settings, "FLUENT_COMMENTS_DEFAULT_MODERATOR", "default" 21 | ) 22 | FLUENT_COMMENTS_USE_EMAIL_NOTIFICATION = getattr( 23 | settings, "FLUENT_COMMENTS_USE_EMAIL_NOTIFICATION", True 24 | ) # enable by default 25 | FLUENT_COMMENTS_MULTIPART_EMAILS = getattr( 26 | settings, "FLUENT_COMMENTS_MULTIPART_EMAILS", False 27 | ) # disable by default 28 | FLUENT_COMMENTS_CLOSE_AFTER_DAYS = getattr(settings, "FLUENT_COMMENTS_CLOSE_AFTER_DAYS", None) 29 | FLUENT_COMMENTS_MODERATE_BAD_WORDS = getattr(settings, "FLUENT_COMMENTS_MODERATE_BAD_WORDS", ()) 30 | FLUENT_COMMENTS_MODERATE_AFTER_DAYS = getattr( 31 | settings, "FLUENT_COMMENTS_MODERATE_AFTER_DAYS", None 32 | ) 33 | FLUENT_COMMENTS_AKISMET_ACTION = getattr( 34 | settings, "FLUENT_COMMENTS_AKISMET_ACTION", "soft_delete" 35 | ) # or 'moderate', 'delete, 'soft_delete' 36 | 37 | FLUENT_COMMENTS_FIELD_ORDER = tuple(getattr(settings, "FLUENT_COMMENTS_FIELD_ORDER", ()) or ()) 38 | FLUENT_COMMENTS_EXCLUDE_FIELDS = tuple( 39 | getattr(settings, "FLUENT_COMMENTS_EXCLUDE_FIELDS", ()) or () 40 | ) 41 | FLUENT_COMMENTS_FORM_CLASS = getattr(settings, "FLUENT_COMMENTS_FORM_CLASS", None) 42 | FLUENT_COMMENTS_FORM_CSS_CLASS = getattr( 43 | settings, "FLUENT_COMMENTS_FORM_CSS_CLASS", "comments-form form-horizontal" 44 | ) 45 | FLUENT_COMMENTS_LABEL_CSS_CLASS = getattr(settings, "FLUENT_COMMENTS_LABEL_CSS_CLASS", "col-sm-2") 46 | FLUENT_COMMENTS_FIELD_CSS_CLASS = getattr(settings, "FLUENT_COMMENTS_FIELD_CSS_CLASS", "col-sm-10") 47 | 48 | # Compact style settings 49 | FLUENT_COMMENTS_COMPACT_FIELDS = getattr( 50 | settings, "FLUENT_COMMENTS_COMPACT_FIELDS", ("name", "email", "url") 51 | ) 52 | FLUENT_COMMENTS_COMPACT_GRID_SIZE = getattr(settings, "FLUENT_COMMENTS_COMPACT_GRID_SIZE", 12) 53 | FLUENT_COMMENTS_COMPACT_COLUMN_CSS_CLASS = getattr( 54 | settings, "FLUENT_COMMENTS_COMPACT_COLUMN_CSS_CLASS", "col-sm-{size}" 55 | ) 56 | 57 | 58 | if FLUENT_COMMENTS_AKISMET_ACTION not in ("auto", "moderate", "soft_delete", "delete"): 59 | raise ImproperlyConfigured( 60 | "FLUENT_COMMENTS_AKISMET_ACTION can be 'auto', 'moderate', 'soft_delete' or 'delete'" 61 | ) 62 | 63 | if FLUENT_COMMENTS_EXCLUDE_FIELDS or FLUENT_COMMENTS_FORM_CLASS or FLUENT_COMMENTS_FIELD_ORDER: 64 | # The exclude option only works when our form is used. 65 | # Allow derived packages to inherit our form class too. 66 | if not hasattr(settings, "COMMENTS_APP") or settings.COMMENTS_APP == "comments": 67 | raise ImproperlyConfigured( 68 | "To use django-fluent-comments, also specify: COMMENTS_APP = 'fluent_comments'" 69 | ) 70 | -------------------------------------------------------------------------------- /fluent_comments/email.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.sites.shortcuts import get_current_site 3 | from django.core.mail import send_mail 4 | from django.template.loader import render_to_string 5 | from django.utils.encoding import force_str 6 | from fluent_comments import appsettings 7 | 8 | 9 | def send_comment_posted(comment, request): 10 | """ 11 | Send the email to staff that an comment was posted. 12 | 13 | While the django_comments module has email support, 14 | it doesn't pass the 'request' to the context. 15 | This also changes the subject to show the page title. 16 | """ 17 | recipient_list = [manager_tuple[1] for manager_tuple in settings.MANAGERS] 18 | site = get_current_site(request) 19 | content_object = comment.content_object 20 | content_title = force_str(content_object) 21 | 22 | if comment.is_removed: 23 | subject = u'[{0}] Spam comment on "{1}"'.format(site.name, content_title) 24 | elif not comment.is_public: 25 | subject = u'[{0}] Moderated comment on "{1}"'.format(site.name, content_title) 26 | else: 27 | subject = u'[{0}] New comment posted on "{1}"'.format(site.name, content_title) 28 | 29 | context = {"site": site, "comment": comment, "content_object": content_object} 30 | 31 | message = render_to_string("comments/comment_notification_email.txt", context, request=request) 32 | if appsettings.FLUENT_COMMENTS_MULTIPART_EMAILS: 33 | html_message = render_to_string( 34 | "comments/comment_notification_email.html", context, request=request 35 | ) 36 | else: 37 | html_message = None 38 | 39 | send_mail( 40 | subject, 41 | message, 42 | settings.DEFAULT_FROM_EMAIL, 43 | recipient_list, 44 | fail_silently=True, 45 | html_message=html_message, 46 | ) 47 | -------------------------------------------------------------------------------- /fluent_comments/forms/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractCommentForm, CommentFormHelper 2 | from .default import DefaultCommentForm 3 | from .compact import CompactLabelsCommentForm, CompactCommentForm 4 | from .helper import CommentFormHelper, SubmitButton, PreviewButton, CompactLabelsCommentFormHelper 5 | 6 | FluentCommentForm = DefaultCommentForm # noqa, for backwards compatibility 7 | 8 | __all__ = ( 9 | "AbstractCommentForm", 10 | "CommentFormHelper", 11 | "DefaultCommentForm", 12 | "CompactLabelsCommentFormHelper", 13 | "CompactLabelsCommentForm", 14 | "CompactCommentForm", 15 | "SubmitButton", 16 | "PreviewButton", 17 | ) 18 | -------------------------------------------------------------------------------- /fluent_comments/forms/_captcha.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | 4 | class CaptchaFormMixin(object): 5 | def _reorder_fields(self, ordering): 6 | """ 7 | Test that the 'captcha' field is really present. 8 | This could be broken by a bad FLUENT_COMMENTS_FIELD_ORDER configuration. 9 | """ 10 | if "captcha" not in ordering: 11 | raise ImproperlyConfigured( 12 | "When using 'FLUENT_COMMENTS_FIELD_ORDER', " 13 | "make sure the 'captcha' field included too to use '{}' form. ".format( 14 | self.__class__.__name__ 15 | ) 16 | ) 17 | super()._reorder_fields(ordering) 18 | 19 | # Avoid making captcha required for previews. 20 | if self.is_preview: 21 | self.fields.pop("captcha") 22 | -------------------------------------------------------------------------------- /fluent_comments/forms/base.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | import django_comments 4 | from django.core.exceptions import ImproperlyConfigured 5 | from fluent_comments import appsettings 6 | from fluent_comments.forms.helper import CommentFormHelper 7 | from fluent_comments.forms.helper import ( 8 | SubmitButton, 9 | PreviewButton, 10 | ) # noqa, import at old class location too 11 | 12 | if appsettings.USE_THREADEDCOMMENTS: 13 | from threadedcomments.forms import ThreadedCommentForm as base_class 14 | else: 15 | from django_comments.forms import CommentForm as base_class 16 | 17 | 18 | class AbstractCommentForm(base_class): 19 | """ 20 | The comment form, applies various settings. 21 | """ 22 | 23 | #: Helper for {% crispy %} template tag 24 | helper = CommentFormHelper() 25 | 26 | def __init__(self, *args, **kwargs): 27 | self.is_preview = kwargs.pop("is_preview", False) 28 | super().__init__(*args, **kwargs) 29 | 30 | # Remove fields from the form. 31 | # This has to be done in the constructor, because the ThreadedCommentForm 32 | # inserts the title field in the __init__, instead of the static form definition. 33 | for name in appsettings.FLUENT_COMMENTS_EXCLUDE_FIELDS: 34 | try: 35 | self.fields.pop(name) 36 | except KeyError: 37 | raise ImproperlyConfigured( 38 | "Field name '{0}' in FLUENT_COMMENTS_EXCLUDE_FIELDS is invalid, " 39 | "it does not exist in the '{1}' class.".format(name, self.__class__.__name__) 40 | ) 41 | 42 | if appsettings.FLUENT_COMMENTS_FIELD_ORDER: 43 | ordering = ( 44 | CommentFormHelper.BASE_FIELDS_TOP 45 | + appsettings.FLUENT_COMMENTS_FIELD_ORDER 46 | + CommentFormHelper.BASE_FIELDS_END 47 | ) 48 | self._reorder_fields(ordering) 49 | 50 | def _reorder_fields(self, ordering): 51 | new_fields = OrderedDict() 52 | for name in ordering: 53 | new_fields[name] = self.fields[name] 54 | self.fields = new_fields 55 | 56 | def get_comment_model(self): 57 | # Provide the model used for comments. When this doesn't match 58 | # the sender used by django_comments.moderation.Moderator used in 59 | # `comment_will_be_posted.connect(..., sender=...)`, it will break moderation. 60 | # 61 | # Since ThreadedCommentForm overrides this method, it breaks moderation 62 | # with COMMENTS_APP="fluent_comments". Hence, by default let this match 63 | # the the model the app is configured with. 64 | return django_comments.get_model() 65 | 66 | def get_comment_create_data(self, *args, **kwargs): 67 | # Fake form data for excluded fields, so there are no KeyError exceptions 68 | for name in appsettings.FLUENT_COMMENTS_EXCLUDE_FIELDS: 69 | self.cleaned_data[name] = "" 70 | 71 | # Pass args, kwargs for django-contrib-comments 1.8, which accepts a ``site_id`` argument. 72 | return super().get_comment_create_data(*args, **kwargs) 73 | -------------------------------------------------------------------------------- /fluent_comments/forms/captcha.py: -------------------------------------------------------------------------------- 1 | """ 2 | Variations of the form that use 3 | """ 4 | from django.utils.translation import pgettext_lazy 5 | 6 | from fluent_comments.forms._captcha import CaptchaFormMixin 7 | from .compact import CompactCommentForm, CompactLabelsCommentForm 8 | from .default import DefaultCommentForm 9 | 10 | try: 11 | from captcha.fields import CaptchaField 12 | except ImportError: 13 | raise ImportError( 14 | "To use '{}', you need to have django-simple-captcha installed.".format(__name__) 15 | ) 16 | 17 | captcha_field = CaptchaField(help_text=pgettext_lazy("captcha-help-text", u"Type the text.")) 18 | 19 | 20 | class DefaultCommentForm(CaptchaFormMixin, DefaultCommentForm): 21 | """ 22 | Comment form with reCAPTCHA field. 23 | """ 24 | 25 | captcha = captcha_field 26 | 27 | 28 | class CompactCommentForm(CaptchaFormMixin, CompactCommentForm): 29 | """ 30 | Compact variation 1. 31 | """ 32 | 33 | captcha = captcha_field 34 | 35 | 36 | class CompactLabelsCommentForm(CaptchaFormMixin, CompactLabelsCommentForm): 37 | """ 38 | Compact variation 2. 39 | """ 40 | 41 | captcha = captcha_field 42 | -------------------------------------------------------------------------------- /fluent_comments/forms/compact.py: -------------------------------------------------------------------------------- 1 | """ 2 | Different form layout, very compact 3 | """ 4 | from crispy_forms.layout import Column, Layout, Row 5 | from django.utils.functional import cached_property 6 | 7 | from fluent_comments import appsettings 8 | from fluent_comments.forms.base import AbstractCommentForm, PreviewButton, SubmitButton 9 | from fluent_comments.forms.helper import CompactLabelsCommentFormHelper 10 | 11 | 12 | class CompactLabelsCommentForm(AbstractCommentForm): 13 | """ 14 | A form layout where the labels are replaced with ``placeholder`` attributes. 15 | """ 16 | 17 | @cached_property 18 | def helper(self): 19 | # Initialize on demand 20 | helper = CompactLabelsCommentFormHelper() 21 | helper.layout = Layout(*self.fields.keys()) 22 | helper.add_input(SubmitButton()) 23 | helper.add_input(PreviewButton()) 24 | return helper 25 | 26 | 27 | class CompactCommentForm(AbstractCommentForm): 28 | """ 29 | A form with a very compact layout; 30 | all the name/email/phone fields are displayed in a single top row. 31 | It uses Bootstrap 3 layout by default to generate the columns. 32 | """ 33 | 34 | top_row_fields = appsettings.FLUENT_COMMENTS_COMPACT_FIELDS 35 | top_row_columns = appsettings.FLUENT_COMMENTS_COMPACT_GRID_SIZE 36 | top_column_class = appsettings.FLUENT_COMMENTS_COMPACT_COLUMN_CSS_CLASS 37 | 38 | @cached_property 39 | def helper(self): 40 | # As extra service, auto-adjust the layout based on the project settings. 41 | # This allows defining the top-row, and still get either 2 or 3 columns 42 | compact_fields = [name for name in self.fields.keys() if name in self.top_row_fields] 43 | other_fields = [name for name in self.fields.keys() if name not in self.top_row_fields] 44 | col_size = int(self.top_row_columns / len(compact_fields)) 45 | col_class = self.top_column_class.format(size=col_size) 46 | 47 | compact_row = Row(*[Column(name, css_class=col_class) for name in compact_fields]) 48 | 49 | # The fields are already ordered by the AbstractCommentForm.__init__ method. 50 | # See where the compact row should be. 51 | pos = list(self.fields.keys()).index(compact_fields[0]) 52 | new_fields = other_fields 53 | new_fields.insert(pos, compact_row) 54 | 55 | helper = CompactLabelsCommentFormHelper() 56 | helper.layout = Layout(*new_fields) 57 | helper.add_input(SubmitButton()) 58 | helper.add_input(PreviewButton()) 59 | return helper 60 | -------------------------------------------------------------------------------- /fluent_comments/forms/default.py: -------------------------------------------------------------------------------- 1 | from fluent_comments.forms.base import AbstractCommentForm, CommentFormHelper 2 | 3 | 4 | class DefaultCommentForm(AbstractCommentForm): 5 | """ 6 | A simple comment form, backed by a model to save all data (in case email fails). 7 | """ 8 | 9 | helper = CommentFormHelper() 10 | -------------------------------------------------------------------------------- /fluent_comments/forms/helper.py: -------------------------------------------------------------------------------- 1 | from crispy_forms.helper import FormHelper 2 | from crispy_forms.layout import Button, Submit 3 | from crispy_forms.utils import TEMPLATE_PACK 4 | from django import forms 5 | from django.forms.widgets import Input 6 | from django.utils.translation import gettext_lazy as _ 7 | from django_comments import get_form_target 8 | 9 | from fluent_comments import appsettings 10 | 11 | 12 | class CommentFormHelper(FormHelper): 13 | """ 14 | The django-crispy-forms configuration that handles form appearance. 15 | The default is configured to show bootstrap forms nicely. 16 | """ 17 | 18 | form_tag = False # we need to define the form_tag 19 | form_id = "comment-form-ID" 20 | form_class = "js-comments-form {0}".format(appsettings.FLUENT_COMMENTS_FORM_CSS_CLASS) 21 | label_class = appsettings.FLUENT_COMMENTS_LABEL_CSS_CLASS 22 | field_class = appsettings.FLUENT_COMMENTS_FIELD_CSS_CLASS 23 | render_unmentioned_fields = True # like honeypot and security_hash 24 | 25 | BASE_FIELDS_TOP = ("content_type", "object_pk", "timestamp", "security_hash") 26 | BASE_FIELDS_END = ("honeypot",) 27 | 28 | if appsettings.USE_THREADEDCOMMENTS: 29 | BASE_FIELDS_TOP += ("parent",) 30 | 31 | @property 32 | def form_action(self): 33 | return get_form_target() # reads get_form_target from COMMENTS_APP 34 | 35 | def __init__(self, form=None): 36 | super().__init__(form=form) 37 | if form is not None: 38 | # When using the helper like this, it could generate all fields. 39 | self.form_id = "comment-form-{0}".format(form.target_object.pk) 40 | self.attrs = { 41 | "data-object-id": form.target_object.pk, 42 | } 43 | 44 | 45 | class CompactLabelsCommentFormHelper(CommentFormHelper): 46 | """ 47 | Compact labels in the form, show them as placeholder text instead. 48 | 49 | .. note:: 50 | Make sure that the :attr:`layout` attribute is defined and 51 | it has fields added to it, otherwise the placeholders don't appear. 52 | 53 | The text input can easily be resized using CSS like: 54 | 55 | .. code-block: css 56 | 57 | @media only screen and (min-width: 768px) { 58 | form.comments-form input.form-control { 59 | width: 50%; 60 | } 61 | } 62 | 63 | """ 64 | 65 | form_class = ( 66 | CommentFormHelper.form_class.replace("form-horizontal", "form-vertical") 67 | + " comments-form-compact" 68 | ) 69 | label_class = "sr-only" 70 | field_class = "" 71 | 72 | def render_layout(self, form, context, template_pack=TEMPLATE_PACK): 73 | """ 74 | Copy any field label to the ``placeholder`` attribute. 75 | Note, this method is called when :attr:`layout` is defined. 76 | """ 77 | # Writing the label values into the field placeholders. 78 | # This is done at rendering time, so the Form.__init__() could update any labels before. 79 | # Django 1.11 no longer lets EmailInput or URLInput inherit from TextInput, 80 | # so checking for `Input` instead while excluding `HiddenInput`. 81 | for field in form.fields.values(): 82 | if ( 83 | field.label 84 | and isinstance(field.widget, (Input, forms.Textarea)) 85 | and not isinstance(field.widget, forms.HiddenInput) 86 | ): 87 | field.widget.attrs["placeholder"] = u"{0}:".format(field.label) 88 | 89 | return super().render_layout(form, context, template_pack=template_pack) 90 | 91 | 92 | class SubmitButton(Submit): 93 | """ 94 | The submit button to add to the layout. 95 | 96 | Note: the ``name=post`` is mandatory, it helps the 97 | """ 98 | 99 | def __init__(self, text=_("Post Comment"), **kwargs): 100 | super().__init__(name="post", value=text, **kwargs) 101 | 102 | 103 | class PreviewButton(Button): 104 | """ 105 | The preview button to add to the layout. 106 | 107 | Note: the ``name=post`` is mandatory, it helps the 108 | """ 109 | 110 | input_type = "submit" 111 | 112 | def __init__(self, text=_("Preview"), **kwargs): 113 | kwargs.setdefault("css_class", "btn-default") 114 | super().__init__(name="preview", value=text, **kwargs) 115 | -------------------------------------------------------------------------------- /fluent_comments/forms/recaptcha.py: -------------------------------------------------------------------------------- 1 | """ 2 | Variations of the form that use 3 | """ 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | 7 | from fluent_comments.forms._captcha import CaptchaFormMixin 8 | from .compact import CompactCommentForm, CompactLabelsCommentForm 9 | from .default import DefaultCommentForm 10 | 11 | try: 12 | from nocaptcha_recaptcha.fields import NoReCaptchaField 13 | 14 | captcha_field = NoReCaptchaField() 15 | except ImportError: 16 | try: 17 | from captcha.fields import ReCaptchaField 18 | 19 | captcha_field = ReCaptchaField() 20 | 21 | if not getattr(settings, "NOCAPTCHA", False): 22 | raise ImproperlyConfigured( 23 | "reCAPTCHA v1 is phased out. Add `NOCAPTCHA = True` to your settings " 24 | 'to use the modern "no captcha" reCAPTCHA v2.' 25 | ) 26 | except ImportError: 27 | raise ImportError( 28 | "To use '{}', you need to have django-nocaptcha-recaptcha" 29 | " or django-recaptcha2 installed.".format(__name__) 30 | ) 31 | 32 | 33 | class DefaultCommentForm(CaptchaFormMixin, DefaultCommentForm): 34 | """ 35 | Contact form with reCAPTCHA field. 36 | """ 37 | 38 | captcha = captcha_field 39 | 40 | class Media: 41 | js = ("https://www.google.com/recaptcha/api.js",) 42 | 43 | 44 | class CompactCommentForm(CaptchaFormMixin, CompactCommentForm): 45 | """ 46 | Compact variation 1. 47 | """ 48 | 49 | captcha = captcha_field 50 | 51 | class Media: 52 | js = ("https://www.google.com/recaptcha/api.js",) 53 | 54 | 55 | class CompactLabelsCommentForm(CaptchaFormMixin, CompactLabelsCommentForm): 56 | """ 57 | Compact variation 2. 58 | """ 59 | 60 | captcha = captcha_field 61 | 62 | class Media: 63 | js = ("https://www.google.com/recaptcha/api.js",) 64 | -------------------------------------------------------------------------------- /fluent_comments/locale/cs/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-05-04 14:18+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2\n" 20 | 21 | #: admin.py:36 22 | msgid "Content" 23 | msgstr "Obsah" 24 | 25 | #: admin.py:39 26 | msgid "Account information" 27 | msgstr "Informace o účtu" 28 | 29 | #: admin.py:42 30 | msgid "Moderation" 31 | msgstr "Moderování" 32 | 33 | #: admin.py:53 34 | msgid "Hierarchy" 35 | msgstr "Hierarchie" 36 | 37 | #: admin.py:74 38 | msgid "Page" 39 | msgstr "Strana" 40 | 41 | #: admin.py:85 42 | msgid "user's name" 43 | msgstr "jméno uživatele" 44 | 45 | #: forms/helper.py:93 templates/comments/form.html:21 46 | msgid "Post Comment" 47 | msgstr "Odeslat váš komentář" 48 | 49 | #: forms/helper.py:105 templates/comments/form.html:22 50 | #: templates/comments/preview.html:27 51 | msgid "Preview" 52 | msgstr "Náhled" 53 | 54 | #: models.py:44 55 | msgid "Comment" 56 | msgstr "" 57 | 58 | #: models.py:45 59 | msgid "Comments" 60 | msgstr "" 61 | 62 | #: templates/comments/comment.html:22 templates/comments/preview.html:12 63 | msgid "Preview of your comment" 64 | msgstr "Náhled komentáře" 65 | 66 | #: templates/comments/comment.html:26 67 | msgid "Anonymous" 68 | msgstr "Anonymní" 69 | 70 | #: templates/comments/comment.html:29 71 | #, python-format 72 | msgid "on %(submit_date)s" 73 | msgstr "poslal %(submit_date)s" 74 | 75 | #: templates/comments/comment.html:30 76 | msgid "moderated" 77 | msgstr "moderován" 78 | 79 | #: templates/comments/comment.html:31 80 | msgid "reply" 81 | msgstr "odpovědět" 82 | 83 | #: templates/comments/deleted.html:4 84 | msgid "Thanks for removing" 85 | msgstr "Děkujeme za odstranění" 86 | 87 | #: templates/comments/deleted.html:12 88 | msgid "Thanks for removing the comment" 89 | msgstr "Děkujeme za odstranění komentáře" 90 | 91 | #: templates/comments/deleted.html:14 templates/comments/flagged.html:14 92 | msgid "" 93 | "\n" 94 | " Thanks for taking the time to improve the quality of discussion on our " 95 | "site.
\n" 96 | " You will be sent back to the article...\n" 97 | " " 98 | msgstr "" 99 | "\n" 100 | " Děkuji za váš čas věnovaný na zlepšení kvality diskuze na naší stránce." 101 | "
\n" 102 | " Budete přesměrováni zpět na článek...\n" 103 | " " 104 | 105 | #: templates/comments/deleted.html:20 templates/comments/flagged.html:20 106 | #: templates/comments/posted.html:26 107 | msgid "Back to the article" 108 | msgstr "Zpět na článek" 109 | 110 | #: templates/comments/flagged.html:4 111 | msgid "Thanks for flagging" 112 | msgstr "Děkujeme za označkování" 113 | 114 | #: templates/comments/flagged.html:12 115 | msgid "Thanks for flagging the comment" 116 | msgstr "Děkujeme za označkování komentáře" 117 | 118 | #: templates/comments/form.html:4 119 | msgid "Comments are closed." 120 | msgstr "Komentáře jsou uzavřené" 121 | 122 | #: templates/comments/posted.html:3 123 | msgid "Thanks for commenting" 124 | msgstr "Děkujeme za komentář" 125 | 126 | #: templates/comments/posted.html:11 127 | msgid "Thanks for posting your comment" 128 | msgstr "Děkujeme za odeslání komentáře" 129 | 130 | #: templates/comments/posted.html:13 131 | msgid "" 132 | "\n" 133 | " We have received your comment, and posted it on the web site.
\n" 134 | " You will be sent back to the article...\n" 135 | " " 136 | msgstr "" 137 | "\n" 138 | " Váš komentář byl přijat a publikován na naší stránce
\n" 139 | " Budete přesměrován zpět na článek...\n" 140 | " " 141 | 142 | #: templates/comments/preview.html:3 143 | msgid "Preview your comment" 144 | msgstr "Náhled vašeho komentáře" 145 | 146 | #: templates/comments/preview.html:10 147 | msgid "Please correct the error below" 148 | msgid_plural "Please correct the errors below" 149 | msgstr[0] "Prosím opravte chybu níže" 150 | msgstr[1] "Prosím opravte chyby níže" 151 | 152 | #: templates/comments/preview.html:17 153 | msgid "Post your comment" 154 | msgstr "Odeslat váš komentář" 155 | 156 | #: templates/comments/preview.html:20 157 | msgid "Or make changes" 158 | msgstr "Nebo udělat změny" 159 | 160 | #: templates/comments/preview.html:26 161 | msgid "Post" 162 | msgstr "Odeslat" 163 | 164 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:2 165 | msgid "cancel reply" 166 | msgstr "zrušit odpověď" 167 | 168 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:4 169 | msgid "Please wait . . ." 170 | msgstr "Prosím čekejte . . ." 171 | 172 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:6 173 | msgid "Your comment has been posted!" 174 | msgstr "Váš komentář byl publikován!" 175 | 176 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:7 177 | msgid "" 178 | "Your comment has been posted, it will be visible for other users after " 179 | "approval." 180 | msgstr "" 181 | "Váš komentář byl odeslán, bude zobrazen dalším uživatelům jakmile bude " 182 | "schválen." 183 | -------------------------------------------------------------------------------- /fluent_comments/locale/nl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2010-2012 2 | # This file is distributed under the same license as the PACKAGE package. 3 | # 4 | # Diederik van der Boor , 2010-2012 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: \n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2018-05-04 14:18+0200\n" 10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 11 | "Last-Translator: Diederik van der Boor \n" 12 | "Language-Team: Dutch \n" 13 | "Language: nl\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 18 | 19 | #: admin.py:36 20 | msgid "Content" 21 | msgstr "Inhoud" 22 | 23 | #: admin.py:39 24 | msgid "Account information" 25 | msgstr "Account informatie" 26 | 27 | #: admin.py:42 28 | msgid "Moderation" 29 | msgstr "Moderatie" 30 | 31 | #: admin.py:53 32 | msgid "Hierarchy" 33 | msgstr "Hiërarchie" 34 | 35 | #: admin.py:74 36 | msgid "Page" 37 | msgstr "Pagina" 38 | 39 | #: admin.py:85 40 | msgid "user's name" 41 | msgstr "" 42 | 43 | #: forms/helper.py:93 templates/comments/form.html:21 44 | msgid "Post Comment" 45 | msgstr "Plaats reactie" 46 | 47 | #: forms/helper.py:105 templates/comments/form.html:22 48 | #: templates/comments/preview.html:27 49 | msgid "Preview" 50 | msgstr "Voorbeeld" 51 | 52 | #: models.py:44 53 | msgid "Comment" 54 | msgstr "" 55 | 56 | #: models.py:45 57 | msgid "Comments" 58 | msgstr "" 59 | 60 | #: templates/comments/comment.html:22 templates/comments/preview.html:12 61 | msgid "Preview of your comment" 62 | msgstr "Voorbeeld van je reactie" 63 | 64 | #: templates/comments/comment.html:26 65 | msgid "Anonymous" 66 | msgstr "Anoniem" 67 | 68 | #: templates/comments/comment.html:29 69 | #, python-format 70 | msgid "on %(submit_date)s" 71 | msgstr "op %(submit_date)s" 72 | 73 | #: templates/comments/comment.html:30 74 | msgid "moderated" 75 | msgstr "gemodereerd" 76 | 77 | #: templates/comments/comment.html:31 78 | msgid "reply" 79 | msgstr "reageren" 80 | 81 | #: templates/comments/deleted.html:4 82 | msgid "Thanks for removing" 83 | msgstr "Bedankt voor het verwijderen" 84 | 85 | #: templates/comments/deleted.html:12 86 | msgid "Thanks for removing the comment" 87 | msgstr "Bedankt voor het verwijderen van de reactie" 88 | 89 | #: templates/comments/deleted.html:14 templates/comments/flagged.html:14 90 | msgid "" 91 | "\n" 92 | " Thanks for taking the time to improve the quality of discussion on our " 93 | "site.
\n" 94 | " You will be sent back to the article...\n" 95 | " " 96 | msgstr "" 97 | "\n" 98 | " Bedankt voor je tijd om de kwaliteit van discussie op onze site te " 99 | "verbeteren.
\n" 100 | " Je wordt nu teruggestuurd naar de vorige pagina...\n" 101 | " " 102 | 103 | #: templates/comments/deleted.html:20 templates/comments/flagged.html:20 104 | #: templates/comments/posted.html:26 105 | msgid "Back to the article" 106 | msgstr "Terug naar de pagina" 107 | 108 | #: templates/comments/flagged.html:4 109 | msgid "Thanks for flagging" 110 | msgstr "Bedankt voor je melding" 111 | 112 | #: templates/comments/flagged.html:12 113 | msgid "Thanks for flagging the comment" 114 | msgstr "Bedankt voor het rapporteren van de reactie" 115 | 116 | #: templates/comments/form.html:4 117 | msgid "Comments are closed." 118 | msgstr "Reacties zijn gesloten." 119 | 120 | #: templates/comments/posted.html:3 121 | msgid "Thanks for commenting" 122 | msgstr "Bedankt voor je reactie" 123 | 124 | #: templates/comments/posted.html:11 125 | msgid "Thanks for posting your comment" 126 | msgstr "Bedankt voor het insturen van je reactie" 127 | 128 | #: templates/comments/posted.html:13 129 | msgid "" 130 | "\n" 131 | " We have received your comment, and posted it on the web site.
\n" 132 | " You will be sent back to the article...\n" 133 | " " 134 | msgstr "" 135 | "\n" 136 | " We hebben je reactie ontvangen, en geplaatst op de website.
\n" 137 | " Je wordt nu teruggestuurd naar de vorige pagina...\n" 138 | " " 139 | 140 | #: templates/comments/preview.html:3 141 | msgid "Preview your comment" 142 | msgstr "Voorbeeld van je reactie" 143 | 144 | #: templates/comments/preview.html:10 145 | msgid "Please correct the error below" 146 | msgid_plural "Please correct the errors below" 147 | msgstr[0] "Corrigeer a.u.b. de fout hieronder" 148 | msgstr[1] "Corrigeer a.u.b. de fouten hieronder" 149 | 150 | #: templates/comments/preview.html:17 151 | msgid "Post your comment" 152 | msgstr "Reactie plaatsen" 153 | 154 | #: templates/comments/preview.html:20 155 | msgid "Or make changes" 156 | msgstr "Of pas het bericht aan" 157 | 158 | #: templates/comments/preview.html:26 159 | msgid "Post" 160 | msgstr "Plaatsen" 161 | 162 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:2 163 | msgid "cancel reply" 164 | msgstr "annuleer reactie" 165 | 166 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:4 167 | msgid "Please wait . . ." 168 | msgstr "Even geduld . . ." 169 | 170 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:6 171 | msgid "Your comment has been posted!" 172 | msgstr "Je reactie is geplaatst!" 173 | 174 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:7 175 | msgid "" 176 | "Your comment has been posted, it will be visible for other users after " 177 | "approval." 178 | msgstr "" 179 | "Je reactie is geplaatst en wordt pas na goedkeuring zichtbaar voor andere " 180 | "gebruikers." 181 | -------------------------------------------------------------------------------- /fluent_comments/locale/sk/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-05-04 14:18+0200\n" 12 | "PO-Revision-Date: 2016-05-09 22:00+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2\n" 20 | 21 | #: admin.py:36 22 | msgid "Content" 23 | msgstr "Obsah" 24 | 25 | #: admin.py:39 26 | msgid "Account information" 27 | msgstr "Informácie o účte" 28 | 29 | #: admin.py:42 30 | msgid "Moderation" 31 | msgstr "Moderácia" 32 | 33 | #: admin.py:53 34 | msgid "Hierarchy" 35 | msgstr "Hierarchia" 36 | 37 | #: admin.py:74 38 | msgid "Page" 39 | msgstr "Stránka" 40 | 41 | #: admin.py:85 42 | msgid "user's name" 43 | msgstr "používateľské meno" 44 | 45 | #: forms/helper.py:93 templates/comments/form.html:21 46 | msgid "Post Comment" 47 | msgstr "Odoslať komentár" 48 | 49 | #: forms/helper.py:105 templates/comments/form.html:22 50 | #: templates/comments/preview.html:27 51 | msgid "Preview" 52 | msgstr "Náhľad" 53 | 54 | #: models.py:44 55 | msgid "Comment" 56 | msgstr "" 57 | 58 | #: models.py:45 59 | msgid "Comments" 60 | msgstr "" 61 | 62 | #: templates/comments/comment.html:22 templates/comments/preview.html:12 63 | msgid "Preview of your comment" 64 | msgstr "Náhľad komentára" 65 | 66 | #: templates/comments/comment.html:26 67 | msgid "Anonymous" 68 | msgstr "Anonym" 69 | 70 | #: templates/comments/comment.html:29 71 | #, python-format 72 | msgid "on %(submit_date)s" 73 | msgstr "odoslané %(submit_date)s" 74 | 75 | #: templates/comments/comment.html:30 76 | msgid "moderated" 77 | msgstr "moderované" 78 | 79 | #: templates/comments/comment.html:31 80 | msgid "reply" 81 | msgstr "odpovedať" 82 | 83 | #: templates/comments/deleted.html:4 84 | msgid "Thanks for removing" 85 | msgstr "Ďakujeme za odstránenie" 86 | 87 | #: templates/comments/deleted.html:12 88 | msgid "Thanks for removing the comment" 89 | msgstr "Ďakujeme za odstránenie komentára" 90 | 91 | #: templates/comments/deleted.html:14 templates/comments/flagged.html:14 92 | msgid "" 93 | "\n" 94 | " Thanks for taking the time to improve the quality of discussion on our " 95 | "site.
\n" 96 | " You will be sent back to the article...\n" 97 | " " 98 | msgstr "" 99 | "\n" 100 | " Vďaka, že ste si našli čas na zlepšenie kvality diskusie na našom webe." 101 | "
\n" 102 | " Presmerujeme vás späť na diskusiu...\n" 103 | " " 104 | 105 | #: templates/comments/deleted.html:20 templates/comments/flagged.html:20 106 | #: templates/comments/posted.html:26 107 | msgid "Back to the article" 108 | msgstr "Naspäť k diskusii" 109 | 110 | #: templates/comments/flagged.html:4 111 | msgid "Thanks for flagging" 112 | msgstr "Ďakujeme za označenie" 113 | 114 | #: templates/comments/flagged.html:12 115 | msgid "Thanks for flagging the comment" 116 | msgstr "Vďaka za označenie komentára" 117 | 118 | #: templates/comments/form.html:4 119 | msgid "Comments are closed." 120 | msgstr "Diskusia je uzavretá." 121 | 122 | #: templates/comments/posted.html:3 123 | msgid "Thanks for commenting" 124 | msgstr "Vďaka za komentár" 125 | 126 | #: templates/comments/posted.html:11 127 | msgid "Thanks for posting your comment" 128 | msgstr "Vďaka za komentár" 129 | 130 | #: templates/comments/posted.html:13 131 | msgid "" 132 | "\n" 133 | " We have received your comment, and posted it on the web site.
\n" 134 | " You will be sent back to the article...\n" 135 | " " 136 | msgstr "" 137 | "\n" 138 | " Dostali sme váš komentár a zobrazili sme ho na stránke.
\n" 139 | " Presmerujeme vás späť na diskusiu...\n" 140 | " " 141 | 142 | #: templates/comments/preview.html:3 143 | msgid "Preview your comment" 144 | msgstr "Náhľad komentára" 145 | 146 | #: templates/comments/preview.html:10 147 | msgid "Please correct the error below" 148 | msgid_plural "Please correct the errors below" 149 | msgstr[0] "Prosím opravte nasledujúcu chybu" 150 | msgstr[1] "Prosím opravte nasledujúce chyby" 151 | 152 | #: templates/comments/preview.html:17 153 | msgid "Post your comment" 154 | msgstr "Odoslať komentár" 155 | 156 | #: templates/comments/preview.html:20 157 | msgid "Or make changes" 158 | msgstr "Alebo spravte zmeny" 159 | 160 | #: templates/comments/preview.html:26 161 | msgid "Post" 162 | msgstr "Odoslať" 163 | 164 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:2 165 | msgid "cancel reply" 166 | msgstr "zrušiť odpoveď" 167 | 168 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:4 169 | msgid "Please wait . . ." 170 | msgstr "Počkajte, prosím . . ." 171 | 172 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:6 173 | msgid "Your comment has been posted!" 174 | msgstr "Váš komentár bol publikovaný!" 175 | 176 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:7 177 | msgid "" 178 | "Your comment has been posted, it will be visible for other users after " 179 | "approval." 180 | msgstr "" 181 | "Komentár bol odoslaný, pre ostatných užívateľov bude viditeľný po súhlase " 182 | "moderátora." 183 | -------------------------------------------------------------------------------- /fluent_comments/locale/zh/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 0.1\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-05-04 14:18+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Zhiwei Wang \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: Chinese\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 20 | 21 | #: admin.py:36 22 | msgid "Content" 23 | msgstr "内容" 24 | 25 | #: admin.py:39 26 | msgid "Account information" 27 | msgstr "账户信息" 28 | 29 | #: admin.py:42 30 | msgid "Moderation" 31 | msgstr "审核" 32 | 33 | #: admin.py:53 34 | msgid "Hierarchy" 35 | msgstr "层级" 36 | 37 | #: admin.py:74 38 | msgid "Page" 39 | msgstr "页面" 40 | 41 | #: admin.py:85 42 | msgid "user's name" 43 | msgstr "用户名称" 44 | 45 | #: forms/helper.py:93 templates/comments/form.html:21 46 | msgid "Post Comment" 47 | msgstr "发布评论" 48 | 49 | #: forms/helper.py:105 templates/comments/form.html:22 50 | #: templates/comments/preview.html:27 51 | msgid "Preview" 52 | msgstr "预览" 53 | 54 | #: models.py:44 55 | msgid "Comment" 56 | msgstr "" 57 | 58 | #: models.py:45 59 | msgid "Comments" 60 | msgstr "" 61 | 62 | #: templates/comments/comment.html:22 templates/comments/preview.html:12 63 | msgid "Preview of your comment" 64 | msgstr "预览评论" 65 | 66 | #: templates/comments/comment.html:26 67 | msgid "Anonymous" 68 | msgstr "匿名" 69 | 70 | #: templates/comments/comment.html:29 71 | #, python-format 72 | msgid "on %(submit_date)s" 73 | msgstr "于%(submit_date)s" 74 | 75 | #: templates/comments/comment.html:30 76 | msgid "moderated" 77 | msgstr "正在审核" 78 | 79 | #: templates/comments/comment.html:31 80 | msgid "reply" 81 | msgstr "回复" 82 | 83 | #: templates/comments/deleted.html:4 84 | msgid "Thanks for removing" 85 | msgstr "感谢移除" 86 | 87 | #: templates/comments/deleted.html:12 88 | msgid "Thanks for removing the comment" 89 | msgstr "感谢移除该评论" 90 | 91 | #: templates/comments/deleted.html:14 templates/comments/flagged.html:14 92 | msgid "" 93 | "\n" 94 | " Thanks for taking the time to improve the quality of discussion on our " 95 | "site.
\n" 96 | " You will be sent back to the article...\n" 97 | " " 98 | msgstr "" 99 | "\n" 100 | " 感谢您花时间提升我们网站的评论质量。
\n" 101 | " 您将回到文章页面。。。\n" 102 | " " 103 | 104 | #: templates/comments/deleted.html:20 templates/comments/flagged.html:20 105 | #: templates/comments/posted.html:26 106 | msgid "Back to the article" 107 | msgstr "返回文章" 108 | 109 | #: templates/comments/flagged.html:4 110 | msgid "Thanks for flagging" 111 | msgstr "感谢标记" 112 | 113 | #: templates/comments/flagged.html:12 114 | msgid "Thanks for flagging the comment" 115 | msgstr "感谢标记该评论" 116 | 117 | #: templates/comments/form.html:4 118 | msgid "Comments are closed." 119 | msgstr "评论已关闭" 120 | 121 | #: templates/comments/posted.html:3 122 | msgid "Thanks for commenting" 123 | msgstr "感谢评论" 124 | 125 | #: templates/comments/posted.html:11 126 | msgid "Thanks for posting your comment" 127 | msgstr "感谢发布您的评论" 128 | 129 | #: templates/comments/posted.html:13 130 | msgid "" 131 | "\n" 132 | " We have received your comment, and posted it on the web site.
\n" 133 | " You will be sent back to the article...\n" 134 | " " 135 | msgstr "" 136 | "\n" 137 | " 我们已经收到您的评论并发布在了网站上。
\n" 138 | " 您将回到文章页面。。。\n" 139 | " " 140 | 141 | #: templates/comments/preview.html:3 142 | msgid "Preview your comment" 143 | msgstr "预览评论" 144 | 145 | #: templates/comments/preview.html:10 146 | msgid "Please correct the error below" 147 | msgid_plural "Please correct the errors below" 148 | msgstr[0] "请修正下面的错误" 149 | msgstr[1] "请修正以下这些错误" 150 | 151 | #: templates/comments/preview.html:17 152 | msgid "Post your comment" 153 | msgstr "发布评论" 154 | 155 | #: templates/comments/preview.html:20 156 | msgid "Or make changes" 157 | msgstr "修改" 158 | 159 | #: templates/comments/preview.html:26 160 | msgid "Post" 161 | msgstr "发布" 162 | 163 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:2 164 | msgid "cancel reply" 165 | msgstr "取消回复" 166 | 167 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:4 168 | msgid "Please wait . . ." 169 | msgstr "请稍候。。。" 170 | 171 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:6 172 | msgid "Your comment has been posted!" 173 | msgstr "您的评论已发布!" 174 | 175 | #: templates/fluent_comments/templatetags/ajax_comment_tags.html:7 176 | msgid "" 177 | "Your comment has been posted, it will be visible for other users after " 178 | "approval." 179 | msgstr "您的评论已发布,待审核后可见。" 180 | -------------------------------------------------------------------------------- /fluent_comments/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import models, migrations 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ("django_comments", "__first__"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="FluentComment", 14 | fields=[], 15 | options={ 16 | "managed": False, 17 | "proxy": True, 18 | "managed": False, 19 | "verbose_name": "Comment", 20 | "verbose_name_plural": "Comments", 21 | }, 22 | bases=("django_comments.comment",), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /fluent_comments/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-fluent/django-fluent-comments/5d53578544b5b5ecd8c34719672fd43668fb2dd4/fluent_comments/migrations/__init__.py -------------------------------------------------------------------------------- /fluent_comments/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericRelation 2 | from django.utils.translation import gettext_lazy as _ 3 | from django_comments import get_model as get_comments_model 4 | from django_comments.managers import CommentManager 5 | 6 | from fluent_comments import appsettings 7 | 8 | if appsettings.USE_THREADEDCOMMENTS: 9 | from threadedcomments.models import ThreadedComment as BaseModel 10 | else: 11 | from django_comments.models import Comment as BaseModel 12 | 13 | 14 | class FluentCommentManager(CommentManager): 15 | """ 16 | Manager to optimize SQL queries for comments. 17 | """ 18 | 19 | def get_queryset(self): 20 | return super().get_queryset().select_related("user") 21 | 22 | 23 | class FluentComment(BaseModel): 24 | """ 25 | Proxy model to make sure that a ``select_related()`` is performed on the ``user`` field. 26 | """ 27 | 28 | objects = FluentCommentManager() 29 | 30 | class Meta: 31 | verbose_name = _("Comment") 32 | verbose_name_plural = _("Comments") 33 | proxy = True 34 | managed = False 35 | 36 | 37 | def get_comments_for_model(content_object, include_moderated=False): 38 | """ 39 | Return the QuerySet with all comments for a given model. 40 | """ 41 | qs = get_comments_model().objects.for_model(content_object) 42 | 43 | if not include_moderated: 44 | qs = qs.filter(is_public=True, is_removed=False) 45 | 46 | return qs 47 | 48 | 49 | class CommentsRelation(GenericRelation): 50 | """ 51 | A :class:`~django.contrib.contenttypes.generic.GenericRelation` which can be applied to a parent model that 52 | is expected to have comments. For example: 53 | 54 | .. code-block:: python 55 | 56 | class Article(models.Model): 57 | comments_set = CommentsRelation() 58 | """ 59 | 60 | def __init__(self, *args, **kwargs): 61 | super().__init__( 62 | to=get_comments_model(), 63 | content_type_field="content_type", 64 | object_id_field="object_pk", 65 | **kwargs 66 | ) 67 | -------------------------------------------------------------------------------- /fluent_comments/moderation.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from urllib.parse import urljoin 3 | 4 | from akismet import SpamStatus 5 | from django_comments.moderation import moderator, CommentModerator 6 | 7 | from fluent_comments import appsettings 8 | from fluent_comments.akismet import akismet_check 9 | from fluent_comments.email import send_comment_posted 10 | from fluent_comments.utils import split_words 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | # Akismet code originally based on django-comments-spamfighter. 16 | 17 | __all__ = ( 18 | "FluentCommentsModerator", 19 | "moderate_model", 20 | "get_model_moderator", 21 | "comments_are_open", 22 | "comments_are_moderated", 23 | ) 24 | 25 | 26 | class FluentCommentsModerator(CommentModerator): 27 | """ 28 | Moderation policy for fluent-comments. 29 | """ 30 | 31 | auto_close_field = None 32 | auto_moderate_field = None 33 | enable_field = None 34 | 35 | close_after = appsettings.FLUENT_COMMENTS_CLOSE_AFTER_DAYS 36 | moderate_after = appsettings.FLUENT_COMMENTS_MODERATE_AFTER_DAYS 37 | email_notification = appsettings.FLUENT_COMMENTS_USE_EMAIL_NOTIFICATION 38 | akismet_check = appsettings.FLUENT_CONTENTS_USE_AKISMET 39 | akismet_check_action = appsettings.FLUENT_COMMENTS_AKISMET_ACTION 40 | moderate_bad_words = set(appsettings.FLUENT_COMMENTS_MODERATE_BAD_WORDS) 41 | 42 | def allow(self, comment, content_object, request): 43 | """ 44 | Determine whether a given comment is allowed to be posted on a given object. 45 | 46 | Returns ``True`` if the comment should be allowed, ``False`` otherwise. 47 | """ 48 | # Parent class check 49 | if not super().allow(comment, content_object, request): 50 | return False 51 | 52 | # Akismet check 53 | if self.akismet_check: 54 | akismet_result = akismet_check(comment, content_object, request) 55 | if self.akismet_check_action == "delete" and akismet_result in ( 56 | SpamStatus.ProbableSpam, 57 | SpamStatus.DefiniteSpam, 58 | ): 59 | return False # Akismet marked the comment as spam. 60 | elif self.akismet_check_action == "auto" and akismet_result == SpamStatus.DefiniteSpam: 61 | return False # Clearly spam 62 | 63 | return True 64 | 65 | def moderate(self, comment, content_object, request): 66 | """ 67 | Determine whether a given comment on a given object should be allowed to show up immediately, 68 | or should be marked non-public and await approval. 69 | 70 | Returns ``True`` if the comment should be moderated (marked non-public), ``False`` otherwise. 71 | """ 72 | 73 | # Soft delete checks are done first, so these comments are not mistakenly "just moderated" 74 | # for expiring the `close_after` date, but correctly get marked as spam instead. 75 | # This helps staff to quickly see which comments need real moderation. 76 | if self.akismet_check: 77 | akismet_result = akismet_check(comment, content_object, request) 78 | if akismet_result: 79 | # Typically action=delete never gets here, unless the service was having problems. 80 | if ( 81 | akismet_result 82 | in ( 83 | SpamStatus.ProbableSpam, 84 | SpamStatus.DefiniteSpam, 85 | ) 86 | and self.akismet_check_action in ("auto", "soft_delete", "delete") 87 | ): 88 | comment.is_removed = True # Set extra marker 89 | 90 | # SpamStatus.Unknown or action=moderate will end up in the moderation queue 91 | return True 92 | 93 | # Parent class check 94 | if super().moderate(comment, content_object, request): 95 | return True 96 | 97 | # Bad words check 98 | if self.moderate_bad_words: 99 | input_words = split_words(comment.comment) 100 | if self.moderate_bad_words.intersection(input_words): 101 | return True 102 | 103 | # Akismet check 104 | if self.akismet_check and self.akismet_check_action not in ("soft_delete", "delete"): 105 | # Return True if akismet marks this comment as spam and we want to moderate it. 106 | if akismet_check(comment, content_object, request): 107 | return True 108 | 109 | return False 110 | 111 | def email(self, comment, content_object, request): 112 | """ 113 | Overwritten for a better email notification. 114 | """ 115 | if not self.email_notification: 116 | return 117 | 118 | send_comment_posted(comment, request) 119 | 120 | 121 | class NullModerator(FluentCommentsModerator): 122 | """ 123 | A moderator class that has the same effect as not being here. 124 | It allows all comments, disabling all moderation. 125 | This can be used in ``FLUENT_COMMENTS_DEFAULT_MODERATOR``. 126 | """ 127 | 128 | def allow(self, comment, content_object, request): 129 | return True 130 | 131 | def moderate(self, comment, content_object, request): 132 | logger.info("Unconditionally allow comment, no default moderation set.") 133 | return False 134 | 135 | 136 | class AlwaysModerate(FluentCommentsModerator): 137 | """ 138 | A moderator class that will always mark the comment as moderated. 139 | This can be used in ``FLUENT_COMMENTS_DEFAULT_MODERATOR``. 140 | """ 141 | 142 | def moderate(self, comment, content_object, request): 143 | # Still calling super in case Akismet marks the comment as spam. 144 | return super().moderate(comment, content_object, request) or True 145 | 146 | 147 | class AlwaysDeny(FluentCommentsModerator): 148 | """ 149 | A moderator that will deny any comments to be posted. 150 | This can be used in ``FLUENT_COMMENTS_DEFAULT_MODERATOR``. 151 | """ 152 | 153 | def allow(self, comment, content_object, request): 154 | logger.warning( 155 | "Discarded comment on unregistered model '%s'", content_object.__class__.__name__ 156 | ) 157 | return False 158 | 159 | 160 | def moderate_model(ParentModel, publication_date_field=None, enable_comments_field=None): 161 | """ 162 | Register a parent model (e.g. ``Blog`` or ``Article``) that should receive comment moderation. 163 | 164 | :param ParentModel: The parent model, e.g. a ``Blog`` or ``Article`` model. 165 | :param publication_date_field: The field name of a :class:`~django.db.models.DateTimeField` in the parent model which stores the publication date. 166 | :type publication_date_field: str 167 | :param enable_comments_field: The field name of a :class:`~django.db.models.BooleanField` in the parent model which stores the whether comments are enabled. 168 | :type enable_comments_field: str 169 | """ 170 | attrs = { 171 | "auto_close_field": publication_date_field, 172 | "auto_moderate_field": publication_date_field, 173 | "enable_field": enable_comments_field, 174 | } 175 | ModerationClass = type(ParentModel.__name__ + "Moderator", (FluentCommentsModerator,), attrs) 176 | moderator.register(ParentModel, ModerationClass) 177 | 178 | 179 | def get_model_moderator(model): 180 | """ 181 | Return the moderator class that is registered with a content object. 182 | If there is no associated moderator with a class, None is returned. 183 | 184 | :param model: The Django model registered with :func:`moderate_model` 185 | :type model: :class:`~django.db.models.Model` 186 | :return: The moderator class which holds the moderation policies. 187 | :rtype: :class:`~django_comments.moderation.CommentModerator` 188 | """ 189 | try: 190 | return moderator._registry[model] 191 | except KeyError: 192 | return None 193 | 194 | 195 | def comments_are_open(content_object): 196 | """ 197 | Return whether comments are still open for a given target object. 198 | """ 199 | moderator = get_model_moderator(content_object.__class__) 200 | if moderator is None: 201 | return True 202 | 203 | # Check the 'enable_field', 'auto_close_field' and 'close_after', 204 | # by reusing the basic Django policies. 205 | return CommentModerator.allow(moderator, None, content_object, None) 206 | 207 | 208 | def comments_are_moderated(content_object): 209 | """ 210 | Return whether comments are moderated for a given target object. 211 | """ 212 | moderator = get_model_moderator(content_object.__class__) 213 | if moderator is None: 214 | return False 215 | 216 | # Check the 'auto_moderate_field', 'moderate_after', 217 | # by reusing the basic Django policies. 218 | return CommentModerator.moderate(moderator, None, content_object, None) 219 | -------------------------------------------------------------------------------- /fluent_comments/receivers.py: -------------------------------------------------------------------------------- 1 | """ 2 | The comment signals are handled to fallback to a default moderator. 3 | 4 | This avoids not checking for spam or sending email notifications 5 | for comments that bypassed the moderator registration 6 | (e.g. posting a comment on a different page). 7 | 8 | This is especially useful when a django-fluent-contents "CommentsAreaItem" 9 | element is added to a random page subclass (which is likely not registered). 10 | """ 11 | import logging 12 | 13 | import django_comments 14 | from django.core.exceptions import ImproperlyConfigured 15 | from django.dispatch import receiver 16 | from django.utils.module_loading import import_string 17 | from django_comments import signals 18 | 19 | from fluent_comments import appsettings, moderation 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def load_default_moderator(): 25 | """ 26 | Find a moderator object 27 | """ 28 | if appsettings.FLUENT_COMMENTS_DEFAULT_MODERATOR == "default": 29 | # Perform spam checks 30 | return moderation.FluentCommentsModerator(None) 31 | elif appsettings.FLUENT_COMMENTS_DEFAULT_MODERATOR == "deny": 32 | # Deny all comments not from known registered models. 33 | return moderation.AlwaysDeny(None) 34 | elif str(appsettings.FLUENT_COMMENTS_DEFAULT_MODERATOR).lower() == "none": 35 | # Disables default moderator 36 | return moderation.NullModerator(None) 37 | elif "." in appsettings.FLUENT_COMMENTS_DEFAULT_MODERATOR: 38 | return import_string(appsettings.FLUENT_COMMENTS_DEFAULT_MODERATOR)(None) 39 | else: 40 | raise ImproperlyConfigured( 41 | "Bad FLUENT_COMMENTS_DEFAULT_MODERATOR value. Provide default/deny/none or a dotted path" 42 | ) 43 | 44 | 45 | default_moderator = load_default_moderator() 46 | CommentModel = django_comments.get_model() 47 | 48 | 49 | @receiver(signals.comment_will_be_posted) 50 | def on_comment_will_be_posted(sender, comment, request, **kwargs): 51 | """ 52 | Make sure both the Ajax and regular comments are checked for moderation. 53 | This signal is also used to link moderators to the comment posting. 54 | """ 55 | content_object = comment.content_object 56 | moderator = moderation.get_model_moderator(content_object.__class__) 57 | if moderator and comment.__class__ is not CommentModel: 58 | # Help with some hard to diagnose problems. The default Django moderator connects 59 | # to the configured comment model. When this model differs from the signal sender, 60 | # the the form stores a different model then COMMENTS_APP provides. 61 | moderator = None 62 | logger.warning( 63 | "Comment of type '%s' was not moderated by '%s', " 64 | "because the parent '%s' has a moderator installed for '%s' instead", 65 | comment.__class__.__name__, 66 | moderator.__class__.__name__, 67 | content_object.__class__.__name__, 68 | CommentModel.__name__, 69 | ) 70 | 71 | if moderator is None: 72 | logger.info( 73 | "Using default moderator for comment '%s' on parent '%s'", 74 | comment.__class__.__name__, 75 | content_object.__class__.__name__, 76 | ) 77 | _run_default_moderator(comment, content_object, request) 78 | 79 | 80 | def _run_default_moderator(comment, content_object, request): 81 | """ 82 | Run the default moderator 83 | """ 84 | # The default moderator will likely not check things like "auto close". 85 | # It can still provide akismet and bad word checking. 86 | if not default_moderator.allow(comment, content_object, request): 87 | # Comment will be disallowed outright (HTTP 403 response) 88 | return False 89 | 90 | if default_moderator.moderate(comment, content_object, request): 91 | comment.is_public = False 92 | 93 | 94 | @receiver(signals.comment_was_posted) 95 | def on_comment_posted(sender, comment, request, **kwargs): 96 | """ 97 | Send email notification of a new comment to site staff when email notifications have been requested. 98 | """ 99 | content_object = comment.content_object 100 | 101 | moderator = moderation.get_model_moderator(content_object.__class__) 102 | if moderator is None or comment.__class__ is not CommentModel: 103 | # No custom moderator means no email would be sent. 104 | # This still pass the comment to the default moderator. 105 | default_moderator.email(comment, content_object, request) 106 | -------------------------------------------------------------------------------- /fluent_comments/static/fluent_comments/css/ajaxcomments.css: -------------------------------------------------------------------------------- 1 | #comment-waiting { 2 | line-height: 16px; 3 | } 4 | 5 | #comment-waiting img { 6 | vertical-align: middle; 7 | padding: 0 4px 0 10px; 8 | } 9 | 10 | #comment-added-message, 11 | #comment-thanks { 12 | padding-left: 10px; 13 | } 14 | 15 | .comment-moderated-flag { 16 | font-variant: small-caps; 17 | margin-left: 5px; 18 | } 19 | 20 | #div_id_honeypot { 21 | /* Hide the honeypot from django_comments by default */ 22 | display: none; 23 | } 24 | 25 | 26 | /* ---- threaded comments ---- */ 27 | 28 | ul.comment-list-wrapper { 29 | /* to avoid touching our own "ul" tags, our tags are explicitly decorated with a class selector */ 30 | margin-left: 0; 31 | padding-left: 0; 32 | } 33 | 34 | ul.comment-list-wrapper ul.comment-list-wrapper { 35 | margin-left: 1em; 36 | padding-left: 0; 37 | } 38 | 39 | li.comment-wrapper { 40 | list-style: none; 41 | margin-left: 0; 42 | padding-left: 0; 43 | } 44 | 45 | .js-comments-form-orig-position .comment-cancel-reply-link { 46 | display: none; 47 | } 48 | -------------------------------------------------------------------------------- /fluent_comments/static/fluent_comments/img/ajax-wait.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-fluent/django-fluent-comments/5d53578544b5b5ecd8c34719672fd43668fb2dd4/fluent_comments/static/fluent_comments/img/ajax-wait.gif -------------------------------------------------------------------------------- /fluent_comments/templates/comments/comment.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Something that django_comments does not provide: 3 | An individual template for a single comment, to easily be reused. 4 | 5 | This include is also used by the Ajax comments view. 6 | The div id should be "c{id}", because the comment.get_absolute_url() points to it. 7 | 8 | NOTE: to override the displayed date format, don't replace this template. 9 | Instead, define DATETIME_FORMAT in a locale file. Requires setting: 10 | 11 | FORMAT_MODULE_PATH = 'settings.locale' 12 | 13 | Then create 'settings/locale/XY/formats.py' with: 14 | 15 | DATETIME_FORMAT = '...' 16 | 17 | This should give you consistent dates across all views. 18 | {% endcomment %} 19 | {% load i18n %} 20 | 21 | {% block comment_item %} 22 | {% if preview %}

{% trans "Preview of your comment" %}

{% endif %} 23 |

24 | {% block comment_title %} 25 | {% if comment.url %}{% endif %} 26 | {% if comment.name %}{{ comment.name }}{% else %}{% trans "Anonymous" %}{% endif %}{% comment %} 27 | {% endcomment %}{% if comment.url %}{% endif %} 28 | {% blocktrans with submit_date=comment.submit_date %}on {{ submit_date }}{% endblocktrans %} 29 | {% if not comment.is_public %}({% trans "moderated" %}){% endif %} 30 | {% if USE_THREADEDCOMMENTS and not preview %}{% trans "reply" %}{% endif %} 31 | {% endblock %} 32 |

33 | 34 |
{{ comment.comment|linebreaks }}
35 | {% endblock %} 36 |
37 | -------------------------------------------------------------------------------- /fluent_comments/templates/comments/comment_notification_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{ site }}: New comment 10 | 11 | 14 | 15 | 16 | 17 | Posted by: {{ comment.user_name|default:comment.user }} 18 | 19 |
20 | A new comment has been posted on your site "{{ site }}", to the page entitled 21 | {{ content_object }}. 22 |
23 | 24 |
25 | 26 | {% if comment.title %} 27 |
Title: {{ comment.title }}
28 | {% endif %} 29 |
Name: {{ comment.user_name|default:comment.user }}
30 |
Email: {{ comment.user_email }}
31 |
Homepage: {{ comment.user_url }}
32 |
Moderated: {{ comment.is_public|yesno:'no,yes' }}
33 | 34 |
35 | Comment:
36 | {{ comment.comment }} 37 |
38 | 39 |
40 | 41 |
You have the following options available:
42 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /fluent_comments/templates/comments/comment_notification_email.txt: -------------------------------------------------------------------------------- 1 | {% autoescape off %}{% comment %} 2 | {% endcomment %}A new comment has been posted on your site "{{ site }}, to the page entitled "{{ content_object }}". 3 | Link to the page: {{ request.scheme }}://{{ site.domain }}{{ content_object.get_absolute_url }} 4 | 5 | {% if comment.title %}Title: {{ comment.title }} 6 | {% endif %}Name: {{ comment.user_name|default:comment.user }} 7 | Email: {{ comment.user_email }} 8 | Homepage: {{ comment.user_url }} 9 | Moderated: {{ comment.is_public|yesno:'no,yes' }} 10 | 11 | Comment: 12 | {{ comment.comment }} 13 | 14 | ---- 15 | You have the following options available: 16 | View comment -- {{ request.scheme }}://{{ site.domain }}{{ comment.get_absolute_url }} 17 | Flag comment -- {{ request.scheme }}://{{ site.domain }}{% url 'comments-flag' comment.pk %} 18 | Delete comment -- {{ request.scheme }}://{{ site.domain }}{% url 'comments-delete' comment.pk %} 19 | {% endautoescape %} 20 | -------------------------------------------------------------------------------- /fluent_comments/templates/comments/deleted.html: -------------------------------------------------------------------------------- 1 | {% extends "comments/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Thanks for removing" %}.{% endblock %} 5 | 6 | {% block extrahead %} 7 | {{ block.super }} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 |

{% trans "Thanks for removing the comment" %}

13 |

14 | {% blocktrans %} 15 | Thanks for taking the time to improve the quality of discussion on our site.
16 | You will be sent back to the article... 17 | {% endblocktrans %} 18 |

19 | 20 |

{% trans "Back to the article" %}

21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /fluent_comments/templates/comments/flagged.html: -------------------------------------------------------------------------------- 1 | {% extends "comments/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Thanks for flagging" %}.{% endblock %} 5 | 6 | {% block extrahead %} 7 | {{ block.super }} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 |

{% trans "Thanks for flagging the comment" %}

13 |

14 | {% blocktrans %} 15 | Thanks for taking the time to improve the quality of discussion on our site.
16 | You will be sent back to the article... 17 | {% endblocktrans %} 18 |

19 | 20 |

{% trans "Back to the article" %}

21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /fluent_comments/templates/comments/form.html: -------------------------------------------------------------------------------- 1 | {% load comments i18n crispy_forms_tags fluent_comments_tags %} 2 | 3 | {% if not form.target_object|comments_are_open %} 4 |

{% trans "Comments are closed." %}

5 | {% else %} 6 | {% if not form.helper or not form.helper.form_tag %} 7 |
9 | {% if next %}
{% endif %} 10 | {% endif %} 11 | 12 | {% block comment_form %} 13 | {% block form_fields %} 14 | {% crispy form %} 15 | {% endblock %} 16 | 17 | {% block form_actions %} 18 | {% if not form.helper.inputs %} 19 |
20 |
21 | 22 | 23 | {% ajax_comment_tags for form.target_object %} 24 |
25 |
26 | {% else %} 27 | {% ajax_comment_tags for form.target_object %} 28 | {% endif %} 29 | {% endblock %} 30 | {% endblock %} 31 | 32 | {% if not form.helper or not form.helper.form_tag %} 33 |
34 | {% endif %} 35 | {% endif %} 36 | -------------------------------------------------------------------------------- /fluent_comments/templates/comments/list.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | 3 | Since we support both flat comments, and threadedcomments, 4 | the 'fluent_comments_list' templatetag loads the proper template. 5 | 6 | It either loads: 7 | - fluent_comments/templatetags/flat_html.html 8 | - fluent_comments/templatetags/threaded_list.html 9 | 10 | Both reuse comments/comment.html eventually. 11 | To style comments, consider overwriting the comment.html template. 12 | 13 | {% endcomment %} 14 | {% load fluent_comments_tags %}{% fluent_comments_list %} 15 | -------------------------------------------------------------------------------- /fluent_comments/templates/comments/posted.html: -------------------------------------------------------------------------------- 1 | {% extends "comments/base.html" %}{% load i18n fluent_comments_tags %} 2 | 3 | {% block title %}{% trans "Thanks for commenting" %}{% endblock %} 4 | 5 | {% block extrahead %} 6 | {{ block.super }} 7 | 8 | {% endblock %} 9 | 10 | {% block content %} 11 |

{% trans "Thanks for posting your comment" %}

12 |

13 | {% blocktrans %} 14 | We have received your comment, and posted it on the web site.
15 | You will be sent back to the article... 16 | {% endblocktrans %} 17 |

18 | 19 | {% if comment %}{# django_comments accepts GET without data #} 20 | {# Use identical formatting to normal comment list #} 21 |
22 | {% render_comment comment %} 23 |
24 | {% endif %} 25 | 26 |

{% trans "Back to the article" %}

27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /fluent_comments/templates/comments/preview.html: -------------------------------------------------------------------------------- 1 | {% extends "comments/base.html" %}{% load i18n crispy_forms_tags comments %} 2 | 3 | {% block title %}{% trans "Preview your comment" %}{% endblock %} 4 | 5 | {% block content %} 6 |
{% csrf_token %} 7 | {% if next %}
{% endif %} 8 | 9 | {% if form.errors %} 10 |

{% blocktrans count form.errors|length as counter %}Please correct the error below{% plural %}Please correct the errors below{% endblocktrans %}

11 | {% else %} 12 |

{% trans "Preview of your comment" %}

13 | 14 |
{{ comment|linebreaks }}
15 | 16 |
17 | 18 |
19 | 20 |

{% trans "Or make changes" %}:

21 | {% endif %} 22 | 23 | {{ form|crispy }} 24 | 25 |
26 | 27 | 28 |
29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /fluent_comments/templates/fluent_comments/templatetags/ajax_comment_tags.html: -------------------------------------------------------------------------------- 1 | {% load i18n static %} 2 | {% trans "cancel reply" %} 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /fluent_comments/templates/fluent_comments/templatetags/flat_list.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | 3 | The normal list, spiced up. 4 | This is included via comments/list.html 5 | 6 | The id="comments-..." is required for JavaScript, 7 | the 'comments/comment.html template is also used by the Ajax view. 8 | 9 | {% endcomment %} 10 | {% load fluent_comments_tags %} 11 |
12 | {% for comment in comment_list %}{% render_comment comment %}{% endfor %} 13 |
14 | -------------------------------------------------------------------------------- /fluent_comments/templates/fluent_comments/templatetags/threaded_list.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | 3 | The template for threadedcomments, spiced up. 4 | This is included via comments/list.html 5 | 6 | The id="comments-..." is required for JavaScript, 7 | the 'comments/comment.html template is also used by the Ajax view. 8 | 9 | {% endcomment %} 10 | {% load fluent_comments_tags threadedcomments_tags %} 11 |
12 | {% for comment in comment_list|fill_tree|annotate_tree %} 13 | {% ifchanged comment.parent_id %}{% else %}{% endifchanged %} 14 | {% if not comment.open and not comment.close %}{% endif %} 15 | {% if comment.open %}
    {% endif %} 16 |
  • 17 | {% render_comment comment %} 18 | {% for close in comment.close %}
{% endfor %} 19 | {% endfor %} 20 |
21 | -------------------------------------------------------------------------------- /fluent_comments/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-fluent/django-fluent-comments/5d53578544b5b5ecd8c34719672fd43668fb2dd4/fluent_comments/templatetags/__init__.py -------------------------------------------------------------------------------- /fluent_comments/templatetags/fluent_comments_tags.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.template import Library, Node, context_processors 3 | from django.template.loader import get_template 4 | from fluent_comments.utils import get_comment_template_name, get_comment_context_data 5 | from tag_parser import parse_token_kwargs 6 | from tag_parser.basetags import BaseInclusionNode 7 | from fluent_comments import appsettings 8 | from fluent_comments.models import get_comments_for_model 9 | from fluent_comments.moderation import comments_are_open, comments_are_moderated 10 | 11 | 12 | register = Library() 13 | 14 | 15 | class AjaxCommentTags(BaseInclusionNode): 16 | """ 17 | Custom inclusion node with some special parsing features. 18 | Using the ``@register.inclusion_tag`` is not sufficient, 19 | because some keywords require custom parsing. 20 | """ 21 | 22 | template_name = "fluent_comments/templatetags/ajax_comment_tags.html" 23 | min_args = 1 24 | max_args = 1 25 | 26 | @classmethod 27 | def parse(cls, parser, token): 28 | """ 29 | Custom parsing for the ``{% ajax_comment_tags for ... %}`` tag. 30 | """ 31 | # Process the template line. 32 | tag_name, args, kwargs = parse_token_kwargs( 33 | parser, 34 | token, 35 | allowed_kwargs=cls.allowed_kwargs, 36 | compile_args=False, # Only overrule here, keep at render() phase. 37 | compile_kwargs=cls.compile_kwargs, 38 | ) 39 | 40 | # remove "for" keyword, so all other args can be resolved in render(). 41 | if args[0] == "for": 42 | args.pop(0) 43 | 44 | # And apply the compilation afterwards 45 | for i in range(len(args)): 46 | args[i] = parser.compile_filter(args[i]) 47 | 48 | cls.validate_args(tag_name, *args, **kwargs) 49 | return cls(tag_name, *args, **kwargs) 50 | 51 | def get_context_data(self, parent_context, *tag_args, **tag_kwargs): 52 | """ 53 | The main logic for the inclusion node, analogous to ``@register.inclusion_node``. 54 | """ 55 | target_object = tag_args[0] # moved one spot due to .pop(0) 56 | new_context = { 57 | "STATIC_URL": parent_context.get("STATIC_URL", None), 58 | "USE_THREADEDCOMMENTS": appsettings.USE_THREADEDCOMMENTS, 59 | "target_object": target_object, 60 | } 61 | 62 | # Be configuration independent: 63 | if new_context["STATIC_URL"] is None: 64 | try: 65 | request = parent_context["request"] 66 | except KeyError: 67 | new_context.update({"STATIC_URL": settings.STATIC_URL}) 68 | else: 69 | new_context.update(context_processors.static(request)) 70 | 71 | return new_context 72 | 73 | 74 | @register.tag 75 | def ajax_comment_tags(parser, token): 76 | """ 77 | Display the required ``
`` elements to let the Ajax comment functionality work with your form. 78 | """ 79 | return AjaxCommentTags.parse(parser, token) 80 | 81 | 82 | register.filter("comments_are_open", comments_are_open) 83 | register.filter("comments_are_moderated", comments_are_moderated) 84 | 85 | 86 | @register.filter 87 | def comments_count(content_object): 88 | """ 89 | Return the number of comments posted at a target object. 90 | 91 | You can use this instead of the ``{% get_comment_count for [object] as [varname] %}`` tag. 92 | """ 93 | return get_comments_for_model(content_object).count() 94 | 95 | 96 | class FluentCommentsList(Node): 97 | def render(self, context): 98 | # Include proper template, avoid parsing it twice by operating like @register.inclusion_tag() 99 | if not getattr(self, "nodelist", None): 100 | if appsettings.USE_THREADEDCOMMENTS: 101 | template = get_template("fluent_comments/templatetags/threaded_list.html") 102 | else: 103 | template = get_template("fluent_comments/templatetags/flat_list.html") 104 | self.nodelist = template 105 | 106 | # NOTE NOTE NOTE 107 | # HACK: Determine the parent object based on the comment list queryset. 108 | # the {% render_comment_list for article %} tag does not pass the object in a general form to the template. 109 | # Not assuming that 'object.pk' holds the correct value. 110 | # 111 | # This obviously doesn't work when the list is empty. 112 | # To address that, the client-side code also fixes that, by looking for the object ID in the nearby form. 113 | target_object_id = context.get("target_object_id", None) 114 | if not target_object_id: 115 | comment_list = context["comment_list"] 116 | if isinstance(comment_list, list) and comment_list: 117 | target_object_id = comment_list[0].object_pk 118 | 119 | # Render the node 120 | context["USE_THREADEDCOMMENTS"] = appsettings.USE_THREADEDCOMMENTS 121 | context["target_object_id"] = target_object_id 122 | 123 | context = context.flatten() 124 | return self.nodelist.render(context) 125 | 126 | 127 | @register.tag 128 | def fluent_comments_list(parser, token): 129 | """ 130 | A tag to select the proper template for the current comments app. 131 | """ 132 | return FluentCommentsList() 133 | 134 | 135 | class RenderCommentNode(BaseInclusionNode): 136 | min_args = 1 137 | max_args = 1 138 | 139 | def get_template_name(self, *tag_args, **tag_kwargs): 140 | return get_comment_template_name(comment=tag_args[0]) 141 | 142 | def get_context_data(self, parent_context, *tag_args, **tag_kwargs): 143 | context = get_comment_context_data(comment=tag_args[0]) 144 | context["request"] = parent_context.get("request") 145 | return context 146 | 147 | 148 | @register.tag 149 | def render_comment(parser, token): 150 | """ 151 | Render a single comment. 152 | This tag does not exist in the standard django_comments, 153 | because it only renders a complete list. 154 | """ 155 | return RenderCommentNode.parse(parser, token) 156 | -------------------------------------------------------------------------------- /fluent_comments/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-fluent/django-fluent-comments/5d53578544b5b5ecd8c34719672fd43668fb2dd4/fluent_comments/tests/__init__.py -------------------------------------------------------------------------------- /fluent_comments/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import SimpleTestCase 2 | 3 | from fluent_comments.utils import split_words 4 | 5 | 6 | class TestUtils(SimpleTestCase): 7 | def test_split_words(self): 8 | text = """college scholarship essays - how to write a good introduction for a college essay 9 | boston university college essay how to write an essay for college 10 | https://collegeessays.us/ 11 | http://www.monkeyface.com/__media__/js/netsoltrademark.php?d=collegeessays.us""" 12 | self.assertEqual( 13 | split_words(text), 14 | { 15 | "__media__", 16 | "a", 17 | "an", 18 | "boston", 19 | "college", 20 | "collegeessays", 21 | "com", 22 | "d", 23 | "essay", 24 | "essays", 25 | "for", 26 | "good", 27 | "how", 28 | "href", 29 | "http", 30 | "https", 31 | "introduction", 32 | "js", 33 | "monkeyface", 34 | "netsoltrademark", 35 | "php", 36 | "scholarship", 37 | "to", 38 | "university", 39 | "us", 40 | "write", 41 | "www", 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /fluent_comments/tests/utils.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | import fluent_comments 4 | from fluent_comments import appsettings 5 | from fluent_comments.moderation import FluentCommentsModerator 6 | 7 | 8 | def override_appsettings(**settings): 9 | """ 10 | Temporary override the appsettings. 11 | """ 12 | 13 | def _dec(func): 14 | @wraps(func) 15 | def _inner(*args, **kwargs): 16 | # Apply new settings, backup old, clear caches 17 | old_values = {} 18 | for key, new_value in settings.items(): 19 | old_values[key] = getattr(appsettings, key) 20 | setattr(appsettings, key, new_value) 21 | _reset_setting_caches() 22 | 23 | func(*args, **kwargs) 24 | for key, old_value in old_values.items(): 25 | setattr(appsettings, key, old_value) 26 | 27 | # reset caches 28 | _reset_setting_caches() 29 | 30 | return _inner 31 | 32 | return _dec 33 | 34 | 35 | def _reset_setting_caches(): 36 | fluent_comments.form_class = None 37 | fluent_comments.model_class = None 38 | FluentCommentsModerator.close_after = appsettings.FLUENT_COMMENTS_CLOSE_AFTER_DAYS 39 | FluentCommentsModerator.moderate_after = appsettings.FLUENT_COMMENTS_MODERATE_AFTER_DAYS 40 | FluentCommentsModerator.akismet_check = appsettings.FLUENT_CONTENTS_USE_AKISMET 41 | FluentCommentsModerator.akismet_check_action = appsettings.FLUENT_COMMENTS_AKISMET_ACTION 42 | FluentCommentsModerator.moderate_bad_words = set( 43 | appsettings.FLUENT_COMMENTS_MODERATE_BAD_WORDS 44 | ) 45 | 46 | 47 | class MockedResponse(object): 48 | def __init__(self, result, definitive=False): 49 | self.result = result 50 | self.headers = {} 51 | if definitive: 52 | self.headers["X-Akismet-Pro-Tip"] = "discard" 53 | 54 | def json(self): 55 | return self.result 56 | -------------------------------------------------------------------------------- /fluent_comments/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | import django_comments.urls 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path("post/ajax/", views.post_comment_ajax, name="comments-post-comment-ajax"), 8 | path("", include(django_comments.urls)), 9 | ] 10 | -------------------------------------------------------------------------------- /fluent_comments/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Internal utils 3 | """ 4 | import re 5 | 6 | from django.contrib.contenttypes.models import ContentType 7 | 8 | from fluent_comments import appsettings 9 | 10 | 11 | RE_INTERPUNCTION = re.compile(r"\W+") 12 | 13 | 14 | def get_comment_template_name(comment): 15 | """ 16 | Internal function for the rendering of comments. 17 | """ 18 | ctype = ContentType.objects.get_for_id(comment.content_type_id) 19 | return [ 20 | "comments/%s/%s/comment.html" % (ctype.app_label, ctype.model), 21 | "comments/%s/comment.html" % ctype.app_label, 22 | "comments/comment.html", 23 | ] 24 | 25 | 26 | def get_comment_context_data(comment, action=None): 27 | """ 28 | Internal function for the rendering of comments. 29 | """ 30 | return { 31 | "comment": comment, 32 | "action": action, 33 | "preview": (action == "preview"), 34 | "USE_THREADEDCOMMENTS": appsettings.USE_THREADEDCOMMENTS, 35 | } 36 | 37 | 38 | def split_words(comment): 39 | """ 40 | Internal function to split words 41 | """ 42 | return set(RE_INTERPUNCTION.sub(" ", comment).split()) 43 | -------------------------------------------------------------------------------- /fluent_comments/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | import django_comments 5 | from django.apps import apps 6 | from django.core.exceptions import ObjectDoesNotExist, ValidationError 7 | from django.http import HttpResponse, HttpResponseBadRequest 8 | from django.template.loader import render_to_string 9 | from django.utils.html import escape 10 | from django.views.decorators.csrf import csrf_protect 11 | from django.views.decorators.http import require_POST 12 | from django_comments import signals 13 | from django_comments.views.comments import CommentPostBadRequest 14 | 15 | from fluent_comments.utils import get_comment_template_name, get_comment_context_data 16 | from fluent_comments import appsettings 17 | 18 | if sys.version_info[0] >= 3: 19 | long = int 20 | 21 | 22 | @csrf_protect 23 | @require_POST 24 | def post_comment_ajax(request, using=None): 25 | """ 26 | Post a comment, via an Ajax call. 27 | """ 28 | is_ajax = request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" 29 | if not is_ajax: 30 | return HttpResponseBadRequest("Expecting Ajax call") 31 | 32 | # This is copied from django_comments. 33 | # Basically that view does too much, and doesn't offer a hook to change the rendering. 34 | # The request object is not passed to next_redirect for example. 35 | # 36 | # This is a separate view to integrate both features. Previously this used django-ajaxcomments 37 | # which is unfortunately not thread-safe (it it changes the comment view per request). 38 | 39 | # Fill out some initial data fields from an authenticated user, if present 40 | data = request.POST.copy() 41 | if request.user.is_authenticated: 42 | if not data.get("name", ""): 43 | data["name"] = request.user.get_full_name() or request.user.username 44 | if not data.get("email", ""): 45 | data["email"] = request.user.email 46 | 47 | # Look up the object we're trying to comment about 48 | ctype = data.get("content_type") 49 | object_pk = data.get("object_pk") 50 | if ctype is None or object_pk is None: 51 | return CommentPostBadRequest("Missing content_type or object_pk field.") 52 | try: 53 | model = apps.get_model(*ctype.split(".", 1)) 54 | target = model._default_manager.using(using).get(pk=object_pk) 55 | except ValueError: 56 | return CommentPostBadRequest("Invalid object_pk value: {0}".format(escape(object_pk))) 57 | except (TypeError, LookupError): 58 | return CommentPostBadRequest("Invalid content_type value: {0}".format(escape(ctype))) 59 | except AttributeError: 60 | return CommentPostBadRequest( 61 | "The given content-type {0} does not resolve to a valid model.".format(escape(ctype)) 62 | ) 63 | except ObjectDoesNotExist: 64 | return CommentPostBadRequest( 65 | "No object matching content-type {0} and object PK {1} exists.".format( 66 | escape(ctype), escape(object_pk) 67 | ) 68 | ) 69 | except (ValueError, ValidationError) as e: 70 | return CommentPostBadRequest( 71 | "Attempting go get content-type {0!r} and object PK {1!r} exists raised {2}".format( 72 | escape(ctype), escape(object_pk), e.__class__.__name__ 73 | ) 74 | ) 75 | 76 | # Do we want to preview the comment? 77 | is_preview = "preview" in data 78 | 79 | # Construct the comment form 80 | form = django_comments.get_form()(target, data=data, is_preview=is_preview) 81 | 82 | # Check security information 83 | if form.security_errors(): 84 | return CommentPostBadRequest( 85 | "The comment form failed security verification: {0}".format(form.security_errors()) 86 | ) 87 | 88 | # If there are errors or if we requested a preview show the comment 89 | if is_preview: 90 | comment = form.get_comment_object() if not form.errors else None 91 | return _ajax_result(request, form, "preview", comment, object_id=object_pk) 92 | if form.errors: 93 | return _ajax_result(request, form, "post", object_id=object_pk) 94 | 95 | # Otherwise create the comment 96 | comment = form.get_comment_object() 97 | comment.ip_address = request.META.get("REMOTE_ADDR", None) 98 | if request.user.is_authenticated: 99 | comment.user = request.user 100 | 101 | # Signal that the comment is about to be saved 102 | responses = signals.comment_will_be_posted.send( 103 | sender=comment.__class__, comment=comment, request=request 104 | ) 105 | 106 | for (receiver, response) in responses: 107 | if response is False: 108 | return CommentPostBadRequest( 109 | "comment_will_be_posted receiver {0} killed the comment".format(receiver.__name__) 110 | ) 111 | 112 | # Save the comment and signal that it was saved 113 | comment.save() 114 | signals.comment_was_posted.send(sender=comment.__class__, comment=comment, request=request) 115 | 116 | return _ajax_result(request, form, "post", comment, object_id=object_pk) 117 | 118 | 119 | def _ajax_result(request, form, action, comment=None, object_id=None): 120 | # Based on django-ajaxcomments, BSD licensed. 121 | # Copyright (c) 2009 Brandon Konkle and individual contributors. 122 | # 123 | # This code was extracted out of django-ajaxcomments because 124 | # django-ajaxcomments is not threadsafe, and it was refactored afterwards. 125 | 126 | success = True 127 | json_errors = {} 128 | 129 | if form.errors: 130 | for field_name in form.errors: 131 | field = form[field_name] 132 | json_errors[field_name] = _render_errors(field) 133 | success = False 134 | 135 | json_return = { 136 | "success": success, 137 | "action": action, 138 | "errors": json_errors, 139 | "object_id": object_id, 140 | "use_threadedcomments": bool(appsettings.USE_THREADEDCOMMENTS), 141 | } 142 | 143 | if comment is not None: 144 | # Render the comment, like {% render_comment comment %} does 145 | context = get_comment_context_data(comment, action) 146 | context["request"] = request 147 | template_name = get_comment_template_name(comment) 148 | 149 | comment_html = render_to_string(template_name, context, request=request) 150 | json_return.update( 151 | { 152 | "html": comment_html, 153 | "comment_id": comment.id, 154 | "parent_id": None, 155 | "is_moderated": not comment.is_public, # is_public flags changes in comment_will_be_posted 156 | } 157 | ) 158 | if appsettings.USE_THREADEDCOMMENTS: 159 | json_return["parent_id"] = comment.parent_id 160 | 161 | json_response = json.dumps(json_return) 162 | return HttpResponse(json_response, content_type="application/json") 163 | 164 | 165 | def _render_errors(field): 166 | """ 167 | Render form errors in crispy-forms style. 168 | """ 169 | template = "{0}/layout/field_errors.html".format(appsettings.CRISPY_TEMPLATE_PACK) 170 | return render_to_string( 171 | template, 172 | { 173 | "field": field, 174 | "form_show_errors": True, 175 | }, 176 | ) 177 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | line_length = 99 4 | 5 | [tool.black] 6 | line-length = 99 7 | exclude = ''' 8 | /( 9 | \.git 10 | | \.tox 11 | | \.venv 12 | | dist 13 | )/ 14 | ''' 15 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python -c " 4 | import sys, django, os 5 | sys.stderr.write('Using Python version {0} from {1}\n'.format(sys.version[:5], sys.executable)) 6 | sys.stderr.write('Using Django version {0} from {1}\n'.format( 7 | django.get_version(), os.path.dirname(os.path.abspath(django.__file__)))) 8 | " 9 | 10 | exec python -Wd example/manage.py test "$@" 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | # create "py2.py3-none-any.whl" package 3 | universal = 1 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | from os import path 4 | import codecs 5 | import os 6 | import re 7 | import sys 8 | 9 | 10 | # When creating the sdist, make sure the django.mo file also exists: 11 | if "sdist" in sys.argv or "develop" in sys.argv: 12 | os.chdir("fluent_comments") 13 | try: 14 | from django.core import management 15 | 16 | management.call_command("compilemessages", stdout=sys.stderr, verbosity=1) 17 | except ImportError: 18 | if "sdist" in sys.argv: 19 | raise 20 | finally: 21 | os.chdir("..") 22 | 23 | 24 | def read(*parts): 25 | file_path = path.join(path.dirname(__file__), *parts) 26 | return codecs.open(file_path, encoding="utf-8").read() 27 | 28 | 29 | def find_version(*parts): 30 | version_file = read(*parts) 31 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 32 | if version_match: 33 | return str(version_match.group(1)) 34 | raise RuntimeError("Unable to find version string.") 35 | 36 | 37 | setup( 38 | name="django-fluent-comments", 39 | version=find_version("fluent_comments", "__init__.py"), 40 | license="Apache 2.0", 41 | install_requires=[ 42 | "django-crispy-forms>=1.9.2", 43 | "django-tag-parser>=3.2", 44 | "django-contrib-comments>=2.0.0", 45 | "python-akismet>=0.4.1", # Python 3 port, replaces Python 2-only "akismet" library. 46 | ], 47 | requires=[ 48 | "Django (>=2.2)", 49 | ], 50 | extras_require={ 51 | "threadedcomments": ["django-threadedcomments>=1.2"], 52 | }, 53 | description="A modern, ajax-based appearance for django_comments", 54 | long_description=read("README.rst"), 55 | author="Diederik van der Boor", 56 | author_email="opensource@edoburu.nl", 57 | url="https://github.com/edoburu/django-fluent-comments", 58 | download_url="https://github.com/edoburu/django-fluent-comments/zipball/master", 59 | packages=find_packages(exclude=("example*",)), 60 | include_package_data=True, 61 | zip_safe=False, 62 | classifiers=[ 63 | "Development Status :: 5 - Production/Stable", 64 | "Environment :: Web Environment", 65 | "Framework :: Django", 66 | "Intended Audience :: Developers", 67 | "License :: OSI Approved :: Apache Software License", 68 | "Operating System :: OS Independent", 69 | "Programming Language :: Python", 70 | "Programming Language :: Python :: 3.7", 71 | "Programming Language :: Python :: 3.8", 72 | "Programming Language :: Python :: 3.9", 73 | "Framework :: Django", 74 | "Framework :: Django :: 2.2", 75 | "Framework :: Django :: 3.0", 76 | "Framework :: Django :: 3.1", 77 | "Framework :: Django :: 3.2", 78 | "Topic :: Internet :: WWW/HTTP", 79 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 80 | "Topic :: Software Development :: Libraries :: Application Frameworks", 81 | "Topic :: Software Development :: Libraries :: Python Modules", 82 | ], 83 | ) 84 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | py38-django{22,31,32}, 4 | py38-django{22,30,31,32}-tc 5 | 6 | [testenv] 7 | deps = 8 | django22: Django~=2.2 9 | django30: Django~=3.0 10 | django31: Django~=3.1 11 | django32: Django~=3.2 12 | tc: django-threadedcomments >= 1.2 13 | django-dev: https://github.com/django/django/tarball/master 14 | commands= 15 | ./runtests.sh 16 | --------------------------------------------------------------------------------