views.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  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:appearance:themes:index"
  22. model = Theme
  23. form = 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 initialize_form(self, form, request, _):
  31. if request.method == "POST":
  32. return form(request.POST, request.FILES)
  33. try:
  34. initial = {"parent": int(request.GET.get("parent"))}
  35. except (TypeError, ValueError):
  36. initial = {}
  37. return form(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 button_action(self, request, target):
  76. return export_theme(target)
  77. class ImportTheme(ThemeAdmin, generic.FormView):
  78. form = ImportForm
  79. template = "import.html"
  80. def handle_form(self, form, request):
  81. try:
  82. self.import_theme(request, **form.cleaned_data)
  83. return redirect(self.root_link)
  84. except ThemeImportError as e:
  85. form.add_error("upload", str(e))
  86. return self.render(request, {"form": form})
  87. def import_theme(self, request, *_, name, parent, upload):
  88. theme = import_theme(name, parent, upload)
  89. message = gettext('Theme "%(name)s" has been imported.')
  90. messages.success(request, message % {"name": theme})
  91. class ThemeAssetsAdmin(ThemeAdmin):
  92. def check_permissions(self, request, theme):
  93. if theme.is_default:
  94. return gettext("Default theme assets can't be edited.")
  95. def redirect_to_theme_assets(self, theme):
  96. return redirect("misago:admin:appearance:themes:assets", pk=theme.pk)
  97. class ThemeAssets(ThemeAssetsAdmin, generic.TargetedView):
  98. template = "assets/list.html"
  99. def real_dispatch(self, request, theme):
  100. return self.render(request, {"theme": theme})
  101. class ThemeAssetsActionAdmin(ThemeAssetsAdmin):
  102. def real_dispatch(self, request, theme):
  103. if request.method == "POST":
  104. self.action(request, theme)
  105. return self.redirect_to_theme_assets(theme)
  106. def action(self, request, theme):
  107. raise NotImplementedError(
  108. "action method must be implemented in inheriting class"
  109. )
  110. class UploadThemeAssets(ThemeAssetsActionAdmin, generic.TargetedView):
  111. message_partial_success = _(
  112. "Some css files could not have been added to the theme."
  113. )
  114. message_submit = None
  115. form = None
  116. def action(self, request, theme):
  117. form = self.form( # pylint: disable=not-callable
  118. request.POST, request.FILES, instance=theme
  119. )
  120. if not form.is_valid():
  121. if form.cleaned_data.get("assets"):
  122. messages.info(request, self.message_partial_success)
  123. for error in form.errors["assets"]:
  124. messages.error(request, error)
  125. if form.cleaned_data.get("assets"):
  126. form.save()
  127. build_theme_css.delay(theme.pk)
  128. messages.success(request, self.message_success)
  129. class UploadThemeCss(UploadThemeAssets):
  130. message_success = _("New CSS files have been added to the theme.")
  131. form = UploadCssForm
  132. class UploadThemeMedia(UploadThemeAssets):
  133. message_success = _("New media files have been added to the theme.")
  134. form = UploadMediaForm
  135. class DeleteThemeAssets(ThemeAssetsActionAdmin, generic.TargetedView):
  136. message_submit = None
  137. queryset_attr = None
  138. def action(self, request, theme):
  139. items = self.clean_items_list(request)
  140. if items:
  141. queryset = getattr(theme, self.queryset_attr)
  142. for item in items:
  143. self.delete_asset(queryset, item)
  144. messages.success(request, self.message_submit)
  145. def clean_items_list(self, request):
  146. try:
  147. return {int(i) for i in request.POST.getlist("item")[:20]}
  148. except (ValueError, TypeError):
  149. pass
  150. def delete_asset(self, queryset, item):
  151. try:
  152. queryset.get(pk=item).delete()
  153. except ObjectDoesNotExist:
  154. pass
  155. class DeleteThemeCss(DeleteThemeAssets):
  156. message_submit = _("Selected CSS files have been deleted.")
  157. queryset_attr = "css"
  158. def action(self, request, theme):
  159. super().action(request, theme)
  160. clear_theme_cache()
  161. class DeleteThemeMedia(DeleteThemeAssets):
  162. message_submit = _("Selected media have been deleted.")
  163. queryset_attr = "media"
  164. class ThemeCssAdmin(ThemeAssetsAdmin, generic.TargetedView):
  165. def wrapped_dispatch(self, request, pk, css_pk=None):
  166. theme = self.get_target_or_none(request, {"pk": pk})
  167. if not theme:
  168. messages.error(request, self.message_404)
  169. return redirect(self.root_link)
  170. error = self.check_permissions( # pylint: disable=assignment-from-no-return
  171. request, theme
  172. )
  173. if error:
  174. messages.error(request, error)
  175. return redirect(self.root_link)
  176. css = self.get_theme_css_or_none(theme, css_pk)
  177. if css_pk and not css:
  178. css_error = gettext("Requested CSS could not be found in the theme.")
  179. messages.error(request, css_error)
  180. return self.redirect_to_theme_assets(theme)
  181. return self.real_dispatch(request, theme, css)
  182. def get_theme_css_or_none(self, theme, css_pk):
  183. if not css_pk:
  184. return None
  185. try:
  186. return theme.css.select_for_update().get(pk=css_pk)
  187. except ObjectDoesNotExist:
  188. return None
  189. def real_dispatch(self, request, theme, css):
  190. raise NotImplementedError(
  191. "Admin views extending the ThemeCssAdmin"
  192. "should define real_dispatch(request, theme, css)"
  193. )
  194. class MoveThemeCssUp(ThemeCssAdmin):
  195. def real_dispatch(self, request, theme, css):
  196. if request.method == "POST" and move_css_up(theme, css):
  197. clear_theme_cache()
  198. messages.success(request, gettext('"%s" was moved up.') % css)
  199. return self.redirect_to_theme_assets(theme)
  200. class MoveThemeCssDown(ThemeCssAdmin):
  201. def real_dispatch(self, request, theme, css):
  202. if request.method == "POST" and move_css_down(theme, css):
  203. clear_theme_cache()
  204. messages.success(request, gettext('"%s" was moved down.') % css)
  205. return self.redirect_to_theme_assets(theme)
  206. class ThemeCssFormAdmin(ThemeCssAdmin, generic.ModelFormView):
  207. is_atomic = False # atomic updates cause race condition with celery tasks
  208. def real_dispatch(self, request, theme, css=None):
  209. form = self.initialize_form(self.form, request, theme, css)
  210. if request.method == "POST" and form.is_valid():
  211. response = self.handle_form( # pylint: disable=assignment-from-no-return
  212. form, request, theme, css
  213. )
  214. if response:
  215. return response
  216. if "stay" in request.POST:
  217. return self.redirect_to_edit_form(theme, form.instance)
  218. return self.redirect_to_theme_assets(theme)
  219. return self.render(request, {"form": form, "theme": theme, "target": css})
  220. def initialize_form(self, form, request, theme, css):
  221. raise NotImplementedError(
  222. "Admin views extending the ThemeCssFormAdmin "
  223. "should define the initialize_form(form, request, theme, css)"
  224. )
  225. def handle_form(self, form, request, theme, css):
  226. form.save()
  227. if css.source_needs_building:
  228. build_single_theme_css.delay(css.pk)
  229. else:
  230. clear_theme_cache()
  231. messages.success(request, self.message_submit % {"name": css.name})
  232. class NewThemeCss(ThemeCssFormAdmin):
  233. message_submit = _('New CSS "%(name)s" has been saved.')
  234. form = CssEditorForm
  235. template = "assets/css-editor-form.html"
  236. def get_theme_css_or_none(self, theme, _):
  237. return Css(theme=theme)
  238. def initialize_form(self, form, request, theme, css):
  239. if request.method == "POST":
  240. return form(request.POST, instance=css)
  241. return form(instance=css)
  242. def redirect_to_edit_form(self, theme, css):
  243. return redirect(
  244. "misago:admin:appearance:themes:edit-css-file", pk=theme.pk, css_pk=css.pk
  245. )
  246. class EditThemeCss(NewThemeCss):
  247. message_submit = _('CSS "%(name)s" has been updated.')
  248. def get_theme_css_or_none(self, theme, css_pk):
  249. try:
  250. return theme.css.get(pk=css_pk, url__isnull=True)
  251. except ObjectDoesNotExist:
  252. return None
  253. def initialize_form(self, form, request, theme, css):
  254. if request.method == "POST":
  255. return form(request.POST, instance=css)
  256. initial_data = {"source": css.source_file.read()}
  257. return form(instance=css, initial=initial_data)
  258. def handle_form(self, form, request, theme, css):
  259. if "source" in form.changed_data:
  260. form.save()
  261. if css.source_needs_building:
  262. build_single_theme_css.delay(css.pk)
  263. else:
  264. clear_theme_cache()
  265. messages.success(request, self.message_submit % {"name": css.name})
  266. else:
  267. message = gettext('No changes have been made to "%(css)s".')
  268. messages.info(request, message % {"name": css.name})
  269. class NewThemeCssLink(ThemeCssFormAdmin):
  270. message_submit = _('New CSS link "%(name)s" has been saved.')
  271. form = CssLinkForm
  272. template = "assets/css-link-form.html"
  273. def get_theme_css_or_none(self, theme, _):
  274. return Css(theme=theme)
  275. def initialize_form(self, form, request, theme, css):
  276. if request.method == "POST":
  277. return form(request.POST, instance=css)
  278. return form(instance=css)
  279. def handle_form(self, form, request, theme, css):
  280. super().handle_form(form, request, theme, css)
  281. if "url" in form.changed_data:
  282. update_remote_css_size.delay(css.pk)
  283. clear_theme_cache()
  284. def redirect_to_edit_form(self, theme, css):
  285. return redirect("misago:admin:appearance:themes:new-css-link", pk=theme.pk)
  286. class EditThemeCssLink(NewThemeCssLink):
  287. message_submit = _('CSS link "%(name)s" has been updated.')
  288. def get_theme_css_or_none(self, theme, css_pk):
  289. try:
  290. return theme.css.get(pk=css_pk, url__isnull=False)
  291. except ObjectDoesNotExist:
  292. return None
  293. def redirect_to_edit_form(self, theme, css):
  294. return redirect(
  295. "misago:admin:appearance:themes:edit-css-link", pk=theme.pk, css_pk=css.pk
  296. )