Beispiel #1
0
def _expose_wrapper(f, template, request_method=None, permission=None):
    """Returns a function that will render the passed in function according
    to the passed in template"""
    f.exposed = True

    # Shortcut for simple expose of strings
    if template == 'string' and not request_method and not permission:
        return f

    if request_method:
        request_method = request_method.upper()

    def wrapped_f(*args, **kwargs):
        if request_method and request_method != request.method:
            raise HTTPMethodNotAllowed().exception

        result = f(*args, **kwargs)
        tmpl = template

        if hasattr(request, 'override_template'):
            tmpl = request.override_template

        if tmpl == 'string':
            return result

        if tmpl == 'json':
            if isinstance(result, (list, tuple)):
                msg = ("JSON responses with Array envelopes are susceptible "
                       "to cross-site data leak attacks, see "
                       "http://wiki.pylonshq.com/display/pylonsfaq/Warnings")
                if config['debug']:
                    raise TypeError(msg)
                warnings.warn(msg, Warning, 2)
                log.warning(msg)
            response.headers['Content-Type'] = 'application/json'
            return simplejson.dumps(result)

        if request.environ.get('paste.testing', False):
            # Make the vars passed from action to template accessible to tests
            request.environ['paste.testing_variables']['tmpl_vars'] = result

            # Serve application/xhtml+xml instead of text/html during testing.
            # This allows us to query the response xhtml as ElementTree XML
            # instead of BeautifulSoup HTML.
            # NOTE: We do not serve true xhtml to all clients that support it
            #       because of a bug in Mootools Swiff as of v1.2.4:
            #       https://mootools.lighthouseapp.com/projects/2706/tickets/758
            if response.content_type == 'text/html':
                response.content_type = 'application/xhtml+xml'

        return render(tmpl, tmpl_vars=result, method='auto')

    if permission:
        from mediadrop.lib.auth import FunctionProtector, has_permission
        wrapped_f = FunctionProtector(
            has_permission(permission)).wrap(wrapped_f)

    return wrapped_f
def _expose_wrapper(f, template, request_method=None, permission=None):
    """Returns a function that will render the passed in function according
    to the passed in template"""
    f.exposed = True

    # Shortcut for simple expose of strings
    if template == 'string' and not request_method and not permission:
        return f

    if request_method:
        request_method = request_method.upper()

    def wrapped_f(*args, **kwargs):
        if request_method and request_method != request.method:
            raise HTTPMethodNotAllowed().exception

        result = f(*args, **kwargs)
        tmpl = template

        if hasattr(request, 'override_template'):
            tmpl = request.override_template

        if tmpl == 'string':
            return result

        if tmpl == 'json':
            if isinstance(result, (list, tuple)):
                msg = ("JSON responses with Array envelopes are susceptible "
                       "to cross-site data leak attacks, see "
                       "http://wiki.pylonshq.com/display/pylonsfaq/Warnings")
                if config['debug']:
                    raise TypeError(msg)
                warnings.warn(msg, Warning, 2)
                log.warning(msg)
            response.headers['Content-Type'] = 'application/json'
            return simplejson.dumps(result)

        if request.environ.get('paste.testing', False):
            # Make the vars passed from action to template accessible to tests
            request.environ['paste.testing_variables']['tmpl_vars'] = result

            # Serve application/xhtml+xml instead of text/html during testing.
            # This allows us to query the response xhtml as ElementTree XML
            # instead of BeautifulSoup HTML.
            # NOTE: We do not serve true xhtml to all clients that support it
            #       because of a bug in Mootools Swiff as of v1.2.4:
            #       https://mootools.lighthouseapp.com/projects/2706/tickets/758
            if response.content_type == 'text/html':
                response.content_type = 'application/xhtml+xml'

        return render(tmpl, tmpl_vars=result, method='auto')

    if permission:
        from mediadrop.lib.auth import FunctionProtector, has_permission
        wrapped_f = FunctionProtector(has_permission(permission)).wrap(wrapped_f)

    return wrapped_f
Beispiel #3
0
class PlayersController(BaseController):
    """Admin player preference actions"""
    allow_only = has_permission('admin')

    @expose('admin/players/index.html')
    @observable(events.Admin.PlayersController.index)
    def index(self, **kwargs):
        """List players.

        :rtype: Dict
        :returns:
            players
                The list of :class:`~mediadrop.model.players.PlayerPrefs`
                instances for this page.

        """
        players = PlayerPrefs.query.order_by(PlayerPrefs.priority).all()

        return {
            'players': players,
        }

    @expose('admin/players/edit.html')
    @observable(events.Admin.PlayersController.edit)
    def edit(self, id, name=None, **kwargs):
        """Display the :class:`~mediadrop.model.players.PlayerPrefs` for editing or adding.

        :param id: PlayerPrefs ID
        :type id: ``int`` or ``"new"``
        :rtype: dict
        :returns:

        """
        playerp = fetch_row(PlayerPrefs, id)

        return {
            'player': playerp,
            'form': playerp.settings_form,
            'form_action': url_for(action='save'),
            'form_values': kwargs,
        }

    @expose(request_method='POST')
    @autocommit
    def save(self, id, **kwargs):
        player = fetch_row(PlayerPrefs, id)
        form = player.settings_form

        if id == 'new':
            DBSession.add(player)

        @validate(form, error_handler=self.edit)
        def save(id, **kwargs):
            # Allow the form to modify the player directly
            # since each can have radically different fields.
            save_func = getattr(form, 'save_data')
            save_func(player, **tmpl_context.form_values)
            redirect(controller='/admin/players', action='index')

        return save(id, **kwargs)

    @expose(request_method='POST')
    @autocommit
    @observable(events.Admin.PlayersController.delete)
    def delete(self, id, **kwargs):
        """Delete a PlayerPref.

        After deleting the PlayerPref, cleans up the players table,
        ensuring that each Player class is represented--if the deleted
        PlayerPref is the last example of that Player class, creates a new
        disabled PlayerPref for that Player class with the default settings.

        :param id: Player ID.
        :type id: ``int``
        :returns: Redirect back to :meth:`index` after successful delete.
        """
        player = fetch_row(PlayerPrefs, id)
        DBSession.delete(player)
        DBSession.flush()
        cleanup_players_table()
        redirect(action='index', id=None)

    @expose(request_method='POST')
    @autocommit
    @observable(events.Admin.PlayersController.enable)
    def enable(self, id, **kwargs):
        """Enable a PlayerPref.

        :param id: Player ID.
        :type id: ``int``
        :returns: Redirect back to :meth:`index` after success.
        """
        player = fetch_row(PlayerPrefs, id)
        player.enabled = True
        update_enabled_players()
        redirect(action='index', id=None)

    @expose(request_method='POST')
    @autocommit
    @observable(events.Admin.PlayersController.disable)
    def disable(self, id, **kwargs):
        """Disable a PlayerPref.

        :param id: Player ID.
        :type id: ``int``
        :returns: Redirect back to :meth:`index` after success.
        """
        player = fetch_row(PlayerPrefs, id)
        player.enabled = False
        update_enabled_players()
        redirect(action='index', id=None)

    @expose(request_method='POST')
    @autocommit
    @observable(events.Admin.PlayersController.reorder)
    def reorder(self, id, direction, **kwargs):
        """Reorder a PlayerPref.

        :param id: Player ID.
        :type id: ``int``
        :param direction: ``"up"`` for higher priority, ``"down"`` for
            lower priority
        :type direction: ``unicode``
        :returns: Redirect back to :meth:`index` after success.
        """
        if direction == 'up':
            offset = -1
        elif direction == 'down':
            offset = 1
        else:
            return

        player1 = fetch_row(PlayerPrefs, id)
        new_priority = player1.priority + offset
        try:
            player2 = fetch_row(PlayerPrefs, priority=new_priority)
            player2.priority = player1.priority
            player1.priority = new_priority
        except HTTPException, e:
            if e.code != 404:
                raise

        redirect(action='index', id=None)
