docs: Document video resolution config.
[mediagoblin.git] / mediagoblin / user_pages / views.py
... / ...
CommitLineData
1# GNU MediaGoblin -- federated, autonomous media hosting
2# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU Affero General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17import logging
18import datetime
19import json
20
21import six
22
23from mediagoblin import messages, mg_globals
24from mediagoblin.db.models import (MediaEntry, MediaTag, Collection, Comment,
25 CollectionItem, LocalUser, Activity, \
26 GenericModelReference)
27from mediagoblin.plugins.api.tools import get_media_file_paths
28from mediagoblin.tools.response import render_to_response, render_404, \
29 redirect, redirect_obj
30from mediagoblin.tools.text import cleaned_markdown_conversion
31from mediagoblin.tools.translate import pass_to_ugettext as _
32from mediagoblin.tools.pagination import Pagination
33from mediagoblin.tools.federation import create_activity
34from mediagoblin.user_pages import forms as user_forms
35from mediagoblin.user_pages.lib import (send_comment_email,
36 add_media_to_collection, build_report_object)
37from mediagoblin.notifications import trigger_notification, \
38 add_comment_subscription, mark_comment_notification_seen
39from mediagoblin.tools.pluginapi import hook_transform
40
41from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
42 get_media_entry_by_id, user_has_privilege, user_not_banned,
43 require_active_login, user_may_delete_media, user_may_alter_collection,
44 get_user_collection, get_user_collection_item, active_user_from_url,
45 get_optional_media_comment_by_id, allow_reporting)
46
47from werkzeug.contrib.atom import AtomFeed
48from werkzeug.exceptions import MethodNotAllowed
49from werkzeug.wrappers import Response
50
51
52_log = logging.getLogger(__name__)
53_log.setLevel(logging.DEBUG)
54
55@user_not_banned
56@uses_pagination
57def user_home(request, page):
58 """'Homepage' of a LocalUser()"""
59 user = LocalUser.query.filter_by(username=request.matchdict['user']).first()
60 if not user:
61 return render_404(request)
62 elif not user.has_privilege(u'active'):
63 return render_to_response(
64 request,
65 'mediagoblin/user_pages/user_nonactive.html',
66 {'user': user})
67
68 cursor = MediaEntry.query.\
69 filter_by(actor = user.id).order_by(MediaEntry.created.desc())
70
71 pagination = Pagination(page, cursor)
72 media_entries = pagination()
73
74 # if no data is available, return NotFound
75 if media_entries == None:
76 return render_404(request)
77
78 user_gallery_url = request.urlgen(
79 'mediagoblin.user_pages.user_gallery',
80 user=user.username)
81
82 return render_to_response(
83 request,
84 'mediagoblin/user_pages/user.html',
85 {'user': user,
86 'user_gallery_url': user_gallery_url,
87 'media_entries': media_entries,
88 'pagination': pagination})
89
90@user_not_banned
91@active_user_from_url
92@uses_pagination
93def user_gallery(request, page, url_user=None):
94 """'Gallery' of a LocalUser()"""
95 tag = request.matchdict.get('tag', None)
96 cursor = MediaEntry.query.filter_by(
97 actor=url_user.id,
98 state=u'processed').order_by(MediaEntry.created.desc())
99
100 # Filter potentially by tag too:
101 if tag:
102 cursor = cursor.filter(
103 MediaEntry.tags_helper.any(
104 MediaTag.slug == request.matchdict['tag']))
105
106 # Paginate gallery
107 pagination = Pagination(page, cursor)
108 media_entries = pagination()
109
110 #if no data is available, return NotFound
111 # TODO: Should we really also return 404 for empty galleries?
112 if media_entries == None:
113 return render_404(request)
114
115 return render_to_response(
116 request,
117 'mediagoblin/user_pages/gallery.html',
118 {'user': url_user, 'tag': tag,
119 'media_entries': media_entries,
120 'pagination': pagination})
121
122
123MEDIA_COMMENTS_PER_PAGE = 50
124
125@user_not_banned
126@get_user_media_entry
127@uses_pagination
128def media_home(request, media, page, **kwargs):
129 """
130 'Homepage' of a MediaEntry()
131 """
132 comment_id = request.matchdict.get('comment', None)
133 if comment_id:
134 if request.user:
135 mark_comment_notification_seen(comment_id, request.user)
136
137 pagination = Pagination(
138 page, media.get_comments(
139 mg_globals.app_config['comments_ascending']),
140 MEDIA_COMMENTS_PER_PAGE,
141 comment_id)
142 else:
143 pagination = Pagination(
144 page, media.get_comments(
145 mg_globals.app_config['comments_ascending']),
146 MEDIA_COMMENTS_PER_PAGE)
147
148 comments = pagination()
149
150 comment_form = user_forms.MediaCommentForm(request.form)
151
152 media_template_name = media.media_manager.display_template
153
154 context = {
155 'media': media,
156 'comments': comments,
157 'pagination': pagination,
158 'comment_form': comment_form,
159 'app_config': mg_globals.app_config}
160
161 # Since the media template name gets swapped out for each media
162 # type, normal context hooks don't work if you want to affect all
163 # media displays. This gives a general purpose hook.
164 context = hook_transform(
165 "media_home_context", context)
166
167 return render_to_response(
168 request,
169 media_template_name,
170 context)
171
172
173@get_media_entry_by_id
174@user_has_privilege(u'commenter')
175def media_post_comment(request, media):
176 """
177 recieves POST from a MediaEntry() comment form, saves the comment.
178 """
179 if not request.method == 'POST':
180 raise MethodNotAllowed()
181
182 # If media is not processed, return NotFound.
183 if not media.state == u'processed':
184 return render_404(request)
185
186 comment = request.db.TextComment()
187 comment.actor = request.user.id
188 comment.content = six.text_type(request.form['comment_content'])
189
190 # Show error message if commenting is disabled.
191 if not mg_globals.app_config['allow_comments']:
192 messages.add_message(
193 request,
194 messages.ERROR,
195 _("Sorry, comments are disabled."))
196 elif not comment.content.strip():
197 messages.add_message(
198 request,
199 messages.ERROR,
200 _("Oops, your comment was empty."))
201 else:
202 create_activity("post", comment, comment.actor, target=media)
203 add_comment_subscription(request.user, media)
204 comment.save()
205
206 link = request.db.Comment()
207 link.target = media
208 link.comment = comment
209 link.save()
210
211 messages.add_message(
212 request,
213 messages.SUCCESS,
214 _('Your comment has been posted!'))
215 trigger_notification(link, media, request)
216
217 return redirect_obj(request, media)
218
219
220
221def media_preview_comment(request):
222 """Runs a comment through markdown so it can be previewed."""
223 # If this isn't an ajax request, render_404
224 if not request.is_xhr:
225 return render_404(request)
226
227 comment = six.text_type(request.form['comment_content'])
228 cleancomment = { "content":cleaned_markdown_conversion(comment)}
229
230 return Response(json.dumps(cleancomment))
231
232@user_not_banned
233@get_media_entry_by_id
234@require_active_login
235def media_collect(request, media):
236 """Add media to collection submission"""
237
238 # If media is not processed, return NotFound.
239 if not media.state == u'processed':
240 return render_404(request)
241
242 form = user_forms.MediaCollectForm(request.form)
243 # A user's own collections:
244 form.collection.query = Collection.query.filter_by(
245 actor=request.user.id,
246 type=Collection.USER_DEFINED_TYPE
247 ).order_by(Collection.title)
248
249 if request.method != 'POST' or not form.validate():
250 # No POST submission, or invalid form
251 if not form.validate():
252 messages.add_message(
253 request,
254 messages.ERROR,
255 _('Please check your entries and try again.'))
256
257 return render_to_response(
258 request,
259 'mediagoblin/user_pages/media_collect.html',
260 {'media': media,
261 'form': form})
262
263 # If we are here, method=POST and the form is valid, submit things.
264 # If the user is adding a new collection, use that:
265 if form.collection_title.data:
266 # Make sure this user isn't duplicating an existing collection
267 existing_collection = Collection.query.filter_by(
268 actor=request.user.id,
269 title=form.collection_title.data,
270 type=Collection.USER_DEFINED_TYPE
271 ).first()
272 if existing_collection:
273 messages.add_message(
274 request,
275 messages.ERROR,
276 _('You already have a collection called "%s"!') %
277 existing_collection.title)
278 return redirect(request, "mediagoblin.user_pages.media_home",
279 user=media.get_actor.username,
280 media=media.slug_or_id)
281
282 collection = Collection()
283 collection.title = form.collection_title.data
284 collection.description = form.collection_description.data
285 collection.actor = request.user.id
286 collection.type = Collection.USER_DEFINED_TYPE
287 collection.generate_slug()
288 collection.get_public_id(request.urlgen)
289 create_activity("create", collection, collection.actor)
290 collection.save()
291
292 # Otherwise, use the collection selected from the drop-down
293 else:
294 collection = form.collection.data
295 if collection and collection.actor != request.user.id:
296 collection = None
297
298 # Make sure the user actually selected a collection
299 if not collection:
300 messages.add_message(
301 request,
302 messages.ERROR,
303 _('You have to select or add a collection'))
304 return redirect(request, "mediagoblin.user_pages.media_collect",
305 user=media.get_actor.username,
306 media_id=media.id)
307
308 item = CollectionItem.query.filter_by(collection=collection.id)
309 item = item.join(CollectionItem.object_helper).filter_by(
310 model_type=media.__tablename__,
311 obj_pk=media.id
312 ).first()
313
314 # Check whether media already exists in collection
315 if item is not None:
316 messages.add_message(
317 request,
318 messages.ERROR,
319 _('"%s" already in collection "%s"') %
320 (media.title, collection.title))
321 else: # Add item to collection
322 add_media_to_collection(collection, media, form.note.data)
323 create_activity("add", media, request.user, target=collection)
324 messages.add_message(
325 request,
326 messages.SUCCESS,
327 _('"%s" added to collection "%s"') %
328 (media.title, collection.title))
329
330 return redirect_obj(request, media)
331
332
333#TODO: Why does @user_may_delete_media not implicate @require_active_login?
334@get_media_entry_by_id
335@require_active_login
336@user_may_delete_media
337def media_confirm_delete(request, media):
338
339 form = user_forms.ConfirmDeleteForm(request.form)
340
341 if request.method == 'POST' and form.validate():
342 if form.confirm.data is True:
343 username = media.get_actor.username
344
345 # This probably is already filled but just in case it has slipped
346 # through the net somehow, we need to try and make sure the
347 # MediaEntry has a public ID so it gets properly soft-deleted.
348 media.get_public_id(request.urlgen)
349
350 # Decrement the users uploaded quota.
351 media.get_actor.uploaded = media.get_actor.uploaded - \
352 media.file_size
353 media.get_actor.save()
354
355 # Delete MediaEntry and all related files, comments etc.
356 media.delete()
357 messages.add_message(
358 request,
359 messages.SUCCESS,
360 _('You deleted the media.'))
361
362 location = media.url_to_next(request.urlgen)
363 if not location:
364 location=media.url_to_prev(request.urlgen)
365 if not location:
366 location=request.urlgen("mediagoblin.user_pages.user_home",
367 user=username)
368 return redirect(request, location=location)
369 else:
370 messages.add_message(
371 request,
372 messages.ERROR,
373 _("The media was not deleted because you didn't check "
374 "that you were sure."))
375 return redirect_obj(request, media)
376
377 if ((request.user.has_privilege(u'admin') and
378 request.user.id != media.actor)):
379 messages.add_message(
380 request,
381 messages.WARNING,
382 _("You are about to delete another user's media. "
383 "Proceed with caution."))
384
385 return render_to_response(
386 request,
387 'mediagoblin/user_pages/media_confirm_delete.html',
388 {'media': media,
389 'form': form})
390
391@user_not_banned
392@active_user_from_url
393@uses_pagination
394def user_collection(request, page, url_user=None):
395 """A User-defined Collection"""
396 collection = Collection.query.filter_by(
397 get_actor=url_user,
398 slug=request.matchdict['collection']).first()
399
400 if not collection:
401 return render_404(request)
402
403 cursor = collection.get_collection_items()
404
405 pagination = Pagination(page, cursor)
406 collection_items = pagination()
407
408 # if no data is available, return NotFound
409 # TODO: Should an empty collection really also return 404?
410 if collection_items == None:
411 return render_404(request)
412
413 return render_to_response(
414 request,
415 'mediagoblin/user_pages/collection.html',
416 {'user': url_user,
417 'collection': collection,
418 'collection_items': collection_items,
419 'pagination': pagination})
420
421@user_not_banned
422@active_user_from_url
423def collection_list(request, url_user=None):
424 """A User-defined Collection"""
425 collections = Collection.query.filter_by(
426 get_actor=url_user)
427
428 return render_to_response(
429 request,
430 'mediagoblin/user_pages/collection_list.html',
431 {'user': url_user,
432 'collections': collections})
433
434
435@get_user_collection_item
436@require_active_login
437@user_may_alter_collection
438def collection_item_confirm_remove(request, collection_item):
439
440 form = user_forms.ConfirmCollectionItemRemoveForm(request.form)
441
442 if request.method == 'POST' and form.validate():
443 username = collection_item.in_collection.get_actor.username
444 collection = collection_item.in_collection
445
446 if form.confirm.data is True:
447 obj = collection_item.get_object()
448 obj.save()
449
450 collection_item.delete()
451 collection.num_items = collection.num_items - 1
452 collection.save()
453
454 messages.add_message(
455 request,
456 messages.SUCCESS,
457 _('You deleted the item from the collection.'))
458 else:
459 messages.add_message(
460 request,
461 messages.ERROR,
462 _("The item was not removed because you didn't check "
463 "that you were sure."))
464
465 return redirect_obj(request, collection)
466
467 if ((request.user.has_privilege(u'admin') and
468 request.user.id != collection_item.in_collection.actor)):
469 messages.add_message(
470 request,
471 messages.WARNING,
472 _("You are about to delete an item from another user's collection. "
473 "Proceed with caution."))
474
475 return render_to_response(
476 request,
477 'mediagoblin/user_pages/collection_item_confirm_remove.html',
478 {'collection_item': collection_item,
479 'form': form})
480
481
482@get_user_collection
483@require_active_login
484@user_may_alter_collection
485def collection_confirm_delete(request, collection):
486
487 form = user_forms.ConfirmDeleteForm(request.form)
488
489 if request.method == 'POST' and form.validate():
490
491 username = collection.get_actor.username
492
493 if form.confirm.data is True:
494 collection_title = collection.title
495
496 # Firstly like with the MediaEntry delete, lets ensure the
497 # public_id is populated as this is really important!
498 collection.get_public_id(request.urlgen)
499
500 # Delete all the associated collection items
501 for item in collection.get_collection_items():
502 obj = item.get_object()
503 obj.save()
504 item.delete()
505
506 collection.delete()
507 messages.add_message(
508 request,
509 messages.SUCCESS,
510 _('You deleted the collection "%s"') %
511 collection_title)
512
513 return redirect(request, "mediagoblin.user_pages.user_home",
514 user=username)
515 else:
516 messages.add_message(
517 request,
518 messages.ERROR,
519 _("The collection was not deleted because you didn't "
520 "check that you were sure."))
521
522 return redirect_obj(request, collection)
523
524 if ((request.user.has_privilege(u'admin') and
525 request.user.id != collection.actor)):
526 messages.add_message(
527 request, messages.WARNING,
528 _("You are about to delete another user's collection. "
529 "Proceed with caution."))
530
531 return render_to_response(
532 request,
533 'mediagoblin/user_pages/collection_confirm_delete.html',
534 {'collection': collection,
535 'form': form})
536
537
538ATOM_DEFAULT_NR_OF_UPDATED_ITEMS = 15
539
540
541def atom_feed(request):
542 """
543 generates the atom feed with the newest images
544 """
545 user = LocalUser.query.filter_by(
546 username = request.matchdict['user']).first()
547 if not user or not user.has_privilege(u'active'):
548 return render_404(request)
549 feed_title = "MediaGoblin Feed for user '%s'" % request.matchdict['user']
550 link = request.urlgen('mediagoblin.user_pages.user_home',
551 qualified=True, user=request.matchdict['user'])
552 cursor = MediaEntry.query.filter_by(actor=user.id, state=u'processed')
553 cursor = cursor.order_by(MediaEntry.created.desc())
554 cursor = cursor.limit(ATOM_DEFAULT_NR_OF_UPDATED_ITEMS)
555
556
557 """
558 ATOM feed id is a tag URI (see http://en.wikipedia.org/wiki/Tag_URI)
559 """
560 atomlinks = [{
561 'href': link,
562 'rel': 'alternate',
563 'type': 'text/html'}]
564
565 if mg_globals.app_config["push_urls"]:
566 for push_url in mg_globals.app_config["push_urls"]:
567 atomlinks.append({
568 'rel': 'hub',
569 'href': push_url})
570
571 feed = AtomFeed(
572 feed_title,
573 feed_url=request.url,
574 id='tag:{host},{year}:gallery.user-{user}'.format(
575 host=request.host,
576 year=datetime.datetime.today().strftime('%Y'),
577 user=request.matchdict['user']),
578 links=atomlinks)
579
580 for entry in cursor:
581 # Include a thumbnail image in content.
582 file_urls = get_media_file_paths(entry.media_files, request.urlgen)
583 if 'thumb' in file_urls:
584 content = u'<img src="{thumb}" alt='' /> {desc}'.format(
585 thumb=file_urls['thumb'], desc=entry.description_html)
586 else:
587 content = entry.description_html
588
589 feed.add(
590 entry.get('title'),
591 content,
592 id=entry.url_for_self(request.urlgen, qualified=True),
593 content_type='html',
594 author={
595 'name': entry.get_actor.username,
596 'uri': request.urlgen(
597 'mediagoblin.user_pages.user_home',
598 qualified=True,
599 user=entry.get_actor.username)},
600 updated=entry.get('created'),
601 links=[{
602 'href': entry.url_for_self(
603 request.urlgen,
604 qualified=True),
605 'rel': 'alternate',
606 'type': 'text/html'}])
607
608 return feed.get_response()
609
610
611def collection_atom_feed(request):
612 """
613 generates the atom feed with the newest images from a collection
614 """
615 user = LocalUser.query.filter_by(
616 username = request.matchdict['user']).first()
617 if not user or not user.has_privilege(u'active'):
618 return render_404(request)
619
620 collection = Collection.query.filter_by(
621 actor=user.id,
622 slug=request.matchdict['collection']).first()
623 if not collection:
624 return render_404(request)
625
626 cursor = CollectionItem.query.filter_by(
627 collection=collection.id) \
628 .order_by(CollectionItem.added.desc()) \
629 .limit(ATOM_DEFAULT_NR_OF_UPDATED_ITEMS)
630
631 """
632 ATOM feed id is a tag URI (see http://en.wikipedia.org/wiki/Tag_URI)
633 """
634 atomlinks = [{
635 'href': collection.url_for_self(request.urlgen, qualified=True),
636 'rel': 'alternate',
637 'type': 'text/html'
638 }]
639
640 if mg_globals.app_config["push_urls"]:
641 for push_url in mg_globals.app_config["push_urls"]:
642 atomlinks.append({
643 'rel': 'hub',
644 'href': push_url})
645
646 feed = AtomFeed(
647 "MediaGoblin: Feed for %s's collection %s" %
648 (request.matchdict['user'], collection.title),
649 feed_url=request.url,
650 id=u'tag:{host},{year}:gnu-mediagoblin.{user}.collection.{slug}'\
651 .format(
652 host=request.host,
653 year=collection.created.strftime('%Y'),
654 user=request.matchdict['user'],
655 slug=collection.slug),
656 links=atomlinks)
657
658 for item in cursor:
659 obj = item.get_object()
660 feed.add(
661 obj.get('title'),
662 item.note_html,
663 id=obj.url_for_self(request.urlgen, qualified=True),
664 content_type='html',
665 author={
666 'name': obj.get_actor.username,
667 'uri': request.urlgen(
668 'mediagoblin.user_pages.user_home',
669 qualified=True, user=obj.get_actor.username)},
670 updated=item.get('added'),
671 links=[{
672 'href': obj.url_for_self(
673 request.urlgen,
674 qualified=True),
675 'rel': 'alternate',
676 'type': 'text/html'}])
677
678 return feed.get_response()
679
680@active_user_from_url
681@uses_pagination
682@require_active_login
683def processing_panel(request, page, url_user):
684 """
685 Show to the user what media is still in conversion/processing...
686 and what failed, and why!
687 """
688 user = LocalUser.query.filter_by(username=request.matchdict['user']).first()
689 # TODO: XXX: Should this be a decorator?
690 #
691 # Make sure we have permission to access this user's panel. Only
692 # admins and this user herself should be able to do so.
693 if not (user.id == request.user.id or request.user.has_privilege(u'admin')):
694 # No? Simply redirect to this user's homepage.
695 return redirect(
696 request, 'mediagoblin.user_pages.user_home',
697 user=user.username)
698 # Get media entries which are in-processing
699 entries = (MediaEntry.query.filter_by(actor=user.id)
700 .order_by(MediaEntry.created.desc()))
701
702 try:
703 state = request.matchdict['state']
704 # no exception was thrown, filter entries by state
705 entries = entries.filter_by(state=state)
706 except KeyError:
707 # show all entries
708 pass
709
710 pagination = Pagination(page, entries)
711 pagination.per_page = 30
712 entries_on_a_page = pagination()
713
714 # Render to response
715 return render_to_response(
716 request,
717 'mediagoblin/user_pages/processing_panel.html',
718 {'user': user,
719 'entries': entries_on_a_page,
720 'pagination': pagination})
721
722@allow_reporting
723@get_user_media_entry
724@user_has_privilege(u'reporter')
725@get_optional_media_comment_by_id
726def file_a_report(request, media, comment):
727 """
728 This view handles the filing of a Report.
729 """
730 if comment is not None:
731 if not comment.target().id == media.id:
732 return render_404(request)
733
734 form = user_forms.CommentReportForm(request.form)
735 context = {'media': comment.target(),
736 'comment':comment,
737 'form':form}
738 else:
739 form = user_forms.MediaReportForm(request.form)
740 context = {'media': media,
741 'form':form}
742 form.reporter_id.data = request.user.id
743
744
745 if request.method == "POST":
746 report_object = build_report_object(
747 form,
748 media_entry=media,
749 comment=comment
750 )
751
752 # if the object was built successfully, report_table will not be None
753 if report_object:
754 report_object.save()
755 return redirect(
756 request,
757 'index')
758
759
760 return render_to_response(
761 request,
762 'mediagoblin/user_pages/report.html',
763 context)
764
765@require_active_login
766def activity_view(request):
767 """ /<username>/activity/<id> - Display activity
768
769 This should display a HTML presentation of the activity
770 this is NOT an API endpoint.
771 """
772 # Get the user object.
773 username = request.matchdict["username"]
774 user = LocalUser.query.filter_by(username=username).first()
775
776 activity_id = request.matchdict["id"]
777
778 if request.user is None:
779 return render_404(request)
780
781 activity = Activity.query.filter_by(
782 id=activity_id,
783 author=user.id
784 ).first()
785
786 # There isn't many places to check that the public_id is filled so this
787 # will do, it really should be, lets try and fix that if it isn't.
788 activity.get_public_id(request.urlgen)
789
790 if activity is None:
791 return render_404(request)
792
793 return render_to_response(
794 request,
795 "mediagoblin/api/activity.html",
796 {"activity": activity}
797 )