├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── python-publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── __init__.py ├── demo ├── default-1.png ├── default-2.png ├── default-3.png ├── default-4.png ├── demo-templates │ ├── answer_form.html │ ├── base.html │ ├── categories_list.html │ ├── category_detail.html │ ├── comment_form.html │ ├── comments.html │ ├── question_base.html │ ├── question_detail.html │ ├── question_form.html │ ├── questions_list.html │ └── vote_form.html ├── demo.md ├── option-1-1.png ├── option-1-2.png ├── option-10-1.png ├── option-11-1.png ├── option-2-1.png ├── option-2-2.png ├── option-3-1.png ├── option-3-2.png ├── option-4-1.png ├── option-4-2.png ├── option-5-1.png ├── option-6-1.png ├── option-8-1.png └── option-9-1.png ├── example ├── example │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── home │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── home │ │ │ └── index.html │ ├── tests.py │ └── views.py ├── manage.py ├── requirements.txt └── templates │ └── faq │ ├── answer_form.html │ ├── base.html │ ├── categories_list.html │ ├── category_detail.html │ ├── comment_form.html │ ├── comments.html │ ├── question_base.html │ ├── question_detail.html │ ├── question_form.html │ ├── questions_list.html │ └── vote_form.html ├── faq ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_answer_id_alter_answerhelpful_id_and_more.py │ ├── 0003_auto_20220619_0939.py │ ├── 0004_alter_answer_slug_alter_category_slug.py │ ├── 0005_rename_description_category__description.py │ ├── 0006_answer_is_rich_text.py │ └── __init__.py ├── models.py ├── path_converters.py ├── snippets.py ├── templates │ └── faq │ │ ├── answer_form.html │ │ ├── base.html │ │ ├── categories_list.html │ │ ├── category_detail.html │ │ ├── comment_form.html │ │ ├── comments.html │ │ ├── question_base.html │ │ ├── question_detail.html │ │ ├── question_form.html │ │ ├── questions_list.html │ │ └── vote_form.html ├── tests.py ├── urls.py └── views.py └── setup.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '27 8 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | jobs: 8 | build: 9 | name: Build distribution 📦 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.x" 18 | - name: Set env 19 | run: 20 | echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 21 | - name: Install pypa/build 22 | run: >- 23 | python3 -m 24 | pip install 25 | build 26 | --user 27 | - name: Build a binary wheel and a source tarball 28 | run: python3 -m build 29 | - name: Store the distribution packages 30 | uses: actions/upload-artifact@v3 31 | with: 32 | name: python-package-distributions 33 | path: dist/ 34 | publish-to-pypi: 35 | name: >- 36 | Publish Python 🐍 distribution 📦 to PyPI 37 | needs: 38 | - build 39 | runs-on: ubuntu-latest 40 | environment: 41 | name: pypi 42 | url: https://pypi.org/p/django-easy-faq/ 43 | permissions: 44 | id-token: write # IMPORTANT: mandatory for trusted publishing 45 | steps: 46 | - name: Download all the dists 47 | uses: actions/download-artifact@v3 48 | with: 49 | name: python-package-distributions 50 | path: dist/ 51 | - name: Publish distribution 📦 to PyPI 52 | uses: pypa/gh-action-pypi-publish@release/v1 -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test django-easy-faq 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | max-parallel: 4 14 | matrix: 15 | python-version: ["3.9", "3.12"] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Set env 24 | run: echo "RELEASE_VERSION=0.0.1" >> $GITHUB_ENV 25 | - name: Install Dependencies 26 | run: | 27 | pip install django-tinymce 28 | pip install Django 29 | pip install -e . 30 | - name: Run Tests 31 | run: | 32 | cd example 33 | python manage.py test faq 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | __pycache__/__init__.cpython-38.pyc 3 | *.pyc 4 | /.idea/.gitignore 5 | /.idea/django-easy-faq.iml 6 | /dist/django-easy-faq-1.4.tar.gz 7 | /.idea/misc.xml 8 | /.idea/modules.xml 9 | /.idea/inspectionProfiles/profiles_settings.xml 10 | /.idea/vcs.xml 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 smark-1 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include faq/migrations * 2 | recursive-include faq/templates * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-easy-faq 2 | 3 | django-easy-faq is a Django app to allow for a simple yet feature rich faq app. with categories, commenting voting of questions and answers all as an optional part of the app. To see screenshots of what this django-easy-faq could look like with bootstrap 5 styling [click here](demo/demo.md). 4 | 5 | 6 | ## Quick start 7 | 8 | 1. pip install: 9 | 10 | `pip install django-easy-faq` 11 | 12 | 2. Add "faq" to your INSTALLED_APPS setting like this: 13 | 14 | ```python 15 | INSTALLED_APPS = [ 16 | ... 17 | 'faq',] 18 | ``` 19 | 20 | 3. Include the easy-faq URLconf in your project urls.py like this:: 21 | 22 | ```python 23 | #… 24 | path('faq/', include('faq.urls')), 25 | #… 26 | ``` 27 | 28 | 4. Add `FAQ_SETTINGS = []` to your `settings.py` 29 | 5. Run ``python manage.py makemigrations`` to create the faq models migrations. 30 | 6. Run ``python manage.py migrate`` to create the faq models. 31 | 32 | 7. Start the development server and visit http://127.0.0.1:8000/admin/ 33 | to create a category (you'll need the Admin app enabled).(categories part of the app can be disabled) 34 | 35 | 8. Visit http://127.0.0.1:8000/faq/ to see the categories. 36 | 37 | ## Settings 38 | 39 | you can change most things in settings below is a list of all settings 40 | add any or all to change to desired behavior:: 41 | 42 | 43 | FAQ_SETTINGS = ['your_settings_here',] 44 | 45 | 46 | 1. no_category_description - add if using categories but don't want descriptions for them 47 | 2. no_category - add if don't want to use categories 48 | 3. logged_in_users_can_add_question - add if you want any logged in user to be able to ask a question 49 | 4. logged_in_users_can_answer_question - add if you want any logged in user to be able to answer a question 50 | 5. allow_multiple_answers - add if you want a question to be able to be answered multiple times 51 | 6. no_comments - add if don't want to use comments 52 | 7. anonymous_user_can_comment - add if you want any user to be able to comment including anonymous users 53 | 8. view_only_comments - add if you want users to see posted comments but not be able to add any more 54 | 9. no_votes - add if don't want any voting for useful questions or answers 55 | 10. no_answer_votes - add if only want question voting 56 | 11. no_question_votes - add if only want answer voting 57 | 12. allow_unicode - add if you want to allow unicode slugs 58 | 13. login_required - add if you want to only let logged in users see FAQ's 59 | 14. rich_text_answers - add if you want to use rich text for answers. This requires the django-tinymce package to be installed 60 | 61 | ## Templates 62 | 63 | all of the templates are meant to be overwritten 64 | to overwrite them create a faq directory inside of the templates directory and add a html file with the same name to it 65 | 66 | if this doesn't work make sure that the templates setting has 'DIRS': ['templates'], in it:: 67 | 68 | TEMPLATES = [ 69 | { 70 | ... 71 | 'DIRS': ['templates'], 72 | ... 73 | }, 74 | ] 75 | 76 | here is a list of templates and there default template you can overwrite 77 | 78 | 1. categories_list.html - faq main view if using categories:: 79 | 80 | 81 |

select a FAQ category

82 | {% for category in categories %} 83 |

{{category.name}}

84 | {% if category.description %} 85 |

{{category.description}}

86 | {% endif %} 87 |
88 | {% endfor %} 89 | 90 | 91 | 2. category_detail.html - faq category detail view if using categories:: 92 | 93 | 94 |

choose a FAQ Question

95 |

{{category}}

96 | {% if category.description %} 97 |

{{category.description}}

98 | {% endif %} 99 |
100 | {% for question in category.question_set.all %} 101 |

{{question.question}}

102 | {% endfor %} 103 |
104 | back 105 | {% if can_add_question %} 106 | add question 107 | {% endif %} 108 | 109 | 110 | 3. questions_list.html - lists all questions if not using categories:: 111 | 112 | 113 |

choose a FAQ Question

114 | {% for question in questions %} 115 |

{{question.question}}

116 | {% endfor %} 117 | 118 | {% if can_add_question %} 119 |
120 | add question 121 | {% endif %} 122 | 123 | 124 | 4. question_detail.html - the question detail page:: 125 | 126 | 127 | {% extends 'faq/question_base.html' %} 128 | 129 | {% block question_content %} 130 | {% if allow_multiple_answers %} 131 |

answers

132 | 151 | 152 | {% else %} 153 | {% if question.answer_set.exists %} 154 |

answer:

155 |

{{question.answer_set.first.answer}}

156 | {% if can_vote_answer %} 157 | found this answer helpful? 158 |
159 | {% csrf_token %} 160 | 161 | 162 |
163 |
164 | {% csrf_token %} 165 | 166 | 167 |
168 | {% endif %} 169 | {% else %} 170 | no answers yet 171 | {% endif %} 172 | {% endif %} 173 | 174 | 175 | {% if can_answer_question %} 176 | {% if category_enabled %} 177 | answer question 178 | {% else %} 179 | answer question 180 | {% endif %} 181 | {% endif %} 182 |
183 | {% if comments_allowed %} 184 | {% include 'faq/comments.html' %} 185 | {% endif %} 186 | 187 | {% endblock %} 188 | 189 | 5. answer_form.html - form to add answer to question:: 190 | 191 | 192 |

Answer Question

193 |

{{question.question}}

194 |
195 | {% csrf_token %} 196 | {{form}} 197 | 198 |
199 | 200 | 6. comment_form.html - form to add comments to question (only shows up when form has error because view only gets posted to):: 201 | 202 | 203 |

Post A Comment

204 |

{{question.question}}

205 |
206 | {% csrf_token %} 207 | {{form}} 208 | 209 |
210 | 211 | 7. question_form.html - form to add a new question:: 212 | 213 | 214 |

Add Your Question

215 |
216 | {% csrf_token %} 217 | {{form}} 218 | 219 |
220 | 221 | 8. vote_form.html - form for voting questions and answers (only shows up when form has error because view only gets posted to):: 222 | 223 | 224 |

vote

225 |
226 | {% csrf_token %} 227 | {{form}} 228 | 229 |
230 | 231 | 9. comments.html - if comments are allowed this template is included in the question detail.html:: 232 | 233 | 234 |

comments