Beispiel #4
0
class CommentsController(BaseController):
    allow_only = has_permission('edit')

    @expose_xhr('admin/comments/index.html', 'admin/comments/index-table.html')
    @paginate('comments', items_per_page=25)
    @observable(events.Admin.CommentsController.index)
    def index(self, page=1, search=None, media_filter=None, **kwargs):
        """List comments with pagination and filtering.

        :param page: Page number, defaults to 1.
        :type page: int
        :param search: Optional search term to filter by
        :type search: unicode or None
        :param media_filter: Optional media ID to filter by
        :type media_filter: int or None
        :rtype: dict
        :returns:
            comments
                The list of :class:`~mediadrop.model.comments.Comment` instances
                for this page.
            edit_form
                The :class:`mediadrop.forms.admin.comments.EditCommentForm` instance,
                to be rendered for each instance in ``comments``.
            search
                The given search term, if any
            search_form
                The :class:`~mediadrop.forms.admin.SearchForm` instance
            media_filter
                The given podcast ID to filter by, if any
            media_filter_title
                The media title for rendering if a ``media_filter`` was specified.

        """
        comments = Comment.query.trash(False)\
            .order_by(Comment.reviewed.asc(),
                      Comment.created_on.desc())

        # This only works since we only have comments on one type of content.
        # It will need re-evaluation if we ever add others.
        comments = comments.options(orm.eagerload('media'))

        if search is not None:
            comments = comments.search(search)

        media_filter_title = media_filter
        if media_filter is not None:
            comments = comments.filter(
                Comment.media.has(Media.id == media_filter))
            media_filter_title = DBSession.query(Media.title).get(media_filter)
            media_filter = int(media_filter)

        return dict(
            comments=comments,
            edit_form=edit_form,
            media_filter=media_filter,
            media_filter_title=media_filter_title,
            search=search,
            search_form=search_form,
        )

    @expose('json', request_method='POST')
    @autocommit
    @observable(events.Admin.CommentsController.save_status)
    def save_status(self, id, status, ids=None, **kwargs):
        """Approve or delete a comment or comments.

        :param id: A :attr:`~mediadrop.model.comments.Comment.id` if we are
            acting on a single comment, or ``"bulk"`` if we should refer to
            ``ids``.
        :type id: ``int`` or ``"bulk"``
        :param status: ``"approve"`` or ``"trash"`` depending on what action
            the user requests.
        :param ids: An optional string of IDs separated by commas.
        :type ids: ``unicode`` or ``None``
        :rtype: JSON dict
        :returns:
            success
                bool
            ids
                A list of :attr:`~mediadrop.model.comments.Comment.id`
                that have changed.

        """
        if id != 'bulk':
            ids = [id]
        if not isinstance(ids, list):
            ids = [ids]

        if status == 'approve':
            publishable = True
        elif status == 'trash':
            publishable = False
        else:
            # XXX: This form should never be submitted without a valid status.
            raise AssertionError('Unexpected status: %r' % status)

        comments = Comment.query.filter(Comment.id.in_(ids)).all()

        for comment in comments:
            comment.reviewed = True
            comment.publishable = publishable
            DBSession.add(comment)

        DBSession.flush()

        if request.is_xhr:
            return dict(success=True, ids=ids)
        else:
            redirect(action='index')

    @expose('json', request_method='POST')
    @autocommit
    @observable(events.Admin.CommentsController.save_edit)
    def save_edit(self, id, body, **kwargs):
        """Save an edit from :class:`~mediadrop.forms.admin.comments.EditCommentForm`.

        :param id: Comment ID
        :type id: ``int``
        :rtype: JSON dict
        :returns:
            success
                bool
            body
                The edited comment body after validation/filtering

        """
        comment = fetch_row(Comment, id)
        comment.body = body
        DBSession.add(comment)
        return dict(
            success=True,
            body=comment.body,
        )
