New notifications
authorJoar Wandborg <joar@wandborg.se>
Sun, 7 Apr 2013 21:17:23 +0000 (23:17 +0200)
committerJoar Wandborg <joar@wandborg.se>
Sun, 9 Jun 2013 19:18:37 +0000 (21:18 +0200)
- Added request.notifications
- Email configuration fixes
  - Set config_spec default SMTP port to `0` and switch to SSL/non-SSL
    default if `port == 0`
  - Added email_smtp_use_ssl configuration setting
- Added migrations for notification tables
- Added __repr__ to MediaComment(Mixin)
- Added MediaComment.get_entry => MediaEntry
- Added CommentSubscription, CommentNotification, Notification,
  ProcessingNotification tables
- Added notifications.task to celery init
- Fixed a bug in the video transcoder where pygst would hijack the
  --help argument.
- Added notifications
  - views
    - silence
    - subscribe
  - routes
  - utility methods
  - celery task
- Added half-hearted .active comment CSS style
- Added quick JS to show header_dropdown
- Added fragment template to show notifications in header_dropdown
- Added fragment template to show subscribe/unsubscribe buttons on
  media/comment pages
- Updated celery setup tests with notifications.task
- Tried to fix test_misc tests that I broke
- Added notification tests
- Added and extended tests.tools fixtures
- Integrated new notifications into media_home, media_post_comment views
- Bumped SQLAlchemy dependency to >= 0.8.0 since we need polymorphic for
  the notifications to work

28 files changed:
mediagoblin/app.py
mediagoblin/config_spec.ini
mediagoblin/db/migrations.py
mediagoblin/db/mixin.py
mediagoblin/db/models.py
mediagoblin/init/celery/__init__.py
mediagoblin/media_types/stl/processing.py
mediagoblin/media_types/video/transcoders.py
mediagoblin/notifications/__init__.py [new file with mode: 0644]
mediagoblin/notifications/routing.py [new file with mode: 0644]
mediagoblin/notifications/task.py [new file with mode: 0644]
mediagoblin/notifications/tools.py [new file with mode: 0644]
mediagoblin/notifications/views.py [new file with mode: 0644]
mediagoblin/routing.py
mediagoblin/static/css/base.css
mediagoblin/static/js/notifications.js [new file with mode: 0644]
mediagoblin/submit/views.py
mediagoblin/templates/mediagoblin/base.html
mediagoblin/templates/mediagoblin/fragments/header_notifications.html [new file with mode: 0644]
mediagoblin/templates/mediagoblin/user_pages/media.html
mediagoblin/templates/mediagoblin/utils/comment-subscription.html [new file with mode: 0644]
mediagoblin/tests/test_celery_setup.py
mediagoblin/tests/test_misc.py
mediagoblin/tests/test_notifications.py [new file with mode: 0644]
mediagoblin/tests/tools.py
mediagoblin/tools/mail.py
mediagoblin/user_pages/views.py
setup.py

index 1984ce77da91dc2e0f065a7165a7ad347bdae090..580583607f32f2ca3bd7558993ec5900f469ebf2 100644 (file)
@@ -37,6 +37,7 @@ from mediagoblin.init import (get_jinja_loader, get_staticdirector,
     setup_storage)
 from mediagoblin.tools.pluginapi import PluginManager, hook_transform
 from mediagoblin.tools.crypto import setup_crypto
+from mediagoblin import notifications
 
 
 _log = logging.getLogger(__name__)
@@ -186,6 +187,8 @@ class MediaGoblinApp(object):
 
         request.urlgen = build_proxy
 
+        request.notifications = notifications
+
         mg_request.setup_user_in_request(request)
 
         request.controller_name = None
index b213970de595b7679f9279462671deff9fb605ad..4547ea545c65d76432372103bba98e821ddf6075 100644 (file)
@@ -22,9 +22,10 @@ direct_remote_path = string(default="/mgoblin_static/")
 
 # set to false to enable sending notices
 email_debug_mode = boolean(default=True)
+email_smtp_use_ssl = boolean(default=False)
 email_sender_address = string(default="notice@mediagoblin.example.org")
 email_smtp_host = string(default='')
-email_smtp_port = integer(default=25)
+email_smtp_port = integer(default=0)
 email_smtp_user = string(default=None)
 email_smtp_pass = string(default=None)
 
index 2c55339608610c90726e56d1ffa26741a23e158d..29b2522ad792cc82ec7f7d81a642d697cc5d864b 100644 (file)
@@ -26,7 +26,7 @@ from sqlalchemy.sql import and_
 from migrate.changeset.constraint import UniqueConstraint
 
 from mediagoblin.db.migration_tools import RegisterMigration, inspect_table
-from mediagoblin.db.models import MediaEntry, Collection, User
+from mediagoblin.db.models import MediaEntry, Collection, User, MediaComment
 
 MIGRATIONS = {}
 
@@ -287,3 +287,58 @@ def unique_collections_slug(db):
     constraint.create()
 
     db.commit()
