New notifications

- 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
This commit is contained in:
Joar Wandborg 2013-04-07 23:17:23 +02:00
parent 25aad338d4
commit 2d7b6bdef9
28 changed files with 891 additions and 29 deletions

View File

@ -37,6 +37,7 @@ from mediagoblin.init import (get_jinja_loader, get_staticdirector,
setup_storage) setup_storage)
from mediagoblin.tools.pluginapi import PluginManager, hook_transform from mediagoblin.tools.pluginapi import PluginManager, hook_transform
from mediagoblin.tools.crypto import setup_crypto from mediagoblin.tools.crypto import setup_crypto
from mediagoblin import notifications
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@ -186,6 +187,8 @@ class MediaGoblinApp(object):
request.urlgen = build_proxy request.urlgen = build_proxy
request.notifications = notifications
mg_request.setup_user_in_request(request) mg_request.setup_user_in_request(request)
request.controller_name = None request.controller_name = None

View File

@ -22,9 +22,10 @@ direct_remote_path = string(default="/mgoblin_static/")
# set to false to enable sending notices # set to false to enable sending notices
email_debug_mode = boolean(default=True) email_debug_mode = boolean(default=True)
email_smtp_use_ssl = boolean(default=False)
email_sender_address = string(default="notice@mediagoblin.example.org") email_sender_address = string(default="notice@mediagoblin.example.org")
email_smtp_host = string(default='') 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_user = string(default=None)
email_smtp_pass = string(default=None) email_smtp_pass = string(default=None)

View File

@ -26,7 +26,7 @@ from sqlalchemy.sql import and_
from migrate.changeset.constraint import UniqueConstraint from migrate.changeset.constraint import UniqueConstraint
from mediagoblin.db.migration_tools import RegisterMigration, inspect_table 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 = {} MIGRATIONS = {}
@ -287,3 +287,58 @@ def unique_collections_slug(db):
constraint.create() constraint.create()
db.commit() 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)

View File

@ -31,6 +31,8 @@ import uuid
import re import re
import datetime import datetime
from datetime import datetime
from werkzeug.utils import cached_property from werkzeug.utils import cached_property
from mediagoblin import mg_globals from mediagoblin import mg_globals
@ -288,6 +290,13 @@ class MediaCommentMixin(object):
""" """
return cleaned_markdown_conversion(self.content) 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): class CollectionMixin(GenerateSlugMixin):
def check_slug_used(self, slug): def check_slug_used(self, slug):

View File

@ -24,15 +24,17 @@ import datetime
from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \ from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \ Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
SmallInteger 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.orm.collections import attribute_mapped_collection
from sqlalchemy.sql.expression import desc from sqlalchemy.sql.expression import desc
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.util import memoized_property from sqlalchemy.util import memoized_property
from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded
from mediagoblin.db.base import Base, DictReadAttrProxy 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.files import delete_media_files
from mediagoblin.tools.common import import_component 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 # the RFC) and because it would be a mess to implement at this
# point. # point.
email = Column(Unicode, nullable=False) email = Column(Unicode, nullable=False)
created = Column(DateTime, nullable=False, default=datetime.datetime.now)
pw_hash = Column(Unicode, nullable=False) pw_hash = Column(Unicode, nullable=False)
email_verified = Column(Boolean, default=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) status = Column(Unicode, default=u"needs_email_verification", nullable=False)
# Intented to be nullable=False, but migrations would not work for it # Intented to be nullable=False, but migrations would not work for it
# set to nullable=True implicitly. # set to nullable=True implicitly.
@ -392,6 +394,10 @@ class MediaComment(Base, MediaCommentMixin):
backref=backref("posted_comments", backref=backref("posted_comments",
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan")) 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. # Cascade: Comments are somewhat owned by their MediaEntry.
# So do the full thing. # So do the full thing.
@ -484,9 +490,103 @@ class ProcessingMetaData(Base):
return DictReadAttrProxy(self) 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 = [ MODELS = [
User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames, User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
MediaAttachmentFile, ProcessingMetaData] MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
Notification, CommentNotification, ProcessingNotification,
CommentSubscription]
###################################################### ######################################################

View File

@ -16,12 +16,18 @@
import os import os
import sys import sys
import logging
from celery import Celery from celery import Celery
from mediagoblin.tools.pluginapi import hook_runall 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' 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: if set_environ:
os.environ['CELERY_CONFIG_MODULE'] = settings_module 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']))

View File

@ -22,9 +22,15 @@ import logging
import urllib import urllib
import multiprocessing import multiprocessing
import gobject import gobject
old_argv = sys.argv
sys.argv = []
import pygst import pygst
pygst.require('0.10') pygst.require('0.10')
import gst import gst
sys.argv = old_argv
import struct import struct
try: try:
from PIL import Image from PIL import Image

View File

@ -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

View File

@ -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')

View File

@ -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]

View File

@ -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}