Beispiel #5
0
class UsersController(BaseController):
    """Admin user actions"""
    allow_only = has_permission('admin')

    @expose_xhr('admin/users/index.html')
    @paginate('users', items_per_page=50)
    @observable(events.Admin.UsersController.index)
    def index(self, page=1, **kwargs):
        """List users with pagination.

        :param page: Page number, defaults to 1.
        :type page: int
        :rtype: Dict
        :returns:
            users
                The list of :class:`~mediadrop.model.auth.User`
                instances for this page.

        """
        users = DBSession.query(User).order_by(User.display_name,
                                               User.email_address)
        return dict(users=users)

    @expose('admin/users/edit.html')
    @observable(events.Admin.UsersController.edit)
    def edit(self, id, **kwargs):
        """Display the :class:`~mediadrop.forms.admin.users.UserForm` for editing or adding.

        :param id: User ID
        :type id: ``int`` or ``"new"``
        :rtype: dict
        :returns:
            user
                The :class:`~mediadrop.model.auth.User` instance we're editing.
            user_form
                The :class:`~mediadrop.forms.admin.users.UserForm` instance.
            user_action
                ``str`` form submit url
            user_values
                ``dict`` form values

        """
        user = fetch_row(User, id)

        if tmpl_context.action == 'save' or id == 'new':
            # Use the values from error_handler or GET for new users
            user_values = kwargs
            user_values['login_details.password'] = None
            user_values['login_details.confirm_password'] = None
        else:
            group_ids = None
            if user.groups:
                group_ids = map(lambda group: group.group_id, user.groups)
            user_values = dict(
                display_name=user.display_name,
                email_address=user.email_address,
                login_details=dict(
                    groups=group_ids,
                    user_name=user.user_name,
                ),
            )

        return dict(
            user=user,
            user_form=user_form,
            user_action=url_for(action='save'),
            user_values=user_values,
        )

    @expose(request_method='POST')
    @validate(user_form, error_handler=edit)
    @autocommit
    @observable(events.Admin.UsersController.save)
    def save(self,
             id,
             email_address,
             display_name,
             login_details,
             delete=None,
             **kwargs):
        """Save changes or create a new :class:`~mediadrop.model.auth.User` instance.

        :param id: User ID. If ``"new"`` a new user is created.
        :type id: ``int`` or ``"new"``
        :returns: Redirect back to :meth:`index` after successful save.

        """
        user = fetch_row(User, id)

        if delete:
            DBSession.delete(user)
            redirect(action='index', id=None)

        user.display_name = display_name
        user.email_address = email_address
        user.user_name = login_details['user_name']

        password = login_details['password']
        if password is not None and password != '':
            user.password = password

        if login_details['groups']:
            query = DBSession.query(Group).filter(
                Group.group_id.in_(login_details['groups']))
            user.groups = list(query.all())
        else:
            user.groups = []

        DBSession.add(user)

        # Check if we're changing the logged in user's own password
        if user.id == request.perm.user.id \
        and password is not None and password != '':
            DBSession.commit()
            # repoze.who sees the Unauthorized response and clears the cookie,
            # forcing a fresh login with the new password
            raise webob.exc.HTTPUnauthorized().exception

        redirect(action='index', id=None)

    @expose('json', request_method='POST')
    @autocommit
    @observable(events.Admin.UsersController.delete)
    def delete(self, id, **kwargs):
        """Delete a user.

        :param id: User ID.
        :type id: ``int``
        :returns: Redirect back to :meth:`index` after successful delete.
        """
        user = fetch_row(User, id)
        DBSession.delete(user)

        if request.is_xhr:
            return dict(success=True)
        redirect(action='index', id=None)
Beispiel #6
0
class TagsController(BaseController):
    allow_only = has_permission('edit')

    @expose('admin/tags/index.html')
    @paginate('tags', items_per_page=25)
    @observable(events.Admin.TagsController.index)
    def index(self, page=1, **kwargs):
        """List tags with pagination.

        :param page: Page number, defaults to 1.
        :type page: int
        :rtype: Dict
        :returns:
            tags
                The list of :class:`~mediadrop.model.tags.Tag`
                instances for this page.
            tag_form
                The :class:`~mediadrop.forms.admin.settings.tags.TagForm` instance.

        """
        tags = DBSession.query(Tag)\
            .options(orm.undefer('media_count'))\
            .order_by(Tag.name)

        return dict(
            tags = tags,
            tag_form = tag_form,
            tag_row_form = tag_row_form,
        )

    @expose('admin/tags/edit.html')
    @observable(events.Admin.TagsController.edit)
    def edit(self, id, **kwargs):
        """Edit a single tag.

        :param id: Tag ID
        :rtype: Dict
        :returns:
            tags
                The list of :class:`~mediadrop.model.tags.Tag`
                instances for this page.
            tag_form
                The :class:`~mediadrop.forms.admin.settings.tags.TagForm` instance.

        """
        tag = fetch_row(Tag, id)

        return dict(
            tag = tag,
            tag_form = tag_form,
        )

    @expose('json', request_method='POST')
    @validate(tag_form)
    @autocommit
    @observable(events.Admin.TagsController.save)
    def save(self, id, delete=False, **kwargs):
        """Save changes or create a tag.

        See :class:`~mediadrop.forms.admin.settings.tags.TagForm` for POST vars.

        :param id: Tag ID
        :rtype: JSON dict
        :returns:
            success
                bool

        """
        if tmpl_context.form_errors:
            if request.is_xhr:
                return dict(success=False, errors=tmpl_context.form_errors)
            else:
                # TODO: Add error reporting for users with JS disabled?
                return redirect(action='edit')

        tag = fetch_row(Tag, id)

        if delete:
            DBSession.delete(tag)
            data = dict(success=True, id=tag.id)
        else:
            tag.name = kwargs['name']
            tag.slug = get_available_slug(Tag, kwargs['slug'], tag)
            DBSession.add(tag)
            DBSession.flush()
            data = dict(
                success = True,
                id = tag.id,
                name = tag.name,
                slug = tag.slug,
                row = unicode(tag_row_form.display(tag=tag)),
            )

        if request.is_xhr:
            return data
        else:
            redirect(action='index', id=None)

    @expose('json', request_method='POST')
    @autocommit
    @observable(events.Admin.TagsController.bulk)
    def bulk(self, type=None, ids=None, **kwargs):
        """Perform bulk operations on media items

        :param type: The type of bulk action to perform (delete)
        :param ids: A list of IDs.

        """
        if not ids:
            ids = []
        elif not isinstance(ids, list):
            ids = [ids]

        success = True

        if type == 'delete':
            Tag.query.filter(Tag.id.in_(ids)).delete(False)
        else:
            success = False

        return dict(
            success = success,
            ids = ids,
        )
Beispiel #7
0
class IndexController(BaseController):
    """Admin dashboard actions"""
    allow_only = has_permission('edit')

    @expose('admin/index.html')
    @observable(events.Admin.IndexController.index)
    def index(self, **kwargs):
        """List recent and important items that deserve admin attention.

        We do not use the :func:`mediadrop.lib.helpers.paginate` decorator
        because its somewhat incompatible with the way we handle ajax
        fetching with :meth:`video_table`. This should be refactored and
        fixed at a later date.

        :rtype: Dict
        :returns:
            review_page
                A :class:`webhelpers.paginate.Page` instance containing
                :term:`unreviewed` :class:`~mediadrop.model.media.Media`.
            encode_page
                A :class:`webhelpers.paginate.Page` instance containing
                :term:`unencoded` :class:`~mediadrop.model.media.Media`.
            publish_page
                A :class:`webhelpers.paginate.Page` instance containing
                :term:`draft` :class:`~mediadrop.model.media.Media`.
            recent_media
                A list of recently published
                :class:`~mediadrop.model.media.Media`.
            comment_count
                Total num comments
            comment_count_published
                Total approved comments
            comment_count_unreviewed
                Total unreviewed comments
            comment_count_trash
                Total deleted comments

        """
        # Any publishable video that does have a publish_on date that is in the
        # past and is publishable is 'Recently Published'
        recent_media = Media.query.published()\
            .order_by(Media.publish_on.desc())[:5]

        return dict(
            review_page = self._fetch_page('awaiting_review'),
            encode_page = self._fetch_page('awaiting_encoding'),
            publish_page = self._fetch_page('awaiting_publishing'),
            recent_media = recent_media,
            comments = Comment.query,
        )


    @expose('admin/media/dash-table.html')
    @observable(events.Admin.IndexController.media_table)
    def media_table(self, table, page, **kwargs):
        """Fetch XHTML to inject when the 'showmore' ajax action is clicked.

        :param table: ``awaiting_review``, ``awaiting_encoding``, or
            ``awaiting_publishing``.
        :type table: ``unicode``
        :param page: Page number, defaults to 1.
        :type page: int
        :rtype: dict
        :returns:
            media
                A list of :class:`~mediadrop.model.media.Media` instances.

        """
        return dict(
            media = self._fetch_page(table, page).items,
        )


    def _fetch_page(self, type='awaiting_review', page=1, items_per_page=6):
        """Helper method for paginating media results"""
        query = Media.query.order_by(Media.modified_on.desc())

        if type == 'awaiting_review':
            query = query.filter_by(reviewed=False)
        elif type == 'awaiting_encoding':
            query = query.filter_by(reviewed=True, encoded=False)
        elif type == 'awaiting_publishing':
            query = query.filter_by(reviewed=True, encoded=True, publishable=False)

        return webhelpers.paginate.Page(query, page, items_per_page)