235 | 241 | {% if add_new_comment_allowed %} 242 | {% if category_enabled %} 243 |
244 | {% else %} 245 | 246 | {% endif %} 247 |
248 | Post Your Comment Here: 249 | {% csrf_token %} 250 | {{comment_form}} 251 | 252 |
253 |
254 | {% endif %} 255 | 256 | ## Template Variables 257 | 258 | 1. categories_list.html 259 | categories - all the categories (category queryset) 260 | 261 | 2. categories_detail.html 262 | category - the category chosen (category object) 263 | can_add_question - bool if the user can add a question (depends on the settings) 264 | 3. questions_list.html 265 | questions - all the questions (question queryset) 266 | can_add_question - bool if the user can add a question (depends on the settings) 267 | 4. question_detail.html 268 | question - the question chosen (question object) 269 | can_vote_question - bool if the user can vote a question (depends on the settings) 270 | category_enabled - bool if category enabled in settings 271 | allow_multiple_answers - bool if multiple answers allowed in settings 272 | can_vote_answer - bool if the user can vote an answer (depends on the settings) 273 | can_answer_question - bool if current user can answer question (depends on the settings) 274 | comments_allowed - bool if using comments in settings 275 | add_new_comment_allowed - bool if current user can add comment (depends on the settings) 276 | comment_form - form to submit a new comment 277 | 5. answer_form.html 278 | question - the question to add answer to (question object) 279 | form - form to add new answer 280 | 6. comment_form.html 281 | question - the question to add comment to (question object) 282 | form - form to add new comment 283 | 7. question_form.html 284 | form - form to add new question 285 | 8. vote_form.html 286 | form - form to vote for a question or answer 287 | 288 | ## Urls 289 | 290 | all of the following urls are by name then additional 291 | the app name for the urls is ``'faq'`` 292 | 293 | * index_view 294 | * no arguments 295 | * displays all the categories if categories are enabled otherwise shows questions 296 | * category_detail 297 | * needs category slug as slug 298 | * displays all the questions given the category when categories are enabled 299 | * add_question 300 | * if categories are enabled needs category slug as slug 301 | * if logged_in_users_can_add_question then displays form for logged in users to ask a new question 302 | * question_detail 303 | * needs question slug as question | if categories are enabled needs category slug as slug 304 | * displays the main FAQ page with the question all the comments and answers 305 | * answer_question 306 | * needs question slug as question | if categories are enabled needs category slug as category 307 | * displays the answer question form 308 | * add_comment 309 | * needs question slug as question | if categories are enabled needs category slug as category 310 | * only works if using comments 311 | * used to post comment form from question_detail to database 312 | * vote_answer 313 | * needs question slug as question | needs answer slug as answer | if categories are enabled needs category slug as category 314 | * only works if using answer voting 315 | * used to post hidden input vote = 1 or vote = 0 depending on vote up or down 316 | * vote_question 317 | * needs question slug as question | if categories are enabled needs category slug as category 318 | * only works if using question voting 319 | * used to post hidden input vote = 1 or vote = 0 depending on vote up or down 320 | 321 | ## django-tinymce 322 | If you want to use rich text answers you will need to [install django-tinymce](https://django-tinymce.readthedocs.io/en/latest/installation.html#id2) 323 | 324 | Make sure to include in the template the `{{ form.media }}` to include the tinymce javascript and css files. 325 | > [!WARNING] 326 | > Failing to follow the following steps will result in a xss vulnerability in your site. 327 | 328 | To allow the rich text answers to be rendered properly you will need to use the safe filter in your templates. 329 | While django-tinymce does escape the html the answers that were created when the rich text editor was not enabled **has not been escaped and is not safe**. 330 | So these answers cannot be rendered with the safe filter. So a flag was added to the answer model 'is_rich_text' that is set to True when the answer is created with the rich text editor. 331 | In the template you can use the following code to render the answer properly:: 332 | 333 | {% if answer.is_rich_text %} 334 | {{answer.answer|safe}} 335 | {% else %} 336 | {{answer.answer}} 337 | {% endif %} 338 | 339 | ## Contributing 340 | django-easy-faq aims to be the best faq app for django. It welcomes contributions of all types - issues, bugs, feature requests, documentation updates, tests and pull requests. 341 | 342 | ## change log 343 | - 1.9 added view onsite link in admin, added richtext answers in admin 344 | 345 | - 1.8 added support for richtext answers with django-tinymce 346 | 347 | - 1.7 added support for django 5.0 348 | 349 | - 1.6 fixed bug where no_category_description did not do remove the category description in the admin 350 | 351 | - 1.5 added login_required setting to allow faq app to be available to only logged in users 352 | 353 | - 1.4 added unicode option to add unicode slugs 354 | 355 | - 1.3 fixed bug where a slug must be filled out in admin even though slug gets auto generated to save for questions, answers, and categories. Made questions, answers, categories slugs readonly in admin 356 | 357 | - 1.2 fixed bug in pypi distro not including faq app 358 | 359 | - 1.1 added more templates to override easily 360 | 361 | - 1.0 added pypi distribution 362 | 363 | - 0.5 fixed migrations 364 | 365 | - 0.4 fixed bug that logged out users can vote - which then raises exceptions 366 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/__init__.py -------------------------------------------------------------------------------- /demo/default-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/default-1.png -------------------------------------------------------------------------------- /demo/default-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/default-2.png -------------------------------------------------------------------------------- /demo/default-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/default-3.png -------------------------------------------------------------------------------- /demo/default-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/default-4.png -------------------------------------------------------------------------------- /demo/demo-templates/answer_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Answer Question{% endblock %} 3 | {% block heading %}Answer Question{% endblock %} 4 | {% block breadcrumbs %} 5 | 17 | {% endblock %} 18 | {% block content %} 19 |

{{question.question}}

20 |
21 | {% csrf_token %} 22 | {% for field in form %} 23 |
24 | 25 | 26 | {% if field.errors %} 27 | {% for error in field.errors %} 28 |

{{ error }}

29 | {% endfor %} 30 | {% endif %} 31 |
32 | {% endfor %} 33 | 34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /demo/demo-templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | {% block title %}FAQ with Bootstrap{% endblock %} 10 | 11 | 12 | 48 | {% block breadcrumbs %}{% endblock %} 49 |

{% block heading %}{% endblock %}

50 |
51 | {% block content %} 52 | {% endblock %} 53 |
54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /demo/demo-templates/categories_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Choose Category{% endblock %} 3 | {% block heading %}Select a FAQ category{% endblock %} 4 | 5 | {% block content %} 6 |
7 | {% for category in categories %} 8 |
9 |
10 |
11 |
{{ category.name }}
12 | {% if category.description %} 13 |

{{category.description}}

14 | {% endif %} 15 | View 16 |
17 |
18 |
19 | {% endfor %} 20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /demo/demo-templates/category_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Choose a FAQ Question{% endblock %} 3 | {% block heading %}{{category}}{% endblock %} 4 | {% block breadcrumbs %} 5 | 11 | {% endblock %} 12 | {% block content %} 13 | 14 | {% if category.description %} 15 |

{{category.description}}

16 | {% endif %} 17 | 18 | {% for question in category.question_set.all %} 19 |

{{question.question}}

20 | {% endfor %} 21 |
22 | Back 23 | {% if can_add_question %} 24 | Add Question 25 | {% endif %} 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /demo/demo-templates/comment_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/question_base.html' %} 2 | 3 | {% block question_content %} 4 |

Post A Comment

5 |

{{question.question}}

6 |
7 | {% csrf_token %} 8 | {{form}} 9 | 10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /demo/demo-templates/comments.html: -------------------------------------------------------------------------------- 1 |
2 |

Comments

3 | {% for comment in question.faqcomment_set.all %} 4 |
5 |

{{comment.comment}}

6 | posted by {% if comment.user%}{{comment.user}}{% else %}anonymous{% endif %} {{comment.post_time|timesince}} ago 7 |
8 | {% endfor %} 9 | 10 | 11 | {% if add_new_comment_allowed %} 12 |
13 |
14 | {% if category_enabled %} 15 |
16 | {% else %} 17 | 18 | {% endif %} 19 | {% csrf_token %} 20 | 21 | {% for field in comment_form %} 22 |
23 | 24 | 25 | {% if field.errors %} 26 | {% for error in field.errors %} 27 |

{{ error }}

28 | {% endfor %} 29 | {% endif %} 30 |
31 | {% endfor %} 32 | 33 |
34 | {% endif %} 35 |
-------------------------------------------------------------------------------- /demo/demo-templates/question_base.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | {{question.question|title}}{% endblock %} 3 | {% block heading %}{{question.question|title}} {% if can_vote_question %} 4 |
5 | {% csrf_token %} 6 | 7 | 8 |
9 |
10 | {% csrf_token %} 11 | 12 | 13 |
14 | {% endif %}{% endblock %} 15 | {% block breadcrumbs %} 16 | 25 | {% endblock %} 26 | {% block content %} 27 | 28 | {% if question.category and category_enabled %} 29 | {{question.category.name}} 30 | {% endif %} 31 | 32 |
33 | 34 | {% block question_content %} 35 | {% endblock %} 36 | 37 | {% endblock %} -------------------------------------------------------------------------------- /demo/demo-templates/question_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/question_base.html' %} 2 | 3 | {% block question_content %} 4 | {% if allow_multiple_answers %} 5 |

Answers

6 |
7 | {% for answer in question.answer_set.all %} 8 |
9 |
10 |
11 |
{{answer.answer}}
12 |
13 | {% if can_vote_answer %} 14 | 27 | {% endif %} 28 |
29 |
30 | {% endfor %} 31 |
32 | 33 | {% else %} 34 | {% if question.answer_set.exists %} 35 |

Answer:

36 |

{{question.answer_set.first.answer}}

37 | {% if can_vote_answer %} 38 | found this answer helpful? 39 |
40 | {% csrf_token %} 41 | 42 | 43 |
44 |
45 | {% csrf_token %} 46 | 47 | 48 |
49 | {% endif %} 50 | {% else %} 51 | no answers yet 52 | {% endif %} 53 | {% endif %} 54 | 55 | 56 | {% if can_answer_question %} 57 | {% if category_enabled %} 58 | answer question 59 | {% else %} 60 | answer question 61 | {% endif %} 62 | {% endif %} 63 |
64 | {% if comments_allowed %} 65 | {% include 'faq/comments.html' %} 66 | {% endif %} 67 | 68 | {% endblock %} -------------------------------------------------------------------------------- /demo/demo-templates/question_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Ask a Question{% endblock %} 3 | {% block heading %}Ask a Question{% endblock %} 4 | {% block breadcrumbs %} 5 | 14 | {% endblock %} 15 | {% block content %} 16 |
17 | {% csrf_token %} 18 | {% for field in form %} 19 |
20 | 21 | 22 | {% if field.errors %} 23 | {% for error in field.errors %} 24 |

{{ error }}