+
+class CommentSubscription_v0(declarative_base()):
+    __tablename__ = 'core__comment_subscriptions'
+    id = Column(Integer, primary_key=True)
+
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+
+    media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
+
+    user_id = Column(Integer, ForeignKey(User.id), nullable=False)
+
+    notify = Column(Boolean, nullable=False, default=True)
+    send_email = Column(Boolean, nullable=False, default=True)
+
+
+class Notification_v0(declarative_base()):
+    __tablename__ = 'core__notifications'
+    id = Column(Integer, primary_key=True)
+    type = Column(Unicode)
+
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+
+    user_id = Column(Integer, ForeignKey(User.id), nullable=False,
+                     index=True)
+    seen = Column(Boolean, default=lambda: False, index=True)
+
+
+class CommentNotification_v0(Notification_v0):
+    __tablename__ = 'core__comment_notifications'
+    id = Column(Integer, ForeignKey(Notification_v0.id), primary_key=True)
+
+    subject_id = Column(Integer, ForeignKey(MediaComment.id))
+
+
+class ProcessingNotification_v0(Notification_v0):
+    __tablename__ = 'core__processing_notifications'
+
+    id = Column(Integer, ForeignKey(Notification_v0.id), primary_key=True)
+
+    subject_id = Column(Integer, ForeignKey(MediaEntry.id))
+
+
+@RegisterMigration(11, MIGRATIONS)
+def add_new_notification_tables(db):
+    metadata = MetaData(bind=db.bind)
+
+    user_table = inspect_table(metadata, 'core__users')
+    mediaentry_table = inspect_table(metadata, 'core__media_entries')
+    mediacomment_table = inspect_table(metadata, 'core__media_comments')
+
+    CommentSubscription_v0.__table__.create(db.bind)
+
+    Notification_v0.__table__.create(db.bind)
+    CommentNotification_v0.__table__.create(db.bind)
+    ProcessingNotification_v0.__table__.create(db.bind)
index 9f566e36d5a7ce9d6ad15ed90d50565886ab5c40..1b32d83810b6982dbc873461f4c10587d4c809fc 100644 (file)
@@ -31,6 +31,8 @@ import uuid
 import re
 import datetime
 
+from datetime import datetime
+
 from werkzeug.utils import cached_property
 
 from mediagoblin import mg_globals