Beispiel #8
0
class MediaController(BaseController):
    allow_only = has_permission('edit')

    @expose_xhr('admin/media/index.html', 'admin/media/index-table.html')
    @paginate('media', items_per_page=15)
    @observable(events.Admin.MediaController.index)
    def index(self,
              page=1,
              search=None,
              filter=None,
              podcast=None,
              category=None,
              tag=None,
              **kwargs):
        """List media with pagination and filtering.

        :param page: Page number, defaults to 1.
        :type page: int
        :param search: Optional search term to filter by
        :type search: unicode or None
        :param podcast_filter: Optional podcast to filter by
        :type podcast_filter: int or None
        :rtype: dict
        :returns:
            media
                The list of :class:`~mediadrop.model.media.Media` instances
                for this page.
            search
                The given search term, if any
            search_form
                The :class:`~mediadrop.forms.admin.SearchForm` instance
            podcast
                The podcast object for rendering if filtering by podcast.

        """
        media = Media.query.options(orm.undefer('comment_count_published'))

        if search:
            media = media.admin_search(search)
        else:
            media = media.order_by_status()\
                         .order_by(Media.publish_on.desc(),
                                   Media.modified_on.desc())

        if not filter:
            pass
        elif filter == 'unreviewed':
            media = media.reviewed(False)
        elif filter == 'unencoded':
            media = media.reviewed().encoded(False)
        elif filter == 'drafts':
            media = media.drafts()
        elif filter == 'published':
            media = media.published()

        if category:
            category = fetch_row(Category, slug=category)
            media = media.filter(Media.categories.contains(category))
        if tag:
            tag = fetch_row(Tag, slug=tag)
            media = media.filter(Media.tags.contains(tag))
        if podcast:
            podcast = fetch_row(Podcast, slug=podcast)
            media = media.filter(Media.podcast == podcast)

        return dict(
            media=media,
            search=search,
            search_form=search_form,
            media_filter=filter,
            category=category,
            tag=tag,
            podcast=podcast,
        )

    def json_error(self, *args, **kwargs):
        validation_exception = tmpl_context._current_obj().validation_exception
        return dict(success=False, message=validation_exception.msg)

    @expose('admin/media/edit.html')
    @validate(validators={'podcast': validators.Int()})
    @autocommit
    @observable(events.Admin.MediaController.edit)
    def edit(self, id, **kwargs):
        """Display the media forms for editing or adding.

        This page serves as the error_handler for every kind of edit action,
        if anything goes wrong with them they'll be redirected here.

        :param id: Media ID
        :type id: ``int`` or ``"new"``
        :param \*\*kwargs: Extra args populate the form for ``"new"`` media
        :returns:
            media
                :class:`~mediadrop.model.media.Media` instance
            media_form
                The :class:`~mediadrop.forms.admin.media.MediaForm` instance
            media_action
                ``str`` form submit url
            media_values
                ``dict`` form values
            file_add_form
                The :class:`~mediadrop.forms.admin.media.AddFileForm` instance
            file_add_action
                ``str`` form submit url
            file_edit_form
                The :class:`~mediadrop.forms.admin.media.EditFileForm` instance
            file_edit_action
                ``str`` form submit url
            thumb_form
                The :class:`~mediadrop.forms.admin.ThumbForm` instance
            thumb_action
                ``str`` form submit url
            update_status_form
                The :class:`~mediadrop.forms.admin.media.UpdateStatusForm` instance
            update_status_action
                ``str`` form submit url

        """
        media = fetch_row(Media, id)

        if tmpl_context.action == 'save' or id == 'new':
            # Use the values from error_handler or GET for new podcast media
            media_values = kwargs
            user = request.perm.user
            media_values.setdefault('author_name', user.display_name)
            media_values.setdefault('author_email', user.email_address)
        else:
            # Pull the defaults from the media item
            media_values = dict(
                podcast=media.podcast_id,
                slug=media.slug,
                title=media.title,
                author_name=media.author.name,
                author_email=media.author.email,
                description=media.description,
                tags=', '.join((tag.name for tag in media.tags)),
                categories=[category.id for category in media.categories],
                notes=media.notes,
            )

        # Re-verify the state of our Media object in case the data is nonsensical
        if id != 'new':
            media.update_status()

        return dict(
            media=media,
            media_form=media_form,
            media_action=url_for(action='save'),
            media_values=media_values,
            category_tree=Category.query.order_by(
                Category.name).populated_tree(),
            file_add_form=add_file_form,
            file_add_action=url_for(action='add_file'),
            file_edit_form=edit_file_form,
            file_edit_action=url_for(action='edit_file'),
            thumb_form=thumb_form,
            thumb_action=url_for(action='save_thumb'),
            update_status_form=update_status_form,
            update_status_action=url_for(action='update_status'),
        )

    @expose_xhr(request_method='POST')
    @validate_xhr(media_form, error_handler=edit)
    @autocommit
    @observable(events.Admin.MediaController.save)
    def save(self,
             id,
             slug,
             title,
             author_name,
             author_email,
             description,
             notes,
             podcast,
             tags,
             categories,
             delete=None,
             **kwargs):
        """Save changes or create a new :class:`~mediadrop.model.media.Media` instance.

        Form handler the :meth:`edit` action and the
        :class:`~mediadrop.forms.admin.media.MediaForm`.

        Redirects back to :meth:`edit` after successful editing
        and :meth:`index` after successful deletion.

        """
        media = fetch_row(Media, id)

        if delete:
            self._delete_media(media)
            redirect(action='index', id=None)

        if not slug:
            slug = slugify(title)
        elif slug.startswith('_stub_'):
            slug = slug[len('_stub_'):]
        if slug != media.slug:
            media.slug = get_available_slug(Media, slug, media)
        media.title = title
        media.author = Author(author_name, author_email)
        media.description = description
        media.notes = notes
        media.podcast_id = podcast
        media.set_tags(tags)
        media.set_categories(categories)

        media.update_status()
        DBSession.add(media)
        DBSession.flush()

        if id == 'new' and not has_thumbs(media):
            create_default_thumbs_for(media)

        if request.is_xhr:
            status_form_xhtml = unicode(
                update_status_form.display(action=url_for(
                    action='update_status', id=media.id),
                                           media=media))

            return dict(
                media_id=media.id,
                values={'slug': slug},
                link=url_for(action='edit', id=media.id),
                status_form=status_form_xhtml,
            )
        else:
            redirect(action='edit', id=media.id)

    @expose('json', request_method='POST')
    @validate(add_file_form, error_handler=json_error)
    @autocommit
    @observable(events.Admin.MediaController.add_file)
    def add_file(self, id, file=None, url=None, **kwargs):
        """Save action for the :class:`~mediadrop.forms.admin.media.AddFileForm`.

        Creates a new :class:`~mediadrop.model.media.MediaFile` from the
        uploaded file or the local or remote URL.

        :param id: Media ID. If ``"new"`` a new Media stub is created.
        :type id: :class:`int` or ``"new"``
        :param file: The uploaded file
        :type file: :class:`cgi.FieldStorage` or ``None``
        :param url: A URL to a recognizable audio or video file
        :type url: :class:`unicode` or ``None``
        :rtype: JSON dict
        :returns:
            success
                bool
            message
                Error message, if unsuccessful
            media_id
                The :attr:`~mediadrop.model.media.Media.id` which is
                important if new media has just been created.
            file_id
                The :attr:`~mediadrop.model.media.MediaFile.id` for the newly
                created file.
            edit_form
                The rendered XHTML :class:`~mediadrop.forms.admin.media.EditFileForm`
                for this file.
            status_form
                The rendered XHTML :class:`~mediadrop.forms.admin.media.UpdateStatusForm`

        """
        if id == 'new':
            media = Media()
            user = request.perm.user
            media.author = Author(user.display_name, user.email_address)
            # Create a temp stub until we can set it to something meaningful
            timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            media.title = u'Temporary stub %s' % timestamp
            media.slug = get_available_slug(Media, '_stub_' + timestamp)
            media.reviewed = True
            DBSession.add(media)
            DBSession.flush()
        else:
            media = fetch_row(Media, id)

        try:
            media_file = add_new_media_file(media, file, url)
        except UserStorageError as e:
            return dict(success=False, message=e.message)
        if media.slug.startswith('_stub_'):
            media.title = media_file.display_name
            media.slug = get_available_slug(Media, '_stub_' + media.title)

        # The thumbs may have been created already by add_new_media_file
        if id == 'new' and not has_thumbs(media):
            create_default_thumbs_for(media)

        media.update_status()

        # Render some widgets so the XHTML can be injected into the page
        edit_form_xhtml = unicode(
            edit_file_form.display(action=url_for(action='edit_file',
                                                  id=media.id),
                                   file=media_file))
        status_form_xhtml = unicode(
            update_status_form.display(action=url_for(action='update_status',
                                                      id=media.id),
                                       media=media))

        data = dict(
            success=True,
            media_id=media.id,
            file_id=media_file.id,
            file_type=media_file.type,
            edit_form=edit_form_xhtml,
            status_form=status_form_xhtml,
            title=media.title,
            slug=media.slug,
            description=media.description,
            link=url_for(action='edit', id=media.id),
            duration=helpers.duration_from_seconds(media.duration),
        )

        return data

    @expose('json', request_method='POST')
    @autocommit
    @observable(events.Admin.MediaController.edit_file)
    def edit_file(self,
                  id,
                  file_id,
                  file_type=None,
                  duration=None,
                  delete=None,
                  bitrate=None,
                  width_height=None,
                  **kwargs):
        """Save action for the :class:`~mediadrop.forms.admin.media.EditFileForm`.

        Changes or deletes a :class:`~mediadrop.model.media.MediaFile`.

        XXX: We do NOT use the @validate decorator due to complications with
             partial validation. The JS sends only the value it wishes to
             change, so we only want to validate that one value.
             FancyValidator.if_missing seems to eat empty values and assign
             them None, but there's an important difference to us between
             None (no value from the user) and an empty value (the user
             is clearing the value of a field).

        :param id: Media ID
        :type id: :class:`int`
        :rtype: JSON dict
        :returns:
            success
                bool
            message
                Error message, if unsuccessful
            status_form
                Rendered XHTML for the status form, updated to reflect the
                changes made.

        """
        media = fetch_row(Media, id)
        data = dict(success=False)
        file_id = int(file_id)  # Just in case validation failed somewhere.

        for file in media.files:
            if file.id == file_id:
                break
        else:
            file = None

        fields = edit_file_form.c
        try:
            if file is None:
                data['message'] = _('File "%s" does not exist.') % file_id
            elif file_type:
                file.type = fields.file_type.validate(file_type)
                data['success'] = True
            elif duration is not None:
                media.duration = fields.duration.validate(duration)
                data['success'] = True
                data['duration'] = helpers.duration_from_seconds(
                    media.duration)
            elif width_height is not None:
                width_height = fields.width_height.validate(width_height)
                file.width, file.height = width_height or (0, 0)
                data['success'] = True
            elif bitrate is not None:
                file.bitrate = fields.bitrate.validate(bitrate)
                data['success'] = True
            elif delete:
                file.storage.delete(file.unique_id)
                DBSession.delete(file)
                DBSession.flush()
                # media.files must be updated to reflect the file deletion above
                DBSession.refresh(media)
                data['success'] = True
            else:
                data['message'] = _('No action to perform.')
        except Invalid, e:
            data['success'] = False
            data['message'] = unicode(e)

        if data['success']:
            data['file_type'] = file.type
            media.update_status()
            DBSession.flush()

            # Return the rendered widget for injection
            status_form_xhtml = unicode(
                update_status_form.display(
                    action=url_for(action='update_status'), media=media))
            data['status_form'] = status_form_xhtml
        return data
