diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 00000000..211fe098 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,13 @@ + +The PRIMARY AUTHORS are: + + * James Tauber + * Brian Rosner + * Jannis Leidel + +ADDITIONAL CONTRIBUTORS include: + + * Eduardo Padoan + * Fabian Neumann + * Juanjo Conti + * Michael Trier diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 00000000..5b9a4ff4 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,22 @@ +BI = backward incompatible change + +0.2.0 +----- + + * BI: renamed Notice.user to Notice.recipient + * BI: renamed {{ user }} context variable in notification templates to + {{ recipient }} + * BI: added nullable Notice.sender and modified send_now and queue to take + an optional sender + * added received and sent methods taking a User instance to Notice.objects + * New default behavior: single notice view now marks unseen notices as seen + * no longer optionally depend on mailer; use django.core.mail.send_mail and + we now encourge use of Django 1.2+ for mailer support + * notifications are not sent to inactive users + * users which do not exist when sending notification are now ignored + * BI: split settings part of notices view to its own view notice_settings + +0.1.5 +----- + + * added support for DEFAULT_HTTP_PROTOCOL allowing https absolute URLs \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..0fce86cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2008 James Tauber and contributors + +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. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..7ae2c663 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include AUTHORS +include LICENSE +recursive-include docs * +recursive-include notification/templates/notification * diff --git a/README b/README new file mode 100644 index 00000000..a57ed79b --- /dev/null +++ b/README @@ -0,0 +1,11 @@ + +Many sites need to notify users when certain events have occurred and to allow +configurable options as to how those notifications are to be received. + +The project aims to provide a Django app for this sort of functionality. This +includes: + + * submission of notification messages by other apps + * notification messages on signing in + * notification messages via email (configurable by user) + * notification messages via feed diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..3b35ccbc --- /dev/null +++ b/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 ' where 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/django-notification.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-notification.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/django-notification" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-notification" + @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." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..53c56ef2 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# +# django-notification documentation build configuration file, created by +# sphinx-quickstart on Thu Jun 30 13:22:02 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 = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] + +# 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'django-notification' +copyright = u'2011, James Tauber and contributors' + +# 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.2' +# The full version, including alpha/beta/rc tags. +release = '0.2.0' + +# 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 +# " v 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 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 = 'django-notificationdoc' + + +# -- 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', 'django-notification.tex', u'django-notification Documentation', + u'James Tauber and contributors', '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', 'django-notification', u'django-notification Documentation', + [u'James Tauber and contributors'], 1) +] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..d5dd6f06 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,34 @@ +.. django-notification documentation master file, created by + sphinx-quickstart on Thu Jun 30 13:22:02 2011. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to django-notification's documentation! +=============================================== + +Many sites need to notify users when certain events have occurred and to allow +configurable options as to how those notifications are to be received. + +The project aims to provide a Django app for this sort of functionality. This includes: + + * Submission of notification messages by other apps. + * Notification messages on signing in. + * Notification messages via email (configurable by user). + * Notification messages via feed. + + +Contents: + +.. toctree:: + :maxdepth: 2 + + usage + settings + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..122133a9 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,155 @@ +@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 ^` where ^ 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 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "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. + goto end +) + +if "%1" == "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\django-notification.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-notification.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "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. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 00000000..4b817783 --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,78 @@ +================================ +Notification specific Settings +================================ + +The following allows you to specify the behavior of django-notification in your +project. Please be aware of the native Django settings which can affect the +behavior of django-notification. + + +NOTIFICATION_BACKENDS +====================== + +**Default**:: + + [ + ("email", "notification.backends.email.EmailBackend"),` + ] + +TODO: Describe usage. Look at Pinax + + +DEFAULT_HTTP_PROTOCOL +====================== + +**Default**: `http` + +This is used to specify the beginning of URLs in the default `email_body.txt` +file. A common use-case for overriding this default might be `https` for use on +more secure projects. + +NOTIFICATION_LANGUAGE_MODULE +============================= + +**Default**: `Not defined` + +The default behavior for this setting is that it does not exist. It allows users to specify their own notification language. + +Example model in a `languages` app:: + + from django.conf import settings + + class Language(models.Model): + + user = models.ForeignKey(User) + language = models.CharField(_("language"), choices=settings.LANGUAGES, max_length="10") + +Setting this value in `settings.py`:: + + NOTIFICATION_LANGUAGE_MODULE = "languages.Language" + +DEFAULT_FROM_EMAIL +================== + +**Default**: `webmaster@localhost` + +Docs: https://docs.djangoproject.com/en/1.3/ref/settings/#default-from-email + +Default e-mail address to use for various automated correspondence from +notification.backends.email. Is actually part of Django core settings. + +LANGUAGES +========== + +**Default**: `A tuple of all available languages.` + +Docs: https://docs.djangoproject.com/en/1.3/ref/settings/#languages + +The default for this is specifically used for things like the Django admin. +However, if you need to specify a subset of languages for your site's front end +you can use this setting to override the default. In which case this is the +definated pattern of usage:: + + gettext = lambda s: s + + LANGUAGES = ( + ('en', gettext('English')), + ('fr', gettext('French')), + ) \ No newline at end of file diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 00000000..43de2cff --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,150 @@ +===== +Usage +===== + +Integrating notification support into your app is a simple three-step process. + + * create your notice types + * create your notice templates + * send notifications + +Creating Notice Types +===================== + +You need to call ``create_notice_type(label, display, description)`` once to +create the notice types for your application in the database. ``label`` is just +the internal shortname that will be used for the type, ``display`` is what the +user will see as the name of the notification type and `description` is a +short description. + +For example:: + + notification.create_notice_type("friends_invite", "Invitation Received", "you have received an invitation") + +One good way to automatically do this notice type creation is in a +``management.py`` file for your app, attached to the syncdb signal. +Here is an example:: + + from django.conf import settings + from django.utils.translation import ugettext_noop as _ + + if "notification" in settings.INSTALLED_APPS: + from notification import models as notification + + def create_notice_types(app, created_models, verbosity, **kwargs): + notification.create_notice_type("friends_invite", _("Invitation Received"), _("you have received an invitation")) + notification.create_notice_type("friends_accept", _("Acceptance Received"), _("an invitation you sent has been accepted")) + + signals.post_syncdb.connect(create_notice_types, sender=notification) + else: + print "Skipping creation of NoticeTypes as notification app not found" + +Notice that the code is wrapped in a conditional clause so if +django-notification is not installed, your app will proceed anyway. + +Note that the display and description arguments are marked for translation by +using ugettext_noop. That will enable you to use Django's makemessages +management command and use django-notification's i18n capabilities. + +Notification templates +====================== + +There are four different templates that can to be written for the actual content of the notices: + + * ``short.txt`` is a very short, text-only version of the notice (suitable for things like email subjects) + * ``full.txt`` is a longer, text-only version of the notice (suitable for things like email bodies) + * ``notice.html`` is a short, html version of the notice, displayed in a user's notice list on the website + * ``full.html`` is a long, html version of the notice (not currently used for anything) + +Each of these should be put in a directory on the template path called ``notification//``. +If any of these are missing, a default would be used. In practice, ``notice.html`` and ``full.txt`` should be provided at a minimum. + +For example, ``notification/friends_invite/notice.html`` might contain:: + + {% load i18n %}{% url invitations as invitation_page %}{% url profile_detail username=invitation.from_user.username as user_url %} + {% blocktrans with invitation.from_user as invitation_from_user %}{{ invitation_from_user }} has requested to add you as a friend (see invitations){% endblocktrans %} + +and ``notification/friends_full.txt`` might contain:: + + {% load i18n %}{% url invitations as invitation_page %}{% blocktrans with invitation.from_user as invitation_from_user %}{{ invitation_from_user }} has requested to add you as a friend. You can accept their invitation at: + + http://{{ current_site }}{{ invitation_page }} + {% endblocktrans %} + +The context variables are provided when sending the notification. + + +Sending Notification +==================== + +There are two different ways of sending out notifications. We have support +for blocking and non-blocking methods of sending notifications. The most +simple way to send out a notification, for example:: + + notification.send([to_user], "friends_invite", {"from_user": from_user}) + +One thing to note is that ``send`` is a proxy around either ``send_now`` or +``queue``. They all have the same signature:: + + send(users, label, extra_context) + +The parameters are: + + * ``users`` is an iterable of ``User`` objects to send the notification to. + * ``label`` is the label you used in the previous step to identify the notice + type. + * ``extra_content`` is a dictionary to add custom context entries to the + template used to render to notification. This is optional. + +``send_now`` vs. ``queue`` vs. ``send`` +--------------------------------------- + +Lets first break down what each does. + +``send_now`` +~~~~~~~~~~~~ + +This is a blocking call that will check each user for elgibility of the +notice and actually peform the send. + +``queue`` +~~~~~~~~~ + +This is a non-blocking call that will queue the call to ``send_now`` to +be executed at a later time. To later execute the call you need to use +the ``emit_notices`` management command. + +``send`` +~~~~~~~~ + +A proxy around ``send_now`` and ``queue``. It gets its behavior from a global +setting named ``NOTIFICATION_QUEUE_ALL``. By default it is ``False``. This +setting is meant to help control whether you want to queue any call to +``send``. + +``send`` also accepts ``now`` and ``queue`` keyword arguments. By default +each option is set to ``False`` to honor the global setting which is ``False``. +This enables you to override on a per call basis whether it should call +``send_now`` or ``queue``. + +Optional notification support +----------------------------- + +In case you want to use django-notification in your reusable app, you can +wrap the import of django-notification in a conditional clause that tests +if it's installed before sending a notice. As a result your app or +project still functions without notification. + +For example:: + + from django.conf import settings + + if "notification" in settings.INSTALLED_APPS: + from notification import models as notification + else: + notification = None + +and then, later:: + + if notification: + notification.send([to_user], "friends_invite", {"from_user": from_user}) diff --git a/notification/.gitignore b/notification/.gitignore new file mode 100644 index 00000000..0d20b648 --- /dev/null +++ b/notification/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/notification/__init__.py b/notification/__init__.py new file mode 100644 index 00000000..52799e96 --- /dev/null +++ b/notification/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0b1" diff --git a/notification/admin.py b/notification/admin.py new file mode 100644 index 00000000..c94e7c53 --- /dev/null +++ b/notification/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin + +from notification.models import NoticeType, NoticeSetting, NoticeQueueBatch + + +class NoticeTypeAdmin(admin.ModelAdmin): + list_display = ["label", "display", "description", "default"] + + +class NoticeSettingAdmin(admin.ModelAdmin): + list_display = ["id", "user", "notice_type", "medium", "send"] + + +admin.site.register(NoticeQueueBatch) +admin.site.register(NoticeType, NoticeTypeAdmin) +admin.site.register(NoticeSetting, NoticeSettingAdmin) diff --git a/notification/backends/__init__.py b/notification/backends/__init__.py new file mode 100644 index 00000000..f2cf3378 --- /dev/null +++ b/notification/backends/__init__.py @@ -0,0 +1,37 @@ +import sys + +from django.conf import settings +from django.core import exceptions + +from base import BaseBackend + + +# mostly for backend compatibility +default_backends = [ + ("email", "notification.backends.email.EmailBackend"), +] + + +def load_backends(): + backends = [] + for medium_id, bits in enumerate(getattr(settings, "NOTIFICATION_BACKENDS", default_backends)): + if len(bits) == 2: + label, backend_path = bits + spam_sensitivity = None + elif len(bits) == 3: + label, backend_path, spam_sensitivity = bits + else: + raise exceptions.ImproperlyConfigured, "NOTIFICATION_BACKENDS does not contain enough data." + dot = backend_path.rindex(".") + backend_mod, backend_class = backend_path[:dot], backend_path[dot+1:] + try: + # import the module and get the module from sys.modules + __import__(backend_mod) + mod = sys.modules[backend_mod] + except ImportError, e: + raise exceptions.ImproperlyConfigured, 'Error importing notification backend %s: "%s"' % (backend_mod, e) + # add the backend label and an instantiated backend class to the + # backends list. + backend_instance = getattr(mod, backend_class)(medium_id, spam_sensitivity) + backends.append(((medium_id, label), backend_instance)) + return dict(backends) diff --git a/notification/backends/base.py b/notification/backends/base.py new file mode 100644 index 00000000..fd7ddeaf --- /dev/null +++ b/notification/backends/base.py @@ -0,0 +1,54 @@ +from django.conf import settings +from django.template import Context +from django.template.loader import render_to_string + +from django.contrib.sites.models import Site + + +class BaseBackend(object): + """ + The base backend. + """ + def __init__(self, medium_id, spam_sensitivity=None): + self.medium_id = medium_id + if spam_sensitivity is not None: + self.spam_sensitivity = spam_sensitivity + + def can_send(self, user, notice_type): + """ + Determines whether this backend is allowed to send a notification to + the given user and notice_type. + """ + from notification.models import NoticeSetting + return NoticeSetting.for_user(user, notice_type, self.medium_id).send + + def deliver(self, recipient, notice_type, extra_context): + """ + Deliver a notification to the given recipient. + """ + raise NotImplemented() + + def get_formatted_messages(self, formats, label, context): + """ + Returns a dictionary with the format identifier as the key. The values are + are fully rendered templates with the given context. + """ + format_templates = {} + for format in formats: + # conditionally turn off autoescaping for .txt extensions in format + if format.endswith(".txt"): + context.autoescape = False + format_templates[format] = render_to_string(( + "notification/%s/%s" % (label, format), + "notification/%s" % format), context_instance=context) + return format_templates + + def default_context(self): + default_http_protocol = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") + current_site = Site.objects.get_current() + base_url = "%s://%s" % (default_http_protocol, current_site.domain) + return Context({ + "default_http_protocol": default_http_protocol, + "current_site": current_site, + "base_url": base_url + }) diff --git a/notification/backends/email.py b/notification/backends/email.py new file mode 100644 index 00000000..42f9b416 --- /dev/null +++ b/notification/backends/email.py @@ -0,0 +1,42 @@ +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils.translation import ugettext + +from notification import backends + + +class EmailBackend(backends.BaseBackend): + spam_sensitivity = 2 + + def can_send(self, user, notice_type): + can_send = super(EmailBackend, self).can_send(user, notice_type) + if can_send and user.email: + return True + return False + + def deliver(self, recipient, sender, notice_type, extra_context): + # TODO: require this to be passed in extra_context + + context = self.default_context() + context.update({ + "recipient": recipient, + "sender": sender, + "notice": ugettext(notice_type.display), + }) + context.update(extra_context) + + messages = self.get_formatted_messages(( + "short.txt", + "full.txt" + ), notice_type.label, context) + + subject = "".join(render_to_string("notification/email_subject.txt", { + "message": messages["short.txt"], + }, context).splitlines()) + + body = render_to_string("notification/email_body.txt", { + "message": messages["full.txt"], + }, context) + + send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [recipient.email]) diff --git a/notification/decorators.py b/notification/decorators.py new file mode 100644 index 00000000..aab2b2d1 --- /dev/null +++ b/notification/decorators.py @@ -0,0 +1,65 @@ +from django.utils.translation import ugettext as _ +from django.http import HttpResponse +from django.conf import settings + +from django.contrib.auth import authenticate, login + + +def simple_basic_auth_callback(request, user, *args, **kwargs): + """ + Simple callback to automatically login the given user after a successful + basic authentication. + """ + login(request, user) + request.user = user + + +def basic_auth_required(realm=None, test_func=None, callback_func=None): + """ + This decorator should be used with views that need simple authentication + against Django's authentication framework. + + The ``realm`` string is shown during the basic auth query. + + It takes a ``test_func`` argument that is used to validate the given + credentials and return the decorated function if successful. + + If unsuccessful the decorator will try to authenticate and checks if the + user has the ``is_active`` field set to True. + + In case of a successful authentication the ``callback_func`` will be + called by passing the ``request`` and the ``user`` object. After that the + actual view function will be called. + + If all of the above fails a "Authorization Required" message will be shown. + """ + if realm is None: + realm = getattr(settings, "HTTP_AUTHENTICATION_REALM", _("Restricted Access")) + if test_func is None: + test_func = lambda u: u.is_authenticated() + + def decorator(view_func): + def basic_auth(request, *args, **kwargs): + # Just return the original view because already logged in + if test_func(request.user): + return view_func(request, *args, **kwargs) + + # Not logged in, look if login credentials are provided + if "HTTP_AUTHORIZATION" in request.META: + auth_method, auth = request.META["HTTP_AUTHORIZATION"].split(" ", 1) + if "basic" == auth_method.lower(): + auth = auth.strip().decode("base64") + username, password = auth.split(":",1) + user = authenticate(username=username, password=password) + if user is not None: + if user.is_active: + if callback_func is not None and callable(callback_func): + callback_func(request, user, *args, **kwargs) + return view_func(request, *args, **kwargs) + + response = HttpResponse(_("Authorization Required"), mimetype="text/plain") + response.status_code = 401 + response["WWW-Authenticate"] = "Basic realm='%s'" % realm + return response + return basic_auth + return decorator diff --git a/notification/engine.py b/notification/engine.py new file mode 100644 index 00000000..00276ed0 --- /dev/null +++ b/notification/engine.py @@ -0,0 +1,90 @@ +import sys +import time +import logging +import traceback + +try: + import cPickle as pickle +except ImportError: + import pickle + +from django.conf import settings +from django.core.mail import mail_admins +from django.contrib.auth.models import User +from django.contrib.sites.models import Site + +from lockfile import FileLock, AlreadyLocked, LockTimeout + +from notification.models import NoticeQueueBatch +from notification.signals import emitted_notices +from notification import models as notification + +# lock timeout value. how long to wait for the lock to become available. +# default behavior is to never wait for the lock to be available. +LOCK_WAIT_TIMEOUT = getattr(settings, "NOTIFICATION_LOCK_WAIT_TIMEOUT", -1) + + +def send_all(*args): + if len(args) == 1: + lock = FileLock(args[0]) + else: + lock = FileLock("send_notices") + + logging.debug("acquiring lock...") + try: + lock.acquire(LOCK_WAIT_TIMEOUT) + except AlreadyLocked: + logging.debug("lock already in place. quitting.") + return + except LockTimeout: + logging.debug("waiting for the lock timed out. quitting.") + return + logging.debug("acquired.") + + batches, sent, sent_actual = 0, 0, 0 + start_time = time.time() + + try: + # nesting the try statement to be Python 2.4 + try: + for queued_batch in NoticeQueueBatch.objects.all(): + notices = pickle.loads(str(queued_batch.pickled_data).decode("base64")) + for user, label, extra_context, sender in notices: + try: + user = User.objects.get(pk=user) + logging.info("emitting notice %s to %s" % (label, user)) + # call this once per user to be atomic and allow for logging to + # accurately show how long each takes. + if notification.send_now([user], label, extra_context, sender): + sent_actual += 1 + except User.DoesNotExist: + # Ignore deleted users, just warn about them + logging.warning("not emitting notice %s to user %s since it does not exist" % (label, user)) + sent += 1 + queued_batch.delete() + batches += 1 + emitted_notices.send( + sender=NoticeQueueBatch, + batches=batches, + sent=sent, + sent_actual=sent_actual, + run_time="%.2f seconds" % (time.time() - start_time) + ) + except: + # get the exception + exc_class, e, t = sys.exc_info() + # email people + current_site = Site.objects.get_current() + subject = "[%s emit_notices] %r" % (current_site.name, e) + message = "%s" % ("\n".join(traceback.format_exception(*sys.exc_info())),) + mail_admins(subject, message, fail_silently=True) + # log it as critical + logging.critical("an exception occurred: %r" % e) + finally: + logging.debug("releasing lock...") + lock.release() + logging.debug("released.") + + logging.info("") + logging.info("%s batches, %s sent" % (batches, sent,)) + logging.info("done in %.2f seconds" % (time.time() - start_time)) diff --git a/notification/lockfile.py b/notification/lockfile.py new file mode 100644 index 00000000..07c93fac --- /dev/null +++ b/notification/lockfile.py @@ -0,0 +1,497 @@ +""" +lockfile.py - Platform-independent advisory file locks. + +Requires Python 2.5 unless you apply 2.4.diff +Locking is done on a per-thread basis instead of a per-process basis. + +Usage: + +>>> lock = FileLock('somefile') +>>> try: +... lock.acquire() +... except AlreadyLocked: +... print 'somefile', 'is locked already.' +... except LockFailed: +... print 'somefile', 'can\\'t be locked.' +... else: +... print 'got lock' +got lock +>>> print lock.is_locked() +True +>>> lock.release() + +>>> lock = FileLock('somefile') +>>> print lock.is_locked() +False +>>> with lock: +... print lock.is_locked() +True +>>> print lock.is_locked() +False +>>> # It is okay to lock twice from the same thread... +>>> with lock: +... lock.acquire() +... +>>> # Though no counter is kept, so you can't unlock multiple times... +>>> print lock.is_locked() +False + +Exceptions: + + Error - base class for other exceptions + LockError - base class for all locking exceptions + AlreadyLocked - Another thread or process already holds the lock + LockFailed - Lock failed for some other reason + UnlockError - base class for all unlocking exceptions + AlreadyUnlocked - File was not locked. + NotMyLock - File was locked but not by the current thread/process +""" + +from __future__ import division + +import sys +import socket +import os +import thread +import threading +import time +import errno +import urllib + +# Work with PEP8 and non-PEP8 versions of threading module. +if not hasattr(threading, "current_thread"): + threading.current_thread = threading.currentThread +if not hasattr(threading.Thread, "get_name"): + threading.Thread.get_name = threading.Thread.getName + +__all__ = ['Error', 'LockError', 'LockTimeout', 'AlreadyLocked', + 'LockFailed', 'UnlockError', 'NotLocked', 'NotMyLock', + 'LinkFileLock', 'MkdirFileLock', 'SQLiteFileLock'] + +class Error(Exception): + """ + Base class for other exceptions. + + >>> try: + ... raise Error + ... except Exception: + ... pass + """ + pass + +class LockError(Error): + """ + Base class for error arising from attempts to acquire the lock. + + >>> try: + ... raise LockError + ... except Error: + ... pass + """ + pass + +class LockTimeout(LockError): + """Raised when lock creation fails within a user-defined period of time. + + >>> try: + ... raise LockTimeout + ... except LockError: + ... pass + """ + pass + +class AlreadyLocked(LockError): + """Some other thread/process is locking the file. + + >>> try: + ... raise AlreadyLocked + ... except LockError: + ... pass + """ + pass + +class LockFailed(LockError): + """Lock file creation failed for some other reason. + + >>> try: + ... raise LockFailed + ... except LockError: + ... pass + """ + pass + +class UnlockError(Error): + """ + Base class for errors arising from attempts to release the lock. + + >>> try: + ... raise UnlockError + ... except Error: + ... pass + """ + pass + +class NotLocked(UnlockError): + """Raised when an attempt is made to unlock an unlocked file. + + >>> try: + ... raise NotLocked + ... except UnlockError: + ... pass + """ + pass + +class NotMyLock(UnlockError): + """Raised when an attempt is made to unlock a file someone else locked. + + >>> try: + ... raise NotMyLock + ... except UnlockError: + ... pass + """ + pass + +class LockBase: + """Base class for platform-specific lock classes.""" + def __init__(self, path, threaded=True): + """ + >>> lock = LockBase('somefile') + >>> lock = LockBase('somefile', threaded=False) + """ + self.path = path + self.lock_file = os.path.abspath(path) + ".lock" + self.hostname = socket.gethostname() + self.pid = os.getpid() + if threaded: + name = threading.current_thread().get_name() + tname = "%s-" % urllib.quote(name, safe="") + else: + tname = "" + dirname = os.path.dirname(self.lock_file) + self.unique_name = os.path.join(dirname, + "%s.%s%s" % (self.hostname, + tname, + self.pid)) + + def acquire(self, timeout=None): + """ + Acquire the lock. + + * If timeout is omitted (or None), wait forever trying to lock the + file. + + * If timeout > 0, try to acquire the lock for that many seconds. If + the lock period expires and the file is still locked, raise + LockTimeout. + + * If timeout <= 0, raise AlreadyLocked immediately if the file is + already locked. + """ + raise NotImplemented("implement in subclass") + + def release(self): + """ + Release the lock. + + If the file is not locked, raise NotLocked. + """ + raise NotImplemented("implement in subclass") + + def is_locked(self): + """ + Tell whether or not the file is locked. + """ + raise NotImplemented("implement in subclass") + + def i_am_locking(self): + """ + Return True if this object is locking the file. + """ + raise NotImplemented("implement in subclass") + + def break_lock(self): + """ + Remove a lock. Useful if a locking thread failed to unlock. + """ + raise NotImplemented("implement in subclass") + + def __enter__(self): + """ + Context manager support. + """ + self.acquire() + return self + + def __exit__(self, *_exc): + """ + Context manager support. + """ + self.release() + +class LinkFileLock(LockBase): + """Lock access to a file using atomic property of link(2).""" + + def acquire(self, timeout=None): + try: + open(self.unique_name, "wb").close() + except IOError: + raise LockFailed("failed to create %s" % self.unique_name) + + end_time = time.time() + if timeout is not None and timeout > 0: + end_time += timeout + + while True: + # Try and create a hard link to it. + try: + os.link(self.unique_name, self.lock_file) + except OSError: + # Link creation failed. Maybe we've double-locked? + nlinks = os.stat(self.unique_name).st_nlink + if nlinks == 2: + # The original link plus the one I created == 2. We're + # good to go. + return + else: + # Otherwise the lock creation failed. + if timeout is not None and time.time() > end_time: + os.unlink(self.unique_name) + if timeout > 0: + raise LockTimeout + else: + raise AlreadyLocked + time.sleep(timeout is not None and timeout/10 or 0.1) + else: + # Link creation succeeded. We're good to go. + return + + def release(self): + if not self.is_locked(): + raise NotLocked + elif not os.path.exists(self.unique_name): + raise NotMyLock + os.unlink(self.unique_name) + os.unlink(self.lock_file) + + def is_locked(self): + return os.path.exists(self.lock_file) + + def i_am_locking(self): + return (self.is_locked() and + os.path.exists(self.unique_name) and + os.stat(self.unique_name).st_nlink == 2) + + def break_lock(self): + if os.path.exists(self.lock_file): + os.unlink(self.lock_file) + +class MkdirFileLock(LockBase): + """Lock file by creating a directory.""" + def __init__(self, path, threaded=True): + """ + >>> lock = MkdirFileLock('somefile') + >>> lock = MkdirFileLock('somefile', threaded=False) + """ + LockBase.__init__(self, path, threaded) + if threaded: + tname = "%x-" % thread.get_ident() + else: + tname = "" + # Lock file itself is a directory. Place the unique file name into + # it. + self.unique_name = os.path.join(self.lock_file, + "%s.%s%s" % (self.hostname, + tname, + self.pid)) + + def acquire(self, timeout=None): + end_time = time.time() + if timeout is not None and timeout > 0: + end_time += timeout + + if timeout is None: + wait = 0.1 + else: + wait = max(0, timeout / 10) + + while True: + try: + os.mkdir(self.lock_file) + except OSError: + err = sys.exc_info()[1] + if err.errno == errno.EEXIST: + # Already locked. + if os.path.exists(self.unique_name): + # Already locked by me. + return + if timeout is not None and time.time() > end_time: + if timeout > 0: + raise LockTimeout + else: + # Someone else has the lock. + raise AlreadyLocked + time.sleep(wait) + else: + # Couldn't create the lock for some other reason + raise LockFailed("failed to create %s" % self.lock_file) + else: + open(self.unique_name, "wb").close() + return + + def release(self): + if not self.is_locked(): + raise NotLocked + elif not os.path.exists(self.unique_name): + raise NotMyLock + os.unlink(self.unique_name) + os.rmdir(self.lock_file) + + def is_locked(self): + return os.path.exists(self.lock_file) + + def i_am_locking(self): + return (self.is_locked() and + os.path.exists(self.unique_name)) + + def break_lock(self): + if os.path.exists(self.lock_file): + for name in os.listdir(self.lock_file): + os.unlink(os.path.join(self.lock_file, name)) + os.rmdir(self.lock_file) + +class SQLiteFileLock(LockBase): + "Demonstration of using same SQL-based locking." + + import tempfile + _fd, testdb = tempfile.mkstemp() + os.close(_fd) + os.unlink(testdb) + del _fd, tempfile + + def __init__(self, path, threaded=True): + LockBase.__init__(self, path, threaded) + self.lock_file = unicode(self.lock_file) + self.unique_name = unicode(self.unique_name) + + import sqlite3 + self.connection = sqlite3.connect(SQLiteFileLock.testdb) + + c = self.connection.cursor() + try: + c.execute("create table locks" + "(" + " lock_file varchar(32)," + " unique_name varchar(32)" + ")") + except sqlite3.OperationalError: + pass + else: + self.connection.commit() + import atexit + atexit.register(os.unlink, SQLiteFileLock.testdb) + + def acquire(self, timeout=None): + end_time = time.time() + if timeout is not None and timeout > 0: + end_time += timeout + + if timeout is None: + wait = 0.1 + elif timeout <= 0: + wait = 0 + else: + wait = timeout / 10 + + cursor = self.connection.cursor() + + while True: + if not self.is_locked(): + # Not locked. Try to lock it. + cursor.execute("insert into locks" + " (lock_file, unique_name)" + " values" + " (?, ?)", + (self.lock_file, self.unique_name)) + self.connection.commit() + + # Check to see if we are the only lock holder. + cursor.execute("select * from locks" + " where unique_name = ?", + (self.unique_name,)) + rows = cursor.fetchall() + if len(rows) > 1: + # Nope. Someone else got there. Remove our lock. + cursor.execute("delete from locks" + " where unique_name = ?", + (self.unique_name,)) + self.connection.commit() + else: + # Yup. We're done, so go home. + return + else: + # Check to see if we are the only lock holder. + cursor.execute("select * from locks" + " where unique_name = ?", + (self.unique_name,)) + rows = cursor.fetchall() + if len(rows) == 1: + # We're the locker, so go home. + return + + # Maybe we should wait a bit longer. + if timeout is not None and time.time() > end_time: + if timeout > 0: + # No more waiting. + raise LockTimeout + else: + # Someone else has the lock and we are impatient.. + raise AlreadyLocked + + # Well, okay. We'll give it a bit longer. + time.sleep(wait) + + def release(self): + if not self.is_locked(): + raise NotLocked + if not self.i_am_locking(): + raise NotMyLock((self._who_is_locking(), self.unique_name)) + cursor = self.connection.cursor() + cursor.execute("delete from locks" + " where unique_name = ?", + (self.unique_name,)) + self.connection.commit() + + def _who_is_locking(self): + cursor = self.connection.cursor() + cursor.execute("select unique_name from locks" + " where lock_file = ?", + (self.lock_file,)) + return cursor.fetchone()[0] + + def is_locked(self): + cursor = self.connection.cursor() + cursor.execute("select * from locks" + " where lock_file = ?", + (self.lock_file,)) + rows = cursor.fetchall() + return not not rows + + def i_am_locking(self): + cursor = self.connection.cursor() + cursor.execute("select * from locks" + " where lock_file = ?" + " and unique_name = ?", + (self.lock_file, self.unique_name)) + return not not cursor.fetchall() + + def break_lock(self): + cursor = self.connection.cursor() + cursor.execute("delete from locks" + " where lock_file = ?", + (self.lock_file,)) + self.connection.commit() + +if hasattr(os, "link"): + FileLock = LinkFileLock +else: + FileLock = MkdirFileLock diff --git a/notification/management/__init__.py b/notification/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notification/management/commands/__init__.py b/notification/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notification/management/commands/emit_notices.py b/notification/management/commands/emit_notices.py new file mode 100644 index 00000000..6dce399b --- /dev/null +++ b/notification/management/commands/emit_notices.py @@ -0,0 +1,14 @@ +import logging + +from django.core.management.base import BaseCommand + +from notification.engine import send_all + + +class Command(BaseCommand): + help = "Emit queued notices." + + def handle(self, *args, **options): + logging.basicConfig(level=logging.DEBUG, format="%(message)s") + logging.info("-" * 72) + send_all(*args) diff --git a/notification/message.py b/notification/message.py new file mode 100644 index 00000000..7421ae4c --- /dev/null +++ b/notification/message.py @@ -0,0 +1,107 @@ +from django.db.models import get_model +from django.utils.translation import ugettext + +# a notice like "foo and bar are now friends" is stored in the database +# as "{auth.User.5} and {auth.User.7} are now friends". +# +# encode_object takes an object and turns it into "{app.Model.pk}" or +# "{app.Model.pk.msgid}" if named arguments are used in send() +# decode_object takes "{app.Model.pk}" and turns it into the object +# +# encode_message takes either ("%s and %s are now friends", [foo, bar]) or +# ("%(foo)s and %(bar)s are now friends", {'foo':foo, 'bar':bar}) and turns +# it into "{auth.User.5} and {auth.User.7} are now friends". +# +# decode_message takes "{auth.User.5} and {auth.User.7}" and converts it +# into a string using the given decode function to convert the object to +# string representation +# +# message_to_text and message_to_html use decode_message to produce a +# text and html version of the message respectively. + +def encode_object(obj, name=None): + encoded = "%s.%s.%s" % (obj._meta.app_label, obj._meta.object_name, obj.pk) + if name: + encoded = "%s.%s" % (encoded, name) + return "{%s}" % encoded + + +def encode_message(message_template, objects): + if objects is None: + return message_template + if isinstance(objects, list) or isinstance(objects, tuple): + return message_template % tuple(encode_object(obj) for obj in objects) + if type(objects) is dict: + return message_template % dict((name, encode_object(obj, name)) for name, obj in objects.iteritems()) + return "" + + +def decode_object(ref): + decoded = ref.split(".") + if len(decoded) == 4: + app, name, pk, msgid = decoded + return get_model(app, name).objects.get(pk=pk), msgid + app, name, pk = decoded + return get_model(app, name).objects.get(pk=pk), None + + +class FormatException(Exception): + pass + + +def decode_message(message, decoder): + out = [] + objects = [] + mapping = {} + in_field = False + prev = 0 + for index, ch in enumerate(message): + if not in_field: + if ch == "{": + in_field = True + if prev != index: + out.append(message[prev:index]) + prev = index + elif ch == "}": + raise FormatException("unmatched }") + elif in_field: + if ch == "{": + raise FormatException("{ inside {}") + elif ch == "}": + in_field = False + obj, msgid = decoder(message[prev+1:index]) + if msgid is None: + objects.append(obj) + out.append("%s") + else: + mapping[msgid] = obj + out.append("%("+msgid+")s") + prev = index + 1 + if in_field: + raise FormatException("unmatched {") + if prev <= index: + out.append(message[prev:index+1]) + result = "".join(out) + if mapping: + args = mapping + else: + args = tuple(objects) + return ugettext(result) % args + + +def message_to_text(message): + def decoder(ref): + obj, msgid = decode_object(ref) + return unicode(obj), msgid + return decode_message(message, decoder) + + +def message_to_html(message): + def decoder(ref): + obj, msgid = decode_object(ref) + if hasattr(obj, "get_absolute_url"): # don't fail silenty if get_absolute_url hasn't been defined + return u"""%s""" % (obj.get_absolute_url(), unicode(obj)), msgid + else: + return unicode(obj), msgid + return decode_message(message, decoder) + \ No newline at end of file diff --git a/notification/models.py b/notification/models.py new file mode 100644 index 00000000..8c72e85e --- /dev/null +++ b/notification/models.py @@ -0,0 +1,223 @@ +try: + import cPickle as pickle +except ImportError: + import pickle + +from django.db import models +from django.db.models.query import QuerySet +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import get_language, activate + +from django.contrib.auth.models import User + +from notification import backends +from notification.message import encode_message + + +DEFAULT_QUEUE_ALL = False + + +class LanguageStoreNotAvailable(Exception): + pass + + +class NoticeType(models.Model): + + label = models.CharField(_("label"), max_length=40) + display = models.CharField(_("display"), max_length=50) + description = models.CharField(_("description"), max_length=100) + + # by default only on for media with sensitivity less than or equal to this number + default = models.IntegerField(_("default")) + + def __unicode__(self): + return self.label + + class Meta: + verbose_name = _("notice type") + verbose_name_plural = _("notice types") + + @classmethod + def create(cls, label, display, description, default=2, verbosity=1): + """ + Creates a new NoticeType. + + This is intended to be used by other apps as a post_syncdb manangement step. + """ + try: + notice_type = cls._default_manager.get(label=label) + updated = False + if display != notice_type.display: + notice_type.display = display + updated = True + if description != notice_type.description: + notice_type.description = description + updated = True + if default != notice_type.default: + notice_type.default = default + updated = True + if updated: + notice_type.save() + if verbosity > 1: + print "Updated %s NoticeType" % label + except cls.DoesNotExist: + cls(label=label, display=display, description=description, default=default).save() + if verbosity > 1: + print "Created %s NoticeType" % label + + +NOTIFICATION_BACKENDS = backends.load_backends() + +NOTICE_MEDIA = [] +NOTICE_MEDIA_DEFAULTS = {} +for key, backend in NOTIFICATION_BACKENDS.items(): + # key is a tuple (medium_id, backend_label) + NOTICE_MEDIA.append(key) + NOTICE_MEDIA_DEFAULTS[key[0]] = backend.spam_sensitivity + + +class NoticeSetting(models.Model): + """ + Indicates, for a given user, whether to send notifications + of a given type to a given medium. + """ + + user = models.ForeignKey(User, verbose_name=_("user")) + notice_type = models.ForeignKey(NoticeType, verbose_name=_("notice type")) + medium = models.CharField(_("medium"), max_length=1, choices=NOTICE_MEDIA) + send = models.BooleanField(_("send")) + + class Meta: + verbose_name = _("notice setting") + verbose_name_plural = _("notice settings") + unique_together = ("user", "notice_type", "medium") + + @classmethod + def for_user(cls, user, notice_type, medium): + try: + return cls._default_manager.get(user=user, notice_type=notice_type, medium=medium) + except cls.DoesNotExist: + default = (NOTICE_MEDIA_DEFAULTS[medium] <= notice_type.default) + setting = cls(user=user, notice_type=notice_type, medium=medium, send=default) + setting.save() + return setting + + +def get_notification_setting(user, notice_type, medium): + try: + return NoticeSetting.objects.get(user=user, notice_type=notice_type, medium=medium) + except NoticeSetting.DoesNotExist: + default = (NOTICE_MEDIA_DEFAULTS[medium] <= notice_type.default) + setting = NoticeSetting(user=user, notice_type=notice_type, medium=medium, send=default) + setting.save() + return setting + + +class NoticeQueueBatch(models.Model): + """ + A queued notice. + Denormalized data for a notice. + """ + pickled_data = models.TextField() + + +def get_notification_language(user): + """ + Returns site-specific notification language for this user. Raises + LanguageStoreNotAvailable if this site does not use translated + notifications. + """ + if getattr(settings, "NOTIFICATION_LANGUAGE_MODULE", False): + try: + app_label, model_name = settings.NOTIFICATION_LANGUAGE_MODULE.split(".") + model = models.get_model(app_label, model_name) + language_model = model._default_manager.get(user__id__exact=user.id) + if hasattr(language_model, "language"): + return language_model.language + except (ImportError, ImproperlyConfigured, model.DoesNotExist): + raise LanguageStoreNotAvailable + raise LanguageStoreNotAvailable + + +def send_now(users, label, extra_context=None, sender=None): + """ + Creates a new notice. + + This is intended to be how other apps create new notices. + + notification.send(user, "friends_invite_sent", { + "spam": "eggs", + "foo": "bar", + ) + """ + sent = False + if extra_context is None: + extra_context = {} + + notice_type = NoticeType.objects.get(label=label) + + current_language = get_language() + + for user in users: + # get user language for user from language store defined in + # NOTIFICATION_LANGUAGE_MODULE setting + try: + language = get_notification_language(user) + except LanguageStoreNotAvailable: + language = None + + if language is not None: + # activate the user's language + activate(language) + + for backend in NOTIFICATION_BACKENDS.values(): + if backend.can_send(user, notice_type): + backend.deliver(user, sender, notice_type, extra_context) + sent = True + + # reset environment to original language + activate(current_language) + return sent + + +def send(*args, **kwargs): + """ + A basic interface around both queue and send_now. This honors a global + flag NOTIFICATION_QUEUE_ALL that helps determine whether all calls should + be queued or not. A per call ``queue`` or ``now`` keyword argument can be + used to always override the default global behavior. + """ + QUEUE_ALL = getattr(settings, "NOTIFICATION_QUEUE_ALL", DEFAULT_QUEUE_ALL) + queue_flag = kwargs.pop("queue", False) + now_flag = kwargs.pop("now", False) + assert not (queue_flag and now_flag), "'queue' and 'now' cannot both be True." + if queue_flag: + return queue(*args, **kwargs) + elif now_flag: + return send_now(*args, **kwargs) + else: + if QUEUE_ALL: + return queue(*args, **kwargs) + else: + return send_now(*args, **kwargs) + + +def queue(users, label, extra_context=None, sender=None): + """ + Queue the notification in NoticeQueueBatch. This allows for large amounts + of user notifications to be deferred to a seperate process running outside + the webserver. + """ + QUEUE_ALL = getattr(settings, "NOTIFICATION_QUEUE_ALL", DEFAULT_QUEUE_ALL) + if extra_context is None: + extra_context = {} + if isinstance(users, QuerySet): + users = [row["pk"] for row in users.values("pk")] + else: + users = [user.pk for user in users] + notices = [] + for user in users: + notices.append((user, label, extra_context, sender)) + NoticeQueueBatch(pickled_data=pickle.dumps(notices).encode("base64")).save() diff --git a/notification/signals.py b/notification/signals.py new file mode 100644 index 00000000..06644a89 --- /dev/null +++ b/notification/signals.py @@ -0,0 +1,4 @@ +import django.dispatch + + +emitted_notices = django.dispatch.Signal(providing_args=["batches", "sent", "sent_actual", "run_time"]) diff --git a/notification/templates/notification/email_body.txt b/notification/templates/notification/email_body.txt new file mode 100644 index 00000000..155d9a03 --- /dev/null +++ b/notification/templates/notification/email_body.txt @@ -0,0 +1,6 @@ +{% load i18n %}{% url notification_notices as notices_url %}{% blocktrans %}You have received the following notice from {{ current_site }}: + +{{ message }} + +To see other notices or change how you receive notifications, please go to {{ default_http_protocol }}://{{ current_site }}{{ notices_url }} +{% endblocktrans %} diff --git a/notification/templates/notification/email_subject.txt b/notification/templates/notification/email_subject.txt new file mode 100644 index 00000000..91d1953d --- /dev/null +++ b/notification/templates/notification/email_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans %}[{{ current_site }}] {{ message }}{% endblocktrans %} \ No newline at end of file diff --git a/notification/templates/notification/full.html b/notification/templates/notification/full.html new file mode 100644 index 00000000..e70ad6e5 --- /dev/null +++ b/notification/templates/notification/full.html @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans %}{{ notice }}{% endblocktrans %} \ No newline at end of file diff --git a/notification/templates/notification/full.txt b/notification/templates/notification/full.txt new file mode 100644 index 00000000..01f21b36 --- /dev/null +++ b/notification/templates/notification/full.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans %}{{ notice }}{% endblocktrans %} diff --git a/notification/templates/notification/notice.html b/notification/templates/notification/notice.html new file mode 100644 index 00000000..e70ad6e5 --- /dev/null +++ b/notification/templates/notification/notice.html @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans %}{{ notice }}{% endblocktrans %} \ No newline at end of file diff --git a/notification/templates/notification/short.txt b/notification/templates/notification/short.txt new file mode 100644 index 00000000..e70ad6e5 --- /dev/null +++ b/notification/templates/notification/short.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans %}{{ notice }}{% endblocktrans %} \ No newline at end of file diff --git a/notification/templatetags/__init__.py b/notification/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notification/templatetags/captureas_tag.py b/notification/templatetags/captureas_tag.py new file mode 100644 index 00000000..50e0cb09 --- /dev/null +++ b/notification/templatetags/captureas_tag.py @@ -0,0 +1,23 @@ +from django import template + +register = template.Library() + +@register.tag(name='captureas') +def do_captureas(parser, token): + try: + tag_name, args = token.contents.split(None, 1) + except ValueError: + raise template.TemplateSyntaxError("'captureas' node requires a variable name.") + nodelist = parser.parse(('endcaptureas',)) + parser.delete_first_token() + return CaptureasNode(nodelist, args) + +class CaptureasNode(template.Node): + def __init__(self, nodelist, varname): + self.nodelist = nodelist + self.varname = varname + + def render(self, context): + output = self.nodelist.render(context) + context[self.varname] = output + return '' diff --git a/notification/tests/__init__.py b/notification/tests/__init__.py new file mode 100644 index 00000000..2c65249d --- /dev/null +++ b/notification/tests/__init__.py @@ -0,0 +1 @@ +from notification.tests.test_models import * \ No newline at end of file diff --git a/notification/tests/test_models.py b/notification/tests/test_models.py new file mode 100644 index 00000000..98442ff9 --- /dev/null +++ b/notification/tests/test_models.py @@ -0,0 +1,21 @@ +from django.core.cache import cache +from django.contrib.auth.models import User +from django.test import TestCase + +from notification.models import NoticeType, NoticeSetting + +class BaseTest(TestCase): + + def setUp(self): + + self.user = User.objects.create_user('testuser','test@example.com','pw') + self.user.save() + + +class TestNoticeType(BaseTest): + + def test_create(self): + + self.assertEquals(NoticeType.objects.count(), 0) + NoticeType.create("notice_type", "New notice type", "You have a new notice type") + self.assertEquals(NoticeType.objects.count(), 1) \ No newline at end of file diff --git a/notification/urls.py b/notification/urls.py new file mode 100644 index 00000000..d0cf56aa --- /dev/null +++ b/notification/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls.defaults import * + +from notification.views import notice_settings + + +urlpatterns = patterns("", + url(r"^settings/$", notice_settings, name="notification_notice_settings"), +) \ No newline at end of file diff --git a/notification/views.py b/notification/views.py new file mode 100644 index 00000000..37169d2e --- /dev/null +++ b/notification/views.py @@ -0,0 +1,61 @@ +from django.shortcuts import render_to_response +from django.http import HttpResponseRedirect +from django.template import RequestContext + +from django.contrib.auth.decorators import login_required + +from notification.models import * + + +@login_required +def notice_settings(request): + """ + The notice settings view. + + Template: :template:`notification/notice_settings.html` + + Context: + + notice_types + A list of all :model:`notification.NoticeType` objects. + + notice_settings + A dictionary containing ``column_headers`` for each ``NOTICE_MEDIA`` + and ``rows`` containing a list of dictionaries: ``notice_type``, a + :model:`notification.NoticeType` object and ``cells``, a list of + tuples whose first value is suitable for use in forms and the second + value is ``True`` or ``False`` depending on a ``request.POST`` + variable called ``form_label``, whose valid value is ``on``. + """ + notice_types = NoticeType.objects.all() + settings_table = [] + for notice_type in notice_types: + settings_row = [] + for medium_id, medium_display in NOTICE_MEDIA: + form_label = "%s_%s" % (notice_type.label, medium_id) + setting = get_notification_setting(request.user, notice_type, medium_id) + if request.method == "POST": + if request.POST.get(form_label) == "on": + if not setting.send: + setting.send = True + setting.save() + else: + if setting.send: + setting.send = False + setting.save() + settings_row.append((form_label, setting.send)) + settings_table.append({"notice_type": notice_type, "cells": settings_row}) + + if request.method == "POST": + next_page = request.POST.get("next_page", ".") + return HttpResponseRedirect(next_page) + + notice_settings = { + "column_headers": [medium_display for medium_id, medium_display in NOTICE_MEDIA], + "rows": settings_table, + } + + return render_to_response("notification/notice_settings.html", { + "notice_types": notice_types, + "notice_settings": notice_settings, + }, context_instance=RequestContext(request)) diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..870f3f5c --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +from setuptools import setup, find_packages + + +setup( + name="django-notification", + version=__import__("notification").__version__, + description="User notification management for the Django web framework", + long_description=open("docs/usage.rst").read(), + author="James Tauber", + author_email="jtauber@jtauber.com", + url="https://github.com/jtauber/django-notification", + packages=find_packages(), + classifiers=[ + "Development Status :: 3 - Alpha", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Framework :: Django", + ], + include_package_data=True, + zip_safe=False, +)