├── AUTHORS ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README ├── docs ├── index.txt └── usage.txt ├── notification ├── .gitignore ├── __init__.py ├── admin.py ├── atomformat.py ├── backends │ ├── __init__.py │ ├── base.py │ └── email.py ├── context_processors.py ├── decorators.py ├── engine.py ├── feeds.py ├── lockfile.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── emit_notices.py ├── message.py ├── models.py ├── templates │ └── notification │ │ ├── email_body.txt │ │ ├── email_subject.txt │ │ ├── full.html │ │ ├── full.txt │ │ ├── notice.html │ │ └── short.txt ├── templatetags │ ├── __init__.py │ └── captureas_tag.py ├── urls.py └── views.py └── setup.py /AUTHORS: -------------------------------------------------------------------------------- 1 | 2 | The PRIMARY AUTHORS are: 3 | 4 | * James Tauber 5 | * Brian Rosner 6 | * Jannis Leidel 7 | 8 | ADDITIONAL CONTRIBUTORS include: 9 | 10 | * Eduardo Padoan 11 | * Fabian Neumann 12 | * Juanjo Conti 13 | * Michael Trier 14 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | BI = backward incompatible change 2 | 3 | 0.2.0 4 | ----- 5 | 6 | * BI: renamed Notice.user to Notice.recipient 7 | * BI: renamed {{ user }} context variable in notification templates to 8 | {{ recipient }} 9 | * BI: added nullable Notice.sender and modified send_now and queue to take 10 | an optional sender 11 | * added received and sent methods taking a User instance to Notice.objects 12 | * New default behavior: single notice view now marks unseen notices as seen 13 | * no longer optionally depend on mailer; use django.core.mail.send_mail and 14 | we now encourge use of Django 1.2+ for mailer support 15 | * notifications are not sent to inactive users 16 | * users which do not exist when sending notification are now ignored 17 | * BI: split settings part of notices view to its own view notice_settings 18 | 19 | 0.1.5 20 | ----- 21 | 22 | * added support for DEFAULT_HTTP_PROTOCOL allowing https absolute URLs -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 James Tauber and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include LICENSE 3 | recursive-include docs * 4 | recursive-include notification/templates/notification * 5 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | 2 | Many sites need to notify users when certain events have occurred and to allow 3 | configurable options as to how those notifications are to be received. 4 | 5 | The project aims to provide a Django app for this sort of functionality. This 6 | includes: 7 | 8 | * submission of notification messages by other apps 9 | * notification messages on signing in 10 | * notification messages via email (configurable by user) 11 | * notification messages via feed 12 | -------------------------------------------------------------------------------- /docs/index.txt: -------------------------------------------------------------------------------- 1 | =================== 2 | django-notification 3 | =================== 4 | 5 | Many sites need to notify users when certain events have occurred and to allow 6 | configurable options as to how those notifications are to be received. 7 | 8 | The project aims to provide a Django app for this sort of functionality. This 9 | includes: 10 | 11 | * Submission of notification messages by other apps. 12 | * Notification messages on signing in. 13 | * Notification messages via email (configurable by user). 14 | * Notification messages via feed. 15 | 16 | Contents: 17 | 18 | .. toctree:: 19 | 20 | usage 21 | -------------------------------------------------------------------------------- /docs/usage.txt: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | Integrating notification support into your app is a simple three-step process. 6 | 7 | * create your notice types 8 | * create your notice templates 9 | * send notifications 10 | 11 | Creating Notice Types 12 | ===================== 13 | 14 | You need to call ``create_notice_type(label, display, description)`` once to 15 | create the notice types for your application in the database. ``label`` is just 16 | the internal shortname that will be used for the type, ``display`` is what the 17 | user will see as the name of the notification type and `description` is a 18 | short description. 19 | 20 | For example:: 21 | 22 | notification.create_notice_type("friends_invite", "Invitation Received", "you have received an invitation") 23 | 24 | One good way to automatically do this notice type creation is in a 25 | ``management.py`` file for your app, attached to the syncdb signal. 26 | Here is an example:: 27 | 28 | from django.conf import settings 29 | from django.utils.translation import ugettext_noop as _ 30 | 31 | if "notification" in settings.INSTALLED_APPS: 32 | from notification import models as notification 33 | 34 | def create_notice_types(app, created_models, verbosity, **kwargs): 35 | notification.create_notice_type("friends_invite", _("Invitation Received"), _("you have received an invitation")) 36 | notification.create_notice_type("friends_accept", _("Acceptance Received"), _("an invitation you sent has been accepted")) 37 | 38 | signals.post_syncdb.connect(create_notice_types, sender=notification) 39 | else: 40 | print "Skipping creation of NoticeTypes as notification app not found" 41 | 42 | Notice that the code is wrapped in a conditional clause so if 43 | django-notification is not installed, your app will proceed anyway. 44 | 45 | Note that the display and description arguments are marked for translation by 46 | using ugettext_noop. That will enable you to use Django's makemessages 47 | management command and use django-notification's i18n capabilities. 48 | 49 | Notification templates 50 | ====================== 51 | 52 | There are four different templates that can to be written for the actual content of the notices: 53 | 54 | * ``short.txt`` is a very short, text-only version of the notice (suitable for things like email subjects) 55 | * ``full.txt`` is a longer, text-only version of the notice (suitable for things like email bodies) 56 | * ``notice.html`` is a short, html version of the notice, displayed in a user's notice list on the website 57 | * ``full.html`` is a long, html version of the notice (not currently used for anything) 58 | 59 | Each of these should be put in a directory on the template path called ``notification//``. 60 | If any of these are missing, a default would be used. In practice, ``notice.html`` and ``full.txt`` should be provided at a minimum. 61 | 62 | For example, ``notification/friends_invite/notice.html`` might contain:: 63 | 64 | {% load i18n %}{% url invitations as invitation_page %}{% url profile_detail username=invitation.from_user.username as user_url %} 65 | {% blocktrans with invitation.from_user as invitation_from_user %}{{ invitation_from_user }} has requested to add you as a friend (see invitations){% endblocktrans %} 66 | 67 | and ``notification/friends_full.txt`` might contain:: 68 | 69 | {% 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: 70 | 71 | http://{{ current_site }}{{ invitation_page }} 72 | {% endblocktrans %} 73 | 74 | The context variables are provided when sending the notification. 75 | 76 | 77 | Sending Notification 78 | ==================== 79 | 80 | There are two different ways of sending out notifications. We have support 81 | for blocking and non-blocking methods of sending notifications. The most 82 | simple way to send out a notification, for example:: 83 | 84 | notification.send([to_user], "friends_invite", {"from_user": from_user}) 85 | 86 | One thing to note is that ``send`` is a proxy around either ``send_now`` or 87 | ``queue``. They all have the same signature:: 88 | 89 | send(users, label, extra_context, on_site) 90 | 91 | The parameters are: 92 | 93 | * ``users`` is an iterable of ``User`` objects to send the notification to. 94 | * ``label`` is the label you used in the previous step to identify the notice 95 | type. 96 | * ``extra_content`` is a dictionary to add custom context entries to the 97 | template used to render to notification. This is optional. 98 | * ``on_site`` is a boolean flag to determine whether an ``Notice`` object is 99 | created in the database. 100 | 101 | ``send_now`` vs. ``queue`` vs. ``send`` 102 | --------------------------------------- 103 | 104 | Lets first break down what each does. 105 | 106 | ``send_now`` 107 | ~~~~~~~~~~~~ 108 | 109 | This is a blocking call that will check each user for elgibility of the 110 | notice and actually peform the send. 111 | 112 | ``queue`` 113 | ~~~~~~~~~ 114 | 115 | This is a non-blocking call that will queue the call to ``send_now`` to 116 | be executed at a later time. To later execute the call you need to use 117 | the ``emit_notices`` management command. 118 | 119 | ``send`` 120 | ~~~~~~~~ 121 | 122 | A proxy around ``send_now`` and ``queue``. It gets its behavior from a global 123 | setting named ``NOTIFICATION_QUEUE_ALL``. By default it is ``False``. This 124 | setting is meant to help control whether you want to queue any call to 125 | ``send``. 126 | 127 | ``send`` also accepts ``now`` and ``queue`` keyword arguments. By default 128 | each option is set to ``False`` to honor the global setting which is ``False``. 129 | This enables you to override on a per call basis whether it should call 130 | ``send_now`` or ``queue``. 131 | 132 | Optional notification support 133 | ----------------------------- 134 | 135 | In case you want to use django-notification in your reusable app, you can 136 | wrap the import of django-notification in a conditional clause that tests 137 | if it's installed before sending a notice. As a result your app or 138 | project still functions without notification. 139 | 140 | For example:: 141 | 142 | from django.conf import settings 143 | 144 | if "notification" in settings.INSTALLED_APPS: 145 | from notification import models as notification 146 | else: 147 | notification = None 148 | 149 | and then, later:: 150 | 151 | if notification: 152 | notification.send([to_user], "friends_invite", {"from_user": from_user}) 153 | -------------------------------------------------------------------------------- /notification/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /notification/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 2, 0, "a", 1) # following PEP 386 2 | DEV_N = 4 3 | 4 | 5 | def get_version(): 6 | version = "%s.%s" % (VERSION[0], VERSION[1]) 7 | if VERSION[2]: 8 | version = "%s.%s" % (version, VERSION[2]) 9 | if VERSION[3] != "f": 10 | version = "%s%s%s" % (version, VERSION[3], VERSION[4]) 11 | if DEV_N: 12 | version = "%s.dev%s" % (version, DEV_N) 13 | return version 14 | 15 | 16 | __version__ = get_version() 17 | -------------------------------------------------------------------------------- /notification/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from notification.models import NoticeType, NoticeSetting, Notice, ObservedItem, NoticeQueueBatch 4 | 5 | 6 | class NoticeTypeAdmin(admin.ModelAdmin): 7 | list_display = ["label", "display", "description", "default"] 8 | 9 | 10 | class NoticeSettingAdmin(admin.ModelAdmin): 11 | list_display = ["id", "user", "notice_type", "medium", "send"] 12 | 13 | 14 | class NoticeAdmin(admin.ModelAdmin): 15 | list_display = ["message", "recipient", "sender", "notice_type", "added", "unseen", "archived"] 16 | 17 | 18 | admin.site.register(NoticeQueueBatch) 19 | admin.site.register(NoticeType, NoticeTypeAdmin) 20 | admin.site.register(NoticeSetting, NoticeSettingAdmin) 21 | admin.site.register(Notice, NoticeAdmin) 22 | admin.site.register(ObservedItem) 23 | -------------------------------------------------------------------------------- /notification/atomformat.py: -------------------------------------------------------------------------------- 1 | # 2 | # django-atompub by James Tauber 3 | # http://code.google.com/p/django-atompub/ 4 | # An implementation of the Atom format and protocol for Django 5 | # 6 | # For instructions on how to use this module to generate Atom feeds, 7 | # see http://code.google.com/p/django-atompub/wiki/UserGuide 8 | # 9 | # 10 | # Copyright (c) 2007, James Tauber 11 | # 12 | # Permission is hereby granted, free of charge, to any person obtaining a copy 13 | # of this software and associated documentation files (the "Software"), to deal 14 | # in the Software without restriction, including without limitation the rights 15 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | # copies of the Software, and to permit persons to whom the Software is 17 | # furnished to do so, subject to the following conditions: 18 | # 19 | # The above copyright notice and this permission notice shall be included in 20 | # all copies or substantial portions of the Software. 21 | # 22 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | # THE SOFTWARE. 29 | # 30 | 31 | from xml.sax.saxutils import XMLGenerator 32 | from datetime import datetime 33 | 34 | 35 | GENERATOR_TEXT = 'django-atompub' 36 | GENERATOR_ATTR = { 37 | 'uri': 'http://code.google.com/p/django-atompub/', 38 | 'version': 'r33' 39 | } 40 | 41 | 42 | 43 | ## based on django.utils.xmlutils.SimplerXMLGenerator 44 | class SimplerXMLGenerator(XMLGenerator): 45 | def addQuickElement(self, name, contents=None, attrs=None): 46 | "Convenience method for adding an element with no children" 47 | if attrs is None: attrs = {} 48 | self.startElement(name, attrs) 49 | if contents is not None: 50 | self.characters(contents) 51 | self.endElement(name) 52 | 53 | 54 | 55 | ## based on django.utils.feedgenerator.rfc3339_date 56 | def rfc3339_date(date): 57 | return date.strftime('%Y-%m-%dT%H:%M:%SZ') 58 | 59 | 60 | 61 | ## based on django.utils.feedgenerator.get_tag_uri 62 | def get_tag_uri(url, date): 63 | "Creates a TagURI. See http://diveintomark.org/archives/2004/05/28/howto-atom-id" 64 | parts = urlparse.urlparse(url) 65 | date_part = "" 66 | if date is not None: 67 | date_part = ",%s:" % date.strftime("%Y-%m-%d") 68 | return "tag:%s%s%s/%s" % ( 69 | parts.hostname, 70 | date_part, 71 | parts.path, 72 | parts.fragment, 73 | ) 74 | 75 | 76 | 77 | ## based on django.contrib.syndication.feeds.Feed 78 | class Feed(object): 79 | 80 | 81 | VALIDATE = True 82 | 83 | 84 | def __init__(self, slug, feed_url): 85 | # @@@ slug and feed_url are not used yet 86 | pass 87 | 88 | 89 | def __get_dynamic_attr(self, attname, obj, default=None): 90 | try: 91 | attr = getattr(self, attname) 92 | except AttributeError: 93 | return default 94 | if callable(attr): 95 | # Check func_code.co_argcount rather than try/excepting the 96 | # function and catching the TypeError, because something inside 97 | # the function may raise the TypeError. This technique is more 98 | # accurate. 99 | if hasattr(attr, 'func_code'): 100 | argcount = attr.func_code.co_argcount 101 | else: 102 | argcount = attr.__call__.func_code.co_argcount 103 | if argcount == 2: # one argument is 'self' 104 | return attr(obj) 105 | else: 106 | return attr() 107 | return attr 108 | 109 | 110 | def get_feed(self, extra_params=None): 111 | 112 | if extra_params: 113 | try: 114 | obj = self.get_object(extra_params.split('/')) 115 | except (AttributeError, LookupError): 116 | raise LookupError('Feed does not exist') 117 | else: 118 | obj = None 119 | 120 | feed = AtomFeed( 121 | atom_id = self.__get_dynamic_attr('feed_id', obj), 122 | title = self.__get_dynamic_attr('feed_title', obj), 123 | updated = self.__get_dynamic_attr('feed_updated', obj), 124 | icon = self.__get_dynamic_attr('feed_icon', obj), 125 | logo = self.__get_dynamic_attr('feed_logo', obj), 126 | rights = self.__get_dynamic_attr('feed_rights', obj), 127 | subtitle = self.__get_dynamic_attr('feed_subtitle', obj), 128 | authors = self.__get_dynamic_attr('feed_authors', obj, default=[]), 129 | categories = self.__get_dynamic_attr('feed_categories', obj, default=[]), 130 | contributors = self.__get_dynamic_attr('feed_contributors', obj, default=[]), 131 | links = self.__get_dynamic_attr('feed_links', obj, default=[]), 132 | extra_attrs = self.__get_dynamic_attr('feed_extra_attrs', obj), 133 | hide_generator = self.__get_dynamic_attr('hide_generator', obj, default=False) 134 | ) 135 | 136 | items = self.__get_dynamic_attr('items', obj) 137 | if items is None: 138 | raise LookupError('Feed has no items field') 139 | 140 | for item in items: 141 | feed.add_item( 142 | atom_id = self.__get_dynamic_attr('item_id', item), 143 | title = self.__get_dynamic_attr('item_title', item), 144 | updated = self.__get_dynamic_attr('item_updated', item), 145 | content = self.__get_dynamic_attr('item_content', item), 146 | published = self.__get_dynamic_attr('item_published', item), 147 | rights = self.__get_dynamic_attr('item_rights', item), 148 | source = self.__get_dynamic_attr('item_source', item), 149 | summary = self.__get_dynamic_attr('item_summary', item), 150 | authors = self.__get_dynamic_attr('item_authors', item, default=[]), 151 | categories = self.__get_dynamic_attr('item_categories', item, default=[]), 152 | contributors = self.__get_dynamic_attr('item_contributors', item, default=[]), 153 | links = self.__get_dynamic_attr('item_links', item, default=[]), 154 | extra_attrs = self.__get_dynamic_attr('item_extra_attrs', None, default={}), 155 | ) 156 | 157 | if self.VALIDATE: 158 | feed.validate() 159 | return feed 160 | 161 | 162 | 163 | class ValidationError(Exception): 164 | pass 165 | 166 | 167 | 168 | ## based on django.utils.feedgenerator.SyndicationFeed and django.utils.feedgenerator.Atom1Feed 169 | class AtomFeed(object): 170 | 171 | 172 | mime_type = 'application/atom+xml' 173 | ns = u'http://www.w3.org/2005/Atom' 174 | 175 | 176 | def __init__(self, atom_id, title, updated=None, icon=None, logo=None, rights=None, subtitle=None, 177 | authors=[], categories=[], contributors=[], links=[], extra_attrs={}, hide_generator=False): 178 | if atom_id is None: 179 | raise LookupError('Feed has no feed_id field') 180 | if title is None: 181 | raise LookupError('Feed has no feed_title field') 182 | # if updated == None, we'll calculate it 183 | self.feed = { 184 | 'id': atom_id, 185 | 'title': title, 186 | 'updated': updated, 187 | 'icon': icon, 188 | 'logo': logo, 189 | 'rights': rights, 190 | 'subtitle': subtitle, 191 | 'authors': authors, 192 | 'categories': categories, 193 | 'contributors': contributors, 194 | 'links': links, 195 | 'extra_attrs': extra_attrs, 196 | 'hide_generator': hide_generator, 197 | } 198 | self.items = [] 199 | 200 | 201 | def add_item(self, atom_id, title, updated, content=None, published=None, rights=None, source=None, summary=None, 202 | authors=[], categories=[], contributors=[], links=[], extra_attrs={}): 203 | if atom_id is None: 204 | raise LookupError('Feed has no item_id method') 205 | if title is None: 206 | raise LookupError('Feed has no item_title method') 207 | if updated is None: 208 | raise LookupError('Feed has no item_updated method') 209 | self.items.append({ 210 | 'id': atom_id, 211 | 'title': title, 212 | 'updated': updated, 213 | 'content': content, 214 | 'published': published, 215 | 'rights': rights, 216 | 'source': source, 217 | 'summary': summary, 218 | 'authors': authors, 219 | 'categories': categories, 220 | 'contributors': contributors, 221 | 'links': links, 222 | 'extra_attrs': extra_attrs, 223 | }) 224 | 225 | 226 | def latest_updated(self): 227 | """ 228 | Returns the latest item's updated or the current time if there are no items. 229 | """ 230 | updates = [item['updated'] for item in self.items] 231 | if len(updates) > 0: 232 | updates.sort() 233 | return updates[-1] 234 | else: 235 | return datetime.now() # @@@ really we should allow a feed to define its "start" for this case 236 | 237 | 238 | def write_text_construct(self, handler, element_name, data): 239 | if isinstance(data, tuple): 240 | text_type, text = data 241 | if text_type == 'xhtml': 242 | handler.startElement(element_name, {'type': text_type}) 243 | handler._write(text) # write unescaped -- it had better be well-formed XML 244 | handler.endElement(element_name) 245 | else: 246 | handler.addQuickElement(element_name, text, {'type': text_type}) 247 | else: 248 | handler.addQuickElement(element_name, data) 249 | 250 | 251 | def write_person_construct(self, handler, element_name, person): 252 | handler.startElement(element_name, {}) 253 | handler.addQuickElement(u'name', person['name']) 254 | if 'uri' in person: 255 | handler.addQuickElement(u'uri', person['uri']) 256 | if 'email' in person: 257 | handler.addQuickElement(u'email', person['email']) 258 | handler.endElement(element_name) 259 | 260 | 261 | def write_link_construct(self, handler, link): 262 | if 'length' in link: 263 | link['length'] = str(link['length']) 264 | handler.addQuickElement(u'link', None, link) 265 | 266 | 267 | def write_category_construct(self, handler, category): 268 | handler.addQuickElement(u'category', None, category) 269 | 270 | 271 | def write_source(self, handler, data): 272 | handler.startElement(u'source', {}) 273 | if data.get('id'): 274 | handler.addQuickElement(u'id', data['id']) 275 | if data.get('title'): 276 | self.write_text_construct(handler, u'title', data['title']) 277 | if data.get('subtitle'): 278 | self.write_text_construct(handler, u'subtitle', data['subtitle']) 279 | if data.get('icon'): 280 | handler.addQuickElement(u'icon', data['icon']) 281 | if data.get('logo'): 282 | handler.addQuickElement(u'logo', data['logo']) 283 | if data.get('updated'): 284 | handler.addQuickElement(u'updated', rfc3339_date(data['updated'])) 285 | for category in data.get('categories', []): 286 | self.write_category_construct(handler, category) 287 | for link in data.get('links', []): 288 | self.write_link_construct(handler, link) 289 | for author in data.get('authors', []): 290 | self.write_person_construct(handler, u'author', author) 291 | for contributor in data.get('contributors', []): 292 | self.write_person_construct(handler, u'contributor', contributor) 293 | if data.get('rights'): 294 | self.write_text_construct(handler, u'rights', data['rights']) 295 | handler.endElement(u'source') 296 | 297 | 298 | def write_content(self, handler, data): 299 | if isinstance(data, tuple): 300 | content_dict, text = data 301 | if content_dict.get('type') == 'xhtml': 302 | handler.startElement(u'content', content_dict) 303 | handler._write(text) # write unescaped -- it had better be well-formed XML 304 | handler.endElement(u'content') 305 | else: 306 | handler.addQuickElement(u'content', text, content_dict) 307 | else: 308 | handler.addQuickElement(u'content', data) 309 | 310 | 311 | def write(self, outfile, encoding): 312 | handler = SimplerXMLGenerator(outfile, encoding) 313 | handler.startDocument() 314 | feed_attrs = {u'xmlns': self.ns} 315 | if self.feed.get('extra_attrs'): 316 | feed_attrs.update(self.feed['extra_attrs']) 317 | handler.startElement(u'feed', feed_attrs) 318 | handler.addQuickElement(u'id', self.feed['id']) 319 | self.write_text_construct(handler, u'title', self.feed['title']) 320 | if self.feed.get('subtitle'): 321 | self.write_text_construct(handler, u'subtitle', self.feed['subtitle']) 322 | if self.feed.get('icon'): 323 | handler.addQuickElement(u'icon', self.feed['icon']) 324 | if self.feed.get('logo'): 325 | handler.addQuickElement(u'logo', self.feed['logo']) 326 | if self.feed['updated']: 327 | handler.addQuickElement(u'updated', rfc3339_date(self.feed['updated'])) 328 | else: 329 | handler.addQuickElement(u'updated', rfc3339_date(self.latest_updated())) 330 | for category in self.feed['categories']: 331 | self.write_category_construct(handler, category) 332 | for link in self.feed['links']: 333 | self.write_link_construct(handler, link) 334 | for author in self.feed['authors']: 335 | self.write_person_construct(handler, u'author', author) 336 | for contributor in self.feed['contributors']: 337 | self.write_person_construct(handler, u'contributor', contributor) 338 | if self.feed.get('rights'): 339 | self.write_text_construct(handler, u'rights', self.feed['rights']) 340 | if not self.feed.get('hide_generator'): 341 | handler.addQuickElement(u'generator', GENERATOR_TEXT, GENERATOR_ATTR) 342 | 343 | self.write_items(handler) 344 | 345 | handler.endElement(u'feed') 346 | 347 | 348 | def write_items(self, handler): 349 | for item in self.items: 350 | entry_attrs = item.get('extra_attrs', {}) 351 | handler.startElement(u'entry', entry_attrs) 352 | 353 | handler.addQuickElement(u'id', item['id']) 354 | self.write_text_construct(handler, u'title', item['title']) 355 | handler.addQuickElement(u'updated', rfc3339_date(item['updated'])) 356 | if item.get('published'): 357 | handler.addQuickElement(u'published', rfc3339_date(item['published'])) 358 | if item.get('rights'): 359 | self.write_text_construct(handler, u'rights', item['rights']) 360 | if item.get('source'): 361 | self.write_source(handler, item['source']) 362 | 363 | for author in item['authors']: 364 | self.write_person_construct(handler, u'author', author) 365 | for contributor in item['contributors']: 366 | self.write_person_construct(handler, u'contributor', contributor) 367 | for category in item['categories']: 368 | self.write_category_construct(handler, category) 369 | for link in item['links']: 370 | self.write_link_construct(handler, link) 371 | if item.get('summary'): 372 | self.write_text_construct(handler, u'summary', item['summary']) 373 | if item.get('content'): 374 | self.write_content(handler, item['content']) 375 | 376 | handler.endElement(u'entry') 377 | 378 | 379 | def validate(self): 380 | 381 | def validate_text_construct(obj): 382 | if isinstance(obj, tuple): 383 | if obj[0] not in ['text', 'html', 'xhtml']: 384 | return False 385 | # @@@ no validation is done that 'html' text constructs are valid HTML 386 | # @@@ no validation is done that 'xhtml' text constructs are well-formed XML or valid XHTML 387 | 388 | return True 389 | 390 | if not validate_text_construct(self.feed['title']): 391 | raise ValidationError('feed title has invalid type') 392 | if self.feed.get('subtitle'): 393 | if not validate_text_construct(self.feed['subtitle']): 394 | raise ValidationError('feed subtitle has invalid type') 395 | if self.feed.get('rights'): 396 | if not validate_text_construct(self.feed['rights']): 397 | raise ValidationError('feed rights has invalid type') 398 | 399 | alternate_links = {} 400 | for link in self.feed.get('links'): 401 | if link.get('rel') == 'alternate' or link.get('rel') == None: 402 | key = (link.get('type'), link.get('hreflang')) 403 | if key in alternate_links: 404 | raise ValidationError('alternate links must have unique type/hreflang') 405 | alternate_links[key] = link 406 | 407 | if self.feed.get('authors'): 408 | feed_author = True 409 | else: 410 | feed_author = False 411 | 412 | for item in self.items: 413 | if not feed_author and not item.get('authors'): 414 | if item.get('source') and item['source'].get('authors'): 415 | pass 416 | else: 417 | raise ValidationError('if no feed author, all entries must have author (possibly in source)') 418 | 419 | if not validate_text_construct(item['title']): 420 | raise ValidationError('entry title has invalid type') 421 | if item.get('rights'): 422 | if not validate_text_construct(item['rights']): 423 | raise ValidationError('entry rights has invalid type') 424 | if item.get('summary'): 425 | if not validate_text_construct(item['summary']): 426 | raise ValidationError('entry summary has invalid type') 427 | source = item.get('source') 428 | if source: 429 | if source.get('title'): 430 | if not validate_text_construct(source['title']): 431 | raise ValidationError('source title has invalid type') 432 | if source.get('subtitle'): 433 | if not validate_text_construct(source['subtitle']): 434 | raise ValidationError('source subtitle has invalid type') 435 | if source.get('rights'): 436 | if not validate_text_construct(source['rights']): 437 | raise ValidationError('source rights has invalid type') 438 | 439 | alternate_links = {} 440 | for link in item.get('links'): 441 | if link.get('rel') == 'alternate' or link.get('rel') == None: 442 | key = (link.get('type'), link.get('hreflang')) 443 | if key in alternate_links: 444 | raise ValidationError('alternate links must have unique type/hreflang') 445 | alternate_links[key] = link 446 | 447 | if not item.get('content'): 448 | if not alternate_links: 449 | raise ValidationError('if no content, entry must have alternate link') 450 | 451 | if item.get('content') and isinstance(item.get('content'), tuple): 452 | content_type = item.get('content')[0].get('type') 453 | if item.get('content')[0].get('src'): 454 | if item.get('content')[1]: 455 | raise ValidationError('content with src should be empty') 456 | if not item.get('summary'): 457 | raise ValidationError('content with src requires a summary too') 458 | if content_type in ['text', 'html', 'xhtml']: 459 | raise ValidationError('content with src cannot have type of text, html or xhtml') 460 | if content_type: 461 | if '/' in content_type and \ 462 | not content_type.startswith('text/') and \ 463 | not content_type.endswith('/xml') and not content_type.endswith('+xml') and \ 464 | not content_type in ['application/xml-external-parsed-entity', 'application/xml-dtd']: 465 | # @@@ check content is Base64 466 | if not item.get('summary'): 467 | raise ValidationError('content in Base64 requires a summary too') 468 | if content_type not in ['text', 'html', 'xhtml'] and '/' not in content_type: 469 | raise ValidationError('content type does not appear to be valid') 470 | 471 | # @@@ no validation is done that 'html' text constructs are valid HTML 472 | # @@@ no validation is done that 'xhtml' text constructs are well-formed XML or valid XHTML 473 | 474 | return 475 | 476 | return 477 | 478 | 479 | 480 | class LegacySyndicationFeed(AtomFeed): 481 | """ 482 | Provides an SyndicationFeed-compatible interface in its __init__ and 483 | add_item but is really a new AtomFeed object. 484 | """ 485 | 486 | def __init__(self, title, link, description, language=None, author_email=None, 487 | author_name=None, author_link=None, subtitle=None, categories=[], 488 | feed_url=None, feed_copyright=None): 489 | 490 | atom_id = link 491 | title = title 492 | updated = None # will be calculated 493 | rights = feed_copyright 494 | subtitle = subtitle 495 | author_dict = {'name': author_name} 496 | if author_link: 497 | author_dict['uri'] = author_uri 498 | if author_email: 499 | author_dict['email'] = author_email 500 | authors = [author_dict] 501 | if categories: 502 | categories = [{'term': term} for term in categories] 503 | links = [{'rel': 'alternate', 'href': link}] 504 | if feed_url: 505 | links.append({'rel': 'self', 'href': feed_url}) 506 | if language: 507 | extra_attrs = {'xml:lang': language} 508 | else: 509 | extra_attrs = {} 510 | 511 | # description ignored (as with Atom1Feed) 512 | 513 | AtomFeed.__init__(self, atom_id, title, updated, rights=rights, subtitle=subtitle, 514 | authors=authors, categories=categories, links=links, extra_attrs=extra_attrs) 515 | 516 | 517 | def add_item(self, title, link, description, author_email=None, 518 | author_name=None, author_link=None, pubdate=None, comments=None, 519 | unique_id=None, enclosure=None, categories=[], item_copyright=None): 520 | 521 | if unique_id: 522 | atom_id = unique_id 523 | else: 524 | atom_id = get_tag_uri(link, pubdate) 525 | title = title 526 | updated = pubdate 527 | if item_copyright: 528 | rights = item_copyright 529 | else: 530 | rights = None 531 | if description: 532 | summary = 'html', description 533 | else: 534 | summary = None 535 | author_dict = {'name': author_name} 536 | if author_link: 537 | author_dict['uri'] = author_uri 538 | if author_email: 539 | author_dict['email'] = author_email 540 | authors = [author_dict] 541 | categories = [{'term': term} for term in categories] 542 | links = [{'rel': 'alternate', 'href': link}] 543 | if enclosure: 544 | links.append({'rel': 'enclosure', 'href': enclosure.url, 'length': enclosure.length, 'type': enclosure.mime_type}) 545 | 546 | AtomFeed.add_item(self, atom_id, title, updated, rights=rights, summary=summary, 547 | authors=authors, categories=categories, links=links) 548 | -------------------------------------------------------------------------------- /notification/backends/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | 4 | from django.conf import settings 5 | from django.core import exceptions 6 | 7 | from base import BaseBackend 8 | 9 | # mostly for backend compatibility 10 | default_backends = ( 11 | ("email", "notification.backends.email.EmailBackend"), 12 | ) 13 | 14 | def load_backends(): 15 | backends = [] 16 | for medium_id, bits in enumerate(getattr(settings, "NOTIFICATION_BACKENDS", default_backends)): 17 | if len(bits) == 2: 18 | label, backend_path = bits 19 | spam_sensitivity = None 20 | elif len(bits) == 3: 21 | label, backend_path, spam_sensitivity = bits 22 | else: 23 | raise exceptions.ImproperlyConfigured, "NOTIFICATION_BACKENDS does not contain enough data." 24 | dot = backend_path.rindex(".") 25 | backend_mod, backend_class = backend_path[:dot], backend_path[dot+1:] 26 | try: 27 | # import the module and get the module from sys.modules 28 | __import__(backend_mod) 29 | mod = sys.modules[backend_mod] 30 | except ImportError, e: 31 | raise exceptions.ImproperlyConfigured, 'Error importing notification backend %s: "%s"' % (backend_mod, e) 32 | # add the backend label and an instantiated backend class to the 33 | # backends list. 34 | backend_instance = getattr(mod, backend_class)(medium_id, spam_sensitivity) 35 | backends.append(((medium_id, label), backend_instance)) 36 | return dict(backends) 37 | -------------------------------------------------------------------------------- /notification/backends/base.py: -------------------------------------------------------------------------------- 1 | 2 | from django.template.loader import render_to_string 3 | 4 | class BaseBackend(object): 5 | """ 6 | The base backend. 7 | """ 8 | def __init__(self, medium_id, spam_sensitivity=None): 9 | self.medium_id = medium_id 10 | if spam_sensitivity is not None: 11 | self.spam_sensitivity = spam_sensitivity 12 | 13 | def can_send(self, user, notice_type): 14 | """ 15 | Determines whether this backend is allowed to send a notification to 16 | the given user and notice_type. 17 | """ 18 | from notification.models import should_send 19 | if should_send(user, notice_type, self.medium_id): 20 | return True 21 | return False 22 | 23 | def deliver(self, recipient, notice_type, extra_context): 24 | """ 25 | Deliver a notification to the given recipient. 26 | """ 27 | raise NotImplemented() 28 | 29 | def get_formatted_messages(self, formats, label, context): 30 | """ 31 | Returns a dictionary with the format identifier as the key. The values are 32 | are fully rendered templates with the given context. 33 | """ 34 | format_templates = {} 35 | for format in formats: 36 | # conditionally turn off autoescaping for .txt extensions in format 37 | if format.endswith(".txt"): 38 | context.autoescape = False 39 | format_templates[format] = render_to_string(( 40 | "notification/%s/%s" % (label, format), 41 | "notification/%s" % format), context_instance=context) 42 | return format_templates 43 | -------------------------------------------------------------------------------- /notification/backends/email.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.mail import send_mail 3 | from django.core.urlresolvers import reverse 4 | from django.db.models.loading import get_app 5 | from django.template import Context 6 | from django.template.loader import render_to_string 7 | from django.utils.translation import ugettext 8 | from django.core.exceptions import ImproperlyConfigured 9 | 10 | from django.contrib.sites.models import Site 11 | 12 | from notification import backends 13 | from notification.message import message_to_text 14 | 15 | 16 | class EmailBackend(backends.BaseBackend): 17 | spam_sensitivity = 2 18 | 19 | def can_send(self, user, notice_type): 20 | can_send = super(EmailBackend, self).can_send(user, notice_type) 21 | if can_send and user.email: 22 | return True 23 | return False 24 | 25 | def deliver(self, recipient, sender, notice_type, extra_context): 26 | # TODO: require this to be passed in extra_context 27 | current_site = Site.objects.get_current() 28 | notices_url = u"http://%s%s" % ( 29 | unicode(Site.objects.get_current()), 30 | reverse("notification_notices"), 31 | ) 32 | 33 | # update context with user specific translations 34 | context = Context({ 35 | "recipient": recipient, 36 | "sender": sender, 37 | "notice": ugettext(notice_type.display), 38 | "notices_url": notices_url, 39 | "current_site": current_site, 40 | }) 41 | context.update(extra_context) 42 | 43 | messages = self.get_formatted_messages(( 44 | "short.txt", 45 | "full.txt" 46 | ), notice_type.label, context) 47 | 48 | subject = "".join(render_to_string("notification/email_subject.txt", { 49 | "message": messages["short.txt"], 50 | }, context).splitlines()) 51 | 52 | body = render_to_string("notification/email_body.txt", { 53 | "message": messages["full.txt"], 54 | }, context) 55 | 56 | send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [recipient.email]) 57 | -------------------------------------------------------------------------------- /notification/context_processors.py: -------------------------------------------------------------------------------- 1 | from notification.models import Notice 2 | 3 | 4 | def notification(request): 5 | if request.user.is_authenticated(): 6 | return { 7 | "notice_unseen_count": Notice.objects.unseen_count_for(request.user, on_site=True), 8 | } 9 | else: 10 | return {} 11 | -------------------------------------------------------------------------------- /notification/decorators.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext as _ 2 | from django.http import HttpResponse 3 | from django.conf import settings 4 | 5 | from django.contrib.auth import authenticate, login 6 | 7 | 8 | def simple_basic_auth_callback(request, user, *args, **kwargs): 9 | """ 10 | Simple callback to automatically login the given user after a successful 11 | basic authentication. 12 | """ 13 | login(request, user) 14 | request.user = user 15 | 16 | 17 | def basic_auth_required(realm=None, test_func=None, callback_func=None): 18 | """ 19 | This decorator should be used with views that need simple authentication 20 | against Django's authentication framework. 21 | 22 | The ``realm`` string is shown during the basic auth query. 23 | 24 | It takes a ``test_func`` argument that is used to validate the given 25 | credentials and return the decorated function if successful. 26 | 27 | If unsuccessful the decorator will try to authenticate and checks if the 28 | user has the ``is_active`` field set to True. 29 | 30 | In case of a successful authentication the ``callback_func`` will be 31 | called by passing the ``request`` and the ``user`` object. After that the 32 | actual view function will be called. 33 | 34 | If all of the above fails a "Authorization Required" message will be shown. 35 | """ 36 | if realm is None: 37 | realm = getattr(settings, "HTTP_AUTHENTICATION_REALM", _("Restricted Access")) 38 | if test_func is None: 39 | test_func = lambda u: u.is_authenticated() 40 | 41 | def decorator(view_func): 42 | def basic_auth(request, *args, **kwargs): 43 | # Just return the original view because already logged in 44 | if test_func(request.user): 45 | return view_func(request, *args, **kwargs) 46 | 47 | # Not logged in, look if login credentials are provided 48 | if "HTTP_AUTHORIZATION" in request.META: 49 | auth_method, auth = request.META["HTTP_AUTHORIZATION"].split(" ", 1) 50 | if "basic" == auth_method.lower(): 51 | auth = auth.strip().decode("base64") 52 | username, password = auth.split(":",1) 53 | user = authenticate(username=username, password=password) 54 | if user is not None: 55 | if user.is_active: 56 | if callback_func is not None and callable(callback_func): 57 | callback_func(request, user, *args, **kwargs) 58 | return view_func(request, *args, **kwargs) 59 | 60 | response = HttpResponse(_("Authorization Required"), mimetype="text/plain") 61 | response.status_code = 401 62 | response["WWW-Authenticate"] = "Basic realm='%s'" % realm 63 | return response 64 | return basic_auth 65 | return decorator 66 | -------------------------------------------------------------------------------- /notification/engine.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import logging 4 | import traceback 5 | 6 | try: 7 | import cPickle as pickle 8 | except ImportError: 9 | import pickle 10 | 11 | from django.conf import settings 12 | from django.core.mail import mail_admins 13 | from django.contrib.auth.models import User 14 | from django.contrib.sites.models import Site 15 | 16 | from lockfile import FileLock, AlreadyLocked, LockTimeout 17 | 18 | from notification.models import NoticeQueueBatch 19 | from notification import models as notification 20 | 21 | # lock timeout value. how long to wait for the lock to become available. 22 | # default behavior is to never wait for the lock to be available. 23 | LOCK_WAIT_TIMEOUT = getattr(settings, "NOTIFICATION_LOCK_WAIT_TIMEOUT", -1) 24 | 25 | 26 | def send_all(): 27 | lock = FileLock("send_notices") 28 | 29 | logging.debug("acquiring lock...") 30 | try: 31 | lock.acquire(LOCK_WAIT_TIMEOUT) 32 | except AlreadyLocked: 33 | logging.debug("lock already in place. quitting.") 34 | return 35 | except LockTimeout: 36 | logging.debug("waiting for the lock timed out. quitting.") 37 | return 38 | logging.debug("acquired.") 39 | 40 | batches, sent = 0, 0 41 | start_time = time.time() 42 | 43 | try: 44 | # nesting the try statement to be Python 2.4 45 | try: 46 | for queued_batch in NoticeQueueBatch.objects.all(): 47 | notices = pickle.loads(str(queued_batch.pickled_data).decode("base64")) 48 | for user, label, extra_context, on_site, sender in notices: 49 | try: 50 | user = User.objects.get(pk=user) 51 | logging.info("emitting notice %s to %s" % (label, user)) 52 | # call this once per user to be atomic and allow for logging to 53 | # accurately show how long each takes. 54 | notification.send_now([user], label, extra_context, on_site, sender) 55 | except User.DoesNotExist: 56 | # Ignore deleted users, just warn about them 57 | logging.warning("not emitting notice %s to user %s since it does not exist" % (label, user)) 58 | sent += 1 59 | queued_batch.delete() 60 | batches += 1 61 | except: 62 | # get the exception 63 | exc_class, e, t = sys.exc_info() 64 | # email people 65 | current_site = Site.objects.get_current() 66 | subject = "[%s emit_notices] %r" % (current_site.name, e) 67 | message = "%s" % ("\n".join(traceback.format_exception(*sys.exc_info())),) 68 | mail_admins(subject, message, fail_silently=True) 69 | # log it as critical 70 | logging.critical("an exception occurred: %r" % e) 71 | finally: 72 | logging.debug("releasing lock...") 73 | lock.release() 74 | logging.debug("released.") 75 | 76 | logging.info("") 77 | logging.info("%s batches, %s sent" % (batches, sent,)) 78 | logging.info("done in %.2f seconds" % (time.time() - start_time)) 79 | -------------------------------------------------------------------------------- /notification/feeds.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.core.urlresolvers import reverse 4 | from django.conf import settings 5 | from django.shortcuts import get_object_or_404 6 | from django.template.defaultfilters import linebreaks, escape, striptags 7 | from django.utils.translation import ugettext_lazy as _ 8 | 9 | from django.contrib.auth.models import User 10 | from django.contrib.sites.models import Site 11 | 12 | from notification.models import Notice 13 | from notification.atomformat import Feed 14 | 15 | 16 | ITEMS_PER_FEED = getattr(settings, "ITEMS_PER_FEED", 20) 17 | DEFAULT_HTTP_PROTOCOL = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") 18 | 19 | 20 | class BaseNoticeFeed(Feed): 21 | 22 | def item_id(self, notification): 23 | return "%s://%s%s" % ( 24 | DEFAULT_HTTP_PROTOCOL, 25 | Site.objects.get_current().domain, 26 | notification.get_absolute_url(), 27 | ) 28 | 29 | def item_title(self, notification): 30 | return striptags(notification.message) 31 | 32 | def item_updated(self, notification): 33 | return notification.added 34 | 35 | def item_published(self, notification): 36 | return notification.added 37 | 38 | def item_content(self, notification): 39 | return {"type": "html"}, linebreaks(escape(notification.message)) 40 | 41 | def item_links(self, notification): 42 | return [{"href": self.item_id(notification)}] 43 | 44 | def item_authors(self, notification): 45 | return [{"name": notification.recipient.username}] 46 | 47 | 48 | class NoticeUserFeed(BaseNoticeFeed): 49 | 50 | def get_object(self, params): 51 | return get_object_or_404(User, username=params[0].lower()) 52 | 53 | def feed_id(self, user): 54 | return "%s://%s%s" % ( 55 | DEFAULT_HTTP_PROTOCOL, 56 | Site.objects.get_current().domain, 57 | reverse("notification_feed_for_user"), 58 | ) 59 | 60 | def feed_title(self, user): 61 | return _("Notices Feed") 62 | 63 | def feed_updated(self, user): 64 | qs = Notice.objects.filter(recipient=user) 65 | # We return an arbitrary date if there are no results, because there 66 | # must be a feed_updated field as per the Atom specifications, however 67 | # there is no real data to go by, and an arbitrary date can be static. 68 | if qs.count() == 0: 69 | return datetime.datetime(year=2008, month=7, day=1) 70 | return qs.latest("added").added 71 | 72 | def feed_links(self, user): 73 | complete_url = "%s://%s%s" % ( 74 | DEFAULT_HTTP_PROTOCOL, 75 | Site.objects.get_current().domain, 76 | reverse("notification_notices"), 77 | ) 78 | return ({"href": complete_url},) 79 | 80 | def items(self, user): 81 | return Notice.objects.notices_for(user).order_by("-added")[:ITEMS_PER_FEED] 82 | -------------------------------------------------------------------------------- /notification/lockfile.py: -------------------------------------------------------------------------------- 1 | """ 2 | lockfile.py - Platform-independent advisory file locks. 3 | 4 | Requires Python 2.5 unless you apply 2.4.diff 5 | Locking is done on a per-thread basis instead of a per-process basis. 6 | 7 | Usage: 8 | 9 | >>> lock = FileLock('somefile') 10 | >>> try: 11 | ... lock.acquire() 12 | ... except AlreadyLocked: 13 | ... print 'somefile', 'is locked already.' 14 | ... except LockFailed: 15 | ... print 'somefile', 'can\\'t be locked.' 16 | ... else: 17 | ... print 'got lock' 18 | got lock 19 | >>> print lock.is_locked() 20 | True 21 | >>> lock.release() 22 | 23 | >>> lock = FileLock('somefile') 24 | >>> print lock.is_locked() 25 | False 26 | >>> with lock: 27 | ... print lock.is_locked() 28 | True 29 | >>> print lock.is_locked() 30 | False 31 | >>> # It is okay to lock twice from the same thread... 32 | >>> with lock: 33 | ... lock.acquire() 34 | ... 35 | >>> # Though no counter is kept, so you can't unlock multiple times... 36 | >>> print lock.is_locked() 37 | False 38 | 39 | Exceptions: 40 | 41 | Error - base class for other exceptions 42 | LockError - base class for all locking exceptions 43 | AlreadyLocked - Another thread or process already holds the lock 44 | LockFailed - Lock failed for some other reason 45 | UnlockError - base class for all unlocking exceptions 46 | AlreadyUnlocked - File was not locked. 47 | NotMyLock - File was locked but not by the current thread/process 48 | """ 49 | 50 | from __future__ import division 51 | 52 | import sys 53 | import socket 54 | import os 55 | import thread 56 | import threading 57 | import time 58 | import errno 59 | import urllib 60 | 61 | # Work with PEP8 and non-PEP8 versions of threading module. 62 | if not hasattr(threading, "current_thread"): 63 | threading.current_thread = threading.currentThread 64 | if not hasattr(threading.Thread, "get_name"): 65 | threading.Thread.get_name = threading.Thread.getName 66 | 67 | __all__ = ['Error', 'LockError', 'LockTimeout', 'AlreadyLocked', 68 | 'LockFailed', 'UnlockError', 'NotLocked', 'NotMyLock', 69 | 'LinkFileLock', 'MkdirFileLock', 'SQLiteFileLock'] 70 | 71 | class Error(Exception): 72 | """ 73 | Base class for other exceptions. 74 | 75 | >>> try: 76 | ... raise Error 77 | ... except Exception: 78 | ... pass 79 | """ 80 | pass 81 | 82 | class LockError(Error): 83 | """ 84 | Base class for error arising from attempts to acquire the lock. 85 | 86 | >>> try: 87 | ... raise LockError 88 | ... except Error: 89 | ... pass 90 | """ 91 | pass 92 | 93 | class LockTimeout(LockError): 94 | """Raised when lock creation fails within a user-defined period of time. 95 | 96 | >>> try: 97 | ... raise LockTimeout 98 | ... except LockError: 99 | ... pass 100 | """ 101 | pass 102 | 103 | class AlreadyLocked(LockError): 104 | """Some other thread/process is locking the file. 105 | 106 | >>> try: 107 | ... raise AlreadyLocked 108 | ... except LockError: 109 | ... pass 110 | """ 111 | pass 112 | 113 | class LockFailed(LockError): 114 | """Lock file creation failed for some other reason. 115 | 116 | >>> try: 117 | ... raise LockFailed 118 | ... except LockError: 119 | ... pass 120 | """ 121 | pass 122 | 123 | class UnlockError(Error): 124 | """ 125 | Base class for errors arising from attempts to release the lock. 126 | 127 | >>> try: 128 | ... raise UnlockError 129 | ... except Error: 130 | ... pass 131 | """ 132 | pass 133 | 134 | class NotLocked(UnlockError): 135 | """Raised when an attempt is made to unlock an unlocked file. 136 | 137 | >>> try: 138 | ... raise NotLocked 139 | ... except UnlockError: 140 | ... pass 141 | """ 142 | pass 143 | 144 | class NotMyLock(UnlockError): 145 | """Raised when an attempt is made to unlock a file someone else locked. 146 | 147 | >>> try: 148 | ... raise NotMyLock 149 | ... except UnlockError: 150 | ... pass 151 | """ 152 | pass 153 | 154 | class LockBase: 155 | """Base class for platform-specific lock classes.""" 156 | def __init__(self, path, threaded=True): 157 | """ 158 | >>> lock = LockBase('somefile') 159 | >>> lock = LockBase('somefile', threaded=False) 160 | """ 161 | self.path = path 162 | self.lock_file = os.path.abspath(path) + ".lock" 163 | self.hostname = socket.gethostname() 164 | self.pid = os.getpid() 165 | if threaded: 166 | name = threading.current_thread().get_name() 167 | tname = "%s-" % urllib.quote(name, safe="") 168 | else: 169 | tname = "" 170 | dirname = os.path.dirname(self.lock_file) 171 | self.unique_name = os.path.join(dirname, 172 | "%s.%s%s" % (self.hostname, 173 | tname, 174 | self.pid)) 175 | 176 | def acquire(self, timeout=None): 177 | """ 178 | Acquire the lock. 179 | 180 | * If timeout is omitted (or None), wait forever trying to lock the 181 | file. 182 | 183 | * If timeout > 0, try to acquire the lock for that many seconds. If 184 | the lock period expires and the file is still locked, raise 185 | LockTimeout. 186 | 187 | * If timeout <= 0, raise AlreadyLocked immediately if the file is 188 | already locked. 189 | """ 190 | raise NotImplemented("implement in subclass") 191 | 192 | def release(self): 193 | """ 194 | Release the lock. 195 | 196 | If the file is not locked, raise NotLocked. 197 | """ 198 | raise NotImplemented("implement in subclass") 199 | 200 | def is_locked(self): 201 | """ 202 | Tell whether or not the file is locked. 203 | """ 204 | raise NotImplemented("implement in subclass") 205 | 206 | def i_am_locking(self): 207 | """ 208 | Return True if this object is locking the file. 209 | """ 210 | raise NotImplemented("implement in subclass") 211 | 212 | def break_lock(self): 213 | """ 214 | Remove a lock. Useful if a locking thread failed to unlock. 215 | """ 216 | raise NotImplemented("implement in subclass") 217 | 218 | def __enter__(self): 219 | """ 220 | Context manager support. 221 | """ 222 | self.acquire() 223 | return self 224 | 225 | def __exit__(self, *_exc): 226 | """ 227 | Context manager support. 228 | """ 229 | self.release() 230 | 231 | class LinkFileLock(LockBase): 232 | """Lock access to a file using atomic property of link(2).""" 233 | 234 | def acquire(self, timeout=None): 235 | try: 236 | open(self.unique_name, "wb").close() 237 | except IOError: 238 | raise LockFailed("failed to create %s" % self.unique_name) 239 | 240 | end_time = time.time() 241 | if timeout is not None and timeout > 0: 242 | end_time += timeout 243 | 244 | while True: 245 | # Try and create a hard link to it. 246 | try: 247 | os.link(self.unique_name, self.lock_file) 248 | except OSError: 249 | # Link creation failed. Maybe we've double-locked? 250 | nlinks = os.stat(self.unique_name).st_nlink 251 | if nlinks == 2: 252 | # The original link plus the one I created == 2. We're 253 | # good to go. 254 | return 255 | else: 256 | # Otherwise the lock creation failed. 257 | if timeout is not None and time.time() > end_time: 258 | os.unlink(self.unique_name) 259 | if timeout > 0: 260 | raise LockTimeout 261 | else: 262 | raise AlreadyLocked 263 | time.sleep(timeout is not None and timeout/10 or 0.1) 264 | else: 265 | # Link creation succeeded. We're good to go. 266 | return 267 | 268 | def release(self): 269 | if not self.is_locked(): 270 | raise NotLocked 271 | elif not os.path.exists(self.unique_name): 272 | raise NotMyLock 273 | os.unlink(self.unique_name) 274 | os.unlink(self.lock_file) 275 | 276 | def is_locked(self): 277 | return os.path.exists(self.lock_file) 278 | 279 | def i_am_locking(self): 280 | return (self.is_locked() and 281 | os.path.exists(self.unique_name) and 282 | os.stat(self.unique_name).st_nlink == 2) 283 | 284 | def break_lock(self): 285 | if os.path.exists(self.lock_file): 286 | os.unlink(self.lock_file) 287 | 288 | class MkdirFileLock(LockBase): 289 | """Lock file by creating a directory.""" 290 | def __init__(self, path, threaded=True): 291 | """ 292 | >>> lock = MkdirFileLock('somefile') 293 | >>> lock = MkdirFileLock('somefile', threaded=False) 294 | """ 295 | LockBase.__init__(self, path, threaded) 296 | if threaded: 297 | tname = "%x-" % thread.get_ident() 298 | else: 299 | tname = "" 300 | # Lock file itself is a directory. Place the unique file name into 301 | # it. 302 | self.unique_name = os.path.join(self.lock_file, 303 | "%s.%s%s" % (self.hostname, 304 | tname, 305 | self.pid)) 306 | 307 | def acquire(self, timeout=None): 308 | end_time = time.time() 309 | if timeout is not None and timeout > 0: 310 | end_time += timeout 311 | 312 | if timeout is None: 313 | wait = 0.1 314 | else: 315 | wait = max(0, timeout / 10) 316 | 317 | while True: 318 | try: 319 | os.mkdir(self.lock_file) 320 | except OSError: 321 | err = sys.exc_info()[1] 322 | if err.errno == errno.EEXIST: 323 | # Already locked. 324 | if os.path.exists(self.unique_name): 325 | # Already locked by me. 326 | return 327 | if timeout is not None and time.time() > end_time: 328 | if timeout > 0: 329 | raise LockTimeout 330 | else: 331 | # Someone else has the lock. 332 | raise AlreadyLocked 333 | time.sleep(wait) 334 | else: 335 | # Couldn't create the lock for some other reason 336 | raise LockFailed("failed to create %s" % self.lock_file) 337 | else: 338 | open(self.unique_name, "wb").close() 339 | return 340 | 341 | def release(self): 342 | if not self.is_locked(): 343 | raise NotLocked 344 | elif not os.path.exists(self.unique_name): 345 | raise NotMyLock 346 | os.unlink(self.unique_name) 347 | os.rmdir(self.lock_file) 348 | 349 | def is_locked(self): 350 | return os.path.exists(self.lock_file) 351 | 352 | def i_am_locking(self): 353 | return (self.is_locked() and 354 | os.path.exists(self.unique_name)) 355 | 356 | def break_lock(self): 357 | if os.path.exists(self.lock_file): 358 | for name in os.listdir(self.lock_file): 359 | os.unlink(os.path.join(self.lock_file, name)) 360 | os.rmdir(self.lock_file) 361 | 362 | class SQLiteFileLock(LockBase): 363 | "Demonstration of using same SQL-based locking." 364 | 365 | import tempfile 366 | _fd, testdb = tempfile.mkstemp() 367 | os.close(_fd) 368 | os.unlink(testdb) 369 | del _fd, tempfile 370 | 371 | def __init__(self, path, threaded=True): 372 | LockBase.__init__(self, path, threaded) 373 | self.lock_file = unicode(self.lock_file) 374 | self.unique_name = unicode(self.unique_name) 375 | 376 | import sqlite3 377 | self.connection = sqlite3.connect(SQLiteFileLock.testdb) 378 | 379 | c = self.connection.cursor() 380 | try: 381 | c.execute("create table locks" 382 | "(" 383 | " lock_file varchar(32)," 384 | " unique_name varchar(32)" 385 | ")") 386 | except sqlite3.OperationalError: 387 | pass 388 | else: 389 | self.connection.commit() 390 | import atexit 391 | atexit.register(os.unlink, SQLiteFileLock.testdb) 392 | 393 | def acquire(self, timeout=None): 394 | end_time = time.time() 395 | if timeout is not None and timeout > 0: 396 | end_time += timeout 397 | 398 | if timeout is None: 399 | wait = 0.1 400 | elif timeout <= 0: 401 | wait = 0 402 | else: 403 | wait = timeout / 10 404 | 405 | cursor = self.connection.cursor() 406 | 407 | while True: 408 | if not self.is_locked(): 409 | # Not locked. Try to lock it. 410 | cursor.execute("insert into locks" 411 | " (lock_file, unique_name)" 412 | " values" 413 | " (?, ?)", 414 | (self.lock_file, self.unique_name)) 415 | self.connection.commit() 416 | 417 | # Check to see if we are the only lock holder. 418 | cursor.execute("select * from locks" 419 | " where unique_name = ?", 420 | (self.unique_name,)) 421 | rows = cursor.fetchall() 422 | if len(rows) > 1: 423 | # Nope. Someone else got there. Remove our lock. 424 | cursor.execute("delete from locks" 425 | " where unique_name = ?", 426 | (self.unique_name,)) 427 | self.connection.commit() 428 | else: 429 | # Yup. We're done, so go home. 430 | return 431 | else: 432 | # Check to see if we are the only lock holder. 433 | cursor.execute("select * from locks" 434 | " where unique_name = ?", 435 | (self.unique_name,)) 436 | rows = cursor.fetchall() 437 | if len(rows) == 1: 438 | # We're the locker, so go home. 439 | return 440 | 441 | # Maybe we should wait a bit longer. 442 | if timeout is not None and time.time() > end_time: 443 | if timeout > 0: 444 | # No more waiting. 445 | raise LockTimeout 446 | else: 447 | # Someone else has the lock and we are impatient.. 448 | raise AlreadyLocked 449 | 450 | # Well, okay. We'll give it a bit longer. 451 | time.sleep(wait) 452 | 453 | def release(self): 454 | if not self.is_locked(): 455 | raise NotLocked 456 | if not self.i_am_locking(): 457 | raise NotMyLock((self._who_is_locking(), self.unique_name)) 458 | cursor = self.connection.cursor() 459 | cursor.execute("delete from locks" 460 | " where unique_name = ?", 461 | (self.unique_name,)) 462 | self.connection.commit() 463 | 464 | def _who_is_locking(self): 465 | cursor = self.connection.cursor() 466 | cursor.execute("select unique_name from locks" 467 | " where lock_file = ?", 468 | (self.lock_file,)) 469 | return cursor.fetchone()[0] 470 | 471 | def is_locked(self): 472 | cursor = self.connection.cursor() 473 | cursor.execute("select * from locks" 474 | " where lock_file = ?", 475 | (self.lock_file,)) 476 | rows = cursor.fetchall() 477 | return not not rows 478 | 479 | def i_am_locking(self): 480 | cursor = self.connection.cursor() 481 | cursor.execute("select * from locks" 482 | " where lock_file = ?" 483 | " and unique_name = ?", 484 | (self.lock_file, self.unique_name)) 485 | return not not cursor.fetchall() 486 | 487 | def break_lock(self): 488 | cursor = self.connection.cursor() 489 | cursor.execute("delete from locks" 490 | " where lock_file = ?", 491 | (self.lock_file,)) 492 | self.connection.commit() 493 | 494 | if hasattr(os, "link"): 495 | FileLock = LinkFileLock 496 | else: 497 | FileLock = MkdirFileLock 498 | -------------------------------------------------------------------------------- /notification/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brosner/django-notification/29d5ae604099b8406fc3c31084dd6f66dccf2bc8/notification/management/__init__.py -------------------------------------------------------------------------------- /notification/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brosner/django-notification/29d5ae604099b8406fc3c31084dd6f66dccf2bc8/notification/management/commands/__init__.py -------------------------------------------------------------------------------- /notification/management/commands/emit_notices.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | 4 | from django.core.management.base import NoArgsCommand 5 | 6 | from notification.engine import send_all 7 | 8 | class Command(NoArgsCommand): 9 | help = "Emit queued notices." 10 | 11 | def handle_noargs(self, **options): 12 | logging.basicConfig(level=logging.DEBUG, format="%(message)s") 13 | logging.info("-" * 72) 14 | send_all() 15 | -------------------------------------------------------------------------------- /notification/message.py: -------------------------------------------------------------------------------- 1 | from django.db.models import get_model 2 | from django.utils.translation import ugettext 3 | 4 | # a notice like "foo and bar are now friends" is stored in the database 5 | # as "{auth.User.5} and {auth.User.7} are now friends". 6 | # 7 | # encode_object takes an object and turns it into "{app.Model.pk}" or 8 | # "{app.Model.pk.msgid}" if named arguments are used in send() 9 | # decode_object takes "{app.Model.pk}" and turns it into the object 10 | # 11 | # encode_message takes either ("%s and %s are now friends", [foo, bar]) or 12 | # ("%(foo)s and %(bar)s are now friends", {'foo':foo, 'bar':bar}) and turns 13 | # it into "{auth.User.5} and {auth.User.7} are now friends". 14 | # 15 | # decode_message takes "{auth.User.5} and {auth.User.7}" and converts it 16 | # into a string using the given decode function to convert the object to 17 | # string representation 18 | # 19 | # message_to_text and message_to_html use decode_message to produce a 20 | # text and html version of the message respectively. 21 | 22 | def encode_object(obj, name=None): 23 | encoded = "%s.%s.%s" % (obj._meta.app_label, obj._meta.object_name, obj.pk) 24 | if name: 25 | encoded = "%s.%s" % (encoded, name) 26 | return "{%s}" % encoded 27 | 28 | 29 | def encode_message(message_template, objects): 30 | if objects is None: 31 | return message_template 32 | if isinstance(objects, list) or isinstance(objects, tuple): 33 | return message_template % tuple(encode_object(obj) for obj in objects) 34 | if type(objects) is dict: 35 | return message_template % dict((name, encode_object(obj, name)) for name, obj in objects.iteritems()) 36 | return "" 37 | 38 | 39 | def decode_object(ref): 40 | decoded = ref.split(".") 41 | if len(decoded) == 4: 42 | app, name, pk, msgid = decoded 43 | return get_model(app, name).objects.get(pk=pk), msgid 44 | app, name, pk = decoded 45 | return get_model(app, name).objects.get(pk=pk), None 46 | 47 | 48 | class FormatException(Exception): 49 | pass 50 | 51 | 52 | def decode_message(message, decoder): 53 | out = [] 54 | objects = [] 55 | mapping = {} 56 | in_field = False 57 | prev = 0 58 | for index, ch in enumerate(message): 59 | if not in_field: 60 | if ch == "{": 61 | in_field = True 62 | if prev != index: 63 | out.append(message[prev:index]) 64 | prev = index 65 | elif ch == "}": 66 | raise FormatException("unmatched }") 67 | elif in_field: 68 | if ch == "{": 69 | raise FormatException("{ inside {}") 70 | elif ch == "}": 71 | in_field = False 72 | obj, msgid = decoder(message[prev+1:index]) 73 | if msgid is None: 74 | objects.append(obj) 75 | out.append("%s") 76 | else: 77 | mapping[msgid] = obj 78 | out.append("%("+msgid+")s") 79 | prev = index + 1 80 | if in_field: 81 | raise FormatException("unmatched {") 82 | if prev <= index: 83 | out.append(message[prev:index+1]) 84 | result = "".join(out) 85 | if mapping: 86 | args = mapping 87 | else: 88 | args = tuple(objects) 89 | return ugettext(result) % args 90 | 91 | 92 | def message_to_text(message): 93 | def decoder(ref): 94 | obj, msgid = decode_object(ref) 95 | return unicode(obj), msgid 96 | return decode_message(message, decoder) 97 | 98 | 99 | def message_to_html(message): 100 | def decoder(ref): 101 | obj, msgid = decode_object(ref) 102 | if hasattr(obj, "get_absolute_url"): # don't fail silenty if get_absolute_url hasn't been defined 103 | return u"""%s""" % (obj.get_absolute_url(), unicode(obj)), msgid 104 | else: 105 | return unicode(obj), msgid 106 | return decode_message(message, decoder) 107 | -------------------------------------------------------------------------------- /notification/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | try: 4 | import cPickle as pickle 5 | except ImportError: 6 | import pickle 7 | 8 | from django.db import models 9 | from django.db.models.query import QuerySet 10 | from django.conf import settings 11 | from django.core.exceptions import ImproperlyConfigured 12 | from django.core.mail import send_mail 13 | from django.core.urlresolvers import reverse 14 | from django.template import Context 15 | from django.template.loader import render_to_string 16 | from django.utils.translation import ugettext_lazy as _ 17 | from django.utils.translation import ugettext, get_language, activate 18 | 19 | from django.contrib.sites.models import Site 20 | from django.contrib.auth.models import User 21 | from django.contrib.auth.models import AnonymousUser 22 | from django.contrib.contenttypes.models import ContentType 23 | from django.contrib.contenttypes import generic 24 | 25 | from notification import backends 26 | from notification.message import encode_message 27 | 28 | 29 | QUEUE_ALL = getattr(settings, "NOTIFICATION_QUEUE_ALL", False) 30 | 31 | 32 | class LanguageStoreNotAvailable(Exception): 33 | pass 34 | 35 | 36 | class NoticeType(models.Model): 37 | 38 | label = models.CharField(_("label"), max_length=40) 39 | display = models.CharField(_("display"), max_length=50) 40 | description = models.CharField(_("description"), max_length=100) 41 | 42 | # by default only on for media with sensitivity less than or equal to this number 43 | default = models.IntegerField(_("default")) 44 | 45 | def __unicode__(self): 46 | return self.label 47 | 48 | class Meta: 49 | verbose_name = _("notice type") 50 | verbose_name_plural = _("notice types") 51 | 52 | 53 | NOTIFICATION_BACKENDS = backends.load_backends() 54 | 55 | NOTICE_MEDIA = [] 56 | NOTICE_MEDIA_DEFAULTS = {} 57 | for key, backend in NOTIFICATION_BACKENDS.items(): 58 | # key is a tuple (medium_id, backend_label) 59 | NOTICE_MEDIA.append(key) 60 | NOTICE_MEDIA_DEFAULTS[key[0]] = backend.spam_sensitivity 61 | 62 | 63 | class NoticeSetting(models.Model): 64 | """ 65 | Indicates, for a given user, whether to send notifications 66 | of a given type to a given medium. 67 | """ 68 | 69 | user = models.ForeignKey(User, verbose_name=_("user")) 70 | notice_type = models.ForeignKey(NoticeType, verbose_name=_("notice type")) 71 | medium = models.CharField(_("medium"), max_length=1, choices=NOTICE_MEDIA) 72 | send = models.BooleanField(_("send")) 73 | 74 | class Meta: 75 | verbose_name = _("notice setting") 76 | verbose_name_plural = _("notice settings") 77 | unique_together = ("user", "notice_type", "medium") 78 | 79 | 80 | def get_notification_setting(user, notice_type, medium): 81 | try: 82 | return NoticeSetting.objects.get(user=user, notice_type=notice_type, medium=medium) 83 | except NoticeSetting.DoesNotExist: 84 | default = (NOTICE_MEDIA_DEFAULTS[medium] <= notice_type.default) 85 | setting = NoticeSetting(user=user, notice_type=notice_type, medium=medium, send=default) 86 | setting.save() 87 | return setting 88 | 89 | 90 | def should_send(user, notice_type, medium): 91 | return get_notification_setting(user, notice_type, medium).send 92 | 93 | 94 | class NoticeManager(models.Manager): 95 | 96 | def notices_for(self, user, archived=False, unseen=None, on_site=None, sent=False): 97 | """ 98 | returns Notice objects for the given user. 99 | 100 | If archived=False, it only include notices not archived. 101 | If archived=True, it returns all notices for that user. 102 | 103 | If unseen=None, it includes all notices. 104 | If unseen=True, return only unseen notices. 105 | If unseen=False, return only seen notices. 106 | """ 107 | if sent: 108 | lookup_kwargs = {"sender": user} 109 | else: 110 | lookup_kwargs = {"recipient": user} 111 | qs = self.filter(**lookup_kwargs) 112 | if not archived: 113 | self.filter(archived=archived) 114 | if unseen is not None: 115 | qs = qs.filter(unseen=unseen) 116 | if on_site is not None: 117 | qs = qs.filter(on_site=on_site) 118 | return qs 119 | 120 | def unseen_count_for(self, recipient, **kwargs): 121 | """ 122 | returns the number of unseen notices for the given user but does not 123 | mark them seen 124 | """ 125 | return self.notices_for(recipient, unseen=True, **kwargs).count() 126 | 127 | def received(self, recipient, **kwargs): 128 | """ 129 | returns notices the given recipient has recieved. 130 | """ 131 | kwargs["sent"] = False 132 | return self.notices_for(recipient, **kwargs) 133 | 134 | def sent(self, sender, **kwargs): 135 | """ 136 | returns notices the given sender has sent 137 | """ 138 | kwargs["sent"] = True 139 | return self.notices_for(sender, **kwargs) 140 | 141 | 142 | class Notice(models.Model): 143 | 144 | recipient = models.ForeignKey(User, related_name="recieved_notices", verbose_name=_("recipient")) 145 | sender = models.ForeignKey(User, null=True, related_name="sent_notices", verbose_name=_("sender")) 146 | message = models.TextField(_("message")) 147 | notice_type = models.ForeignKey(NoticeType, verbose_name=_("notice type")) 148 | added = models.DateTimeField(_("added"), default=datetime.datetime.now) 149 | unseen = models.BooleanField(_("unseen"), default=True) 150 | archived = models.BooleanField(_("archived"), default=False) 151 | on_site = models.BooleanField(_("on site")) 152 | 153 | objects = NoticeManager() 154 | 155 | def __unicode__(self): 156 | return self.message 157 | 158 | def archive(self): 159 | self.archived = True 160 | self.save() 161 | 162 | def is_unseen(self): 163 | """ 164 | returns value of self.unseen but also changes it to false. 165 | 166 | Use this in a template to mark an unseen notice differently the first 167 | time it is shown. 168 | """ 169 | unseen = self.unseen 170 | if unseen: 171 | self.unseen = False 172 | self.save() 173 | return unseen 174 | 175 | class Meta: 176 | ordering = ["-added"] 177 | verbose_name = _("notice") 178 | verbose_name_plural = _("notices") 179 | 180 | def get_absolute_url(self): 181 | return reverse("notification_notice", args=[str(self.pk)]) 182 | 183 | 184 | class NoticeQueueBatch(models.Model): 185 | """ 186 | A queued notice. 187 | Denormalized data for a notice. 188 | """ 189 | pickled_data = models.TextField() 190 | 191 | 192 | def create_notice_type(label, display, description, default=2, verbosity=1): 193 | """ 194 | Creates a new NoticeType. 195 | 196 | This is intended to be used by other apps as a post_syncdb manangement step. 197 | """ 198 | try: 199 | notice_type = NoticeType.objects.get(label=label) 200 | updated = False 201 | if display != notice_type.display: 202 | notice_type.display = display 203 | updated = True 204 | if description != notice_type.description: 205 | notice_type.description = description 206 | updated = True 207 | if default != notice_type.default: 208 | notice_type.default = default 209 | updated = True 210 | if updated: 211 | notice_type.save() 212 | if verbosity > 1: 213 | print "Updated %s NoticeType" % label 214 | except NoticeType.DoesNotExist: 215 | NoticeType(label=label, display=display, description=description, default=default).save() 216 | if verbosity > 1: 217 | print "Created %s NoticeType" % label 218 | 219 | 220 | def get_notification_language(user): 221 | """ 222 | Returns site-specific notification language for this user. Raises 223 | LanguageStoreNotAvailable if this site does not use translated 224 | notifications. 225 | """ 226 | if getattr(settings, "NOTIFICATION_LANGUAGE_MODULE", False): 227 | try: 228 | app_label, model_name = settings.NOTIFICATION_LANGUAGE_MODULE.split(".") 229 | model = models.get_model(app_label, model_name) 230 | language_model = model._default_manager.get(user__id__exact=user.id) 231 | if hasattr(language_model, "language"): 232 | return language_model.language 233 | except (ImportError, ImproperlyConfigured, model.DoesNotExist): 234 | raise LanguageStoreNotAvailable 235 | raise LanguageStoreNotAvailable 236 | 237 | 238 | def get_formatted_messages(formats, label, context): 239 | """ 240 | Returns a dictionary with the format identifier as the key. The values are 241 | are fully rendered templates with the given context. 242 | """ 243 | format_templates = {} 244 | for format in formats: 245 | # conditionally turn off autoescaping for .txt extensions in format 246 | if format.endswith(".txt"): 247 | context.autoescape = False 248 | else: 249 | context.autoescape = True 250 | format_templates[format] = render_to_string(( 251 | "notification/%s/%s" % (label, format), 252 | "notification/%s" % format), context_instance=context) 253 | return format_templates 254 | 255 | 256 | def send_now(users, label, extra_context=None, on_site=True, sender=None): 257 | """ 258 | Creates a new notice. 259 | 260 | This is intended to be how other apps create new notices. 261 | 262 | notification.send(user, "friends_invite_sent", { 263 | "spam": "eggs", 264 | "foo": "bar", 265 | ) 266 | 267 | You can pass in on_site=False to prevent the notice emitted from being 268 | displayed on the site. 269 | """ 270 | if extra_context is None: 271 | extra_context = {} 272 | 273 | notice_type = NoticeType.objects.get(label=label) 274 | 275 | protocol = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") 276 | current_site = Site.objects.get_current() 277 | 278 | notices_url = u"%s://%s%s" % ( 279 | protocol, 280 | unicode(current_site), 281 | reverse("notification_notices"), 282 | ) 283 | 284 | current_language = get_language() 285 | 286 | formats = ( 287 | "short.txt", 288 | "full.txt", 289 | "notice.html", 290 | "full.html", 291 | ) # TODO make formats configurable 292 | 293 | for user in users: 294 | recipients = [] 295 | # get user language for user from language store defined in 296 | # NOTIFICATION_LANGUAGE_MODULE setting 297 | try: 298 | language = get_notification_language(user) 299 | except LanguageStoreNotAvailable: 300 | language = None 301 | 302 | if language is not None: 303 | # activate the user's language 304 | activate(language) 305 | 306 | for backend in NOTIFICATION_BACKENDS.values(): 307 | if backend.can_send(user, notice_type): 308 | backend.deliver(user, sender, notice_type, extra_context) 309 | 310 | # reset environment to original language 311 | activate(current_language) 312 | 313 | 314 | def send(*args, **kwargs): 315 | """ 316 | A basic interface around both queue and send_now. This honors a global 317 | flag NOTIFICATION_QUEUE_ALL that helps determine whether all calls should 318 | be queued or not. A per call ``queue`` or ``now`` keyword argument can be 319 | used to always override the default global behavior. 320 | """ 321 | queue_flag = kwargs.pop("queue", False) 322 | now_flag = kwargs.pop("now", False) 323 | assert not (queue_flag and now_flag), "'queue' and 'now' cannot both be True." 324 | if queue_flag: 325 | return queue(*args, **kwargs) 326 | elif now_flag: 327 | return send_now(*args, **kwargs) 328 | else: 329 | if QUEUE_ALL: 330 | return queue(*args, **kwargs) 331 | else: 332 | return send_now(*args, **kwargs) 333 | 334 | 335 | def queue(users, label, extra_context=None, on_site=True, sender=None): 336 | """ 337 | Queue the notification in NoticeQueueBatch. This allows for large amounts 338 | of user notifications to be deferred to a seperate process running outside 339 | the webserver. 340 | """ 341 | if extra_context is None: 342 | extra_context = {} 343 | if isinstance(users, QuerySet): 344 | users = [row["pk"] for row in users.values("pk")] 345 | else: 346 | users = [user.pk for user in users] 347 | notices = [] 348 | for user in users: 349 | notices.append((user, label, extra_context, on_site, sender)) 350 | NoticeQueueBatch(pickled_data=pickle.dumps(notices).encode("base64")).save() 351 | 352 | 353 | class ObservedItemManager(models.Manager): 354 | 355 | def all_for(self, observed, signal): 356 | """ 357 | Returns all ObservedItems for an observed object, 358 | to be sent when a signal is emited. 359 | """ 360 | content_type = ContentType.objects.get_for_model(observed) 361 | observed_items = self.filter(content_type=content_type, object_id=observed.id, signal=signal) 362 | return observed_items 363 | 364 | def get_for(self, observed, observer, signal): 365 | content_type = ContentType.objects.get_for_model(observed) 366 | observed_item = self.get(content_type=content_type, object_id=observed.id, user=observer, signal=signal) 367 | return observed_item 368 | 369 | 370 | class ObservedItem(models.Model): 371 | 372 | user = models.ForeignKey(User, verbose_name=_("user")) 373 | 374 | content_type = models.ForeignKey(ContentType) 375 | object_id = models.PositiveIntegerField() 376 | observed_object = generic.GenericForeignKey("content_type", "object_id") 377 | 378 | notice_type = models.ForeignKey(NoticeType, verbose_name=_("notice type")) 379 | 380 | added = models.DateTimeField(_("added"), default=datetime.datetime.now) 381 | 382 | # the signal that will be listened to send the notice 383 | signal = models.TextField(verbose_name=_("signal")) 384 | 385 | objects = ObservedItemManager() 386 | 387 | class Meta: 388 | ordering = ["-added"] 389 | verbose_name = _("observed item") 390 | verbose_name_plural = _("observed items") 391 | 392 | def send_notice(self, extra_context=None): 393 | if extra_context is None: 394 | extra_context = {} 395 | extra_context.update({"observed": self.observed_object}) 396 | send([self.user], self.notice_type.label, extra_context) 397 | 398 | 399 | def observe(observed, observer, notice_type_label, signal="post_save"): 400 | """ 401 | Create a new ObservedItem. 402 | 403 | To be used by applications to register a user as an observer for some object. 404 | """ 405 | notice_type = NoticeType.objects.get(label=notice_type_label) 406 | observed_item = ObservedItem( 407 | user=observer, observed_object=observed, 408 | notice_type=notice_type, signal=signal 409 | ) 410 | observed_item.save() 411 | return observed_item 412 | 413 | 414 | def stop_observing(observed, observer, signal="post_save"): 415 | """ 416 | Remove an observed item. 417 | """ 418 | observed_item = ObservedItem.objects.get_for(observed, observer, signal) 419 | observed_item.delete() 420 | 421 | 422 | def send_observation_notices_for(observed, signal="post_save", extra_context=None): 423 | """ 424 | Send a notice for each registered user about an observed object. 425 | """ 426 | if extra_context is None: 427 | extra_context = {} 428 | observed_items = ObservedItem.objects.all_for(observed, signal) 429 | for observed_item in observed_items: 430 | observed_item.send_notice(extra_context) 431 | return observed_items 432 | 433 | 434 | def is_observing(observed, observer, signal="post_save"): 435 | if isinstance(observer, AnonymousUser): 436 | return False 437 | try: 438 | observed_items = ObservedItem.objects.get_for(observed, observer, signal) 439 | return True 440 | except ObservedItem.DoesNotExist: 441 | return False 442 | except ObservedItem.MultipleObjectsReturned: 443 | return True 444 | 445 | 446 | def handle_observations(sender, instance, *args, **kw): 447 | send_observation_notices_for(instance) 448 | -------------------------------------------------------------------------------- /notification/templates/notification/email_body.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans %}You have received the following notice from {{ current_site }}: 2 | 3 | {{ message }} 4 | 5 | To see other notices or change how you receive notifications, please go to {{ notices_url }}. 6 | {% endblocktrans %} 7 | -------------------------------------------------------------------------------- /notification/templates/notification/email_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans %}[{{ current_site }}] {{ message }}{% endblocktrans %} -------------------------------------------------------------------------------- /notification/templates/notification/full.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans %}{{ notice }}{% endblocktrans %} -------------------------------------------------------------------------------- /notification/templates/notification/full.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans %}{{ notice }}{% endblocktrans %} 2 | -------------------------------------------------------------------------------- /notification/templates/notification/notice.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans %}{{ notice }}{% endblocktrans %} -------------------------------------------------------------------------------- /notification/templates/notification/short.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans %}{{ notice }}{% endblocktrans %} -------------------------------------------------------------------------------- /notification/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brosner/django-notification/29d5ae604099b8406fc3c31084dd6f66dccf2bc8/notification/templatetags/__init__.py -------------------------------------------------------------------------------- /notification/templatetags/captureas_tag.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | @register.tag(name='captureas') 6 | def do_captureas(parser, token): 7 | try: 8 | tag_name, args = token.contents.split(None, 1) 9 | except ValueError: 10 | raise template.TemplateSyntaxError("'captureas' node requires a variable name.") 11 | nodelist = parser.parse(('endcaptureas',)) 12 | parser.delete_first_token() 13 | return CaptureasNode(nodelist, args) 14 | 15 | class CaptureasNode(template.Node): 16 | def __init__(self, nodelist, varname): 17 | self.nodelist = nodelist 18 | self.varname = varname 19 | 20 | def render(self, context): 21 | output = self.nodelist.render(context) 22 | context[self.varname] = output 23 | return '' 24 | -------------------------------------------------------------------------------- /notification/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | from notification.views import notices, mark_all_seen, feed_for_user, single, notice_settings 4 | 5 | 6 | urlpatterns = patterns("", 7 | url(r"^$", notices, name="notification_notices"), 8 | url(r"^settings/$", notice_settings, name="notification_notice_settings"), 9 | url(r"^(\d+)/$", single, name="notification_notice"), 10 | url(r"^feed/$", feed_for_user, name="notification_feed_for_user"), 11 | url(r"^mark_all_seen/$", mark_all_seen, name="notification_mark_all_seen"), 12 | ) -------------------------------------------------------------------------------- /notification/views.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse 2 | from django.shortcuts import render_to_response, get_object_or_404 3 | from django.http import HttpResponseRedirect, Http404 4 | from django.template import RequestContext 5 | 6 | from django.contrib.auth.decorators import login_required 7 | from django.contrib.syndication.views import feed 8 | 9 | from notification.models import * 10 | from notification.decorators import basic_auth_required, simple_basic_auth_callback 11 | from notification.feeds import NoticeUserFeed 12 | 13 | 14 | @basic_auth_required(realm="Notices Feed", callback_func=simple_basic_auth_callback) 15 | def feed_for_user(request): 16 | """ 17 | An atom feed for all unarchived :model:`notification.Notice`s for a user. 18 | """ 19 | url = "feed/%s" % request.user.username 20 | return feed(request, url, { 21 | "feed": NoticeUserFeed, 22 | }) 23 | 24 | 25 | @login_required 26 | def notices(request): 27 | """ 28 | The main notices index view. 29 | 30 | Template: :template:`notification/notices.html` 31 | 32 | Context: 33 | 34 | notices 35 | A list of :model:`notification.Notice` objects that are not archived 36 | and to be displayed on the site. 37 | """ 38 | notices = Notice.objects.notices_for(request.user, on_site=True) 39 | 40 | return render_to_response("notification/notices.html", { 41 | "notices": notices, 42 | }, context_instance=RequestContext(request)) 43 | 44 | 45 | @login_required 46 | def notice_settings(request): 47 | """ 48 | The notice settings view. 49 | 50 | Template: :template:`notification/notice_settings.html` 51 | 52 | Context: 53 | 54 | notice_types 55 | A list of all :model:`notification.NoticeType` objects. 56 | 57 | notice_settings 58 | A dictionary containing ``column_headers`` for each ``NOTICE_MEDIA`` 59 | and ``rows`` containing a list of dictionaries: ``notice_type``, a 60 | :model:`notification.NoticeType` object and ``cells``, a list of 61 | tuples whose first value is suitable for use in forms and the second 62 | value is ``True`` or ``False`` depending on a ``request.POST`` 63 | variable called ``form_label``, whose valid value is ``on``. 64 | """ 65 | notice_types = NoticeType.objects.all() 66 | settings_table = [] 67 | for notice_type in notice_types: 68 | settings_row = [] 69 | for medium_id, medium_display in NOTICE_MEDIA: 70 | form_label = "%s_%s" % (notice_type.label, medium_id) 71 | setting = get_notification_setting(request.user, notice_type, medium_id) 72 | if request.method == "POST": 73 | if request.POST.get(form_label) == "on": 74 | if not setting.send: 75 | setting.send = True 76 | setting.save() 77 | else: 78 | if setting.send: 79 | setting.send = False 80 | setting.save() 81 | settings_row.append((form_label, setting.send)) 82 | settings_table.append({"notice_type": notice_type, "cells": settings_row}) 83 | 84 | if request.method == "POST": 85 | next_page = request.POST.get("next_page", ".") 86 | return HttpResponseRedirect(next_page) 87 | 88 | notice_settings = { 89 | "column_headers": [medium_display for medium_id, medium_display in NOTICE_MEDIA], 90 | "rows": settings_table, 91 | } 92 | 93 | return render_to_response("notification/notice_settings.html", { 94 | "notice_types": notice_types, 95 | "notice_settings": notice_settings, 96 | }, context_instance=RequestContext(request)) 97 | 98 | 99 | @login_required 100 | def single(request, id, mark_seen=True): 101 | """ 102 | Detail view for a single :model:`notification.Notice`. 103 | 104 | Template: :template:`notification/single.html` 105 | 106 | Context: 107 | 108 | notice 109 | The :model:`notification.Notice` being viewed 110 | 111 | Optional arguments: 112 | 113 | mark_seen 114 | If ``True``, mark the notice as seen if it isn't 115 | already. Do nothing if ``False``. Default: ``True``. 116 | """ 117 | notice = get_object_or_404(Notice, id=id) 118 | if request.user == notice.recipient: 119 | if mark_seen and notice.unseen: 120 | notice.unseen = False 121 | notice.save() 122 | return render_to_response("notification/single.html", { 123 | "notice": notice, 124 | }, context_instance=RequestContext(request)) 125 | raise Http404 126 | 127 | 128 | @login_required 129 | def archive(request, noticeid=None, next_page=None): 130 | """ 131 | Archive a :model:`notices.Notice` if the requesting user is the 132 | recipient or if the user is a superuser. Returns a 133 | ``HttpResponseRedirect`` when complete. 134 | 135 | Optional arguments: 136 | 137 | noticeid 138 | The ID of the :model:`notices.Notice` to be archived. 139 | 140 | next_page 141 | The page to redirect to when done. 142 | """ 143 | if noticeid: 144 | try: 145 | notice = Notice.objects.get(id=noticeid) 146 | if request.user == notice.recipient or request.user.is_superuser: 147 | notice.archive() 148 | else: # you can archive other users' notices 149 | # only if you are superuser. 150 | return HttpResponseRedirect(next_page) 151 | except Notice.DoesNotExist: 152 | return HttpResponseRedirect(next_page) 153 | return HttpResponseRedirect(next_page) 154 | 155 | 156 | @login_required 157 | def delete(request, noticeid=None, next_page=None): 158 | """ 159 | Delete a :model:`notices.Notice` if the requesting user is the recipient 160 | or if the user is a superuser. Returns a ``HttpResponseRedirect`` when 161 | complete. 162 | 163 | Optional arguments: 164 | 165 | noticeid 166 | The ID of the :model:`notices.Notice` to be archived. 167 | 168 | next_page 169 | The page to redirect to when done. 170 | """ 171 | if noticeid: 172 | try: 173 | notice = Notice.objects.get(id=noticeid) 174 | if request.user == notice.recipient or request.user.is_superuser: 175 | notice.delete() 176 | else: # you can delete other users' notices 177 | # only if you are superuser. 178 | return HttpResponseRedirect(next_page) 179 | except Notice.DoesNotExist: 180 | return HttpResponseRedirect(next_page) 181 | return HttpResponseRedirect(next_page) 182 | 183 | 184 | @login_required 185 | def mark_all_seen(request): 186 | """ 187 | Mark all unseen notices for the requesting user as seen. Returns a 188 | ``HttpResponseRedirect`` when complete. 189 | """ 190 | 191 | for notice in Notice.objects.notices_for(request.user, unseen=True): 192 | notice.unseen = False 193 | notice.save() 194 | return HttpResponseRedirect(reverse("notification_notices")) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name="django-notification", 6 | version=__import__("notification").__version__, 7 | description="User notification management for the Django web framework", 8 | long_description=open("docs/usage.txt").read(), 9 | author="James Tauber", 10 | author_email="jtauber@jtauber.com", 11 | url="https://github.com/jtauber/django-notification", 12 | packages=find_packages(), 13 | classifiers=[ 14 | "Development Status :: 3 - Alpha", 15 | "Environment :: Web Environment", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python", 20 | "Framework :: Django", 21 | ], 22 | include_package_data=True, 23 | zip_safe=False, 24 | ) 25 | --------------------------------------------------------------------------------