Beispiel #9
0
class StorageController(BaseController):
    """Admin storage engine actions"""
    allow_only = has_permission('admin')

    @expose('admin/storage/index.html')
    @observable(events.Admin.StorageController.index)
    def index(self, page=1, **kwargs):
        """List storage engines with pagination.

        :rtype: Dict
        :returns:
            engines
                The list of :class:`~mediadrop.lib.storage.StorageEngine`
                instances for this page.

        """
        engines = DBSession.query(StorageEngine)\
            .options(orm.undefer('file_count'),
                     orm.undefer('file_size_sum'))\
            .all()
        engines = list(sort_engines(engines))
        existing_types = set(ecls.engine_type for ecls in engines)
        addable_engines = [
            ecls for ecls in StorageEngine
            if not ecls.is_singleton or ecls.engine_type not in existing_types
        ]

        return {
            'engines': engines,
            'addable_engines': addable_engines,
        }

    @expose('admin/storage/edit.html')
    @observable(events.Admin.StorageController.edit)
    def edit(self, id, engine_type=None, **kwargs):
        """Display the :class:`~mediadrop.lib.storage.StorageEngine` for editing or adding.

        :param id: Storage ID
        :type id: ``int`` or ``"new"``
        :rtype: dict
        :returns:

        """
        engine = self.fetch_engine(id, engine_type)

        return {
            'engine': engine,
            'form': engine.settings_form,
            'form_action': url_for(action='save', engine_type=engine_type),
            'form_values': kwargs,
        }

    @expose(request_method='POST')
    @autocommit
    def save(self, id, engine_type=None, **kwargs):
        if id == 'new':
            assert engine_type is not None, 'engine_type must be specified when saving a new StorageEngine.'

        engine = self.fetch_engine(id, engine_type)
        form = engine.settings_form

        if id == 'new':
            DBSession.add(engine)

        @validate(form, error_handler=self.edit)
        def save_engine_params(id, general, **kwargs):
            # Allow the form to modify the StorageEngine directly
            # since each can have radically different fields.
            save_func = getattr(form, 'save_engine_params')
            save_func(engine, **tmpl_context.form_values)
            redirect(controller='/admin/storage', action='index')

        return save_engine_params(id, **kwargs)

    def fetch_engine(self, id, engine_type=None):
        if id != 'new':
            engine = fetch_row(StorageEngine, id)
        else:
            types = dict((cls.engine_type, cls) for cls in StorageEngine)
            engine_cls = types.get(engine_type, None)
            if not engine_cls:
                redirect(controller='/admin/storage', action='index')
            engine = engine_cls()
        return engine

    @expose('json', request_method='POST')
    @autocommit
    @observable(events.Admin.StorageController.delete)
    def delete(self, id, **kwargs):
        """Delete a StorageEngine.

        :param id: Storage ID.
        :type id: ``int``
        :returns: Redirect back to :meth:`index` after successful delete.
        """
        engine = fetch_row(StorageEngine, id)
        files = engine.files
        for f in files:
            engine.delete(f.unique_id)
        DBSession.delete(engine)
        redirect(action='index', id=None)

    @expose(request_method='POST')
    @autocommit
    @observable(events.Admin.StorageController.enable)
    def enable(self, id, **kwargs):
        """Enable a StorageEngine.

        :param id: Storage ID.
        :type id: ``int``
        :returns: Redirect back to :meth:`index` after success.
        """
        engine = fetch_row(StorageEngine, id)
        engine.enabled = True
        redirect(action='index', id=None)

    @expose(request_method='POST')
    @autocommit
    @observable(events.Admin.StorageController.disable)
    def disable(self, id, **kwargs):
        """Disable a StorageEngine.

        :param id: engine ID.
        :type id: ``int``
        :returns: Redirect back to :meth:`index` after success.
        """
        engine = fetch_row(StorageEngine, id)
        engine.enabled = False
        redirect(action='index', id=None)
