patch.py 16 KB

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