Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

importing

  • Loading branch information...
commit fe0d33ac4f2c3ba02656e8978a34aa704b17a172 0 parents
@pterk authored
Showing with 2,851 additions and 0 deletions.
  1. +8 −0 .gitignore
  2. +22 −0 LICENSE.txt
  3. +3 −0  MANIFEST.in
  4. +1 −0  README.rst
  5. +130 −0 docs/Makefile
  6. +216 −0 docs/conf.py
  7. +20 −0 docs/index.rst
  8. +170 −0 docs/make.bat
  9. +27 −0 setup.py
  10. +44 −0 tcc/__init__.py
  11. +4 −0 tcc/admin.py
  12. +207 −0 tcc/api.py
  13. +124 −0 tcc/forms.py
  14. +100 −0 tcc/locale/es/LC_MESSAGES/django.po
  15. +42 −0 tcc/locale/es/LC_MESSAGES/djangojs.po
  16. +100 −0 tcc/locale/nl/LC_MESSAGES/django.po
  17. +39 −0 tcc/locale/nl/LC_MESSAGES/djangojs.po
  18. +100 −0 tcc/locale/pt/LC_MESSAGES/django.po
  19. +42 −0 tcc/locale/pt/LC_MESSAGES/djangojs.po
  20. +51 −0 tcc/managers.py
  21. +260 −0 tcc/models.py
  22. +40 −0 tcc/settings.py
  23. +40 −0 tcc/static/tcc/css/tcc.css
  24. +227 −0 tcc/static/tcc/js/jquery.tcc.js
  25. +7 −0 tcc/templates/tcc/base.html
  26. +24 −0 tcc/templates/tcc/comment.html
  27. +6 −0 tcc/templates/tcc/index.html
  28. +128 −0 tcc/templates/tcc/list-comments.html
  29. +3 −0  tcc/templates/tcc/replies.html
  30. 0  tcc/templatetags/__init__.py
  31. +199 −0 tcc/templatetags/paginator.py
  32. +27 −0 tcc/templatetags/tcc_tags.py
  33. +251 −0 tcc/tests.py
  34. +24 −0 tcc/urls.py
  35. +165 −0 tcc/views.py