Beispiel #10
0
class BaseSettingsController(BaseController):
    """
    Dumb controller for display and saving basic settings forms

    This maps forms from :class:`mediadrop.forms.admin.settings` to our
    model :class:`~mediadrop.model.settings.Setting`. This controller
    doesn't care what settings are used, the form dictates everything.
    The form field names should exactly match the name in the model,
    regardless of it's nesting in the form.

    If and when setting values need to be altered for display purposes,
    or before it is saved to the database, it should be done with a
    field validator instead of adding complexity here.

    """
    allow_only = has_permission('admin')

    def __before__(self, *args, **kwargs):
        """Load all our settings before each request."""
        BaseController.__before__(self, *args, **kwargs)
        from mediadrop.model import Setting
        tmpl_context.settings = dict(DBSession.query(Setting.key, Setting))

    def _update_settings(self, values):
        """Modify the settings associated with the given dictionary."""
        for name, value in values.iteritems():
            if name in tmpl_context.settings:
                setting = tmpl_context.settings[name]
            else:
                setting = Setting(key=name, value=value)
            if value is None:
                value = u''
            else:
                value = unicode(value)
            if setting.value != value:
                setting.value = value
                DBSession.add(setting)
        DBSession.flush()

        # Clear the settings cache unless there are multiple processes.
        # We have no way of notifying the other processes that they need
        # to clear their caches too, so we've just gotta let it play out
        # until all the caches expire.
        if not request.environ.get('wsgi.multiprocess', False):
            app_globals.settings_cache.clear()
        else:
            # uWSGI provides an automagically included module
            # that we can use to call a graceful restart of all
            # the uwsgi processes.
            # http://projects.unbit.it/uwsgi/wiki/uWSGIReload
            try:
                import uwsgi
                uwsgi.reload()
            except ImportError:
                pass

    def _display(self, form, values=None, action=None):
        """Return the template variables for display of the form.

        :rtype: dict
        :returns:
            form
                The passed in form instance.
            form_values
                ``dict`` form values
        """
        form_values = self._nest_settings_for_form(tmpl_context.settings, form)
        if values:
            form_values.update(values)
        return dict(
            form=form,
            form_action=action,
            form_values=form_values,
        )

    def _save(self, form, redirect_action=None, values=None):
        """Save the values from the passed in form instance."""
        values = self._flatten_settings_from_form(form, values)
        self._update_settings(values)
        if redirect_action:
            helpers.redirect(action=redirect_action)

    def _is_button(self, field):
        return getattr(field, 'type',
                       None) in ('button', 'submit', 'reset', 'image')

    def _nest_settings_for_form(self, settings, form):
        """Create a dict of setting values nested to match the form."""
        form_values = {}
        for field in form.c:
            if isinstance(field, _ContainerMixin):
                form_values[field._name] = self._nest_settings_for_form(
                    settings, field)
            elif field._name in settings:
                form_values[field._name] = settings[field._name].value
        return form_values

    def _flatten_settings_from_form(self, form, form_values):
        """Take a nested dict and return a flat dict of setting values."""
        setting_values = {}
        for field in form.c:
            if isinstance(field, _ContainerMixin):
                setting_values.update(
                    self._flatten_settings_from_form(field,
                                                     form_values[field._name]))
            elif not self._is_button(field):
                setting_values[field._name] = form_values[field._name]
        return setting_values