View File

@ -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))

View File

@ -35,6 +35,7 @@ def get_url_map():
import mediagoblin.edit.routing import mediagoblin.edit.routing
import mediagoblin.webfinger.routing import mediagoblin.webfinger.routing
import mediagoblin.listings.routing import mediagoblin.listings.routing
import mediagoblin.notifications.routing
for route in PluginManager().get_routes(): for route in PluginManager().get_routes():
add_route(*route) add_route(*route)

View File

@ -384,6 +384,12 @@ a.comment_whenlink:hover {
margin-top: 8px; margin-top: 8px;
} }
.comment_active {
box-shadow: 0px 0px 15px 15px #378566;
background: #378566;
color: #f7f7f7;
}
textarea#comment_content { textarea#comment_content {
resize: vertical; resize: vertical;
width: 100%; width: 100%;

View File

@ -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();
});

View File

@ -34,6 +34,8 @@ from mediagoblin.media_types import sniff_media, \
from mediagoblin.submit.lib import check_file_field, prepare_queue_task, \ from mediagoblin.submit.lib import check_file_field, prepare_queue_task, \
run_process_media, new_upload_entry run_process_media, new_upload_entry
from mediagoblin.notifications import add_comment_subscription
@require_active_login @require_active_login
def submit_start(request): def submit_start(request):
@ -92,6 +94,8 @@ def submit_start(request):
run_process_media(entry, feed_url) run_process_media(entry, feed_url)
add_message(request, SUCCESS, _('Woohoo! Submitted!')) add_message(request, SUCCESS, _('Woohoo! Submitted!'))
add_comment_subscription(request.user, entry)
return redirect(request, "mediagoblin.user_pages.user_home", return redirect(request, "mediagoblin.user_pages.user_home",
user=request.user.username) user=request.user.username)
except Exception as e: except Exception as e:

View File

@ -34,6 +34,8 @@
src="{{ request.staticdirect('/js/extlib/jquery.js') }}"></script> src="{{ request.staticdirect('/js/extlib/jquery.js') }}"></script>
<script type="text/javascript" <script type="text/javascript"
src="{{ request.staticdirect('/js/header_dropdown.js') }}"></script> 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 {# For clarification, the difference between the extra_head.html template
# and the head template hook is that the former should be used by # and the head template hook is that the former should be used by
@ -57,6 +59,9 @@
<div class="header_right"> <div class="header_right">
{%- if request.user %} {%- if request.user %}
{% if request.user and request.user.status == 'active' %} {% 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_down">&#9660;</div>
<div class="button_action header_dropdown_up">&#9650;</div> <div class="button_action header_dropdown_up">&#9650;</div>
{% elif request.user and request.user.status == "needs_email_verification" %} {% elif request.user and request.user.status == "needs_email_verification" %}
@ -109,6 +114,7 @@
</a> </a>
</p> </p>
{% endif %} {% endif %}
{% include 'mediagoblin/fragments/header_notifications.html' %}
</div> </div>
{% endif %} {% endif %}
</header> </header>

View File

@ -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 %}

View File

@ -167,6 +167,8 @@
{% include "mediagoblin/utils/exif.html" %} {% include "mediagoblin/utils/exif.html" %}
{% include "mediagoblin/utils/comment-subscription.html" %}
{%- if media.attachment_files|count %} {%- if media.attachment_files|count %}
<h3>{% trans %}Attachments{% endtrans %}</h3> <h3>{% trans %}Attachments{% endtrans %}</h3>
<ul> <ul>

View File

@ -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 %}

View File

