patch.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. from rest_framework import serializers
  2. from rest_framework.response import Response
  3. from django.contrib.auth import get_user_model
  4. from django.core.exceptions import PermissionDenied, ValidationError
  5. from django.http import Http404
  6. from django.shortcuts import get_object_or_404
  7. from django.utils.translation import ugettext as _
  8. from misago.acl import add_acl
  9. from misago.categories.models import Category
  10. from misago.categories.permissions import allow_browse_category, allow_see_category
  11. from misago.categories.serializers import CategorySerializer
  12. from misago.conf import settings
  13. from misago.core.apipatch import ApiPatch
  14. from misago.core.shortcuts import get_int_or_404
  15. from misago.threads.moderation import threads as moderation
  16. from misago.threads.participants import (
  17. add_participant, change_owner, make_participants_aware, remove_participant
  18. )
  19. from misago.threads.permissions import (
  20. allow_add_participant, allow_add_participants, allow_approve_thread, allow_change_best_answer,
  21. allow_change_owner, allow_edit_thread, allow_pin_thread, allow_hide_thread, allow_mark_as_best_answer,
  22. allow_mark_best_answer, allow_move_thread, allow_remove_participant, allow_see_post,
  23. allow_start_thread, allow_unhide_thread, allow_unmark_best_answer
  24. )
  25. from misago.threads.serializers import ThreadParticipantSerializer
  26. from misago.threads.validators import validate_title
  27. PATCH_LIMIT = settings.MISAGO_THREADS_PER_PAGE + settings.MISAGO_THREADS_TAIL
  28. UserModel = get_user_model()
  29. thread_patch_dispatcher = ApiPatch()
  30. def patch_acl(request, thread, value):
  31. """useful little op that updates thread acl to current state"""
  32. if value:
  33. add_acl(request.user, thread)
  34. return {'acl': thread.acl}
  35. else:
  36. return {'acl': None}
  37. thread_patch_dispatcher.add('acl', patch_acl)
  38. def patch_title(request, thread, value):
  39. try:
  40. value_cleaned = str(value).strip()
  41. except (TypeError, ValueError):
  42. raise PermissionDenied(_('Not a valid string.'))
  43. try:
  44. validate_title(value_cleaned)
  45. except ValidationError as e:
  46. raise PermissionDenied(e.args[0])
  47. allow_edit_thread(request.user, thread)
  48. moderation.change_thread_title(request, thread, value_cleaned)
  49. return {'title': thread.title}
  50. thread_patch_dispatcher.replace('title', patch_title)
  51. def patch_weight(request, thread, value):
  52. allow_pin_thread(request.user, thread)
  53. if not thread.acl.get('can_pin_globally') and thread.weight == 2:
  54. raise PermissionDenied(_("You can't change globally pinned threads weights in this category."))
  55. if value == 2:
  56. if thread.acl.get('can_pin_globally'):
  57. moderation.pin_thread_globally(request, thread)
  58. else:
  59. raise PermissionDenied(_("You can't pin threads globally in this category."))
  60. elif value == 1:
  61. moderation.pin_thread_locally(request, thread)
  62. elif value == 0:
  63. moderation.unpin_thread(request, thread)
  64. return {'weight': thread.weight}
  65. thread_patch_dispatcher.replace('weight', patch_weight)
  66. def patch_move(request, thread, value):
  67. allow_move_thread(request.user, thread)
  68. category_pk = get_int_or_404(value)
  69. new_category = get_object_or_404(
  70. Category.objects.all_categories().select_related('parent'), pk=category_pk
  71. )
  72. add_acl(request.user, new_category)
  73. allow_see_category(request.user, new_category)
  74. allow_browse_category(request.user, new_category)
  75. allow_start_thread(request.user, new_category)
  76. if new_category == thread.category:
  77. raise PermissionDenied(_("You can't move thread to the category it's already in."))
  78. moderation.move_thread(request, thread, new_category)
  79. return {'category': CategorySerializer(new_category).data}
  80. thread_patch_dispatcher.replace('category', patch_move)
  81. def patch_flatten_categories(request, thread, value):
  82. try:
  83. return {'category': thread.category_id}
  84. except AttributeError:
  85. return {'category': thread.category_id}
  86. thread_patch_dispatcher.replace('flatten-categories', patch_flatten_categories)
  87. def patch_is_unapproved(request, thread, value):
  88. allow_approve_thread(request.user, thread)
  89. if value:
  90. raise PermissionDenied(_("Content approval can't be reversed."))
  91. moderation.approve_thread(request, thread)
  92. return {
  93. 'is_unapproved': thread.is_unapproved,
  94. 'has_unapproved_posts': thread.has_unapproved_posts,
  95. }
  96. thread_patch_dispatcher.replace('is-unapproved', patch_is_unapproved)
  97. def patch_is_closed(request, thread, value):
  98. if thread.acl.get('can_close'):
  99. if value:
  100. moderation.close_thread(request, thread)
  101. else:
  102. moderation.open_thread(request, thread)
  103. return {'is_closed': thread.is_closed}
  104. else:
  105. if value:
  106. raise PermissionDenied(_("You don't have permission to close this thread."))
  107. else:
  108. raise PermissionDenied(_("You don't have permission to open this thread."))
  109. thread_patch_dispatcher.replace('is-closed', patch_is_closed)
  110. def patch_is_hidden(request, thread, value):
  111. if value:
  112. allow_hide_thread(request.user, thread)
  113. moderation.hide_thread(request, thread)
  114. else:
  115. allow_unhide_thread(request.user, thread)
  116. moderation.unhide_thread(request, thread)
  117. return {'is_hidden': thread.is_hidden}
  118. thread_patch_dispatcher.replace('is-hidden', patch_is_hidden)
  119. def patch_subscription(request, thread, value):
  120. request.user.subscription_set.filter(thread=thread).delete()
  121. if value == 'notify':
  122. thread.subscription = request.user.subscription_set.create(
  123. thread=thread,
  124. category=thread.category,
  125. last_read_on=thread.last_post_on,
  126. send_email=False,
  127. )
  128. return {'subscription': False}
  129. elif value == 'email':
  130. thread.subscription = request.user.subscription_set.create(
  131. thread=thread,
  132. category=thread.category,
  133. last_read_on=thread.last_post_on,
  134. send_email=True,
  135. )
  136. return {'subscription': True}
  137. else:
  138. return {'subscription': None}
  139. thread_patch_dispatcher.replace('subscription', patch_subscription)
  140. def patch_best_answer(request, thread, value):
  141. try:
  142. post_id = int(value)
  143. except (TypeError, ValueError):
  144. raise PermissionDenied(_("A valid integer is required."))
  145. allow_mark_best_answer(request.user, thread)
  146. post = get_object_or_404(thread.post_set, id=post_id)
  147. post.category = thread.category
  148. post.thread = thread
  149. allow_see_post(request.user, post)
  150. allow_mark_as_best_answer(request.user, post)
  151. if post.is_best_answer:
  152. raise PermissionDenied(_("This post is already marked as thread's best answer."))
  153. if thread.has_best_answer:
  154. allow_change_best_answer(request.user, thread)
  155. thread.set_best_answer(request.user, post)
  156. thread.save()
  157. return {
  158. 'best_answer': thread.best_answer_id,
  159. 'best_answer_is_protected': thread.best_answer_is_protected,
  160. 'best_answer_marked_on': thread.best_answer_marked_on,
  161. 'best_answer_marked_by': thread.best_answer_marked_by_id,
  162. 'best_answer_marked_by_name': thread.best_answer_marked_by_name,
  163. 'best_answer_marked_by_slug': thread.best_answer_marked_by_slug,
  164. }
  165. thread_patch_dispatcher.replace('best-answer', patch_best_answer)
  166. def patch_unmark_best_answer(request, thread, value):
  167. try:
  168. post_id = int(value)
  169. except (TypeError, ValueError):
  170. raise PermissionDenied(_("A valid integer is required."))
  171. post = get_object_or_404(thread.post_set, id=post_id)
  172. post.category = thread.category
  173. post.thread = thread
  174. if not post.is_best_answer:
  175. raise PermissionDenied(
  176. _("This post can't be unmarked because it's not currently marked as best answer."))
  177. allow_unmark_best_answer(request.user, thread)
  178. thread.clear_best_answer()
  179. thread.save()
  180. return {
  181. 'best_answer': None,
  182. 'best_answer_is_protected': False,
  183. 'best_answer_marked_on': None,
  184. 'best_answer_marked_by': None,
  185. 'best_answer_marked_by_name': None,
  186. 'best_answer_marked_by_slug': None,
  187. }
  188. thread_patch_dispatcher.remove('best-answer', patch_unmark_best_answer)
  189. def patch_add_participant(request, thread, value):
  190. allow_add_participants(request.user, thread)
  191. try:
  192. username = str(value).strip().lower()
  193. if not username:
  194. raise PermissionDenied(_("You have to enter new participant's username."))
  195. participant = UserModel.objects.get(slug=username)
  196. except UserModel.DoesNotExist:
  197. raise PermissionDenied(_("No user with such name exists."))
  198. if participant in [p.user for p in thread.participants_list]:
  199. raise PermissionDenied(_("This user is already thread participant."))
  200. allow_add_participant(request.user, participant)
  201. add_participant(request, thread, participant)
  202. make_participants_aware(request.user, thread)
  203. participants = ThreadParticipantSerializer(thread.participants_list, many=True)
  204. return {'participants': participants.data}
  205. thread_patch_dispatcher.add('participants', patch_add_participant)
  206. def patch_remove_participant(request, thread, value):
  207. try:
  208. user_id = int(value)
  209. except (ValueError, TypeError):
  210. raise PermissionDenied(_("A valid integer is required."))
  211. for participant in thread.participants_list:
  212. if participant.user_id == user_id:
  213. break
  214. else:
  215. raise PermissionDenied(_("Participant doesn't exist."))
  216. allow_remove_participant(request.user, thread, participant.user)
  217. remove_participant(request, thread, participant.user)
  218. if len(thread.participants_list) == 1:
  219. return {'deleted': True}
  220. else:
  221. make_participants_aware(request.user, thread)
  222. participants = ThreadParticipantSerializer(thread.participants_list, many=True)
  223. return {
  224. 'deleted': False,
  225. 'participants': participants.data,
  226. }
  227. thread_patch_dispatcher.remove('participants', patch_remove_participant)
  228. def patch_replace_owner(request, thread, value):
  229. try:
  230. user_id = int(value)
  231. except (ValueError, TypeError):
  232. raise PermissionDenied(_("A valid integer is required."))
  233. for participant in thread.participants_list:
  234. if participant.user_id == user_id:
  235. if participant.is_owner:
  236. raise PermissionDenied(_("This user already is thread owner."))
  237. else:
  238. break
  239. else:
  240. raise PermissionDenied(_("Participant doesn't exist."))
  241. allow_change_owner(request.user, thread)
  242. change_owner(request, thread, participant.user)
  243. make_participants_aware(request.user, thread)
  244. participants = ThreadParticipantSerializer(thread.participants_list, many=True)
  245. return {'participants': participants.data}
  246. thread_patch_dispatcher.replace('owner', patch_replace_owner)
  247. def thread_patch_endpoint(request, thread):
  248. old_title = thread.title
  249. old_is_hidden = thread.is_hidden
  250. old_is_unapproved = thread.is_unapproved
  251. old_category = thread.category
  252. response = thread_patch_dispatcher.dispatch(request, thread)
  253. # diff thread's state against pre-patch and resync category if necessary
  254. hidden_changed = old_is_hidden != thread.is_hidden
  255. unapproved_changed = old_is_unapproved != thread.is_unapproved
  256. category_changed = old_category != thread.category
  257. title_changed = old_title != thread.title
  258. if thread.category.last_thread_id != thread.pk:
  259. title_changed = False # don't trigger resync on simple title change
  260. if hidden_changed or unapproved_changed or category_changed:
  261. thread.category.synchronize()
  262. thread.category.save()
  263. if category_changed:
  264. old_category.synchronize()
  265. old_category.save()
  266. elif title_changed:
  267. thread.category.last_thread_title = thread.title
  268. thread.category.last_thread_slug = thread.slug
  269. thread.category.save(update_fields=['last_thread_title', 'last_thread_slug'])
  270. return response
  271. def bulk_patch_endpoint(request, viewmodel):
  272. serializer = BulkPatchSerializer(data=request.data)
  273. if not serializer.is_valid():
  274. return Response(serializer.errors, status=400)
  275. threads = clean_threads_for_patch(request, viewmodel, serializer.data['ids'])
  276. old_titles = [t.title for t in threads]
  277. old_is_hidden = [t.is_hidden for t in threads]
  278. old_is_unapproved = [t.is_unapproved for t in threads]
  279. old_category = [t.category_id for t in threads]
  280. response = thread_patch_dispatcher.dispatch_bulk(request, threads)
  281. new_titles = [t.title for t in threads]
  282. new_is_hidden = [t.is_hidden for t in threads]
  283. new_is_unapproved = [t.is_unapproved for t in threads]
  284. new_category = [t.category_id for t in threads]
  285. # sync titles
  286. if new_titles != old_titles:
  287. for i, t in enumerate(threads):
  288. if t.title != old_titles[i] and t.category.last_thread_id == t.pk:
  289. t.category.last_thread_title = t.title
  290. t.category.last_thread_slug = t.slug
  291. t.category.save(update_fields=['last_thread_title', 'last_thread_slug'])
  292. # sync categories
  293. sync_categories = []
  294. if new_is_hidden != old_is_hidden:
  295. for i, t in enumerate(threads):
  296. if t.is_hidden != old_is_hidden[i] and t.category_id not in sync_categories:
  297. sync_categories.append(t.category_id)
  298. if new_is_unapproved != old_is_unapproved:
  299. for i, t in enumerate(threads):
  300. if t.is_unapproved != old_is_unapproved[i] and t.category_id not in sync_categories:
  301. sync_categories.append(t.category_id)
  302. if new_category != old_category:
  303. for i, t in enumerate(threads):
  304. if t.category_id != old_category[i]:
  305. if t.category_id not in sync_categories:
  306. sync_categories.append(t.category_id)
  307. if old_category[i] not in sync_categories:
  308. sync_categories.append(old_category[i])
  309. if sync_categories:
  310. for category in Category.objects.filter(id__in=sync_categories):
  311. category.synchronize()
  312. category.save()
  313. return response
  314. def clean_threads_for_patch(request, viewmodel, threads_ids):
  315. threads = []
  316. for thread_id in sorted(set(threads_ids), reverse=True):
  317. try:
  318. threads.append(viewmodel(request, thread_id).unwrap())
  319. except (Http404, PermissionDenied):
  320. raise PermissionDenied(_("One or more threads to update could not be found."))
  321. return threads
  322. class BulkPatchSerializer(serializers.Serializer):
  323. ids = serializers.ListField(
  324. child=serializers.IntegerField(min_value=1),
  325. max_length=PATCH_LIMIT,
  326. min_length=1,
  327. )
  328. ops = serializers.ListField(
  329. child=serializers.DictField(),
  330. min_length=1,
  331. max_length=10,
  332. )