Beispiel #11
0
class GroupsController(BaseController):
    """Admin group actions"""
    allow_only = has_permission('admin')

    @expose('admin/groups/index.html')
    @paginate('groups', items_per_page=50)
    @observable(events.Admin.GroupsController.index)
    def index(self, page=1, **kwargs):
        """List groups with pagination.

        :param page: Page number, defaults to 1.
        :type page: int
        :rtype: Dict
        :returns:
            users
                The list of :class:`~mediadrop.model.auth.Group`
                instances for this page.

        """
        groups = DBSession.query(Group).order_by(Group.display_name,
                                                 Group.group_name)
        return dict(groups=groups)

    @expose('admin/groups/edit.html')
    @observable(events.Admin.GroupsController.edit)
    def edit(self, id, **kwargs):
        """Display the :class:`~mediadrop.forms.admin.groups.GroupForm` for editing or adding.

        :param id: Group ID
        :type id: ``int`` or ``"new"``
        :rtype: dict
        :returns:
            user
                The :class:`~mediadrop.model.auth.Group` instance we're editing.
            user_form
                The :class:`~mediadrop.forms.admin.groups.GroupForm` instance.
            user_action
                ``str`` form submit url
            group_values
                ``dict`` form values

        """
        group = fetch_row(Group, id)

        if tmpl_context.action == 'save' or id == 'new':
            # Use the values from error_handler or GET for new groups
            group_values = kwargs
        else:
            permission_ids = map(lambda permission: permission.permission_id,
                                 group.permissions)
            group_values = dict(display_name=group.display_name,
                                group_name=group.group_name,
                                permissions=permission_ids)

        return dict(
            group=group,
            group_form=group_form,
            group_action=url_for(action='save'),
            group_values=group_values,
        )

    @expose(request_method='POST')
    @validate(group_form, error_handler=edit)
    @autocommit
    @observable(events.Admin.GroupsController.save)
    def save(self,
             id,
             display_name,
             group_name,
             permissions,
             delete=None,
             **kwargs):
        """Save changes or create a new :class:`~mediadrop.model.auth.Group` instance.

        :param id: Group ID. If ``"new"`` a new group is created.
        :type id: ``int`` or ``"new"``
        :returns: Redirect back to :meth:`index` after successful save.

        """
        group = fetch_row(Group, id)

        if delete:
            DBSession.delete(group)
            redirect(action='index', id=None)

        group.display_name = display_name
        group.group_name = group_name
        if permissions:
            query = DBSession.query(Permission).filter(
                Permission.permission_id.in_(permissions))
            group.permissions = list(query.all())
        else:
            group.permissions = []
        DBSession.add(group)

        redirect(action='index', id=None)

    @expose('json', request_method='POST')
    @autocommit
    @observable(events.Admin.GroupsController.delete)
    def delete(self, id, **kwargs):
        """Delete a group.

        :param id: Group ID.
        :type id: ``int``
        :returns: Redirect back to :meth:`index` after successful delete.
        """
        group = fetch_row(Group, id)
        DBSession.delete(group)

        if request.is_xhr:
            return dict(success=True)
        redirect(action='index', id=None)
Beispiel #12
0
class PodcastsController(BaseController):
    allow_only = has_permission('edit')

    @expose_xhr('admin/podcasts/index.html', 'admin/podcasts/index-table.html')
    @paginate('podcasts', items_per_page=10)
    @observable(events.Admin.PodcastsController.index)
    def index(self, page=1, **kw):
        """List podcasts with pagination.

        :param page: Page number, defaults to 1.
        :type page: int
        :rtype: Dict
        :returns:
            podcasts
                The list of :class:`~mediadrop.model.podcasts.Podcast`
                instances for this page.
        """
        podcasts = DBSession.query(Podcast)\
            .options(orm.undefer('media_count'))\
            .order_by(Podcast.title)
        return dict(podcasts=podcasts)

    @expose('admin/podcasts/edit.html')
    @observable(events.Admin.PodcastsController.edit)
    def edit(self, id, **kwargs):
        """Display the podcast forms for editing or adding.

        This page serves as the error_handler for every kind of edit action,
        if anything goes wrong with them they'll be redirected here.

        :param id: Podcast ID
        :type id: ``int`` or ``"new"``
        :param \*\*kwargs: Extra args populate the form for ``"new"`` podcasts
        :returns:
            podcast
                :class:`~mediadrop.model.podcasts.Podcast` instance
            form
                :class:`~mediadrop.forms.admin.podcasts.PodcastForm` instance
            form_action
                ``str`` form submit url
            form_values
                ``dict`` form values
            thumb_form
                :class:`~mediadrop.forms.admin.ThumbForm` instance
            thumb_action
                ``str`` form submit url

        """
        podcast = fetch_row(Podcast, id)

        if tmpl_context.action == 'save' or id == 'new':
            form_values = kwargs
            user = request.perm.user
            form_values.setdefault('author_name', user.display_name)
            form_values.setdefault('author_email', user.email_address)
            form_values.setdefault('feed', {}).setdefault(
                'feed_url', _('Save the podcast to get your feed URL'))
        else:
            explicit_values = {True: 'yes', False: 'clean', None: 'no'}
            form_values = dict(
                slug=podcast.slug,
                title=podcast.title,
                subtitle=podcast.subtitle,
                author_name=podcast.author and podcast.author.name or None,
                author_email=podcast.author and podcast.author.email or None,
                description=podcast.description,
                details=dict(
                    explicit=explicit_values.get(podcast.explicit),
                    category=podcast.category,
                    copyright=podcast.copyright,
                ),
                feed=dict(
                    feed_url=url_for(controller='/podcasts',
                                     action='feed',
                                     slug=podcast.slug,
                                     qualified=True),
                    itunes_url=podcast.itunes_url,
                    feedburner_url=podcast.feedburner_url,
                ),
            )

        return dict(
            podcast=podcast,
            form=podcast_form,
            form_action=url_for(action='save'),
            form_values=form_values,
            thumb_form=thumb_form,
            thumb_action=url_for(action='save_thumb'),
        )

    @expose(request_method='POST')
    @validate(podcast_form, error_handler=edit)
    @autocommit
    @observable(events.Admin.PodcastsController.save)
    def save(self,
             id,
             slug,
             title,
             subtitle,
             author_name,
             author_email,
             description,
             details,
             feed,
             delete=None,
             **kwargs):
        """Save changes or create a new :class:`~mediadrop.model.podcasts.Podcast` instance.

        Form handler the :meth:`edit` action and the
        :class:`~mediadrop.forms.admin.podcasts.PodcastForm`.

        Redirects back to :meth:`edit` after successful editing
        and :meth:`index` after successful deletion.

        """
        podcast = fetch_row(Podcast, id)

        if delete:
            DBSession.delete(podcast)
            DBSession.commit()
            delete_thumbs(podcast)
            redirect(action='index', id=None)

        if not slug:
            slug = title
        if slug != podcast.slug:
            podcast.slug = get_available_slug(Podcast, slug, podcast)
        podcast.title = title
        podcast.subtitle = subtitle
        podcast.author = Author(author_name, author_email)
        podcast.description = description
        podcast.copyright = details['copyright']
        podcast.category = details['category']
        podcast.itunes_url = feed['itunes_url']
        podcast.feedburner_url = feed['feedburner_url']
        podcast.explicit = {
            'yes': True,
            'clean': False
        }.get(details['explicit'], None)

        if id == 'new':
            DBSession.add(podcast)
            DBSession.flush()
            create_default_thumbs_for(podcast)

        redirect(action='edit', id=podcast.id)

    @expose('json', request_method='POST')
    @validate(thumb_form, error_handler=edit)
    @observable(events.Admin.PodcastsController.save_thumb)
    def save_thumb(self, id, thumb, **values):
        """Save a thumbnail uploaded with :class:`~mediadrop.forms.admin.ThumbForm`.

        :param id: Media ID. If ``"new"`` a new Podcast stub is created.
        :type id: ``int`` or ``"new"``
        :param file: The uploaded file
        :type file: :class:`cgi.FieldStorage` or ``None``
        :rtype: JSON dict
        :returns:
            success
                bool
            message
                Error message, if unsuccessful
            id
                The :attr:`~mediadrop.model.podcasts.Podcast.id` which is
                important if a new podcast has just been created.

        """
        if id == 'new':
            return dict(
                success=False,
                message=
                u'You must first save the podcast before you can upload a thumbnail',
            )

        podcast = fetch_row(Podcast, id)

        try:
            # Create JPEG thumbs
            create_thumbs_for(podcast, thumb.file, thumb.filename)
            success = True
            message = None
        except IOError, e:
            success = False
            if e.errno == 13:
                message = _('Permission denied, cannot write file')
            elif e.message == 'cannot identify image file':
                message = _('Unsupport image type: %s') \
                    % os.path.splitext(thumb.filename)[1].lstrip('.')
            elif e.message == 'cannot read interlaced PNG files':
                message = _('Interlaced PNGs are not supported.')
            else:
                raise

        return dict(
            success=success,
            message=message,
        )