25 | {% endfor %} 26 | {% endif %} 27 |
28 | {% endfor %} 29 | 30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /demo/demo-templates/questions_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Choose Question{% endblock %} 3 | {% block heading %}Choose a FAQ Question{% endblock %} 4 | {% block content %} 5 | {% for question in questions %} 6 |

{{question.question}}

7 | {% endfor %} 8 | 9 | {% if can_add_question %} 10 |
11 | add question 12 | {% endif %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /demo/demo-templates/vote_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Vote{% endblock %} 3 | {% block heading %}Vote{% endblock %} 4 | {% block content %} 5 |
6 | {% csrf_token %} 7 | {{form}} 8 | 9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /demo/demo.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | This is sample of what django-easy-faq can look like with [Bootstrap 5](https://getbootstrap.com/) styled templates. 3 | This is here to demonstrate what this django app provides and what features it supports. 4 | Below is some screenshots with the default settings and of what it looks like with some of the other settings turned on. 5 | 6 | ### with no settings 7 | 8 | This is for a user that is logged in when the faq app has no settings set. 9 | 10 | /faq/ 11 | ![image](default-1.png) 12 | /faq/category-1/ 13 | ![image](default-2.png) 14 | /faq/category-1/do-you-offer-any-discounts-or-promotions/ 15 | ![image](default-3.png) 16 | with an answer 17 | ![image](default-4.png) 18 | ### no_category_description 19 | 20 | Categories don't have category descriptions. 21 | 22 | /faq/ 23 | ![image](option-1-1.png) 24 | 25 | /faq/category-1/ 26 | ![image](option-1-2.png) 27 | ### no_category 28 | 29 | there are no categories for questions. All questions show on the faq index page 30 | 31 | /faq/ 32 | ![image](option-2-1.png) 33 | 34 | /faq/do-you-offer-any-discounts-or-promotions/ 35 | ![image](option-2-2.png) 36 | 37 | ### logged_in_users_can_add_question 38 | 39 | allow logged in users to ask questions 40 | 41 | /faq/category-1/ 42 | ![image](option-3-1.png) 43 | 44 | /faq/category-1/add/question/ 45 | ![image](option-3-2.png) 46 | 47 | ### logged_in_users_can_answer_question 48 | 49 | allow users that are logged in to be able to answer the questions 50 | 51 | /faq/category-1/what-payment-methods-do-you-accept/ 52 | ![image](option-4-1.png) 53 | 54 | /faq/category-1/what-payment-methods-do-you-accept/answer/ 55 | ![image](option-4-2.png) 56 | 57 | ### allow_multiple_answers 58 | 59 | More than one answer can exist for the question. If `logged_in_users_can_answer_question` is also set then users can add their own answers to the question. 60 | 61 | /faq/category-1/what-is-your-return-policy/ 62 | ![image](option-5-1.png) 63 | 64 | ### no_comments 65 | 66 | showing or adding comments to questions is removed 67 | 68 | /faq/category-1/do-you-offer-any-discounts-or-promotions/ 69 | ![image](option-6-1.png) 70 | 71 | ### anonymous_user_can_comment 72 | 73 | even users that are not logged in can comment 74 | 75 | /faq/category-1/do-you-offer-any-discounts-or-promotions/ 76 | ![image](default-3.png) 77 | 78 | ### view_only_comments 79 | 80 | users can only view the comments 81 | 82 | /faq/category-1/do-you-offer-any-discounts-or-promotions/ 83 | ![image](option-8-1.png) 84 | 85 | ### no_votes 86 | 87 | users cannot vote on questions or answers 88 | 89 | /faq/category-1/do-you-offer-any-discounts-or-promotions/ 90 | ![image](option-9-1.png) 91 | 92 | ### no_answer_votes 93 | 94 | can only vote on questions 95 | 96 | /faq/category-1/do-you-offer-any-discounts-or-promotions/ 97 | ![image](option-10-1.png) 98 | 99 | ### no_question_votes 100 | 101 | can only vote on answers 102 | 103 | /faq/category-1/do-you-offer-any-discounts-or-promotions/ 104 | ![image](option-11-1.png) 105 | -------------------------------------------------------------------------------- /demo/option-1-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-1-1.png -------------------------------------------------------------------------------- /demo/option-1-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-1-2.png -------------------------------------------------------------------------------- /demo/option-10-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-10-1.png -------------------------------------------------------------------------------- /demo/option-11-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-11-1.png -------------------------------------------------------------------------------- /demo/option-2-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-2-1.png -------------------------------------------------------------------------------- /demo/option-2-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-2-2.png -------------------------------------------------------------------------------- /demo/option-3-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-3-1.png -------------------------------------------------------------------------------- /demo/option-3-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-3-2.png -------------------------------------------------------------------------------- /demo/option-4-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-4-1.png -------------------------------------------------------------------------------- /demo/option-4-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-4-2.png -------------------------------------------------------------------------------- /demo/option-5-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-5-1.png -------------------------------------------------------------------------------- /demo/option-6-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-6-1.png -------------------------------------------------------------------------------- /demo/option-8-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-8-1.png -------------------------------------------------------------------------------- /demo/option-9-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/demo/option-9-1.png -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.1.dev20240220195926. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/dev/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | 16 | PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | BASE_DIR = os.path.dirname(PROJECT_DIR) 18 | 19 | DEBUG = True 20 | 21 | SECRET_KEY = "django-insecure-tkvp+i6^@nq@v2c19720n_hhd$p_)v8ei)bw2%kte!@ihci7))" 22 | 23 | ALLOWED_HOSTS = ["*"] 24 | # Quick-start development settings - unsuitable for production 25 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ 26 | 27 | 28 | # Application definition 29 | 30 | INSTALLED_APPS = [ 31 | "home", 32 | "django.contrib.admin", 33 | "django.contrib.auth", 34 | "django.contrib.contenttypes", 35 | "django.contrib.sessions", 36 | "django.contrib.messages", 37 | "django.contrib.staticfiles", 38 | 'faq', 39 | 'tinymce', 40 | ] 41 | 42 | MIDDLEWARE = [ 43 | "django.contrib.sessions.middleware.SessionMiddleware", 44 | "django.middleware.common.CommonMiddleware", 45 | "django.middleware.csrf.CsrfViewMiddleware", 46 | "django.contrib.auth.middleware.AuthenticationMiddleware", 47 | "django.contrib.messages.middleware.MessageMiddleware", 48 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 49 | "django.middleware.security.SecurityMiddleware", 50 | ] 51 | 52 | ROOT_URLCONF = "example.urls" 53 | 54 | TEMPLATES = [ 55 | { 56 | "BACKEND": "django.template.backends.django.DjangoTemplates", 57 | "DIRS": [ 58 | os.path.join(PROJECT_DIR, "templates"), 59 | ], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = "example.wsgi.application" 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 92 | }, 93 | { 94 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 95 | }, 96 | { 97 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 98 | }, 99 | { 100 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/dev/topics/i18n/ 107 | 108 | LANGUAGE_CODE = "en-us" 109 | 110 | TIME_ZONE = "UTC" 111 | 112 | USE_I18N = True 113 | 114 | USE_TZ = True 115 | 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/dev/howto/static-files/ 119 | 120 | STATICFILES_FINDERS = [ 121 | "django.contrib.staticfiles.finders.FileSystemFinder", 122 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 123 | ] 124 | 125 | STATICFILES_DIRS = [ 126 | os.path.join(PROJECT_DIR, "static"), 127 | ] 128 | 129 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 130 | STATIC_URL = "/static/" 131 | 132 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 133 | MEDIA_URL = "/media/" 134 | 135 | # Default storage settings, with the staticfiles storage updated. 136 | # See https://docs.djangoproject.com/en/dev/ref/settings/#std-setting-STORAGES 137 | STORAGES = { 138 | "default": { 139 | "BACKEND": "django.core.files.storage.FileSystemStorage", 140 | }, 141 | # ManifestStaticFilesStorage is recommended in production, to prevent 142 | # outdated JavaScript / CSS assets being served from cache 143 | # (e.g. after a Wagtail upgrade). 144 | # See https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#manifeststaticfilesstorage 145 | "staticfiles": { 146 | "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage", 147 | }, 148 | } 149 | 150 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 151 | 152 | # https://github.com/smark-1/django-easy-faq 153 | # Django easy FAQ settings 154 | FAQ_SETTINGS = [ 155 | 'logged_in_users_can_answer_question', 156 | 'allow_multiple_answers', 157 | 'rich_text_answers', 158 | 'logged_in_users_can_add_question', 159 | ] -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from django.contrib import admin 3 | from home.views import home 4 | 5 | urlpatterns = [ 6 | path("", home), 7 | path('faq/', include('faq.urls')), 8 | path("admin/", admin.site.urls), 9 | path('tinymce/', include('tinymce.urls')), 10 | ] 11 | 12 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings.dev") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/home/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/example/home/__init__.py -------------------------------------------------------------------------------- /example/home/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /example/home/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HomeConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'home' 7 | -------------------------------------------------------------------------------- /example/home/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/example/home/migrations/__init__.py -------------------------------------------------------------------------------- /example/home/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /example/home/templates/home/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | 3 | {% block title %}Example app{% endblock %} 4 | {% block heading %}Example FAQ{% endblock %} 5 | {% block content %} 6 |
7 | view faq 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /example/home/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /example/home/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | # Create your views here. 5 | def home(request): 6 | return render(request, 'home/index.html') 7 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=4.2,<5.2 2 | django-easy-faq<=1.9 -------------------------------------------------------------------------------- /example/templates/faq/answer_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Answer Question{% endblock %} 3 | {% block heading %}Answer Question{% endblock %} 4 | {% block breadcrumbs %} 5 | 17 | {% endblock %} 18 | {% block content %} 19 | {{ form.media }} 20 |

{{question.question}}

21 |
22 | {% csrf_token %} 23 | {{ form.as_p }} 24 | {# {% for field in form %}#} 25 | {#
#} 26 | {# #} 27 | {# #} 28 | {# {% if field.errors %}#} 29 | {# {% for error in field.errors %}#} 30 | {#

{{ error }}

#} 31 | {# {% endfor %}#} 32 | {# {% endif %}#} 33 | {#
#} 34 | {# {% endfor %}#} 35 | 36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /example/templates/faq/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | {% block title %}FAQ with Bootstrap{% endblock %} 10 | 11 | 12 | 48 | {% block breadcrumbs %}{% endblock %} 49 |

{% block heading %}{% endblock %}

50 |
51 | {% block content %} 52 | {% endblock %} 53 |
54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /example/templates/faq/categories_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Choose Category{% endblock %} 3 | {% block heading %}Select a FAQ category{% endblock %} 4 | 5 | {% block content %} 6 |
7 | {% for category in categories %} 8 |
9 |
10 |
11 |
{{ category.name }}
12 | {% if category.description %} 13 |

{{category.description}}

14 | {% endif %} 15 | View 16 |
17 |
18 |
19 | {% endfor %} 20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /example/templates/faq/category_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Choose a FAQ Question{% endblock %} 3 | {% block heading %}{{category}}{% endblock %} 4 | {% block breadcrumbs %} 5 | 11 | {% endblock %} 12 | {% block content %} 13 | 14 | {% if category.description %} 15 |

{{category.description}}

16 | {% endif %} 17 | 18 | {% for question in category.question_set.all %} 19 |

{{question.question}}

20 | {% endfor %} 21 |
22 | Back 23 | {% if can_add_question %} 24 | Add Question 25 | {% endif %} 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /example/templates/faq/comment_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/question_base.html' %} 2 | 3 | {% block question_content %} 4 |

Post A Comment

5 |

{{question.question}}

6 |
7 | {% csrf_token %} 8 | {{form}} 9 | 10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /example/templates/faq/comments.html: -------------------------------------------------------------------------------- 1 |
2 |

Comments

3 | {% for comment in question.faqcomment_set.all %} 4 |
5 |

{{comment.comment}}

6 | posted by {% if comment.user%}{{comment.user}}{% else %}anonymous{% endif %} {{comment.post_time|timesince}} ago 7 |
8 | {% endfor %} 9 | 10 | 11 | {% if add_new_comment_allowed %} 12 |
13 |
14 | {% if category_enabled %} 15 |
16 | {% else %} 17 | 18 | {% endif %} 19 | {% csrf_token %} 20 | 21 | {% for field in comment_form %} 22 |
23 | 24 | 25 | {% if field.errors %} 26 | {% for error in field.errors %} 27 |

{{ error }}

28 | {% endfor %} 29 | {% endif %} 30 |
31 | {% endfor %} 32 | 33 |
34 | {% endif %} 35 |
-------------------------------------------------------------------------------- /example/templates/faq/question_base.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | {{question.question|title}}{% endblock %} 3 | {% block heading %}{{question.question|title}} {% if can_vote_question %} 4 |
5 | {% csrf_token %} 6 | 7 | 8 |
9 |
10 | {% csrf_token %} 11 | 12 | 13 |
14 | {% endif %}{% endblock %} 15 | {% block breadcrumbs %} 16 | 25 | {% endblock %} 26 | {% block content %} 27 | 28 | {% if question.category and category_enabled %} 29 | {{question.category.name}} 30 | {% endif %} 31 | 32 |
33 | 34 | {% block question_content %} 35 | {% endblock %} 36 | 37 | {% endblock %} -------------------------------------------------------------------------------- /example/templates/faq/question_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/question_base.html' %} 2 | 3 | {% block question_content %} 4 | {% if allow_multiple_answers %} 5 |

Answers

6 |
7 | {% for answer in question.answer_set.all %} 8 |
9 |
10 |
11 | {% if answer.is_rich_text %} 12 |

{{ answer.answer|safe }}

13 | {% else %} 14 |
{{answer.answer}}
15 | {% endif %} 16 |
17 | {% if can_vote_answer %} 18 | 31 | {% endif %} 32 |
33 |
34 | {% endfor %} 35 |
36 | 37 | {% else %} 38 | {% if question.answer_set.exists %} 39 |

Answer:

40 | {% if question.answer_set.first.is_rich_text %} 41 |

{{ question.answer_set.first.answer|safe }}

42 | {% else %} 43 |

{{question.answer_set.first.answer}}

44 | {% endif %} 45 | 46 | {% if can_vote_answer %} 47 | found this answer helpful? 48 |
49 | {% csrf_token %} 50 | 51 | 52 |
53 |
54 | {% csrf_token %} 55 | 56 | 57 |
58 | {% endif %} 59 | {% else %} 60 | no answers yet 61 | {% endif %} 62 | {% endif %} 63 | 64 | 65 | {% if can_answer_question %} 66 | {% if category_enabled %} 67 | answer question 68 | {% else %} 69 | answer question 70 | {% endif %} 71 | {% endif %} 72 |
73 | {% if comments_allowed %} 74 | {% include 'faq/comments.html' %} 75 | {% endif %} 76 | 77 | {% endblock %} -------------------------------------------------------------------------------- /example/templates/faq/question_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Ask a Question{% endblock %} 3 | {% block heading %}Ask a Question{% endblock %} 4 | {% block breadcrumbs %} 5 | 14 | {% endblock %} 15 | {% block content %} 16 |
17 | {% csrf_token %} 18 | {% for field in form %} 19 |
20 | 21 | 22 | {% if field.errors %} 23 | {% for error in field.errors %} 24 |

{{ error }}

25 | {% endfor %} 26 | {% endif %} 27 |
28 | {% endfor %} 29 | 30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /example/templates/faq/questions_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Choose Question{% endblock %} 3 | {% block heading %}Choose a FAQ Question{% endblock %} 4 | {% block content %} 5 | {% for question in questions %} 6 |

{{question.question}}

7 | {% endfor %} 8 | 9 | {% if can_add_question %} 10 |
11 | add question 12 | {% endif %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /example/templates/faq/vote_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Vote{% endblock %} 3 | {% block heading %}Vote{% endblock %} 4 | {% block content %} 5 |
6 | {% csrf_token %} 7 | {{form}} 8 | 9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /faq/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'faq.apps.FaqConfig' -------------------------------------------------------------------------------- /faq/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import admin 3 | from .models import * 4 | from django.conf import settings 5 | from django.utils.html import strip_tags 6 | # Register your models here. 7 | 8 | class AnswerHelpfulAdmin(admin.ModelAdmin): 9 | list_display = ("vote", "answer", "user") 10 | list_filter = ('vote',) 11 | search_fields = ['answer', "user"] 12 | 13 | 14 | class QuestionHelpfulAdmin(admin.ModelAdmin): 15 | list_display = ("vote", "question", "user") 16 | list_filter = ('vote',) 17 | search_fields = ['question', "user"] 18 | 19 | 20 | class AnswerAdminForm(forms.ModelForm): 21 | class Meta: 22 | model = Answer 23 | fields = "__all__" 24 | 25 | def __init__(self, *args, **kwargs): 26 | super().__init__(*args, **kwargs) 27 | if self.instance.pk: 28 | if self.instance.is_rich_text: 29 | try: 30 | from tinymce.widgets import TinyMCE 31 | self.fields["answer"].widget = TinyMCE() 32 | except ImportError: 33 | raise ImportError("Please install django-tinymce to use rich text answers") 34 | elif "rich_text_answers" in settings.FAQ_SETTINGS: 35 | self.instance.is_rich_text = True 36 | try: 37 | from tinymce.widgets import TinyMCE 38 | self.fields["answer"].widget = TinyMCE() 39 | except ImportError: 40 | raise ImportError("Please install django-tinymce to use rich text answers") 41 | 42 | 43 | class AnswerAdmin(admin.ModelAdmin): 44 | list_display = ("answer_", "question", "helpful", "not_helpful",'is_rich_text') 45 | list_filter = ('helpful', "not_helpful",'is_rich_text') 46 | search_fields = ['answer', "question"] 47 | readonly_fields = ('helpful', "not_helpful", 'slug','is_rich_text') 48 | form = AnswerAdminForm 49 | def answer_(self, obj): 50 | if obj.is_rich_text: 51 | return strip_tags(obj.answer) 52 | return obj.answer 53 | class CategoryAdmin(admin.ModelAdmin): 54 | search_fields = ['name', "_description"] 55 | readonly_fields = ("slug",) 56 | 57 | def get_list_display(self, request): 58 | if "no_category_description" not in settings.FAQ_SETTINGS: 59 | return ["name", "slug", "description"] 60 | return ['name', 'slug'] 61 | 62 | def get_exclude(self, request, obj=None): 63 | if "no_category_description" in settings.FAQ_SETTINGS: 64 | return ['_description'] 65 | else: 66 | return None 67 | 68 | class CommentAdmin(admin.ModelAdmin): 69 | list_display = ("comment", "question", "user", "post_time") 70 | list_filter = ('question', "post_time") 71 | search_fields = ['comment', "question"] 72 | 73 | 74 | class QuestionAdmin(admin.ModelAdmin): 75 | list_display = ("question", "category", "slug", "helpful", "not_helpful") 76 | list_filter = ('helpful', "not_helpful", "category") 77 | search_fields = ["question"] 78 | readonly_fields = ('helpful', "not_helpful", "slug") 79 | 80 | 81 | admin.site.register(Question, QuestionAdmin) 82 | admin.site.register(Answer, AnswerAdmin) 83 | 84 | # if category enabled 85 | if "no_category" not in settings.FAQ_SETTINGS: 86 | admin.site.register(Category, CategoryAdmin) 87 | 88 | # if comments are enabled 89 | if "no_comments" not in settings.FAQ_SETTINGS: 90 | admin.site.register(FAQComment, CommentAdmin) 91 | 92 | # if votes are enabled 93 | if "no_votes" not in settings.FAQ_SETTINGS: 94 | # if answer votes are enabled 95 | if "no_answer_votes" not in settings.FAQ_SETTINGS: 96 | admin.site.register(AnswerHelpful, AnswerHelpfulAdmin) 97 | 98 | # if question votes are enabled 99 | if "no_question_votes" not in settings.FAQ_SETTINGS: 100 | admin.site.register(QuestionHelpful, QuestionHelpfulAdmin) 101 | -------------------------------------------------------------------------------- /faq/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FaqConfig(AppConfig): 5 | name = 'faq' 6 | verbose_name = "faq" 7 | default_auto_field = 'django.db.models.BigAutoField' 8 | -------------------------------------------------------------------------------- /faq/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | from . import models 4 | 5 | class AnswerForm(forms.ModelForm): 6 | class Meta: 7 | model = models.Answer 8 | fields = ["answer"] 9 | 10 | def __init__(self, *args, **kwargs): 11 | super().__init__(*args, **kwargs) 12 | if "rich_text_answers" in settings.FAQ_SETTINGS: 13 | try: 14 | from tinymce.widgets import TinyMCE 15 | self.fields["answer"].widget = TinyMCE() 16 | except ImportError: 17 | raise ImportError("Please install django-tinymce to use rich text answers") 18 | 19 | class CommentForm(forms.ModelForm): 20 | class Meta: 21 | model = models.FAQComment 22 | fields = ["comment"] 23 | 24 | 25 | class VoteForm(forms.Form): 26 | vote = forms.BooleanField(label="helpful? ", required=False) 27 | -------------------------------------------------------------------------------- /faq/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-02-28 16:07 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Answer', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('answer', models.TextField()), 22 | ('slug', models.SlugField(max_length=10)), 23 | ('helpful', models.IntegerField(default=0)), 24 | ('not_helpful', models.IntegerField(default=0)), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='Category', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('name', models.CharField(max_length=50, unique=True)), 32 | ('description', models.TextField()), 33 | ('slug', models.SlugField(unique=True)), 34 | ], 35 | options={ 36 | 'verbose_name_plural': 'categories', 37 | }, 38 | ), 39 | migrations.CreateModel( 40 | name='Question', 41 | fields=[ 42 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 43 | ('question', models.CharField(max_length=150, unique=True)), 44 | ('slug', models.SlugField(max_length=150, unique=True)), 45 | ('helpful', models.IntegerField(default=0)), 46 | ('not_helpful', models.IntegerField(default=0)), 47 | ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='faq.category')), 48 | ], 49 | ), 50 | migrations.CreateModel( 51 | name='QuestionHelpful', 52 | fields=[ 53 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 54 | ('vote', models.BooleanField()), 55 | ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='faq.question')), 56 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 57 | ], 58 | options={ 59 | 'ordering': ['question', 'vote'], 60 | }, 61 | ), 62 | migrations.CreateModel( 63 | name='FAQComment', 64 | fields=[ 65 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 66 | ('comment', models.TextField()), 67 | ('post_time', models.DateTimeField(auto_now_add=True)), 68 | ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='faq.question')), 69 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 70 | ], 71 | options={ 72 | 'ordering': ['question', '-post_time'], 73 | }, 74 | ), 75 | migrations.CreateModel( 76 | name='AnswerHelpful', 77 | fields=[ 78 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 79 | ('vote', models.BooleanField()), 80 | ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='faq.answer')), 81 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 82 | ], 83 | options={ 84 | 'ordering': ['answer', 'vote'], 85 | }, 86 | ), 87 | migrations.AddField( 88 | model_name='answer', 89 | name='question', 90 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='faq.question'), 91 | ), 92 | migrations.AlterOrderWithRespectTo( 93 | name='answer', 94 | order_with_respect_to='question', 95 | ), 96 | ] 97 | -------------------------------------------------------------------------------- /faq/migrations/0002_alter_answer_id_alter_answerhelpful_id_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2021-12-19 14:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('faq', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='answer', 15 | name='id', 16 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 17 | ), 18 | migrations.AlterField( 19 | model_name='answerhelpful', 20 | name='id', 21 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 22 | ), 23 | migrations.AlterField( 24 | model_name='category', 25 | name='id', 26 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 27 | ), 28 | migrations.AlterField( 29 | model_name='faqcomment', 30 | name='id', 31 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 32 | ), 33 | migrations.AlterField( 34 | model_name='question', 35 | name='id', 36 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 37 | ), 38 | migrations.AlterField( 39 | model_name='questionhelpful', 40 | name='id', 41 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /faq/migrations/0003_auto_20220619_0939.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2022-06-19 09:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('faq', '0002_alter_answer_id_alter_answerhelpful_id_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='question', 15 | name='slug', 16 | field=models.SlugField(blank=True, max_length=150, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /faq/migrations/0004_alter_answer_slug_alter_category_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2022-06-19 16:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('faq', '0003_auto_20220619_0939'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='answer', 15 | name='slug', 16 | field=models.SlugField(blank=True, max_length=10), 17 | ), 18 | migrations.AlterField( 19 | model_name='category', 20 | name='slug', 21 | field=models.SlugField(blank=True, unique=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /faq/migrations/0005_rename_description_category__description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-05-05 20:01 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('faq', '0004_alter_answer_slug_alter_category_slug'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='category', 15 | old_name='description', 16 | new_name='_description', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /faq/migrations/0006_answer_is_rich_text.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.4 on 2024-05-08 01:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('faq', '0005_rename_description_category__description'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='answer', 15 | name='is_rich_text', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /faq/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smark-1/django-easy-faq/fa0c7c03ff815d8c0b3b1970e4bdb3f8563250b0/faq/migrations/__init__.py -------------------------------------------------------------------------------- /faq/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.utils.text import slugify 4 | from django.shortcuts import reverse 5 | 6 | from . import snippets 7 | 8 | 9 | # Create your models here. 10 | class Question(models.Model): 11 | category = models.ForeignKey("category", on_delete=models.SET_NULL, null=True, blank=True) 12 | question = models.CharField(max_length=150, unique=True) 13 | slug = models.SlugField(max_length=150, unique=True, blank=True) 14 | helpful = models.IntegerField(default=0) 15 | not_helpful = models.IntegerField(default=0) 16 | 17 | def get_helpful(self): 18 | return QuestionHelpful.objects.filter(question=self, vote=True).count() 19 | 20 | def get_not_helpful(self): 21 | return QuestionHelpful.objects.filter(question=self, vote=False).count() 22 | 23 | def __str__(self): 24 | return self.question 25 | 26 | def save(self, *args, **kwargs): 27 | self.slug = slugify(self.question, allow_unicode='allow_unicode' in settings.FAQ_SETTINGS)[:150] 28 | # if question already exists 29 | if self.pk: 30 | self.helpful = self.get_helpful() 31 | self.not_helpful = self.get_not_helpful() 32 | return super().save(*args, **kwargs) 33 | 34 | def get_absolute_url(self): 35 | # if using categories 36 | if "no_category" not in settings.FAQ_SETTINGS: 37 | return reverse("faq:question_detail", args=(self.category.slug, self.slug)) 38 | else: 39 | return reverse("faq:question_detail", args=(self.slug,)) 40 | 41 | class Meta: 42 | app_label = 'faq' 43 | 44 | 45 | class Answer(models.Model): 46 | question = models.ForeignKey(Question, on_delete=models.CASCADE) 47 | answer = models.TextField() 48 | slug = models.SlugField(max_length=10, blank=True) 49 | helpful = models.IntegerField(default=0) 50 | not_helpful = models.IntegerField(default=0) 51 | is_rich_text = models.BooleanField(default=False) 52 | 53 | def get_helpful(self): 54 | return AnswerHelpful.objects.filter(answer=self, vote=True).count() 55 | 56 | def get_not_helpful(self): 57 | return AnswerHelpful.objects.filter(answer=self, vote=False).count() 58 | 59 | def __str__(self): 60 | return self.answer 61 | 62 | class Meta: 63 | order_with_respect_to = 'question' 64 | app_label = 'faq' 65 | 66 | def save(self, *args, **kwargs): 67 | # if first time saving add a new slug 68 | if not self.pk or not self.slug: 69 | new_slug = snippets.create_random_slug(5) 70 | while Answer.objects.filter(slug=new_slug, answer=self.answer).exists(): 71 | new_slug = snippets.create_random_slug() 72 | self.slug = new_slug 73 | # if answer is not new 74 | if self.pk: 75 | self.helpful = self.get_helpful() 76 | self.not_helpful = self.get_not_helpful() 77 | super().save(*args, **kwargs) 78 | 79 | def get_absolute_url(self): 80 | # if using categories 81 | if "no_category" not in settings.FAQ_SETTINGS: 82 | return reverse("faq:question_detail", args=(self.question.category.slug, self.question.slug)) 83 | else: 84 | return reverse("faq:question_detail", args=(self.question.slug,)) 85 | 86 | 87 | class Category(models.Model): 88 | name = models.CharField(max_length=50, unique=True) 89 | _description = models.TextField() 90 | slug = models.SlugField(max_length=50, unique=True, blank=True) 91 | 92 | def __str__(self): 93 | return self.name 94 | 95 | class Meta: 96 | verbose_name_plural = "categories" 97 | app_label = 'faq' 98 | 99 | @property 100 | def description(self): 101 | """only show the description is categories have descriptions""" 102 | if "no_category_description" in settings.FAQ_SETTINGS: 103 | return None 104 | else: 105 | return self._description 106 | 107 | def save(self, *args, **kwargs): 108 | self.slug = slugify(self.name, allow_unicode='allow_unicode' in settings.FAQ_SETTINGS)[:50] 109 | return super().save(*args, **kwargs) 110 | 111 | def get_absolute_url(self): 112 | return reverse("faq:category_detail", args=(self.slug,)) 113 | 114 | 115 | class FAQComment(models.Model): 116 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True) 117 | comment = models.TextField() 118 | question = models.ForeignKey(Question, on_delete=models.CASCADE) 119 | post_time = models.DateTimeField(auto_now_add=True) 120 | 121 | def __str__(self): 122 | return self.comment 123 | 124 | class Meta: 125 | ordering = ['question', '-post_time'] 126 | app_label = 'faq' 127 | 128 | def get_absolute_url(self): 129 | # if using categories 130 | if "no_category" not in settings.FAQ_SETTINGS: 131 | return reverse("faq:question_detail", args=(self.question.category.slug, self.question.slug)) 132 | else: 133 | return reverse("faq:question_detail", args=(self.question.slug,)) 134 | 135 | 136 | class AnswerHelpful(models.Model): 137 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 138 | answer = models.ForeignKey(Answer, on_delete=models.CASCADE) 139 | vote = models.BooleanField() 140 | 141 | def __str__(self): 142 | if self.vote: 143 | vote_var = 'like' 144 | else: 145 | vote_var = 'dislike' 146 | 147 | return str(self.answer) + '- ' + vote_var 148 | 149 | class Meta: 150 | ordering = ['answer', 'vote'] 151 | app_label = 'faq' 152 | 153 | def get_absolute_url(self): 154 | # if using categories 155 | if "no_category" not in settings.FAQ_SETTINGS: 156 | return reverse("faq:question_detail", args=(self.answer.question.category.slug, self.answer.question.slug)) 157 | else: 158 | return reverse("faq:question_detail", args=(self.answer.question.slug,)) 159 | 160 | 161 | 162 | class QuestionHelpful(models.Model): 163 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 164 | question = models.ForeignKey(Question, on_delete=models.CASCADE) 165 | vote = models.BooleanField() 166 | 167 | def __str__(self): 168 | if self.vote: 169 | vote_var = 'like' 170 | else: 171 | vote_var = 'dislike' 172 | 173 | return str(self.question) + '- ' + vote_var 174 | 175 | class Meta: 176 | ordering = ['question', 'vote'] 177 | app_label = 'faq' 178 | 179 | def get_absolute_url(self): 180 | # if using categories 181 | if "no_category" not in settings.FAQ_SETTINGS: 182 | return reverse("faq:question_detail", args=(self.question.category.slug, self.question.slug)) 183 | else: 184 | return reverse("faq:question_detail", args=(self.question.slug,)) 185 | -------------------------------------------------------------------------------- /faq/path_converters.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls.converters import SlugConverter, register_converter 3 | 4 | if 'allow_unicode' in settings.FAQ_SETTINGS: 5 | class UnicodeSlugConverter(SlugConverter): 6 | regex = '[0-9\w_-]+' 7 | 8 | 9 | register_converter(UnicodeSlugConverter, 'uslug') 10 | 11 | else: 12 | register_converter(SlugConverter, 'uslug') 13 | -------------------------------------------------------------------------------- /faq/snippets.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from django.conf import settings 3 | 4 | ALL_URL_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 5 | 6 | 7 | def create_random_slug(size=10): 8 | """amount of characters you want generated for random_slug""" 9 | res = ''.join(secrets.choice(ALL_URL_CHARS) for _ in range(size)) 10 | return str(res) 11 | 12 | 13 | def get_template_settings(request): 14 | """returns most of the settings to be added into get_context_data method""" 15 | context = {} 16 | # if using categories 17 | if "no_category" not in settings.FAQ_SETTINGS: 18 | context['category_enabled'] = True 19 | else: 20 | context['category_enabled'] = False 21 | 22 | if "allow_multiple_answers" in settings.FAQ_SETTINGS: 23 | context['allow_multiple_answers'] = True 24 | else: 25 | context['allow_multiple_answers'] = False 26 | 27 | # if using comments 28 | if "no_comments" not in settings.FAQ_SETTINGS: 29 | context["comments_allowed"] = True 30 | if "anonymous_user_can_comment" in settings.FAQ_SETTINGS: 31 | context['add_new_comment_allowed'] = True 32 | else: 33 | if request.user.is_authenticated: 34 | context['add_new_comment_allowed'] = True 35 | else: 36 | context['add_new_comment_allowed'] = False 37 | 38 | if "view_only_comments" in settings.FAQ_SETTINGS: 39 | context['add_new_comment_allowed'] = False 40 | else: 41 | context["comments_allowed"] = False 42 | 43 | # if can vote on answers 44 | if "no_votes" not in settings.FAQ_SETTINGS: 45 | # if can vote answer 46 | if "no_answer_votes" not in settings.FAQ_SETTINGS: 47 | if request.user.is_authenticated: 48 | context["can_vote_answer"] = True 49 | else: 50 | context["can_vote_answer"] = False 51 | else: 52 | 53 | context["can_vote_answer"] = False 54 | 55 | if "no_question_votes" not in settings.FAQ_SETTINGS: 56 | if request.user.is_authenticated: 57 | context["can_vote_question"] = True 58 | else: 59 | context["can_vote_question"] = False 60 | else: 61 | 62 | context["can_vote_question"] = False 63 | else: 64 | context["can_vote_answer"] = False 65 | context["can_vote_question"] = False 66 | 67 | context["can_add_question"] = False 68 | if "logged_in_users_can_add_question" in settings.FAQ_SETTINGS: 69 | if request.user.is_authenticated: 70 | context["can_add_question"] = True 71 | 72 | return context 73 | -------------------------------------------------------------------------------- /faq/templates/faq/answer_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Answer Question{% endblock %} 3 | {% block heading %}Answer Question{% endblock %} 4 | {% block content %} 5 |

{{question.question}}

6 |
7 | {% csrf_token %} 8 | {{form}} 9 | 10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /faq/templates/faq/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}FAQ{% endblock %} 6 | {% if form %} 7 | {{ form.media }} 8 | {% endif %} 9 | 10 | 11 |

{% block heading %}{% endblock %}

12 | 13 | {% block content %} 14 | {% endblock %} 15 | 16 | -------------------------------------------------------------------------------- /faq/templates/faq/categories_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Choose Category{% endblock %} 3 | {% block heading %}Select a FAQ category{% endblock %} 4 | {% block content %} 5 | {% for category in categories %} 6 |

{{category.name}}

7 | {% if category.description %} 8 |

{{category.description}}

9 | {% endif %} 10 |
11 | {% endfor %} 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /faq/templates/faq/category_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Choose a FAQ Question{% endblock %} 3 | {% block heading %}Choose a FAQ Question{% endblock %} 4 | 5 | {% block content %} 6 |

{{category}}

7 | {% if category.description %} 8 |

{{category.description}}

9 | {% endif %} 10 |
11 | {% for question in category.question_set.all %} 12 |

{{question.question}}

13 | {% endfor %} 14 |
15 | back 16 | {% if can_add_question %} 17 | add question 18 | {% endif %} 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /faq/templates/faq/comment_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/question_base.html' %} 2 | 3 | {% block question_content %} 4 |

Post A Comment

5 |

{{question.question}}

6 |
7 | {% csrf_token %} 8 | {{form}} 9 | 10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /faq/templates/faq/comments.html: -------------------------------------------------------------------------------- 1 |

comments

2 | 8 | {% if add_new_comment_allowed %} 9 | {% if category_enabled %} 10 |
11 | {% else %} 12 | 13 | {% endif %} 14 |
15 | Post Your Comment Here: 16 | {% csrf_token %} 17 | {{comment_form}} 18 | 19 |
20 |
21 | {% endif %} -------------------------------------------------------------------------------- /faq/templates/faq/question_base.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | {{question.question|title}}{% endblock %} 3 | {% block heading %}{{question.question|title}}{% endblock %} 4 | 5 | {% block content %} 6 | {% if can_vote_question %} 7 | found this question helpful? 8 |
9 | {% csrf_token %} 10 | 11 | 12 |
13 |
14 | {% csrf_token %} 15 | 16 | 17 |
18 | {% endif %} 19 | {% if question.category and category_enabled %} 20 |

category - {{question.category.name}}

21 | {% endif %} 22 |
23 | 24 | {% block question_content %} 25 | {% endblock %} 26 | 27 | {% endblock %} -------------------------------------------------------------------------------- /faq/templates/faq/question_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/question_base.html' %} 2 | 3 | {% block question_content %} 4 | {% if allow_multiple_answers %} 5 |

answers

6 | 30 | 31 | {% else %} 32 | {% if question.answer_set.exists %} 33 |

answer:

34 | {% if question.answer_set.first.is_rich_text %} 35 |

{{question.answer_set.first.answer|safe}}

36 | {% else %} 37 |

{{question.answer_set.first.answer}}

38 | {% endif %} 39 | {% if can_vote_answer %} 40 | found this answer helpful? 41 |
42 | {% csrf_token %} 43 | 44 | 45 |
46 |
47 | {% csrf_token %} 48 | 49 | 50 |
51 | {% endif %} 52 | {% else %} 53 | no answers yet 54 | {% endif %} 55 | {% endif %} 56 | 57 | 58 | {% if can_answer_question %} 59 | {% if category_enabled %} 60 | answer question 61 | {% else %} 62 | answer question 63 | {% endif %} 64 | {% endif %} 65 |
66 | {% if comments_allowed %} 67 | {% include 'faq/comments.html' %} 68 | {% endif %} 69 | 70 | {% endblock %} -------------------------------------------------------------------------------- /faq/templates/faq/question_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Ask a Question{% endblock %} 3 | {% block heading %}Ask a Question{% endblock %} 4 | {% block content %} 5 |
6 | {% csrf_token %} 7 | {{form}} 8 | 9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /faq/templates/faq/questions_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Choose Question{% endblock %} 3 | {% block heading %}Choose a FAQ Question{% endblock %} 4 | {% block content %} 5 | {% for question in questions %} 6 |

{{question.question}}

7 | {% endfor %} 8 | 9 | {% if can_add_question %} 10 |
11 | add question 12 | {% endif %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /faq/templates/faq/vote_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'faq/base.html' %} 2 | {% block title %}FAQ | Vote{% endblock %} 3 | {% block heading %}Vote{% endblock %} 4 | {% block content %} 5 |
6 | {% csrf_token %} 7 | {{form}} 8 | 9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /faq/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, RequestFactory, override_settings 2 | from django.shortcuts import reverse 3 | from faq.views import IndexView, CategoryDetail, QuestionDetail 4 | from faq import models 5 | from django.contrib.auth.models import User, AnonymousUser 6 | 7 | 8 | # Create your tests here. 9 | 10 | class IndexViewTestCase(TestCase): 11 | 12 | @override_settings(FAQ_SETTINGS=["no_category"]) 13 | def test_get_template_names_no_categories(self): 14 | """gets correct template when not using categories""" 15 | request = RequestFactory().get(reverse("faq:index_view")) 16 | view = IndexView() 17 | view.setup(request) 18 | 19 | self.assertEqual(view.get_template_names(), "faq/questions_list.html") 20 | self.assertNotEqual(view.get_template_names(), "faq/categories_list.html") 21 | 22 | @override_settings(FAQ_SETTINGS=[]) 23 | def test_get_template_names_categories(self): 24 | """gets correct template when not using categories""" 25 | request = RequestFactory().get(reverse("faq:index_view")) 26 | view = IndexView() 27 | view.setup(request) 28 | 29 | self.assertNotEqual(view.get_template_names(), "faq/questions_list.html") 30 | self.assertEqual(view.get_template_names(), "faq/categories_list.html") 31 | 32 | 33 | @override_settings(FAQ_SETTINGS=["no_category"]) 34 | def test_get_queryset_no_categories(self): 35 | """gets correct query set when not using categories""" 36 | request = RequestFactory().get(reverse("faq:index_view")) 37 | view = IndexView() 38 | view.setup(request) 39 | 40 | models.Question.objects.create(question="?") 41 | models.Question.objects.create(question="the?") 42 | view.get_queryset() 43 | 44 | self.assertEqual(view.get_queryset().first(), models.Question.objects.first()) 45 | self.assertNotEqual(view.get_queryset().first(), models.Question.objects.last()) 46 | self.assertNotEqual(view.get_queryset().first(), models.Category.objects.first()) 47 | 48 | @override_settings(FAQ_SETTINGS=[]) 49 | def test_get_queryset_categories(self): 50 | """gets correct query set when using categories""" 51 | request = RequestFactory().get(reverse("faq:index_view")) 52 | view = IndexView() 53 | view.setup(request) 54 | 55 | category = models.Category.objects.create(name="category", _description="this is a category") 56 | models.Category.objects.create(name="category 2", _description="this is a category") 57 | models.Question.objects.create(question="category question", category=category) 58 | models.Question.objects.create(question="category question 2", category=category) 59 | models.Question.objects.create(question="question not in category") 60 | 61 | self.assertEqual(view.get_queryset().first(), models.Category.objects.first()) 62 | self.assertNotEqual(view.get_queryset().first(), models.Question.objects.first()) 63 | self.assertNotEqual(view.get_queryset().first(), models.Category.objects.last()) 64 | 65 | @override_settings(FAQ_SETTINGS=[]) 66 | def test_get_context_object_name_categories(self): 67 | """gets correct template variable when using categories""" 68 | request = RequestFactory().get(reverse("faq:index_view")) 69 | view = IndexView() 70 | view.setup(request) 71 | 72 | self.assertEqual(view.get_context_object_name([]), "categories") 73 | self.assertNotEqual(view.get_context_object_name([]), "questions") 74 | 75 | @override_settings(FAQ_SETTINGS=["no_category"]) 76 | def test_get_context_object_name_no_categories(self): 77 | """gets correct template variable when not using categories""" 78 | request = RequestFactory().get(reverse("faq:index_view")) 79 | view = IndexView() 80 | view.setup(request) 81 | 82 | self.assertEqual(view.get_context_object_name([]), "questions") 83 | self.assertNotEqual(view.get_context_object_name([]), "categories") 84 | 85 | @override_settings(FAQ_SETTINGS=["no_category"]) 86 | def test_get_context_data_not_using_categories(self): 87 | """gets context data correctly when not using categories""" 88 | request = RequestFactory().get(reverse("faq:index_view")) 89 | request.user = AnonymousUser() 90 | view = IndexView() 91 | view.object_list = view.get_queryset() 92 | view.setup(request) 93 | 94 | self.assertIn("can_add_question", view.get_context_data()) 95 | 96 | @override_settings(FAQ_SETTINGS=[]) 97 | def test_get_context_data_using_categories(self): 98 | """gets context data correctly when using categories""" 99 | request = RequestFactory().get(reverse("faq:index_view")) 100 | request.user = AnonymousUser() 101 | view = IndexView() 102 | view.object_list = view.get_queryset() 103 | view.setup(request) 104 | 105 | self.assertEqual(view.get_context_data()['can_add_question'],False) 106 | 107 | @override_settings(FAQ_SETTINGS=["no_category", "logged_in_users_can_add_question"]) 108 | def test_get_context_data_not_using_categories_logged_in_can_add(self): 109 | """gets context data correctly when not using categories and logged_in_users_can_add_question""" 110 | request = RequestFactory() 111 | request = request.get(reverse("faq:index_view")) 112 | request.user = User.objects.create_user(username="jim", password="the") 113 | view = IndexView() 114 | view.object_list = view.get_queryset() 115 | view.setup(request) 116 | 117 | self.assertIn("can_add_question", view.get_context_data()) 118 | 119 | 120 | class CategoryDetailTestCase(TestCase): 121 | def setUp(self): 122 | models.Category.objects.create(name="cat1", _description="descript") 123 | models.Category.objects.create(name="cat2", _description="descript2") 124 | models.Category.objects.create(name="cat3") 125 | 126 | 127 | class QuestionViewTestCase(TestCase): 128 | def setUp(self): 129 | category = models.Category.objects.create(name="cat1", _description="descript") 130 | 131 | models.Question.objects.create(category=category, question="great question") 132 | 133 | @override_settings(FAQ_SETTINGS=[]) 134 | def test_anonymous_user_cant_vote(self): 135 | """a user doesn't get a link to vote""" 136 | 137 | question = models.Question.objects.first() 138 | response = self.client.get(reverse("faq:question_detail", args=(question.category.slug, question.slug,))) 139 | 140 | self.assertEqual(response.context['can_vote_question'], False) 141 | self.assertEqual(response.context['can_vote_answer'], False) 142 | 143 | 144 | class VoteQuestionTestCase(TestCase): 145 | def setUp(self): 146 | category = models.Category.objects.create(name="cat1", _description="descript") 147 | 148 | models.Question.objects.create(category=category, question="great question") 149 | 150 | @override_settings(FAQ_SETTINGS=[]) 151 | def test_anonymous_user_cant_vote(self): 152 | """redirects logged out users to login page""" 153 | 154 | question = models.Question.objects.first() 155 | response = self.client.post(reverse("faq:vote_question", args=(question.category.slug, question.slug,))) 156 | 157 | self.assertEqual(response.status_code, 302) 158 | 159 | 160 | class VoteanswerTestCase(TestCase): 161 | def setUp(self): 162 | category = models.Category.objects.create(name="cat1", _description="descript") 163 | 164 | question = models.Question.objects.create(category=category, question="great question") 165 | 166 | self.answer = models.Answer.objects.create(question=question, answer="because") 167 | 168 | @override_settings(FAQ_SETTINGS=[]) 169 | def test_anonymous_user_cant_vote(self): 170 | """redirects logged out users to login page""" 171 | 172 | question = models.Question.objects.first() 173 | response = self.client.post( 174 | reverse("faq:vote_answer", args=(question.category.slug, question.slug, self.answer.slug))) 175 | 176 | self.assertEqual(response.status_code, 302) 177 | -------------------------------------------------------------------------------- /faq/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | from . import path_converters 4 | from django.conf import settings 5 | from django.contrib.auth.decorators import login_required 6 | 7 | app_name = 'faq' 8 | 9 | 10 | if "login_required" in settings.FAQ_SETTINGS: 11 | fn_decorater = login_required 12 | else: 13 | fn_decorater = lambda x: x 14 | 15 | 16 | urlpatterns = [ 17 | path("", fn_decorater(views.IndexView.as_view()), name="index_view"), 18 | ] 19 | 20 | # if using categories 21 | if "no_category" not in settings.FAQ_SETTINGS: 22 | urlpatterns += [ 23 | path("/", 24 | fn_decorater(views.CategoryDetail.as_view()), 25 | name="category_detail"), 26 | path("/add/question/", 27 | fn_decorater(views.AddQuestion.as_view()), 28 | name="add_question"), 29 | path("//", 30 | fn_decorater(views.QuestionDetail.as_view()), 31 | name="question_detail"), 32 | path("//answer/", 33 | fn_decorater(views.AddAnswer.as_view()), 34 | name="answer_question"), 35 | ] 36 | else: 37 | urlpatterns += [ 38 | path("/", 39 | fn_decorater(views.QuestionDetail.as_view()), 40 | name="question_detail"), 41 | path("/answer/", 42 | fn_decorater(views.AddAnswer.as_view()), 43 | name="answer_question"), 44 | path("add/question", 45 | fn_decorater(views.AddQuestion.as_view()), 46 | name="add_question"), 47 | ] 48 | 49 | # if using comments 50 | if "no_comments" not in settings.FAQ_SETTINGS: 51 | # if using categories 52 | if "no_category" not in settings.FAQ_SETTINGS: 53 | urlpatterns += [ 54 | path("//add/comment/", 55 | fn_decorater(views.AddComment.as_view()), 56 | name="add_comment"), 57 | ] 58 | else: 59 | urlpatterns += [ 60 | path("/add/comment/", 61 | fn_decorater(views.AddComment.as_view()), 62 | name="add_comment"), 63 | ] 64 | 65 | # if using votes 66 | if "no_votes" not in settings.FAQ_SETTINGS: 67 | # if using categories 68 | if "no_category" not in settings.FAQ_SETTINGS: 69 | urlpatterns += [ 70 | path("///vote/", 71 | fn_decorater(views.VoteAnswerHelpful.as_view()), 72 | name="vote_answer"), 73 | path("//vote/", 74 | fn_decorater(views.VoteQuestionHelpful.as_view()), 75 | name="vote_question") 76 | ] 77 | else: 78 | urlpatterns += [ 79 | path("//vote/", 80 | fn_decorater(views.VoteAnswerHelpful.as_view()), 81 | name="vote_answer"), 82 | path("/vote/", 83 | fn_decorater(views.VoteQuestionHelpful.as_view()), 84 | name="vote_question") 85 | ] 86 | -------------------------------------------------------------------------------- /faq/views.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied 2 | from django.shortcuts import reverse 3 | from django.views import generic 4 | from django.contrib.auth.mixins import UserPassesTestMixin 5 | from django.conf import settings 6 | from . import models 7 | from . import forms 8 | from .snippets import get_template_settings 9 | 10 | 11 | # Create your views here. 12 | class IndexView(generic.ListView): 13 | """ 14 | this view depending on settings either displays all the categories as a list if the categories is enabled using the categories_list.html template 15 | 16 | if categories are not enabled it will then show a list of all the questions using questions_list.html template 17 | """ 18 | 19 | def get_template_names(self): 20 | """if "no_category" render questions_list.html 21 | else render categories_list.html""" 22 | if "no_category" in settings.FAQ_SETTINGS: 23 | return "faq/questions_list.html" 24 | return "faq/categories_list.html" 25 | 26 | def get_queryset(self): 27 | if "no_category" in settings.FAQ_SETTINGS: 28 | return models.Question.objects.all() 29 | return models.Category.objects.all() 30 | 31 | def get_context_object_name(self, object_list): 32 | if "no_category" in settings.FAQ_SETTINGS: 33 | return "questions" 34 | return "categories" 35 | 36 | def get_context_data(self, **kwargs): 37 | context = super().get_context_data(**kwargs) 38 | context.update(get_template_settings(self.request)) # add in all settings into templates 39 | return context 40 | 41 | 42 | class CategoryDetail(generic.DetailView): 43 | """ 44 | this view only runs when categories are enabled 45 | this view shows all the questions related to this category 46 | """ 47 | model = models.Category 48 | template_name = "faq/category_detail.html" 49 | 50 | def get_context_data(self, *, object_list=None, **kwargs): 51 | context = super().get_context_data() 52 | context.update(get_template_settings(self.request)) # add in all settings into templates 53 | return context 54 | 55 | 56 | class AddQuestion(UserPassesTestMixin, generic.CreateView): 57 | """ 58 | this view is for a user to add a question to the faq questions 59 | if the using categories then this will also add the current category to it 60 | the default setting is only to allow the superuser to add new questions 61 | to change this: 62 | A) override the testfunc method 63 | B) set staff_can_add_question in the FAQ_SETTINGS to True (allows any staff user to add a question) 64 | C) set authenticated_user_can_add_question in the FAQ_SETTINGS to True (allows any authenticated user to add a question) 65 | """ 66 | model = models.Question 67 | fields = ['question'] 68 | 69 | def test_func(self): 70 | # when authenticated_user_can_add_question in the FAQ_SETTINGS is set to True 71 | if "logged_in_users_can_add_question" in settings.FAQ_SETTINGS: 72 | if self.request.user.is_authenticated: 73 | return True 74 | return False 75 | 76 | def get_success_url(self): 77 | return self.question_url 78 | 79 | def form_valid(self, form): 80 | # if using categories 81 | if "no_category" not in settings.FAQ_SETTINGS: 82 | form = form.save(commit=False) 83 | form.category = models.Category.objects.get(slug=self.kwargs["slug"]) 84 | form.save() 85 | self.question_url = form.get_absolute_url() 86 | return super().form_valid(form) 87 | else: 88 | form = form.save() 89 | self.question_url = form.get_absolute_url() 90 | return super().form_valid(form) 91 | 92 | def get_context_data(self, **kwargs): 93 | context = super().get_context_data(**kwargs) 94 | 95 | if "no_category" not in settings.FAQ_SETTINGS: 96 | context['category'] = models.Category.objects.get(slug=self.kwargs["slug"]) 97 | return context 98 | 99 | 100 | class QuestionDetail(generic.DetailView): 101 | model = models.Question 102 | template_name = "faq/question_detail.html" 103 | context_object_name = "question" 104 | 105 | def get_object(self, queryset=None): 106 | # if using categories 107 | if "no_category" not in settings.FAQ_SETTINGS: 108 | return self.model.objects.get(category__slug=self.kwargs["slug"], slug=self.kwargs["question"]) 109 | else: 110 | return self.model.objects.get(slug=self.kwargs["slug"]) 111 | 112 | def get_context_data(self, **kwargs): 113 | context = super().get_context_data() 114 | context.update(get_template_settings(self.request)) # add in all settings into templates 115 | 116 | # check if logged in users are allowed to answer questions 117 | if "logged_in_users_can_answer_question" in settings.FAQ_SETTINGS: 118 | # check if user is logged in 119 | if self.request.user.is_authenticated: 120 | if "allow_multiple_answers" in settings.FAQ_SETTINGS: 121 | context['can_answer_question'] = True 122 | else: 123 | # if there is already one answer 124 | if self.get_object().answer_set.count() > 0: 125 | context['can_answer_question'] = False 126 | else: 127 | context['can_answer_question'] = True 128 | else: 129 | context['can_answer_question'] = False 130 | else: 131 | context['can_answer_question'] = False 132 | 133 | context["comment_form"] = forms.CommentForm() 134 | return context 135 | 136 | 137 | class AddAnswer(UserPassesTestMixin, generic.CreateView): 138 | template_name = "faq/answer_form.html" 139 | form_class = forms.AnswerForm 140 | 141 | def test_func(self): 142 | # when authenticated_user_can_add_question in the FAQ_SETTINGS is set to True 143 | if "logged_in_users_can_answer_question" in settings.FAQ_SETTINGS: 144 | if self.request.user.is_authenticated: 145 | if "allow_multiple_answers" in settings.FAQ_SETTINGS: 146 | return True 147 | else: 148 | 149 | # if using categories 150 | if "no_category" not in settings.FAQ_SETTINGS: 151 | question = models.Question.objects.get(category__slug=self.kwargs['category'], 152 | slug=self.kwargs['question']) 153 | else: 154 | question = models.Question.objects.get(slug=self.kwargs['question']) 155 | 156 | # if there is already one answer don't allow user to add answer 157 | if question.answer_set.count() > 0: 158 | return False 159 | else: 160 | return True 161 | return False 162 | 163 | def get_success_url(self): 164 | # if using categories 165 | if "no_category" not in settings.FAQ_SETTINGS: 166 | return reverse("faq:question_detail", args=(self.kwargs['category'], self.kwargs['question'])) 167 | else: 168 | return reverse("faq:question_detail", args=(self.kwargs['question'],)) 169 | 170 | def get_context_data(self, **kwargs): 171 | context = super().get_context_data() 172 | context.update(get_template_settings(self.request)) # add in all settings into templates 173 | 174 | # if using categories 175 | if "no_category" not in settings.FAQ_SETTINGS: 176 | question = models.Question.objects.get(category__slug=self.kwargs['category'], slug=self.kwargs['question']) 177 | else: 178 | question = models.Question.objects.get(slug=self.kwargs['question']) 179 | 180 | context["question"] = question 181 | return context 182 | 183 | def form_valid(self, form): 184 | form = form.save(commit=False) 185 | 186 | # if using categories 187 | if "no_category" not in settings.FAQ_SETTINGS: 188 | question = models.Question.objects.get(category__slug=self.kwargs['category'], slug=self.kwargs['question']) 189 | else: 190 | question = models.Question.objects.get(slug=self.kwargs['question']) 191 | 192 | form.question = question 193 | if "rich_text_answers" in settings.FAQ_SETTINGS: 194 | form.is_rich_text = True 195 | form.save() 196 | return super().form_valid(form) 197 | 198 | 199 | class AddComment(generic.CreateView): 200 | model = models.FAQComment 201 | form_class = forms.CommentForm 202 | template_name = "faq/comment_form.html" 203 | 204 | def get_question(self): 205 | # if using categories 206 | if "no_category" not in settings.FAQ_SETTINGS: 207 | question = models.Question.objects.get(category__slug=self.kwargs['category'], slug=self.kwargs['question']) 208 | else: 209 | question = models.Question.objects.get(slug=self.kwargs['question']) 210 | 211 | return question 212 | 213 | def get_success_url(self): 214 | return self.get_question().get_absolute_url() 215 | 216 | def get_context_data(self, **kwargs): 217 | context = super().get_context_data() 218 | context.update(get_template_settings(self.request)) # add in all settings into templates 219 | context["question"] = self.get_question() 220 | return context 221 | 222 | def form_valid(self, form): 223 | form = form.save(commit=False) 224 | form.question = self.get_question() 225 | if self.request.user.is_authenticated: 226 | form.user = self.request.user 227 | form.save() 228 | return super().form_valid(form) 229 | 230 | def get(self, *args, **kwargs): 231 | if "view_only_comments" in settings.FAQ_SETTINGS: 232 | raise PermissionDenied("comments are view only at this time") 233 | if self.request.user.is_authenticated: 234 | pass 235 | else: 236 | if not "anonymous_user_can_comment" in settings.FAQ_SETTINGS: 237 | raise PermissionDenied("have to be logged in to comment") 238 | return super().get(*args, **kwargs) 239 | 240 | def post(self, *args, **kwargs): 241 | if "view_only_comments" in settings.FAQ_SETTINGS: 242 | raise PermissionDenied("comments are view only at this time") 243 | if self.request.user.is_authenticated: 244 | pass 245 | else: 246 | if not "anonymous_user_can_comment" in settings.FAQ_SETTINGS: 247 | raise PermissionDenied("have to be logged in to comment") 248 | return super().post(*args, **kwargs) 249 | 250 | 251 | class VoteAnswerHelpful(UserPassesTestMixin, generic.FormView): 252 | form_class = forms.VoteForm 253 | template_name = "faq/vote_form.html" 254 | 255 | def get_success_url(self): 256 | return self.get_question().get_absolute_url() 257 | 258 | def get_question(self): 259 | # if using categories 260 | if "no_category" not in settings.FAQ_SETTINGS: 261 | question = models.Question.objects.get(category__slug=self.kwargs['category'], slug=self.kwargs['question']) 262 | else: 263 | question = models.Question.objects.get(slug=self.kwargs['question']) 264 | 265 | return question 266 | 267 | def get_answer(self): 268 | return models.Answer.objects.get(question=self.get_question(), slug=self.kwargs['answer']) 269 | 270 | def form_valid(self, form): 271 | # if already voted get vote otherwise create it 272 | if models.AnswerHelpful.objects.filter(answer=self.get_answer(), user=self.request.user).exists(): 273 | helpful = models.AnswerHelpful.objects.get(answer=self.get_answer(), user=self.request.user) 274 | else: 275 | helpful = models.AnswerHelpful(answer=self.get_answer(), user=self.request.user) 276 | 277 | helpful.vote = form.cleaned_data['vote'] 278 | helpful.save() 279 | self.get_answer().save() 280 | return super().form_valid(form) 281 | 282 | def test_func(self): 283 | if "no_answer_votes" in settings.FAQ_SETTINGS: 284 | return False 285 | elif "no_votes" in settings.FAQ_SETTINGS: 286 | return False 287 | if not self.request.user.is_authenticated: 288 | return False 289 | return True 290 | 291 | 292 | class VoteQuestionHelpful(UserPassesTestMixin, generic.FormView): 293 | form_class = forms.VoteForm 294 | template_name = "faq/vote_form.html" 295 | 296 | def get_success_url(self): 297 | return self.get_question().get_absolute_url() 298 | 299 | def get_question(self): 300 | # if using categories 301 | if "no_category" not in settings.FAQ_SETTINGS: 302 | question = models.Question.objects.get(category__slug=self.kwargs['category'], slug=self.kwargs['question']) 303 | else: 304 | question = models.Question.objects.get(slug=self.kwargs['question']) 305 | 306 | return question 307 | 308 | def form_valid(self, form): 309 | # if already voted get vote otherwise create it 310 | if models.QuestionHelpful.objects.filter(question=self.get_question(), user=self.request.user).exists(): 311 | helpful = models.QuestionHelpful.objects.get(question=self.get_question(), user=self.request.user) 312 | else: 313 | helpful = models.QuestionHelpful(question=self.get_question(), user=self.request.user) 314 | 315 | helpful.vote = form.cleaned_data['vote'] 316 | helpful.save() 317 | self.get_question().save() 318 | return super().form_valid(form) 319 | 320 | def test_func(self): 321 | if "no_question_votes" in settings.FAQ_SETTINGS: 322 | return False 323 | elif "no_votes" in settings.FAQ_SETTINGS: 324 | return False 325 | if not self.request.user.is_authenticated: 326 | return False 327 | return True 328 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup 4 | from os import path 5 | 6 | install_requires = [ 7 | "Django>=3.2", 8 | ] 9 | 10 | here = path.abspath(path.dirname(__file__)) 11 | 12 | with open(path.join(here, "README.md"), encoding="utf-8") as f: 13 | long_description = f.read() 14 | 15 | setup( 16 | name="django-easy-faq", 17 | version=os.environ.get("RELEASE_VERSION", '0.0.1'), 18 | description="A Django app to add great FAQ functionality to website", 19 | long_description=long_description, 20 | long_description_content_type="text/markdown", 21 | url="https://github.com/smark-1/django-easy-faq/", 22 | download_url="https://pypi.python.org/pypi/django-easy-faq", 23 | license="MIT", 24 | packages=["faq"], 25 | install_requires=install_requires, 26 | include_package_data=True, 27 | python_requires=">=3.9", 28 | keywords=[ 29 | "django", 30 | "faq", 31 | ], 32 | classifiers=[ 33 | "Intended Audience :: Developers", 34 | "License :: OSI Approved :: MIT License", 35 | "Operating System :: OS Independent", 36 | "Programming Language :: Python :: 3", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | "Framework :: Django", 42 | ], 43 | ) --------------------------------------------------------------------------------