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__)
request.urlgen = build_proxy
+ request.notifications = notifications
+
mg_request.setup_user_in_request(request)
request.controller_name = None
# 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)
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 = {}
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)
import re
import datetime
+from datetime import datetime
+
from werkzeug.utils import cached_property
from mediagoblin import mg_globals
"""
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):
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
# 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.
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.
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]
######################################################
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'
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']))
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
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
--- /dev/null
+# 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
--- /dev/null
+# 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')
--- /dev/null
+# 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]
--- /dev/null
+# 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}
--- /dev/null
+# 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))
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)
margin-top: 8px;
}
+.comment_active {
+ box-shadow: 0px 0px 15px 15px #378566;
+ background: #378566;
+ color: #f7f7f7;
+}
+
textarea#comment_content {
resize: vertical;
width: 100%;
--- /dev/null
+'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();
+});
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):
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:
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
<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">▼</div>
<div class="button_action header_dropdown_up">▲</div>
{% elif request.user and request.user.status == "needs_email_verification" %}
</a>
</p>
{% endif %}
+ {% include 'mediagoblin/fragments/header_notifications.html' %}
</div>
{% endif %}
</header>
--- /dev/null
+{% 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 %}
{% include "mediagoblin/utils/exif.html" %}
+ {% include "mediagoblin/utils/comment-subscription.html" %}
+
{%- if media.attachment_files|count %}
<h3>{% trans %}Attachments{% endtrans %}</h3>
<ul>
--- /dev/null
+{#
+# 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 %}
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:///' +
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()
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"],
--- /dev/null
+# 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
# 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
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:
test_user.email_verified = True
test_user.status = u'active'
+ test_user.wants_comment_notification = wants_comment_notification
+
test_user.save()
# Reload
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
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
+
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'])
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,
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__)
'media_entries': media_entries,
'pagination': pagination})
+
MEDIA_COMMENTS_PER_PAGE = 50
"""
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']),
"""
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
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)
'webtest<2',
'ConfigObj',
'Markdown',
- 'sqlalchemy>=0.7.0',
+ 'sqlalchemy>=0.8.0',
'sqlalchemy-migrate',
'mock',
'itsdangerous',