@@ -288,6 +290,13 @@ class MediaCommentMixin(object):
         """
         return cleaned_markdown_conversion(self.content)
 
+    def __repr__(self):
+        return '<{klass} #{id} {author} "{comment}">'.format(
+            klass=self.__class__.__name__,
+            id=self.id,
+            author=self.get_author,
+            comment=self.content)
+
 
 class CollectionMixin(GenerateSlugMixin):
     def check_slug_used(self, slug):
index 2b92598344baaaee3edefbc6e877878077c97de0..62090126ccea9869f1453d139ec6cb9e66072e99 100644 (file)
@@ -24,15 +24,17 @@ import datetime
 from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
         Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
         SmallInteger
-from sqlalchemy.orm import relationship, backref
+from sqlalchemy.orm import relationship, backref, with_polymorphic
 from sqlalchemy.orm.collections import attribute_mapped_collection
 from sqlalchemy.sql.expression import desc
 from sqlalchemy.ext.associationproxy import association_proxy
 from sqlalchemy.util import memoized_property
 
+
 from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded
 from mediagoblin.db.base import Base, DictReadAttrProxy
-from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, MediaCommentMixin, CollectionMixin, CollectionItemMixin
+from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, \
+        MediaCommentMixin, CollectionMixin, CollectionItemMixin
 from mediagoblin.tools.files import delete_media_files
 from mediagoblin.tools.common import import_component
 
@@ -60,9 +62,9 @@ class User(Base, UserMixin):
     # the RFC) and because it would be a mess to implement at this
     # point.
     email = Column(Unicode, nullable=False)
-    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
     pw_hash = Column(Unicode, nullable=False)
     email_verified = Column(Boolean, default=False)
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
     status = Column(Unicode, default=u"needs_email_verification", nullable=False)
     # Intented to be nullable=False, but migrations would not work for it
     # set to nullable=True implicitly.
@@ -392,6 +394,10 @@ class MediaComment(Base, MediaCommentMixin):
                               backref=backref("posted_comments",
                                               lazy="dynamic",
                                               cascade="all, delete-orphan"))
+    get_entry = relationship(MediaEntry,
+                             backref=backref("comments",
+                                             lazy="dynamic",
+                                             cascade="all, delete-orphan"))
 
     # Cascade: Comments are somewhat owned by their MediaEntry.
     #     So do the full thing.
@@ -484,9 +490,103 @@ class ProcessingMetaData(Base):
         return DictReadAttrProxy(self)
 
 
+class CommentSubscription(Base):
+    __tablename__ = 'core__comment_subscriptions'
+    id = Column(Integer, primary_key=True)
+
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+
+    media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
+    media_entry = relationship(MediaEntry,
+                        backref=backref('comment_subscriptions',
+                                        cascade='all, delete-orphan'))
+
+    user_id = Column(Integer, ForeignKey(User.id), nullable=False)
+    user = relationship(User,
+                        backref=backref('comment_subscriptions',
+                                        cascade='all, delete-orphan'))
+
+    notify = Column(Boolean, nullable=False, default=True)
+    send_email = Column(Boolean, nullable=False, default=True)
+
+    def __repr__(self):
+        return ('<{classname} #{id}: {user} {media} notify: '
+                '{notify} email: {email}>').format(
+            id=self.id,
+            classname=self.__class__.__name__,
+            user=self.user,
+            media=self.media_entry,
+            notify=self.notify,
+            email=self.send_email)
+
+
+class Notification(Base):
+    __tablename__ = 'core__notifications'
+    id = Column(Integer, primary_key=True)
+    type = Column(Unicode)
+
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+
+    user_id = Column(Integer, ForeignKey('core__users.id'), nullable=False,
+                     index=True)
+    seen = Column(Boolean, default=lambda: False, index=True)
+    user = relationship(
+        User,
+        backref=backref('notifications', cascade='all, delete-orphan'))
+
+    __mapper_args__ = {
+        'polymorphic_identity': 'notification',
+        'polymorphic_on': type
+    }
+
+    def __repr__(self):
+        return '<{klass} #{id}: {user}: {subject} ({seen})>'.format(
+            id=self.id,
+            klass=self.__class__.__name__,
+            user=self.user,
+            subject=getattr(self, 'subject', None),
+            seen='unseen' if not self.seen else 'seen')
+
+
+class CommentNotification(Notification):
+    __tablename__ = 'core__comment_notifications'
+    id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
+
+    subject_id = Column(Integer, ForeignKey(MediaComment.id))
+    subject = relationship(
+        MediaComment,
+        backref=backref('comment_notifications', cascade='all, delete-orphan'))
+
+    __mapper_args__ = {
+        'polymorphic_identity': 'comment_notification'
+    }
+
+
+class ProcessingNotification(Notification):
+    __tablename__ = 'core__processing_notifications'
+
+    id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
+
+    subject_id = Column(Integer, ForeignKey(MediaEntry.id))
+    subject = relationship(
+        MediaEntry,
+        backref=backref('processing_notifications',
+                        cascade='all, delete-orphan'))
+
+    __mapper_args__ = {
+        'polymorphic_identity': 'processing_notification'
+    }
+
+
+with_polymorphic(
+    Notification,
+    [ProcessingNotification, CommentNotification])
+
 MODELS = [
-    User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames,
-    MediaAttachmentFile, ProcessingMetaData]
+    User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
+    MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
+    Notification, CommentNotification, ProcessingNotification,
+    CommentSubscription]
 
 
 ######################################################
index 169cc93595901567237673fac4054463cb9d311d..57242bf6bd7a7f2116fa7c7b82db73d2c68c9f96 100644 (file)
 
 import os
 import sys
+import logging
 
 from celery import Celery
 from mediagoblin.tools.pluginapi import hook_runall
 
 
-MANDATORY_CELERY_IMPORTS = ['mediagoblin.processing.task']
+_log = logging.getLogger(__name__)
+
+
+MANDATORY_CELERY_IMPORTS = [
+    'mediagoblin.processing.task',
+    'mediagoblin.notifications.task']
 
 DEFAULT_SETTINGS_MODULE = 'mediagoblin.init.celery.dummy_settings_module'
 
@@ -97,3 +103,13 @@ def setup_celery_from_config(app_config, global_config,
 
     if set_environ:
         os.environ['CELERY_CONFIG_MODULE'] = settings_module
+
+    # Replace the default celery.current_app.conf if celery has already been
+    # initiated
+    from celery import current_app
+
+    _log.info('Setting celery configuration from object "{0}"'.format(
+        settings_module))
+    current_app.config_from_object(this_module)
+
+    _log.debug('Celery broker host: {0}'.format(current_app.conf['BROKER_HOST']))
index 4938249540acee00c8eae7b8252fef474913b662..ce7a5d3711fbd0d9a2f6de83d401c46f45c7e35f 100644 (file)
@@ -46,7 +46,7 @@ def sniff_handler(media_file, **kw):
     if kw.get('media') is not None:
         name, ext = os.path.splitext(kw['media'].filename)
         clean_ext = ext[1:].lower()
-    
+
         if clean_ext in SUPPORTED_FILETYPES:
             _log.info('Found file extension in supported filetypes')
             return True
index 90a767dd21920927acb60e8cc8fc455af751bc1e..9d6b765540ef8657dc9b5f9bc7ff86a23d3a967c 100644 (file)
@@ -22,9 +22,15 @@ import logging
 import urllib
 import multiprocessing
 import gobject
+
+old_argv = sys.argv
+sys.argv = []
+
 import pygst
 pygst.require('0.10')
 import gst
+
+sys.argv = old_argv
 import struct
 try:
     from PIL import Image
diff --git a/mediagoblin/notifications/__init__.py b/mediagoblin/notifications/__init__.py
new file mode 100644 (file)
index 0000000..4b7fbb8
--- /dev/null
@@ -0,0 +1,141 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors.  See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+
+from mediagoblin.db.models import Notification, \
+        CommentNotification, CommentSubscription
+from mediagoblin.notifications.task import email_notification_task
+from mediagoblin.notifications.tools import generate_comment_message
+
+_log = logging.getLogger(__name__)
+
+def trigger_notification(comment, media_entry, request):
+    '''
+    Send out notifications about a new comment.
+    '''
+    subscriptions = CommentSubscription.query.filter_by(
+        media_entry_id=media_entry.id).all()
+
+    for subscription in subscriptions:
+        if not subscription.notify:
+            continue
+
+        if comment.get_author == subscription.user:
+            continue
+
+        cn = CommentNotification(
+            user_id=subscription.user_id,
+            subject_id=comment.id)
+
+        cn.save()
+
+        if subscription.send_email:
+            message = generate_comment_message(
+                subscription.user,
+                comment,
+                media_entry,
+                request)
+
+            email_notification_task.apply_async([cn.id, message])
+
+
+def mark_notification_seen(notification):
+    if notification:
+        notification.seen = True
+        notification.save()
+
+
+def mark_comment_notification_seen(comment_id, user):
+    notification = CommentNotification.query.filter_by(
+        user_id=user.id,
+        subject_id=comment_id).first()
+
+    _log.debug('Marking {0} as seen.'.format(notification))
+
+    mark_notification_seen(notification)
+
+
+def get_comment_subscription(user_id, media_entry_id):
+    return CommentSubscription.query.filter_by(
+        user_id=user_id,
+        media_entry_id=media_entry_id).first()
+
+def add_comment_subscription(user, media_entry):
+    '''
+    Create a comment subscription for a User on a MediaEntry.
+
+    Uses the User's wants_comment_notification to set email notifications for
+    the subscription to enabled/disabled.
+    '''
+    cn = get_comment_subscription(user.id, media_entry.id)
+
+    if not cn:
+        cn = CommentSubscription(
+            user_id=user.id,
+            media_entry_id=media_entry.id)
+
+    cn.notify = True
+
+    if not user.wants_comment_notification:
+        cn.send_email = False
+
+    cn.save()
+
+
+def silence_comment_subscription(user, media_entry):
+    '''
+    Silence a subscription so that the user is never notified in any way about
+    new comments on an entry
+    '''
+    cn = get_comment_subscription(user.id, media_entry.id)
+
+    if cn:
+        cn.notify = False
+        cn.send_email = False
+        cn.save()
+
+
+def remove_comment_subscription(user, media_entry):
+    cn = get_comment_subscription(user.id, media_entry.id)
+
+    if cn:
+        cn.delete()
+
+
+NOTIFICATION_FETCH_LIMIT = 100
+
+
+def get_notifications(user_id, only_unseen=True):
+    query = Notification.query.filter_by(user_id=user_id)
+
+    if only_unseen:
+        query = query.filter_by(seen=False)
+
+    notifications = query.limit(
+        NOTIFICATION_FETCH_LIMIT).all()
+
+    return notifications
+
+def get_notification_count(user_id, only_unseen=True):
+    query = Notification.query.filter_by(user_id=user_id)
+
+    if only_unseen:
+        query = query.filter_by(seen=False)
+
+    count = query.count()
+
+    return count
diff --git a/mediagoblin/notifications/routing.py b/mediagoblin/notifications/routing.py
new file mode 100644 (file)
index 0000000..e57956d
--- /dev/null
@@ -0,0 +1,25 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors.  See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from mediagoblin.tools.routing import add_route
+
+add_route('mediagoblin.notifications.subscribe_comments',
+          '/u/<string:user>/m/<string:media>/notifications/subscribe/comments/',
+          'mediagoblin.notifications.views:subscribe_comments')
+
+add_route('mediagoblin.notifications.silence_comments',
+          '/u/<string:user>/m/<string:media>/notifications/silence/',
+          'mediagoblin.notifications.views:silence_comments')
diff --git a/mediagoblin/notifications/task.py b/mediagoblin/notifications/task.py
new file mode 100644 (file)
index 0000000..52573b5
--- /dev/null
@@ -0,0 +1,46 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors.  See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+
+from celery import registry
+from celery.task import Task
+
+from mediagoblin.tools.mail import send_email
+from mediagoblin.db.models import CommentNotification
+
+
+_log = logging.getLogger(__name__)
+
+
+class EmailNotificationTask(Task):
+    '''
+    Celery notification task.
+
+    This task is executed by celeryd to offload long-running operations from
+    the web server.
+    '''
+    def run(self, notification_id, message):
+        cn = CommentNotification.query.filter_by(id=notification_id).first()
+        _log.info('Sending notification email about {0}'.format(cn))
+
+        return send_email(
+            message['from'],
+            [message['to']],
+            message['subject'],
+            message['body'])
+
+email_notification_task = registry.tasks[EmailNotificationTask.name]
diff --git a/mediagoblin/notifications/tools.py b/mediagoblin/notifications/tools.py
new file mode 100644 (file)
index 0000000..2543278
--- /dev/null
@@ -0,0 +1,55 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors.  See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from mediagoblin.tools.template import render_template
+from mediagoblin.tools.translate import pass_to_ugettext as _
+from mediagoblin import mg_globals
+
+def generate_comment_message(user, comment, media, request):
+    """
+    Sends comment email to user when a comment is made on their media.
+
+    Args:
+    - user: the user object to whom the email is sent
+    - comment: the comment object referencing user's media
+    - media: the media object the comment is about
+    - request: the request
+    """
+
+    comment_url = request.urlgen(
+                    'mediagoblin.user_pages.media_home.view_comment',
+                    comment=comment.id,
+                    user=media.get_uploader.username,
+                    media=media.slug_or_id,
+                    qualified=True) + '#comment'
+
+    comment_author = comment.get_author.username
+
+    rendered_email = render_template(
+        request, 'mediagoblin/user_pages/comment_email.txt',
+        {'username': user.username,
+         'comment_author': comment_author,
+         'comment_content': comment.content,
+         'comment_url': comment_url})
+
+    return {
+        'from': mg_globals.app_config['email_sender_address'],
+        'to': user.email,
+        'subject': '{instance_title} - {comment_author} '.format(
+            comment_author=comment_author,
+            instance_title=mg_globals.app_config['html_title']) \
+                    + _('commented on your post'),
+        'body': rendered_email}
diff --git a/mediagoblin/notifications/views.py b/mediagoblin/notifications/views.py
new file mode 100644 (file)
index 0000000..d275bc9
--- /dev/null
@@ -0,0 +1,54 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors.  See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from mediagoblin.tools.response import render_to_response, render_404, redirect
+from mediagoblin.tools.translate import pass_to_ugettext as _
+from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
+    get_media_entry_by_id,
+    require_active_login, user_may_delete_media, user_may_alter_collection,
+    get_user_collection, get_user_collection_item, active_user_from_url)
+
+from mediagoblin import messages
+
+from mediagoblin.notifications import add_comment_subscription, \
+        silence_comment_subscription
+
+from werkzeug.exceptions import BadRequest
+
+@get_user_media_entry
+@require_active_login
+def subscribe_comments(request, media):
+
+    add_comment_subscription(request.user, media)
+
+    messages.add_message(request,
+                         messages.SUCCESS,
+                         _('Subscribed to comments on %s!')
+                         % media.title)
+
+    return redirect(request, location=media.url_for_self(request.urlgen))
+
+@get_user_media_entry
+@require_active_login
+def silence_comments(request, media):
+    silence_comment_subscription(request.user, media)
+
+    messages.add_message(request,
+                         messages.SUCCESS,
+                         _('You will not receive notifications for comments on'
+                           ' %s.') % media.title)
+
+    return redirect(request, location=media.url_for_self(request.urlgen))
index a650f22fbd7d10b6daf5b3bcf4849758bd12f532..986eb2edd10e9242e9df842d3787de0545012df5 100644 (file)
@@ -35,6 +35,7 @@ def get_url_map():
     import mediagoblin.edit.routing
     import mediagoblin.webfinger.routing
     import mediagoblin.listings.routing
+    import mediagoblin.notifications.routing
 
     for route in PluginManager().get_routes():
         add_route(*route)
index 5b8226e6d86d2f12aaa1cac71880f16c1aed6e36..888d4e42e7ef7e45475ec68d08289bf4ad91b032 100644 (file)
@@ -384,6 +384,12 @@ a.comment_whenlink:hover {
   margin-top: 8px;
 }
 
+.comment_active {
+  box-shadow: 0px 0px 15px 15px #378566;
+  background: #378566;
+  color: #f7f7f7;
+}
+
 textarea#comment_content {
   resize: vertical;
   width: 100%;
diff --git a/mediagoblin/static/js/notifications.js b/mediagoblin/static/js/notifications.js
new file mode 100644 (file)
index 0000000..77793b3
--- /dev/null
@@ -0,0 +1,18 @@
+'use strict';
+var notifications = {};
+
+(function (n) {
+    n._base = '/';
+    n._endpoint = 'notifications/json';
+
+    n.init = function () {
+        $('.notification-gem').on('click', function () {
+            $('.header_dropdown_down:visible').click();
+        });
+    }
+
+})(notifications)
+
+$(document).ready(function () {
+    notifications.init();
+});
index a70c89b4f608bf6db14732670cda24edbaf51fdc..64e6791b36a93081f98c2a262cece956642af3d9 100644 (file)
@@ -34,6 +34,8 @@ from mediagoblin.media_types import sniff_media, \
 from mediagoblin.submit.lib import check_file_field, prepare_queue_task, \
     run_process_media, new_upload_entry
 
+from mediagoblin.notifications import add_comment_subscription
+
 
 @require_active_login
 def submit_start(request):
@@ -92,6 +94,8 @@ def submit_start(request):
                 run_process_media(entry, feed_url)
                 add_message(request, SUCCESS, _('Woohoo! Submitted!'))
 
+                add_comment_subscription(request.user, entry)
+
                 return redirect(request, "mediagoblin.user_pages.user_home",
                                 user=request.user.username)
             except Exception as e:
index 6c7c07d06204bc6a4d04d2496de76edd00abed1b..f2723edb22d6d58902065a9faee34a2a26981bcc 100644 (file)
@@ -34,6 +34,8 @@
             src="{{ request.staticdirect('/js/extlib/jquery.js') }}"></script>
     <script type="text/javascript"
             src="{{ request.staticdirect('/js/header_dropdown.js') }}"></script>
+    <script type="text/javascript"
+            src="{{ request.staticdirect('/js/notifications.js') }}"></script>
 
     {# For clarification, the difference between the extra_head.html template
      # and the head template hook is that the former should be used by
@@ -57,6 +59,9 @@
           <div class="header_right">
             {%- if request.user %}
               {% if request.user and request.user.status == 'active' %}
+
+                <a href="#notifications" class="notification-gem button_action" title="Notifications">
+                {{ request.notifications.get_notification_count(request.user.id) }}</a>
                 <div class="button_action header_dropdown_down">&#9660;</div>
                 <div class="button_action header_dropdown_up">&#9650;</div>
               {% elif request.user and request.user.status == "needs_email_verification" %}
                   </a>
                 </p>
               {% endif %}
+              {% include 'mediagoblin/fragments/header_notifications.html' %}
             </div>
           {% endif %}
         </header>
diff --git a/mediagoblin/templates/mediagoblin/fragments/header_notifications.html b/mediagoblin/templates/mediagoblin/fragments/header_notifications.html
new file mode 100644 (file)
index 0000000..613100a
--- /dev/null
@@ -0,0 +1,40 @@
+{% set notifications = request.notifications.get_notifications(request.user.id) %}
+{% if notifications %}
+    <div class="header_notifications">
+    <h3>{% trans %}New comments{% endtrans %}</h3>
+    <ul>
+        {% for notification in  notifications %}
+        {% set comment = notification.subject %}
+        {% set comment_author = comment.get_author %}
+        {% set media = comment.get_entry %}
+        <li class="comment_wrapper">
+            <div class="comment_author">
+                <img src="{{ request.staticdirect('/images/icon_comment.png') }}" />
+                <a href="{{ request.urlgen('mediagoblin.user_pages.user_home',
+                            user=comment_author.username) }}"
+                    class="comment_authorlink">
+                    {{- comment_author.username -}}
+                </a>
+                <a href="{{ request.urlgen('mediagoblin.user_pages.media_home.view_comment',
+                            comment=comment.id,
+                            user=media.get_uploader.username,
+                            media=media.slug_or_id) }}#comment"
+                    class="comment_whenlink">
+                    <span title='{{- comment.created.strftime("%I:%M%p %Y-%m-%d") -}}'>
+                    {%- trans formatted_time=timesince(comment.created) -%}
+                        {{ formatted_time }} ago
+                    {%- endtrans -%}
+                    </span>
+                </a>:
+            </div>
+            <div class="comment_content">
+                {% autoescape False -%}
+                {{ comment.content_html }}
+                {%- endautoescape %}
+            </div>
+
+        </li>
+        {% endfor %}
+    </ul>
+    </div>
+{% endif %}
index fb892fd788ab1e364a2eea976dcd33009c594fd3..a2a8f3b6650f08ed8509d8d7441101631121eafe 100644 (file)
 
     {% include "mediagoblin/utils/exif.html" %}
 
+    {% include "mediagoblin/utils/comment-subscription.html" %}
+
     {%- if media.attachment_files|count %}
       <h3>{% trans %}Attachments{% endtrans %}</h3>
       <ul>
diff --git a/mediagoblin/templates/mediagoblin/utils/comment-subscription.html b/mediagoblin/templates/mediagoblin/utils/comment-subscription.html
new file mode 100644 (file)
index 0000000..6598c73
--- /dev/null
@@ -0,0 +1,36 @@
+{#
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors.  See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#}
+{%- if request.user %}
+<p>
+    {% set subscription = request.notifications.get_comment_subscription(
+                                request.user.id, media.id) %}
+    {% if not subscription or not subscription.notify %}
+        <a type="submit" href="{{ request.urlgen('mediagoblin.notifications.subscribe_comments',
+            user=media.get_uploader.username,
+            media=media.slug)}}"
+            class="button_action">Subscribe to comments
+        </a>
+    {% else %}
+        <a type="submit" href="{{ request.urlgen('mediagoblin.notifications.silence_comments',
+            user=media.get_uploader.username,
+            media=media.slug)}}"
+            class="button_action">Silence comments
+        </a>
+    {% endif %}
+</p>
+{%- endif %}
index 5530c6f223ec924c968cc90fb6c30451877f96e4..0184436a7b4f7af781a0cdc5050880f778208ab7 100644 (file)
@@ -48,7 +48,7 @@ def test_setup_celery_from_config():
     assert isinstance(fake_celery_module.CELERYD_ETA_SCHEDULER_PRECISION, float)
     assert fake_celery_module.CELERY_RESULT_PERSISTENT is True
     assert fake_celery_module.CELERY_IMPORTS == [
-        'foo.bar.baz', 'this.is.an.import', 'mediagoblin.processing.task']
+        'foo.bar.baz', 'this.is.an.import', 'mediagoblin.processing.task', 'mediagoblin.notifications.task']
     assert fake_celery_module.CELERY_RESULT_BACKEND == 'database'
     assert fake_celery_module.CELERY_RESULT_DBURI == (
         'sqlite:///' +
index 755d863fbf1a14f3601c11f8f3852a8de87cd215..6af6bf92edb97abf28c0b4a5c721d4e0db052159 100644 (file)
@@ -28,8 +28,10 @@ def test_user_deletes_other_comments(test_app):
     user_a = fixture_add_user(u"chris_a")
     user_b = fixture_add_user(u"chris_b")
 
-    media_a = fixture_media_entry(uploader=user_a.id, save=False)
-    media_b = fixture_media_entry(uploader=user_b.id, save=False)
+    media_a = fixture_media_entry(uploader=user_a.id, save=False,
+                                  expunge=False)
+    media_b = fixture_media_entry(uploader=user_b.id, save=False,
+                                  expunge=False)
     Session.add(media_a)
     Session.add(media_b)
     Session.flush()
@@ -79,7 +81,7 @@ def test_user_deletes_other_comments(test_app):
 def test_media_deletes_broken_attachment(test_app):
     user_a = fixture_add_user(u"chris_a")
 
-    media = fixture_media_entry(uploader=user_a.id, save=False)
+    media = fixture_media_entry(uploader=user_a.id, save=False, expunge=False)
     media.attachment_files.append(dict(
             name=u"some name",
             filepath=[u"does", u"not", u"exist"],
diff --git a/mediagoblin/tests/test_notifications.py b/mediagoblin/tests/test_notifications.py
new file mode 100644 (file)
index 0000000..d52b8d5
--- /dev/null
@@ -0,0 +1,151 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors.  See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import pytest
+
+import urlparse
+
+from mediagoblin.tools import template, mail
+
+from mediagoblin.db.models import Notification, CommentNotification, \
+        CommentSubscription
+from mediagoblin.db.base import Session
+
+from mediagoblin.notifications import mark_comment_notification_seen
+
+from mediagoblin.tests.tools import fixture_add_comment, \
+    fixture_media_entry, fixture_add_user, \
+    fixture_comment_subscription
+
+
+class TestNotifications:
+    @pytest.fixture(autouse=True)
+    def setup(self, test_app):
+        self.test_app = test_app
+
+        # TODO: Possibly abstract into a decorator like:
+        # @as_authenticated_user('chris')
+        self.test_user = fixture_add_user()
+
+        self.current_user = None
+
+        self.login()
+
+    def login(self, username=u'chris', password=u'toast'):
+        response = self.test_app.post(
+            '/auth/login/', {
+                'username': username,
+                'password': password})
+
+        response.follow()
+
+        assert urlparse.urlsplit(response.location)[2] == '/'
+        assert 'mediagoblin/root.html' in template.TEMPLATE_TEST_CONTEXT
+
+        ctx = template.TEMPLATE_TEST_CONTEXT['mediagoblin/root.html']
+
+        assert Session.merge(ctx['request'].user).username == username
+
+        self.current_user = ctx['request'].user
+
+    def logout(self):
+        self.test_app.get('/auth/logout/')
+        self.current_user = None
+
+    @pytest.mark.parametrize('wants_email', [True, False])
+    def test_comment_notification(self, wants_email):
+        '''
+        Test
+        - if a notification is created when posting a comment on
+          another users media entry.
+        - that the comment data is consistent and exists.
+
+        '''
+        user = fixture_add_user('otherperson', password='nosreprehto',
+                                wants_comment_notification=wants_email)
+
+        user_id = user.id
+
+        media_entry = fixture_media_entry(uploader=user.id, state=u'processed')
+
+        media_entry_id = media_entry.id
+
+        subscription = fixture_comment_subscription(media_entry)
+
+        subscription_id = subscription.id
+
+        media_uri_id = '/u/{0}/m/{1}/'.format(user.username,
+                                              media_entry.id)
+        media_uri_slug = '/u/{0}/m/{1}/'.format(user.username,
+                                                media_entry.slug)
+
+        self.test_app.post(
+            media_uri_id + 'comment/add/',
+            {
+                'comment_content': u'Test comment #42'
+            }
+        )
+
+        notifications = Notification.query.filter_by(
+            user_id=user.id).all()
+
+        assert len(notifications) == 1
+
+        notification = notifications[0]
+
+        assert type(notification) == CommentNotification
+        assert notification.seen == False
+        assert notification.user_id == user.id
+        assert notification.subject.get_author.id == self.test_user.id
+        assert notification.subject.content == u'Test comment #42'
+
+        if wants_email == True:
+            assert mail.EMAIL_TEST_MBOX_INBOX == [
+                {'from': 'notice@mediagoblin.example.org',
+                'message': 'Content-Type: text/plain; \
+charset="utf-8"\nMIME-Version: 1.0\nContent-Transfer-Encoding: \
+base64\nSubject: GNU MediaGoblin - chris commented on your \
+post\nFrom: notice@mediagoblin.example.org\nTo: \
+otherperson@example.com\n\nSGkgb3RoZXJwZXJzb24sCmNocmlzIGNvbW1lbnRlZCBvbiB5b3VyIHBvc3QgKGh0dHA6Ly9sb2Nh\nbGhvc3Q6ODAvdS9vdGhlcnBlcnNvbi9tL3NvbWUtdGl0bGUvYy8xLyNjb21tZW50KSBhdCBHTlUg\nTWVkaWFHb2JsaW4KClRlc3QgY29tbWVudCAjNDIKCkdOVSBNZWRpYUdvYmxpbg==\n',
+                'to': [u'otherperson@example.com']}]
+        else:
+            assert mail.EMAIL_TEST_MBOX_INBOX == []
+
+        # Save the ids temporarily because of DetachedInstanceError
+        notification_id = notification.id
+        comment_id = notification.subject.id
+
+        self.logout()
+        self.login('otherperson', 'nosreprehto')
+
+        self.test_app.get(media_uri_slug + '/c/{0}/'.format(comment_id))
+
+        notification = Notification.query.filter_by(id=notification_id).first()
+
+        assert notification.seen == True
+
+        self.test_app.get(media_uri_slug + '/notifications/silence/')
+
+        subscription = CommentSubscription.query.filter_by(id=subscription_id)\
+                .first()
+
+        assert subscription.notify == False
+
+        notifications = Notification.query.filter_by(
+            user_id=user_id).all()
+
+        # User should not have been notified
+        assert len(notifications) == 1
index 2ee39e89c00c57f5f534972d0b4dec86b98f5df2..836072b3f64cf18e91a6f0af422630010a04c8ac 100644 (file)
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
-import sys
 import os
 import pkg_resources
 import shutil
 
-from functools import wraps
 
 from paste.deploy import loadapp
 from webtest import TestApp
 
 from mediagoblin import mg_globals
-from mediagoblin.db.models import User, MediaEntry, Collection
+from mediagoblin.db.models import User, MediaEntry, Collection, MediaComment, \
+    CommentSubscription, CommentNotification
 from mediagoblin.tools import testing
 from mediagoblin.init.config import read_mediagoblin_config
 from mediagoblin.db.base import Session
@@ -171,7 +170,7 @@ def assert_db_meets_expected(db, expected):
 
 
 def fixture_add_user(username=u'chris', password=u'toast',
-                     active_user=True):
+                     active_user=True, wants_comment_notification=True):
     # Reuse existing user or create a new one
     test_user = User.query.filter_by(username=username).first()
     if test_user is None:
@@ -184,6 +183,8 @@ def fixture_add_user(username=u'chris', password=u'toast',
         test_user.email_verified = True
         test_user.status = u'active'
 
+    test_user.wants_comment_notification = wants_comment_notification
+
     test_user.save()
 
     # Reload
@@ -195,19 +196,71 @@ def fixture_add_user(username=u'chris', password=u'toast',
     return test_user
 
 
+def fixture_comment_subscription(entry, notify=True, send_email=None):
+    if send_email is None:
+        uploader = User.query.filter_by(id=entry.uploader).first()
+        send_email = uploader.wants_comment_notification
+
+    cs = CommentSubscription(
+        media_entry_id=entry.id,
+        user_id=entry.uploader,
+        notify=notify,
+        send_email=send_email)
+
+    cs.save()
+
+    cs = CommentSubscription.query.filter_by(id=cs.id).first()
+
+    Session.expunge(cs)
+
+    return cs
+
+
+def fixture_add_comment_notification(entry_id, subject_id, user_id,
+                                     seen=False):
+    cn = CommentNotification(user_id=user_id,
+                             seen=seen,
+                             subject_id=subject_id)
+    cn.save()
+
+    cn = CommentNotification.query.filter_by(id=cn.id).first()
+
+    Session.expunge(cn)
+
+    return cn
+
+
 def fixture_media_entry(title=u"Some title", slug=None,
-                        uploader=None, save=True, gen_slug=True):
+                        uploader=None, save=True, gen_slug=True,
+                        state=u'unprocessed', fake_upload=True,
+                        expunge=True):
+    if uploader is None:
+        uploader = fixture_add_user().id
+
     entry = MediaEntry()
     entry.title = title
     entry.slug = slug
-    entry.uploader = uploader or fixture_add_user().id
+    entry.uploader = uploader
     entry.media_type = u'image'
+    entry.state = state
+
+    if fake_upload:
+        entry.media_files = {'thumb': ['a', 'b', 'c.jpg'],
+                             'medium': ['d', 'e', 'f.png'],
+                             'original': ['g', 'h', 'i.png']}
+        entry.media_type = u'mediagoblin.media_types.image'
 
     if gen_slug:
         entry.generate_slug()
+
     if save:
         entry.save()
 
+    if expunge:
+        entry = MediaEntry.query.filter_by(id=entry.id).first()
+
+        Session.expunge(entry)
+
     return entry
 
 
@@ -231,3 +284,25 @@ def fixture_add_collection(name=u"My first Collection", user=None):
 
     return coll
 
+def fixture_add_comment(author=None, media_entry=None, comment=None):
+    if author is None:
+        author = fixture_add_user().id
+
+    if media_entry is None:
+        media_entry = fixture_media_entry().id
+
+    if comment is None:
+        comment = \
+            'Auto-generated test comment by user #{0} on media #{0}'.format(
+                author, media_entry)
+
+    comment = MediaComment(author=author,
+                      media_entry=media_entry,
+                      content=comment)
+
+    comment.save()
+
+    Session.expunge(comment)
+
+    return comment
+
index 6886c859307fc9042703bbe7b70af4e89face393..0fabc5a9c3aa1f77a7c58b61fc228a0320209431 100644 (file)
@@ -90,7 +90,12 @@ def send_email(from_addr, to_addrs, subject, message_body):
     if common.TESTS_ENABLED or mg_globals.app_config['email_debug_mode']:
         mhost = FakeMhost()
     elif not mg_globals.app_config['email_debug_mode']:
-        mhost = smtplib.SMTP(
+        if mg_globals.app_config['email_smtp_use_ssl']:
+            smtp_init = smtplib.SMTP_SSL
+        else:
+            smtp_init = smtplib.SMTP
+
+        mhost = smtp_init(
             mg_globals.app_config['email_smtp_host'],
             mg_globals.app_config['email_smtp_port'])
 
index 738cc054f51b594d2c48d96a4d276fd89f486e13..83a524ec4421ec980e56ac24b56f75076e97c73d 100644 (file)
@@ -25,8 +25,9 @@ from mediagoblin.tools.response import render_to_response, render_404, \
 from mediagoblin.tools.translate import pass_to_ugettext as _
 from mediagoblin.tools.pagination import Pagination
 from mediagoblin.user_pages import forms as user_forms
-from mediagoblin.user_pages.lib import (send_comment_email,
-    add_media_to_collection)
+from mediagoblin.user_pages.lib import add_media_to_collection
+from mediagoblin.notifications import trigger_notification, \
+    add_comment_subscription, mark_comment_notification_seen
 
 from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
     get_media_entry_by_id,
@@ -34,6 +35,7 @@ from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
     get_user_collection, get_user_collection_item, active_user_from_url)
 
 from werkzeug.contrib.atom import AtomFeed
+from werkzeug.exceptions import MethodNotAllowed
 
 
 _log = logging.getLogger(__name__)
@@ -110,6 +112,7 @@ def user_gallery(request, page, url_user=None):
          'media_entries': media_entries,
          'pagination': pagination})
 
+
 MEDIA_COMMENTS_PER_PAGE = 50
 
 
@@ -121,6 +124,9 @@ def media_home(request, media, page, **kwargs):
     """
     comment_id = request.matchdict.get('comment', None)
     if comment_id:
+        if request.user:
+            mark_comment_notification_seen(comment_id, request.user)
+
         pagination = Pagination(
             page, media.get_comments(
                 mg_globals.app_config['comments_ascending']),
@@ -154,7 +160,8 @@ def media_post_comment(request, media):
     """
     recieves POST from a MediaEntry() comment form, saves the comment.
     """
-    assert request.method == 'POST'
+    if not request.method == 'POST':
+        raise MethodNotAllowed()
 
     comment = request.db.MediaComment()
     comment.media_entry = media.id
@@ -179,11 +186,9 @@ def media_post_comment(request, media):
             request, messages.SUCCESS,
             _('Your comment has been posted!'))
 
-        media_uploader = media.get_uploader
-        #don't send email if you comment on your own post
-        if (comment.author != media_uploader and
-            media_uploader.wants_comment_notification):
-            send_comment_email(media_uploader, comment, media, request)
+        trigger_notification(comment, media, request)
+
+        add_comment_subscription(request.user, media)
 
     return redirect_obj(request, media)
 
index f320e92cb774262dec31910b429df58b4d59e528..a0a49a28c82523c30ba648c2152d513232bd8fac 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -57,7 +57,7 @@ setup(
         'webtest<2',
         'ConfigObj',
         'Markdown',
-        'sqlalchemy>=0.7.0',
+        'sqlalchemy>=0.8.0',
         'sqlalchemy-migrate',
         'mock',
         'itsdangerous',