Beispiel #13
0
class CategoriesController(BaseController):
    allow_only = has_permission('edit')

    @expose('admin/categories/index.html')
    @paginate('tags', items_per_page=25)
    @observable(events.Admin.CategoriesController.index)
    def index(self, **kwargs):
        """List categories.

        :rtype: Dict
        :returns:
            categories
                The list of :class:`~mediadrop.model.categories.Category`
                instances for this page.
            category_form
                The :class:`~mediadrop.forms.admin.settings.categories.CategoryForm` instance.

        """
        categories = Category.query\
            .order_by(Category.name)\
            .options(orm.undefer('media_count'))\
            .populated_tree()

        return dict(
            categories=categories,
            category_form=category_form,
            category_row_form=category_row_form,
        )

    @expose('admin/categories/edit.html')
    @observable(events.Admin.CategoriesController.edit)
    def edit(self, id, **kwargs):
        """Edit a single category.

        :param id: Category ID
        :rtype: Dict
        :returns:
            categories
                The list of :class:`~mediadrop.model.categories.Category`
                instances for this page.
            category_form
                The :class:`~mediadrop.forms.admin.settings.categories.CategoryForm` instance.

        """
        category = fetch_row(Category, id)

        return dict(
            category=category,
            category_form=category_form,
            category_row_form=category_row_form,
        )

    @expose('json', request_method='POST')
    @validate(category_form)
    @autocommit
    @observable(events.Admin.CategoriesController.save)
    def save(self, id, delete=None, **kwargs):
        """Save changes or create a category.

        See :class:`~mediadrop.forms.admin.settings.categories.CategoryForm` for POST vars.

        :param id: Category ID
        :param delete: If true the category is to be deleted rather than saved.
        :type delete: bool
        :rtype: JSON dict
        :returns:
            success
                bool

        """
        if tmpl_context.form_errors:
            if request.is_xhr:
                return dict(success=False, errors=tmpl_context.form_errors)
            else:
                # TODO: Add error reporting for users with JS disabled?
                return redirect(action='edit')

        cat = fetch_row(Category, id)

        if delete:
            DBSession.delete(cat)
            data = dict(
                success=True,
                id=cat.id,
                parent_options=unicode(category_form.c['parent_id'].display()),
            )
        else:
            cat.name = kwargs['name']
            cat.slug = get_available_slug(Category, kwargs['slug'], cat)

            if kwargs['parent_id']:
                parent = fetch_row(Category, kwargs['parent_id'])
                if parent is not cat and cat not in parent.ancestors():
                    cat.parent = parent
            else:
                cat.parent = None

            DBSession.add(cat)
            DBSession.flush()

            data = dict(
                success=True,
                id=cat.id,
                name=cat.name,
                slug=cat.slug,
                parent_id=cat.parent_id,
                parent_options=unicode(category_form.c['parent_id'].display()),
                depth=cat.depth(),
                row=unicode(
                    category_row_form.display(
                        action=url_for(id=cat.id),
                        category=cat,
                        depth=cat.depth(),
                        first_child=True,
                    )),
            )

        if request.is_xhr:
            return data
        else:
            redirect(action='index', id=None)

    @expose('json', request_method='POST')
    @autocommit
    @observable(events.Admin.CategoriesController.bulk)
    def bulk(self, type=None, ids=None, **kwargs):
        """Perform bulk operations on media items

        :param type: The type of bulk action to perform (delete)
        :param ids: A list of IDs.

        """
        if not ids:
            ids = []
        elif not isinstance(ids, list):
            ids = [ids]

        if type == 'delete':
            Category.query.filter(Category.id.in_(ids)).delete(False)
            DBSession.commit()
            success = True
        else:
            success = False

        return dict(
            success=success,
            ids=ids,
            parent_options=unicode(category_form.c['parent_id'].display()),
        )