Merge branch 'comments'
[mediagoblin.git] / mediagoblin / user_pages / views.py
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
17 import logging
18 import datetime
19 import json
20
21 import six
22
23 from mediagoblin import messages, mg_globals
24 from mediagoblin.db.models import (MediaEntry, MediaTag, Collection, Comment,
25 CollectionItem, LocalUser, Activity, \
26 GenericModelReference)
27 from mediagoblin.tools.response import render_to_response, render_404, \
28 redirect, redirect_obj
29 from mediagoblin.tools.text import cleaned_markdown_conversion
30 from mediagoblin.tools.translate import pass_to_ugettext as _
31 from mediagoblin.tools.pagination import Pagination
32 from mediagoblin.tools.federation import create_activity
33 from mediagoblin.user_pages import forms as user_forms
34 from mediagoblin.user_pages.lib import (send_comment_email,
35 add_media_to_collection, build_report_object)
36 from mediagoblin.notifications import trigger_notification, \
37 add_comment_subscription, mark_comment_notification_seen
38 from mediagoblin.tools.pluginapi import hook_transform
39
40 from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
41 get_media_entry_by_id, user_has_privilege, user_not_banned,
42 require_active_login, user_may_delete_media, user_may_alter_collection,
43 get_user_collection, get_user_collection_item, active_user_from_url,
44 get_optional_media_comment_by_id, allow_reporting)
45
46 from werkzeug.contrib.atom import AtomFeed
47 from werkzeug.exceptions import MethodNotAllowed
48 from werkzeug.wrappers import Response
49
50
51 _log = logging.getLogger(__name__)
52 _log.setLevel(logging.DEBUG)
53
54 @user_not_banned
55 @uses_pagination
56 def user_home(request, page):
57 """'Homepage' of a LocalUser()"""
58 user = LocalUser.query.filter_by(username=request.matchdict['user']).first()
59 if not user:
60 return render_404(request)
61 elif not user.has_privilege(u'active'):
62 return render_to_response(
63 request,
64 'mediagoblin/user_pages/user_nonactive.html',
65 {'user': user})
66
67 cursor = MediaEntry.query.\
68 filter_by(actor = user.id,
69 state = u'processed').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
93 def 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
123 MEDIA_COMMENTS_PER_PAGE = 50
124
125 @user_not_banned
126 @get_user_media_entry
127 @uses_pagination
128 def 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')
175 def 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 comment = request.db.TextComment()
183 comment.actor = request.user.id
184 comment.content = six.text_type(request.form['comment_content'])
185
186 # Show error message if commenting is disabled.
187 if not mg_globals.app_config['allow_comments']:
188 messages.add_message(
189 request,
190 messages.ERROR,
191 _("Sorry, comments are disabled."))
192 elif not comment.content.strip():
193 messages.add_message(
194 request,
195 messages.ERROR,
196 _("Oops, your comment was empty."))
197 else:
198 create_activity("post", comment, comment.actor, target=media)
199 add_comment_subscription(request.user, media)
200 comment.save()
201
202 link = request.db.Comment()
203 link.target = media
204 link.comment = comment
205 link.save()
206
207 messages.add_message(
208 request, messages.SUCCESS,
209 _('Your comment has been posted!'))
210 trigger_notification(comment, media, request)
211
212 return redirect_obj(request, media)
213
214
215
216 def media_preview_comment(request):
217 """Runs a comment through markdown so it can be previewed."""
218 # If this isn't an ajax request, render_404
219 if not request.is_xhr:
220 return render_404(request)
221
222 comment = six.text_type(request.form['comment_content'])
223 cleancomment = { "content":cleaned_markdown_conversion(comment)}
224
225 return Response(json.dumps(cleancomment))
226
227 @user_not_banned
228 @get_media_entry_by_id
229 @require_active_login
230 def media_collect(request, media):
231 """Add media to collection submission"""
232
233 form = user_forms.MediaCollectForm(request.form)
234 # A user's own collections:
235 form.collection.query = Collection.query.filter_by(
236 actor=request.user.id,
237 type=Collection.USER_DEFINED_TYPE
238 ).order_by(Collection.title)
239
240 if request.method != 'POST' or not form.validate():
241 # No POST submission, or invalid form
242 if not form.validate():
243 messages.add_message(request, messages.ERROR,
244 _('Please check your entries and try again.'))
245
246 return render_to_response(
247 request,
248 'mediagoblin/user_pages/media_collect.html',
249 {'media': media,
250 'form': form})
251
252 # If we are here, method=POST and the form is valid, submit things.
253 # If the user is adding a new collection, use that:
254 if form.collection_title.data:
255 # Make sure this user isn't duplicating an existing collection
256 existing_collection = Collection.query.filter_by(
257 actor=request.user.id,
258 title=form.collection_title.data,
259 type=Collection.USER_DEFINED_TYPE
260 ).first()
261 if existing_collection:
262 messages.add_message(request, messages.ERROR,
263 _('You already have a collection called "%s"!')
264 % existing_collection.title)
265 return redirect(request, "mediagoblin.user_pages.media_home",
266 user=media.get_actor.username,
267 media=media.slug_or_id)
268
269 collection = Collection()
270 collection.title = form.collection_title.data
271 collection.description = form.collection_description.data
272 collection.actor = request.user.id
273 collection.type = Collection.USER_DEFINED_TYPE
274 collection.generate_slug()
275 collection.get_public_id(request.urlgen)
276 create_activity("create", collection, collection.actor)
277 collection.save()
278
279 # Otherwise, use the collection selected from the drop-down
280 else:
281 collection = form.collection.data
282 if collection and collection.actor != request.user.id:
283 collection = None
284
285 # Make sure the user actually selected a collection
286 item = CollectionItem.query.filter_by(collection=collection.id)
287 item = item.join(CollectionItem.object_helper).filter_by(
288 model_type=media.__tablename__,
289 obj_pk=media.id
290 ).first()
291
292 if not collection:
293 messages.add_message(
294 request, messages.ERROR,
295 _('You have to select or add a collection'))
296 return redirect(request, "mediagoblin.user_pages.media_collect",
297 user=media.get_actor.username,
298 media_id=media.id)
299
300 # Check whether media already exists in collection
301 elif item is not None:
302 messages.add_message(request, messages.ERROR,
303 _('"%s" already in collection "%s"')
304 % (media.title, collection.title))
305 else: # Add item to collection
306 add_media_to_collection(collection, media, form.note.data)
307 create_activity("add", media, request.user, target=collection)
308 messages.add_message(request, messages.SUCCESS,
309 _('"%s" added to collection "%s"')
310 % (media.title, collection.title))
311
312 return redirect_obj(request, media)
313
314
315 #TODO: Why does @user_may_delete_media not implicate @require_active_login?
316 @get_media_entry_by_id
317 @require_active_login
318 @user_may_delete_media
319 def media_confirm_delete(request, media):
320
321 form = user_forms.ConfirmDeleteForm(request.form)
322
323 if request.method == 'POST' and form.validate():
324 if form.confirm.data is True:
325 username = media.get_actor.username
326
327 # This probably is already filled but just in case it has slipped
328 # through the net somehow, we need to try and make sure the
329 # MediaEntry has a public ID so it gets properly soft-deleted.
330 media.get_public_id(request.urlgen)
331
332 # Decrement the users uploaded quota.
333 media.get_actor.uploaded = media.get_actor.uploaded - \
334 media.file_size
335 media.get_actor.save()
336
337 # Delete MediaEntry and all related files, comments etc.
338 media.delete()
339 messages.add_message(
340 request, messages.SUCCESS, _('You deleted the media.'))
341
342 location = media.url_to_next(request.urlgen)
343 if not location:
344 location=media.url_to_prev(request.urlgen)
345 if not location:
346 location=request.urlgen("mediagoblin.user_pages.user_home",
347 user=username)
348 return redirect(request, location=location)
349 else:
350 messages.add_message(
351 request, messages.ERROR,
352 _("The media was not deleted because you didn't check that you were sure."))
353 return redirect_obj(request, media)
354
355 if ((request.user.has_privilege(u'admin') and
356 request.user.id != media.actor)):
357 messages.add_message(
358 request, messages.WARNING,
359 _("You are about to delete another user's media. "
360 "Proceed with caution."))
361
362 return render_to_response(
363 request,
364 'mediagoblin/user_pages/media_confirm_delete.html',
365 {'media': media,
366 'form': form})
367
368 @user_not_banned
369 @active_user_from_url
370 @uses_pagination
371 def user_collection(request, page, url_user=None):
372 """A User-defined Collection"""
373 collection = Collection.query.filter_by(
374 get_actor=url_user,
375 slug=request.matchdict['collection']).first()
376
377 if not collection:
378 return render_404(request)
379
380 cursor = collection.get_collection_items()
381
382 pagination = Pagination(page, cursor)
383 collection_items = pagination()
384
385 # if no data is available, return NotFound
386 # TODO: Should an empty collection really also return 404?
387 if collection_items == None:
388 return render_404(request)
389
390 return render_to_response(
391 request,
392 'mediagoblin/user_pages/collection.html',
393 {'user': url_user,
394 'collection': collection,
395 'collection_items': collection_items,
396 'pagination': pagination})
397
398 @user_not_banned
399 @active_user_from_url
400 def collection_list(request, url_user=None):
401 """A User-defined Collection"""
402 collections = Collection.query.filter_by(
403 get_actor=url_user)
404
405 return render_to_response(
406 request,
407 'mediagoblin/user_pages/collection_list.html',
408 {'user': url_user,
409 'collections': collections})
410
411
412 @get_user_collection_item
413 @require_active_login
414 @user_may_alter_collection
415 def collection_item_confirm_remove(request, collection_item):
416
417 form = user_forms.ConfirmCollectionItemRemoveForm(request.form)
418
419 if request.method == 'POST' and form.validate():
420 username = collection_item.in_collection.get_actor.username
421 collection = collection_item.in_collection
422
423 if form.confirm.data is True:
424 obj = collection_item.get_object()
425 obj.save()
426
427 collection_item.delete()
428 collection.num_items = collection.num_items - 1
429 collection.save()
430
431 messages.add_message(
432 request, messages.SUCCESS, _('You deleted the item from the collection.'))
433 else:
434 messages.add_message(
435 request, messages.ERROR,
436 _("The item was not removed because you didn't check that you were sure."))
437
438 return redirect_obj(request, collection)
439
440 if ((request.user.has_privilege(u'admin') and
441 request.user.id != collection_item.in_collection.actor)):
442 messages.add_message(
443 request, messages.WARNING,
444 _("You are about to delete an item from another user's collection. "
445 "Proceed with caution."))
446
447 return render_to_response(
448 request,
449 'mediagoblin/user_pages/collection_item_confirm_remove.html',
450 {'collection_item': collection_item,
451 'form': form})
452
453
454 @get_user_collection
455 @require_active_login
456 @user_may_alter_collection
457 def collection_confirm_delete(request, collection):
458
459 form = user_forms.ConfirmDeleteForm(request.form)
460
461 if request.method == 'POST' and form.validate():
462
463 username = collection.get_actor.username
464
465 if form.confirm.data is True:
466 collection_title = collection.title
467
468 # Firstly like with the MediaEntry delete, lets ensure the
469 # public_id is populated as this is really important!
470 collection.get_public_id(request.urlgen)
471
472 # Delete all the associated collection items
473 for item in collection.get_collection_items():
474 obj = item.get_object()
475 obj.save()
476 item.delete()
477
478 collection.delete()
479 messages.add_message(request, messages.SUCCESS,
480 _('You deleted the collection "%s"') % collection_title)
481
482 return redirect(request, "mediagoblin.user_pages.user_home",
483 user=username)
484 else:
485 messages.add_message(
486 request, messages.ERROR,
487 _("The collection was not deleted because you didn't check that you were sure."))
488
489 return redirect_obj(request, collection)
490
491 if ((request.user.has_privilege(u'admin') and
492 request.user.id != collection.actor)):
493 messages.add_message(
494 request, messages.WARNING,
495 _("You are about to delete another user's collection. "
496 "Proceed with caution."))
497
498 return render_to_response(
499 request,
500 'mediagoblin/user_pages/collection_confirm_delete.html',
501 {'collection': collection,
502 'form': form})
503
504
505 ATOM_DEFAULT_NR_OF_UPDATED_ITEMS = 15
506
507
508 def atom_feed(request):
509 """
510 generates the atom feed with the newest images
511 """
512 user = LocalUser.query.filter_by(
513 username = request.matchdict['user']).first()
514 if not user or not user.has_privilege(u'active'):
515 return render_404(request)
516
517 cursor = MediaEntry.query.filter_by(
518 actor = user.id,
519 state = u'processed').\
520 order_by(MediaEntry.created.desc()).\
521 limit(ATOM_DEFAULT_NR_OF_UPDATED_ITEMS)
522
523 """
524 ATOM feed id is a tag URI (see http://en.wikipedia.org/wiki/Tag_URI)
525 """
526 atomlinks = [{
527 'href': request.urlgen(
528 'mediagoblin.user_pages.user_home',
529 qualified=True, user=request.matchdict['user']),
530 'rel': 'alternate',
531 'type': 'text/html'
532 }]
533
534 if mg_globals.app_config["push_urls"]:
535 for push_url in mg_globals.app_config["push_urls"]:
536 atomlinks.append({
537 'rel': 'hub',
538 'href': push_url})
539
540 feed = AtomFeed(
541 "MediaGoblin: Feed for user '%s'" % request.matchdict['user'],
542 feed_url=request.url,
543 id='tag:{host},{year}:gallery.user-{user}'.format(
544 host=request.host,
545 year=datetime.datetime.today().strftime('%Y'),
546 user=request.matchdict['user']),
547 links=atomlinks)
548
549 for entry in cursor:
550 feed.add(
551 entry.get('title'),
552 entry.description_html,
553 id=entry.url_for_self(request.urlgen, qualified=True),
554 content_type='html',
555 author={
556 'name': entry.get_actor.username,
557 'uri': request.urlgen(
558 'mediagoblin.user_pages.user_home',
559 qualified=True, user=entry.get_actor.username)},
560 updated=entry.get('created'),
561 links=[{
562 'href': entry.url_for_self(
563 request.urlgen,
564 qualified=True),
565 'rel': 'alternate',
566 'type': 'text/html'}])
567
568 return feed.get_response()
569
570
571 def collection_atom_feed(request):
572 """
573 generates the atom feed with the newest images from a collection
574 """
575 user = LocalUser.query.filter_by(
576 username = request.matchdict['user']).first()
577 if not user or not user.has_privilege(u'active'):
578 return render_404(request)
579
580 collection = Collection.query.filter_by(
581 actor=user.id,
582 slug=request.matchdict['collection']).first()
583 if not collection:
584 return render_404(request)
585
586 cursor = CollectionItem.query.filter_by(
587 collection=collection.id) \
588 .order_by(CollectionItem.added.desc()) \
589 .limit(ATOM_DEFAULT_NR_OF_UPDATED_ITEMS)
590
591 """
592 ATOM feed id is a tag URI (see http://en.wikipedia.org/wiki/Tag_URI)
593 """
594 atomlinks = [{
595 'href': collection.url_for_self(request.urlgen, qualified=True),
596 'rel': 'alternate',
597 'type': 'text/html'
598 }]
599
600 if mg_globals.app_config["push_urls"]:
601 for push_url in mg_globals.app_config["push_urls"]:
602 atomlinks.append({
603 'rel': 'hub',
604 'href': push_url})
605
606 feed = AtomFeed(
607 "MediaGoblin: Feed for %s's collection %s" %
608 (request.matchdict['user'], collection.title),
609 feed_url=request.url,
610 id=u'tag:{host},{year}:gnu-mediagoblin.{user}.collection.{slug}'\
611 .format(
612 host=request.host,
613 year=collection.created.strftime('%Y'),
614 user=request.matchdict['user'],
615 slug=collection.slug),
616 links=atomlinks)
617
618 for item in cursor:
619 obj = item.get_object()
620 feed.add(
621 obj.get('title'),
622 item.note_html,
623 id=obj.url_for_self(request.urlgen, qualified=True),
624 content_type='html',
625 author={
626 'name': obj.get_actor().username,
627 'uri': request.urlgen(
628 'mediagoblin.user_pages.user_home',
629 qualified=True, user=obj.get_actor().username)},
630 updated=item.get('added'),
631 links=[{
632 'href': obj.url_for_self(
633 request.urlgen,
634 qualified=True),
635 'rel': 'alternate',
636 'type': 'text/html'}])
637
638 return feed.get_response()
639
640 @require_active_login
641 def processing_panel(request):
642 """
643 Show to the user what media is still in conversion/processing...
644 and what failed, and why!
645 """
646 user = LocalUser.query.filter_by(username=request.matchdict['user']).first()
647 # TODO: XXX: Should this be a decorator?
648 #
649 # Make sure we have permission to access this user's panel. Only
650 # admins and this user herself should be able to do so.
651 if not (user.id == request.user.id or request.user.has_privilege(u'admin')):
652 # No? Simply redirect to this user's homepage.
653 return redirect(
654 request, 'mediagoblin.user_pages.user_home',
655 user=user.username)
656
657 # Get media entries which are in-processing
658 processing_entries = MediaEntry.query.\
659 filter_by(actor = user.id,
660 state = u'processing').\
661 order_by(MediaEntry.created.desc())
662
663 # Get media entries which have failed to process
664 failed_entries = MediaEntry.query.\
665 filter_by(actor = user.id,
666 state = u'failed').\
667 order_by(MediaEntry.created.desc())
668
669 processed_entries = MediaEntry.query.\
670 filter_by(actor = user.id,
671 state = u'processed').\
672 order_by(MediaEntry.created.desc()).\
673 limit(10)
674
675 # Render to response
676 return render_to_response(
677 request,
678 'mediagoblin/user_pages/processing_panel.html',
679 {'user': user,
680 'processing_entries': processing_entries,
681 'failed_entries': failed_entries,
682 'processed_entries': processed_entries})
683
684 @allow_reporting
685 @get_user_media_entry
686 @user_has_privilege(u'reporter')
687 @get_optional_media_comment_by_id
688 def file_a_report(request, media, comment):
689 """
690 This view handles the filing of a Report.
691 """
692 if comment is not None:
693 if not comment.target().id == media.id:
694 return render_404(request)
695
696 form = user_forms.CommentReportForm(request.form)
697 context = {'media': comment.target(),
698 'comment':comment.comment(),
699 'form':form}
700 else:
701 form = user_forms.MediaReportForm(request.form)
702 context = {'media': media,
703 'form':form}
704 form.reporter_id.data = request.user.id
705
706
707 if request.method == "POST":
708 report_object = build_report_object(
709 form,
710 media_entry=media,
711 comment=comment
712 )
713
714 # if the object was built successfully, report_table will not be None
715 if report_object:
716 report_object.save()
717 return redirect(
718 request,
719 'index')
720
721
722 return render_to_response(
723 request,
724 'mediagoblin/user_pages/report.html',
725 context)
726
727 @require_active_login
728 def activity_view(request):
729 """ /<username>/activity/<id> - Display activity
730
731 This should display a HTML presentation of the activity
732 this is NOT an API endpoint.
733 """
734 # Get the user object.
735 username = request.matchdict["username"]
736 user = LocalUser.query.filter_by(username=username).first()
737
738 activity_id = request.matchdict["id"]
739
740 if request.user is None:
741 return render_404(request)
742
743 activity = Activity.query.filter_by(
744 id=activity_id,
745 author=user.id
746 ).first()
747
748 # There isn't many places to check that the public_id is filled so this
749 # will do, it really should be, lets try and fix that if it isn't.
750 activity.get_public_id(request.urlgen)
751
752 if activity is None:
753 return render_404(request)
754
755 return render_to_response(
756 request,
757 "mediagoblin/api/activity.html",
758 {"activity": activity}
759 )