diff options
50 files changed, 918 insertions, 407 deletions
diff --git a/mediagoblin/auth/forms.py b/mediagoblin/auth/forms.py index 0b2bf959..7cae951a 100644 --- a/mediagoblin/auth/forms.py +++ b/mediagoblin/auth/forms.py @@ -17,52 +17,75 @@ import wtforms import re +from mediagoblin.tools.mail import normalize_email from mediagoblin.tools.translate import fake_ugettext_passthrough as _ +def normalize_user_or_email_field(allow_email=True, allow_user=True): + """Check if we were passed a field that matches a username and/or email pattern + + This is useful for fields that can take either a username or email + address. Use the parameters if you want to only allow a username for + instance""" + message = _(u'Invalid User name or email address.') + nomail_msg = _(u"This field does not take email addresses.") + nouser_msg = _(u"This field requires an email address.") + + def _normalize_field(form, field): + email = u'@' in field.data + if email: # normalize email address casing + if not allow_email: + raise wtforms.ValidationError(nomail_msg) + wtforms.validators.Email()(form, field) + field.data = normalize_email(field.data) + else: # lower case user names + if not allow_user: + raise wtforms.ValidationError(nouser_msg) + wtforms.validators.Length(min=3, max=30)(form, field) + wtforms.validators.Regexp(r'^\w+$')(form, field) + field.data = field.data.lower() + if field.data is None: # should not happen, but be cautious anyway + raise wtforms.ValidationError(message) + return _normalize_field + class RegistrationForm(wtforms.Form): username = wtforms.TextField( _('Username'), [wtforms.validators.Required(), - wtforms.validators.Length(min=3, max=30), - wtforms.validators.Regexp(r'^\w+$')]) + normalize_user_or_email_field(allow_email=False)]) password = wtforms.PasswordField( _('Password'), [wtforms.validators.Required(), - wtforms.validators.Length(min=6, max=30)]) + wtforms.validators.Length(min=5, max=1024)]) email = wtforms.TextField( _('Email address'), [wtforms.validators.Required(), - wtforms.validators.Email()]) + normalize_user_or_email_field(allow_user=False)]) class LoginForm(wtforms.Form): username = wtforms.TextField( _('Username'), [wtforms.validators.Required(), - wtforms.validators.Regexp(r'^\w+$')]) + normalize_user_or_email_field(allow_email=False)]) password = wtforms.PasswordField( _('Password'), - [wtforms.validators.Required()]) + [wtforms.validators.Required(), + wtforms.validators.Length(min=5, max=1024)]) class ForgotPassForm(wtforms.Form): username = wtforms.TextField( _('Username or email'), - [wtforms.validators.Required()]) - - def validate_username(form, field): - if not (re.match(r'^\w+$', field.data) or - re.match(r'^.+@[^.].*\.[a-z]{2,10}$', field.data, - re.IGNORECASE)): - raise wtforms.ValidationError(_(u'Incorrect input')) + [wtforms.validators.Required(), + normalize_user_or_email_field()]) class ChangePassForm(wtforms.Form): password = wtforms.PasswordField( 'Password', [wtforms.validators.Required(), - wtforms.validators.Length(min=6, max=30)]) + wtforms.validators.Length(min=5, max=1024)]) userid = wtforms.HiddenField( '', [wtforms.validators.Required()]) diff --git a/mediagoblin/auth/views.py b/mediagoblin/auth/views.py index 43354135..d8ad7b51 100644 --- a/mediagoblin/auth/views.py +++ b/mediagoblin/auth/views.py @@ -41,8 +41,10 @@ def email_debug_message(request): def register(request): - """ - Your classic registration view! + """The registration view. + + Note that usernames will always be lowercased. Email domains are lowercased while + the first part remains case-sensitive. """ # Redirects to indexpage if registrations are disabled if not mg_globals.app_config["allow_registration"]: @@ -56,12 +58,8 @@ def register(request): if request.method == 'POST' and register_form.validate(): # TODO: Make sure the user doesn't exist already - username = unicode(request.form['username'].lower()) - em_user, em_dom = unicode(request.form['email']).split("@", 1) - em_dom = em_dom.lower() - email = em_user + "@" + em_dom - users_with_username = User.query.filter_by(username=username).count() - users_with_email = User.query.filter_by(email=email).count() + users_with_username = User.query.filter_by(username=register_form.data['username']).count() + users_with_email = User.query.filter_by(email=register_form.data['email']).count() extra_validation_passes = True @@ -77,8 +75,8 @@ def register(request): if extra_validation_passes: # Create the user user = User() - user.username = username - user.email = email + user.username = register_form.data['username'] + user.email = register_form.data['email'] user.pw_hash = auth_lib.bcrypt_gen_password_hash( request.form['password']) user.verification_key = unicode(uuid.uuid4()) @@ -114,20 +112,21 @@ def login(request): login_failed = False - if request.method == 'POST' and login_form.validate(): - user = User.query.filter_by(username=request.form['username'].lower()).first() + if request.method == 'POST': + if login_form.validate(): + user = User.query.filter_by(username=login_form.data['username']).first() - if user and user.check_login(request.form['password']): - # set up login in session - request.session['user_id'] = unicode(user.id) - request.session.save() + if user and user.check_login(request.form['password']): + # set up login in session + request.session['user_id'] = unicode(user.id) + request.session.save() - if request.form.get('next'): - return redirect(request, location=request.form['next']) - else: - return redirect(request, "index") + if request.form.get('next'): + return redirect(request, location=request.form['next']) + else: + return redirect(request, "index") - else: + # Some failure during login occured if we are here! # Prevent detecting who's on this system by testing login # attempt timings auth_lib.fake_login_attempt() @@ -227,59 +226,66 @@ def forgot_password(request): """ Forgot password view - Sends an email with an url to renew forgotten password + Sends an email with an url to renew forgotten password. + Use GET querystring parameter 'username' to pre-populate the input field """ fp_form = auth_forms.ForgotPassForm(request.form, - username=request.GET.get('username')) - - if request.method == 'POST' and fp_form.validate(): - - # '$or' not available till mongodb 1.5.3 - user = User.query.filter_by(username=request.form['username']).first() - if not user: - user = User.query.filter_by(email=request.form['username']).first() - - if user: - if user.email_verified and user.status == 'active': - user.fp_verification_key = unicode(uuid.uuid4()) - user.fp_token_expire = datetime.datetime.now() + \ - datetime.timedelta(days=10) - user.save() - - send_fp_verification_email(user, request) - - messages.add_message( - request, - messages.INFO, - _("An email has been sent with instructions on how to " - "change your password.")) - email_debug_message(request) - - else: - # special case... we can't send the email because the - # username is inactive / hasn't verified their email - messages.add_message( - request, - messages.WARNING, - _("Could not send password recovery email as " - "your username is inactive or your account's " - "email address has not been verified.")) - - return redirect( - request, 'mediagoblin.user_pages.user_home', - user=user.username) - return redirect(request, 'mediagoblin.auth.login') - else: - messages.add_message( - request, - messages.WARNING, - _("Couldn't find someone with that username or email.")) + username=request.args.get('username')) + + if not (request.method == 'POST' and fp_form.validate()): + # Either GET request, or invalid form submitted. Display the template + return render_to_response(request, + 'mediagoblin/auth/forgot_password.html', {'fp_form': fp_form}) + + # If we are here: method == POST and form is valid. username casing + # has been sanitized. Store if a user was found by email. We should + # not reveal if the operation was successful then as we don't want to + # leak if an email address exists in the system. + found_by_email = '@' in request.form['username'] + + if found_by_email: + user = User.query.filter_by( + email = request.form['username']).first() + # Don't reveal success in case the lookup happened by email address. + success_message=_("If that email address (case sensitive!) is " + "registered an email has been sent with instructions " + "on how to change your password.") + + else: # found by username + user = User.query.filter_by( + username = request.form['username']).first() + + if user is None: + messages.add_message(request, + messages.WARNING, + _("Couldn't find someone with that username.")) return redirect(request, 'mediagoblin.auth.forgot_password') - return render_to_response( - request, - 'mediagoblin/auth/forgot_password.html', - {'fp_form': fp_form}) + success_message=_("An email has been sent with instructions " + "on how to change your password.") + + if user and not(user.email_verified and user.status == 'active'): + # Don't send reminder because user is inactive or has no verified email + messages.add_message(request, + messages.WARNING, + _("Could not send password recovery email as your username is in" + "active or your account's email address has not been verified.")) + + return redirect(request, 'mediagoblin.user_pages.user_home', + user=user.username) + + # SUCCESS. Send reminder and return to login page + if user: + user.fp_verification_key = unicode(uuid.uuid4()) + user.fp_token_expire = datetime.datetime.now() + \ + datetime.timedelta(days=10) + user.save() + + email_debug_message(request) + send_fp_verification_email(user, request) + + messages.add_message(request, messages.INFO, success_message) + return redirect(request, 'mediagoblin.auth.login') def verify_forgot_password(request): diff --git a/mediagoblin/config_spec.ini b/mediagoblin/config_spec.ini index bee67d46..50ce252e 100644 --- a/mediagoblin/config_spec.ini +++ b/mediagoblin/config_spec.ini @@ -86,6 +86,10 @@ max_height = integer(default=640) max_width = integer(default=180) max_height = integer(default=180) +[media_type:mediagoblin.media_types.image] +# One of BICUBIC, BILINEAR, NEAREST, ANTIALIAS +resize_filter = string(default="ANTIALIAS") + [media_type:mediagoblin.media_types.video] # Should we keep the original file? keep_original = boolean(default=False) diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index 80ec5269..b25d577d 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -184,3 +184,14 @@ def fix_CollectionItem_v0_constraint(db_conn): pass db_conn.commit() + + +@RegisterMigration(8, MIGRATIONS) +def add_license_preference(db): + metadata = MetaData(bind=db.bind) + + user_table = inspect_table(metadata, 'core__users') + + col = Column('license_preference', Unicode) + col.create(user_table) + db.commit() diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index ea915ae5..de491e96 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -18,7 +18,7 @@ TODO: indexes on foreignkeys, where useful. """ - +import logging import datetime import sys @@ -34,6 +34,7 @@ from sqlalchemy.util import memoized_property from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded from mediagoblin.db.base import Base, DictReadAttrProxy, Session from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, MediaCommentMixin, CollectionMixin, CollectionItemMixin +from mediagoblin.tools.files import delete_media_files # It's actually kind of annoying how sqlalchemy-migrate does this, if # I understand it right, but whatever. Anyway, don't remove this :P @@ -42,6 +43,8 @@ from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, MediaCommentMixin, # this import-based meddling... from migrate import changeset +_log = logging.getLogger(__name__) + class User(Base, UserMixin): """ @@ -60,6 +63,7 @@ class User(Base, UserMixin): # Intented to be nullable=False, but migrations would not work for it # set to nullable=True implicitly. wants_comment_notification = Column(Boolean, default=True) + license_preference = Column(Unicode) verification_key = Column(Unicode) is_admin = Column(Boolean, default=False, nullable=False) url = Column(Unicode) @@ -78,6 +82,25 @@ class User(Base, UserMixin): 'admin' if self.is_admin else 'user', self.username) + def delete(self, **kwargs): + """Deletes a User and all related entries/comments/files/...""" + # Collections get deleted by relationships. + + media_entries = MediaEntry.query.filter(MediaEntry.uploader == self.id) + for media in media_entries: + # TODO: Make sure that "MediaEntry.delete()" also deletes + # all related files/Comments + media.delete(del_orphan_tags=False, commit=False) + + # Delete now unused tags + # TODO: import here due to cyclic imports!!! This cries for refactoring + from mediagoblin.db.util import clean_orphan_tags + clean_orphan_tags(commit=False) + + # Delete user, pass through commit=False/True in kwargs + super(User, self).delete(**kwargs) + _log.info('Deleted user "{0}" account'.format(self.username)) + class MediaEntry(Base, MediaEntryMixin): """ @@ -122,7 +145,6 @@ class MediaEntry(Base, MediaEntryMixin): ) attachment_files_helper = relationship("MediaAttachmentFile", - cascade="all, delete-orphan", order_by="MediaAttachmentFile.created" ) attachment_files = association_proxy("attachment_files_helper", "dict_view", @@ -131,7 +153,7 @@ class MediaEntry(Base, MediaEntryMixin): ) tags_helper = relationship("MediaTag", - cascade="all, delete-orphan" + cascade="all, delete-orphan" # should be automatically deleted ) tags = association_proxy("tags_helper", "dict_view", creator=lambda v: MediaTag(name=v["name"], slug=v["slug"]) @@ -216,6 +238,37 @@ class MediaEntry(Base, MediaEntryMixin): id=self.id, title=safe_title) + def delete(self, del_orphan_tags=True, **kwargs): + """Delete MediaEntry and all related files/attachments/comments + + This will *not* automatically delete unused collections, which + can remain empty... + + :param del_orphan_tags: True/false if we delete unused Tags too + :param commit: True/False if this should end the db transaction""" + # User's CollectionItems are automatically deleted via "cascade". + # Delete all the associated comments + for comment in self.get_comments(): + comment.delete(commit=False) + + # Delete all related files/attachments + try: + delete_media_files(self) + except OSError, error: + # Returns list of files we failed to delete + _log.error('No such files from the user "{1}" to delete: ' + '{0}'.format(str(error), self.get_uploader)) + _log.info('Deleted Media entry id "{0}"'.format(self.id)) + # Related MediaTag's are automatically cleaned, but we might + # want to clean out unused Tag's too. + if del_orphan_tags: + # TODO: Import here due to cyclic imports!!! + # This cries for refactoring + from mediagoblin.db.util import clean_orphan_tags + clean_orphan_tags(commit=False) + # pass through commit=False/True in kwargs + super(MediaEntry, self).delete(**kwargs) + class FileKeynames(Base): """ @@ -344,6 +397,10 @@ class MediaComment(Base, MediaCommentMixin): class Collection(Base, CollectionMixin): + """An 'album' or 'set' of media by a user. + + On deletion, contained CollectionItems get automatically reaped via + SQL cascade""" __tablename__ = "core__collections" id = Column(Integer, primary_key=True) @@ -353,11 +410,16 @@ class Collection(Base, CollectionMixin): index=True) description = Column(UnicodeText) creator = Column(Integer, ForeignKey(User.id), nullable=False) + # TODO: No of items in Collection. Badly named, can we migrate to num_items? items = Column(Integer, default=0) - get_creator = relationship(User) + # Cascade: Collections are owned by their creator. So do the full thing. + get_creator = relationship(User, + backref=backref("collections", + cascade="all, delete-orphan")) def get_collection_items(self, ascending=False): + #TODO, is this still needed with self.collection_items being available? order_col = CollectionItem.position if not ascending: order_col = desc(order_col) @@ -375,7 +437,12 @@ class CollectionItem(Base, CollectionItemMixin): note = Column(UnicodeText, nullable=True) added = Column(DateTime, nullable=False, default=datetime.datetime.now) position = Column(Integer) - in_collection = relationship("Collection") + + # Cascade: CollectionItems are owned by their Collection. So do the full thing. + in_collection = relationship(Collection, + backref=backref( + "collection_items", + cascade="all, delete-orphan")) get_media_entry = relationship(MediaEntry) diff --git a/mediagoblin/db/open.py b/mediagoblin/db/open.py index d976acd8..5fd5ed03 100644 --- a/mediagoblin/db/open.py +++ b/mediagoblin/db/open.py @@ -15,7 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event import logging from mediagoblin.db.base import Base, Session @@ -66,9 +66,20 @@ def load_models(app_config): exc)) +def _sqlite_fk_pragma_on_connect(dbapi_con, con_record): + """Enable foreign key checking on each new sqlite connection""" + dbapi_con.execute('pragma foreign_keys=on') + + def setup_connection_and_db_from_config(app_config): engine = create_engine(app_config['sql_engine']) + + # Enable foreign key checking for sqlite + if app_config['sql_engine'].startswith('sqlite://'): + event.listen(engine, 'connect', _sqlite_fk_pragma_on_connect) + # logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) + Session.configure(bind=engine) return DatabaseMaster(engine) diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py index 5533e81d..09235614 100644 --- a/mediagoblin/decorators.py +++ b/mediagoblin/decorators.py @@ -20,6 +20,7 @@ from urlparse import urljoin from werkzeug.exceptions import Forbidden, NotFound from werkzeug.urls import url_quote +from mediagoblin import mg_globals as mgg from mediagoblin.db.models import MediaEntry, User from mediagoblin.tools.response import redirect, render_404 @@ -31,11 +32,11 @@ def require_active_login(controller): @wraps(controller) def new_controller_func(request, *args, **kwargs): if request.user and \ - request.user.get('status') == u'needs_email_verification': + request.user.status == u'needs_email_verification': return redirect( request, 'mediagoblin.user_pages.user_home', user=request.user.username) - elif not request.user or request.user.get('status') != u'active': + elif not request.user or request.user.status != u'active': next_url = urljoin( request.urlgen('mediagoblin.auth.login', qualified=True), @@ -69,7 +70,7 @@ def user_may_delete_media(controller): """ @wraps(controller) def wrapper(request, *args, **kwargs): - uploader_id = MediaEntry.query.get(request.matchdict['media']).uploader + uploader_id = kwargs['media'].uploader if not (request.user.is_admin or request.user.id == uploader_id): raise Forbidden() @@ -209,12 +210,27 @@ def get_media_entry_by_id(controller): @wraps(controller) def wrapper(request, *args, **kwargs): media = MediaEntry.query.filter_by( - id=request.matchdict['media'], + id=request.matchdict['media_id'], state=u'processed').first() # Still no media? Okay, 404. if not media: return render_404(request) + given_username = request.matchdict.get('user') + if given_username and (given_username != media.get_uploader.username): + return render_404(request) + return controller(request, media=media, *args, **kwargs) return wrapper + + +def get_workbench(func): + """Decorator, passing in a workbench as kwarg which is cleaned up afterwards""" + + @wraps(func) + def new_func(*args, **kwargs): + with mgg.workbench_manager.create() as workbench: + return func(*args, workbench=workbench, **kwargs) + + return new_func diff --git a/mediagoblin/edit/forms.py b/mediagoblin/edit/forms.py index 293c3bb2..2673967b 100644 --- a/mediagoblin/edit/forms.py +++ b/mediagoblin/edit/forms.py @@ -65,8 +65,19 @@ class EditAccountForm(wtforms.Form): "Enter your old password to prove you own this account.")) new_password = wtforms.PasswordField( _('New password'), - [wtforms.validators.Length(min=6, max=30)], + [ + wtforms.validators.Optional(), + wtforms.validators.Length(min=6, max=30) + ], id="password") + license_preference = wtforms.SelectField( + _('License preference'), + [ + wtforms.validators.Optional(), + wtforms.validators.AnyOf([lic[0] for lic in licenses_as_choices()]), + ], + choices=licenses_as_choices(), + description=_('This will be your default license on upload forms.')) wants_comment_notification = wtforms.BooleanField( label=_("Email me when others comment on my media")) diff --git a/mediagoblin/edit/routing.py b/mediagoblin/edit/routing.py index d382e549..035a766f 100644 --- a/mediagoblin/edit/routing.py +++ b/mediagoblin/edit/routing.py @@ -22,3 +22,5 @@ add_route('mediagoblin.edit.legacy_edit_profile', '/edit/profile/', 'mediagoblin.edit.views:legacy_edit_profile') add_route('mediagoblin.edit.account', '/edit/account/', 'mediagoblin.edit.views:edit_account') +add_route('mediagoblin.edit.delete_account', '/edit/account/delete/', + 'mediagoblin.edit.views:delete_account') diff --git a/mediagoblin/edit/views.py b/mediagoblin/edit/views.py index 646a9e5b..25a617fd 100644 --- a/mediagoblin/edit/views.py +++ b/mediagoblin/edit/views.py @@ -27,6 +27,7 @@ from mediagoblin.auth import lib as auth_lib from mediagoblin.edit import forms from mediagoblin.edit.lib import may_edit_media from mediagoblin.decorators import (require_active_login, active_user_from_url, + get_media_entry_by_id, get_user_media_entry, user_may_alter_collection, get_user_collection) from mediagoblin.tools.response import render_to_response, redirect from mediagoblin.tools.translate import pass_to_ugettext as _ @@ -38,7 +39,7 @@ from mediagoblin.db.util import check_media_slug_used, check_collection_slug_use import mimetypes -@get_user_media_entry +@get_media_entry_by_id @require_active_login def edit_media(request, media): if not may_edit_media(request, media): @@ -190,8 +191,8 @@ def edit_profile(request, url_user=None): user = url_user form = forms.EditProfileForm(request.form, - url=user.get('url'), - bio=user.get('bio')) + url=user.url, + bio=user.bio) if request.method == 'POST' and form.validate(): user.url = unicode(request.form['url']) @@ -217,45 +218,42 @@ def edit_profile(request, url_user=None): def edit_account(request): user = request.user form = forms.EditAccountForm(request.form, - wants_comment_notification=user.get('wants_comment_notification')) + wants_comment_notification=user.wants_comment_notification, + license_preference=user.license_preference) if request.method == 'POST': form_validated = form.validate() - #if the user has not filled in the new or old password fields - if not form.new_password.data and not form.old_password.data: - if form.wants_comment_notification.validate(form): - user.wants_comment_notification = \ - form.wants_comment_notification.data - user.save() - messages.add_message(request, - messages.SUCCESS, - _("Account settings saved")) - return redirect(request, - 'mediagoblin.user_pages.user_home', - user=user.username) - - #so the user has filled in one or both of the password fields - else: - if form_validated: - password_matches = auth_lib.bcrypt_check_password( - form.old_password.data, - user.pw_hash) - if password_matches: - #the entire form validates and the password matches - user.pw_hash = auth_lib.bcrypt_gen_password_hash( - form.new_password.data) - user.wants_comment_notification = \ - form.wants_comment_notification.data - user.save() - messages.add_message(request, - messages.SUCCESS, - _("Account settings saved")) - return redirect(request, - 'mediagoblin.user_pages.user_home', - user=user.username) - else: - form.old_password.errors.append(_('Wrong password')) + if form_validated and \ + form.wants_comment_notification.validate(form): + user.wants_comment_notification = \ + form.wants_comment_notification.data + + if form_validated and \ + form.new_password.data or form.old_password.data: + password_matches = auth_lib.bcrypt_check_password( + form.old_password.data, + user.pw_hash) + if password_matches: + #the entire form validates and the password matches + user.pw_hash = auth_lib.bcrypt_gen_password_hash( + form.new_password.data) + else: + form.old_password.errors.append(_('Wrong password')) + + if form_validated and \ + form.license_preference.validate(form): + user.license_preference = \ + form.license_preference.data + + if form_validated and not form.errors: + user.save() + messages.add_message(request, + messages.SUCCESS, + _("Account settings saved")) + return redirect(request, + 'mediagoblin.user_pages.user_home', + user=user.username) return render_to_response( request, @@ -265,6 +263,37 @@ def edit_account(request): @require_active_login +def delete_account(request): + """Delete a user completely""" + user = request.user + if request.method == 'POST': + if request.form.get(u'confirmed'): + # Form submitted and confirmed. Actually delete the user account + # Log out user and delete cookies etc. + # TODO: Should we be using MG.auth.views.py:logout for this? + request.session.delete() + + # Delete user account and all related media files etc.... + request.user.delete() + + # We should send a message that the user has been deleted + # successfully. But we just deleted the session, so we + # can't... + return redirect(request, 'index') + + else: # Did not check the confirmation box... + messages.add_message( + request, messages.WARNING, + _('You need to confirm the deletion of your account.')) + + # No POST submission or not confirmed, just show page + return render_to_response( + request, + 'mediagoblin/edit/delete_account.html', + {'user': user}) + + +@require_active_login @user_may_alter_collection @get_user_collection def edit_collection(request, collection): diff --git a/mediagoblin/init/__init__.py b/mediagoblin/init/__init__.py index ab6e6399..7c832442 100644 --- a/mediagoblin/init/__init__.py +++ b/mediagoblin/init/__init__.py @@ -26,7 +26,7 @@ from mediagoblin import mg_globals from mediagoblin.mg_globals import setup_globals from mediagoblin.db.open import setup_connection_and_db_from_config, \ check_db_migrations_current, load_models -from mediagoblin.workbench import WorkbenchManager +from mediagoblin.tools.workbench import WorkbenchManager from mediagoblin.storage import storage_system_from_config diff --git a/mediagoblin/media_types/ascii/processing.py b/mediagoblin/media_types/ascii/processing.py index 04d1166c..254717eb 100644 --- a/mediagoblin/media_types/ascii/processing.py +++ b/mediagoblin/media_types/ascii/processing.py @@ -19,6 +19,7 @@ import Image import logging from mediagoblin import mg_globals as mgg +from mediagoblin.decorators import get_workbench from mediagoblin.processing import create_pub_filepath from mediagoblin.media_types.ascii import asciitoimage @@ -38,12 +39,14 @@ def sniff_handler(media_file, **kw): return False -def process_ascii(entry): - ''' - Code to process a txt file - ''' +@get_workbench +def process_ascii(entry, workbench=None): + """Code to process a txt file. Will be run by celery. + + A Workbench() represents a local tempory dir. It is automatically + cleaned up when this function exits. + """ ascii_config = mgg.global_config['media_type:mediagoblin.media_types.ascii'] - workbench = mgg.workbench_manager.create_workbench() # Conversions subdirectory to avoid collisions conversions_subdir = os.path.join( workbench.dir, 'conversions') diff --git a/mediagoblin/media_types/audio/processing.py b/mediagoblin/media_types/audio/processing.py index aee843d5..e12cefe6 100644 --- a/mediagoblin/media_types/audio/processing.py +++ b/mediagoblin/media_types/audio/processing.py @@ -15,10 +15,11 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import logging -import tempfile +from tempfile import NamedTemporaryFile import os from mediagoblin import mg_globals as mgg +from mediagoblin.decorators import get_workbench from mediagoblin.processing import (create_pub_filepath, BadMediaFail, FilenameBuilder, ProgressCallback) @@ -42,10 +43,14 @@ def sniff_handler(media_file, **kw): return False -def process_audio(entry): - audio_config = mgg.global_config['media_type:mediagoblin.media_types.audio'] +@get_workbench +def process_audio(entry, workbench=None): + """Code to process uploaded audio. Will be run by celery. - workbench = mgg.workbench_manager.create_workbench() + A Workbench() represents a local tempory dir. It is automatically + cleaned up when this function exits. + """ + audio_config = mgg.global_config['media_type:mediagoblin.media_types.audio'] queued_filepath = entry.queued_media_file queued_filename = workbench.localized_file( @@ -73,7 +78,7 @@ def process_audio(entry): transcoder = AudioTranscoder() - with tempfile.NamedTemporaryFile() as webm_audio_tmp: + with NamedTemporaryFile(dir=workbench.dir) as webm_audio_tmp: progress_callback = ProgressCallback(entry) transcoder.transcode( @@ -99,7 +104,7 @@ def process_audio(entry): original=os.path.splitext( queued_filepath[-1])[0])) - with tempfile.NamedTemporaryFile(suffix='.ogg') as wav_tmp: + with NamedTemporaryFile(dir=workbench.dir, suffix='.ogg') as wav_tmp: _log.info('Creating OGG source for spectrogram') transcoder.transcode( queued_filename, @@ -109,7 +114,7 @@ def process_audio(entry): thumbnailer = AudioThumbnailer() - with tempfile.NamedTemporaryFile(suffix='.jpg') as spectrogram_tmp: + with NamedTemporaryFile(dir=workbench.dir, suffix='.jpg') as spectrogram_tmp: thumbnailer.spectrogram( wav_tmp.name, spectrogram_tmp.name, @@ -122,7 +127,7 @@ def process_audio(entry): entry.media_files['spectrogram'] = spectrogram_filepath - with tempfile.NamedTemporaryFile(suffix='.jpg') as thumb_tmp: + with NamedTemporaryFile(dir=workbench.dir, suffix='.jpg') as thumb_tmp: thumbnailer.thumbnail_spectrogram( spectrogram_tmp.name, thumb_tmp.name, @@ -143,6 +148,3 @@ def process_audio(entry): entry.media_files['thumb'] = ['fake', 'thumb', 'path.jpg'] mgg.queue_store.delete_file(queued_filepath) - - # clean up workbench - workbench.destroy_self() diff --git a/mediagoblin/media_types/image/processing.py b/mediagoblin/media_types/image/processing.py index bf464069..99be848f 100644 --- a/mediagoblin/media_types/image/processing.py +++ b/mediagoblin/media_types/image/processing.py @@ -19,6 +19,7 @@ import os import logging from mediagoblin import mg_globals as mgg +from mediagoblin.decorators import get_workbench from mediagoblin.processing import BadMediaFail, \ create_pub_filepath, FilenameBuilder from mediagoblin.tools.exif import exif_fix_image_orientation, \ @@ -27,6 +28,12 @@ from mediagoblin.tools.exif import exif_fix_image_orientation, \ _log = logging.getLogger(__name__) +PIL_FILTERS = { + 'NEAREST': Image.NEAREST, + 'BILINEAR': Image.BILINEAR, + 'BICUBIC': Image.BICUBIC, + 'ANTIALIAS': Image.ANTIALIAS} + def resize_image(entry, filename, new_path, exif_tags, workdir, new_size, size_limits=(0, 0)): @@ -46,7 +53,19 @@ def resize_image(entry, filename, new_path, exif_tags, workdir, new_size, except IOError: raise BadMediaFail() resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation - resized.thumbnail(new_size, Image.ANTIALIAS) + + filter_config = \ + mgg.global_config['media_type:mediagoblin.media_types.image']\ + ['resize_filter'] + + try: + resize_filter = PIL_FILTERS[filter_config.upper()] + except KeyError: + raise Exception('Filter "{0}" not found, choose one of {1}'.format( + unicode(filter_config), + u', '.join(PIL_FILTERS.keys()))) + + resized.thumbnail(new_size, resize_filter) # Copy the new file to the conversion subdir, then remotely. tmp_resized_filename = os.path.join(workdir, new_path[-1]) @@ -76,11 +95,13 @@ def sniff_handler(media_file, **kw): return False -def process_image(entry): - """ - Code to process an image +@get_workbench +def process_image(entry, workbench=None): + """Code to process an image. Will be run by celery. + + A Workbench() represents a local tempory dir. It is automatically + cleaned up when this function exits. """ - workbench = mgg.workbench_manager.create_workbench() # Conversions subdirectory to avoid collisions conversions_subdir = os.path.join( workbench.dir, 'conversions') @@ -147,8 +168,6 @@ def process_image(entry): gps_data['gps_' + key] = gps_data.pop(key) entry.media_data_init(**gps_data) - # clean up workbench - workbench.destroy_self() if __name__ == '__main__': import sys diff --git a/mediagoblin/media_types/stl/processing.py b/mediagoblin/media_types/stl/processing.py index cd949e2a..3089f295 100644 --- a/mediagoblin/media_types/stl/processing.py +++ b/mediagoblin/media_types/stl/processing.py @@ -21,6 +21,7 @@ import subprocess import pkg_resources from mediagoblin import mg_globals as mgg +from mediagoblin.decorators import get_workbench from mediagoblin.processing import create_pub_filepath, \ FilenameBuilder @@ -75,11 +76,13 @@ def blender_render(config): env=env) -def process_stl(entry): - """ - Code to process an stl or obj model. +@get_workbench +def process_stl(entry, workbench=None): + """Code to process an stl or obj model. Will be run by celery. + + A Workbench() represents a local tempory dir. It is automatically + cleaned up when this function exits. """ - workbench = mgg.workbench_manager.create_workbench() queued_filepath = entry.queued_media_file queued_filename = workbench.localized_file( mgg.queue_store, queued_filepath, 'source') @@ -164,7 +167,7 @@ def process_stl(entry): # Remove queued media file from storage and database mgg.queue_store.delete_file(queued_filepath) entry.queued_media_file = [] - + # Insert media file information into database media_files_dict = entry.setdefault('media_files', {}) media_files_dict[u'original'] = model_filepath @@ -185,6 +188,3 @@ def process_stl(entry): "file_type" : ext, } entry.media_data_init(**dimensions) - - # clean up workbench - workbench.destroy_self() diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py index aa6a25df..4c9f0131 100644 --- a/mediagoblin/media_types/video/processing.py +++ b/mediagoblin/media_types/video/processing.py @@ -14,10 +14,11 @@ # 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 tempfile +from tempfile import NamedTemporaryFile import logging from mediagoblin import mg_globals as mgg +from mediagoblin.decorators import get_workbench from mediagoblin.processing import \ create_pub_filepath, FilenameBuilder, BaseProcessingFail, ProgressCallback from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ @@ -51,16 +52,17 @@ def sniff_handler(media_file, **kw): return False - -def process_video(entry): +@get_workbench +def process_video(entry, workbench=None): """ Process a video entry, transcode the queued media files (originals) and create a thumbnail for the entry. + + A Workbench() represents a local tempory dir. It is automatically + cleaned up when this function exits. """ video_config = mgg.global_config['media_type:mediagoblin.media_types.video'] - workbench = mgg.workbench_manager.create_workbench() - queued_filepath = entry.queued_media_file queued_filename = workbench.localized_file( mgg.queue_store, queued_filepath, @@ -73,9 +75,8 @@ def process_video(entry): thumbnail_filepath = create_pub_filepath( entry, name_builder.fill('{basename}.thumbnail.jpg')) - # Create a temporary file for the video destination - tmp_dst = tempfile.NamedTemporaryFile() - + # Create a temporary file for the video destination (cleaned up with workbench) + tmp_dst = NamedTemporaryFile(dir=workbench.dir, delete=False) with tmp_dst: # Transcode queued file to a VP8/vorbis file that fits in a 640x640 square progress_callback = ProgressCallback(entry) @@ -86,21 +87,20 @@ def process_video(entry): vorbis_quality=video_config['vorbis_quality'], progress_callback=progress_callback) - # Push transcoded video to public storage - _log.debug('Saving medium...') - mgg.public_store.get_file(medium_filepath, 'wb').write( - tmp_dst.read()) - _log.debug('Saved medium') + # Push transcoded video to public storage + _log.debug('Saving medium...') + mgg.public_store.copy_local_to_storage(tmp_dst.name, medium_filepath) + _log.debug('Saved medium') - entry.media_files['webm_640'] = medium_filepath + entry.media_files['webm_640'] = medium_filepath - # Save the width and height of the transcoded video - entry.media_data_init( - width=transcoder.dst_data.videowidth, - height=transcoder.dst_data.videoheight) + # Save the width and height of the transcoded video + entry.media_data_init( + width=transcoder.dst_data.videowidth, + height=transcoder.dst_data.videoheight) - # Create a temporary file for the video thumbnail - tmp_thumb = tempfile.NamedTemporaryFile(suffix='.jpg') + # Temporary file for the video thumbnail (cleaned up with workbench) + tmp_thumb = NamedTemporaryFile(dir=workbench.dir, suffix='.jpg', delete=False) with tmp_thumb: # Create a thumbnail.jpg that fits in a 180x180 square @@ -109,29 +109,16 @@ def process_video(entry): tmp_thumb.name, 180) - # Push the thumbnail to public storage - _log.debug('Saving thumbnail...') - mgg.public_store.get_file(thumbnail_filepath, 'wb').write( - tmp_thumb.read()) - _log.debug('Saved thumbnail') - - entry.media_files['thumb'] = thumbnail_filepath + # Push the thumbnail to public storage + _log.debug('Saving thumbnail...') + mgg.public_store.copy_local_to_storage(tmp_thumb.name, thumbnail_filepath) + entry.media_files['thumb'] = thumbnail_filepath if video_config['keep_original']: # Push original file to public storage - queued_file = file(queued_filename, 'rb') - - with queued_file: - original_filepath = create_pub_filepath( - entry, - queued_filepath[-1]) - - with mgg.public_store.get_file(original_filepath, 'wb') as \ - original_file: - _log.debug('Saving original...') - original_file.write(queued_file.read()) - _log.debug('Saved original') - - entry.media_files['original'] = original_filepath + _log.debug('Saving original...') + original_filepath = create_pub_filepath(entry, queued_filepath[-1]) + mgg.public_store.copy_local_to_storage(queued_filename, original_filepath) + entry.media_files['original'] = original_filepath mgg.queue_store.delete_file(queued_filepath) diff --git a/mediagoblin/plugins/api/views.py b/mediagoblin/plugins/api/views.py index 6aa4ef9f..2055a663 100644 --- a/mediagoblin/plugins/api/views.py +++ b/mediagoblin/plugins/api/views.py @@ -86,7 +86,10 @@ def post_entry(request): # # (... don't change entry after this point to avoid race # conditions with changes to the document via processing code) - run_process_media(entry) + feed_url = request.urlgen( + 'mediagoblin.user_pages.atom_feed', + qualified=True, user=request.user.username) + run_process_media(entry, feed_url) return json_response(get_entry_serializable(entry, request.urlgen)) diff --git a/mediagoblin/processing/task.py b/mediagoblin/processing/task.py index b29de9bd..e9bbe084 100644 --- a/mediagoblin/processing/task.py +++ b/mediagoblin/processing/task.py @@ -15,8 +15,10 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import logging +import urllib +import urllib2 -from celery.task import Task +from celery import registry, task from mediagoblin import mg_globals as mgg from mediagoblin.db.models import MediaEntry @@ -28,18 +30,51 @@ logging.basicConfig() _log.setLevel(logging.DEBUG) +@task.task(default_retry_delay=2 * 60) +def handle_push_urls(feed_url): + """Subtask, notifying the PuSH servers of new content + + Retry 3 times every 2 minutes if run in separate process before failing.""" + if not mgg.app_config["push_urls"]: + return # Nothing to do + _log.debug('Notifying Push servers for feed {0}'.format(feed_url)) + hubparameters = { + 'hub.mode': 'publish', + 'hub.url': feed_url} + hubdata = urllib.urlencode(hubparameters) + hubheaders = { + "Content-type": "application/x-www-form-urlencoded", + "Connection": "close"} + for huburl in mgg.app_config["push_urls"]: + hubrequest = urllib2.Request(huburl, hubdata, hubheaders) + try: + hubresponse = urllib2.urlopen(hubrequest) + except (urllib2.HTTPError, urllib2.URLError) as exc: + # We retry by default 3 times before failing + _log.info("PuSH url %r gave error %r", huburl, exc) + try: + return handle_push_urls.retry(exc=exc, throw=False) + except Exception as e: + # All retries failed, Failure is no tragedy here, probably. + _log.warn('Failed to notify PuSH server for feed {0}. ' + 'Giving up.'.format(feed_url)) + return False + ################################ # Media processing initial steps ################################ -class ProcessMedia(Task): +class ProcessMedia(task.Task): """ Pass this entry off for processing. """ - def run(self, media_id): + def run(self, media_id, feed_url): """ Pass the media entry off to the appropriate processing function (for now just process_image...) + + :param feed_url: The feed URL that the PuSH server needs to be + updated for. """ entry = MediaEntry.query.get(media_id) @@ -58,6 +93,10 @@ class ProcessMedia(Task): entry.state = u'processed' entry.save() + # Notify the PuSH servers as async task + if mgg.app_config["push_urls"] and feed_url: + handle_push_urls.subtask().delay(feed_url) + json_processing_callback(entry) except BaseProcessingFail as exc: mark_entry_failed(entry.id, exc) @@ -97,3 +136,7 @@ class ProcessMedia(Task): entry = mgg.database.MediaEntry.query.filter_by(id=entry_id).first() json_processing_callback(entry) + +# Register the task +process_media = registry.tasks[ProcessMedia.name] + diff --git a/mediagoblin/static/css/base.css b/mediagoblin/static/css/base.css index 2085cfc9..04b4ee28 100644 --- a/mediagoblin/static/css/base.css +++ b/mediagoblin/static/css/base.css @@ -113,10 +113,12 @@ input, textarea { header { width: 100%; + max-width: 940px; + margin-left: auto; + margin-right: auto; padding: 0; margin-bottom: 42px; - background-color: #303030; - border-bottom: 1px solid #252525; + border-bottom: 1px solid #333; } .header_right { @@ -125,19 +127,18 @@ header { float: right; } -.header_right ul { - display: none; - position: absolute; - top: 42px; - right: 0px; - background: #252525; - padding: 20px; +.header_dropdown { + margin-bottom: 20px; } -.header_right li { +.header_dropdown li { list-style: none; } +.dropdown_title { + font-size: 20px; +} + a.logo { color: #fff; font-weight: bold; @@ -145,7 +146,7 @@ a.logo { .logo img { vertical-align: middle; - margin: 6px 8px; + margin: 6px 8px 6px 0; } .mediagoblin_content { @@ -331,6 +332,12 @@ textarea#description, textarea#bio { height: 100px; } +.delete { + margin-top: 36px; + display: block; + text-align: center; +} + /* comments */ .comment_wrapper { diff --git a/mediagoblin/static/js/header_dropdown.js b/mediagoblin/static/js/header_dropdown.js new file mode 100644 index 00000000..1b2fb00f --- /dev/null +++ b/mediagoblin/static/js/header_dropdown.js @@ -0,0 +1,27 @@ +/** + * 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/>. + */ + +$(document).ready(function(){ + $(".header_dropdown").hide(); + $(".header_dropdown_up").hide(); + $(".header_dropdown_down,.header_dropdown_up").click(function() { + $(".header_dropdown_down").toggle(); + $(".header_dropdown_up").toggle(); + $(".header_dropdown").slideToggle(); + }); +}); diff --git a/mediagoblin/submit/lib.py b/mediagoblin/submit/lib.py index db5dfe53..679fc543 100644 --- a/mediagoblin/submit/lib.py +++ b/mediagoblin/submit/lib.py @@ -14,16 +14,12 @@ # 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 urllib -import urllib2 import logging import uuid -from celery import registry from werkzeug.utils import secure_filename -from mediagoblin import mg_globals from mediagoblin.processing import mark_entry_failed -from mediagoblin.processing.task import ProcessMedia +from mediagoblin.processing.task import process_media _log = logging.getLogger(__name__) @@ -58,11 +54,17 @@ def prepare_queue_task(app, entry, filename): return queue_file -def run_process_media(entry): - process_media = registry.tasks[ProcessMedia.name] +def run_process_media(entry, feed_url=None): + """Process the media asynchronously + + :param entry: MediaEntry() instance to be processed. + :param feed_url: A string indicating the feed_url that the PuSH servers + should be notified of. This will be sth like: `request.urlgen( + 'mediagoblin.user_pages.atom_feed',qualified=True, + user=request.user.username)`""" try: process_media.apply_async( - [unicode(entry.id)], {}, + [entry.id, feed_url], {}, task_id=entry.queued_task_id) except BaseException as exc: # The purpose of this section is because when running in "lazy" @@ -76,30 +78,3 @@ def run_process_media(entry): mark_entry_failed(entry.id, exc) # re-raise the exception raise - - -def handle_push_urls(request): - if mg_globals.app_config["push_urls"]: - feed_url = request.urlgen( - 'mediagoblin.user_pages.atom_feed', - qualified=True, - user=request.user.username) - hubparameters = { - 'hub.mode': 'publish', - 'hub.url': feed_url} - hubdata = urllib.urlencode(hubparameters) - hubheaders = { - "Content-type": "application/x-www-form-urlencoded", - "Connection": "close"} - for huburl in mg_globals.app_config["push_urls"]: - hubrequest = urllib2.Request(huburl, hubdata, hubheaders) - try: - hubresponse = urllib2.urlopen(hubrequest) - except urllib2.HTTPError as exc: - # This is not a big issue, the item will be fetched - # by the PuSH server next time we hit it - _log.warning( - "push url %r gave error %r", huburl, exc.code) - except urllib2.URLError as exc: - _log.warning( - "push url %r is unreachable %r", huburl, exc.reason) diff --git a/mediagoblin/submit/views.py b/mediagoblin/submit/views.py index 2d609b31..def7e839 100644 --- a/mediagoblin/submit/views.py +++ b/mediagoblin/submit/views.py @@ -32,8 +32,7 @@ from mediagoblin.submit import forms as submit_forms from mediagoblin.messages import add_message, SUCCESS from mediagoblin.media_types import sniff_media, \ InvalidFileType, FileTypeNotSupported -from mediagoblin.submit.lib import handle_push_urls, run_process_media, \ - prepare_queue_task +from mediagoblin.submit.lib import run_process_media, prepare_queue_task @require_active_login @@ -41,7 +40,8 @@ def submit_start(request): """ First view for submitting a file. """ - submit_form = submit_forms.SubmitStartForm(request.form) + submit_form = submit_forms.SubmitStartForm(request.form, + license=request.user.license_preference) if request.method == 'POST' and submit_form.validate(): if not ('file' in request.files @@ -90,10 +90,10 @@ def submit_start(request): # # (... don't change entry after this point to avoid race # conditions with changes to the document via processing code) - run_process_media(entry) - - handle_push_urls(request) - + feed_url = request.urlgen( + 'mediagoblin.user_pages.atom_feed', + qualified=True, user=request.user.username) + run_process_media(entry, feed_url) add_message(request, SUCCESS, _('Woohoo! Submitted!')) return redirect(request, "mediagoblin.user_pages.user_home", diff --git a/mediagoblin/templates/mediagoblin/base.html b/mediagoblin/templates/mediagoblin/base.html index 0a9a56d3..98b32f4a 100644 --- a/mediagoblin/templates/mediagoblin/base.html +++ b/mediagoblin/templates/mediagoblin/base.html @@ -28,6 +28,9 @@ <link rel="shortcut icon" href="{{ request.staticdirect('/images/goblin.ico') }}" /> <script src="{{ request.staticdirect('/js/extlib/jquery.js') }}"></script> + <script type="text/javascript" + src="{{ request.staticdirect('/js/header_dropdown.js') }}"></script> + <!--[if lt IE 9]> <script src="{{ request.staticdirect('/js/extlib/html5shiv.js') }}"></script> <![endif]--> @@ -45,21 +48,16 @@ {% block mediagoblin_header_title %}{% endblock %} <div class="header_right"> {% if request.user %} - {% trans - user_url=request.urlgen('mediagoblin.user_pages.user_home', - user= request.user.username), - user_name=request.user.username -%} - <a href="{{ user_url }}">{{ user_name }}</a>'s account - {%- endtrans %} - (<a href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}log out{% endtrans %}</a>) {% if request.user and request.user.status == 'active' %} - <a class="button_action" href="{{ request.urlgen('mediagoblin.submit.start') }}">{% trans %}Add media{% endtrans %}</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" %} {# the following link should only appear when verification is needed #} <a href="{{ request.urlgen('mediagoblin.user_pages.user_home', user=request.user.username) }}" class="button_action_highlight"> {% trans %}Verify your email!{% endtrans %}</a> + or <a href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}log out{% endtrans %}</a> {% endif %} {% else %} <a href="{{ request.urlgen('mediagoblin.auth.login') }}?next={{ @@ -68,6 +66,44 @@ {% endif %} </div> <div class="clear"></div> + {% if request.user and request.user.status == 'active' %} + <div class="header_dropdown"> + <p> + <span class="dropdown_title"> + {% trans user_url=request.urlgen('mediagoblin.user_pages.user_home', + user=request.user.username), + user_name=request.user.username -%} + <a href="{{ user_url }}">{{ user_name }}</a>'s account + {%- endtrans %} + </span> + (<a href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}log out{% endtrans %}</a>) + </p> + <ul> + <li><a class="button_action" href="{{ request.urlgen('mediagoblin.submit.start') }}"> + {%- trans %}Add media{% endtrans -%} + </a></li> + <li><a class="button_action" href="{{ request.urlgen('mediagoblin.submit.collection') }}"> + {%- trans %}Create new collection{% endtrans -%} + </a></li> + <li><a href="{{ request.urlgen('mediagoblin.edit.account') }}"> + {%- trans %}Change account settings{% endtrans -%} + </a></li> + <li><a href="{{ request.urlgen('mediagoblin.user_pages.processing_panel', + user=request.user.username) }}"> + {%- trans %}Media processing panel{% endtrans -%} + </a></li> + {% if request.user.is_admin %} + <li>Admin: + <ul> + <li><a href="{{ request.urlgen('mediagoblin.admin.panel') }}"> + {%- trans %}Media processing panel{% endtrans -%} + </a></li> + </ul> + </li> + {% endif %} + </ul> + </div> + {% endif %} </header> {% endblock %} <div class="container"> diff --git a/mediagoblin/templates/mediagoblin/edit/delete_account.html b/mediagoblin/templates/mediagoblin/edit/delete_account.html new file mode 100644 index 00000000..84d0b580 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/edit/delete_account.html @@ -0,0 +1,48 @@ +{# +# 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/>. +#} +{% extends "mediagoblin/base.html" %} + +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} + + <form action="{{ request.urlgen('mediagoblin.edit.delete_account') }}" + method="POST" enctype="multipart/form-data"> + <div class="form_box"> + <h1> + {%- trans user_name=user.username -%} + Really delete user '{{ user_name }}' and all related media/comments? + {%- endtrans -%} + </h1> + <p class="delete_checkbox_box"> + <input type="checkbox" name="confirmed"/> + <label for="confirmed"> + {%- trans %}Yes, really delete my account{% endtrans -%} + </label> + </p> + + <div class="form_submit_buttons"> + <a class="button_action" href="{{ request.urlgen( + 'mediagoblin.user_pages.user_home', + user=user.username) }}">{% trans %}Cancel{% endtrans %}</a> + {{ csrf_token }} + <input type="submit" value="{% trans %}Delete permanently{% endtrans %}" class="button_form" /> + </div> + </div> + </form> +{% endblock %} diff --git a/mediagoblin/templates/mediagoblin/edit/edit.html b/mediagoblin/templates/mediagoblin/edit/edit.html index 1f5b91f7..9a040095 100644 --- a/mediagoblin/templates/mediagoblin/edit/edit.html +++ b/mediagoblin/templates/mediagoblin/edit/edit.html @@ -29,7 +29,7 @@ <form action="{{ request.urlgen('mediagoblin.edit.edit_media', user= media.get_uploader.username, - media= media.id) }}" + media_id=media.id) }}" method="POST" enctype="multipart/form-data"> <div class="form_box_xl edit_box"> <h1>{% trans media_title=media.title %}Editing {{ media_title }}{% endtrans %}</h1> diff --git a/mediagoblin/templates/mediagoblin/edit/edit_account.html b/mediagoblin/templates/mediagoblin/edit/edit_account.html index 38d99893..4b980301 100644 --- a/mediagoblin/templates/mediagoblin/edit/edit_account.html +++ b/mediagoblin/templates/mediagoblin/edit/edit_account.html @@ -47,10 +47,19 @@ <p>{{ form.wants_comment_notification }} {{ form.wants_comment_notification.label }}</p> </div> + <div class="form_field_input"> + <p>{{ form.license_preference }} + {{ form.license_preference.label }}</p> + </div> <div class="form_submit_buttons"> <input type="submit" value="{% trans %}Save changes{% endtrans %}" class="button_form" /> {{ csrf_token }} </div> </div> </form> + <div class="delete"> + <a href="{{ request.urlgen('mediagoblin.edit.delete_account') }}"> + {%- trans %}Delete my account{% endtrans -%} + </a> + </div> {% endblock %} diff --git a/mediagoblin/templates/mediagoblin/root.html b/mediagoblin/templates/mediagoblin/root.html index 047dd2bb..5c6eb52f 100644 --- a/mediagoblin/templates/mediagoblin/root.html +++ b/mediagoblin/templates/mediagoblin/root.html @@ -21,33 +21,6 @@ {% block mediagoblin_content %} {% if request.user %} - {% if request.user.status == 'active' %} - <h1>{% trans %}Actions{% endtrans %}</h1> - <ul> - <li><a href="{{ request.urlgen('mediagoblin.submit.start') }}"> - {%- trans %}Add media{% endtrans -%} - </a></li> - <li><a href="{{ request.urlgen('mediagoblin.submit.collection') }}"> - {%- trans %}Create new collection{% endtrans -%} - </a></li> - <li><a href="{{ request.urlgen('mediagoblin.edit.account') }}"> - {%- trans %}Change account settings{% endtrans -%} - </a></li> - <li><a href="{{ request.urlgen('mediagoblin.user_pages.processing_panel', - user=request.user.username) }}"> - {%- trans %}Media processing panel{% endtrans -%} - </a></li> - {% if request.user.is_admin %} - <li>Admin: - <ul> - <li><a href="{{ request.urlgen('mediagoblin.admin.panel') }}"> - {%- trans %}Media processing panel{% endtrans -%} - </a></li> - </ul> - </li> - {% endif %} - </ul> - {% endif %} <h1>{% trans %}Explore{% endtrans %}</h1> {% else %} <h1>{% trans %}Hi there, welcome to this MediaGoblin site!{% endtrans %}</h1> diff --git a/mediagoblin/templates/mediagoblin/user_pages/collection_list.html b/mediagoblin/templates/mediagoblin/user_pages/collection_list.html new file mode 100644 index 00000000..abf22623 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/user_pages/collection_list.html @@ -0,0 +1,56 @@ +{# +# 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/>. +#} +{% extends "mediagoblin/base.html" %} + +{% block title %} + {%- trans username=user.username -%} + {{ username }}'s collections + {%- endtrans %} — {{ super() }} +{% endblock %} + +{% block mediagoblin_content -%} + <h1> + {%- trans username=user.username, + user_url=request.urlgen( + 'mediagoblin.user_pages.user_home', + user=user.username) -%} + <a href="{{ user_url }}">{{ username }}</a>'s collections + {%- endtrans %} + </h1> + + {% if request.user %} + {% if request.user.status == 'active' %} + <p> + <a href="{{ request.urlgen('mediagoblin.submit.collection', + user=user.username) }}"> + {%- trans %}Create new collection{% endtrans -%} + </p> + {% endif %} + {% endif %} + + <ul> + {% for coll in collections %} + {% set coll_url = request.urlgen( + 'mediagoblin.user_pages.user_collection', + user=user.username, + collection=coll.slug) %} + <li><a href="{{ coll_url }}">{{ coll.title }}</li> + {% endfor %} + </ul> + +{% endblock %} diff --git a/mediagoblin/templates/mediagoblin/user_pages/media.html b/mediagoblin/templates/mediagoblin/user_pages/media.html index 11f2a2a1..7e184257 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/media.html +++ b/mediagoblin/templates/mediagoblin/user_pages/media.html @@ -83,11 +83,11 @@ request.user.is_admin) %} {% set edit_url = request.urlgen('mediagoblin.edit.edit_media', user= media.get_uploader.username, - media= media.id) %} + media_id=media.id) %} <a class="button_action" href="{{ edit_url }}">{% trans %}Edit{% endtrans %}</a> {% set delete_url = request.urlgen('mediagoblin.user_pages.media_confirm_delete', user= media.get_uploader.username, - media= media.id) %} + media_id=media.id) %} <a class="button_action" href="{{ delete_url }}">{% trans %}Delete{% endtrans %}</a> {% endif %} {% autoescape False %} @@ -104,10 +104,7 @@ {% if request.user %} <form action="{{ request.urlgen('mediagoblin.user_pages.media_post_comment', user= media.get_uploader.username, - media=media.id) }}" method="POST" id="form_comment"> - <p> - {% trans %}You can use <a href="http://daringfireball.net/projects/markdown/basics">Markdown</a> for formatting.{% endtrans %} - </p> + media_id=media.id) }}" method="POST" id="form_comment"> {{ wtforms_util.render_divs(comment_form) }} <div class="form_submit_buttons"> <input type="submit" value="{% trans %}Add this comment{% endtrans %}" class="button_action" /> @@ -115,35 +112,38 @@ </div> </form> {% endif %} + <ul style="list-style:none"> {% for comment in comments %} {% set comment_author = comment.get_author %} - {% if pagination.active_id == comment.id %} - <div class="comment_wrapper comment_active" id="comment-{{ comment.id }}"> - <a name="comment" id="comment"></a> - {% else %} - <div class="comment_wrapper" id="comment-{{ comment.id }}"> - {% endif %} - <div class="comment_author"> + <li id="comment-{{ comment.id }}" + {%- if pagination.active_id == comment.id %} + class="comment_wrapper comment_active"> + <a name="comment" id="comment"></a> + {%- else %} + class="comment_wrapper"> + {%- endif %} + <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) }}"> - {{ comment_author.username -}} + {{- comment_author.username -}} </a> {% trans %}at{% endtrans %} <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"> - {{ comment.created.strftime("%I:%M%p %Y-%m-%d") }} + {{- comment.created.strftime("%I:%M%p %Y-%m-%d") -}} </a>: </div> <div class="comment_content"> - {% autoescape False %} + {% autoescape False -%} {{ comment.content_html }} - {% endautoescape %} + {%- endautoescape %} </div> - </div> + </li> {% endfor %} + </ul> {{ render_pagination(request, pagination, media.url_for_self(request.urlgen)) }} {% endif %} diff --git a/mediagoblin/templates/mediagoblin/user_pages/media_confirm_delete.html b/mediagoblin/templates/mediagoblin/user_pages/media_confirm_delete.html index 833f500d..d2a5655e 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/media_confirm_delete.html +++ b/mediagoblin/templates/mediagoblin/user_pages/media_confirm_delete.html @@ -23,7 +23,7 @@ <form action="{{ request.urlgen('mediagoblin.user_pages.media_confirm_delete', user=media.get_uploader.username, - media=media.id) }}" + media_id=media.id) }}" method="POST" enctype="multipart/form-data"> <div class="form_box"> <h1> diff --git a/mediagoblin/templates/mediagoblin/user_pages/user.html b/mediagoblin/templates/mediagoblin/user_pages/user.html index 76bce1e2..71acd66c 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/user.html +++ b/mediagoblin/templates/mediagoblin/user_pages/user.html @@ -118,6 +118,12 @@ </a> {% endif %} {% endif %} + <p> + <a href="{{ request.urlgen('mediagoblin.user_pages.collection_list', + user=user.username) }}"> + {%- trans %}Browse collections{% endtrans -%} + </a> + </p> </div> {% if media_entries.count() %} diff --git a/mediagoblin/tests/test_api.py b/mediagoblin/tests/test_api.py index 4b784da3..82b1c1b4 100644 --- a/mediagoblin/tests/test_api.py +++ b/mediagoblin/tests/test_api.py @@ -22,7 +22,7 @@ from pkg_resources import resource_filename from mediagoblin import mg_globals from mediagoblin.tools import template, pluginapi -from mediagoblin.tests.tools import get_test_app, fixture_add_user +from mediagoblin.tests.tools import get_app, fixture_add_user _log = logging.getLogger(__name__) @@ -44,7 +44,7 @@ BIG_BLUE = resource('bigblue.png') class TestAPI(object): def setUp(self): - self.app = get_test_app(dump_old_app=False) + self.app = get_app(dump_old_app=False) self.db = mg_globals.database self.user_password = u'4cc355_70k3N' diff --git a/mediagoblin/tests/test_auth.py b/mediagoblin/tests/test_auth.py index a40c9cbc..f4409121 100644 --- a/mediagoblin/tests/test_auth.py +++ b/mediagoblin/tests/test_auth.py @@ -22,7 +22,7 @@ from nose.tools import assert_equal from mediagoblin import mg_globals from mediagoblin.auth import lib as auth_lib from mediagoblin.db.models import User -from mediagoblin.tests.tools import get_test_app, fixture_add_user +from mediagoblin.tests.tools import setup_fresh_app, get_app, fixture_add_user from mediagoblin.tools import template, mail @@ -67,11 +67,11 @@ def test_bcrypt_gen_password_hash(): 'notthepassword', hashed_pw, '3><7R45417') -def test_register_views(): +@setup_fresh_app +def test_register_views(test_app): """ Massive test function that all our registration-related views all work. """ - test_app = get_test_app(dump_old_app=False) # Test doing a simple GET on the page # ----------------------------------- @@ -105,10 +105,8 @@ def test_register_views(): context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/register.html'] form = context['register_form'] - assert form.username.errors == [ - u'Field must be between 3 and 30 characters long.'] - assert form.password.errors == [ - u'Field must be between 6 and 30 characters long.'] + assert_equal (form.username.errors, [u'Field must be between 3 and 30 characters long.']) + assert_equal (form.password.errors, [u'Field must be between 5 and 1024 characters long.']) ## bad form template.clear_test_template_context() @@ -119,13 +117,11 @@ def test_register_views(): context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/register.html'] form = context['register_form'] - assert form.username.errors == [ - u'Invalid input.'] - assert form.email.errors == [ - u'Invalid email address.'] + assert_equal (form.username.errors, [u'This field does not take email addresses.']) + assert_equal (form.email.errors, [u'This field requires an email address.']) ## At this point there should be no users in the database ;) - assert not User.query.count() + assert_equal(User.query.count(), 0) # Successful register # ------------------- @@ -315,7 +311,7 @@ def test_authentication_views(): """ Test logging in and logging out """ - test_app = get_test_app(dump_old_app=False) + test_app = get_app(dump_old_app=False) # Make a new user test_user = fixture_add_user(active_user=False) @@ -370,7 +366,7 @@ def test_authentication_views(): response = test_app.post( '/auth/login/', { 'username': u'chris', - 'password': 'jam'}) + 'password': 'jam_and_ham'}) context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/login.html'] assert context['login_failed'] diff --git a/mediagoblin/tests/test_collections.py b/mediagoblin/tests/test_collections.py new file mode 100644 index 00000000..b19f6362 --- /dev/null +++ b/mediagoblin/tests/test_collections.py @@ -0,0 +1,37 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 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.tests.tools import fixture_add_collection, fixture_add_user, \ + get_app +from mediagoblin.db.models import Collection, User +from mediagoblin.db.base import Session +from nose.tools import assert_equal + + +def test_user_deletes_collection(): + # Setup db. + get_app(dump_old_app=False) + + user = fixture_add_user() + coll = fixture_add_collection(user=user) + # Reload into session: + user = User.query.get(user.id) + + cnt1 = Collection.query.count() + user.delete() + cnt2 = Collection.query.count() + + assert_equal(cnt1, cnt2 + 1) diff --git a/mediagoblin/tests/test_csrf_middleware.py b/mediagoblin/tests/test_csrf_middleware.py index 22a0eb04..e720264c 100644 --- a/mediagoblin/tests/test_csrf_middleware.py +++ b/mediagoblin/tests/test_csrf_middleware.py @@ -14,12 +14,12 @@ # 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.tests.tools import get_test_app +from mediagoblin.tests.tools import get_app from mediagoblin import mg_globals def test_csrf_cookie_set(): - test_app = get_test_app(dump_old_app=False) + test_app = get_app(dump_old_app=False) cookie_name = mg_globals.app_config['csrf_cookie_name'] # get login page @@ -37,7 +37,7 @@ def test_csrf_token_must_match(): # We need a fresh app for this test on webtest < 1.3.6. # We do not understand why, but it fixes the tests. # If we require webtest >= 1.3.6, we can switch to a non fresh app here. - test_app = get_test_app(dump_old_app=True) + test_app = get_app(dump_old_app=True) # construct a request with no cookie or form token assert test_app.post('/auth/login/', @@ -68,7 +68,7 @@ def test_csrf_token_must_match(): status_int == 200 def test_csrf_exempt(): - test_app = get_test_app(dump_old_app=False) + test_app = get_app(dump_old_app=False) # monkey with the views to decorate a known endpoint import mediagoblin.auth.views from mediagoblin.meddleware.csrf import csrf_exempt diff --git a/mediagoblin/tests/test_edit.py b/mediagoblin/tests/test_edit.py index cbdad649..7db6eaea 100644 --- a/mediagoblin/tests/test_edit.py +++ b/mediagoblin/tests/test_edit.py @@ -18,13 +18,13 @@ from nose.tools import assert_equal from mediagoblin import mg_globals from mediagoblin.db.models import User -from mediagoblin.tests.tools import get_test_app, fixture_add_user +from mediagoblin.tests.tools import get_app, fixture_add_user from mediagoblin.tools import template from mediagoblin.auth.lib import bcrypt_check_password class TestUserEdit(object): def setUp(self): - self.app = get_test_app(dump_old_app=False) + self.app = get_app(dump_old_app=False) # set up new user self.user_password = u'toast' self.user = fixture_add_user(password = self.user_password) @@ -37,6 +37,24 @@ class TestUserEdit(object): 'password': self.user_password}) + def test_user_deletion(self): + """Delete user via web interface""" + # Make sure user exists + assert User.query.filter_by(username=u'chris').first() + + res = self.app.post('/edit/account/delete/', {'confirmed': 'y'}) + + # Make sure user has been deleted + assert User.query.filter_by(username=u'chris').first() == None + + #TODO: make sure all corresponding items comments etc have been + # deleted too. Perhaps in submission test? + + #Restore user at end of test + self.user = fixture_add_user(password = self.user_password) + self.login() + + def test_change_password(self): """Test changing password correctly and incorrectly""" # test that the password can be changed diff --git a/mediagoblin/tests/test_http_callback.py b/mediagoblin/tests/test_http_callback.py index 0f6e489f..8bee7045 100644 --- a/mediagoblin/tests/test_http_callback.py +++ b/mediagoblin/tests/test_http_callback.py @@ -20,14 +20,14 @@ from urlparse import urlparse, parse_qs from mediagoblin import mg_globals from mediagoblin.tools import processing -from mediagoblin.tests.tools import get_test_app, fixture_add_user +from mediagoblin.tests.tools import get_app, fixture_add_user from mediagoblin.tests.test_submission import GOOD_PNG from mediagoblin.tests import test_oauth as oauth class TestHTTPCallback(object): def setUp(self): - self.app = get_test_app(dump_old_app=False) + self.app = get_app(dump_old_app=False) self.db = mg_globals.database self.user_password = u'secret' diff --git a/mediagoblin/tests/test_messages.py b/mediagoblin/tests/test_messages.py index c587e599..4c0f3e2e 100644 --- a/mediagoblin/tests/test_messages.py +++ b/mediagoblin/tests/test_messages.py @@ -15,7 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from mediagoblin.messages import fetch_messages, add_message -from mediagoblin.tests.tools import get_test_app +from mediagoblin.tests.tools import get_app from mediagoblin.tools import template @@ -26,7 +26,7 @@ def test_messages(): fetched messages should be the same as the added ones, and fetching should clear the message list. """ - test_app = get_test_app(dump_old_app=False) + test_app = get_app(dump_old_app=False) # Aquire a request object test_app.get('/') context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/root.html'] diff --git a/mediagoblin/tests/test_misc.py b/mediagoblin/tests/test_misc.py index 8a96e7d0..ae5d7e50 100644 --- a/mediagoblin/tests/test_misc.py +++ b/mediagoblin/tests/test_misc.py @@ -16,9 +16,9 @@ from nose.tools import assert_equal -from mediagoblin.tests.tools import get_test_app +from mediagoblin.tests.tools import get_app def test_404_for_non_existent(): - test_app = get_test_app(dump_old_app=False) + test_app = get_app(dump_old_app=False) res = test_app.get('/does-not-exist/', expect_errors=True) assert_equal(res.status_int, 404) diff --git a/mediagoblin/tests/test_oauth.py b/mediagoblin/tests/test_oauth.py index a72f766e..94ba5dab 100644 --- a/mediagoblin/tests/test_oauth.py +++ b/mediagoblin/tests/test_oauth.py @@ -21,7 +21,7 @@ from urlparse import parse_qs, urlparse from mediagoblin import mg_globals from mediagoblin.tools import template, pluginapi -from mediagoblin.tests.tools import get_test_app, fixture_add_user +from mediagoblin.tests.tools import get_app, fixture_add_user _log = logging.getLogger(__name__) @@ -29,7 +29,7 @@ _log = logging.getLogger(__name__) class TestOAuth(object): def setUp(self): - self.app = get_test_app() + self.app = get_app() self.db = mg_globals.database self.pman = pluginapi.PluginManager() diff --git a/mediagoblin/tests/test_submission.py b/mediagoblin/tests/test_submission.py index faf4e744..00f1ed3d 100644 --- a/mediagoblin/tests/test_submission.py +++ b/mediagoblin/tests/test_submission.py @@ -24,7 +24,7 @@ import os from nose.tools import assert_equal, assert_true from pkg_resources import resource_filename -from mediagoblin.tests.tools import get_test_app, \ +from mediagoblin.tests.tools import get_app, \ fixture_add_user from mediagoblin import mg_globals from mediagoblin.tools import template @@ -50,7 +50,7 @@ REQUEST_CONTEXT = ['mediagoblin/user_pages/user.html', 'request'] class TestSubmission: def setUp(self): - self.test_app = get_test_app(dump_old_app=False) + self.test_app = get_app(dump_old_app=False) # TODO: Possibly abstract into a decorator like: # @as_authenticated_user('chris') @@ -161,11 +161,23 @@ class TestSubmission: media = self.check_media(request, {'title': u'Balanced Goblin'}, 1) media_id = media.id + # render and post to the edit page. + edit_url = request.urlgen( + 'mediagoblin.edit.edit_media', + user=self.test_user.username, media_id=media_id) + self.test_app.get(edit_url) + self.test_app.post(edit_url, + {'title': u'Balanced Goblin', + 'slug': u"Balanced=Goblin", + 'tags': u''}) + media = self.check_media(request, {'title': u'Balanced Goblin'}, 1) + assert_equal(media.slug, u"balanced-goblin") + # Add a comment, so we can test for its deletion later. self.check_comments(request, media_id, 0) comment_url = request.urlgen( 'mediagoblin.user_pages.media_post_comment', - user=self.test_user.username, media=media_id) + user=self.test_user.username, media_id=media_id) response = self.do_post({'comment_content': 'i love this test'}, url=comment_url, do_follow=True)[0] self.check_comments(request, media_id, 1) @@ -174,7 +186,7 @@ class TestSubmission: # --------------------------------------------------- delete_url = request.urlgen( 'mediagoblin.user_pages.media_confirm_delete', - user=self.test_user.username, media=media_id) + user=self.test_user.username, media_id=media_id) # Empty data means don't confirm response = self.do_post({}, do_follow=True, url=delete_url)[0] media = self.check_media(request, {'title': u'Balanced Goblin'}, 1) diff --git a/mediagoblin/tests/test_tags.py b/mediagoblin/tests/test_tags.py index 73af2eea..ccb93085 100644 --- a/mediagoblin/tests/test_tags.py +++ b/mediagoblin/tests/test_tags.py @@ -14,7 +14,7 @@ # 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.tests.tools import get_test_app +from mediagoblin.tests.tools import get_app from mediagoblin.tools import text def test_list_of_dicts_conversion(): @@ -24,7 +24,7 @@ def test_list_of_dicts_conversion(): as a dict. Each tag dict should contain the tag's name and slug. Another function performs the reverse operation when populating a form to edit tags. """ - test_app = get_test_app(dump_old_app=False) + test_app = get_app(dump_old_app=False) # Leading, trailing, and internal whitespace should be removed and slugified assert text.convert_to_tag_list_of_dicts('sleep , 6 AM, chainsaw! ') == [ {'name': u'sleep', 'slug': u'sleep'}, diff --git a/mediagoblin/tests/test_tests.py b/mediagoblin/tests/test_tests.py index d09e8f28..d539f1e0 100644 --- a/mediagoblin/tests/test_tests.py +++ b/mediagoblin/tests/test_tests.py @@ -15,22 +15,22 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from mediagoblin import mg_globals -from mediagoblin.tests.tools import get_test_app, fixture_add_user +from mediagoblin.tests.tools import get_app, fixture_add_user from mediagoblin.db.models import User -def test_get_test_app_wipes_db(): +def test_get_app_wipes_db(): """ Make sure we get a fresh database on every wipe :) """ - get_test_app(dump_old_app=True) + get_app(dump_old_app=True) assert User.query.count() == 0 fixture_add_user() assert User.query.count() == 1 - get_test_app(dump_old_app=False) + get_app(dump_old_app=False) assert User.query.count() == 1 - get_test_app(dump_old_app=True) + get_app(dump_old_app=True) assert User.query.count() == 0 diff --git a/mediagoblin/tests/test_workbench.py b/mediagoblin/tests/test_workbench.py index 04a74653..636c8689 100644 --- a/mediagoblin/tests/test_workbench.py +++ b/mediagoblin/tests/test_workbench.py @@ -18,7 +18,9 @@ import os import tempfile -from mediagoblin import workbench +from mediagoblin.tools import workbench +from mediagoblin.mg_globals import setup_globals +from mediagoblin.decorators import get_workbench from mediagoblin.tests.test_storage import get_tmp_filestorage @@ -28,19 +30,20 @@ class TestWorkbench(object): os.path.join(tempfile.gettempdir(), u'mgoblin_workbench_testing')) def test_create_workbench(self): - workbench = self.workbench_manager.create_workbench() + workbench = self.workbench_manager.create() assert os.path.isdir(workbench.dir) assert workbench.dir.startswith(self.workbench_manager.base_workbench_dir) + workbench.destroy() def test_joinpath(self): - this_workbench = self.workbench_manager.create_workbench() + this_workbench = self.workbench_manager.create() tmpname = this_workbench.joinpath('temp.txt') assert tmpname == os.path.join(this_workbench.dir, 'temp.txt') - this_workbench.destroy_self() + this_workbench.destroy() def test_destroy_workbench(self): # kill a workbench - this_workbench = self.workbench_manager.create_workbench() + this_workbench = self.workbench_manager.create() tmpfile_name = this_workbench.joinpath('temp.txt') tmpfile = file(tmpfile_name, 'w') with tmpfile: @@ -49,14 +52,14 @@ class TestWorkbench(object): assert os.path.exists(tmpfile_name) wb_dir = this_workbench.dir - this_workbench.destroy_self() + this_workbench.destroy() assert not os.path.exists(tmpfile_name) assert not os.path.exists(wb_dir) def test_localized_file(self): tmpdir, this_storage = get_tmp_filestorage() - this_workbench = self.workbench_manager.create_workbench() - + this_workbench = self.workbench_manager.create() + # Write a brand new file filepath = ['dir1', 'dir2', 'ourfile.txt'] @@ -78,7 +81,7 @@ class TestWorkbench(object): filename = this_workbench.localized_file(this_storage, filepath) assert filename == os.path.join( this_workbench.dir, 'ourfile.txt') - + # fake remote file storage, filename_if_copying set filename = this_workbench.localized_file( this_storage, filepath, 'thisfile') @@ -91,3 +94,18 @@ class TestWorkbench(object): this_storage, filepath, 'thisfile.text', False) assert filename == os.path.join( this_workbench.dir, 'thisfile.text') + + def test_workbench_decorator(self): + """Test @get_workbench decorator and automatic cleanup""" + # The decorator needs mg_globals.workbench_manager + setup_globals(workbench_manager=self.workbench_manager) + + @get_workbench + def create_it(workbench=None): + # workbench dir exists? + assert os.path.isdir(workbench.dir) + return workbench.dir + + benchdir = create_it() + # workbench dir has been cleaned up automatically? + assert not os.path.isdir(benchdir) diff --git a/mediagoblin/tests/tools.py b/mediagoblin/tests/tools.py index 3e78b2e3..18d4ec0c 100644 --- a/mediagoblin/tests/tools.py +++ b/mediagoblin/tests/tools.py @@ -25,7 +25,7 @@ from paste.deploy import loadapp from webtest import TestApp from mediagoblin import mg_globals -from mediagoblin.db.models import User +from mediagoblin.db.models import User, Collection from mediagoblin.tools import testing from mediagoblin.init.config import read_mediagoblin_config from mediagoblin.db.open import setup_connection_and_db_from_config @@ -103,7 +103,7 @@ def suicide_if_bad_celery_environ(): raise BadCeleryEnviron(BAD_CELERY_MESSAGE) -def get_test_app(dump_old_app=True): +def get_app(dump_old_app=True): suicide_if_bad_celery_environ() # Make sure we've turned on testing @@ -164,7 +164,7 @@ def setup_fresh_app(func): """ @wraps(func) def wrapper(*args, **kwargs): - test_app = get_test_app() + test_app = get_app() testing.clear_test_buckets() return func(test_app, *args, **kwargs) @@ -226,3 +226,24 @@ def fixture_add_user(username=u'chris', password=u'toast', Session.expunge(test_user) return test_user + + +def fixture_add_collection(name=u"My first Collection", user=None): + if user is None: + user = fixture_add_user() + coll = Collection.query.filter_by(creator=user.id, title=name).first() + if coll is not None: + return coll + coll = Collection() + coll.creator = user.id + coll.title = name + coll.generate_slug() + coll.save() + + # Reload + Session.refresh(coll) + + # ... and detach from session: + Session.expunge(coll) + + return coll diff --git a/mediagoblin/tools/mail.py b/mediagoblin/tools/mail.py index 8639ba0c..4fa02ce5 100644 --- a/mediagoblin/tools/mail.py +++ b/mediagoblin/tools/mail.py @@ -122,3 +122,16 @@ def send_email(from_addr, to_addrs, subject, message_body): print message.get_payload(decode=True) return mhost.sendmail(from_addr, to_addrs, message.as_string()) + + +def normalize_email(email): + """return case sensitive part, lower case domain name + + :returns: None in case of broken email addresses""" + try: + em_user, em_dom = email.split('@', 1) + except ValueError: + # email contained no '@' + return None + email = "@".join((em_user, em_dom.lower())) + return email diff --git a/mediagoblin/workbench.py b/mediagoblin/tools/workbench.py index 2331b551..0bd4096b 100644 --- a/mediagoblin/workbench.py +++ b/mediagoblin/tools/workbench.py @@ -19,10 +19,6 @@ import shutil import tempfile -DEFAULT_WORKBENCH_DIR = os.path.join( - tempfile.gettempdir(), u'mgoblin_workbench') - - # Actual workbench stuff # ---------------------- @@ -119,7 +115,7 @@ class Workbench(object): return full_dest_filename - def destroy_self(self): + def destroy(self): """ Destroy this workbench! Deletes the directory and all its contents! @@ -127,18 +123,33 @@ class Workbench(object): """ # just in case workbench = os.path.abspath(self.dir) - shutil.rmtree(workbench) - del self.dir + def __enter__(self): + """Make Workbench a context manager so we can use `with Workbench() as bench:`""" + return self + + def __exit__(self, *args): + """Clean up context manager, aka ourselves, deleting the workbench""" + self.destroy() + class WorkbenchManager(object): """ A system for generating and destroying workbenches. - Workbenches are actually just subdirectories of a temporary storage space - for during the processing stage. + Workbenches are actually just subdirectories of a (local) temporary + storage space for during the processing stage. The preferred way to + create them is to use: + + with workbenchmger.create() as workbench: + do stuff... + + This will automatically clean up all temporary directories even in + case of an exceptions. Also check the + @mediagoblin.decorators.get_workbench decorator for a convenient + wrapper. """ def __init__(self, base_workbench_dir): @@ -146,7 +157,7 @@ class WorkbenchManager(object): if not os.path.exists(self.base_workbench_dir): os.makedirs(self.base_workbench_dir) - def create_workbench(self): + def create(self): """ Create and return the path to a new workbench (directory). """ diff --git a/mediagoblin/user_pages/forms.py b/mediagoblin/user_pages/forms.py index 9e8ccf01..c7398d84 100644 --- a/mediagoblin/user_pages/forms.py +++ b/mediagoblin/user_pages/forms.py @@ -20,8 +20,11 @@ from mediagoblin.tools.translate import fake_ugettext_passthrough as _ class MediaCommentForm(wtforms.Form): comment_content = wtforms.TextAreaField( - '', - [wtforms.validators.Required()]) + _('Comment'), + [wtforms.validators.Required()], + description=_(u'You can use ' + u'<a href="http://daringfireball.net/projects/markdown/basics">' + u'Markdown</a> for formatting.')) class ConfirmDeleteForm(wtforms.Form): confirm = wtforms.BooleanField( diff --git a/mediagoblin/user_pages/routing.py b/mediagoblin/user_pages/routing.py index 63bf5c2a..2b228355 100644 --- a/mediagoblin/user_pages/routing.py +++ b/mediagoblin/user_pages/routing.py @@ -24,12 +24,12 @@ add_route('mediagoblin.user_pages.media_home', 'mediagoblin.user_pages.views:media_home') add_route('mediagoblin.user_pages.media_confirm_delete', - '/u/<string:user>/m/<string:media>/confirm-delete/', + '/u/<string:user>/m/<int:media_id>/confirm-delete/', 'mediagoblin.user_pages.views:media_confirm_delete') # Submission handling of new comments. TODO: only allow for POST methods add_route('mediagoblin.user_pages.media_post_comment', - '/u/<string:user>/m/<string:media>/comment/add/', + '/u/<string:user>/m/<int:media_id>/comment/add/', 'mediagoblin.user_pages.views:media_post_comment') add_route('mediagoblin.user_pages.user_gallery', @@ -37,7 +37,7 @@ add_route('mediagoblin.user_pages.user_gallery', 'mediagoblin.user_pages.views:user_gallery') add_route('mediagoblin.user_pages.media_home.view_comment', - '/u/<string:user>/m/<string:media>/c/<string:comment>/', + '/u/<string:user>/m/<string:media>/c/<int:comment>/', 'mediagoblin.user_pages.views:media_home') add_route('mediagoblin.user_pages.atom_feed', @@ -48,6 +48,10 @@ add_route('mediagoblin.user_pages.media_collect', '/u/<string:user>/m/<string:media>/collect/', 'mediagoblin.user_pages.views:media_collect') +add_route('mediagoblin.user_pages.collection_list', + '/u/<string:user>/collections/', + 'mediagoblin.user_pages.views:collection_list') + add_route('mediagoblin.user_pages.user_collection', '/u/<string:user>/collection/<string:collection>/', 'mediagoblin.user_pages.views:user_collection') @@ -74,7 +78,7 @@ add_route('mediagoblin.user_pages.processing_panel', # Stray edit routes add_route('mediagoblin.edit.edit_media', - '/u/<string:user>/m/<string:media>/edit/', + '/u/<string:user>/m/<int:media_id>/edit/', 'mediagoblin.edit.views:edit_media') add_route('mediagoblin.edit.attachments', diff --git a/mediagoblin/user_pages/views.py b/mediagoblin/user_pages/views.py index f115c3b8..dea47fbf 100644 --- a/mediagoblin/user_pages/views.py +++ b/mediagoblin/user_pages/views.py @@ -23,11 +23,11 @@ from mediagoblin.db.models import (MediaEntry, Collection, CollectionItem, from mediagoblin.tools.response import render_to_response, render_404, redirect from mediagoblin.tools.translate import pass_to_ugettext as _ from mediagoblin.tools.pagination import Pagination -from mediagoblin.tools.files import delete_media_files from mediagoblin.user_pages import forms as user_forms from mediagoblin.user_pages.lib import send_comment_email 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) @@ -110,12 +110,13 @@ def media_home(request, media, page, **kwargs): """ 'Homepage' of a MediaEntry() """ - if request.matchdict.get('comment', None): + comment_id = request.matchdict.get('comment', None) + if comment_id: pagination = Pagination( page, media.get_comments( mg_globals.app_config['comments_ascending']), MEDIA_COMMENTS_PER_PAGE, - request.matchdict.get('comment')) + comment_id) else: pagination = Pagination( page, media.get_comments( @@ -138,7 +139,7 @@ def media_home(request, media, page, **kwargs): 'app_config': mg_globals.app_config}) -@get_user_media_entry +@get_media_entry_by_id @require_active_login def media_post_comment(request, media): """ @@ -226,6 +227,10 @@ def media_collect(request, media): messages.add_message( request, messages.ERROR, _('You have to select or add a collection')) + return redirect(request, "mediagoblin.user_pages.media_collect", + user=media.get_uploader.username, + media=media.id) + # Check whether media already exists in collection elif CollectionItem.query.filter_by( @@ -258,7 +263,7 @@ def media_collect(request, media): #TODO: Why does @user_may_delete_media not implicate @require_active_login? -@get_user_media_entry +@get_media_entry_by_id @require_active_login @user_may_delete_media def media_confirm_delete(request, media): @@ -268,21 +273,7 @@ def media_confirm_delete(request, media): if request.method == 'POST' and form.validate(): if form.confirm.data is True: username = media.get_uploader.username - - # Delete all the associated comments - for comment in media.get_comments(): - comment.delete() - - # Delete all files on the public storage - try: - delete_media_files(media) - except OSError, error: - _log.error('No such files from the user "{1}"' - ' to delete: {0}'.format(str(error), username)) - messages.add_message(request, messages.ERROR, - _('Some of the files with this entry seem' - ' to be missing. Deleting anyway.')) - + # Delete MediaEntry and all related files, comments etc. media.delete() messages.add_message( request, messages.SUCCESS, _('You deleted the media.')) @@ -337,6 +328,19 @@ def user_collection(request, page, url_user=None): 'pagination': pagination}) +@active_user_from_url +def collection_list(request, url_user=None): + """A User-defined Collection""" + collections = Collection.query.filter_by( + get_creator=url_user) + + return render_to_response( + request, + 'mediagoblin/user_pages/collection_list.html', + {'user': url_user, + 'collections': collections}) + + @get_user_collection_item @require_active_login @user_may_alter_collection |