8 .gitignore
@@ -0,0 +1,8 @@
+*~
+*.pyc
+/TODO
+/docs/_build
+cleanup.sh
+/tcc.egg-info/*
+/tcc/locale/*/*/*.mo
+
22 LICENSE.txt
@@ -0,0 +1,22 @@
+Copyright (c) 2011 Peter van Kampen
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
3  MANIFEST.in
@@ -0,0 +1,3 @@
+include README.rst
+include LICENSE.txt
+recursive-include tcc/templates *
1  README.rst
@@ -0,0 +1 @@
+A django comments app.
130 docs/Makefile
@@ -0,0 +1,130 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = _build
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest
+
+help:
+ @echo "Please use \`make <target>' where <target> is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " singlehtml to make a single large HTML file"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " devhelp to make HTML files and a Devhelp project"
+ @echo " epub to make an epub"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " latexpdf to make LaTeX files and run them through pdflatex"
+ @echo " text to make text files"
+ @echo " man to make manual pages"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+ -rm -rf $(BUILDDIR)/*
+
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+ $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+ @echo
+ @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/projectnamename.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/projectnamename.qhc"
+
+devhelp:
+ $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+ @echo
+ @echo "Build finished."
+ @echo "To view the help file:"
+ @echo "# mkdir -p $$HOME/.local/share/devhelp/projectnamename"
+ @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/projectnamename"
+ @echo "# devhelp"
+
+epub:
+ $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+ @echo
+ @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make' in that directory to run these through (pdf)latex" \
+ "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through pdflatex..."
+ make -C $(BUILDDIR)/latex all-pdf
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+ $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+ @echo
+ @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+ $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+ @echo
+ @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
216 docs/conf.py
@@ -0,0 +1,216 @@
+# -*- coding: utf-8 -*-
+#
+# discus documentation build configuration file, created by
+# sphinx-quickstart on Tue Mar 29 21:59:29 2011.
+#
+# This file is execfile()d with the current directory set to its containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys, os
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#sys.path.insert(0, os.path.abspath('.'))
+
+# -- General configuration -----------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be extensions
+# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = []
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+#source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'discus'
+copyright = u'2011, Peter van Kampen'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = '0.1'
+# The full version, including alpha/beta/rc tags.
+release = '0.1'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+#today = ''
+# Else, today_fmt is used as the format for a strftime call.
+#today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# The reST default role (used for this markup: `text`) to use for all documents.
+#default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+
+# -- Options for HTML output ---------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+html_theme = 'default'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+#html_theme_path = []
+
+# The name for this set of Sphinx documents. If None, it defaults to
+# "<project> v<release> documentation".
+#html_title = None
+
+# A shorter title for the navigation bar. Default is the same as html_title.
+#html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+#html_logo = None
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+#html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+#html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+#html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+#html_additional_pages = {}
+
+# If false, no module index is generated.
+#html_domain_indices = True
+
+# If false, no index is generated.
+#html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+#html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+#html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it. The value of this option must be the
+# base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'discusdoc'
+
+
+# -- Options for LaTeX output --------------------------------------------------
+
+# The paper size ('letter' or 'a4').
+#latex_paper_size = 'letter'
+
+# The font size ('10pt', '11pt' or '12pt').
+#latex_font_size = '10pt'
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass [howto/manual]).
+latex_documents = [
+ ('index', 'discus.tex', u'discus Documentation',
+ u'Peter van Kampen', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+#latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+#latex_use_parts = False
+
+# If true, show page references after internal links.
+#latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#latex_show_urls = False
+
+# Additional stuff for the LaTeX preamble.
+#latex_preamble = ''
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_domain_indices = True
+
+
+# -- Options for manual page output --------------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ ('index', 'discus', u'discus Documentation',
+ [u'Peter van Kampen'], 1)
+]
20 docs/index.rst
@@ -0,0 +1,20 @@
+.. discus documentation master file, created by
+ sphinx-quickstart on Tue Mar 29 21:59:29 2011.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to discus's documentation!
+===========================================
+
+Contents:
+
+.. toctree::
+ :maxdepth: 2
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
170 docs/make.bat
@@ -0,0 +1,170 @@
+@ECHO OFF
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set BUILDDIR=_build
+set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
+if NOT "%PAPER%" == "" (
+ set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
+)
+
+if "%1" == "" goto help
+
+if "%1" == "help" (
+ :help
+ echo.Please use `make ^<target^>` where ^<target^> is one of
+ echo. html to make standalone HTML files
+ echo. dirhtml to make HTML files named index.html in directories
+ echo. singlehtml to make a single large HTML file
+ echo. pickle to make pickle files
+ echo. json to make JSON files
+ echo. htmlhelp to make HTML files and a HTML help project
+ echo. qthelp to make HTML files and a qthelp project
+ echo. devhelp to make HTML files and a Devhelp project
+ echo. epub to make an epub
+ echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
+ echo. text to make text files
+ echo. man to make manual pages
+ echo. changes to make an overview over all changed/added/deprecated items
+ echo. linkcheck to check all external links for integrity
+ echo. doctest to run all doctests embedded in the documentation if enabled
+ goto end
+)
+
+if "%1" == "clean" (
+ for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
+ del /q /s %BUILDDIR%\*
+ goto end
+)
+
+if "%1" == "html" (
+ %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/html.
+ goto end
+)
+
+if "%1" == "dirhtml" (
+ %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
+ goto end
+)
+
+if "%1" == "singlehtml" (
+ %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
+ goto end
+)
+
+if "%1" == "pickle" (
+ %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can process the pickle files.
+ goto end
+)
+
+if "%1" == "json" (
+ %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can process the JSON files.
+ goto end
+)
+
+if "%1" == "htmlhelp" (
+ %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can run HTML Help Workshop with the ^
+.hhp project file in %BUILDDIR%/htmlhelp.
+ goto end
+)
+
+if "%1" == "qthelp" (
+ %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can run "qcollectiongenerator" with the ^
+.qhcp project file in %BUILDDIR%/qthelp, like this:
+ echo.^> qcollectiongenerator %BUILDDIR%\qthelp\discus.qhcp
+ echo.To view the help file:
+ echo.^> assistant -collectionFile %BUILDDIR%\qthelp\discus.ghc
+ goto end
+)
+
+if "%1" == "devhelp" (
+ %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished.
+ goto end
+)
+
+if "%1" == "epub" (
+ %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The epub file is in %BUILDDIR%/epub.
+ goto end
+)
+
+if "%1" == "latex" (
+ %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
+ goto end
+)
+
+if "%1" == "text" (
+ %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The text files are in %BUILDDIR%/text.
+ goto end
+)
+
+if "%1" == "man" (
+ %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The manual pages are in %BUILDDIR%/man.
+ goto end
+)
+
+if "%1" == "changes" (
+ %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.The overview file is in %BUILDDIR%/changes.
+ goto end
+)
+
+if "%1" == "linkcheck" (
+ %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Link check complete; look for any errors in the above output ^
+or in %BUILDDIR%/linkcheck/output.txt.
+ goto end
+)
+
+if "%1" == "doctest" (
+ %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Testing of doctests in the sources finished, look at the ^
+results in %BUILDDIR%/doctest/output.txt.
+ goto end
+)
+
+:end
27 setup.py
@@ -0,0 +1,27 @@
+from setuptools import setup, find_packages
+
+version = '0.1'
+
+setup(
+ name='tcc',
+ version=version,
+ description="Simple but effective comments app",
+ #long_description=open('readme').read(),
+ keywords='',
+ author='Peter van Kampen',
+ author_email='pterk@datatailors.com',
+ url='',
+ license='BSD',
+ packages=find_packages(),
+ # namespace_packages=[],
+ include_package_data=True,
+ zip_safe=False,
+ classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Environment :: Web Environment',
+ 'Intended Audience :: Developers',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'Framework :: Django',
+ ],
+)
44 tcc/__init__.py
@@ -0,0 +1,44 @@
+__version__= '0.1'
+from django.core.urlresolvers import reverse
+
+from tcc.models import Comment
+from tcc.forms import CommentForm
+
+
+# django comment-app api
+def get_model():
+ return Comment
+
+
+def get_form():
+ return CommentForm
+
+
+def get_form_target():
+ return reverse('tcc_post')
+
+
+def get_flag_url(comment):
+ return reverse('tcc_flag', args=[comment.id])
+
+
+def get_delete_url(comment):
+ return reverse('tcc_remove', args=[comment.id])
+
+
+def get_approve_url(comment):
+ return reverse('tcc_approve', args=[comment.id])
+
+
+# extra methods
+def get_unflag_url(comment):
+ return reverse('tcc_unflag', args=[comment.id])
+
+
+def get_undelete_url(comment):
+ return reverse('tcc_restore', args=[comment.id])
+
+
+def get_disapprove_url(comment):
+ return reverse('tcc_disapprove', args=[comment.id])
+
4 tcc/admin.py
@@ -0,0 +1,4 @@
+from django.contrib import admin
+# from tcc.models import MyModel
+
+# admin.site.register(MyModel, MyModelAdmin)
207 tcc/api.py
@@ -0,0 +1,207 @@
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.core.exceptions import ObjectDoesNotExist
+
+from tcc.models import Comment
+
+SITE_ID = getattr(settings, 'SITE_ID', 1)
+
+
+def make_tree(comments):
+ """ Makes a python tree-structure with nested lists of objects
+
+ Loops the queryset
+
+ Large threads will consume quite a bit of memory
+ """
+ root = []
+ levels = []
+ for c in comments:
+ c.replies = []
+ level = c.depth
+ if c.parent:
+ while len(levels) > level:
+ levels.pop() # pragma: no cover
+ levels.append(c)
+ levels[level-1].replies.append(c)
+ else:
+ root.append(c)
+ levels = [c]
+ return root
+
+
+def print_tree(tree): # pragma: no cover
+ for n in tree:
+ print n.id, n.path, n.limit
+ print_tree(n.replies)
+
+
+def get_comments(content_type_id, object_pk, site_id=SITE_ID):
+ return Comment.objects.select_related(
+ 'user', 'userprofile').filter(content_type__id=content_type_id,
+ object_pk=object_pk,
+ site__id=site_id)
+
+
+def get_comments_limited(content_type_id, object_pk, site_id=SITE_ID):
+ return Comment.limited.filter(content_type__id=content_type_id,
+ object_pk=object_pk,
+ site__id=site_id
+ ).select_related('user', 'userprofile')
+
+
+def get_comments_as_tree(content_type_id, object_pk, site_id=SITE_ID):
+ return make_tree(get_comments(content_type_id=content_type_id,
+ object_pk=object_pk,
+ site_id=site_id))
+
+
+def get_comments_limited_as_tree(content_type_id, object_pk, site_id=SITE_ID):
+ return make_tree( # pragma: no cover
+ get_comments_limited(content_type_id=content_type_id,
+ object_pk=object_pk,
+ site_id=site_id))
+
+
+def get_comments_removed(content_type_id, object_pk, site_id=SITE_ID):
+ return Comment.removed.select_related('user').filter(
+ content_type__id=content_type_id, object_pk=object_pk, site__id=site_id)
+
+
+def get_comments_disapproved(content_type_id, object_pk, site_id=SITE_ID):
+ return Comment.disapproved.select_related('user').filter(
+ content_type__id=content_type_id, object_pk=object_pk, site__id=site_id)
+
+
+def post_comment(content_type_id, object_pk,
+ user_id, comment, parent_id=None, site_id=SITE_ID):
+ if parent_id:
+ parent = get_comment(parent_id)
+ if (not parent) or (not parent.is_open):
+ return None
+ c = Comment(
+ content_type_id=content_type_id, object_pk=object_pk, site_id=site_id,
+ user_id=user_id, comment=comment, parent_id=parent_id)
+ c.save()
+ return c
+
+
+def post_reply(parent_id, user_id, comment):
+ """ Shortcut for post_comment if there is a parent_id """
+ parent = get_comment(parent_id)
+ if (not parent) or (not parent.is_open) or (not parent.reply_allowed()):
+ return None
+ c = Comment(
+ content_type_id=parent.content_type_id, object_pk=parent.object_pk,
+ site_id=parent.site_id, user_id=user_id, comment=comment,
+ parent_id=parent_id)
+ c.save()
+ return c
+
+
+def get_comment(comment_id):
+ try:
+ return Comment.objects.select_related('user').get(id=comment_id)
+ except ObjectDoesNotExist:
+ return None
+
+
+def get_comment_thread(comment_id):
+ c = get_comment(comment_id)
+ if c:
+ return c.get_thread()
+
+
+def get_comment_replies(comment_id):
+ c = get_comment(comment_id)
+ if c:
+ return c.get_replies()
+
+
+def get_comment_parents(comment_id):
+ c = get_comment(comment_id)
+ if c:
+ return c.get_parents()
+
+
+def remove_comment(comment_id, user):
+ """ mark comment as removed """
+ c = get_comment(comment_id)
+ if c:
+ if not c.can_remove(user):
+ return None
+ c.is_removed = True
+ c.save()
+ return c
+
+
+def restore_comment(comment_id, user):
+ """ restore remove comment """
+ try:
+ c = Comment.unfiltered.get(id=comment_id)
+ if not c.can_restore(user):
+ return None
+ c.is_removed = False
+ c.save()
+ return c
+ except Comment.DoesNotExist:
+ return None
+
+
+def disapprove_comment(comment_id, user):
+ """ disapprove comment """
+ c = get_comment(comment_id)
+ if c:
+ if not c.can_disapprove(user):
+ return None
+ c.is_approved = False
+ c.save()
+ return c
+
+
+def approve_comment(comment_id, user):
+ """ approve comment """
+ try:
+ c = Comment.unfiltered.get(id=comment_id)
+ if not c.can_approve(user):
+ return None
+ c.is_approved = True
+ c.save()
+ return c
+ except Comment.DoesNotExist:
+ return None
+
+
+def open_comment(comment_id, user):
+ """ Mark comment 'open' (replies welcome) """
+ c = get_comment(comment_id)
+ if c:
+ if not c.can_open(user):
+ return None
+ c.is_open = True
+ c.save()
+ return c
+
+
+def close_comment(comment_id, user):
+ """ Mark a comment as closed (no more replies possible) """
+ c = get_comment(comment_id)
+ if c:
+ if not c.can_close(user):
+ return None
+ c.is_open = False
+ c.save()
+ return c
+
+
+def get_user_comments(user_id,
+ content_type_id=None, object_pk=None, site_id=None):
+ """ Returns all (approved, unremoved) comments by user """
+ extra = {}
+ if content_type_id:
+ extra['content_type__id'] = content_type_id
+ if object_pk:
+ extra['object_pk'] = object_pk
+ if site_id:
+ extra['site__id'] = site_id
+ return Comment.objects.filter(user__id=user_id, **extra)
124 tcc/forms.py
@@ -0,0 +1,124 @@
+import time
+
+from django import forms
+from django.conf import settings
+from django.utils.crypto import salted_hmac, constant_time_compare
+from django.utils.hashcompat import sha_constructor
+from django.utils.translation import ungettext, ugettext_lazy as _
+
+from tcc.models import Comment
+
+
+class CommentForm(forms.ModelForm):
+ """
+ Handles the security aspects (anti-spoofing) for comment forms.
+ """
+ timestamp = forms.IntegerField(widget=forms.HiddenInput)
+ security_hash = forms.CharField(min_length=40, max_length=40,
+ widget=forms.HiddenInput)
+ next = forms.CharField(widget=forms.HiddenInput, required=False)
+ honeypot = forms.CharField(
+ required=False,
+ label=_('If you enter anything in this field '\
+ 'your comment will be treated as spam'))
+
+ class Meta:
+ model = Comment
+ exclude = ['submit_date', 'is_open', 'is_removed', 'is_approved',
+ 'is_public', 'site', 'limit', 'path', 'user_name',
+ 'user_email', 'user_url', 'comment_raw', 'childcount', 'depth']
+ widgets = {
+ 'content_type': forms.HiddenInput,
+ 'object_pk': forms.HiddenInput,
+ 'user': forms.HiddenInput,
+ 'parent': forms.HiddenInput,
+ }
+
+ def __init__(self, target_object, data=None, initial=None):
+ self.target_object = target_object
+ if initial is None:
+ from django.contrib.contenttypes.models import ContentType
+ ct = ContentType.objects.get_for_model(target_object)
+ initial = {'content_type': ct.id}
+ self.content_type = initial['content_type']
+ initial.update(self.generate_security_data())
+ super(CommentForm, self).__init__(data=data, initial=initial)
+
+ def clean_honeypot(self):
+ """Check that nothing's been entered into the honeypot."""
+ value = self.cleaned_data["honeypot"]
+ if value:
+ raise forms.ValidationError(self.fields["honeypot"].label)
+ return value
+
+ def security_errors(self):
+ """Return just those errors associated with security"""
+ errors = ErrorDict()
+ for f in ["honeypot", "timestamp", "security_hash"]:
+ if f in self.errors:
+ errors[f] = self.errors[f]
+ return errors
+
+ def clean_security_hash(self):
+ """Check the security hash."""
+ security_hash_dict = {
+ 'content_type' : self.data.get("content_type", ""),
+ 'object_pk' : self.data.get("object_pk", ""),
+ 'timestamp' : self.data.get("timestamp", ""),
+ }
+ expected_hash = self.generate_security_hash(**security_hash_dict)
+ actual_hash = self.cleaned_data["security_hash"]
+ if not constant_time_compare(expected_hash, actual_hash):
+ # Fallback to Django 1.2 method for compatibility
+ # PendingDeprecationWarning <- here to remind us to remove this
+ # fallback in Django 1.5
+ expected_hash_old = self._generate_security_hash_old(**security_hash_dict)
+ if not constant_time_compare(expected_hash_old, actual_hash):
+ raise forms.ValidationError("Security hash check failed.")
+ return actual_hash
+
+ def clean_timestamp(self):
+ """Make sure the timestamp isn't too far (> 2 hours) in the past."""
+ ts = self.cleaned_data["timestamp"]
+ if time.time() - ts > (2 * 60 * 60):
+ raise forms.ValidationError("Timestamp check failed")
+ return ts
+
+ def generate_security_data(self):
+ """Generate a dict of security data for "initial" data."""
+ timestamp = int(time.time())
+ security_dict = {
+ 'content_type' : str(self.content_type),
+ 'object_pk' : str(self.target_object._get_pk_val()),
+ 'timestamp' : str(timestamp),
+ 'security_hash' : self.initial_security_hash(timestamp),
+ }
+ return security_dict
+
+ def initial_security_hash(self, timestamp):
+ """
+ Generate the initial security hash from self.content_object
+ and a (unix) timestamp.
+ """
+
+ initial_security_dict = {
+ 'content_type' : str(self.content_type),
+ 'object_pk' : str(self.target_object._get_pk_val()),
+ 'timestamp' : str(timestamp),
+ }
+ return self.generate_security_hash(**initial_security_dict)
+
+ def generate_security_hash(self, content_type, object_pk, timestamp):
+ """
+ Generate a HMAC security hash from the provided info.
+ """
+ info = (content_type, object_pk, timestamp)
+ key_salt = "django.contrib.forms.CommentSecurityForm"
+ value = "-".join(info)
+ return salted_hmac(key_salt, value).hexdigest()
+
+ def _generate_security_hash_old(self, content_type, object_pk, timestamp):
+ """Generate a (SHA1) security hash from the provided info."""
+ # Django 1.2 compatibility
+ info = (content_type, object_pk, timestamp, settings.SECRET_KEY)
+ return sha_constructor("".join(info)).hexdigest()
100 tcc/locale/es/LC_MESSAGES/django.po
@@ -0,0 +1,100 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2011-07-29 00:00+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+#: forms.py:22
+msgid ""
+"If you enter anything in this field your comment will be treated as spam"
+msgstr ""
+
+#: models.py:28 models.py:49
+msgid "content type"
+msgstr ""
+
+#: models.py:30 models.py:51
+msgid "object id"
+msgstr ""
+
+#: models.py:35 models.py:69
+msgid "Open"
+msgstr ""
+
+#: models.py:36
+msgid "Moderated"
+msgstr ""
+
+#: models.py:57
+msgid "Reply to"
+msgstr ""
+
+#: models.py:61
+msgid "user's name"
+msgstr ""
+
+#: models.py:62
+msgid "user's email address"
+msgstr ""
+
+#: models.py:63
+msgid "user's URL"
+msgstr ""
+
+#: models.py:64
+msgid "Date"
+msgstr ""
+
+#: models.py:66
+msgid "Comment"
+msgstr ""
+
+#: models.py:67
+msgid "Raw Comment"
+msgstr ""
+
+#: models.py:70
+msgid "Removed"
+msgstr ""
+
+#: models.py:71
+msgid "Approved"
+msgstr ""
+
+#: models.py:74
+msgid "Public"
+msgstr ""
+
+#: models.py:75
+msgid "Path"
+msgstr ""
+
+#: models.py:77
+msgid "Show replies from"
+msgstr ""
+
+#: models.py:79
+msgid "Reply count"
+msgstr ""
+
+#: models.py:80
+msgid "Depth"
+msgstr ""
+
+#: models.py:103
+msgid "Maximum number of replies reached"
+msgstr ""
42 tcc/locale/es/LC_MESSAGES/djangojs.po
@@ -0,0 +1,42 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2011-07-29 00:12+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+#: static/tcc/js/jquery.tcc.js:102
+msgid "just now"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:104
+msgid "today"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:108
+msgid "yesterday"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:111
+msgid " days ago"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:114
+msgid "last week"
+msgstr ""
+
+#~ msgid "You"
+#~ msgstr "Tu"
100 tcc/locale/nl/LC_MESSAGES/django.po
@@ -0,0 +1,100 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2011-07-29 00:00+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+#: forms.py:22
+msgid ""
+"If you enter anything in this field your comment will be treated as spam"
+msgstr ""
+
+#: models.py:28 models.py:49
+msgid "content type"
+msgstr ""
+
+#: models.py:30 models.py:51
+msgid "object id"
+msgstr ""
+
+#: models.py:35 models.py:69
+msgid "Open"
+msgstr ""
+
+#: models.py:36
+msgid "Moderated"
+msgstr ""
+
+#: models.py:57
+msgid "Reply to"
+msgstr ""
+
+#: models.py:61
+msgid "user's name"
+msgstr ""
+
+#: models.py:62
+msgid "user's email address"
+msgstr ""
+
+#: models.py:63
+msgid "user's URL"
+msgstr ""
+
+#: models.py:64
+msgid "Date"
+msgstr ""
+
+#: models.py:66
+msgid "Comment"
+msgstr ""
+
+#: models.py:67
+msgid "Raw Comment"
+msgstr ""
+
+#: models.py:70
+msgid "Removed"
+msgstr ""
+
+#: models.py:71
+msgid "Approved"
+msgstr ""
+
+#: models.py:74
+msgid "Public"
+msgstr ""
+
+#: models.py:75
+msgid "Path"
+msgstr ""
+
+#: models.py:77
+msgid "Show replies from"
+msgstr ""
+
+#: models.py:79
+msgid "Reply count"
+msgstr ""
+
+#: models.py:80
+msgid "Depth"
+msgstr ""
+
+#: models.py:103
+msgid "Maximum number of replies reached"
+msgstr ""
39 tcc/locale/nl/LC_MESSAGES/djangojs.po
@@ -0,0 +1,39 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2011-07-29 00:12+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+#: static/tcc/js/jquery.tcc.js:102
+msgid "just now"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:104
+msgid "today"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:108
+msgid "yesterday"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:111
+msgid " days ago"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:114
+msgid "last week"
+msgstr ""
100 tcc/locale/pt/LC_MESSAGES/django.po
@@ -0,0 +1,100 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2011-07-29 00:00+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+#: forms.py:22
+msgid ""
+"If you enter anything in this field your comment will be treated as spam"
+msgstr ""
+
+#: models.py:28 models.py:49
+msgid "content type"
+msgstr ""
+
+#: models.py:30 models.py:51
+msgid "object id"
+msgstr ""
+
+#: models.py:35 models.py:69
+msgid "Open"
+msgstr ""
+
+#: models.py:36
+msgid "Moderated"
+msgstr ""
+
+#: models.py:57
+msgid "Reply to"
+msgstr ""
+
+#: models.py:61
+msgid "user's name"
+msgstr ""
+
+#: models.py:62
+msgid "user's email address"
+msgstr ""
+
+#: models.py:63
+msgid "user's URL"
+msgstr ""
+
+#: models.py:64
+msgid "Date"
+msgstr ""
+
+#: models.py:66
+msgid "Comment"
+msgstr ""
+
+#: models.py:67
+msgid "Raw Comment"
+msgstr ""
+
+#: models.py:70
+msgid "Removed"
+msgstr ""
+
+#: models.py:71
+msgid "Approved"
+msgstr ""
+
+#: models.py:74
+msgid "Public"
+msgstr ""
+
+#: models.py:75
+msgid "Path"
+msgstr ""
+
+#: models.py:77
+msgid "Show replies from"
+msgstr ""
+
+#: models.py:79
+msgid "Reply count"
+msgstr ""
+
+#: models.py:80
+msgid "Depth"
+msgstr ""
+
+#: models.py:103
+msgid "Maximum number of replies reached"
+msgstr ""
42 tcc/locale/pt/LC_MESSAGES/djangojs.po
@@ -0,0 +1,42 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2011-07-29 00:12+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+#: static/tcc/js/jquery.tcc.js:102
+msgid "just now"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:104
+msgid "today"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:108
+msgid "yesterday"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:111
+msgid " days ago"
+msgstr ""
+
+#: static/tcc/js/jquery.tcc.js:114
+msgid "last week"
+msgstr ""
+
+#~ msgid "You"
+#~ msgstr "Você"
51 tcc/managers.py
@@ -0,0 +1,51 @@
+from django.db.models import Manager, F, Q
+
+from tcc.settings import CONTENT_TYPES
+
+
+class CurrentCommentManager(Manager):
+ """ Returns only approved comments that are not (marked as) removed
+
+ Also filters is_public == False for backwards compatibility
+
+ Also only returns comments whose CONTENT_TYPES are allowed
+ """
+ def get_query_set(self, *args, **kwargs):
+ return super(CurrentCommentManager, self).get_query_set(
+ *args, **kwargs).filter(
+ is_removed=False, is_approved=True, is_public=True,
+ content_type__id__in=CONTENT_TYPES
+ ).filter(
+ Q(parent__isnull=True) | \
+ Q(parent__is_removed=False,
+ parent__is_approved=True,
+ parent__is_public=True))
+
+
+class LimitedCurrentCommentManager(CurrentCommentManager):
+ def get_query_set(self, *args, **kwargs):
+ return super(LimitedCurrentCommentManager, self).get_query_set(
+ *args, **kwargs).filter(
+ Q(parent__isnull=True) | Q(parent__limit__lte=F('submit_date')))
+
+
+class RemovedCommentManager(Manager):
+ """ Returns onle comments marked as removed
+
+ To be able to unmark them...
+ """
+ def get_query_set(self, *args, **kwargs):
+ return super(RemovedCommentManager, self).get_query_set(
+ *args, **kwargs).filter(
+ is_removed=True)
+
+
+class DisapprovedCommentManager(Manager):
+ """ Returns disapproved (unremoved) comments
+
+ To be able to unmark them...
+ """
+ def get_query_set(self, *args, **kwargs):
+ return super(DisapprovedCommentManager, self).get_query_set(
+ *args, **kwargs).filter(
+ is_removed=False, is_approved=False)
260 tcc/models.py
@@ -0,0 +1,260 @@
+from datetime import datetime
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.contrib.contenttypes import generic
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.sites.models import Site
+from django.core.exceptions import ValidationError
+from django.core.urlresolvers import reverse, get_callable
+from django.db import models
+from django.template.defaultfilters import striptags
+from django.utils.http import base36_to_int, int_to_base36
+from django.utils.translation import ugettext_lazy as _
+
+from tcc.settings import (
+ STEPLEN, COMMENT_MAX_LENGTH, MODERATED, REPLY_LIMIT, CONTENT_TYPES,
+ MAX_DEPTH, MAX_REPLIES, ADMIN_CALLBACK
+ )
+from tcc.managers import (
+ CurrentCommentManager, LimitedCurrentCommentManager,
+ RemovedCommentManager, DisapprovedCommentManager,
+ )
+
+SITE_ID = getattr(settings, 'SITE_ID', 1)
+
+
+class Thread(models.Model):
+ content_type = models.ForeignKey(
+ ContentType, verbose_name=_('content type'),
+ related_name="content_type_set_for_tcc_thread")
+ object_pk = models.TextField(_('object id'))
+ content_object = generic.GenericForeignKey(
+ ct_field="content_type", fk_field="object_pk")
+ site = models.ForeignKey(Site, default=SITE_ID,
+ related_name='tccthreads')
+ is_open = models.BooleanField(_('Open'), default=True)
+ is_moderated = models.BooleanField(_('Moderated'), default=MODERATED)
+
+
+class Comment(models.Model):
+ """ A comment table, aimed to be compatible with django.contrib.comments
+
+ """
+ # constants
+ MAX_REPLIES = MAX_REPLIES
+ REPLY_LIMIT = REPLY_LIMIT
+
+ # From comments BaseCommentAbstractModel
+ content_type = models.ForeignKey(
+ ContentType, verbose_name=_('content type'),
+ related_name="content_type_set_for_tcc_comment")
+ object_pk = models.TextField(_('object id'))
+ content_object = generic.GenericForeignKey(
+ ct_field="content_type", fk_field="object_pk")
+ site = models.ForeignKey(Site, default=SITE_ID,
+ related_name='tcccomments')
+ # The actual comment fields
+ parent = models.ForeignKey('self', verbose_name= _('Reply to'),
+ null=True, blank=True, related_name='parents')
+ user = models.ForeignKey(User, verbose_name='Commenter')
+ # These are here mainly for backwards compatibility
+ user_name = models.CharField(_("user's name"), max_length=50, blank=True)
+ user_email = models.EmailField(_("user's email address"), blank=True)
+ user_url = models.URLField(_("user's URL"), blank=True)
+ submit_date = models.DateTimeField(_('Date'), default=datetime.utcnow)
+ # Protip: Use postgres...
+ comment = models.TextField(_('Comment'), max_length=COMMENT_MAX_LENGTH)
+ comment_raw = models.TextField(_('Raw Comment'), max_length=COMMENT_MAX_LENGTH)
+ # still accepting replies?
+ is_open = models.BooleanField(_('Open'), default=True)
+ is_removed = models.BooleanField(_('Removed'), default=False)
+ is_approved = models.BooleanField(_('Approved'), default=not MODERATED)
+ # is_public is rather pointless icw is_removed?
+ # Keeping it for compatibility w/ contrib.comments
+ is_public = models.BooleanField(_('Public'), default=True)
+ path = models.CharField(_('Path'), unique=True, max_length=MAX_DEPTH*STEPLEN)
+ limit = models.DateTimeField(
+ _('Show replies from'), null=True, blank=True)
+ # denormalized cache
+ childcount = models.IntegerField(_('Reply count'), default=0)
+ depth = models.IntegerField(_('Depth'), default=0)
+
+ unfiltered = models.Manager()
+ objects = CurrentCommentManager()
+ limited = LimitedCurrentCommentManager()
+ removed = RemovedCommentManager()
+ disapproved = DisapprovedCommentManager()
+
+ class Meta:
+ ordering = ['path']
+
+ def __unicode__(self):
+ return u"%05d %s % 8s: %s" % (
+ self.id, self.submit_date.isoformat(), self.user.username, self.comment[:20])
+
+ def get_absolute_url(self):
+ link = reverse('tcc_index',
+ args=(self.content_type.id, self.object_pk))
+ return "%s#%s" % (link, self.get_base36())
+
+ def clean(self):
+ if self.parent:
+ if not self.pk and self.parent.childcount >= self.MAX_REPLIES:
+ raise ValidationError(_('Maximum number of replies reached'))
+ if self.comment <> "" and striptags(self.comment).strip() == "":
+ raise ValidationError(_("This field is required."))
+
+ def get_root_path(self):
+ return self.path[0:STEPLEN]
+
+ # The following two methods may seem superfluous and/or convoluted
+ # but they get the root.id of any 'node' without hitting the
+ # database (again)
+ def get_root_id(self):
+ return base36_to_int(self.get_root_path())
+
+ def get_root_base36(self):
+ return int_to_base36(self.get_root_id())
+
+ def get_thread(self):
+ """ returns the entire 'thread' (a 'root' comment and all replies)
+
+ a root comment is a comment without a parent
+ """
+ return Comment.objects.filter(path__startswith=self.get_root_path())
+
+ def get_replies(self, levels=None, include_self=False):
+ if self.parent and self.parent.depth == MAX_DEPTH - 1:
+ return Comment.objects.none()
+ else:
+ replies = Comment.objects.filter(path__startswith=self.path)
+ if levels:
+ # 'z' is the highest value in base36 (as implemented in django)
+ replies = replies.filter(path__lte="%s%s" % (self.path, (levels * STEPLEN * 'z')))
+ if not include_self:
+ replies = replies.exclude(id=self.id)
+ return replies
+
+ def get_root(self):
+ if self.parent:
+ return Comment.objects.get(path=self.get_root_path())
+ return None
+
+ def get_parents(self):
+ if self.parent:
+ parentpaths = []
+ l = len(self.path)
+ for i in range(0, l, STEPLEN):
+ parentpaths.append(self.path[i:i+STEPLEN])
+ return Comment.objects.filter(path__in=parentpaths)
+ else:
+ return Comment.objects.none()
+
+ def has_changed(self, field):
+ """ Checks if a field has changed since the last save
+
+ http://zmsmith.com/2010/05/django-check-if-a-field-has-changed/
+ """
+ if not self.pk:
+ return False
+ old_value = self.__class__._default_manager.\
+ filter(pk=self.pk).values(field).get()[field]
+ return not getattr(self, field) == old_value
+
+ def save(self, *args, **kwargs):
+
+ if self.id:
+ is_new = False
+ else:
+ is_new = True
+
+ self.clean()
+
+ super(Comment, self).save(*args, **kwargs)
+
+ if is_new:
+ self._set_path()
+
+ if REPLY_LIMIT and self.parent:
+ self.parent.set_limit()
+
+ def delete(self, *args, **kwargs):
+ self.get_replies(include_self=True).delete()
+ super(Comment, self).delete(*args, **kwargs)
+ if self.parent:
+ self.parent.set_limit()
+
+ def set_limit(self):
+ replies = self.get_replies(levels=1).order_by('-submit_date')
+ n = replies.count()
+ self.childcount = n
+ if n == 0:
+ self.limit is None
+ elif n < REPLY_LIMIT:
+ self.limit = replies[0].submit_date
+ else:
+ self.limit = replies[REPLY_LIMIT-1].submit_date
+ self.save()
+
+ def get_depth(self):
+ return ( len(self.path) / STEPLEN ) - 1
+
+ def reply_allowed(self):
+ return self.is_open and self.childcount < self.MAX_REPLIES \
+ and ( self.depth < MAX_DEPTH - 1 )
+
+ def can_open(self, user):
+ return self.user == user
+
+ def can_close(self, user):
+ return self.user == user
+
+ def can_approve(self, user):
+ return self.user == user
+
+ def can_disapprove(self, user):
+ return self.user == user
+
+ def can_remove(self, user):
+ return self.user == user or user in self.get_enabled_users('remove')
+
+ def can_restore(self, user):
+ return self.user == user
+
+ def get_base36(self):
+ return int_to_base36(self.id)
+
+ def _set_path(self):
+ """ This will set the path to a base36 encoding of the comment-id
+
+ >>> 2**31
+ 2147483648
+ >>> 2**31 / 1000 / 1000
+ 2147
+
+ So 2**31 is enough for 1 million (1.000.000) comments daily for almost 6 (5.88) years
+
+ If you really need more check out django's BigIntegerField
+
+ >>> 2**63
+ 9223372036854775808L
+
+ """
+ if self.parent:
+ self.path = "%s%s" % (self.parent.path, self.get_base36().zfill(STEPLEN))
+ else:
+ self.path = "%s" % (self.get_base36().zfill(STEPLEN))
+
+ self.depth = self.get_depth()
+
+ self.save()
+
+ def get_enabled_users(self, action):
+ if not callable(ADMIN_CALLBACK):
+ return []
+ assert action in ['open', 'close', 'remove', 'restore',
+ 'approve', 'disapprove']
+ func = get_callable(ADMIN_CALLBACK)
+ return func(self, action)
+
40 tcc/settings.py
@@ -0,0 +1,40 @@
+from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.db import connection
+
+# Tree related
+MAX_DEPTH = getattr(settings, 'TCC_MAX_DEPTH', 2)
+REPLY_LIMIT = getattr(settings, 'TCC_REPLY_LIMIT', 3)
+MAX_REPLIES = getattr(settings, 'TCC_MAX_REPLIES', 50)
+STEPLEN = getattr(settings, 'TCC_STEPLEN', 6)
+# paginator stuff
+PER_PAGE = getattr(settings, 'PER_PAGE', 25)
+PAGE_WINDOW = getattr(settings, 'PAGE_WINDOW', 3)
+PAGE_ORPHANS = getattr(settings, 'PAGE_ORPHANS', REPLY_LIMIT+1)
+# special perms
+ADMIN_CALLBACK = getattr(settings, 'TCC_ADMIN_CALLBACK', None)
+# comment related
+COMMENT_MAX_LENGTH = getattr(settings,'COMMENT_MAX_LENGTH',3000)
+MODERATED = getattr(settings, 'TCC_MODERATE', False)
+TCC_CONTENT_TYPES = getattr(settings, 'TCC_CONTENT_TYPES', [])
+CONTENT_TYPES = []
+if connection.introspection.table_names() != []:
+ # syncdb has run -- can now assume django_content_type table exists
+ for label in TCC_CONTENT_TYPES:
+ ct = ContentType.objects.get_by_natural_key(*label.split("."))
+ CONTENT_TYPES.append(ct.id)
+
+
+# Wow ... weirdness occurs without the following monkeypatch for python2.6
+#
+# See http://stackoverflow.com/questions/5614741/cant-use-a-list-of-methods-in
+# (actually http://bugs.python.org/issue1515 -- but that's down a.t.m. )
+import sys
+if sys.version_info[:2] == (2, 6):
+ import copy
+ import types
+
+ def _deepcopy_method(x, memo):
+ return type(x)(x.im_func, copy.deepcopy(x.im_self, memo), x.im_class)
+ copy._deepcopy_dispatch[types.MethodType] = _deepcopy_method
+
40 tcc/static/tcc/css/tcc.css
@@ -0,0 +1,40 @@
+ul, li {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+#tcc li {
+ margin-bottom: 1em;
+ margin-top: 10px;
+ padding-top: 10px;
+ position: relative;
+ min-height: 3em;
+}
+
+#tcc .replies li {
+ margin-top: .5em;
+ padding-left: 3em;
+}
+
+#tcc .comment-remove, #tcc .comment-reply {
+ display: none;
+}
+
+#tcc form #id_honeypot {
+ display: none;
+}
+
+#tcc .remove-form {
+ color: red;
+ font-weight: bold;
+ margin: 1em 0;
+}
+
+#tcc ul.errors li {
+ margin: 0;
+ margin-top: 10px;
+ padding: 0;
+ color: red;
+ min-height: 0;
+}
227 tcc/static/tcc/js/jquery.tcc.js
@@ -0,0 +1,227 @@
+// closure
+(function($) {
+
+ // private vars (within the closure)
+ var opts;
+ var JSMINUTE = 60*1000; // milliseconds
+ var JSHOUR = 60*JSMINUTE
+ var JSDAY = 24*JSHOUR
+ //
+ // plugin definition
+ //
+ $.fn.tcc = function(options){
+ // build main options before element iteration
+ opts = $.extend({}, $.fn.tcc.defaults, options);
+ // iterate and reformat each matched element
+ return this.each(function(){
+ init();
+ });
+ };
+
+ // Default width and height for the editor
+ $.fn.tcc.defaults = {
+ user_id: null,
+ user_name: null,
+ };
+
+ //
+ // private function for debugging
+ //
+ function debug(msg) {
+ if(window.console && window.console.log){
+ window.console.log(msg);
+ };
+ };
+
+ function listErrors(frm, errors){
+ if($('ul.errors', frm).length == 0){
+ $(frm).prepend('<ul class="errors"/>');
+ };
+ $('ul.errors', frm).empty();
+ $.each(errors, function(field, msgs){
+ $.each(msgs, function(idx, msg){
+ var li = '<li>' + msg + '</li>';
+ $('ul.errors', frm).append(li);
+ });
+ });
+ };
+
+ function isScrolledIntoView(elem){
+ var docViewTop = $(window).scrollTop();
+ var docViewBottom = docViewTop + $(window).height();
+ var elemTop = $(elem).offset().top;
+ var elemBottom = elemTop + $(elem).height();
+ return ((elemBottom >= docViewTop) && (elemTop <= docViewBottom));
+ };
+
+ function apply_hooks(){
+
+ // highlight a thread
+ if(window.location.hash){
+ debug(window.location.hash);
+ $('a[name="' + window.location.hash.slice(1) +'"]').each(function(){
+ $(this).parent().addClass('highlight');
+ });
+ };
+
+ if(opts.user_name){
+ $('.c-user').filter(function(){
+ return $(this).text() == opts.user_name
+ }).text(gettext('You'));
+ };
+
+ function make_local_time(dte){
+ var offset = -1 * new Date().getTimezoneOffset();
+ return new Date(dte.valueOf()+(offset*JSMINUTE));
+ };
+
+ function is_nowish(dte){
+ // This is localtime (for the browser)
+ var now = new Date();
+ return (now-dte < JSMINUTE*5);
+ };
+
+ function date_format(dte){
+ var y=dte.getYear(), m=dte.getMonth(), d=dte.getDay(),
+ h = dte.getHours(), m = dte.getMinutes(), s = dte.getSeconds();
+ if (d < 10) { d = '0' + dd; };
+ if (m < 10) { m = '0' + m; };
+ if (s < 10) { s = '0' + s; };
+ return [y,m,d].join('-')+' '+[h, m].join(':');
+ };
+
+ function days_ago(dte){
+ var now = new Date();
+ return parseInt((now-dte)/JSDAY);
+ };
+
+ $('span.c-date').not('.humanized').each(function(){
+ try {
+ var datetime = $(this).text().split(' ');
+ var dte = datetime[0].split('-');
+ var tme = datetime[1].split(':');
+ // This is a UTC time
+ dte = new Date(parseInt(dte[0]), parseInt(dte[1]) - 1, parseInt(dte[2]),
+ parseInt(tme[0]), parseInt(tme[1]));
+ dte = make_local_time(dte);
+ var humandate = '';
+ var n = days_ago(dte);
+ switch(true){
+ case n==0:
+ if (is_nowish(dte)) {
+ humandate = gettext('just now');
+ } else {
+ humandate = gettext('today');
+ };
+ break;
+ case n==1:
+ humandate = gettext('yesterday');
+ break;
+ case n >1 && n < 7:
+ humandate = n + gettext(' days ago');
+ break;
+ case n > 6 && n < 15:
+ humandate = gettext('last week');
+ break;
+ default:
+ humandate = date_format(dte);
+ };
+ $(this).text(humandate);
+ $(this).addClass('humanized');
+ } catch (e) {
+ // pass
+ };
+ });
+
+ if(opts.user_id){
+ $('.comment-remove-'+opts.user_id).css({'display': 'inline'});
+ $('.comment-remove').click(function(){
+ var parent = $(this).parent().parent();
+ $('form', parent).remove();
+ var action = $('a', this).attr('href');
+ var frm = $('.remove-form').last().clone();
+ $('a.remove-cancel', frm).click(function(){
+ frm.remove();
+ return false;
+ });
+ frm.submit(function(){
+ $.post(action, $(this).serialize(), function(){
+ frm.remove();
+ parent.remove();
+ });
+ $(frm).ajaxError(function(ev, xhr, req, error_message){
+ listErrors(frm, $.parseJSON(xhr.responseText));
+ });
+ return false;
+ });
+ frm.css({'display': 'block'});
+ parent.append(frm);
+ return false;
+ });
+
+ $('.comment-reply').css({'display': 'inline'});
+ $('.comment-reply').click(function(){
+ var parent = $(this).parent().parent();
+ $('form', parent).remove();
+ var frm = $('#tcc form').first().clone();
+ $('#id_parent', frm).val($('a', this).attr('id').slice(5));
+ $(frm).submit(function(){
+ $.post($(this).attr('action'), $(this).serialize(), function(comment){
+ frm.remove();
+ if($('ul.replies', parent).length == 0){ $(parent).append('<ul class="replies"/>');}
+ $('ul.replies', parent).append(comment);
+ apply_hooks();
+ });
+ $(frm).ajaxError(function(ev, xhr, req, error_message){
+ listErrors(frm, $.parseJSON(xhr.responseText));
+ });
+ return false;
+ });
+ parent.append(frm);
+ if(!isScrolledIntoView(frm)){
+ $(document).scrollTop($(frm).offset().top-300);
+ };
+ $('#id_comment', frm).focus();
+ return false;
+ });
+ };
+ };
+
+ function init(){
+ // showall is enable for everyone
+ $('a.showall').click(function(){
+ var parent = $(this).parent();
+ if($('ul.replies', parent).length == 0){ $(parent).append('<ul class="replies"/>');};
+ var ul = $('ul.replies', parent).first();
+ $.get($(this).attr('href'), function(data){
+ $(ul).html(data);
+ apply_hooks();
+ });
+ $(this).remove();
+ return false;
+ });
+
+ apply_hooks();
+
+ if ( opts.user_id ) {
+ // run this only once
+ var frm = $('#tcc form').first();
+ $(frm).submit(function(){
+ $.post($(this).attr('action'), $(this).serialize(), function(data){
+ $('ul#tcc li').first().before(data);
+ apply_hooks();
+ $('#id_comment', frm).val('');
+ var latest = $('ul#tcc li.comment').first();
+ if(!isScrolledIntoView(latest)){
+ $(document).scrollTop($(latest).offset().top-300);
+ };
+ });
+ $(frm).ajaxError(function(ev, xhr, req, error_message){
+ listErrors(frm, $.parseJSON(xhr.responseText));
+ });
+ return false;
+ });
+ };
+ };
+
+})(jQuery);
7 tcc/templates/tcc/base.html
@@ -0,0 +1,7 @@
+{% extends 'base.html' %}
+{% block extrahead %}
+<script type="text/javascript" src="{% url tcc_jsi18n %}"></script>
+<script type="text/javascript" src="{{ STATIC_URL }}tcc/js/jquery.tcc.js"></script>
+<link rel="stylesheet" href="{{ STATIC_URL }}tcc/css/tcc.css" media="screen">
+{{ form.media }}
+{% endblock %}
24 tcc/templates/tcc/comment.html
@@ -0,0 +1,24 @@
+<li class="comment user-{{ c.user_id }}">
+ <a name="{{ c.get_base36() }}"></a>
+ {{ c.comment|safe }}
+ <p class="info">
+ {% trans %}by{% endtrans %} <a class="c-user" href="#">{{ c.user }}</a>
+ | <span class="c-date">{{ c.submit_date|date("Y-m-d H:i") }}</span>
+ {% if c.reply_allowed() %}
+ {# todo : fallback for no js #}
+ <span class="comment-reply" style="display:none">
+ | <a id="post-{{ c.id }}" href="#" title="{% trans %}reply{% endtrans %}">{% trans %}reply{% endtrans %}</a>
+ </span>
+ {% endif %}
+ <span class="comment-remove comment-remove-{{ c.user.id }}{% for u in c.get_enabled_users('remove') %} comment-remove-{{ u.id }}{% endfor %}" style="display:none">
+ | <a href="{% url tcc_remove c.id %}" title="{% trans %}remove{% endtrans %}">{% trans %}remove{% endtrans %}</a>
+ </span>
+ </p>
+ {#
+ The following construction will close the li if doclose is NOT set (a reasonable default)
+
+ It will leave the li 'open' though if doclose is set to something truthy which is what is needed list-comments.html
+ #}
+ {% if not doclose %}
+</li>
+{% endif %}
6 tcc/templates/tcc/index.html
@@ -0,0 +1,6 @@
+{% extends 'tcc/base.html' %}
+
+{% block content %}
+<h1>{% trans %}Comments{% endtrans %}</h1>
+{% include 'tcc/list-comments.html' %}
+{% endblock %}
128 tcc/templates/tcc/list-comments.html
@@ -0,0 +1,128 @@
+{% macro paginator(pages) -%}
+
+{% if pages.is_paginated %}
+<div class="pagination">
+
+ {% if pages.page_obj.has_previous() %}
+ <a href="?page={{ pages.page_obj.previous_page_number() }}{{ pages.getvars }}{{ pages.hashtag }}" class="prev">&lsaquo;&lsaquo; {% trans %}previous{% endtrans %}</a>
+ {% endif %}
+
+ {% for page in pages.pages %}
+ {% if page %}
+ <a href="?{{ pages.prefix }}page={{ page }}{{ pages.getvars }}{{ pages.hashtag }}"{% if page == pages.page_obj.number %} class="selected"{% endif %}>{{ page }}</a>
+ {% else %}
+ ...
+ {% endif %}
+ {% endfor %}
+
+ {% if pages.page_obj.has_next() %}
+ <a href="?{{ pages.prefix }}page={{ pages.page_obj.next_page_number() }}{{ pages.getvars }}{{ pages.hashtag }}" class="next">{% trans %}next{% endtrans %} &rsaquo;&rsaquo;</a>
+ {% endif %}
+
+</div>
+{% endif %}
+
+{%- endmacro %}
+
+<ul id="tcc">
+ {% if user.is_authenticated() %}
+ <form action="{% url tcc_post %}" method="post">
+ {% csrf_token %}
+ {% for fld in form %}{{ fld.as_widget() }}{% endfor %}
+ <div>
+ <input type="submit" name="some_name" value="{% trans %}Save{% endtrans %}">
+ <a style="display:none" class="reply-form" href="#" title="{% trans %}Cancel{% endtrans %}">{% trans %}Cancel{% endtrans %}</a>
+ </div>
+ </form>
+ {% else %}
+ <p>Please <a href="{% url auth_login %}">log in</a> to share your insights</p>
+ {% endif %}
+
+ {% set levels = [0] %}
+ {% set prev = None %}
+
+ {% if not comments %}
+ <div class="blank_slate small" style="margin-top: 10px;">
+ {% trans %}No comments yet...{% endtrans %}
+ </div>
+ {% endif %}
+
+ {% autopaginate comments as cs prefix='c', per_page=21, orphans=3 %}
+
+ {% for c in cs %}
+
+ {% if prev == None and c.parent %}
+ {% continue %}
+ {% endif %}
+
+ {# administration #}
+ {% set prevs = levels %}
+ {% set lvl = c.depth %}
+ {% if c.parent %}
+ {% set childcount = childcount + 1 %}
+ {% set levels = levels[:lvl] + [lvl] %}
+ {% else %}
+ {% set levels = [0] %}
+ {% set childcount = 0 %}
+ {% endif %}
+
+ {# opening / closing of uls and li's #}
+ {% if levels > prevs %}
+ <ul class="replies">
+ {% elif levels == prevs %}
+ </li>
+ {% else %}
+ {% for x in prevs[lvl:-1] %}
+ </ul>
+ {% if prev.parent.childcount > c.REPLY_LIMIT %}<a class="showall" href="{% url tcc_replies prev.parent_id %}" title="{% trans %}Show all{% endtrans %}">
+ {% trans %}Show all{% endtrans %}</a>{% endif %}
+ </li>
+ {% endfor %}
+ {% endif %}
+
+ {% set doclose = true %}
+ {% include 'tcc/comment.html' %}
+
+ {# close the last li (and / or uls) #}
+ {% if loop.last %}
+ {% if lvl == 0 %}
+ {% if c.childcount %}
+ <a class="showall" href="{% url tcc_replies c.id %}" title="{% trans %}Show all{% endtrans %}">
+ {% trans %}Show all{% endtrans %}</a>
+ {% endif %}
+ </li>
+ {% else %}
+ {% for _ in levels[1:] %}
+ </ul>
+ {% if childcount < c.parent.childcount or c.parent.childcount > c.REPLY_LIMIT %}
+ <a class="showall" href="{% url tcc_replies c.parent_id %}" title="{% trans %}Show all{% endtrans %}">
+ {% trans %}Show all{% endtrans %}</a>
+ {% endif %}
+ </li>
+ {% endfor %}
+ {% endif %}
+ {% endif %}
+
+ {% set prev = c %}
+
+ {% endfor %}
+
+ {{ paginator(cs_pages) }}
+
+ <form class="remove-form" action="" method="post" style="display:none">
+ {% csrf_token %}
+ {% trans %}Are you sure you want to delete this comment?{% endtrans %}
+ <input type="submit" name="remove-submit" value="{% trans %}Yes{% endtrans %}">
+ <a class="remove-cancel" href="#">{% trans %}Cancel{% endtrans %}
+ </form>
+</ul>
+
+<script type="text/javascript">
+ $(document).ready(function(){
+ {% if user.is_authenticated() %}
+ $(document).tcc({user_id: {{ user.id }}, user_name: '{{ user.username }}'});
+ {% else %}
+ $(document).tcc();
+ {% endif %}
+ });
+</script>
3  tcc/templates/tcc/replies.html
@@ -0,0 +1,3 @@
+{% for c in comments %}
+{% include 'tcc/comment.html' %}
+{% endfor %}
0  tcc/templatetags/__init__.py
No changes.
199 tcc/templatetags/paginator.py
@@ -0,0 +1,199 @@
+try:
+ set
+except NameError:
+ from sets import Set as set
+
+from django.core.paginator import Paginator, InvalidPage
+from django.http import Http404
+
+from coffin import template
+from jinja2 import nodes
+from jinja2.ext import Extension
+from jinja2.exceptions import TemplateSyntaxError
+
+from tcc import settings
+
+register = template.Library()
+
+# Most of the code below is borrowed from the django_pagination module by James Tauber and Pinax Team,
+# http://pinaxproject.com/docs/dev/external/pagination/index.html
+
+
+class AutopaginateExtension(Extension):
+ """
+ Applies pagination to the given dataset (and saves truncated dataset to the context variable),
+ sets context variable with data enough to build html for paginator
+
+ General syntax:
+
+ {% autopaginate dataset [as ctx_variable] %}
+ if "as" part is omitted, trying to save truncated dataset back to the original
+ context variable.
+ Pagination data is saved to the NAME_pages context variable, where NAME is
+ original name of the dataset or ctx_variable
+ """
+ tags = set(['autopaginate'])
+ default_kwargs = {
+ 'per_page': settings.PER_PAGE,
+ 'orphans': settings.PAGE_ORPHANS,
+ 'window': settings.PAGE_WINDOW,
+ 'hashtag': '',
+ 'prefix': '',
+ }
+
+ def parse(self, parser):
+ lineno = parser.stream.next().lineno
+ object_list = parser.parse_expression()
+ if parser.stream.skip_if('name:as'):
+ name = parser.stream.expect('name').value
+ elif hasattr(object_list, 'name'):
+ name = object_list.name
+ else:
+ raise TemplateSyntaxError("Cannot determine the name of objects you want to paginate, use 'as foobar' syntax", lineno)
+
+
+ kwargs = [] # wait... what?
+ loops = 0
+ while parser.stream.current.type != 'block_end':
+ lineno = parser.stream.current.lineno
+ if loops:
+ parser.stream.expect('comma')
+ key = parser.parse_assign_target().name
+ if key not in self.default_kwargs.keys():
+ raise TemplateSyntaxError(
+ "Unknown keyword argument for autopaginate. Your options are: %s" % (
+ ", ".join(self.default_kwargs.keys())
+ ))
+ parser.stream.expect('assign')
+ value = parser.parse_expression()
+ kwargs.append(nodes.Keyword(key, value)) #.set_lineno(lineno)) # like so?
+ loops += 1
+
+ return [
+ nodes.Assign(nodes.Name(name + '_pages', 'store'),
+ self.call_method('_render_pages', [object_list, nodes.Name('request', 'load')], kwargs)
+ ).set_lineno(lineno),
+
+ nodes.Assign(nodes.Name(name, 'store'),
+ nodes.Getattr(nodes.Name(name + '_pages', 'load'), 'object_list', nodes.Impossible())
+ ).set_lineno(lineno),
+ ]
+
+ def _render_pages(self, objs, request, **kwargs):
+ mykwargs = self.default_kwargs.copy()
+ mykwargs.update(kwargs)
+ prefix = mykwargs.pop('prefix')
+ window = mykwargs.pop('window')
+ hashtag = mykwargs.pop('hashtag')
+ try:
+ paginator = Paginator(objs, **mykwargs)
+
+ key = 'page'
+ if prefix:
+ key = prefix + key
+ try:
+ try:
+ pageno = int(request.GET[key])
+ except (KeyError, ValueError, TypeError):
+ pageno = 1
+ page_obj = paginator.page(pageno)
+ except InvalidPage:
+ raise Http404('Invalid page requested. If DEBUG were set to ' +
+ 'False, an HTTP 404 page would have been shown instead.')
+
+ page_range = paginator.page_range
+ # Calculate the record range in the current page for display.
+ records = {'first': 1 + (page_obj.number - 1) * paginator.per_page}
+ records['last'] = records['first'] + paginator.per_page - 1
+ if records['last'] + paginator.orphans >= paginator.count:
+ records['last'] = paginator.count
+ # First and last are simply the first *n* pages and the last *n* pages,
+ # where *n* is the current window size.
+