@ -48,7 +48,7 @@ def test_setup_celery_from_config():
assert isinstance(fake_celery_module.CELERYD_ETA_SCHEDULER_PRECISION, float) assert isinstance(fake_celery_module.CELERYD_ETA_SCHEDULER_PRECISION, float)
assert fake_celery_module.CELERY_RESULT_PERSISTENT is True assert fake_celery_module.CELERY_RESULT_PERSISTENT is True
assert fake_celery_module.CELERY_IMPORTS == [ 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_BACKEND == 'database'
assert fake_celery_module.CELERY_RESULT_DBURI == ( assert fake_celery_module.CELERY_RESULT_DBURI == (
'sqlite:///' + 'sqlite:///' +

View File

@ -28,8 +28,10 @@ def test_user_deletes_other_comments(test_app):
user_a = fixture_add_user(u"chris_a") user_a = fixture_add_user(u"chris_a")
user_b = fixture_add_user(u"chris_b") user_b = fixture_add_user(u"chris_b")
media_a = fixture_media_entry(uploader=user_a.id, save=False) media_a = fixture_media_entry(uploader=user_a.id, save=False,
media_b = fixture_media_entry(uploader=user_b.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_a)
Session.add(media_b) Session.add(media_b)
Session.flush() Session.flush()
@ -79,7 +81,7 @@ def test_user_deletes_other_comments(test_app):
def test_media_deletes_broken_attachment(test_app): def test_media_deletes_broken_attachment(test_app):
user_a = fixture_add_user(u"chris_a") 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( media.attachment_files.append(dict(
name=u"some name", name=u"some name",
filepath=[u"does", u"not", u"exist"], filepath=[u"does", u"not", u"exist"],

View File

@ -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

View File

@ -15,18 +15,17 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import os import os
import pkg_resources import pkg_resources
import shutil import shutil
from functools import wraps
from paste.deploy import loadapp from paste.deploy import loadapp
from webtest import TestApp from webtest import TestApp
from mediagoblin import mg_globals 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.tools import testing
from mediagoblin.init.config import read_mediagoblin_config from mediagoblin.init.config import read_mediagoblin_config
from mediagoblin.db.base import Session 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', 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 # Reuse existing user or create a new one
test_user = User.query.filter_by(username=username).first() test_user = User.query.filter_by(username=username).first()
if test_user is None: 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.email_verified = True
test_user.status = u'active' test_user.status = u'active'
test_user.wants_comment_notification = wants_comment_notification
test_user.save() test_user.save()
# Reload # Reload
@ -195,19 +196,71 @@ def fixture_add_user(username=u'chris', password=u'toast',
return test_user 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, 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 = MediaEntry()
entry.title = title entry.title = title
entry.slug = slug entry.slug = slug
entry.uploader = uploader or fixture_add_user().id entry.uploader = uploader
entry.media_type = u'image' 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: if gen_slug:
entry.generate_slug() entry.generate_slug()
if save: if save:
entry.save() entry.save()
if expunge:
entry = MediaEntry.query.filter_by(id=entry.id).first()
Session.expunge(entry)
return entry return entry
@ -231,3 +284,25 @@ def fixture_add_collection(name=u"My first Collection", user=None):
return coll 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

View 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']: if common.TESTS_ENABLED or mg_globals.app_config['email_debug_mode']:
mhost = FakeMhost() mhost = FakeMhost()
elif not mg_globals.app_config['email_debug_mode']: 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_host'],
mg_globals.app_config['email_smtp_port']) mg_globals.app_config['email_smtp_port'])

View 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.translate import pass_to_ugettext as _
from mediagoblin.tools.pagination import Pagination from mediagoblin.tools.pagination import Pagination
from mediagoblin.user_pages import forms as user_forms from mediagoblin.user_pages import forms as user_forms
from mediagoblin.user_pages.lib import (send_comment_email, from mediagoblin.user_pages.lib import add_media_to_collection
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, from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
get_media_entry_by_id, 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) get_user_collection, get_user_collection_item, active_user_from_url)
from werkzeug.contrib.atom import AtomFeed from werkzeug.contrib.atom import AtomFeed
from werkzeug.exceptions import MethodNotAllowed
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@ -110,6 +112,7 @@ def user_gallery(request, page, url_user=None):
'media_entries': media_entries, 'media_entries': media_entries,
'pagination': pagination}) 'pagination': pagination})
MEDIA_COMMENTS_PER_PAGE = 50 MEDIA_COMMENTS_PER_PAGE = 50
@ -121,6 +124,9 @@ def media_home(request, media, page, **kwargs):
""" """
comment_id = request.matchdict.get('comment', None) comment_id = request.matchdict.get('comment', None)
if comment_id: if comment_id:
if request.user:
mark_comment_notification_seen(comment_id, request.user)
pagination = Pagination( pagination = Pagination(
page, media.get_comments( page, media.get_comments(
mg_globals.app_config['comments_ascending']), 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. 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 = request.db.MediaComment()
comment.media_entry = media.id comment.media_entry = media.id
@ -179,11 +186,9 @@ def media_post_comment(request, media):
request, messages.SUCCESS, request, messages.SUCCESS,
_('Your comment has been posted!')) _('Your comment has been posted!'))
media_uploader = media.get_uploader trigger_notification(comment, media, request)
#don't send email if you comment on your own post
if (comment.author != media_uploader and add_comment_subscription(request.user, media)
media_uploader.wants_comment_notification):
send_comment_email(media_uploader, comment, media, request)
return redirect_obj(request, media) return redirect_obj(request, media)

View File

@ -57,7 +57,7 @@ setup(
'webtest<2', 'webtest<2',
'ConfigObj', 'ConfigObj',
'Markdown', 'Markdown',
'sqlalchemy>=0.7.0', 'sqlalchemy>=0.8.0',
'sqlalchemy-migrate', 'sqlalchemy-migrate',
'mock', 'mock',
'itsdangerous', 'itsdangerous',