├── .gitignore ├── AUTHORS.rst ├── LICENSE.rst ├── MANIFEST ├── README.rst ├── docs ├── Makefile ├── alerts.rst ├── conf.py ├── customize.rst ├── development.rst ├── images │ ├── ask.png │ ├── home.png │ ├── results.png │ ├── tests.png │ ├── thread-mod.png │ └── thread.png ├── index.rst ├── install.rst ├── make.bat ├── overview.rst ├── performance.rst └── settings.rst ├── knowledge ├── __init__.py ├── admin.py ├── forms.py ├── managers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto__add_field_question_alert__add_field_response_alert.py │ ├── 0003_auto__add_unique_category_slug.py │ └── __init__.py ├── models.py ├── settings.py ├── signals.py ├── static │ └── knowledge │ │ ├── css │ │ ├── main.css │ │ └── reset.css │ │ └── scss │ │ ├── _mixins.scss │ │ ├── main.scss │ │ └── reset.scss ├── templates │ └── django_knowledge │ │ ├── ask.html │ │ ├── base.html │ │ ├── emails │ │ ├── base.html │ │ ├── message.html │ │ ├── message.txt │ │ └── subject.txt │ │ ├── form.html │ │ ├── index.html │ │ ├── inner.html │ │ ├── list.html │ │ ├── mod_bar.html │ │ ├── question_list.html │ │ ├── sidebar.html │ │ ├── thread.html │ │ └── welcome.html ├── templatetags │ ├── __init__.py │ └── knowledge_tags.py ├── urls.py ├── utils.py └── views.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── manage.py ├── migrate.sh ├── mock ├── __init__.py ├── models.py └── tests │ ├── __init__.py │ ├── base.py │ ├── forms.py │ ├── managers.py │ ├── models.py │ ├── sample.py │ ├── settings.py │ ├── signals.py │ ├── templatetags.py │ ├── utils.py │ └── views.py ├── runserver.sh ├── runtests.sh ├── settings.py ├── syncdb.sh ├── templates ├── 404.html └── 500.html ├── updateschema.sh └── urls.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | build 4 | dist 5 | docs/_build 6 | 7 | *.sqlite 8 | .sass-cache 9 | 10 | tests/reports -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Django Knowledge is written and maintained by Zapier and 2 | various contributors: 3 | 4 | 5 | Development Lead 6 | ```````````````` 7 | 8 | - Bryan Helmig 9 | 10 | 11 | Patches and Suggestions 12 | ``````````````````````` 13 | 14 | - Greg Aker -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Zapier LLC. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.py 3 | knowledge/__init__.py 4 | knowledge/admin.py 5 | knowledge/forms.py 6 | knowledge/managers.py 7 | knowledge/models.py 8 | knowledge/settings.py 9 | knowledge/signals.py 10 | knowledge/urls.py 11 | knowledge/utils.py 12 | knowledge/views.py 13 | knowledge/migrations/0001_initial.py 14 | knowledge/migrations/0002_auto__add_field_question_alert__add_field_response_alert.py 15 | knowledge/migrations/0003_auto__add_unique_category_slug.py 16 | knowledge/migrations/__init__.py 17 | knowledge/static/knowledge/css/main.css 18 | knowledge/static/knowledge/css/reset.css 19 | knowledge/templates/django_knowledge/ask.html 20 | knowledge/templates/django_knowledge/base.html 21 | knowledge/templates/django_knowledge/form.html 22 | knowledge/templates/django_knowledge/index.html 23 | knowledge/templates/django_knowledge/inner.html 24 | knowledge/templates/django_knowledge/list.html 25 | knowledge/templates/django_knowledge/mod_bar.html 26 | knowledge/templates/django_knowledge/question_list.html 27 | knowledge/templates/django_knowledge/sidebar.html 28 | knowledge/templates/django_knowledge/thread.html 29 | knowledge/templates/django_knowledge/welcome.html 30 | knowledge/templates/django_knowledge/emails/base.html 31 | knowledge/templates/django_knowledge/emails/message.html 32 | knowledge/templatetags/__init__.py 33 | knowledge/templatetags/knowledge_tags.py 34 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This project is no longer maintained. If you are interested in taking over the project, email `contact@zapier.com 2 | `_. 3 | 4 | Welcome to django-knowledge! 5 | ============================ 6 | 7 | django-knowledge makes it easy to add an integrated support desk, help desk or 8 | knowledge base to your Django project with only a few lines of boilerplate code. 9 | While we give you a generic design for free, you should just as easily be able 10 | to customize the look and feel of the app if you like. 11 | 12 | **django-knowledge** was developed internally for `Zapier `_ 13 | (see it live here `on our support page `_). Or, check 14 | out a `plain, live demo `_. 15 | 16 | 17 | At a glance: 18 | ------------ 19 | 20 | - Turn common questions or support requests into a **knowledge base**. 21 | - Control **who sees what** with simple per object view permissions: *public* (everyone), 22 | *private* (poster & staff), or *internal* (only staff). 23 | - Assign questions and answers to **categories** for easy sorting. 24 | - Staff get **moderation controls** or they can use the familiar *Django admin* to handle support requests. 25 | - Allow **anonymous questions**, or require a standard Django user account (the default). 26 | - Included base **templates and design** with prebundled HTML and CSS. 27 | - Optionally **alert users** of new responses via email (or your own alert system). 28 | - BSD license. 29 | 30 | 31 | Links: 32 | ------ 33 | 34 | * View a `live demo `_. This is the included stock design. 35 | * Check out the `documentation `_ at ReadTheDocs. 36 | * Visit our `GitHub repo `_ and join the development! 37 | 38 | 39 | Screen Shots: 40 | ------------- 41 | 42 | .. image:: https://github.com/zapier/django-knowledge/raw/master/docs/images/thread.png 43 | :width: 100 % 44 | :alt: a common thread viewed by anonymous user 45 | 46 | .. image:: https://github.com/zapier/django-knowledge/raw/master/docs/images/thread-mod.png 47 | :width: 100 % 48 | :alt: a common thread viewed by a moderator (staff) 49 | 50 | .. image:: https://github.com/zapier/django-knowledge/raw/master/docs/images/ask.png 51 | :width: 100 % 52 | :alt: ask form 53 | 54 | .. image:: https://github.com/zapier/django-knowledge/raw/master/docs/images/home.png 55 | :width: 100 % 56 | :alt: the home page 57 | 58 | .. image:: https://github.com/zapier/django-knowledge/raw/master/docs/images/results.png 59 | :width: 100 % 60 | :alt: search results with ask form at bottom 61 | 62 | .. image:: https://github.com/zapier/django-knowledge/raw/master/docs/images/tests.png 63 | :alt: 100% coverage on tests 64 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoKnowledge.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoKnowledge.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoKnowledge" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoKnowledge" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/alerts.rst: -------------------------------------------------------------------------------- 1 | Alerts 2 | ====== 3 | 4 | **django-knowledge** has built in functionality that sends email alerts to 5 | subscribed users when a new response is added or accepted. Users can opt-in 6 | or out this alert at the time of posting a question or response. 7 | 8 | Further, Users with the flag 'is_staff' and the permission to change questions 9 | will receive updates when a new question is added. 10 | 11 | *TODO:* They can also opt-in or out after the fact. 12 | 13 | 14 | Enabling 15 | -------- 16 | 17 | By default, alerts are disabled. to enable them, simply add to your ``settings.py``: 18 | 19 | .. code-block:: python 20 | 21 | KNOWLEDGE_ALERTS = True 22 | 23 | Also ensure that the Django site framework is installed and setup properly, otherwise 24 | the default links may not work properly. 25 | 26 | .. code-block:: python 27 | 28 | SITE_ID = 1 29 | 30 | INSTALLED_APPS = ( 31 | # ... 32 | 'django.contrib.sites', 33 | # ... 34 | ) 35 | 36 | 37 | Scheduling 38 | ---------- 39 | 40 | By default, **django-knowledge** will greedily send emails via whatever email 41 | backend you have set during the request/response cycle. This is likely not 42 | desirable: we recommend using something like 43 | `django-celery-email `_ 44 | to delay the task via a queue. No further action is needed if you go this route. 45 | 46 | Alternatively, you can specify your own email function where you can introduce your 47 | own off request functionality: 48 | 49 | .. code-block:: python 50 | 51 | KNOWLEDGE_ALERTS_FUNCTION_PATH = 'path.to.your.own.function' 52 | 53 | The email function should expect three keyword arguments: 54 | 55 | * ``target_dict`` - A dictionary for {'me@dom.com': ('First Last (or username)', 'me@dom.com')} for 56 | anonymous or {'me@dom.com': }. This list is deduplicated by email 57 | address. This list is generated by including all parties involved in a thread that 58 | had checked ``alert``, or, by list of staff ``User`` models when a new question is 59 | added. 60 | * ``response`` - A Response instance of the model triggering this alert. May be 61 | ``None``. Only passed in when a new response is added. 62 | * ``question`` - A Question instance of the model triggering this alert. May be 63 | ``None``. Only passed in when a new question is added. 64 | * ``**kwargs`` - It would be wise to include a blanket keyword arg catcher, we'll 65 | likely add more things in the future, so this will keep your code from breaking. 66 | 67 | Take a look at ``knowledge.signals.send_alerts`` for the original alert function 68 | to get an idea how it works. 69 | 70 | **Note:** just to clarify: if you change the email function path setting, you will 71 | need to send the alert emails (or any other form of communication) yourself. Our 72 | builtin function will no longer act. 73 | 74 | 75 | Templating 76 | ---------- 77 | 78 | We offer three default templates used to render both the subject and message of 79 | alert emails: 80 | 81 | * ``django_knowledge/emails/subject.txt`` 82 | * ``django_knowledge/emails/message.txt`` 83 | * ``django_knowledge/emails/message.html`` 84 | 85 | Emails are sent with both txt and html formats. Simply override these if you want 86 | to modify the defaults. -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django Knowledge documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Feb 16 12:54:45 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.insert(0, os.path.abspath('..')) 20 | version_tuple = __import__('knowledge').VERSION 21 | version = ".".join([str(v) for v in version_tuple]) 22 | 23 | 24 | # -- General configuration ----------------------------------------------------- 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be extensions 30 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 31 | extensions = [] 32 | 33 | # Add any paths that contain templates here, relative to this directory. 34 | templates_path = ['_templates'] 35 | 36 | # The suffix of source filenames. 37 | source_suffix = '.rst' 38 | 39 | # The encoding of source files. 40 | #source_encoding = 'utf-8-sig' 41 | 42 | # The master toctree document. 43 | master_doc = 'index' 44 | 45 | # General information about the project. 46 | project = u'Django Knowledge' 47 | copyright = u'2012, Zapier' 48 | 49 | # The version info for the project you're documenting, acts as replacement for 50 | # |version| and |release|, also used in various other places throughout the 51 | # built documents. 52 | # 53 | # The short X.Y version. 54 | #version = '0.0.1' 55 | # The full version, including alpha/beta/rc tags. 56 | release = version 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | #language = None 61 | 62 | # There are two options for replacing |today|: either, you set today to some 63 | # non-false value, then it is used: 64 | #today = '' 65 | # Else, today_fmt is used as the format for a strftime call. 66 | #today_fmt = '%B %d, %Y' 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | exclude_patterns = ['_build'] 71 | 72 | # The reST default role (used for this markup: `text`) to use for all documents. 73 | #default_role = None 74 | 75 | # If true, '()' will be appended to :func: etc. cross-reference text. 76 | #add_function_parentheses = True 77 | 78 | # If true, the current module name will be prepended to all description 79 | # unit titles (such as .. function::). 80 | #add_module_names = True 81 | 82 | # If true, sectionauthor and moduleauthor directives will be shown in the 83 | # output. They are ignored by default. 84 | #show_authors = False 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | # A list of ignored prefixes for module index sorting. 90 | #modindex_common_prefix = [] 91 | 92 | 93 | # -- Options for HTML output --------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = 'nature' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | #html_theme_options = {} 103 | 104 | # Add any paths that contain custom themes here, relative to this directory. 105 | #html_theme_path = [] 106 | 107 | # The name for this set of Sphinx documents. If None, it defaults to 108 | # " v documentation". 109 | #html_title = None 110 | 111 | # A shorter title for the navigation bar. Default is the same as html_title. 112 | #html_short_title = None 113 | 114 | # The name of an image file (relative to this directory) to place at the top 115 | # of the sidebar. 116 | #html_logo = None 117 | 118 | # The name of an image file (within the static path) to use as favicon of the 119 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 120 | # pixels large. 121 | #html_favicon = None 122 | 123 | # Add any paths that contain custom static files (such as style sheets) here, 124 | # relative to this directory. They are copied after the builtin static files, 125 | # so a file named "default.css" will overwrite the builtin "default.css". 126 | html_static_path = ['_static'] 127 | 128 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 129 | # using the given strftime format. 130 | #html_last_updated_fmt = '%b %d, %Y' 131 | 132 | # If true, SmartyPants will be used to convert quotes and dashes to 133 | # typographically correct entities. 134 | #html_use_smartypants = True 135 | 136 | # Custom sidebar templates, maps document names to template names. 137 | #html_sidebars = {} 138 | 139 | # Additional templates that should be rendered to pages, maps page names to 140 | # template names. 141 | #html_additional_pages = {} 142 | 143 | # If false, no module index is generated. 144 | #html_domain_indices = True 145 | 146 | # If false, no index is generated. 147 | #html_use_index = True 148 | 149 | # If true, the index is split into individual pages for each letter. 150 | #html_split_index = False 151 | 152 | # If true, links to the reST sources are added to the pages. 153 | #html_show_sourcelink = True 154 | 155 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 156 | #html_show_sphinx = True 157 | 158 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 159 | #html_show_copyright = True 160 | 161 | # If true, an OpenSearch description file will be output, and all pages will 162 | # contain a tag referring to it. The value of this option must be the 163 | # base URL from which the finished HTML is served. 164 | #html_use_opensearch = '' 165 | 166 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 167 | #html_file_suffix = None 168 | 169 | # Output file base name for HTML help builder. 170 | htmlhelp_basename = 'DjangoKnowledgedoc' 171 | 172 | 173 | # -- Options for LaTeX output -------------------------------------------------- 174 | 175 | latex_elements = { 176 | # The paper size ('letterpaper' or 'a4paper'). 177 | #'papersize': 'letterpaper', 178 | 179 | # The font size ('10pt', '11pt' or '12pt'). 180 | #'pointsize': '10pt', 181 | 182 | # Additional stuff for the LaTeX preamble. 183 | #'preamble': '', 184 | } 185 | 186 | # Grouping the document tree into LaTeX files. List of tuples 187 | # (source start file, target name, title, author, documentclass [howto/manual]). 188 | latex_documents = [ 189 | ('index', 'DjangoKnowledge.tex', u'Django Knowledge Documentation', 190 | u'Zapier', 'manual'), 191 | ] 192 | 193 | # The name of an image file (relative to this directory) to place at the top of 194 | # the title page. 195 | #latex_logo = None 196 | 197 | # For "manual" documents, if this is true, then toplevel headings are parts, 198 | # not chapters. 199 | #latex_use_parts = False 200 | 201 | # If true, show page references after internal links. 202 | #latex_show_pagerefs = False 203 | 204 | # If true, show URL addresses after external links. 205 | #latex_show_urls = False 206 | 207 | # Documents to append as an appendix to all manuals. 208 | #latex_appendices = [] 209 | 210 | # If false, no module index is generated. 211 | #latex_domain_indices = True 212 | 213 | 214 | # -- Options for manual page output -------------------------------------------- 215 | 216 | # One entry per manual page. List of tuples 217 | # (source start file, name, description, authors, manual section). 218 | man_pages = [ 219 | ('index', 'djangoknowledge', u'Django Knowledge Documentation', 220 | [u'Zapier'], 1) 221 | ] 222 | 223 | # If true, show URL addresses after external links. 224 | #man_show_urls = False 225 | 226 | 227 | # -- Options for Texinfo output ------------------------------------------------ 228 | 229 | # Grouping the document tree into Texinfo files. List of tuples 230 | # (source start file, target name, title, author, 231 | # dir menu entry, description, category) 232 | texinfo_documents = [ 233 | ('index', 'DjangoKnowledge', u'Django Knowledge Documentation', 234 | u'Zapier', 'DjangoKnowledge', 'One line description of project.', 235 | 'Miscellaneous'), 236 | ] 237 | 238 | # Documents to append as an appendix to all manuals. 239 | #texinfo_appendices = [] 240 | 241 | # If false, no module index is generated. 242 | #texinfo_domain_indices = True 243 | 244 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 245 | #texinfo_show_urls = 'footnote' 246 | -------------------------------------------------------------------------------- /docs/customize.rst: -------------------------------------------------------------------------------- 1 | Customize 2 | ========= 3 | 4 | Since **django-knowledge** ships with default themes and styles, you might have 5 | to spend a little time perfecting *your look*. However, it should work right out 6 | of the box with minimal setup (or none!) if you don't mind the defaults. 7 | 8 | .. _customize-template: 9 | 10 | Templates 11 | --------- 12 | 13 | The default base template is ``django_knowledge/base.html`` which contains a 14 | single ``{% block knowledge_inner %}`` tag. This base template loads two css 15 | files from your static (see below): ``reset.css`` and ``base.css``. 16 | 17 | If you have your own template shim/wrapper: 18 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 19 | 20 | 1. Copy and modify ``django_knowledge/base.html`` to your own template folder. Edit 21 | it as you see fit. 22 | 2. Include ``{{ STATIC_URL }}knowledge/css/main.css`` for knowledge specific styling. 23 | You should purposefully leave out ``{{ STATIC_URL }}knowledge/css/reset.css`` if you 24 | don't want us to reset your existing base styles. 25 | 26 | If you do decide to change the base template via the ``KNOWLEDGE_BASE_TEMPLATE`` 27 | setting, your new template might look something like this: 28 | 29 | .. code-block:: html 30 | 31 | 32 | 33 | 34 | {% block title %}{% endblock title %} | Johnny's Support Center 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 |
47 | Welcome to the Johnny's app! 48 |
49 | 50 |
51 | {% block knowledge_inner %} 52 | 53 | {% block content %} 54 | 55 | {% endblock content %} 56 | 57 | 58 | {% endblock knowledge_inner %} 59 |
60 | 61 | 64 | 65 |
66 | 67 | 68 | 69 | That isn't to say that our css styles will fit in perfectly, but we've been careful 70 | to namespace under ``dk-``` the majority of our css classes, so conflicts should be 71 | minimal. 72 | 73 | If you want to use the included template shim/wrapper: 74 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 75 | 76 | 1. Ensure static resources are loading for ``{{ STATIC_URL }}knowledge/css/reset.css`` 77 | and ``{{ STATIC_URL }}knowledge/css/main.css``. 78 | 2. Done. 79 | 80 | 81 | Modifying common singular sections: 82 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 83 | 84 | There are two very common areas for modification: 85 | 86 | 1. ``django_knowledge/welcome.html`` - The support header containing the link and phrase 87 | "Welcome to our Support Center". Simply override this locally by copying and editing 88 | ``templates/django_knowledge/welcome.html`` to your project. 89 | 90 | 2. ``django_knowledge/sidebar.html`` - The sidebar containing links to the homepage, ask 91 | a question and categories. Likewise, simply override this locally by copying and editing 92 | ``templates/django_knowledge/sidebar.html`` to your project. 93 | 94 | 3. ``django_knowledge/form.html`` - The form loops over given forms and renders them. 95 | Likewise, you can simply override this locally by copying and editing 96 | ``templates/django_knowledge/sidebar.html`` to your project. 97 | 98 | 99 | .. _customize-static: 100 | 101 | Static 102 | ------ 103 | 104 | As long as you are using Django's static files system, setting up static files should 105 | be as easy as ``python manage.py collectstatic``. If not, you can always copy your files 106 | manually to a legacy ``MEDIA_URL`` and override the base template according to the above 107 | templates section. 108 | 109 | Likewise, feel free to override the included CSS with your own rules in your own stylesheets. 110 | We'd recommend not editing the included CSS, as an update or ``collectstatic`` might 111 | overwrite them. 112 | 113 | 114 | .. _customize-css: 115 | 116 | CSS 117 | --- 118 | 119 | We purposefully namespace the majority of our CSS classes with ``dk-`` in order to keep 120 | them from conflicting with your existing CSS. There are two included CSS files: 121 | 122 | * ``reset.css`` - The majority of the base classes that act on body, typography, etc... 123 | This is ripped from Blueprint (though Blueprint is not a prerequisite). This should 124 | probably only be included if you aren't using your own shim. 125 | * ``main.css`` - This contains the real meat of the styles. Most of these are namespaced 126 | so they shouldn't affect your other styles (IE: the header/footer your shim has). 127 | However, the inverse is not true. Your styles may (and probably will) effect knowledge's 128 | css. You're kind of on your own here. -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | Right now *django-knowledge* is still under heavy development. We're approaching the 5 | full development of this product in the manner described below. 6 | 7 | 8 | .. _development-pattern: 9 | 10 | Development pattern 11 | ----------- 12 | 13 | 1. **Documentation first!** 14 | 15 | This is vitally important as we pusposefully want to create something that 16 | is a **best in class** application. We want django-knowledge be the premier 17 | help desk for django. 18 | 19 | 2. **Tests next!** 20 | 21 | Again, we want people to trust this application, so tests are an absolute must. 22 | TDD is the name of the game here. 100% coverage is the goal. 23 | 24 | 3. **Code final!** 25 | 26 | And let's make it good code as well. pep8 and all that jazz! 27 | 28 | 29 | .. _development-guide: 30 | 31 | Development guide 32 | ----------------- 33 | 34 | Please join us in making django-knowledge the best open source help desk in the world! 35 | 36 | 37 | Documentation 38 | ~~~~~~~~~~~~~ 39 | 40 | We're using **Sphinx**, so make sure you have ``pip install sphinx``, browse on into the 41 | *docs* folder and run ``make html``: 42 | 43 | .. code-block:: bash 44 | 45 | cd docs 46 | make html 47 | 48 | Inside *docs/_build* should be the rendered html. Open up *docs/_build/html/index.html* in your 49 | browser to take a looksy. 50 | 51 | Editing the files is equally simple, just adhere to the reStructuredText format. I recommend 52 | using something like `watch `_ while doing 53 | documentation to auto build everything while you work: 54 | 55 | .. code-block:: bash 56 | 57 | cd docs 58 | watch make html 59 | 60 | 61 | Tests 62 | ~~~~~ 63 | 64 | Inside the *tests* directory is a bash script that runs a localized Django project 65 | that tests our application in a project context. A quick command should suffice for 66 | most basic needs: 67 | 68 | .. code-block:: bash 69 | 70 | tests/runtests.sh 71 | 72 | Right now we're not bundling tests inside the installed package, they are part of 73 | their own example application. All tests are found in *tests/example/tests/* under split 74 | out files reflecting their location in the package. 75 | 76 | View the **coverage** stats by opening up the resulting *tests/reports/index.html*. 77 | 78 | 79 | Code 80 | ~~~~ 81 | 82 | Setting up the **development server** is quite easy as well: 83 | 84 | .. code-block:: bash 85 | 86 | pip install -r requirements.txt 87 | tests/syncdb.sh 88 | tests/runserver.sh 89 | 90 | We do use `SASS `_ (and you should too!), so you will need to 91 | follow their install docs and then run something like: 92 | 93 | .. code-block:: bash 94 | 95 | sass --watch knowledge/static/knowledge/scss:knowledge/static/knowledge/css 96 | 97 | Please remember to run **pep8** and fix any errors you see, or explan why 98 | you won't in your commit message so we can yell at you: 99 | 100 | .. code-block:: bash 101 | 102 | pep8 knowledge 103 | 104 | 105 | Committing 106 | ~~~~~~~~~~ 107 | 108 | We work off of the **master branch** in our GitHub repo. Send a pull request! Tagged releases 109 | will be pushed to PyPi. -------------------------------------------------------------------------------- /docs/images/ask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/docs/images/ask.png -------------------------------------------------------------------------------- /docs/images/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/docs/images/home.png -------------------------------------------------------------------------------- /docs/images/results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/docs/images/results.png -------------------------------------------------------------------------------- /docs/images/tests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/docs/images/tests.png -------------------------------------------------------------------------------- /docs/images/thread-mod.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/docs/images/thread-mod.png -------------------------------------------------------------------------------- /docs/images/thread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/docs/images/thread.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Knowledge documentation master file, created by 2 | sphinx-quickstart on Thu Feb 16 12:54:45 2012. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | Welcome to django-knowledge's documentation! 8 | ======================================= 9 | 10 | django-knowledge makes it easy to add an integrated support desk, help desk or 11 | knowledge base to your Django project with only a few lines of boilerplate code. 12 | While we give you a generic design for free, you should just as easily be able 13 | to customize the look and feel of the app if you like. 14 | 15 | **django-knowledge** was developed internally for `Zapier `_. 16 | Check out a `live demo `_. 17 | 18 | 19 | At a glance: 20 | ------------ 21 | 22 | - Turn common questions or support requests into a **knowledge base**. 23 | - Control **who sees what** with simple per object view permissions: *public* (everyone), 24 | *private* (poster & staff), or *internal* (only staff). 25 | - Assign questions and answers to **categories** for easy sorting. 26 | - Staff get **moderation controls** or they can use the familiar *Django admin* to handle support requests. 27 | - Allow **anonymous questions**, or require a standard Django user account (the default). 28 | - Included base **templates and design** with prebundled HTML and CSS. 29 | - Optionally **alert users** of new responses via email (or your own alert system). 30 | - BSD license. 31 | 32 | 33 | Contents: 34 | -------------- 35 | 36 | .. toctree:: 37 | :maxdepth: 2 38 | 39 | overview 40 | install 41 | development 42 | alerts 43 | customize 44 | performance 45 | settings 46 | 47 | 48 | Links: 49 | ------ 50 | 51 | * View a `live demo `_. This is the included stock design. 52 | * Check out the `documentation `_ at ReadTheDocs. 53 | * Visit our `GitHub repo `_ and join the development!. 54 | 55 | 56 | Screen Shots: 57 | ------------- 58 | 59 | .. image:: images/thread.png 60 | :width: 100 % 61 | :alt: a common thread viewed by anonymous user 62 | 63 | .. image:: images/thread-mod.png 64 | :width: 100 % 65 | :alt: a common thread viewed by a moderator (staff) 66 | 67 | .. image:: images/ask.png 68 | :width: 100 % 69 | :alt: ask form 70 | 71 | .. image:: images/home.png 72 | :width: 100 % 73 | :alt: the home page 74 | 75 | .. image:: images/results.png 76 | :width: 100 % 77 | :alt: search results with ask form at bottom 78 | 79 | .. image:: images/tests.png 80 | :alt: 100% coverage on tests 81 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Install 2 | ======= 3 | 4 | - :ref:`Using pip or easy_install `, which is most common for stable releases. 5 | - :ref:`Using a Git checkout `, recommended if you want cutting-edge features. 6 | - :ref:`Using downloadable archives `, useful if you don't have pip or git. 7 | 8 | 9 | .. _installation-pip: 10 | 11 | Using pip or easy_install 12 | ------------------------- 13 | 14 | We highly recommend using pip to install *django-knowledge*, the packages are regularly updated 15 | with stable releases: 16 | 17 | .. code-block:: bash 18 | 19 | pip install django-knowledge 20 | 21 | Or, alternatively: 22 | 23 | .. code-block:: bash 24 | 25 | easy_install django-knowledge 26 | 27 | But really, you shouldn't do that. 28 | 29 | 30 | .. _installation-git: 31 | 32 | Using git repositories 33 | ---------------------- 34 | 35 | Regular development happens at our `GitHub repository `_. Grabbing the 36 | cutting edge version might give you some extra features or fix some newly discovered bugs. We recommend 37 | not installing from the git repo unless you are actively developing *django-knowledge*. Please don't 38 | use it in production (and if you do, report back what broked)! 39 | 40 | .. code-block:: bash 41 | 42 | git clone git@github.com:zapier/django-knowledge.git django-knowledge 43 | 44 | You can add the **knowledge** folder inside the resulting **django-knowledge** to your PYTHONPATH or 45 | simply run ``python setup.py install`` to add it to your **site-packages**. 46 | 47 | 48 | .. _installation-archives: 49 | 50 | Using archives (tarball or zip) 51 | ------------------------------ 52 | 53 | Visit our `tags page `_ to grab the archives of 54 | both current and previous stable releases. After unzipping or untarring, you can add the **knowledge** 55 | folder inside the resulting **django-knowledge** to your PYTHONPATH or simply run ``python setup.py install`` 56 | to add it to your **site-packages**. 57 | 58 | 59 | 60 | .. _installation-setup: 61 | 62 | Setting up your Django project 63 | ------------------------------ 64 | 65 | First, you'll want to add ``knowledge`` and ``django.contrib.markup`` to your ``INSTALLED_APPS``. You may 66 | need to ``pip install markdown`` to cover the markup dependency. 67 | 68 | .. code-block:: python 69 | 70 | INSTALLED_APPS = ( 71 | 'django.contrib.contenttypes', 72 | 'django.contrib.comments', 73 | 'django.contrib.sessions', 74 | 'django.contrib.sites', 75 | 'django.contrib.admin', 76 | 77 | # Your favorite apps 78 | 79 | 'django.contrib.markup', 80 | 'knowledge',) 81 | 82 | 83 | Second, add ``url(r'^knowledge/', include('knowledge.urls'))`` to your ``urls.py``. 84 | 85 | .. code-block:: python 86 | 87 | 88 | urlpatterns = patterns('', 89 | url(r'^admin/', include(admin.site.urls)), 90 | 91 | # your url patterns 92 | 93 | url(r'^knowledge/', include('knowledge.urls')), 94 | ) 95 | 96 | 97 | Third, be sure to run ``python manage.py syncdb`` or ``python manage.py migrate knowledge`` to set up 98 | the necessary database tables. 99 | 100 | .. code-block:: bash 101 | 102 | python manage.py syncdb 103 | # or... 104 | python manage.py migrate knowledge 105 | 106 | 107 | Finally, follow the steps outlined in the :doc:`customize` section for templates and static resources. 108 | Short version, don't forget to run ``python manage.py collectstatic``. 109 | 110 | .. code-block:: bash 111 | 112 | python manage.py collectstatic -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\DjangoKnowledge.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\DjangoKnowledge.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ================= 3 | 4 | Django Knowledge is aiming to be a very simple knowledge base and question support engine, 5 | not entirely dissimilar to ZenKnowledge, Assistly, or HelpJuice, and to some degree 6 | StackOverflow or even UserVoice or GetSatisfaction. 7 | 8 | 9 | .. _about-goals: 10 | 11 | Goals of django-knowledge 12 | -------------------- 13 | 14 | The goals of ``django-knowledge`` are simple and straightforward: 15 | 16 | 1. Searchable knowledge base. 17 | 2. A form to ask a missing question. 18 | 3. Staff can moderate via toolbar or Django's admin interface. 19 | 20 | 21 | .. _about-how-it-works: 22 | 23 | How django-knowledge works 24 | --------------------- 25 | 26 | At its core, there are only a few moving parts, which keeps django-knowledge light and extensible. 27 | 28 | 29 | Models 30 | ~~~~~~ 31 | 32 | There are only three data models in django-knowledge: **Question**, **Response** and **Category**. 33 | As you can imagine, **Question** is the base model which can have an arbitrary number or **Response**'s. 34 | While **Response** is more or less a series of comments on a **Question**. **Question**'s can also 35 | have an arbitrary number of **Categories**. 36 | 37 | **Question**'s and **Response**'s can each be either *public*, *private* or *internal* (or 38 | *inheret* for **Response**). **Categories** are always *public*. 39 | 40 | 41 | Views 42 | ~~~~~ 43 | 44 | In the same spirit, there are only 4 user facing views: **knowledge_index**, **knowledge_list**, 45 | **knowledge_thread**, and **knowledge_ask**. 46 | 47 | - **knowledge_index** a general listing of popular questions plus search box 48 | - **knowledge_list** listing of a specific subset of questions (by tags or search term) 49 | - **knowledge_thread** response thread for a specific question 50 | - **knowledge_moderate** a passthrough for moderators to manage questions & responses 51 | - **knowledge_ask** form for asking a question 52 | 53 | 54 | Templates 55 | ~~~~~~~~~ 56 | 57 | We provide default styles for the templates. You can easily embed this within your own shim/wrapper 58 | or do nothing and just roll with the wrapper we provide. Read more in the :doc:`customize` section. 59 | 60 | 61 | CSS (SASS) 62 | ~~~~~~~~~~~~~~~~~~ 63 | 64 | The included generic styles are compiled via SASS's scss. You can read more in the :doc:`customize` 65 | section. 66 | -------------------------------------------------------------------------------- /docs/performance.rst: -------------------------------------------------------------------------------- 1 | Performance 2 | =========== 3 | 4 | A few of the queries in the manager are a little crazy due to the need to 5 | check parent questions when responses are inherited status. We recommend 6 | ensuring the following indexes for the following fields: 7 | 8 | 9 | DB Indexes 10 | ---------- 11 | 12 | - **question & response `id`** (Django does by default) 13 | - **question & response `user_id`** (Django does by default) 14 | - **question & response `status`** (we do this by default because query by statuses a lot!) 15 | 16 | 17 | Email Alerts 18 | ------------ 19 | 20 | If you decide to turn on email alerts, we highly recommend using something like 21 | `django-celery-email `_ or creating 22 | your own function for delayed execution with Celery, gevent, or whatever. -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | Django Knowledge has its own series of custom settings you can use to tweak its 5 | operation. As with normal Django settings, these go in ``settings.py``, or a variant 6 | thereof. 7 | 8 | 9 | KNOWLEDGE_ALLOW_ANONYMOUS 10 | ------------------------- 11 | 12 | Default ``False``. If ``True``, users who are not logged in can ask questions. If 13 | ``False`` only registered and logged in users can ask questions. 14 | 15 | 16 | KNOWLEDGE_LOGIN_REQUIRED 17 | ------------------------ 18 | 19 | Default ``False``. If ``True`` users that are not authenticated are redirected to 20 | LOGIN_URL. 21 | 22 | 23 | KNOWLEDGE_AUTO_PUBLICIZE 24 | ------------------------ 25 | 26 | Default ``False``. If ``True``, answered questions are automatically published. If 27 | ``False``, staff must manually publish questions after answering. 28 | 29 | 30 | KNOWLEDGE_FREE_RESPONSE 31 | ----------------------- 32 | 33 | Default ``True``. If ``True``, any user (respecting KNOWLEDGE_ALLOW_ANONYMOUS) can 34 | respond to any question. If ``False``, only staff or original poster may respond. 35 | 36 | 37 | KNOWLEDGE_SLUG_URLS 38 | ------------------- 39 | 40 | Default ``True``. If ``True``, the URL for a question will have the slug from its 41 | title appended to the end and incorrect or missing slugs will result in a 301 redirect. 42 | If ``False``, the slug is ommitted. 43 | 44 | 45 | KNOWLEDGE_ALERTS 46 | ---------------- 47 | 48 | Default ``False``. If ``True``, we'll send a signal to the function defined in 49 | ``KNOWLEDGE_ALERTS_FUNCTION_PATH`` and outlined in :doc:`alerts`. If ``False`` we 50 | will not. 51 | 52 | 53 | KNOWLEDGE_ALERTS_FUNCTION_PATH 54 | ------------------------------ 55 | 56 | Default ``knowledge.signals.send_alerts``. This should be the path to a function 57 | as defined in :doc:`alerts`. Depends on ``KNOWLEDGE_ALERTS`` to be active. -------------------------------------------------------------------------------- /knowledge/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 2, 0) 2 | -------------------------------------------------------------------------------- /knowledge/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from knowledge.models import Question, Response, Category 4 | 5 | 6 | class CategoryAdmin(admin.ModelAdmin): 7 | list_display = [f.name for f in Category._meta.fields] 8 | prepopulated_fields = {'slug': ('title', )} 9 | admin.site.register(Category, CategoryAdmin) 10 | 11 | 12 | class QuestionAdmin(admin.ModelAdmin): 13 | list_display = [f.name for f in Question._meta.fields] 14 | list_select_related = True 15 | raw_id_fields = ['user'] 16 | admin.site.register(Question, QuestionAdmin) 17 | 18 | 19 | class ResponseAdmin(admin.ModelAdmin): 20 | list_display = [f.name for f in Response._meta.fields] 21 | list_select_related = True 22 | raw_id_fields = ['user', 'question'] 23 | admin.site.register(Response, ResponseAdmin) 24 | -------------------------------------------------------------------------------- /knowledge/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | from knowledge import settings 5 | from knowledge.models import Question, Response 6 | 7 | OPTIONAL_FIELDS = ['alert', 'phone_number'] 8 | 9 | 10 | __todo__ = """ 11 | This is serious badness. Really? Functions masquerading as 12 | clases? Lame. This should be fixed. Sorry. 13 | ~bryan 14 | """ 15 | 16 | 17 | def QuestionForm(user, *args, **kwargs): 18 | """ 19 | Build and return the appropriate form depending 20 | on the status of the passed in user. 21 | """ 22 | 23 | if user.is_anonymous(): 24 | if not settings.ALLOW_ANONYMOUS: 25 | return None 26 | else: 27 | selected_fields = ['name', 'email', 'title', 'body'] 28 | else: 29 | selected_fields = ['user', 'title', 'body', 'status'] 30 | 31 | if settings.ALERTS: 32 | selected_fields += ['alert'] 33 | 34 | class _QuestionForm(forms.ModelForm): 35 | def __init__(self, *args, **kwargs): 36 | super(_QuestionForm, self).__init__(*args, **kwargs) 37 | 38 | for key in self.fields: 39 | if not key in OPTIONAL_FIELDS: 40 | self.fields[key].required = True 41 | 42 | # hide the internal status for non-staff 43 | qf = self.fields.get('status', None) 44 | if qf and not user.is_staff: 45 | choices = list(qf.choices) 46 | choices.remove(('internal', _('Internal'))) 47 | qf.choices = choices 48 | 49 | # a bit of a hack... 50 | # hide a field, and use clean to force 51 | # a specific value of ours 52 | for key in ['user']: 53 | qf = self.fields.get(key, None) 54 | if qf: 55 | qf.widget = qf.hidden_widget() 56 | qf.required = False 57 | 58 | # honey pot! 59 | phone_number = forms.CharField(required=False) 60 | 61 | def clean_user(self): 62 | return user 63 | 64 | class Meta: 65 | model = Question 66 | fields = selected_fields 67 | 68 | return _QuestionForm(*args, **kwargs) 69 | 70 | 71 | def ResponseForm(user, question, *args, **kwargs): 72 | """ 73 | Build and return the appropriate form depending 74 | on the status of the passed in user and question. 75 | """ 76 | 77 | if question.locked: 78 | return None 79 | 80 | if not settings.FREE_RESPONSE and not \ 81 | (user.is_staff or question.user == user): 82 | return None 83 | 84 | if user.is_anonymous(): 85 | if not settings.ALLOW_ANONYMOUS: 86 | return None 87 | else: 88 | selected_fields = ['name', 'email'] 89 | else: 90 | selected_fields = ['user'] 91 | 92 | selected_fields += ['body', 'question'] 93 | 94 | if user.is_staff: 95 | selected_fields += ['status'] 96 | 97 | if settings.ALERTS: 98 | selected_fields += ['alert'] 99 | 100 | class _ResponseForm(forms.ModelForm): 101 | def __init__(self, *args, **kwargs): 102 | super(_ResponseForm, self).__init__(*args, **kwargs) 103 | 104 | for key in self.fields: 105 | if not key in OPTIONAL_FIELDS: 106 | self.fields[key].required = True 107 | 108 | # a bit of a hack... 109 | for key in ['user', 'question']: 110 | qf = self.fields.get(key, None) 111 | if qf: 112 | qf.widget = qf.hidden_widget() 113 | qf.required = False 114 | 115 | # honey pot! 116 | phone_number = forms.CharField(required=False) 117 | 118 | def clean_user(self): 119 | return user 120 | 121 | def clean_question(self): 122 | return question 123 | 124 | class Meta: 125 | model = Response 126 | fields = selected_fields 127 | 128 | return _ResponseForm(*args, **kwargs) 129 | -------------------------------------------------------------------------------- /knowledge/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Q 3 | 4 | 5 | class QuestionManager(models.Manager): 6 | # def get_query_set(self, *args, **kwargs): 7 | # return super(QuestionManager, self).get_query_set(*args, **kwargs) 8 | 9 | def can_view(self, user): 10 | qs = super(QuestionManager, self).get_query_set()\ 11 | .select_related('user') 12 | 13 | if user.is_staff or user.is_superuser: 14 | return qs.all() 15 | 16 | if user.is_anonymous(): 17 | return qs.filter(status='public') 18 | 19 | return qs.filter( 20 | Q(status='public') | Q(status='private', user=user) 21 | ) 22 | 23 | 24 | class ResponseManager(models.Manager): 25 | # def all(self, *args, **kwargs): 26 | # return super(ResponseManager, self).all(*args, **kwargs)\ 27 | # .select_related('question', 'user') 28 | 29 | def can_view(self, user): 30 | qs = super(ResponseManager, self).get_query_set()\ 31 | .select_related('question', 'user') 32 | 33 | if user.is_staff or user.is_superuser: 34 | return qs.all() 35 | 36 | if user.is_anonymous(): 37 | return qs.filter( 38 | Q(status='public') | 39 | Q(status='inherit', question__status='public') 40 | ) 41 | 42 | # ooooh boy this is crazy! 43 | return qs.filter( 44 | Q(status='public') | 45 | Q( # respect private parent user/status 46 | Q(status='private') & 47 | Q( 48 | Q(user=user) | 49 | Q(question__user=user) 50 | ) 51 | ) | 52 | Q( # follow inherited status/users 53 | Q(status='inherit') & 54 | Q( 55 | Q(question__status='public') | 56 | Q(question__status='private', question__user=user) 57 | ) 58 | ) 59 | ) 60 | -------------------------------------------------------------------------------- /knowledge/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | from knowledge.utils import user_model_label 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | 12 | # Adding model 'Category' 13 | db.create_table('knowledge_category', ( 14 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 15 | ('added', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), 16 | ('lastchanged', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), 17 | ('title', self.gf('django.db.models.fields.CharField')(max_length=255)), 18 | ('slug', self.gf('django.db.models.fields.SlugField')(max_length=50, db_index=True)), 19 | )) 20 | db.send_create_signal('knowledge', ['Category']) 21 | 22 | # Adding model 'Question' 23 | db.create_table('knowledge_question', ( 24 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 25 | ('added', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), 26 | ('lastchanged', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), 27 | ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm[user_model_label], null=True, blank=True)), 28 | ('name', self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True)), 29 | ('email', self.gf('django.db.models.fields.EmailField')(max_length=75, null=True, blank=True)), 30 | ('title', self.gf('django.db.models.fields.CharField')(max_length=255)), 31 | ('body', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), 32 | ('status', self.gf('django.db.models.fields.CharField')(default='private', max_length=32, db_index=True)), 33 | ('locked', self.gf('django.db.models.fields.BooleanField')(default=False)), 34 | )) 35 | db.send_create_signal('knowledge', ['Question']) 36 | 37 | # Adding M2M table for field categories on 'Question' 38 | db.create_table('knowledge_question_categories', ( 39 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), 40 | ('question', models.ForeignKey(orm['knowledge.question'], null=False)), 41 | ('category', models.ForeignKey(orm['knowledge.category'], null=False)) 42 | )) 43 | db.create_unique('knowledge_question_categories', ['question_id', 'category_id']) 44 | 45 | # Adding model 'Response' 46 | db.create_table('knowledge_response', ( 47 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 48 | ('added', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), 49 | ('lastchanged', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), 50 | ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm[user_model_label], null=True, blank=True)), 51 | ('name', self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True)), 52 | ('email', self.gf('django.db.models.fields.EmailField')(max_length=75, null=True, blank=True)), 53 | ('question', self.gf('django.db.models.fields.related.ForeignKey')(related_name='responses', to=orm['knowledge.Question'])), 54 | ('body', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), 55 | ('status', self.gf('django.db.models.fields.CharField')(default='inherit', max_length=32, db_index=True)), 56 | ('accepted', self.gf('django.db.models.fields.BooleanField')(default=False)), 57 | )) 58 | db.send_create_signal('knowledge', ['Response']) 59 | 60 | 61 | def backwards(self, orm): 62 | 63 | # Deleting model 'Category' 64 | db.delete_table('knowledge_category') 65 | 66 | # Deleting model 'Question' 67 | db.delete_table('knowledge_question') 68 | 69 | # Removing M2M table for field categories on 'Question' 70 | db.delete_table('knowledge_question_categories') 71 | 72 | # Deleting model 'Response' 73 | db.delete_table('knowledge_response') 74 | 75 | 76 | models = { 77 | 'auth.group': { 78 | 'Meta': {'object_name': 'Group'}, 79 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 80 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 81 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 82 | }, 83 | 'auth.permission': { 84 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 85 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 86 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 87 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 88 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 89 | }, 90 | user_model_label: { 91 | 'Meta': {'object_name': user_model_label.split('.')[-1]}, 92 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 93 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 94 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 95 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 96 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 97 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 98 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 99 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 100 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 101 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 102 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 103 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 104 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 105 | }, 106 | 'contenttypes.contenttype': { 107 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 108 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 109 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 110 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 111 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 112 | }, 113 | 'knowledge.category': { 114 | 'Meta': {'object_name': 'Category'}, 115 | 'added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 116 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 117 | 'lastchanged': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 118 | 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'}), 119 | 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) 120 | }, 121 | 'knowledge.question': { 122 | 'Meta': {'ordering': "['-added']", 'object_name': 'Question'}, 123 | 'added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 124 | 'body': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 125 | 'categories': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['knowledge.Category']", 'symmetrical': 'False'}), 126 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'null': 'True', 'blank': 'True'}), 127 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 128 | 'lastchanged': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 129 | 'locked': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 130 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), 131 | 'status': ('django.db.models.fields.CharField', [], {'default': "'private'", 'max_length': '32', 'db_index': 'True'}), 132 | 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 133 | 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm[user_model_label]", 'null': 'True', 'blank': 'True'}) 134 | }, 135 | 'knowledge.response': { 136 | 'Meta': {'ordering': "['added']", 'object_name': 'Response'}, 137 | 'accepted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 138 | 'added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 139 | 'body': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 140 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'null': 'True', 'blank': 'True'}), 141 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 142 | 'lastchanged': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 143 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), 144 | 'question': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'responses'", 'to': "orm['knowledge.Question']"}), 145 | 'status': ('django.db.models.fields.CharField', [], {'default': "'inherit'", 'max_length': '32', 'db_index': 'True'}), 146 | 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm[user_model_label]", 'null': 'True', 'blank': 'True'}) 147 | } 148 | } 149 | 150 | complete_apps = ['knowledge'] 151 | -------------------------------------------------------------------------------- /knowledge/migrations/0002_auto__add_field_question_alert__add_field_response_alert.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | from knowledge.utils import user_model_label 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | 12 | # Adding field 'Question.alert' 13 | db.add_column('knowledge_question', 'alert', self.gf('django.db.models.fields.BooleanField')(default=False), keep_default=False) 14 | 15 | # Adding field 'Response.alert' 16 | db.add_column('knowledge_response', 'alert', self.gf('django.db.models.fields.BooleanField')(default=False), keep_default=False) 17 | 18 | 19 | def backwards(self, orm): 20 | 21 | # Deleting field 'Question.alert' 22 | db.delete_column('knowledge_question', 'alert') 23 | 24 | # Deleting field 'Response.alert' 25 | db.delete_column('knowledge_response', 'alert') 26 | 27 | 28 | models = { 29 | 'auth.group': { 30 | 'Meta': {'object_name': 'Group'}, 31 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 32 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 33 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 34 | }, 35 | 'auth.permission': { 36 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 37 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 38 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 39 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 40 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 41 | }, 42 | user_model_label: { 43 | 'Meta': {'object_name': user_model_label.split('.')[-1]}, 44 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 45 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 46 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 47 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 48 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 49 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 50 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 51 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 52 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 53 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 54 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 55 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 56 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 57 | }, 58 | 'contenttypes.contenttype': { 59 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 60 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 61 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 62 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 63 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 64 | }, 65 | 'knowledge.category': { 66 | 'Meta': {'object_name': 'Category'}, 67 | 'added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 68 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 69 | 'lastchanged': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 70 | 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'}), 71 | 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) 72 | }, 73 | 'knowledge.question': { 74 | 'Meta': {'ordering': "['-added']", 'object_name': 'Question'}, 75 | 'added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 76 | 'alert': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 77 | 'body': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 78 | 'categories': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['knowledge.Category']", 'symmetrical': 'False'}), 79 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'null': 'True', 'blank': 'True'}), 80 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 81 | 'lastchanged': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 82 | 'locked': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 83 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), 84 | 'status': ('django.db.models.fields.CharField', [], {'default': "'private'", 'max_length': '32', 'db_index': 'True'}), 85 | 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 86 | 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm[user_model_label]", 'null': 'True', 'blank': 'True'}) 87 | }, 88 | 'knowledge.response': { 89 | 'Meta': {'ordering': "['added']", 'object_name': 'Response'}, 90 | 'accepted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 91 | 'added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 92 | 'alert': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 93 | 'body': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 94 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'null': 'True', 'blank': 'True'}), 95 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 96 | 'lastchanged': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 97 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), 98 | 'question': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'responses'", 'to': "orm['knowledge.Question']"}), 99 | 'status': ('django.db.models.fields.CharField', [], {'default': "'inherit'", 'max_length': '32', 'db_index': 'True'}), 100 | 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm[user_model_label]", 'null': 'True', 'blank': 'True'}) 101 | } 102 | } 103 | 104 | complete_apps = ['knowledge'] 105 | -------------------------------------------------------------------------------- /knowledge/migrations/0003_auto__add_unique_category_slug.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | from knowledge.utils import user_model_label 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | 12 | # Adding unique constraint on 'Category', fields ['slug'] 13 | db.create_unique('knowledge_category', ['slug']) 14 | 15 | 16 | def backwards(self, orm): 17 | 18 | # Removing unique constraint on 'Category', fields ['slug'] 19 | db.delete_unique('knowledge_category', ['slug']) 20 | 21 | 22 | models = { 23 | 'auth.group': { 24 | 'Meta': {'object_name': 'Group'}, 25 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 26 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 27 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 28 | }, 29 | 'auth.permission': { 30 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 31 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 33 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 35 | }, 36 | user_model_label: { 37 | 'Meta': {'object_name': user_model_label.split('.')[-1]}, 38 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 39 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 40 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 41 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 42 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 44 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 46 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 48 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 49 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 50 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 51 | }, 52 | 'contenttypes.contenttype': { 53 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 54 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 58 | }, 59 | 'knowledge.category': { 60 | 'Meta': {'object_name': 'Category'}, 61 | 'added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 62 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 63 | 'lastchanged': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 64 | 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), 65 | 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) 66 | }, 67 | 'knowledge.question': { 68 | 'Meta': {'ordering': "['-added']", 'object_name': 'Question'}, 69 | 'added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 70 | 'alert': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 71 | 'body': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 72 | 'categories': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['knowledge.Category']", 'symmetrical': 'False', 'blank': 'True'}), 73 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'null': 'True', 'blank': 'True'}), 74 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 75 | 'lastchanged': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 76 | 'locked': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 77 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), 78 | 'status': ('django.db.models.fields.CharField', [], {'default': "'private'", 'max_length': '32', 'db_index': 'True'}), 79 | 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 80 | 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm[user_model_label]", 'null': 'True', 'blank': 'True'}) 81 | }, 82 | 'knowledge.response': { 83 | 'Meta': {'ordering': "['added']", 'object_name': 'Response'}, 84 | 'accepted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 85 | 'added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 86 | 'alert': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 87 | 'body': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 88 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'null': 'True', 'blank': 'True'}), 89 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 90 | 'lastchanged': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 91 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), 92 | 'question': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'responses'", 'to': "orm['knowledge.Question']"}), 93 | 'status': ('django.db.models.fields.CharField', [], {'default': "'inherit'", 'max_length': '32', 'db_index': 'True'}), 94 | 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm[user_model_label]", 'null': 'True', 'blank': 'True'}) 95 | } 96 | } 97 | 98 | complete_apps = ['knowledge'] 99 | -------------------------------------------------------------------------------- /knowledge/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/knowledge/migrations/__init__.py -------------------------------------------------------------------------------- /knowledge/models.py: -------------------------------------------------------------------------------- 1 | from knowledge import settings 2 | 3 | import django 4 | from django.db import models 5 | from django.utils.translation import ugettext_lazy as _ 6 | from django.conf import settings as django_settings 7 | 8 | from knowledge.managers import QuestionManager, ResponseManager 9 | from knowledge.signals import knowledge_post_save 10 | 11 | STATUSES = ( 12 | ('public', _('Public')), 13 | ('private', _('Private')), 14 | ('internal', _('Internal')), 15 | ) 16 | 17 | 18 | STATUSES_EXTENDED = STATUSES + ( 19 | ('inherit', _('Inherit')), 20 | ) 21 | 22 | 23 | class Category(models.Model): 24 | added = models.DateTimeField(auto_now_add=True) 25 | lastchanged = models.DateTimeField(auto_now=True) 26 | 27 | title = models.CharField(max_length=255) 28 | slug = models.SlugField(unique=True) 29 | 30 | def __unicode__(self): 31 | return self.title 32 | 33 | class Meta: 34 | ordering = ['title'] 35 | verbose_name = _('Category') 36 | verbose_name_plural = _('Categories') 37 | 38 | 39 | class KnowledgeBase(models.Model): 40 | """ 41 | The base class for Knowledge models. 42 | """ 43 | is_question, is_response = False, False 44 | 45 | added = models.DateTimeField(auto_now_add=True) 46 | lastchanged = models.DateTimeField(auto_now=True) 47 | 48 | user = models.ForeignKey('auth.User' if django.VERSION < (1, 5, 0) else django_settings.AUTH_USER_MODEL, blank=True, 49 | null=True, db_index=True) 50 | alert = models.BooleanField(default=settings.ALERTS, 51 | verbose_name=_('Alert'), 52 | help_text=_('Check this if you want to be alerted when a new' 53 | ' response is added.')) 54 | 55 | # for anonymous posting, if permitted 56 | name = models.CharField(max_length=64, blank=True, null=True, 57 | verbose_name=_('Name'), 58 | help_text=_('Enter your first and last name.')) 59 | email = models.EmailField(blank=True, null=True, 60 | verbose_name=_('Email'), 61 | help_text=_('Enter a valid email address.')) 62 | 63 | class Meta: 64 | abstract = True 65 | 66 | def save(self, *args, **kwargs): 67 | if not self.user and self.name and self.email \ 68 | and not self.id: 69 | # first time because no id 70 | self.public(save=False) 71 | 72 | if settings.AUTO_PUBLICIZE and not self.id: 73 | self.public(save=False) 74 | 75 | super(KnowledgeBase, self).save(*args, **kwargs) 76 | 77 | ######################### 78 | #### GENERIC GETTERS #### 79 | ######################### 80 | 81 | def get_name(self): 82 | """ 83 | Get local name, then self.user's first/last, and finally 84 | their username if all else fails. 85 | """ 86 | name = (self.name or (self.user and ( 87 | u'{0} {1}'.format(self.user.first_name, self.user.last_name).strip()\ 88 | or self.user.username 89 | ))) 90 | return name.strip() or _("Anonymous") 91 | 92 | get_email = lambda s: s.email or (s.user and s.user.email) 93 | get_pair = lambda s: (s.get_name(), s.get_email()) 94 | get_user_or_pair = lambda s: s.user or s.get_pair() 95 | 96 | ######################## 97 | #### STATUS METHODS #### 98 | ######################## 99 | 100 | def can_view(self, user): 101 | """ 102 | Returns a boolean dictating if a User like instance can 103 | view the current Model instance. 104 | """ 105 | 106 | if self.status == 'inherit' and self.is_response: 107 | return self.question.can_view(user) 108 | 109 | if self.status == 'internal' and user.is_staff: 110 | return True 111 | 112 | if self.status == 'private': 113 | if self.user == user or user.is_staff: 114 | return True 115 | if self.is_response and self.question.user == user: 116 | return True 117 | 118 | if self.status == 'public': 119 | return True 120 | 121 | return False 122 | 123 | def switch(self, status, save=True): 124 | self.status = status 125 | if save: 126 | self.save() 127 | switch.alters_data = True 128 | 129 | def public(self, save=True): 130 | self.switch('public', save) 131 | public.alters_data = True 132 | 133 | def private(self, save=True): 134 | self.switch('private', save) 135 | private.alters_data = True 136 | 137 | def inherit(self, save=True): 138 | self.switch('inherit', save) 139 | inherit.alters_data = True 140 | 141 | def internal(self, save=True): 142 | self.switch('internal', save) 143 | internal.alters_data = True 144 | 145 | 146 | class Question(KnowledgeBase): 147 | is_question = True 148 | _requesting_user = None 149 | 150 | title = models.CharField(max_length=255, 151 | verbose_name=_('Question'), 152 | help_text=_('Enter your question or suggestion.')) 153 | body = models.TextField(blank=True, null=True, 154 | verbose_name=_('Description'), 155 | help_text=_('Please offer details. Markdown enabled.')) 156 | 157 | status = models.CharField( 158 | verbose_name=_('Status'), 159 | max_length=32, choices=STATUSES, 160 | default='private', db_index=True) 161 | 162 | locked = models.BooleanField(default=False) 163 | 164 | categories = models.ManyToManyField('knowledge.Category', blank=True) 165 | 166 | objects = QuestionManager() 167 | 168 | class Meta: 169 | ordering = ['-added'] 170 | verbose_name = _('Question') 171 | verbose_name_plural = _('Questions') 172 | 173 | def __unicode__(self): 174 | return self.title 175 | 176 | @models.permalink 177 | def get_absolute_url(self): 178 | from django.template.defaultfilters import slugify 179 | 180 | if settings.SLUG_URLS: 181 | return ('knowledge_thread', [self.id, slugify(self.title)]) 182 | else: 183 | return ('knowledge_thread_no_slug', [self.id]) 184 | 185 | def inherit(self): 186 | pass 187 | 188 | def internal(self): 189 | pass 190 | 191 | def lock(self, save=True): 192 | self.locked = not self.locked 193 | if save: 194 | self.save() 195 | lock.alters_data = True 196 | 197 | ################### 198 | #### RESPONSES #### 199 | ################### 200 | 201 | def get_responses(self, user=None): 202 | user = user or self._requesting_user 203 | if user: 204 | return [r for r in self.responses.all().select_related('user') if r.can_view(user)] 205 | else: 206 | return self.responses.all().select_related('user') 207 | 208 | def answered(self): 209 | """ 210 | Returns a boolean indictating whether there any questions. 211 | """ 212 | return bool(self.get_responses()) 213 | 214 | def accepted(self): 215 | """ 216 | Returns a boolean indictating whether there is a accepted answer 217 | or not. 218 | """ 219 | return any([r.accepted for r in self.get_responses()]) 220 | 221 | def clear_accepted(self): 222 | self.get_responses().update(accepted=False) 223 | clear_accepted.alters_data = True 224 | 225 | def accept(self, response=None): 226 | """ 227 | Given a response, make that the one and only accepted answer. 228 | Similar to StackOverflow. 229 | """ 230 | self.clear_accepted() 231 | 232 | if response and response.question == self: 233 | response.accepted = True 234 | response.save() 235 | return True 236 | else: 237 | return False 238 | accept.alters_data = True 239 | 240 | def states(self): 241 | """ 242 | Handy for checking for mod bar button state. 243 | """ 244 | return [self.status, 'lock' if self.locked else None] 245 | 246 | @property 247 | def url(self): 248 | return self.get_absolute_url() 249 | 250 | 251 | class Response(KnowledgeBase): 252 | is_response = True 253 | 254 | question = models.ForeignKey('knowledge.Question', 255 | related_name='responses') 256 | 257 | body = models.TextField(blank=True, null=True, 258 | verbose_name=_('Response'), 259 | help_text=_('Please enter your response. Markdown enabled.')) 260 | status = models.CharField( 261 | verbose_name=_('Status'), 262 | max_length=32, choices=STATUSES_EXTENDED, 263 | default='inherit', db_index=True) 264 | accepted = models.BooleanField(default=False) 265 | 266 | objects = ResponseManager() 267 | 268 | class Meta: 269 | ordering = ['added'] 270 | verbose_name = _('Response') 271 | verbose_name_plural = _('Responses') 272 | 273 | def __unicode__(self): 274 | return self.body[0:100] + u'...' 275 | 276 | def states(self): 277 | """ 278 | Handy for checking for mod bar button state. 279 | """ 280 | return [self.status, 'accept' if self.accepted else None] 281 | 282 | def accept(self): 283 | self.question.accept(self) 284 | accept.alters_data = True 285 | 286 | 287 | # cannot attach on abstract = True... derp 288 | models.signals.post_save.connect(knowledge_post_save, sender=Question) 289 | models.signals.post_save.connect(knowledge_post_save, sender=Response) 290 | -------------------------------------------------------------------------------- /knowledge/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | # crowd control 4 | LOGIN_REQUIRED = getattr(settings, 'KNOWLEDGE_LOGIN_REQUIRED', False) 5 | LOGIN_URL = getattr(settings, 'LOGIN_URL', '/accounts/login/') 6 | ALLOW_ANONYMOUS = getattr(settings, 'KNOWLEDGE_ALLOW_ANONYMOUS', False) 7 | AUTO_PUBLICIZE = getattr(settings, 'KNOWLEDGE_AUTO_PUBLICIZE', False) 8 | FREE_RESPONSE = getattr(settings, 'KNOWLEDGE_FREE_RESPONSE', True) 9 | 10 | # alerts 11 | ALERTS = getattr(settings, 'KNOWLEDGE_ALERTS', False) 12 | ALERTS_FUNCTION_PATH = getattr(settings, 'KNOWLEDGE_ALERTS_FUNCTION_PATH', 13 | 'knowledge.signals.send_alerts') 14 | 15 | # misc 16 | SLUG_URLS = getattr(settings, 'KNOWLEDGE_SLUG_URLS', True) 17 | 18 | BASE_TEMPLATE = getattr(settings, 'KNOWLEDGE_BASE_TEMPLATE', 'django_knowledge/base.html') 19 | -------------------------------------------------------------------------------- /knowledge/signals.py: -------------------------------------------------------------------------------- 1 | from knowledge.utils import get_module 2 | from knowledge import settings 3 | 4 | 5 | def send_alerts(target_dict, response=None, question=None, **kwargs): 6 | """ 7 | This can be overridden via KNOWLEDGE_ALERTS_FUNCTION_PATH. 8 | """ 9 | from django.contrib.auth.models import User 10 | from django.template.loader import render_to_string 11 | from django.contrib.sites.models import Site 12 | from django.core.mail import EmailMultiAlternatives 13 | 14 | site = Site.objects.get_current() 15 | 16 | for email, name in target_dict.items(): 17 | if isinstance(name, User): 18 | name = u'{0} {1}'.format(name.first_name, name.last_name) 19 | else: 20 | name = name[0] 21 | 22 | context = { 23 | 'name': name, 24 | 'email': email, 25 | 'response': response, 26 | 'question': question, 27 | 'site': site 28 | } 29 | 30 | subject = render_to_string( 31 | 'django_knowledge/emails/subject.txt', context) 32 | 33 | message = render_to_string( 34 | 'django_knowledge/emails/message.txt', context) 35 | 36 | message_html = render_to_string( 37 | 'django_knowledge/emails/message.html', context) 38 | 39 | subject = u' '.join(line.strip() for line in subject.splitlines()).strip() 40 | msg = EmailMultiAlternatives(subject, message, to=[email]) 41 | msg.attach_alternative(message_html, 'text/html') 42 | msg.send() 43 | 44 | 45 | def knowledge_post_save(sender, instance, created, **kwargs): 46 | """ 47 | Gathers all the responses for the sender's parent question 48 | and shuttles them to the predefined module. 49 | """ 50 | from knowledge.models import Question, Response 51 | from django.contrib.auth.models import User 52 | 53 | func = get_module(settings.ALERTS_FUNCTION_PATH) 54 | 55 | if settings.ALERTS and created: 56 | # pull together the out_dict: 57 | # {'e@ma.il': ('first last', 'e@ma.il') or } 58 | if isinstance(instance, Response): 59 | instances = list(instance.question.get_responses()) 60 | instances += [instance.question] 61 | 62 | # dedupe people who want alerts thanks to dict keys... 63 | out_dict = dict([[i.get_email(), i.get_user_or_pair()] 64 | for i in instances if i.alert]) 65 | 66 | elif isinstance(instance, Question): 67 | staffers = User.objects.filter(is_staff=True) 68 | out_dict = dict([[user.email, user] for user in staffers 69 | if user.has_perm('change_question')]) 70 | 71 | # remove the creator... 72 | if instance.get_email() in out_dict.keys(): 73 | del out_dict[instance.get_email()] 74 | 75 | func( 76 | target_dict=out_dict, 77 | response=instance if isinstance(instance, Response) else None, 78 | question=instance if isinstance(instance, Question) else None 79 | ) 80 | -------------------------------------------------------------------------------- /knowledge/static/knowledge/css/main.css: -------------------------------------------------------------------------------- 1 | .dk-inner { 2 | padding: 20px 0; 3 | font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; } 4 | .dk-inner input { 5 | margin: 0; } 6 | .dk-inner .quiet { 7 | font-weight: normal; 8 | color: #888; } 9 | .dk-inner .clear { 10 | clear: both; } 11 | .dk-inner .center { 12 | text-align: center; } 13 | .dk-inner .dk-roof { 14 | margin-bottom: 24px; } 15 | .dk-inner .dk-roof h2 { 16 | margin-bottom: .15em; 17 | font-weight: 600; } 18 | .dk-inner .dk-roof h2 a { 19 | color: #333; 20 | text-decoration: none; } 21 | .dk-inner .dk-roof h2 a:hover { 22 | text-decoration: underline; } 23 | .dk-inner .dk-search { 24 | border: 1px solid #ccc; 25 | padding: 1%; 26 | background: #eee; 27 | -moz-border-radius: 4px; 28 | -webkit-border-radius: 4px; 29 | border-radius: 4px; 30 | margin-bottom: 24px; } 31 | .dk-inner .dk-search input.question-search { 32 | -moz-border-radius: 4px; 33 | -webkit-border-radius: 4px; 34 | border-radius: 4px; 35 | padding: 8px 1%; 36 | border: 1px solid #ccc; 37 | font-size: 16px; 38 | width: 87%; 39 | height: 16px; 40 | float: left; } 41 | .dk-inner .dk-search .submit-question-search { 42 | height: 34px; 43 | width: 10%; 44 | padding: 8px 1%; 45 | float: right; 46 | background-color: #006700; 47 | background-image: -moz-linear-gradient(#008f00, #006700); 48 | background-image: -o-linear-gradient(#008f00, #006700); 49 | background-image: -ms-linear-gradient(#008f00, #006700); 50 | background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #008f00), color-stop(1, #006700)); 51 | background-image: -webkit-linear-gradient(#008f00, #006700); 52 | -moz-border-radius: 4px; 53 | -webkit-border-radius: 4px; 54 | border-radius: 4px; 55 | border: 1px solid #003e00; 56 | cursor: pointer; 57 | color: white; 58 | font-size: 15px; 59 | line-height: 15px; 60 | text-align: center; 61 | text-shadow: 1px 1px black; } 62 | .dk-inner .dk-search .submit-question-search:hover { 63 | background-color: #005700; 64 | background-image: -moz-linear-gradient(green, #005700); 65 | background-image: -o-linear-gradient(green, #005700); 66 | background-image: -ms-linear-gradient(green, #005700); 67 | background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, green), color-stop(1, #005700)); 68 | background-image: -webkit-linear-gradient(green, #005700); } 69 | .dk-inner .dk-content { 70 | width: 72%; 71 | float: left; } 72 | .dk-inner .dk-content .dk-widget { 73 | border: 1px solid #ccc; 74 | -moz-box-shadow: 2px 2px 6px #dddddd; 75 | -webkit-box-shadow: 2px 2px 6px #dddddd; 76 | box-shadow: 2px 2px 6px #dddddd; 77 | -moz-border-radius: 4px; 78 | -webkit-border-radius: 4px; 79 | border-radius: 4px; 80 | padding: 18px 2% 22px; 81 | background: #fff; 82 | margin-bottom: 24px; } 83 | .dk-inner .dk-content .dk-widget h2 { 84 | margin-bottom: 0.10em; 85 | font-weight: 600; } 86 | .dk-inner .dk-content .dk-widget h3 { 87 | margin-bottom: 0.6em; 88 | font-weight: 600; } 89 | .dk-inner .dk-content .dk-widget h5 { 90 | margin-bottom: 1em; } 91 | .dk-inner .dk-content .dk-widget img { 92 | max-width: 96%; 93 | padding: 1%; 94 | border: 1px solid #ccc; } 95 | .dk-inner .dk-content .dk-widget ol.question-list { 96 | font-size: 14px; 97 | margin-left: 10px; } 98 | .dk-inner .dk-content .dk-widget hr.light { 99 | color: #e7e7e7; 100 | background: #e7e7e7; } 101 | .dk-inner .dk-content .dk-widget form.dk-form .field-wrapper { 102 | padding-bottom: 12px; } 103 | .dk-inner .dk-content .dk-widget form.dk-form .field-wrapper.field-phone_number { 104 | display: none; } 105 | .dk-inner .dk-content .dk-widget form.dk-form .field-wrapper .field-label label { 106 | font-size: 14px; 107 | font-weight: 600; } 108 | .dk-inner .dk-content .dk-widget form.dk-form .field-wrapper .field-label small { 109 | font-size: 11px; } 110 | .dk-inner .dk-content .dk-widget form.dk-form .field-wrapper input, .dk-inner .dk-content .dk-widget form.dk-form .field-wrapper textarea { 111 | padding: 8px 1%; 112 | font-size: 12px; 113 | width: 96%; 114 | border: 1px solid #ccc; } 115 | .dk-inner .dk-content .dk-widget form.dk-form .field-wrapper input[type=checkbox] { 116 | margin-top: 6px; } 117 | .dk-inner .dk-content .dk-widget form.dk-form .field-wrapper ul.errorlist { 118 | color: #8a1f11; 119 | margin: 0; 120 | padding: 0; 121 | list-style: none; } 122 | .dk-inner .dk-content .dk-widget form.dk-form .form-footer { 123 | border-top: 1px dashed #ccc; 124 | padding-top: 10px; 125 | width: 96%; } 126 | .dk-inner .dk-content .dk-widget form.dk-form .form-footer input.submit-question { 127 | font-size: 16px; } 128 | .dk-inner .dk-content .dk-widget .dk-dialog { 129 | padding-bottom: 1.8em; 130 | overflow: auto; 131 | min-height: 78px; } 132 | .dk-inner .dk-content .dk-widget .dk-dialog p { 133 | margin-bottom: 1em; } 134 | .dk-inner .dk-content .dk-widget .dk-dialog .the-author.gravatar { 135 | width: 13%; 136 | overflow: hidden; 137 | float: left; } 138 | .dk-inner .dk-content .dk-widget .dk-dialog .the-author.gravatar img { 139 | margin-top: 8px; 140 | padding: 5%; 141 | border: 1px solid #ccc; 142 | max-width: 60px; 143 | max-height: 60px; 144 | width: 80%; 145 | height: 80%; } 146 | .dk-inner .dk-content .dk-widget .dk-dialog .the-author.gravatar.smaller img { 147 | margin-top: 0; 148 | margin-left: 17%; 149 | max-width: 40px; 150 | max-height: 40px; } 151 | .dk-inner .dk-content .dk-widget .dk-dialog .the-content.gravatar { 152 | width: 87%; 153 | float: right; } 154 | .dk-inner .dk-content .dk-widget .dk-dialog:hover .dk-mod-bar { 155 | -moz-opacity: 1; 156 | -khtml-opacity: 1; 157 | opacity: 1; } 158 | .dk-inner .dk-content .dk-widget .dk-mod-bar { 159 | -moz-border-radius: 2px; 160 | -webkit-border-radius: 2px; 161 | border-radius: 2px; 162 | padding: 3px 4px 2px; 163 | background: #eee; 164 | -moz-opacity: 0.5; 165 | -khtml-opacity: 0.5; 166 | opacity: 0.5; 167 | float: right; } 168 | .dk-inner .dk-content .dk-widget .dk-mod-bar .dk-label { 169 | margin: 0 2px; } 170 | .dk-inner .dk-content .dk-widget .dk-mod-bar form { 171 | display: inline-block; } 172 | .dk-inner .dk-content .dk-widget .dk-mod-bar form input { 173 | border: none; 174 | cursor: pointer; } 175 | .dk-inner .dk-sidebar { 176 | width: 25%; 177 | float: right; 178 | padding-top: 10px; } 179 | .dk-inner .dk-sidebar .inner { 180 | padding: 0 2%; } 181 | .dk-inner .dk-sidebar h4 { 182 | margin-bottom: 0.3em; 183 | font-weight: 600; } 184 | .dk-inner .dk-sidebar hr { 185 | margin-bottom: 0.6em; 186 | color: #bbb; 187 | background: #bbb; } 188 | .dk-inner .dk-label { 189 | padding: 1px 3px 2px; 190 | position: relative; 191 | top: -1px; 192 | font-size: 10px; 193 | color: #fff; 194 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3); 195 | background-color: #999; 196 | -moz-border-radius: 3px; 197 | -webkit-border-radius: 3px; 198 | border-radius: 3px; } 199 | .dk-inner .dk-label-important { 200 | background-color: #B94A48; } 201 | .dk-inner .dk-label-warning { 202 | background-color: #C67605; } 203 | .dk-inner .dk-label-success { 204 | background-color: #468847; } 205 | .dk-inner .dk-label-info { 206 | background-color: #3A87AD; } 207 | -------------------------------------------------------------------------------- /knowledge/static/knowledge/css/reset.css: -------------------------------------------------------------------------------- 1 | /* from blueprint */ 2 | div, span, object, iframe, 3 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 4 | a, abbr, acronym, address, code, 5 | del, dfn, em, img, q, dl, dt, dd, ol, ul, li, 6 | fieldset, form, label, legend, 7 | table, caption, tbody, tfoot, thead, tr, th, td, 8 | article, aside, dialog, figure, footer, header, 9 | hgroup, nav, section, hr { 10 | margin: 0; 11 | padding: 0; 12 | border: 0; 13 | font-size: 100%; 14 | font: inherit; 15 | vertical-align: baseline; } 16 | 17 | /* This helps to make newer HTML5 elements behave like DIVs in older browers */ 18 | article, aside, details, figcaption, figure, dialog, 19 | footer, header, hgroup, menu, nav, section { 20 | display: block; } 21 | 22 | /* Line-height should always be unitless! */ 23 | body { 24 | line-height: 1.5; 25 | background: white; } 26 | 27 | /* Tables still need 'cellspacing="0"' in the markup. */ 28 | table { 29 | border-collapse: separate; 30 | border-spacing: 0; } 31 | 32 | /* float:none prevents the span-x classes from breaking table-cell display */ 33 | caption, th, td { 34 | text-align: left; 35 | font-weight: normal; 36 | float: none !important; } 37 | 38 | table, th, td { 39 | vertical-align: middle; } 40 | 41 | /* Remove possible quote marks (") from ,
. */ 42 | blockquote:before, blockquote:after, q:before, q:after { 43 | content: ''; } 44 | 45 | blockquote, q { 46 | quotes: "" ""; } 47 | 48 | /* Remove annoying border on linked images. */ 49 | a img { 50 | border: none; } 51 | 52 | /* Remember to define your own focus styles! */ 53 | :focus { 54 | outline: 0; } 55 | 56 | hr { 57 | color: #ccc; 58 | background: #ccc; 59 | height: 1px; 60 | border: 0; 61 | margin: 0 0 1.5em; } 62 | 63 | /* typography update */ 64 | html { 65 | font-size: 100.01%; } 66 | 67 | body { 68 | font-size: 75%; 69 | color: #333; 70 | background: #fff; 71 | font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; } 72 | 73 | /* Headings 74 | -------------------------------------------------------------- */ 75 | h1, h2, h3, h4, h5, h6 { 76 | font-weight: normal; 77 | color: #111; } 78 | 79 | h1 { 80 | font-size: 3em; 81 | line-height: 1; 82 | margin-bottom: 0.5em; } 83 | 84 | h2 { 85 | font-size: 2em; 86 | margin-bottom: 0.75em; } 87 | 88 | h3 { 89 | font-size: 1.5em; 90 | line-height: 1; 91 | margin-bottom: 1em; } 92 | 93 | h4 { 94 | font-size: 1.2em; 95 | line-height: 1.25; 96 | margin-bottom: 1.25em; } 97 | 98 | h5 { 99 | font-size: 1em; 100 | font-weight: bold; 101 | margin-bottom: 1.5em; } 102 | 103 | h6 { 104 | font-size: 1em; 105 | font-weight: bold; } 106 | 107 | h1 img, h2 img, h3 img, 108 | h4 img, h5 img, h6 img { 109 | margin: 0; } 110 | 111 | /* Text elements 112 | -------------------------------------------------------------- */ 113 | p { 114 | margin: 0 0 1.5em; } 115 | 116 | /* 117 | These can be used to pull an image at the start of a paragraph, so 118 | that the text flows around it (usage:

Text

) 119 | */ 120 | .left { 121 | float: left !important; } 122 | 123 | p .left { 124 | margin: 1.5em 1.5em 1.5em 0; 125 | padding: 0; } 126 | 127 | .right { 128 | float: right !important; } 129 | 130 | p .right { 131 | margin: 1.5em 0 1.5em 1.5em; 132 | padding: 0; } 133 | 134 | a:focus, 135 | a:hover { 136 | color: #09f; } 137 | 138 | a { 139 | color: #06c; 140 | text-decoration: underline; } 141 | 142 | blockquote { 143 | margin: 1.5em; 144 | color: #666; 145 | font-style: italic; } 146 | 147 | strong, dfn { 148 | font-weight: bold; } 149 | 150 | em, dfn { 151 | font-style: italic; } 152 | 153 | sup, sub { 154 | line-height: 0; } 155 | 156 | abbr, 157 | acronym { 158 | border-bottom: 1px dotted #666; } 159 | 160 | address { 161 | margin: 0 0 1.5em; 162 | font-style: italic; } 163 | 164 | del { 165 | color: #666; } 166 | 167 | pre { 168 | margin: 1.5em 0; 169 | white-space: pre; } 170 | 171 | pre, code, tt { 172 | font: 1em 'andale mono', 'lucida console', monospace; 173 | line-height: 1.5; } 174 | 175 | hr { 176 | color: #ccc; 177 | background: #ccc; 178 | height: 1px; 179 | border: 0; 180 | margin: 0 0 1.25em; } 181 | 182 | /* Lists 183 | -------------------------------------------------------------- */ 184 | li ul, 185 | li ol { 186 | margin: 0; } 187 | 188 | ul, ol { 189 | margin: 0 1.5em 1.5em 0; 190 | padding-left: 1.5em; } 191 | 192 | ul { 193 | list-style-type: disc; } 194 | 195 | ol { 196 | list-style-type: decimal; } 197 | 198 | dl { 199 | margin: 0 0 1.5em 0; } 200 | 201 | dl dt { 202 | font-weight: bold; } 203 | 204 | dd { 205 | margin-left: 1.5em; } 206 | 207 | /* Tables 208 | -------------------------------------------------------------- */ 209 | /* 210 | Because of the need for padding on TH and TD, the vertical rhythm 211 | on table cells has to be 27px, instead of the standard 18px or 36px 212 | of other elements. 213 | */ 214 | table { 215 | margin-bottom: 1.4em; 216 | width: 100%; } 217 | 218 | th { 219 | font-weight: bold; } 220 | 221 | thead th { 222 | background: #c3d9ff; } 223 | 224 | th, td, caption { 225 | padding: 4px 10px 4px 5px; } 226 | 227 | /* 228 | You can zebra-stripe your tables in outdated browsers by adding 229 | the class "even" to every other table row. 230 | */ 231 | tbody tr:nth-child(even) td, 232 | tbody tr.even td { 233 | background: #e5ecf9; } 234 | 235 | tfoot { 236 | font-style: italic; } 237 | 238 | caption { 239 | background: #eee; } 240 | 241 | body { 242 | background: #f7f7f7; } 243 | 244 | .wrapper { 245 | max-width: 960px; 246 | margin: 0 auto; } 247 | -------------------------------------------------------------------------------- /knowledge/static/knowledge/scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | // ---- CSS3 SASS MIXINS ---- 2 | // https://github.com/madr/css3-sass-mixins 3 | // 4 | // Copyright (C) 2011 by Anders Ytterström 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in 14 | // all copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | // THE SOFTWARE. 23 | // 24 | 25 | 26 | // Should IE filters be used or not? 27 | // PROS: gradients, drop shadows etc will be handled by css. 28 | // CONS: will harm the site performance badly, 29 | // especially on sites with heavy rendering and scripting. 30 | $useIEFilters: 0; // might be 0 or 1. disabled by default. 31 | 32 | @mixin border-image ($path, $offsets, $repeats) { 33 | -moz-border-image: $path $offsets $repeats; 34 | -o-border-image: $path $offsets $repeats; 35 | -webkit-border-image: $path $offsets $repeats; 36 | border-image: $path $offsets $repeats; 37 | } 38 | 39 | @mixin border-radius ($values) { 40 | -moz-border-radius: $values; 41 | -webkit-border-radius: $values; 42 | border-radius: $values; 43 | } 44 | 45 | @mixin box-shadow ($x, $y, $offset, $hex, $ie: $useIEFilters) { 46 | -moz-box-shadow: $x $y $offset $hex; 47 | -webkit-box-shadow: $x $y $offset $hex; 48 | box-shadow: $x $y $offset $hex; 49 | 50 | @if $ie == 1 { 51 | $iecolor: '#' + red($hex) + green($hex) + blue($hex); 52 | filter: progid:DXImageTransform.Microsoft.dropshadow(OffX=#{$x}, OffY=#{$y}, Color='#{$iecolor}'); 53 | -ms-filter: quote(progid:DXImageTransform.Microsoft.dropshadow(OffX=#{$x}, OffY=#{$y}, Color='#{$iecolor}')); 54 | } 55 | } 56 | 57 | @mixin opacity($opacity, $ie: $useIEFilters) { 58 | -moz-opacity: $opacity; 59 | -khtml-opacity: $opacity; 60 | opacity: $opacity; 61 | 62 | @if $ie == 1 { 63 | filter: alpha(opacity=($opacity * 100)); 64 | -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=" + ($opacity * 100) + ")"; 65 | } 66 | } 67 | 68 | @mixin linear-gradient($from, $to, $ie: $useIEFilters) { 69 | @if $ie != 1 { 70 | background-color: $to; 71 | } 72 | 73 | background-image: -moz-linear-gradient($from, $to); 74 | background-image: -o-linear-gradient($from, $to); 75 | background-image: -ms-linear-gradient($from, $to); 76 | background-image: -webkit-gradient(linear,left top,left bottom,color-stop(0, $from),color-stop(1, $to)); 77 | background-image: -webkit-linear-gradient($from, $to); 78 | 79 | @if $ie == 1 { 80 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#{$from}', endColorstr='#{$to}'); 81 | -ms-filter: quote(progid:DXImageTransform.Microsoft.gradient(startColorstr='#{$from}', endColorstr='#{$to}')); 82 | } 83 | } 84 | 85 | @mixin rgba($hex, $alpha, $ie: $useIEFilters) { 86 | @if $ie == 1 { 87 | // this formula is not accurate enough, will be fixed with sass 3.1 88 | $hexopac: '#' + ceil((($alpha * 255)/16) *10) + $hex; 89 | background-color: none; 90 | filter: progid:DXImageTransform.Microsoft.gradient(startColorStr='#{$hexopac}',EndColorStr='#{$hexopac}}'); 91 | -ms-filter: quote(progid:DXImageTransform.Microsoft.gradient(startColorStr='#{$hexopac}',EndColorStr='#{$hexopac}')); 92 | } 93 | @else { 94 | background-color: $hex; 95 | background-color: rgba(red($hex), green($hex), blue($hex), $alpha); 96 | } 97 | } 98 | 99 | @mixin rotate ($deg) { 100 | -moz-transform: rotate(#{$deg}deg); 101 | -o-transform: rotate(#{$deg}deg); 102 | -ms-transform: rotate(#{$deg}deg); 103 | -webkit-transform: rotate(#{$deg}deg); 104 | } 105 | 106 | @mixin scale ($size) { 107 | -moz-transform: scale(#{$size}); 108 | -o-transform: scale(#{$size}); 109 | -ms-transform: scale(#{$size}); 110 | -webkit-transform: scale(#{$size}); 111 | } 112 | 113 | @mixin transition ($value) { 114 | -moz-transition: $value; 115 | -o-transition: $value; 116 | -ms-transition: $value; 117 | -webkit-transition: $value; 118 | transition: $value; 119 | } 120 | // ==== /CSS3 SASS MIXINS ==== 121 | -------------------------------------------------------------------------------- /knowledge/static/knowledge/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "mixins"; 2 | 3 | .dk-inner { 4 | padding:20px 0; 5 | font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; 6 | 7 | // common resets 8 | input {margin:0;} 9 | 10 | .quiet { 11 | font-weight:normal; 12 | color:#888; 13 | } 14 | .clear { clear:both; } 15 | .center { text-align:center; } 16 | 17 | .dk-roof { 18 | margin-bottom:24px; 19 | h2 { 20 | margin-bottom: .15em; 21 | font-weight:600; 22 | 23 | a { 24 | color:#333; 25 | text-decoration:none; 26 | 27 | &:hover { 28 | text-decoration:underline; 29 | } 30 | } 31 | } // h2 32 | } // .dk-roof 33 | 34 | .dk-search { 35 | border:1px solid #ccc; 36 | padding:1%; 37 | background:#eee; 38 | @include border-radius(4px); 39 | margin-bottom:24px; 40 | 41 | input.question-search { 42 | @include border-radius(4px); 43 | padding:8px 1%; 44 | border:1px solid #ccc; 45 | font-size:16px; 46 | width:87%; 47 | height:16px; 48 | float:left; 49 | } 50 | 51 | .submit-question-search { 52 | height:34px; 53 | width:10%; 54 | padding:8px 1%; 55 | float:right; 56 | @include linear-gradient(lighten(green, 3%), darken(green, 5%)); 57 | 58 | @include border-radius(4px); 59 | border:1px solid darken(green, 13%); 60 | cursor:pointer; 61 | 62 | color:white; 63 | font-size:15px; 64 | line-height:15px; 65 | text-align:center; 66 | text-shadow: 1px 1px darken(green, 50%); 67 | 68 | &:hover { 69 | @include linear-gradient(darken(green, 0%), darken(green, 8%)); 70 | } 71 | } // .submit-question-search 72 | } // .dk-search 73 | 74 | 75 | .dk-content { 76 | width:72%; 77 | float:left; 78 | 79 | .dk-widget { 80 | border:1px solid #ccc; 81 | @include box-shadow(2px, 2px, 6px, #ddd); 82 | @include border-radius(4px); 83 | padding:18px 2% 22px; 84 | background:#fff; 85 | margin-bottom:24px; 86 | 87 | h2 { margin-bottom:0.10em; font-weight:600; } 88 | h3 { margin-bottom:0.6em; font-weight:600; } 89 | h5 { margin-bottom:1em } 90 | 91 | img { max-width:96%; padding:1%; border:1px solid #ccc; } 92 | 93 | ol.question-list { 94 | font-size:14px; 95 | margin-left:10px; 96 | } 97 | hr.light { color:#e7e7e7; background:#e7e7e7; } 98 | 99 | form.dk-form { 100 | .field-wrapper { 101 | padding-bottom:12px; 102 | 103 | &.field-phone_number { 104 | display:none; 105 | } 106 | 107 | .field-label { 108 | label { font-size:14px; font-weight:600; } 109 | small { font-size:11px; } 110 | } 111 | 112 | input, textarea { 113 | padding:8px 1%; 114 | font-size:12px; 115 | width:96%; 116 | border:1px solid #ccc; 117 | } 118 | 119 | input[type=checkbox] { 120 | margin-top:6px; 121 | } 122 | 123 | ul.errorlist { 124 | color:#8a1f11; 125 | margin:0; 126 | padding:0; 127 | list-style:none; 128 | } 129 | } // .field-wrapper 130 | 131 | .form-footer { 132 | border-top:1px dashed #ccc; 133 | padding-top:10px; 134 | width:96%; 135 | 136 | input.submit-question { 137 | font-size:16px; 138 | } 139 | } 140 | } // form.ask-question 141 | 142 | 143 | // these are just stacked, no difference in 144 | // question vs. response 145 | .dk-dialog { 146 | padding-bottom:1.8em; 147 | overflow:auto; 148 | min-height:78px; 149 | 150 | p { margin-bottom: 1em; } 151 | 152 | .the-author.gravatar { 153 | width:13%; 154 | overflow:hidden; 155 | float:left; 156 | 157 | img { 158 | margin-top:8px; 159 | padding:5%; 160 | border: 1px solid #ccc; 161 | max-width:60px; 162 | max-height:60px; 163 | width:80%; 164 | height:80%; 165 | } 166 | 167 | &.smaller { 168 | img { 169 | margin-top:0; 170 | margin-left:17%; 171 | max-width:40px; 172 | max-height:40px; 173 | } 174 | } 175 | } 176 | .the-content.gravatar { 177 | width:87%; 178 | float:right; 179 | } 180 | 181 | &:hover .dk-mod-bar { 182 | @include opacity(1); 183 | } 184 | } 185 | 186 | .dk-mod-bar { 187 | @include border-radius(2px); 188 | padding:3px 4px 2px; 189 | background:#eee; 190 | @include opacity(0.5); 191 | float:right; 192 | 193 | .dk-label { 194 | margin:0 2px; 195 | } 196 | 197 | form { 198 | display:inline-block; 199 | 200 | input { 201 | border:none; 202 | cursor:pointer; 203 | } 204 | } 205 | } // .dk-mod-bar 206 | 207 | } // dk-widget 208 | } // .dk-content 209 | 210 | 211 | .dk-sidebar { 212 | width:25%; 213 | float:right; 214 | padding-top:10px; 215 | 216 | .inner { 217 | padding:0 2%; 218 | } 219 | 220 | h4 { margin-bottom:0.3em; font-weight:600; } 221 | hr { margin-bottom:0.6em; color:#bbb; background:#bbb; } 222 | } 223 | 224 | // from bootstrap 2.0 225 | .dk-label { 226 | padding: 1px 3px 2px; 227 | position:relative; 228 | top:-1px; 229 | font-size: 10px; 230 | //font-weight: bold; 231 | color: #fff; 232 | text-shadow: 0 -1px 0 rgba(0,0,0,.3); 233 | background-color: #999; 234 | @include border-radius(3px); 235 | } 236 | 237 | // Colors 238 | .dk-label-important { background-color: #B94A48; } 239 | .dk-label-warning { background-color: #C67605; } 240 | .dk-label-success { background-color: #468847; } 241 | .dk-label-info { background-color: #3A87AD; } 242 | } // .dk-inner -------------------------------------------------------------------------------- /knowledge/static/knowledge/scss/reset.scss: -------------------------------------------------------------------------------- 1 | 2 | /* from blueprint */ 3 | 4 | div, span, object, iframe, 5 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 6 | a, abbr, acronym, address, code, 7 | del, dfn, em, img, q, dl, dt, dd, ol, ul, li, 8 | fieldset, form, label, legend, 9 | table, caption, tbody, tfoot, thead, tr, th, td, 10 | article, aside, dialog, figure, footer, header, 11 | hgroup, nav, section, hr { 12 | margin: 0; 13 | padding: 0; 14 | border: 0; 15 | font-size: 100%; 16 | font: inherit; 17 | vertical-align: baseline; 18 | } 19 | 20 | /* This helps to make newer HTML5 elements behave like DIVs in older browers */ 21 | article, aside, details, figcaption, figure, dialog, 22 | footer, header, hgroup, menu, nav, section { 23 | display:block; 24 | } 25 | 26 | /* Line-height should always be unitless! */ 27 | body { 28 | line-height: 1.5; 29 | background: white; 30 | } 31 | 32 | /* Tables still need 'cellspacing="0"' in the markup. */ 33 | table { 34 | border-collapse: separate; 35 | border-spacing: 0; 36 | } 37 | /* float:none prevents the span-x classes from breaking table-cell display */ 38 | caption, th, td { 39 | text-align: left; 40 | font-weight: normal; 41 | float:none !important; 42 | } 43 | table, th, td { 44 | vertical-align: middle; 45 | } 46 | 47 | /* Remove possible quote marks (") from ,
. */ 48 | blockquote:before, blockquote:after, q:before, q:after { content: ''; } 49 | blockquote, q { quotes: "" ""; } 50 | 51 | /* Remove annoying border on linked images. */ 52 | a img { border: none; } 53 | 54 | /* Remember to define your own focus styles! */ 55 | :focus { outline: 0; } 56 | 57 | hr { 58 | color:#ccc; 59 | background:#ccc; 60 | height:1px; 61 | border:0; 62 | margin: 0 0 1.5em; 63 | } 64 | 65 | 66 | /* typography update */ 67 | 68 | html { font-size:100.01%; } 69 | body { 70 | font-size: 75%; 71 | color: #333; 72 | background: #fff; 73 | font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; 74 | } 75 | 76 | /* Headings 77 | -------------------------------------------------------------- */ 78 | 79 | h1,h2,h3,h4,h5,h6 { font-weight: normal; color: #111; } 80 | 81 | h1 { font-size: 3em; line-height: 1; margin-bottom: 0.5em; } 82 | h2 { font-size: 2em; margin-bottom: 0.75em; } 83 | h3 { font-size: 1.5em; line-height: 1; margin-bottom: 1em; } 84 | h4 { font-size: 1.2em; line-height: 1.25; margin-bottom: 1.25em; } 85 | h5 { font-size: 1em; font-weight: bold; margin-bottom: 1.5em; } 86 | h6 { font-size: 1em; font-weight: bold; } 87 | 88 | h1 img, h2 img, h3 img, 89 | h4 img, h5 img, h6 img { 90 | margin: 0; 91 | } 92 | 93 | 94 | /* Text elements 95 | -------------------------------------------------------------- */ 96 | 97 | p { margin: 0 0 1.5em; } 98 | /* 99 | These can be used to pull an image at the start of a paragraph, so 100 | that the text flows around it (usage:

Text

) 101 | */ 102 | .left { float: left !important; } 103 | p .left { margin: 1.5em 1.5em 1.5em 0; padding: 0; } 104 | .right { float: right !important; } 105 | p .right { margin: 1.5em 0 1.5em 1.5em; padding: 0; } 106 | 107 | a:focus, 108 | a:hover { color: #09f; } 109 | a { color: #06c; text-decoration: underline; } 110 | 111 | blockquote { margin: 1.5em; color: #666; font-style: italic; } 112 | strong,dfn { font-weight: bold; } 113 | em,dfn { font-style: italic; } 114 | sup, sub { line-height: 0; } 115 | 116 | abbr, 117 | acronym { border-bottom: 1px dotted #666; } 118 | address { margin: 0 0 1.5em; font-style: italic; } 119 | del { color:#666; } 120 | 121 | pre { margin: 1.5em 0; white-space: pre; } 122 | pre,code,tt { font: 1em 'andale mono', 'lucida console', monospace; line-height: 1.5; } 123 | 124 | hr { color:#ccc; background:#ccc; height:1px; border:0; margin: 0 0 1.25em; } 125 | 126 | 127 | 128 | /* Lists 129 | -------------------------------------------------------------- */ 130 | 131 | li ul, 132 | li ol { margin: 0; } 133 | ul, ol { margin: 0 1.5em 1.5em 0; padding-left: 1.5em; } 134 | 135 | ul { list-style-type: disc; } 136 | ol { list-style-type: decimal; } 137 | 138 | dl { margin: 0 0 1.5em 0; } 139 | dl dt { font-weight: bold; } 140 | dd { margin-left: 1.5em;} 141 | 142 | 143 | /* Tables 144 | -------------------------------------------------------------- */ 145 | 146 | /* 147 | Because of the need for padding on TH and TD, the vertical rhythm 148 | on table cells has to be 27px, instead of the standard 18px or 36px 149 | of other elements. 150 | */ 151 | table { margin-bottom: 1.4em; width:100%; } 152 | th { font-weight: bold; } 153 | thead th { background: #c3d9ff; } 154 | th,td,caption { padding: 4px 10px 4px 5px; } 155 | /* 156 | You can zebra-stripe your tables in outdated browsers by adding 157 | the class "even" to every other table row. 158 | */ 159 | tbody tr:nth-child(even) td, 160 | tbody tr.even td { 161 | background: #e5ecf9; 162 | } 163 | tfoot { font-style: italic; } 164 | caption { background: #eee; } 165 | 166 | 167 | body { 168 | background:#f7f7f7; 169 | } 170 | 171 | .wrapper { 172 | // all our reset stuff is namespaced under dk wrapper 173 | max-width:960px; 174 | margin:0 auto; 175 | } 176 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/ask.html: -------------------------------------------------------------------------------- 1 | {% extends 'django_knowledge/inner.html' %} 2 | 3 | {% load i18n %} 4 | {% load url from future %} 5 | 6 | {% block title %}{% trans "Ask a Question" %}{% endblock title %} 7 | 8 | {% block knowledge_widgets %} 9 |
10 |

{% trans "Ask a Question" %}

11 |
12 | 13 | {% if form %} 14 | {% include "django_knowledge/form.html" with submit_value="Submit this support request" submit_and="and we'll get back to you as soon as possible." %} 15 | {% else %} 16 |

{% trans "Please log in to ask a question." %}

17 | {% endif %} 18 | 19 |
20 | {% endblock knowledge_widgets %} 21 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}{% endblock title %} | Support Center 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | {% block knowledge_inner %} 19 | {% endblock knowledge_inner %} 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/emails/base.html: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | 3 | {% endblock content %} 4 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/emails/message.html: -------------------------------------------------------------------------------- 1 | {% extends "django_knowledge/emails/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load markup %} 5 | 6 | {% block content %} 7 |

{% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}

8 | 9 | {% if response %} 10 |

{% blocktrans with url=response.question.url title=response.question.title domain=site.domain %} 11 | We just wanted to let you know that a new response has been added to the question "{{ title }}". You can visit it here: {{ domain }}{{ url "}}. 12 | {% endblocktrans %}

13 | 14 |
15 | {{ response.body|striptags|markdown }} 16 |
17 | 18 |

{% trans "You are receiving these messages because you checked the 'alert' box when you originally posted." %}

19 | {% endif %} 20 | 21 | {% if question %} 22 |

{% blocktrans with url=question.url title=question.title domain=site.domain %} 23 | We just wanted to let you know that a new question has been added: "{{ title }}". You can visit it here: {{ domain }}{{ url "}} 24 | {% endblocktrans %}

25 | 26 |
27 | {{ question.body|striptags|markdown }} 28 |
29 | 30 |

{% trans "You are receiving these messages because you are a staff member." %}

31 | {% endif %} 32 | 33 |

{% blocktrans with name=site.name %}Thank you, 34 | {{ name }} team{% endblocktrans %}

35 | {% endblock content %} 36 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/emails/message.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load markup %} 3 | 4 | {% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %} 5 | 6 | {% if response %} 7 | {% blocktrans with url=response.question.url title=response.question.title domain=site.domain %} 8 | We just wanted to let you know that a new response has been added to the question "{{ title }}". You can visit it here: {{ domain }}{{ url "}}" 9 | {% endblocktrans %} 10 | 11 | {{ response.body }} 12 | 13 | {% trans "You are receiving these messages because you checked the 'alert' box when you originally posted." %} 14 | {% endif %} 15 | 16 | {% if question %} 17 | {% blocktrans with url=question.url title=question.title domain=site.domain %} 18 | We just wanted to let you know that a new question has been added: "{{ title }}". You can visit it here: {{ domain }}{{ url "}}" 19 | {% endblocktrans %} 20 | 21 | {{ question.body }} 22 | 23 | {% trans "You are receiving these messages because you are a staff member." %} 24 | {% endif %} 25 | 26 | {% blocktrans with name=site.name %}Thank you, 27 | {{ name }} team{% endblocktrans %} 28 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/emails/subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% if response %}{% blocktrans with title=response.question.title name=site.name %}New response on "{{ title }}" on {{ name }}.{% endblocktrans %}{% endif %}{% if question %}{% blocktrans with title=question.title name=site.name %}New question "{{ title }}" on {{ name }}.{% endblocktrans %}{% endif %} 2 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/form.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load url from future %} 3 | 4 |
5 | {% csrf_token %} 6 | 7 | {% for field in form.visible_fields %} 8 |
9 |
10 | 11 | 12 | {% if field.field.help_text %} 13 |
14 | {{ field.field.help_text }} 15 | {% endif %} 16 |
17 |
18 | {{ field }} 19 |
20 | 21 | {{ field.errors }} 22 |
23 | {% endfor %} 24 | 25 | {# these are, for the most part, unused #} 26 | {% for field in form.hidden_fields %}{{ field }}{% endfor %} 27 | 28 | 31 |
32 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'django_knowledge/inner.html' %} 2 | 3 | {% load i18n %} 4 | {% load url from future %} 5 | 6 | {% block title %}{% trans "Support" %}{% endblock title %} 7 | 8 | {% block knowledge_widgets %} 9 | 10 |
11 |

{% blocktrans with count=questions|length %}Top {{ count }} Questions{% endblocktrans %}

12 |
13 | 14 | {% include 'django_knowledge/question_list.html' %} 15 |
16 | 17 | {% endblock knowledge_widgets %} 18 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/inner.html: -------------------------------------------------------------------------------- 1 | {% extends BASE_TEMPLATE|default:'django_knowledge/base.html' %} 2 | 3 | {% load i18n %} 4 | {% load url from future %} 5 | 6 | {% block knowledge_inner %} 7 | 8 |
9 | 10 | {% include "django_knowledge/welcome.html" %} 11 | 12 | 18 | 19 |
20 | {% block knowledge_widgets %} 21 | 22 | {% endblock knowledge_widgets %} 23 |
24 | 25 |
26 |
27 | {% include "django_knowledge/sidebar.html" %} 28 |
29 |
30 | 31 |
32 |
33 | 34 | {% endblock knowledge_inner %} 35 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/list.html: -------------------------------------------------------------------------------- 1 | {% extends 'django_knowledge/inner.html' %} 2 | 3 | {% load i18n %} 4 | {% load url from future %} 5 | 6 | {% load knowledge_tags %} 7 | 8 | {% block title %}Showing {{ questions.paginator.count }} results{% if search %} for {{ search }}{% endif %}{% if category %} in {{ category.title }} category{% endif %}{% endblock title %} 9 | 10 | {% block knowledge_widgets %} 11 | 12 |
40 | 41 | {% if form and paginator.count < 5 %} 42 |
43 |

{% trans "Ask a Question" %}

44 |
45 | 46 | {% url "knowledge_ask" as form_url %} 47 | 48 | {% include "django_knowledge/form.html" with submit_value="Submit this support request" submit_and="and we'll get back to you as soon as possible." submit_url=form_url %} 49 |
50 | {% endif %} 51 | 52 | {% endblock knowledge_widgets %} 53 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/mod_bar.html: -------------------------------------------------------------------------------- 1 | {% load url from future %} 2 | 3 | {% if request.user.is_staff %} 4 |
5 | {% for mod in allowed_mods %} 6 |
{% csrf_token %}
7 | {% endfor %} 8 |
9 | {% endif %} 10 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/question_list.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load url from future %} 3 | 4 |
    5 | {% for question in questions %} 6 |
  1. {{ question.title }}  {% if not question.get_responses %}{% trans "no responses" %}{% else %}{{ question.get_responses|length }} responses{% endif %}  {% if question.accepted %}{% trans "accepted" %} {% endif %} {% if question.user.is_staff %}{% trans "staff" %} {% endif %} by {{ question.get_name }}
  2. 7 | {% endfor %} 8 |
9 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/sidebar.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load url from future %} 3 | 4 | {% if my_questions %} 5 | 6 |

{% trans "My Questions" %}

7 |
8 | 9 | 14 | 15 |
16 | 17 | {% endif %} 18 | 19 |

{% trans "Categories" %}

20 |
21 | 22 | 27 | 28 |
29 | 30 |

{% trans "Navigate" %}

31 |
32 | 33 | 37 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/thread.html: -------------------------------------------------------------------------------- 1 | {% extends 'django_knowledge/inner.html' %} 2 | 3 | {% load i18n %} 4 | {% load markup %} 5 | {% load knowledge_tags %} 6 | {% load url from future %} 7 | 8 | {% block title %}{{ question.title }}{% endblock title %} 9 | 10 | {% block knowledge_widgets %} 11 | 12 |
13 | 14 |
15 |
{{ question.get_name }} gravatar
16 |
17 |

{{ question.title }}

18 |
{{ question.get_name }} 19 | {% if question.user %} 20 | {% if question.user.is_staff %} {% trans "staff" %} 21 | {% else %} {% trans "user" %}{% endif %} 22 | {% endif %} 23 |  {{ question.added }} 24 |
25 | 26 | {{ question.body|striptags|markdown }} 27 | 28 | {% include "django_knowledge/mod_bar.html" with allowed_mods=allowed_mods.question type="question" node=question %} 29 |
30 |
31 | 32 | 33 |
34 |
35 | 36 | 37 | {% for response in responses %} 38 |
39 |
{{ response.get_name }} gravatar
40 |
41 |
{{ response.get_name }} 42 | {% if response.accepted %} {% trans "accepted answer" %}{% endif %} 43 | {% if question.user == response.user %} {% trans "poster" %}{% endif %} 44 | {% if response.user %} 45 | {% if response.user.is_staff %} {% trans "staff" %} 46 | {% else %} {% trans "user" %}{% endif %} 47 | {% endif %} 48 |  {{ response.added }} 49 |
50 | 51 | {{ response.body|striptags|markdown }} 52 | 53 | {% include "django_knowledge/mod_bar.html" with allowed_mods=allowed_mods.response type="response" node=response %} 54 |
55 |
56 | {% empty %} 57 |

{% trans "No responses yet." %}

58 | {% endfor %} 59 | 60 | 61 |
62 |
63 |
64 | 65 | 66 | {% if form %} 67 | {% include "django_knowledge/form.html" with submit_value="Respond to this question" submit_and="and check back often for updates." %} 68 | {% else %} 69 | {% if question.locked %} 70 |

{% trans "This question has been closed." %}

71 | {% else %} 72 |

{% trans "Please log in to respond." %}

73 | {% endif %} 74 | {% endif %} 75 | 76 | 77 |
78 | 79 | {% endblock knowledge_widgets %} 80 | -------------------------------------------------------------------------------- /knowledge/templates/django_knowledge/welcome.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load url from future %} 3 | 4 |
5 |

{% trans "Welcome to our Support Center." %}

6 |

{% trans "Search for answers to your questions below, or ask your own." %}

7 |
8 | -------------------------------------------------------------------------------- /knowledge/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/knowledge/templatetags/__init__.py -------------------------------------------------------------------------------- /knowledge/templatetags/knowledge_tags.py: -------------------------------------------------------------------------------- 1 | from hashlib import md5 2 | from urllib import urlencode 3 | 4 | from django import template 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.simple_tag 10 | def get_gravatar(email, size=60, rating='g', default=None): 11 | """ Return url for a Gravatar. From Zinnia blog. """ 12 | url = 'https://secure.gravatar.com/avatar/{0}.jpg'.format( 13 | md5(email.strip().lower()).hexdigest() 14 | ) 15 | options = {'s': size, 'r': rating} 16 | if default: 17 | options['d'] = default 18 | 19 | url = '%s?%s' % (url, urlencode(options)) 20 | return url.replace('&', '&') 21 | 22 | 23 | @register.simple_tag 24 | def page_query(request, page_num): 25 | qs = request.GET.copy() 26 | qs['page'] = page_num 27 | return qs.urlencode().replace('&', '&') 28 | -------------------------------------------------------------------------------- /knowledge/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | 3 | urlpatterns = patterns('knowledge.views', 4 | url(r'^$', 'knowledge_index', name='knowledge_index'), 5 | 6 | url(r'^questions/$', 'knowledge_list', name='knowledge_list'), 7 | 8 | url(r'^questions/(?P\d+)/$', 9 | 'knowledge_thread', name='knowledge_thread_no_slug'), 10 | 11 | url(r'^questions/(?P[a-z0-9-_]+)/$', 'knowledge_list', 12 | name='knowledge_list_category'), 13 | 14 | url(r'^questions/(?P\d+)/(?P[a-z0-9-_]+)/$', 15 | 'knowledge_thread', name='knowledge_thread'), 16 | 17 | url(r'^moderate/(?P[a-z]+)/' 18 | r'(?P\d+)/(?P[a-zA-Z0-9_]+)/$', 19 | 'knowledge_moderate', name='knowledge_moderate'), 20 | 21 | url(r'^ask/$', 'knowledge_ask', name='knowledge_ask'), 22 | ) 23 | -------------------------------------------------------------------------------- /knowledge/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def paginate(iterable, per_page, page_num): 5 | """ 6 | recipes = Recipe.objects.all() 7 | paginator, recipes = paginate(recipes, 12, 8 | request.GET.get('page', '1')) 9 | """ 10 | from django.core.paginator import Paginator, InvalidPage, EmptyPage 11 | 12 | paginator = Paginator(iterable, per_page) 13 | 14 | try: 15 | page = int(page_num) 16 | except ValueError: 17 | page = 1 18 | 19 | try: 20 | iterable = paginator.page(page) 21 | except (EmptyPage, InvalidPage): 22 | iterable = paginator.page(paginator.num_pages) 23 | 24 | return paginator, iterable 25 | 26 | 27 | def get_module(path): 28 | """ 29 | A modified duplicate from Django's built in backend 30 | retriever. 31 | 32 | slugify = get_module('django.template.defaultfilters.slugify') 33 | """ 34 | from django.utils.importlib import import_module 35 | 36 | try: 37 | mod_name, func_name = path.rsplit('.', 1) 38 | mod = import_module(mod_name) 39 | except ImportError, e: 40 | raise ImportError( 41 | 'Error importing alert function {0}: "{1}"'.format(mod_name, e)) 42 | 43 | try: 44 | func = getattr(mod, func_name) 45 | except AttributeError: 46 | raise ImportError( 47 | ('Module "{0}" does not define a "{1}" function' 48 | ).format(mod_name, func_name)) 49 | 50 | return func 51 | 52 | 53 | user_model_label = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') 54 | -------------------------------------------------------------------------------- /knowledge/views.py: -------------------------------------------------------------------------------- 1 | import settings 2 | 3 | from django.http import Http404, HttpResponseRedirect 4 | from django.shortcuts import render, redirect, get_object_or_404 5 | from django.core.urlresolvers import reverse, NoReverseMatch 6 | from django.db.models import Q 7 | 8 | from knowledge.models import Question, Response, Category 9 | from knowledge.forms import QuestionForm, ResponseForm 10 | from knowledge.utils import paginate 11 | 12 | 13 | ALLOWED_MODS = { 14 | 'question': [ 15 | 'private', 'public', 16 | 'delete', 'lock', 17 | 'clear_accepted' 18 | ], 19 | 'response': [ 20 | 'internal', 'inherit', 21 | 'private', 'public', 22 | 'delete', 'accept' 23 | ] 24 | } 25 | 26 | 27 | def get_my_questions(request): 28 | 29 | if settings.LOGIN_REQUIRED and not request.user.is_authenticated(): 30 | return HttpResponseRedirect(settings.LOGIN_URL+"?next=%s" % request.path) 31 | 32 | if request.user.is_anonymous(): 33 | return None 34 | else: 35 | return Question.objects.can_view(request.user)\ 36 | .filter(user=request.user) 37 | 38 | 39 | def knowledge_index(request, 40 | template='django_knowledge/index.html'): 41 | 42 | if settings.LOGIN_REQUIRED and not request.user.is_authenticated(): 43 | return HttpResponseRedirect(settings.LOGIN_URL+"?next=%s" % request.path) 44 | 45 | questions = Question.objects.can_view(request.user)\ 46 | .prefetch_related('responses__question')[0:20] 47 | # this is for get_responses() 48 | [setattr(q, '_requesting_user', request.user) for q in questions] 49 | 50 | return render(request, template, { 51 | 'request': request, 52 | 'questions': questions, 53 | 'my_questions': get_my_questions(request), 54 | 'categories': Category.objects.all(), 55 | 'BASE_TEMPLATE' : settings.BASE_TEMPLATE, 56 | }) 57 | 58 | 59 | def knowledge_list(request, 60 | category_slug=None, 61 | template='django_knowledge/list.html', 62 | Form=QuestionForm): 63 | 64 | if settings.LOGIN_REQUIRED and not request.user.is_authenticated(): 65 | return HttpResponseRedirect(settings.LOGIN_URL+"?next=%s" % request.path) 66 | 67 | search = request.GET.get('title', None) 68 | questions = Question.objects.can_view(request.user)\ 69 | .prefetch_related('responses__question') 70 | 71 | if search: 72 | questions = questions.filter( 73 | Q(title__icontains=search) | Q(body__icontains=search) 74 | ) 75 | 76 | category = None 77 | if category_slug: 78 | category = get_object_or_404(Category, slug=category_slug) 79 | questions = questions.filter(categories=category) 80 | 81 | paginator, questions = paginate(questions, 82 | 50, 83 | request.GET.get('page', '1')) 84 | # this is for get_responses() 85 | [setattr(q, '_requesting_user', request.user) for q in questions] 86 | 87 | return render(request, template, { 88 | 'request': request, 89 | 'search': search, 90 | 'questions': questions, 91 | 'my_questions': get_my_questions(request), 92 | 'category': category, 93 | 'categories': Category.objects.all(), 94 | 'form': Form(request.user, initial={'title': search}), # prefill title 95 | 'BASE_TEMPLATE' : settings.BASE_TEMPLATE, 96 | }) 97 | 98 | 99 | def knowledge_thread(request, 100 | question_id, 101 | slug=None, 102 | template='django_knowledge/thread.html', 103 | Form=ResponseForm): 104 | 105 | if settings.LOGIN_REQUIRED and not request.user.is_authenticated(): 106 | return HttpResponseRedirect(settings.LOGIN_URL+"?next=%s" % request.path) 107 | 108 | try: 109 | question = Question.objects.can_view(request.user)\ 110 | .get(id=question_id) 111 | except Question.DoesNotExist: 112 | if Question.objects.filter(id=question_id).exists() and \ 113 | hasattr(settings, 'LOGIN_REDIRECT_URL'): 114 | return redirect(settings.LOGIN_REDIRECT_URL) 115 | else: 116 | raise Http404 117 | 118 | responses = question.get_responses(request.user) 119 | 120 | if request.path != question.get_absolute_url(): 121 | return redirect(question.get_absolute_url(), permanent=True) 122 | 123 | if request.method == 'POST': 124 | form = Form(request.user, question, request.POST) 125 | if form and form.is_valid(): 126 | if request.user.is_authenticated() or not form.cleaned_data['phone_number']: 127 | form.save() 128 | return redirect(question.get_absolute_url()) 129 | else: 130 | form = Form(request.user, question) 131 | 132 | return render(request, template, { 133 | 'request': request, 134 | 'question': question, 135 | 'my_questions': get_my_questions(request), 136 | 'responses': responses, 137 | 'allowed_mods': ALLOWED_MODS, 138 | 'form': form, 139 | 'categories': Category.objects.all(), 140 | 'BASE_TEMPLATE' : settings.BASE_TEMPLATE, 141 | }) 142 | 143 | 144 | def knowledge_moderate( 145 | request, 146 | lookup_id, 147 | model, 148 | mod, 149 | allowed_mods=ALLOWED_MODS): 150 | 151 | """ 152 | An easy to extend method to moderate questions 153 | and responses in a vaguely RESTful way. 154 | 155 | Usage: 156 | /knowledge/moderate/question/1/inherit/ -> 404 157 | /knowledge/moderate/question/1/public/ -> 200 158 | 159 | /knowledge/moderate/response/3/notreal/ -> 404 160 | /knowledge/moderate/response/3/inherit/ -> 200 161 | 162 | """ 163 | 164 | if settings.LOGIN_REQUIRED and not request.user.is_authenticated(): 165 | return HttpResponseRedirect(settings.LOGIN_URL+"?next=%s" % request.path) 166 | 167 | if request.method != 'POST': 168 | raise Http404 169 | 170 | if model == 'question': 171 | Model, perm = Question, 'change_question' 172 | elif model == 'response': 173 | Model, perm = Response, 'change_response' 174 | else: 175 | raise Http404 176 | 177 | if not request.user.has_perm(perm): 178 | raise Http404 179 | 180 | if mod not in allowed_mods[model]: 181 | raise Http404 182 | 183 | instance = get_object_or_404( 184 | Model.objects.can_view(request.user), 185 | id=lookup_id) 186 | 187 | func = getattr(instance, mod) 188 | if callable(func): 189 | func() 190 | 191 | try: 192 | return redirect(( 193 | instance if instance.is_question else instance.question 194 | ).get_absolute_url()) 195 | except NoReverseMatch: 196 | # if we delete an instance... 197 | return redirect(reverse('knowledge_index')) 198 | 199 | 200 | def knowledge_ask(request, 201 | template='django_knowledge/ask.html', 202 | Form=QuestionForm): 203 | 204 | if settings.LOGIN_REQUIRED and not request.user.is_authenticated(): 205 | return HttpResponseRedirect(settings.LOGIN_URL+"?next=%s" % request.path) 206 | 207 | if request.method == 'POST': 208 | form = Form(request.user, request.POST) 209 | if form and form.is_valid(): 210 | if request.user.is_authenticated() or not form.cleaned_data['phone_number']: 211 | question = form.save() 212 | return redirect(question.get_absolute_url()) 213 | else: 214 | return redirect('knowledge_index') 215 | else: 216 | form = Form(request.user) 217 | 218 | return render(request, template, { 219 | 'request': request, 220 | 'my_questions': get_my_questions(request), 221 | 'form': form, 222 | 'categories': Category.objects.all(), 223 | 'BASE_TEMPLATE' : settings.BASE_TEMPLATE, 224 | }) 225 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.5.1 2 | Markdown==2.1.1 3 | South==0.7.6 4 | Sphinx==1.1.3 5 | coverage==3.5.1 6 | django-coverage==1.2.2 7 | django-debug-toolbar==0.8.5 8 | pep8==0.6.1 9 | pylint==0.25.1 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup # setuptools breaks 2 | 3 | # Dynamically calculate the version based on knowledge.VERSION 4 | version_tuple = __import__('knowledge').VERSION 5 | version = '.'.join([str(v) for v in version_tuple]) 6 | 7 | setup( 8 | name = 'django-knowledge', 9 | description = '''A simple frontend and admin interface for dealing with help 10 | knowledge tickets and issues, including public and private responses and searching.''', 11 | version = version, 12 | author = 'Bryan Helmig', 13 | author_email = 'bryan@zapier.com', 14 | url = 'http://github.com/zapier/django-knowledge', 15 | install_requires=['Markdown>=2.1.1','Django>=1.4'], 16 | packages=['knowledge'], 17 | package_data={'knowledge': [ 18 | 'migrations/*.py', 19 | 'static/knowledge/css/*', 20 | 'templates/django_knowledge/*.html', 21 | 'templates/django_knowledge/emails/*.html', 22 | 'templatetags/*.py']}, 23 | classifiers = ['Development Status :: 3 - Alpha', 24 | 'Environment :: Web Environment', 25 | 'Framework :: Django', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: BSD License', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python', 30 | 'Topic :: Utilities'], 31 | ) -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/tests/__init__.py -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | import imp 4 | try: 5 | imp.find_module('settings') # Assumed to be in the same directory. 6 | except ImportError: 7 | import sys 8 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__) 9 | sys.exit(1) 10 | 11 | import settings 12 | 13 | if __name__ == "__main__": 14 | execute_manager(settings) 15 | -------------------------------------------------------------------------------- /tests/migrate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | export PYTHONPATH="./" 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | TARGET=$DIR"/manage.py" 6 | 7 | python $TARGET migrate --pythonpath="../" -------------------------------------------------------------------------------- /tests/mock/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/tests/mock/__init__.py -------------------------------------------------------------------------------- /tests/mock/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-knowledge/63038cb416aebcca0aba4caeece012221b98a46c/tests/mock/models.py -------------------------------------------------------------------------------- /tests/mock/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | # thanks https://github.com/tomchristie/django-rest-framework/blob/master/djangorestframework/tests/__init__.py 3 | 4 | modules = [filename.rsplit('.', 1)[0] 5 | for filename in os.listdir(os.path.dirname(__file__)) 6 | if filename.endswith('.py') and not filename.startswith('_')] 7 | __test__ = dict() 8 | 9 | for module in modules: 10 | exec("from mock.tests.%s import __doc__ as module_doc" % module) 11 | exec("from mock.tests.%s import *" % module) 12 | __test__[module] = module_doc or "" 13 | -------------------------------------------------------------------------------- /tests/mock/tests/base.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase as DjangoTestCase 2 | from django.contrib.auth.models import User, AnonymousUser 3 | 4 | from knowledge.models import Question, Response 5 | 6 | 7 | class TestCase(DjangoTestCase): 8 | def setUp(self): 9 | self.admin = User.objects.create_superuser('admin', 'admin@example.com', 'secret') 10 | self.joe = User.objects.create_user('joe', 'joedirt@example.com', 'secret') 11 | self.bob = User.objects.create_user('bob', 'bob@example.com', 'secret') 12 | self.anon = AnonymousUser() 13 | 14 | self.joe.first_name = 'Joe' 15 | self.joe.last_name = 'Dirt' 16 | self.joe.save() 17 | 18 | ## joe asks a question ## 19 | self.question = Question.objects.create( 20 | user = self.joe, 21 | title = 'What time is it?', 22 | body = 'Whenever I look at my watch I see the little hand at 3 and the big hand at 7.' 23 | ) 24 | 25 | ## admin responds ## 26 | self.response = Response.objects.create( 27 | question = self.question, 28 | user = self.admin, 29 | body = 'The little hand at 3 means 3 pm or am, the big hand at 7 means 3:35 am or pm.' 30 | ) -------------------------------------------------------------------------------- /tests/mock/tests/forms.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, AnonymousUser 2 | from django.core.urlresolvers import reverse 3 | from django.template.defaultfilters import slugify 4 | 5 | from mock.tests.base import TestCase 6 | from knowledge.models import Question, Response 7 | from knowledge.forms import QuestionForm, ResponseForm 8 | 9 | 10 | class BasicFormTest(TestCase): 11 | """ 12 | This tests reflect our defaults... namely KNOWLEDGE_ALLOW_ANONYMOUS, 13 | KNOWLEDGE_AUTO_PUBLICIZE, and KNOWLEDGE_FREE_RESPONSE. 14 | """ 15 | 16 | def test_question_form_display(self): 17 | self.assertEqual( 18 | None, 19 | QuestionForm(self.anon) 20 | ) 21 | 22 | self.assertNotEqual( 23 | None, 24 | QuestionForm(self.joe) 25 | ) 26 | 27 | self.assertNotEqual( 28 | None, 29 | ResponseForm(self.admin, self.question) 30 | ) 31 | 32 | def test_response_form_display(self): 33 | self.assertEqual( 34 | None, 35 | ResponseForm(self.anon, self.question) 36 | ) 37 | 38 | self.assertNotEqual( 39 | None, 40 | ResponseForm(self.joe, self.question) 41 | ) 42 | 43 | self.assertNotEqual( 44 | None, 45 | ResponseForm(self.admin, self.question) 46 | ) 47 | 48 | # the default is to let others comment on 49 | # questions, even if they aren't staff and 50 | # didn't ask the question (KNOWLEDGE_FREE_RESPONSE) 51 | self.assertNotEqual( 52 | None, 53 | ResponseForm(self.bob, self.question) 54 | ) 55 | 56 | # lock the question... 57 | self.question.lock() 58 | 59 | self.assertEqual( 60 | None, 61 | ResponseForm(self.admin, self.question) 62 | ) 63 | 64 | def test_form_saving(self): 65 | QUESTION_POST = { 66 | 'title': 'This is a title friend!', 67 | 'body': 'This is the body friend!', 68 | 'status': 'private' 69 | } 70 | 71 | form = QuestionForm(self.joe, QUESTION_POST) 72 | 73 | self.assertTrue(form.is_valid()) 74 | 75 | question = form.save() 76 | 77 | self.assertEquals(question.status, 'private') 78 | self.assertEquals(question.name, None) 79 | self.assertEquals(question.email, None) 80 | self.assertEquals(question.title, 'This is a title friend!') 81 | self.assertEquals(question.body, 'This is the body friend!') 82 | self.assertEquals(question.user, self.joe) 83 | 84 | 85 | RESPONSE_POST = { 86 | 'body': 'This is the response body friend!' 87 | } 88 | 89 | form = ResponseForm(self.joe, question, RESPONSE_POST) 90 | 91 | self.assertTrue(form.is_valid()) 92 | 93 | response = form.save() 94 | 95 | self.assertEquals(response.status, 'inherit') 96 | self.assertEquals(response.name, None) 97 | self.assertEquals(response.email, None) 98 | self.assertEquals(response.body, 'This is the response body friend!') 99 | self.assertEquals(response.user, self.joe) 100 | 101 | def test_form_question_status(self): 102 | # test the default for anonymous in tests/settings.py... 103 | form = QuestionForm(self.joe) 104 | self.assertIn('status', form.fields.keys()) 105 | 106 | # internal is only selectable for admins 107 | QUESTION_POST = { 108 | 'title': 'This is a title friend!', 109 | 'body': 'This is the body friend!', 110 | 'status': 'internal' 111 | } 112 | 113 | self.assertFalse(QuestionForm(self.joe, QUESTION_POST).is_valid()) 114 | self.assertTrue(QuestionForm(self.admin, QUESTION_POST).is_valid()) 115 | 116 | QUESTION_POST = { 117 | 'title': 'This is a title friend!', 118 | 'body': 'This is the body friend!', 119 | 'status': 'public' 120 | } 121 | question = QuestionForm(self.joe, QUESTION_POST).save() 122 | self.assertEquals(question.status, 'public') 123 | -------------------------------------------------------------------------------- /tests/mock/tests/managers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, AnonymousUser 2 | 3 | from mock.tests.base import TestCase 4 | from knowledge.models import Question, Response 5 | 6 | Q = Question.objects 7 | R = Response.objects 8 | 9 | 10 | class BasicMangerTest(TestCase): 11 | def test_question_qs(self): 12 | # the auto generated question tests are private by default 13 | self.assertEquals(0, Q.can_view(self.anon).count()) 14 | self.assertEquals(0, Q.can_view(self.bob).count()) 15 | 16 | self.assertEquals(1, Q.can_view(self.joe).count()) 17 | self.assertEquals(1, Q.can_view(self.admin).count()) 18 | 19 | 20 | ## someone comes along and publicizes this question ## 21 | self.question.public() 22 | 23 | # everyone can see 24 | self.assertEquals(1, Q.can_view(self.anon).count()) 25 | self.assertEquals(1, Q.can_view(self.bob).count()) 26 | self.assertEquals(1, Q.can_view(self.joe).count()) 27 | self.assertEquals(1, Q.can_view(self.admin).count()) 28 | 29 | 30 | ## someone comes along and privatizes this question ## 31 | self.question.private() 32 | 33 | self.assertEquals(0, Q.can_view(self.anon).count()) 34 | self.assertEquals(0, Q.can_view(self.bob).count()) 35 | 36 | self.assertEquals(1, Q.can_view(self.joe).count()) 37 | self.assertEquals(1, Q.can_view(self.admin).count()) 38 | 39 | def test_generic_response_qs(self): 40 | # the auto generated response tests are inherit 41 | # (private by question's status) by default 42 | self.assertEquals(0, R.can_view(self.anon).count()) 43 | self.assertEquals(0, R.can_view(self.bob).count()) 44 | 45 | self.assertEquals(1, R.can_view(self.joe).count()) 46 | self.assertEquals(1, R.can_view(self.admin).count()) 47 | 48 | 49 | ## someone comes along and publicizes this response ## 50 | self.response.public() 51 | 52 | # everyone can see 53 | self.assertEquals(1, R.can_view(self.anon).count()) 54 | self.assertEquals(1, R.can_view(self.bob).count()) 55 | self.assertEquals(1, R.can_view(self.joe).count()) 56 | self.assertEquals(1, R.can_view(self.admin).count()) 57 | 58 | 59 | ## someone comes along and internalizes this response ## 60 | self.response.internal() 61 | 62 | # only admin can see 63 | self.assertEquals(0, R.can_view(self.anon).count()) 64 | self.assertEquals(0, R.can_view(self.bob).count()) 65 | self.assertEquals(0, R.can_view(self.joe).count()) 66 | 67 | self.assertEquals(1, R.can_view(self.admin).count()) 68 | 69 | 70 | ## someone comes along and privatizes this response ## 71 | self.response.private() 72 | 73 | self.assertEquals(0, R.can_view(self.anon).count()) 74 | self.assertEquals(0, R.can_view(self.bob).count()) 75 | 76 | self.assertEquals(1, R.can_view(self.joe).count()) 77 | self.assertEquals(1, R.can_view(self.admin).count()) -------------------------------------------------------------------------------- /tests/mock/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, AnonymousUser 2 | from django.core.urlresolvers import reverse 3 | from django.template.defaultfilters import slugify 4 | from django.core import mail 5 | 6 | from mock.tests.base import TestCase 7 | from knowledge.models import Question, Response 8 | 9 | 10 | class BasicModelTest(TestCase): 11 | def test_basic_question_answering(self): 12 | """ 13 | Given a question asked by a real user, track answering and accepted states. 14 | """ 15 | 16 | ## joe asks a question ## 17 | question = Question.objects.create( 18 | user = self.joe, 19 | title = 'What time is it?', 20 | body = 'Whenever I look at my watch I see the little hand at 3 and the big hand at 7.' 21 | ) 22 | 23 | self.assertFalse(question.answered()) 24 | self.assertFalse(question.accepted()) 25 | 26 | ## admin responds ## 27 | response = Response.objects.create( 28 | question = question, 29 | user = self.admin, 30 | body = 'The little hand at 3 means 3 pm or am, the big hand at 7 means 3:35 am or pm.' 31 | ) 32 | 33 | self.assertTrue(question.answered()) 34 | self.assertFalse(question.accepted()) 35 | 36 | 37 | ## joe accepts the answer ## 38 | question.accept(response) 39 | 40 | self.assertTrue(question.answered()) 41 | self.assertTrue(question.accepted()) 42 | self.assertIn('accept', response.states()) 43 | 44 | ## someone clears the accepted answer ## 45 | question.accept() 46 | 47 | self.assertFalse(question.accepted()) 48 | 49 | response = Response.objects.get(id=response.id) # reload 50 | self.assertNotIn('accept', response.states()) 51 | 52 | ## someone used the response accept shortcut ## 53 | response.accept() 54 | 55 | question = Question.objects.get(id=question.id) # reload 56 | self.assertTrue(question.answered()) 57 | self.assertTrue(question.accepted()) 58 | self.assertIn('accept', response.states()) 59 | 60 | 61 | 62 | def test_switching_question(self): 63 | ## joe asks a question ## 64 | question = self.question 65 | self.assertEquals(question.status, 'private') 66 | self.assertIn('private', question.states()) 67 | 68 | question.public() 69 | self.assertEquals(question.status, 'public') 70 | self.assertIn('public', question.states()) 71 | 72 | question.private() 73 | self.assertEquals(question.status, 'private') 74 | self.assertIn('private', question.states()) 75 | 76 | # no change 77 | question.inherit() 78 | self.assertEquals(question.status, 'private') 79 | self.assertIn('private', question.states()) 80 | question.internal() 81 | self.assertEquals(question.status, 'private') 82 | self.assertIn('private', question.states()) 83 | 84 | 85 | def test_switching_response(self): 86 | ## joe asks a question ## 87 | response = self.response 88 | self.assertEquals(response.status, 'inherit') 89 | self.assertIn('inherit', response.states()) 90 | 91 | response.public() 92 | self.assertEquals(response.status, 'public') 93 | self.assertIn('public', response.states()) 94 | 95 | response.internal() 96 | self.assertEquals(response.status, 'internal') 97 | self.assertIn('internal', response.states()) 98 | 99 | response.private() 100 | self.assertEquals(response.status, 'private') 101 | self.assertIn('private', response.states()) 102 | 103 | response.inherit() 104 | self.assertEquals(response.status, 'inherit') 105 | self.assertIn('inherit', response.states()) 106 | 107 | 108 | def test_private_states(self): 109 | """ 110 | Walk through the public, private and internal states for Question, and public, private, 111 | inherit and internal states for Response. 112 | 113 | Then checks who can see what with .can_view(). 114 | """ 115 | 116 | ## joe asks a question ## 117 | question = self.question 118 | 119 | self.assertFalse(question.can_view(self.anon)) 120 | self.assertFalse(question.can_view(self.bob)) 121 | 122 | self.assertTrue(question.can_view(self.joe)) 123 | self.assertTrue(question.can_view(self.admin)) 124 | 125 | 126 | ## someone comes along and publicizes this question ## 127 | question.public() 128 | 129 | # everyone can see 130 | self.assertTrue(question.can_view(self.anon)) 131 | self.assertTrue(question.can_view(self.bob)) 132 | 133 | self.assertTrue(question.can_view(self.joe)) 134 | self.assertTrue(question.can_view(self.admin)) 135 | 136 | 137 | ## someone comes along and privatizes this question ## 138 | question.private() 139 | 140 | self.assertFalse(question.can_view(self.anon)) 141 | self.assertFalse(question.can_view(self.bob)) 142 | 143 | self.assertTrue(question.can_view(self.joe)) 144 | self.assertTrue(question.can_view(self.admin)) 145 | 146 | 147 | ## admin responds ## 148 | response = self.response 149 | response.inherit() 150 | 151 | self.assertFalse(response.can_view(self.anon)) 152 | self.assertFalse(response.can_view(self.bob)) 153 | 154 | self.assertTrue(response.can_view(self.joe)) 155 | self.assertTrue(response.can_view(self.admin)) 156 | 157 | 158 | ## someone comes along and publicizes the parent question ## 159 | question.public() 160 | 161 | self.assertTrue(response.can_view(self.anon)) 162 | self.assertTrue(response.can_view(self.bob)) 163 | self.assertTrue(response.can_view(self.joe)) 164 | self.assertTrue(response.can_view(self.admin)) 165 | 166 | 167 | ## someone privatizes the response ## 168 | response.private() 169 | 170 | # everyone can see question still 171 | self.assertTrue(question.can_view(self.anon)) 172 | self.assertTrue(question.can_view(self.bob)) 173 | self.assertTrue(question.can_view(self.joe)) 174 | self.assertTrue(question.can_view(self.admin)) 175 | 176 | # only joe and admin can see the response though 177 | self.assertFalse(response.can_view(self.anon)) 178 | self.assertFalse(response.can_view(self.bob)) 179 | 180 | self.assertTrue(response.can_view(self.joe)) 181 | self.assertTrue(response.can_view(self.admin)) 182 | 183 | 184 | ## someone internalizes the response ## 185 | response.internal() 186 | 187 | # everyone can see question still 188 | self.assertTrue(question.can_view(self.anon)) 189 | self.assertTrue(question.can_view(self.bob)) 190 | self.assertTrue(question.can_view(self.joe)) 191 | self.assertTrue(question.can_view(self.admin)) 192 | 193 | # only admin can see the response though 194 | self.assertFalse(response.can_view(self.anon)) 195 | self.assertFalse(response.can_view(self.bob)) 196 | self.assertFalse(response.can_view(self.joe)) 197 | 198 | self.assertTrue(response.can_view(self.admin)) 199 | 200 | 201 | def test_get_responses(self): 202 | """ 203 | Ensures adding another response isn't crossed into other responses. 204 | """ 205 | self.assertEquals(len(self.question.get_responses(self.anon)), 0) 206 | self.assertEquals(len(self.question.get_responses(self.joe)), 1) 207 | self.assertEquals(len(self.question.get_responses(self.admin)), 1) 208 | 209 | question = Question.objects.create( 210 | title = 'Where is my cat?', 211 | body = 'His name is whiskers.', 212 | user = self.joe 213 | ) 214 | response = Response.objects.create( 215 | question = question, 216 | user = self.admin, 217 | body = 'I saw him in the backyard.' 218 | ) 219 | 220 | self.assertEquals(len(self.question.get_responses(self.anon)), 0) 221 | self.assertEquals(len(self.question.get_responses(self.joe)), 1) 222 | self.assertEquals(len(self.question.get_responses(self.admin)), 1) 223 | 224 | self.assertEqual(len(mail.outbox), 0) 225 | 226 | 227 | def test_get_public_responses(self): 228 | """ 229 | Bug mentioned in issue #25. 230 | """ 231 | question = Question.objects.create( 232 | title = 'Where is my cat?', 233 | body = 'His name is whiskers.', 234 | user = self.joe, 235 | status = 'public' 236 | 237 | ) 238 | response = Response.objects.create( 239 | question = question, 240 | user = self.admin, 241 | body = 'I saw him in the backyard.', 242 | status = 'inherit' 243 | ) 244 | 245 | self.assertEquals(len(question.get_responses(self.anon)), 1) 246 | self.assertEquals(len(question.get_responses(self.joe)), 1) 247 | self.assertEquals(len(question.get_responses(self.admin)), 1) 248 | 249 | self.assertEqual(len(mail.outbox), 0) 250 | 251 | 252 | def test_urls(self): 253 | question_url = reverse('knowledge_thread', args=[self.question.id, slugify(self.question.title)]) 254 | 255 | self.assertEquals(self.question.url, question_url) 256 | 257 | 258 | def test_locking(self): 259 | self.assertFalse(self.question.locked) 260 | self.assertNotIn('lock', self.question.states()) 261 | 262 | self.question.lock() 263 | 264 | self.assertTrue(self.question.locked) 265 | self.assertIn('lock', self.question.states()) 266 | 267 | 268 | def test_url(self): 269 | self.assertEquals( 270 | '/knowledge/questions/{0}/{1}/'.format( 271 | self.question.id, 272 | slugify(self.question.title)), 273 | self.question.get_absolute_url() 274 | ) 275 | 276 | def test_normal_question(self): 277 | self.assertEquals(self.question.get_name(), 'Joe Dirt') 278 | self.assertEquals(self.question.get_email(), 'joedirt@example.com') 279 | 280 | question = Question.objects.create( 281 | title = 'Where is my cat?', 282 | body = 'His name is whiskers.', 283 | user = self.bob 284 | ) 285 | 286 | self.assertEquals(question.get_name(), 'bob') # no first/last 287 | self.assertEquals(question.get_email(), 'bob@example.com') 288 | 289 | 290 | def test_anon_question(self): 291 | question = Question.objects.create( 292 | title = 'Where is my cat?', 293 | body = 'His name is whiskers.', 294 | name = 'Joe Dirt', 295 | email = 'joedirt@example.com' 296 | ) 297 | 298 | self.assertEquals(question.get_name(), 'Joe Dirt') 299 | self.assertEquals(question.get_email(), 'joedirt@example.com') 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | -------------------------------------------------------------------------------- /tests/mock/tests/sample.py: -------------------------------------------------------------------------------- 1 | from mock.tests.base import TestCase 2 | 3 | 4 | class SimpleTest(TestCase): 5 | def test_basic_addition(self): 6 | """ 7 | Tests that 1 + 1 always equals 2. 8 | """ 9 | self.assertEqual(1 + 1, 2) 10 | 11 | def test_basic_subtraction(self): 12 | """ 13 | Tests that 5 - 3 always equals 2. 14 | """ 15 | self.assertEqual(5 - 3, 2) -------------------------------------------------------------------------------- /tests/mock/tests/settings.py: -------------------------------------------------------------------------------- 1 | from mock.tests.base import TestCase 2 | 3 | from django.test.client import Client 4 | from django.contrib.auth.models import User 5 | from django.core.urlresolvers import reverse 6 | from django.template.defaultfilters import slugify 7 | 8 | from knowledge import settings 9 | from knowledge.models import Question, Response 10 | from knowledge.forms import QuestionForm, ResponseForm 11 | 12 | 13 | class BasicSettingsTest(TestCase): 14 | def test_ALLOW_ANONYMOUS(self): 15 | self.assertFalse(settings.ALLOW_ANONYMOUS) 16 | 17 | self.assertEqual( 18 | None, 19 | QuestionForm(self.anon) 20 | ) 21 | 22 | self.assertEqual( 23 | None, 24 | ResponseForm(self.anon, self.question) 25 | ) 26 | 27 | ############# flip setting ############## 28 | settings.ALLOW_ANONYMOUS = not settings.ALLOW_ANONYMOUS 29 | ############# flip setting ############## 30 | 31 | self.assertNotEqual( 32 | None, 33 | QuestionForm(self.anon) 34 | ) 35 | 36 | self.assertNotEqual( 37 | None, 38 | ResponseForm(self.anon, self.question) 39 | ) 40 | 41 | form = QuestionForm(self.anon) 42 | self.assertNotIn('status', form.fields.keys()) 43 | 44 | # missing the name/email... 45 | QUESTION_POST = { 46 | 'title': 'This is a title friend!', 47 | 'body': 'This is the body friend!' 48 | } 49 | 50 | form = QuestionForm(self.anon, QUESTION_POST) 51 | self.assertFalse(form.is_valid()) 52 | 53 | 54 | QUESTION_POST = { 55 | 'name': 'Test Guy', 56 | 'email': 'anonymous@example.com', 57 | 'title': 'This is a title friend!', 58 | 'body': 'This is the body friend!' 59 | } 60 | 61 | form = QuestionForm(self.anon, QUESTION_POST) 62 | self.assertTrue(form.is_valid()) 63 | 64 | question = form.save() 65 | 66 | # question has no user and is public by default 67 | self.assertFalse(question.user) 68 | self.assertEquals(question.name, 'Test Guy') 69 | self.assertEquals(question.email, 'anonymous@example.com') 70 | self.assertEquals(question.status, 'public') 71 | 72 | ############# flip setting ############## 73 | settings.ALLOW_ANONYMOUS = not settings.ALLOW_ANONYMOUS 74 | ############# flip setting ############## 75 | 76 | 77 | def test_AUTO_PUBLICIZE(self): 78 | self.assertFalse(settings.AUTO_PUBLICIZE) 79 | 80 | QUESTION_POST = { 81 | 'title': 'This is a title friend!', 82 | 'body': 'This is the body friend!', 83 | 'status': 'private' 84 | } 85 | 86 | question = QuestionForm(self.joe, QUESTION_POST).save() 87 | self.assertEquals(question.status, 'private') 88 | 89 | ############# flip setting ############## 90 | settings.AUTO_PUBLICIZE = not settings.AUTO_PUBLICIZE 91 | ############# flip setting ############## 92 | 93 | question = QuestionForm(self.joe, QUESTION_POST).save() 94 | self.assertEquals(question.status, 'public') 95 | 96 | 97 | ############# flip setting ############## 98 | settings.AUTO_PUBLICIZE = not settings.AUTO_PUBLICIZE 99 | ############# flip setting ############## 100 | 101 | 102 | def test_FREE_RESPONSE(self): 103 | self.assertTrue(settings.FREE_RESPONSE) 104 | 105 | # joe authored the question, it is private so any user can respond... 106 | self.assertFalse(ResponseForm(self.anon, self.question)) 107 | self.assertTrue(ResponseForm(self.bob, self.question)) 108 | self.assertTrue(ResponseForm(self.joe, self.question)) 109 | self.assertTrue(ResponseForm(self.admin, self.question)) 110 | 111 | ############# flip setting ############## 112 | settings.FREE_RESPONSE = not settings.FREE_RESPONSE 113 | ############# flip setting ############## 114 | 115 | # ...now bob can't respond! 116 | self.assertFalse(ResponseForm(self.anon, self.question)) 117 | self.assertFalse(ResponseForm(self.bob, self.question)) 118 | self.assertTrue(ResponseForm(self.joe, self.question)) 119 | self.assertTrue(ResponseForm(self.admin, self.question)) 120 | 121 | ############# flip setting ############## 122 | settings.FREE_RESPONSE = not settings.FREE_RESPONSE 123 | ############# flip setting ############## 124 | 125 | 126 | def test_SLUG_URLS(self): 127 | self.assertTrue(settings.SLUG_URLS) 128 | 129 | c = Client() 130 | 131 | self.question.public() 132 | 133 | question_url = reverse('knowledge_thread', args=[self.question.id, slugify(self.question.title)]) 134 | 135 | r = c.get(reverse('knowledge_thread', args=[self.question.id, 'a-big-long-slug'])) 136 | self.assertEquals(r.status_code, 301) 137 | 138 | r = c.get(question_url) 139 | self.assertEquals(r.status_code, 200) 140 | 141 | ############# flip setting ############## 142 | settings.SLUG_URLS = not settings.SLUG_URLS 143 | ############# flip setting ############## 144 | 145 | r = c.get(reverse('knowledge_thread', args=[self.question.id, 'a-big-long-slug'])) 146 | self.assertEquals(r.status_code, 301) 147 | 148 | r = c.get(question_url) 149 | self.assertEquals(r.status_code, 301) 150 | 151 | r = c.get(reverse('knowledge_thread_no_slug', args=[self.question.id])) 152 | self.assertEquals(r.status_code, 200) 153 | 154 | ############# flip setting ############## 155 | settings.SLUG_URLS = not settings.SLUG_URLS 156 | ############# flip setting ############## 157 | -------------------------------------------------------------------------------- /tests/mock/tests/signals.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, AnonymousUser 2 | from django.core import mail 3 | 4 | from mock.tests.base import TestCase 5 | from knowledge.models import Question, Response 6 | from knowledge.forms import QuestionForm, ResponseForm 7 | from knowledge import settings 8 | 9 | 10 | class BasicSignalTest(TestCase): 11 | def setUp(self): 12 | self.assertFalse(settings.ALERTS) 13 | settings.ALERTS = not settings.ALERTS 14 | 15 | self.admin = User.objects.create_superuser('admin', 'admin@example.com', 'secret') 16 | self.joe = User.objects.create_user('joe', 'joedirt@example.com', 'secret') 17 | self.bob = User.objects.create_user('bob', 'bob@example.com', 'secret') 18 | self.anon = AnonymousUser() 19 | 20 | self.joe.first_name = 'Joe' 21 | self.joe.last_name = 'Dirt' 22 | self.joe.save() 23 | 24 | ## joe asks a question ## 25 | self.question = Question.objects.create( 26 | user = self.joe, 27 | title = 'What time is it?', 28 | body = 'Whenever I look at my watch I see the little hand at 3 and the big hand at 7.', 29 | alert = settings.ALERTS, 30 | ) 31 | 32 | ## admin responds ## 33 | self.response = Response.objects.create( 34 | question = self.question, 35 | user = self.admin, 36 | body = 'The little hand at 3 means 3 pm or am, the big hand at 7 means 3:35 am or pm.', 37 | alert = settings.ALERTS, 38 | ) 39 | mail.outbox = [] # reset 40 | 41 | 42 | def tearDown(self): 43 | self.assertTrue(settings.ALERTS) 44 | mail.outbox = [] 45 | settings.ALERTS = not settings.ALERTS 46 | super(BasicSignalTest, self).tearDown() 47 | 48 | 49 | def test_sending_alerts_dedupe(self): 50 | """ 51 | One question by joe, two responses by admin: bob responds. 52 | """ 53 | ######## SETUP 54 | self.assertTrue(settings.ALERTS) 55 | 56 | RESPONSE_POST = { 57 | 'body': 'This is the response body friend!', 58 | 'status': 'inherit', 59 | 'alert': settings.ALERTS, 60 | } 61 | 62 | # another admin response 63 | response = ResponseForm(self.admin, self.question, RESPONSE_POST).save() 64 | 65 | mail.outbox = [] # reset 66 | self.assertEqual(len(mail.outbox), 0) 67 | ######## TEARDOWN 68 | 69 | RESPONSE_POST = { 70 | 'body': 'This is the response body friend!', 71 | 'alert': settings.ALERTS, 72 | } 73 | 74 | # question is by joe, first response is by admin 75 | form = ResponseForm(self.bob, self.question, RESPONSE_POST) 76 | self.assertTrue(form.is_valid()) 77 | response = form.save() 78 | 79 | self.assertTrue(response.alert) 80 | self.assertEqual(len(mail.outbox), 2) # one for joe, one for admin 81 | 82 | 83 | def test_sending_alerts_normal(self): 84 | """ 85 | One question by joe, one response by admin: bob responds. 86 | """ 87 | self.assertTrue(settings.ALERTS) 88 | 89 | self.assertEqual(len(mail.outbox), 0) 90 | 91 | RESPONSE_POST = { 92 | 'body': 'This is the response body friend!', 93 | 'alert': settings.ALERTS, 94 | } 95 | 96 | # question is by joe, first response is by admin 97 | form = ResponseForm(self.bob, self.question, RESPONSE_POST) 98 | self.assertTrue(form.is_valid()) 99 | response = form.save() 100 | 101 | self.assertTrue(response.alert) 102 | self.assertEqual(len(mail.outbox), 2) # one for joe, one for admin 103 | 104 | 105 | def test_sending_alerts_remove_self(self): 106 | """ 107 | One question by joe, one response by admin: joe responds. 108 | """ 109 | self.assertTrue(settings.ALERTS) 110 | 111 | self.assertEqual(len(mail.outbox), 0) 112 | 113 | RESPONSE_POST = { 114 | 'body': 'This is the response body friend!', 115 | 'alert': settings.ALERTS, 116 | } 117 | 118 | # question is by joe, first response is by admin 119 | form = ResponseForm(self.joe, self.question, RESPONSE_POST) 120 | self.assertTrue(form.is_valid()) 121 | response = form.save() 122 | 123 | self.assertTrue(response.alert) 124 | self.assertEqual(len(mail.outbox), 1) # one for admin (not joe!) 125 | 126 | 127 | def test_sending_staffers(self): 128 | self.assertTrue(settings.ALERTS) 129 | 130 | self.assertEqual(len(mail.outbox), 0) 131 | 132 | QUESTION_POST = { 133 | 'title': 'This is a title friend!', 134 | 'body': 'This is the body friend!', 135 | 'status': 'private' 136 | } 137 | 138 | question = QuestionForm(self.joe, QUESTION_POST).save() 139 | self.assertEqual(len(mail.outbox), 1) # one for admin (not joe!) 140 | 141 | 142 | def test_sending_staffers_remove_self(self): 143 | self.assertTrue(settings.ALERTS) 144 | 145 | self.assertEqual(len(mail.outbox), 0) 146 | 147 | QUESTION_POST = { 148 | 'title': 'This is a title friend!', 149 | 'body': 'This is the body friend!', 150 | 'status': 'private' 151 | } 152 | 153 | question = QuestionForm(self.admin, QUESTION_POST).save() 154 | self.assertEqual(len(mail.outbox), 0) # none for admin 155 | -------------------------------------------------------------------------------- /tests/mock/tests/templatetags.py: -------------------------------------------------------------------------------- 1 | from django.test.client import RequestFactory 2 | 3 | from mock.tests.base import TestCase 4 | 5 | from knowledge.templatetags.knowledge_tags import get_gravatar, page_query 6 | 7 | 8 | class BasicTemplateTagTest(TestCase): 9 | def test_gravatar(self): 10 | self.assertEquals( 11 | 'https://secure.gravatar.com/avatar/883955996dbb79f38d8814dbfb336885.jpg?s=60&r=g&d=retro', 12 | get_gravatar('bryan@bryanhelmig.com', 60, 'g', 'retro') 13 | ) 14 | 15 | def test_page_query(self): 16 | request = RequestFactory().get('/faker/?something=extra&page=123') 17 | self.assertEquals('something=extra&page=666', page_query(request, 666)) 18 | 19 | request = RequestFactory().get('/faker/?something=extra') 20 | self.assertEquals('something=extra&page=666', page_query(request, 666)) -------------------------------------------------------------------------------- /tests/mock/tests/utils.py: -------------------------------------------------------------------------------- 1 | from mock.tests.base import TestCase 2 | 3 | from knowledge.utils import paginate, get_module 4 | 5 | 6 | class BasicPaginateTest(TestCase): 7 | def test_paginate_helper(self): 8 | paginator, objects = paginate(range(0,1000), 100, 'xcvb') 9 | self.assertEquals(objects.number, 1) # fall back to first page 10 | 11 | paginator, objects = paginate(range(0,1000), 100, 154543) 12 | self.assertEquals(objects.number, 10) # fall back to last page 13 | 14 | paginator, objects = paginate(range(0,1000), 100, 1) 15 | 16 | self.assertEquals(len(objects.object_list), 100) 17 | self.assertEquals(paginator.count, 1000) 18 | self.assertEquals(paginator.num_pages, 10) 19 | 20 | def test_importer_basic(self): 21 | from django.template.defaultfilters import slugify 22 | sluggy = get_module('django.template.defaultfilters.slugify') 23 | 24 | self.assertTrue(slugify is sluggy) 25 | 26 | def test_importer_fail(self): 27 | self.assertRaises(ImportError, get_module, 'django.notreal.america') 28 | self.assertRaises(ImportError, get_module, 'django.template.defaultfilters.slugbug') -------------------------------------------------------------------------------- /tests/mock/tests/views.py: -------------------------------------------------------------------------------- 1 | from mock.tests.base import TestCase 2 | 3 | from django.test.client import Client 4 | from django.contrib.auth.models import User, AnonymousUser 5 | 6 | from django.core.urlresolvers import reverse 7 | from django.template.defaultfilters import slugify 8 | 9 | from knowledge.models import Question, Response, Category 10 | 11 | 12 | class BasicViewTest(TestCase): 13 | def test_index(self): 14 | c = Client() 15 | 16 | r = c.get(reverse('knowledge_index')) 17 | self.assertEquals(r.status_code, 200) 18 | 19 | 20 | def test_list(self): 21 | c = Client() 22 | 23 | r = c.get(reverse('knowledge_list')) 24 | self.assertEquals(r.status_code, 200) 25 | 26 | 27 | def test_list_category(self): 28 | c = Client() 29 | 30 | r = c.get(reverse('knowledge_list_category', args=['notreal'])) 31 | self.assertEquals(r.status_code, 404) 32 | 33 | category = Category.objects.create(title='Hello!', slug='hello') 34 | 35 | r = c.get(reverse('knowledge_list_category', args=['hello'])) 36 | self.assertEquals(r.status_code, 200) 37 | 38 | 39 | def test_list_search(self): 40 | c = Client() 41 | 42 | r = c.get(reverse('knowledge_list') + '?title=hello!') 43 | self.assertEquals(r.status_code, 200) 44 | 45 | 46 | def test_thread(self): 47 | c = Client() 48 | 49 | question_url = reverse('knowledge_thread', args=[self.question.id, slugify(self.question.title)]) 50 | 51 | r = c.get(reverse('knowledge_thread', args=[123456, 'a-big-long-slug'])) 52 | self.assertEquals(r.status_code, 404) 53 | 54 | # this is private by default 55 | r = c.get(reverse('knowledge_thread', args=[self.question.id, 'a-big-long-slug'])) 56 | self.assertEquals(r.status_code, 404) 57 | 58 | r = c.get(question_url) 59 | self.assertEquals(r.status_code, 404) 60 | 61 | c.login(username='joe', password='secret') 62 | 63 | r = c.get(reverse('knowledge_thread', args=[self.question.id, 'a-big-long-slug'])) 64 | self.assertEquals(r.status_code, 301) 65 | 66 | r = c.get(question_url) 67 | self.assertEquals(r.status_code, 200) 68 | 69 | 70 | RESPONSE_POST = { 71 | 'body': 'This is the response body friend!' 72 | } 73 | 74 | r = c.post(question_url, RESPONSE_POST) 75 | self.assertEquals(r.status_code, 302) 76 | 77 | # back to an anon user 78 | c.logout() 79 | 80 | # lets make it public... 81 | self.question.public() 82 | 83 | r = c.get(question_url) 84 | self.assertEquals(r.status_code, 200) 85 | 86 | # invalid responses POSTs are basically ignored... 87 | r = c.post(question_url, RESPONSE_POST) 88 | self.assertEquals(r.status_code, 200) 89 | 90 | 91 | def test_moderate(self): 92 | c = Client() 93 | 94 | r = c.get(reverse('knowledge_moderate', args=['question', self.question.id, 'public'])) 95 | self.assertEquals(r.status_code, 404) 96 | 97 | r = c.post(reverse('knowledge_moderate', args=['question', self.question.id, 'public'])) 98 | self.assertEquals(r.status_code, 404) 99 | 100 | r = c.post(reverse('knowledge_moderate', args=['response', self.response.id, 'public'])) 101 | self.assertEquals(r.status_code, 404) 102 | 103 | 104 | c.login(username='admin', password='secret') 105 | 106 | r = c.post(reverse('knowledge_moderate', args=['question', self.question.id, 'notreal'])) 107 | self.assertEquals(r.status_code, 404) 108 | 109 | # nice try buddy! 110 | r = c.post(reverse('knowledge_moderate', args=['user', self.admin.id, 'delete'])) 111 | self.assertEquals(r.status_code, 404) 112 | 113 | # GET does not work 114 | r = c.get(reverse('knowledge_moderate', args=['question', self.question.id, 'public'])) 115 | self.assertEquals(r.status_code, 404) 116 | 117 | self.assertEquals(Question.objects.get(id=self.question.id).status, 'private') 118 | r = c.post(reverse('knowledge_moderate', args=['question', self.question.id, 'public'])) 119 | self.assertEquals(r.status_code, 302) 120 | self.assertEquals(Question.objects.get(id=self.question.id).status, 'public') 121 | 122 | r = c.post(reverse('knowledge_moderate', args=['response', self.response.id, 'public'])) 123 | self.assertEquals(r.status_code, 302) 124 | 125 | r = c.post(reverse('knowledge_moderate', args=['question', self.question.id, 'delete'])) 126 | self.assertEquals(r.status_code, 302) 127 | 128 | r = c.post(reverse('knowledge_moderate', args=['question', self.question.id, 'delete'])) 129 | self.assertEquals(r.status_code, 404) 130 | 131 | 132 | def test_ask(self): 133 | c = Client() 134 | 135 | r = c.get(reverse('knowledge_ask')) 136 | self.assertEquals(r.status_code, 200) 137 | 138 | QUESTION_POST = { 139 | 'title': 'This is a title friend!', 140 | 'body': 'This is the body friend!', 141 | 'status': 'private' 142 | } 143 | 144 | # invalid question POSTs are basically ignored... 145 | r = c.post(reverse('knowledge_ask'), QUESTION_POST) 146 | self.assertEquals(r.status_code, 200) 147 | 148 | c.login(username='joe', password='secret') 149 | 150 | # ...unless you are a user with permission to ask 151 | r = c.post(reverse('knowledge_ask'), QUESTION_POST) 152 | self.assertEquals(r.status_code, 302) -------------------------------------------------------------------------------- /tests/runserver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | export PYTHONPATH="./" 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | TARGET=$DIR"/manage.py" 6 | PORT='8080' 7 | 8 | python $TARGET runserver $PORT --pythonpath="../" -------------------------------------------------------------------------------- /tests/runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | export PYTHONPATH="./" 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | TARGET=$DIR"/manage.py" 6 | 7 | python $TARGET test mock knowledge --pythonpath="../" -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DIRNAME = os.path.dirname(__file__) 4 | 5 | DEBUG = True 6 | 7 | DATABASE_ENGINE = 'sqlite3' 8 | DATABASES = { 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.sqlite3', 11 | 'NAME': os.path.join(DIRNAME, 'example.sqlite').replace('\\','/'), 12 | 'USER': '', 13 | 'PASSWORD': '', 14 | 'HOST': '', 15 | 'PORT': '', 16 | } 17 | } 18 | 19 | STATIC_URL = '/static/' 20 | 21 | INTERNAL_IPS = ('127.0.0.1',) 22 | 23 | SITE_ID = 1 24 | SECRET_KEY = 'lolz' 25 | 26 | MIDDLEWARE_CLASSES = ( 27 | 'django.middleware.common.CommonMiddleware', 28 | 'django.contrib.sessions.middleware.SessionMiddleware', 29 | 'django.middleware.csrf.CsrfViewMiddleware', 30 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 31 | 'django.contrib.messages.middleware.MessageMiddleware', 32 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 33 | ) 34 | 35 | INSTALLED_APPS = ( 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.admin', 39 | 'django.contrib.sessions', 40 | 'django.contrib.sites', 41 | 'django.contrib.markup', 42 | 43 | 'debug_toolbar', 44 | 'knowledge', 45 | 'south', 46 | 'django_coverage', 47 | 'mock', 48 | ) 49 | 50 | ROOT_URLCONF = 'tests.urls' 51 | 52 | COVERAGE_REPORT_HTML_OUTPUT_DIR = os.path.join(DIRNAME, 'reports').replace('\\','/') 53 | 54 | TEMPLATE_DIRS = ( 55 | os.path.join(DIRNAME, 'templates').replace('\\','/') 56 | ) 57 | 58 | LOGIN_REDIRECT_URL = '/admin/' 59 | -------------------------------------------------------------------------------- /tests/syncdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | export PYTHONPATH="./" 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | TARGET=$DIR"/manage.py" 6 | 7 | python $TARGET syncdb --pythonpath="../" -------------------------------------------------------------------------------- /tests/templates/404.html: -------------------------------------------------------------------------------- 1 | 404! -------------------------------------------------------------------------------- /tests/templates/500.html: -------------------------------------------------------------------------------- 1 | 500! -------------------------------------------------------------------------------- /tests/updateschema.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | export PYTHONPATH="./" 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | TARGET=$DIR"/manage.py" 6 | 7 | python $TARGET schemamigration knowledge --auto --pythonpath="../" -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf.urls import patterns, include, url 4 | 5 | from django.contrib import admin 6 | admin.autodiscover() 7 | 8 | urlpatterns = patterns('', 9 | url(r'^admin/', include(admin.site.urls)), 10 | url(r'^knowledge/', include('knowledge.urls')), 11 | url(r'^static/(?P.*)$', 'django.views.static.serve', 12 | {'document_root': os.path.join( 13 | os.path.dirname(__file__), '../knowledge/static' 14 | ).replace('\\','/')}), 15 | ) 16 | --------------------------------------------------------------------------------