views.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. from django.contrib import messages
  2. from django.db.models import ObjectDoesNotExist
  3. from django.shortcuts import redirect
  4. from django.utils.translation import gettext, gettext_lazy as _
  5. from ...admin.views import generic
  6. from ..cache import clear_theme_cache
  7. from ..models import Theme, Css
  8. from .css import move_css_down, move_css_up
  9. from .exporter import export_theme
  10. from .forms import (
  11. CssEditorForm,
  12. CssLinkForm,
  13. ImportForm,
  14. ThemeForm,
  15. UploadCssForm,
  16. UploadMediaForm,
  17. )
  18. from .importer import ThemeImportError, import_theme
  19. from .tasks import build_single_theme_css, build_theme_css, update_remote_css_size
  20. class ThemeAdmin(generic.AdminBaseMixin):
  21. root_link = "misago:admin:themes:index"
  22. model = Theme
  23. form_class = ThemeForm
  24. templates_dir = "misago/admin/themes"
  25. message_404 = _("Requested theme does not exist.")
  26. class ThemesList(ThemeAdmin, generic.ListView):
  27. pass
  28. class NewTheme(ThemeAdmin, generic.ModelFormView):
  29. message_submit = _('New theme "%(name)s" has been saved.')
  30. def get_form(self, form_class, request, _):
  31. if request.method == "POST":
  32. return form_class(request.POST, request.FILES)
  33. try:
  34. initial = {"parent": int(request.GET.get("parent"))}
  35. except (TypeError, ValueError):
  36. initial = {}
  37. return form_class(initial=initial)
  38. class EditTheme(ThemeAdmin, generic.ModelFormView):
  39. message_submit = _('Theme "%(name)s" has been updated.')
  40. def check_permissions(self, request, target):
  41. if target.is_default:
  42. return gettext("Default theme can't be edited.")
  43. def handle_form(self, form, request, target):
  44. super().handle_form(form, request, target)
  45. if "parent" in form.changed_data:
  46. clear_theme_cache()
  47. class DeleteTheme(ThemeAdmin, generic.ButtonView):
  48. message_submit = _('Theme "%(name)s" has been deleted.')
  49. def check_permissions(self, request, target):
  50. if target.is_default:
  51. return gettext("Default theme can't be deleted.")
  52. if target.is_active:
  53. return gettext("Active theme can't be deleted.")
  54. if target.get_descendants().filter(is_active=True).exists():
  55. message = gettext(
  56. 'Theme "%(name)s" can\'t be deleted '
  57. "because one of its child themes is set as active."
  58. )
  59. return message % {"name": target}
  60. def button_action(self, request, target):
  61. for theme in reversed(target.get_descendants(include_self=True)):
  62. theme.delete()
  63. clear_theme_cache()
  64. messages.success(request, self.message_submit % {"name": target})
  65. class ActivateTheme(ThemeAdmin, generic.ButtonView):
  66. def button_action(self, request, target):
  67. set_theme_as_active(request, target)
  68. message = gettext('Active theme has been changed to "%(name)s".')
  69. messages.success(request, message % {"name": target})
  70. def set_theme_as_active(request, theme):
  71. Theme.objects.update(is_active=False)
  72. Theme.objects.filter(pk=theme.pk).update(is_active=True)
  73. clear_theme_cache()
  74. class ExportTheme(ThemeAdmin, generic.ButtonView):
  75. def check_permissions(self, request, target):
  76. if target.is_default:
  77. return gettext("Default theme can't be exported.")
  78. def button_action(self, request, target):
  79. return export_theme(target)
  80. class ImportTheme(ThemeAdmin, generic.FormView):
  81. form_class = ImportForm
  82. template_name = "import.html"
  83. def handle_form(self, form, request):
  84. try:
  85. self.import_theme(request, **form.cleaned_data)
  86. return redirect(self.root_link)
  87. except ThemeImportError as e:
  88. form.add_error("upload", str(e))
  89. return self.render(request, {"form": form})
  90. def import_theme(self, request, *_, name, parent, upload):
  91. theme = import_theme(name, parent, upload)
  92. message = gettext('Theme "%(name)s" has been imported.')
  93. messages.success(request, message % {"name": theme})
  94. class ThemeAssetsAdmin(ThemeAdmin):
  95. def check_permissions(self, request, theme):
  96. if theme.is_default:
  97. return gettext("Default theme assets can't be edited.")
  98. def redirect_to_theme_assets(self, theme):
  99. return redirect("misago:admin:themes:assets", pk=theme.pk)
  100. class ThemeAssets(ThemeAssetsAdmin, generic.TargetedView):
  101. template_name = "assets/list.html"
  102. def real_dispatch(self, request, theme):
  103. return self.render(request, {"theme": theme})
  104. class ThemeAssetsActionAdmin(ThemeAssetsAdmin):
  105. def real_dispatch(self, request, theme):
  106. if request.method == "POST":
  107. self.action(request, theme)
  108. return self.redirect_to_theme_assets(theme)
  109. def action(self, request, theme):
  110. raise NotImplementedError(
  111. "action method must be implemented in inheriting class"
  112. )
  113. class UploadThemeAssets(ThemeAssetsActionAdmin, generic.TargetedView):
  114. message_partial_success = _(
  115. "Some css files could not have been added to the theme."
  116. )
  117. message_submit = None
  118. form_class = None
  119. def action(self, request, theme):
  120. form = self.form_class( # pylint: disable=not-callable
  121. request.POST, request.FILES, instance=theme
  122. )
  123. if not form.is_valid():
  124. if form.cleaned_data.get("assets"):
  125. messages.info(request, self.message_partial_success)
  126. for error in form.errors["assets"]:
  127. messages.error(request, error)
  128. if form.cleaned_data.get("assets"):
  129. form.save()
  130. build_theme_css.delay(theme.pk)
  131. messages.success(request, self.message_success)
  132. class UploadThemeCss(UploadThemeAssets):
  133. message_success = _("New CSS files have been added to the theme.")
  134. form_class = UploadCssForm
  135. class UploadThemeMedia(UploadThemeAssets):
  136. message_success = _("New media files have been added to the theme.")
  137. form_class = UploadMediaForm
  138. class DeleteThemeAssets(ThemeAssetsActionAdmin, generic.TargetedView):
  139. message_submit = None
  140. queryset_attr = None
  141. def action(self, request, theme):
  142. items = self.clean_items_list(request)
  143. if items:
  144. queryset = getattr(theme, self.queryset_attr)
  145. for item in items:
  146. self.delete_asset(queryset, item)
  147. messages.success(request, self.message_submit)
  148. def clean_items_list(self, request):
  149. try:
  150. return {int(i) for i in request.POST.getlist("item")[:20]}
  151. except (ValueError, TypeError):
  152. pass
  153. def delete_asset(self, queryset, item):
  154. try:
  155. queryset.get(pk=item).delete()
  156. except ObjectDoesNotExist:
  157. pass
  158. class DeleteThemeCss(DeleteThemeAssets):
  159. message_submit = _("Selected CSS files have been deleted.")
  160. queryset_attr = "css"
  161. def action(self, request, theme):
  162. super().action(request, theme)
  163. clear_theme_cache()
  164. class DeleteThemeMedia(DeleteThemeAssets):
  165. message_submit = _("Selected media have been deleted.")
  166. queryset_attr = "media"
  167. class ThemeCssAdmin(ThemeAssetsAdmin, generic.TargetedView):
  168. def wrapped_dispatch(self, request, pk, css_pk=None):
  169. theme = self.get_target_or_none(request, {"pk": pk})
  170. if not theme:
  171. messages.error(request, self.message_404)
  172. return redirect(self.root_link)
  173. error = self.check_permissions( # pylint: disable=assignment-from-no-return
  174. request, theme
  175. )
  176. if error:
  177. messages.error(request, error)
  178. return redirect(self.root_link)
  179. css = self.get_theme_css_or_none(theme, css_pk)
  180. if css_pk and not css:
  181. css_error = gettext("Requested CSS could not be found in the theme.")
  182. messages.error(request, css_error)
  183. return self.redirect_to_theme_assets(theme)
  184. return self.real_dispatch(request, theme, css)
  185. def get_theme_css_or_none(self, theme, css_pk):
  186. if not css_pk:
  187. return None
  188. try:
  189. return theme.css.select_for_update().get(pk=css_pk)
  190. except ObjectDoesNotExist:
  191. return None
  192. def real_dispatch(self, request, theme, css):
  193. raise NotImplementedError(
  194. "Admin views extending the ThemeCssAdmin"
  195. "should define real_dispatch(request, theme, css)"
  196. )
  197. class MoveThemeCssUp(ThemeCssAdmin):
  198. def real_dispatch(self, request, theme, css):
  199. if request.method == "POST" and move_css_up(theme, css):
  200. clear_theme_cache()
  201. messages.success(request, gettext('"%s" was moved up.') % css)
  202. return self.redirect_to_theme_assets(theme)
  203. class MoveThemeCssDown(ThemeCssAdmin):
  204. def real_dispatch(self, request, theme, css):
  205. if request.method == "POST" and move_css_down(theme, css):
  206. clear_theme_cache()
  207. messages.success(request, gettext('"%s" was moved down.') % css)
  208. return self.redirect_to_theme_assets(theme)
  209. class ThemeCssFormAdmin(ThemeCssAdmin, generic.ModelFormView):
  210. is_atomic = False # atomic updates cause race condition with celery tasks
  211. def real_dispatch(self, request, theme, css=None):
  212. form = self.get_form(self.form_class, request, theme, css)
  213. if request.method == "POST" and form.is_valid():
  214. response = self.handle_form( # pylint: disable=assignment-from-no-return
  215. form, request, theme, css
  216. )
  217. if response:
  218. return response
  219. if "stay" in request.POST:
  220. return self.redirect_to_edit_form(theme, form.instance)
  221. return self.redirect_to_theme_assets(theme)
  222. template_name = self.get_template_name(request, css)
  223. return self.render(
  224. request, {"form": form, "theme": theme, "target": css}, template_name
  225. )
  226. def get_form(self, form_class, request, theme, css):
  227. raise NotImplementedError(
  228. "Admin views extending the ThemeCssFormAdmin "
  229. "should define the get_form(form_class, request, theme, css)"
  230. )
  231. def handle_form(self, form, request, theme, css):
  232. form.save()
  233. if css.source_needs_building:
  234. build_single_theme_css.delay(css.pk)
  235. else:
  236. clear_theme_cache()
  237. messages.success(request, self.message_submit % {"name": css.name})
  238. class NewThemeCss(ThemeCssFormAdmin):
  239. message_submit = _('New CSS "%(name)s" has been saved.')
  240. form_class = CssEditorForm
  241. template_name = "assets/css-editor-form.html"
  242. def get_theme_css_or_none(self, theme, _):
  243. return Css(theme=theme)
  244. def get_form(self, form_class, request, theme, css):
  245. if request.method == "POST":
  246. return form_class(request.POST, instance=css)
  247. return form_class(instance=css)
  248. def redirect_to_edit_form(self, theme, css):
  249. return redirect("misago:admin:themes:edit-css-file", pk=theme.pk, css_pk=css.pk)
  250. class EditThemeCss(NewThemeCss):
  251. message_submit = _('CSS "%(name)s" has been updated.')
  252. def get_theme_css_or_none(self, theme, css_pk):
  253. try:
  254. return theme.css.get(pk=css_pk, url__isnull=True)
  255. except ObjectDoesNotExist:
  256. return None
  257. def get_form(self, form_class, request, theme, css):
  258. if request.method == "POST":
  259. return form_class(request.POST, instance=css)
  260. initial_data = {"source": css.source_file.read().decode("utf-8")}
  261. return form_class(instance=css, initial=initial_data)
  262. def handle_form(self, form, request, theme, css):
  263. if "source" in form.changed_data:
  264. form.save()
  265. if css.source_needs_building:
  266. build_single_theme_css.delay(css.pk)
  267. else:
  268. clear_theme_cache()
  269. messages.success(request, self.message_submit % {"name": css.name})
  270. else:
  271. message = gettext('No changes have been made to "%(css)s".')
  272. messages.info(request, message % {"name": css.name})
  273. class NewThemeCssLink(ThemeCssFormAdmin):
  274. message_submit = _('New CSS link "%(name)s" has been saved.')
  275. form_class = CssLinkForm
  276. template_name = "assets/css-link-form.html"
  277. def get_theme_css_or_none(self, theme, _):
  278. return Css(theme=theme)
  279. def get_form(self, form_class, request, theme, css):
  280. if request.method == "POST":
  281. return form_class(request.POST, instance=css)
  282. return form_class(instance=css)
  283. def handle_form(self, form, request, theme, css):
  284. super().handle_form(form, request, theme, css)
  285. if "url" in form.changed_data:
  286. update_remote_css_size.delay(css.pk)
  287. clear_theme_cache()
  288. def redirect_to_edit_form(self, theme, css):
  289. return redirect("misago:admin:themes:new-css-link", pk=theme.pk)
  290. class EditThemeCssLink(NewThemeCssLink):
  291. message_submit = _('CSS link "%(name)s" has been updated.')
  292. def get_theme_css_or_none(self, theme, css_pk):
  293. try:
  294. return theme.css.get(pk=css_pk, url__isnull=False)
  295. except ObjectDoesNotExist:
  296. return None
  297. def redirect_to_edit_form(self, theme, css):
  298. return redirect("misago:admin:themes:edit-css-link", pk=theme.pk, css_pk=css.pk)