diff --git a/config/v3_urls.py b/config/v3_urls.py index 7454defdd..ab1eb3897 100644 --- a/config/v3_urls.py +++ b/config/v3_urls.py @@ -48,7 +48,7 @@ from django.urls import path, re_path from core.views import LearnPageView, V3ComponentDemoView -from news.views import V3AllTypesCreateView +from news.views import V3AllTypesCreateView, V3AllTypesEditView, V3DeletePostView from users.views import ( V3LoginView, V3PasswordResetDoneView, @@ -73,6 +73,16 @@ V3AllTypesCreateView.as_view(), name="v3-news-create", ), + path( + "v3/news/delete//", + V3DeletePostView.as_view(), + name="v3-news-delete", + ), + path( + "v3/news/edit//", + V3AllTypesEditView.as_view(), + name="v3-news-edit", + ), path( "v3/accounts/login/", V3LoginView.as_view(), diff --git a/news/models.py b/news/models.py index 03af5c76e..1a3df4cd2 100644 --- a/news/models.py +++ b/news/models.py @@ -169,6 +169,10 @@ def image_url(self): else: return self.image.url + @property + def needs_approval(self): + return not self.approved_at and not self.deleted_at + @cached_property def tag(self): return getattr(self, "_tag", self.news_type) @@ -299,6 +303,12 @@ def can_delete(self, user): def author_needs_moderation(self): return acl.author_needs_moderation(self) + def edit_url(self): + return reverse("news-update", kwargs={"slug": self.slug}) + + def delete_url(self): + return reverse("news-delete", kwargs={"slug": self.slug}) + class News(Entry): news_type = "news" diff --git a/news/views.py b/news/views.py index 0ac66240b..c8587395a 100644 --- a/news/views.py +++ b/news/views.py @@ -12,6 +12,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.humanize.templatetags import humanize from django.contrib.messages.views import SuccessMessageMixin +from django.core.exceptions import PermissionDenied from django.http import ( Http404, HttpResponseRedirect, @@ -39,6 +40,9 @@ from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData from wagtail.blocks import Block from wagtail.images.models import Image +from wagtail.models import Page +from waffle import flag_is_active + from core.mixins import V3Mixin from pages.blocks import NEWS_BLOCK, BLOG_BLOCK, LINK_BLOCK, VIDEO_BLOCK @@ -388,26 +392,6 @@ def get(self, request, token, *args, **kwargs): return redirect(entry) -def _v3_create_context(): - """Shared context variables needed by the v3 create-post template.""" - return { - "post_type_options": [ - ("blog", "Blog"), - ("news", "News"), - ("video", "Video"), - ("link", "Link"), - ], - "related_libraries_options": [ - ( - library.slug, - library.name, - ) - for library in Library.objects.all().order_by("name") - ], - "publish_at_initial": localtime(now()).strftime("%Y-%m-%dT%H:%M"), - } - - class EntryCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = None form_class = None @@ -521,14 +505,14 @@ class V3AllTypesCreateView(V3Mixin, AllTypesCreateView): http_method_names = ["get", "post"] _POST_BLOCK_MAP: dict[str, tuple[str, Block]] = { - "blog": BLOG_BLOCK, + "blogpost": BLOG_BLOCK, "news": NEWS_BLOCK, "link": LINK_BLOCK, "video": VIDEO_BLOCK, } _POST_TYPE_MAP = { - "blog": V3BlogPostForm, + "blogpost": V3BlogPostForm, "news": V3NewsForm, "link": V3LinkForm, "video": V3VideoForm, @@ -541,24 +525,96 @@ def dispatch(self, request, *args, **kwargs): return response return super().dispatch(request, *args, **kwargs) - def get_context_data(self, **kwargs): + def _v3_create_context(self): + """Shared context variables needed by the v3 create-post template.""" + return { + "post_type_options": [ + ("blogpost", "Blog"), + ("news", "News"), + ("video", "Video"), + ("link", "Link"), + ], + "related_libraries_options": [ + ( + library.slug, + library.name, + ) + for library in Library.objects.all().order_by("name") + ], + "publish_at_initial": localtime(now()).strftime("%Y-%m-%dT%H:%M"), + "title": "Create Post", + "edit": False, + } + + def get_v3_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context.update(_v3_create_context()) + context.update(self._v3_create_context()) return context + def error_message_and_render(self, message: str, extra_context: dict | None = None): + messages.error(self.request, message) + if extra_context: + context = self.get_context_data(**extra_context) + else: + context = self.get_context_data() + return self.render_to_response(context) + + def set_page_attrs( + self, + page: PostPage, + form, + related_libraries: list, + block_name: str, + post_type: str, + ): + cleaned_data = form.cleaned_data + page.title = cleaned_data.get("title") + page.summary = cleaned_data.get("summary", "") + page.go_live_at = cleaned_data.get("publish_at") + page.content = [ + ( + block_name, + cleaned_data.get("content") or cleaned_data.get("external_url"), + ) + ] + page.live = False + if image := cleaned_data.get("image"): + if image.size >= settings.DOWNSCALE_IMAGE_THRESHOLD: + image = downsize_uploaded_image(image) + wagtail_image = Image.objects.create( + title=image.name, + file=image, + ) + page.image = wagtail_image + tags = [] + if related_libraries: + for library in related_libraries: + lib = Library.objects.get(slug=library) + tag, created = ContentTag.objects.get_or_create( + slug=lib.slug, + defaults={ + "name": lib.name, + }, + ) + tags.append(tag) + if tags: + page.tags.set(tags) + else: + page.tags.clear() + + return page + def post(self, request, *args, **kwargs): post_type = request.POST.get("post_type", "") block_config = self._POST_BLOCK_MAP.get(post_type, None) form_class = self._POST_TYPE_MAP.get(post_type) if block_config is None or form_class is None: - messages.error( - request, - _("Invalid post type selected. Please choose a valid post type."), + return self.error_message_and_render( + message=_( + "Invalid post type selected. Please choose a valid post type." + ) ) - context = self.get_context_data() - return self.render_to_response(context) - block_name, block_class = block_config # The v3 create page has two Description textareas — `description` for @@ -571,71 +627,38 @@ def post(self, request, *args, **kwargs): ) if post_data.get(description_field) and not post_data.get("summary"): post_data["summary"] = post_data[description_field] - form = form_class(post_data, request.FILES) if form.is_valid(): - cleaned_data = form.cleaned_data # Since the PostIndexPage is limited to one, we can just grab the first index_page = PostIndexPage.objects.first() if not index_page: - messages.error( - request, + return self.error_message_and_render( _( "An internal database error has occurred. Please contact an admin." ), + {"form": form, "post_type_selected": post_type}, ) - context = self.get_context_data(form=form, post_type_selected=post_type) - return self.render_to_response(context) try: page = PostPage() page.owner = request.user - page.title = cleaned_data.get("title") - page.summary = cleaned_data.get("summary", "") - page.go_live_at = cleaned_data.get("publish_at") - page.content = [ - ( - block_name, - cleaned_data.get("content") or cleaned_data.get("external_url"), + try: + page = self.set_page_attrs( + page=page, + form=form, + block_name=block_name, + post_type=post_type, + related_libraries=post_data.getlist("related_libraries"), ) - ] - page.live = False - if image := form.cleaned_data.get("image"): - if image.size >= settings.DOWNSCALE_IMAGE_THRESHOLD: - image = downsize_uploaded_image(image) - wagtail_image = Image.objects.create( - title=image.name, - file=image, + except Library.DoesNotExist: + return self.error_message_and_render( + _( + "That related library does not exist, please select another." + ), + {"form": form, "post_type_selected": post_type}, ) - page.image = wagtail_image - tags = [] - if related_libraries := post_data.getlist("related_libraries"): - for library in related_libraries: - try: - lib = Library.objects.get(slug=library) - except Library.DoesNotExist: - messages.error( - request, - _( - "That related library does not exist, please select another." - ), - ) - context = self.get_context_data( - form=form, post_type_selected=post_type - ) - return self.render_to_response(context) - - tag, created = ContentTag.objects.get_or_create( - slug=lib.slug, - defaults={ - "name": lib.name, - }, - ) - tags.append(tag) index_page.add_child(instance=page) - if tags: - page.tags.add(*tags) page.save_revision(user=request.user) page.get_workflow().start(obj=page, user=request.user) except ValidationError as e: @@ -669,6 +692,247 @@ def post(self, request, *args, **kwargs): return self.render_to_response(context) +class V3AllTypesEditView(V3AllTypesCreateView): + _page = PostPage.objects.none() + + def _v3_edit_context(self, page: PostPage): + ctx = {} + ctx["edit"] = True + ctx["title"] = "Edit Post" + + if not page: + return ctx + ctx["post_type_selected"] = page.post_content_type.lower() + ctx["post_type_options"] = [ + (page.post_content_type.lower(), page.post_content_type), + ] + + if page.image: + ctx["current_image"] = page.image_url + + form_class = self._POST_TYPE_MAP.get(page.post_content_type.lower(), None) + if not form_class: + messages.error( + self.request, + _("An internal database error has occurred. Please contact an admin."), + ) + return ctx + + form_data = { + "title": page.title, + "summary": page.summary, + "related_libraries": list(page.tags.all().values_list("slug", flat=True)), + } + go_live = page.go_live_at or localtime(now()) + form_data["publish_at"] = go_live.strftime("%Y-%m-%dT%H:%M") + if page.stream_content_type in ["video", "url"]: + form_data["external_url"] = page.external_url + else: + # Return the raw value of the markdown block not rendered to html + # for cleaner editing in a javascriptless environment + form_data["content"] = page.content[0].value + + form = form_class(initial=form_data) + ctx["form"] = form + + return ctx + + def get_v3_context_data(self, **kwargs): + page = self._page + context = super().get_v3_context_data(**kwargs) + context["related_libraries"] = list( + page.tags.all().values_list("slug", flat=True) + ) + context.update(self._v3_edit_context(page)) + return context + + def get_page(self, slug): + index_page = PostIndexPage.objects.first() + if not index_page: + messages.error( + self.request, + _("An internal database error has occurred. Please contact an admin."), + ) + return + try: + page: PostPage = ( + PostPage.objects.child_of(index_page) + .get(slug=slug) + .get_latest_revision_as_object() + ) + except PostPage.DoesNotExist: + messages.error( + self.request, + _("No page with slug %(slug)s exists…") % {"slug": slug}, + ) + return + + if not self.request.user == page.owner: + raise PermissionDenied("Only the author of a page may edit it.") + + self._page = page + + def get(self, request, *args, **kwargs): + slug = kwargs.get("slug", "") + self.get_page(slug) + + if self._page and not self._page.user_can_edit(request.user): + raise PermissionDenied("You do not have permission to edit this page.") + + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + slug = kwargs.get("slug", "") + self.get_page(slug) + index_page = PostIndexPage.objects.first() + if not index_page: + return self.error_message_and_render( + _("An internal database error has occurred. Please contact an admin."), + ) + try: + page: PostPage = index_page.get_children().get(slug=slug).specific + except Page.DoesNotExist: + messages.error( + self.request, + _("No page with slug %(slug)s exists…") % {"slug": slug}, + ) + context = self.get_context_data() + return self.render_to_response(context) + + post_type = page.post_content_type.lower() + block_config = self._POST_BLOCK_MAP.get(post_type, None) + form_class = self._POST_TYPE_MAP.get(post_type) + + if block_config is None or form_class is None: + return self.error_message_and_render( + message=_( + "Invalid post type selected. Please choose a valid post type." + ) + ) + + block_name, block_class = block_config + + # The v3 create page has two Description textareas — `description` for + # Blog/News and `link_description` for Link/Video — so the two don't + # collide in the submitted form. Bind whichever applies to the model's + # `summary` field on submit for the forms that include it. + post_data = request.POST.copy() + description_field = ( + "link_description" if post_type in ("link", "video") else "description" + ) + if post_data.get(description_field) and not post_data.get("summary"): + post_data["summary"] = post_data[description_field] + + form = form_class(post_data, request.FILES) + if form.is_valid(): + if not page.user_can_edit(request.user): + messages.error( + self.request, + _("You do not have permission to edit this page."), + ) + context = self.get_context_data(form=form, post_type_selected=post_type) + return self.render_to_response(context) + + try: + page = self.set_page_attrs( + page=page, + form=form, + block_name=block_name, + post_type=post_type, + related_libraries=post_data.getlist("related_libraries"), + ) + page.save_revision(user=request.user) + if not page.workflow_in_progress: + page.get_workflow().start(obj=page, user=request.user) + except Library.DoesNotExist: + return self.error_message_and_render( + _("That related library does not exist, please select another."), + {"form": form, "post_type_selected": post_type, "slug": slug}, + ) + except ValidationError as e: + if "slug" in str(e): + form.add_error( + "title", + "A post with this title already exists. Please choose a different title.", + ) + else: + form.add_error( + None, "An unexpected error occurred. Please try again." + ) + messages.error( + request, + _( + "Something went wrong — your draft is saved, so give it another try." + ), + ) + context = self.get_context_data( + form=form, + post_type_selected=post_type, + related_libraries=post_data.getlist("related_libraries"), + ) + return self.render_to_response(context) + + messages.success( + request, + _( + "Your post has been submitted. It'll be reviewed before it goes live, you will receive updates via email." + ), + ) + return redirect(index_page.url) + + context = self.get_context_data(form=form, post_type_selected=post_type) + return self.render_to_response(context) + + +class V3DeletePostView(LoginRequiredMixin, View): + def dispatch(self, request, *args, **kwargs): + """ + v3 mixin doesn't work for this view, since it has no template. Temporarily override the + view to require v3 access + """ + if flag_is_active(request, "v3"): + return super().dispatch(request, *args, **kwargs) + else: + raise Http404 + + def post(self, request, **kwargs): + slug = kwargs.get("slug") + if not slug: + messages.error(request, message=_("No slug was provided to delete")) + return redirect(reverse("news")) + index_page = PostIndexPage.objects.first() + if not index_page: + messages.error( + request=request, + message=_( + "An internal database error has occurred. Please contact an admin." + ), + ) + return redirect(reverse("news")) + try: + page: PostPage = index_page.get_children().get(slug=slug).specific + except Page.DoesNotExist: + messages.error( + request, + _("No page with slug %(slug)s exists…") % {"slug": slug}, + ) + return redirect(reverse("news")) + + if not page.user_can_delete(request.user): + messages.error(request=request, message=_("You do not own this page.")) + return redirect(page.url) + + workflow_state = page.current_workflow_state + if workflow_state: + workflow_state.cancel(user=request.user) + + page.unpublish(user=request.user) + messages.success( + request=request, message=_("This page has been successfully removed.") + ) + return redirect(reverse("news")) + + @login_required @require_POST def generate_description(request): diff --git a/pages/migrations/0005_remove_postindexpage_tags_remove_postpage_tags_and_more.py b/pages/migrations/0005_remove_postindexpage_tags_remove_postpage_tags_and_more.py new file mode 100644 index 000000000..d2e4c6095 --- /dev/null +++ b/pages/migrations/0005_remove_postindexpage_tags_remove_postpage_tags_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.2 on 2026-07-29 23:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0004_alter_postpage_content"), + ] + + operations = [ + migrations.RemoveField( + model_name="postindexpage", + name="tags", + ), + migrations.RemoveField( + model_name="postpage", + name="tags", + ), + migrations.RemoveField( + model_name="routablehomepage", + name="tags", + ), + ] diff --git a/pages/migrations/0006_postpage_tags_alter_taggedcontent_content_object.py b/pages/migrations/0006_postpage_tags_alter_taggedcontent_content_object.py new file mode 100644 index 000000000..67265a36c --- /dev/null +++ b/pages/migrations/0006_postpage_tags_alter_taggedcontent_content_object.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.2 on 2026-07-29 23:46 + +import django.db.models.deletion +import modelcluster.contrib.taggit +import modelcluster.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0005_remove_postindexpage_tags_remove_postpage_tags_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="postpage", + name="tags", + field=modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="pages.TaggedContent", + to="pages.ContentTag", + verbose_name="Tags", + ), + ), + migrations.AlterField( + model_name="taggedcontent", + name="content_object", + field=modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tagged_items", + to="pages.postpage", + ), + ), + ] diff --git a/pages/mixins.py b/pages/mixins.py index e218184a9..345b9e283 100644 --- a/pages/mixins.py +++ b/pages/mixins.py @@ -1,5 +1,4 @@ from django.db import models -from modelcluster.contrib.taggit import ClusterTaggableManager from modelcluster.fields import ParentalKey from taggit.models import ItemBase from taggit.models import TagBase @@ -26,25 +25,13 @@ class TaggedContent(ItemBase): on_delete=models.CASCADE, ) content_object = ParentalKey( - to="wagtailcore.Page", + to="pages.PostPage", on_delete=models.CASCADE, related_name="tagged_items", ) -class TaggableMixin(Page): - tags = ClusterTaggableManager( - through="pages.TaggedContent", - blank=True, - ) - - content_panels = Page.content_panels + ["tags"] - - class Meta: - abstract = True - - -class BasePage(V3Mixin, TaggableMixin, Page): +class BasePage(V3Mixin, Page): """ Abstract Base Page for all our new Pages to inherit from """ diff --git a/pages/models.py b/pages/models.py index 65585a799..0ba0dcbce 100644 --- a/pages/models.py +++ b/pages/models.py @@ -4,8 +4,12 @@ from django.core.paginator import Paginator from django.db import models +from django.urls import reverse_lazy from django.utils.functional import cached_property from django.utils.text import slugify +from django.utils.timezone import localtime, now + +from modelcluster.contrib.taggit import ClusterTaggableManager from pages.blocks import POST_BLOCKS @@ -214,6 +218,7 @@ class PostPage(BasePage): summary = models.TextField( blank=True, default="", help_text="AI generated summary. Delete to regenerate." ) + tags = ClusterTaggableManager(through="pages.TaggedContent", blank=True) def get_content(self): if self.post_content_type in ["News", "Blogpost"]: @@ -223,17 +228,29 @@ def get_content(self): def get_context(self, request, *args, **kwargs): ctx = super().get_context(request, *args, **kwargs) - pages = self.__class__.objects.live().order_by("-first_published_at") - if self.live: + pages: models.QuerySet = PostPage.objects.live().order_by("-first_published_at") + if self.first_published_at: next_objects = pages.filter(first_published_at__gt=self.first_published_at) else: next_objects = pages - ctx["next_post_items"] = [next_objects.last()] - ctx["related_posts"] = pages.filter(content__0__type=self.stream_content_type)[ - :3 - ] + if next_objects.exists(): + ctx["next_post_items"] = [next_objects.last()] + if self.tags.exists(): + ctx["related_posts"] = ( + pages.filter(tags__in=self.tags.all()) + .exclude(pk=self.pk) + .distinct()[:3] + ) + else: + ctx["related_posts"] = pages.filter( + content__0__type=self.stream_content_type + )[:3] ctx["object"] = self.specific ctx["post_author"] = self.author + ctx["user_can_edit"] = self.user_can_edit(request.user) + ctx["user_can_delete"] = self.user_can_delete(request.user) + ctx["user_can_approve"] = False + return ctx def save(self, *args, **kwargs): @@ -251,6 +268,17 @@ def save(self, *args, **kwargs): return result + def get_preview_context(self, request, mode_name): + ctx = super().get_preview_context(request, mode_name) + ctx["is_preview"] = True + return ctx + + def edit_url(self): + return reverse_lazy("v3-news-edit", kwargs={"slug": self.slug}) + + def delete_url(self): + return reverse_lazy("v3-news-delete", kwargs={"slug": self.slug}) + @cached_property def use_summary(self): return bool(len(self.summary)) @@ -263,6 +291,10 @@ def visible_content(self): return self.summary return self.content + @property + def needs_approval(self): + return self.workflow_in_progress + @cached_property def stream_content_type(self): if not len(self.content): @@ -332,7 +364,27 @@ def external_url(self): else: return None + def _in_edit_window(self): + first_revision = self.revisions.order_by("created_at").first() + if not first_revision: + return False + + right_now = localtime(now()) + td = abs(right_now - first_revision.created_at) + if td.days > 0 or abs(right_now - first_revision.created_at).seconds > ( + 6 * 60 * 60 + ): + return False + return True + + def user_can_edit(self, user): + return self.owner == user and self._in_edit_window() + + def user_can_delete(self, user): + return self.owner == user and self._in_edit_window() + content_panels = BasePage.content_panels + [ + "tags", "content", "image", "summary", diff --git a/templates/news/v3/create.html b/templates/news/v3/create.html index bfa7e4fa4..a5a174034 100644 --- a/templates/news/v3/create.html +++ b/templates/news/v3/create.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} -{% block title %}Create Post{% endblock %} +{% block title %}{{title}}{% endblock %} {% block css %} {{ block.super }} @@ -22,16 +22,16 @@ html:not(.js) .create-post-page__description-field { display: none; } html:not(.js) .field--file .field__control { cursor: pointer; } - html:not(.js) form:has(select[name="post_type"] option[value="blog"]:checked) .create-post-page__content-field, + html:not(.js) form:has(select[name="post_type"] option[value="blogpost"]:checked) .create-post-page__content-field, html:not(.js) form:has(select[name="post_type"] option[value="news"]:checked) .create-post-page__content-field { display: flex; } - html:not(.js) form:has(select[name="post_type"] option[value="blog"]:checked) .create-post-page__description-section, + html:not(.js) form:has(select[name="post_type"] option[value="blogpost"]:checked) .create-post-page__description-section, html:not(.js) form:has(select[name="post_type"] option[value="news"]:checked) .create-post-page__description-section { display: flex; } html:not(.js) form:has(select[name="post_type"] option[value="link"]:checked) .create-post-page__description-field, html:not(.js) form:has(select[name="post_type"] option[value="video"]:checked) .create-post-page__description-field { display: flex; } - html:not(.js) form:has(select[name="post_type"] option[value="blog"]:checked) .create-post-page__link-fields, + html:not(.js) form:has(select[name="post_type"] option[value="blogpost"]:checked) .create-post-page__link-fields, html:not(.js) form:has(select[name="post_type"] option[value="news"]:checked) .create-post-page__link-fields { display: none; } @@ -45,14 +45,14 @@ enctype="multipart/form-data" method="post" :novalidate="true" - action="{% url 'v3-news-create' %}" + action="" @submit="prepareSubmit" data-create-post-form > {% csrf_token %}
-

Create Post

+

{{title}}

Posts will be reviewed by the moderation team before they appear in the feed.

@@ -60,7 +60,7 @@

Create Post

{% comment %} Post Type dropdown: Blog, News, Video, Link. Placeholder "Select Post Type". Controls conditional fields. {% endcomment %} - {% include "v3/includes/_field_dropdown.html" with name="post_type" label="Post type *" options=post_type_options placeholder="Select Post Type" required=True selected=post_type_selected alpine_error="errors.post_type" %} + {% include "v3/includes/_field_dropdown.html" with name="post_type" label="Post type *" options=post_type_options placeholder="Select Post Type" required=True selected=post_type_selected alpine_error="errors.post_type" disabled=edit %} {% include "v3/includes/_field_text.html" with name="title" label="Post title *" placeholder="Title" value=form.title.value|default_if_none:"" required=True alpine_error="errors.title" error=form.title.errors.0 %} @@ -182,9 +182,9 @@

Create Post

- {% include "v3/includes/_field_file.html" with name="image" label="Image" accept="image/png,image/jpeg" preview=True alpine_error="errors.image" error=form.errors.image.0 help_text="This should be a PNG or JPEG format and no larger than 5MB" extra_class="field--file-narrow create-post-page__field-image" %} + {% include "v3/includes/_field_file.html" with name="image" label="Image" accept="image/png,image/jpeg" preview=True alpine_error="errors.image" error=form.errors.image.0 help_text="This should be a PNG or JPEG format and no larger than 5MB" extra_class="field--file-narrow create-post-page__field-image" preview_url=current_image %} - {% include "v3/includes/_field_multiselect.html" with name="related_libraries" label="Related Libraries" options=related_libraries_options placeholder="Select" selected=form.data.related_libraries %} + {% include "v3/includes/_field_multiselect.html" with name="related_libraries" label="Related Libraries" options=related_libraries_options placeholder="Select" selected_values=related_libraries %} {% include "v3/includes/_field_datetime.html" with name="publish_at" label="Publish Date *" value=form.publish_at.value|default:publish_at_initial %} @@ -229,7 +229,7 @@

Create Post

image: '{{ form.image.errors.0|default:""|escapejs }}', }, - get isWriteUp() { return this.postType === 'blog' || this.postType === 'news'; }, + get isWriteUp() { return this.postType === 'blogpost' || this.postType === 'news'; }, get isLinkType() { return this.postType === 'video' || this.postType === 'link'; }, get isLink() { return this.postType === 'link'; }, // Auto-Generate fetches and summarizes the linked page, so enable it @@ -256,11 +256,13 @@

Create Post

// Restore saved Title / Link URL / Content / Description / Link-Description // drafts for the current post type. On a fresh load with no draft this // leaves the field untouched. - this.restoreTitleDraft(); - this.restoreExternalUrlDraft(); - this.restoreContentDraft(); - this.restoreDescriptionDraft(); - this.restoreLinkDescriptionDraft(); + {% if not edit %} + this.restoreTitleDraft(); + this.restoreExternalUrlDraft(); + this.restoreContentDraft(); + this.restoreDescriptionDraft(); + this.restoreLinkDescriptionDraft(); + {% endif %} }, handleFieldChange(e) { diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html index 3e9eb5fec..561220a70 100644 --- a/templates/news/v3/detail.html +++ b/templates/news/v3/detail.html @@ -18,28 +18,31 @@ Entry deleted on {{ object.deleted_at|date:"m/d/Y" }}{% if object.deleted_by %} by {{ object.deleted_by.display_name }}{% endif %}.
{% endif %} - {% if user_can_approve or user_can_edit or user_can_delete %} -
- {% if not object.is_approved and not object.deleted_at %} - {% if user_can_approve %} -
+ {% if not is_preview %} + {% if user_can_approve or user_can_edit or user_can_delete %} +
+ {% if object.needs_approval %} + {% if user_can_approve %} + + {% csrf_token %} + {% include "v3/includes/_button.html" with label="Approve" type="submit" style="green" %} + + {% else %} + Pending Moderation + {% endif %} + {% endif %} + {% if user_can_edit and not object.deleted_at %} + {% include "v3/includes/_button.html" with url=object.edit_url label="Edit" style="secondary" %} + {% endif %} + {% if user_can_delete and not object.deleted_at %} + {% include "v3/includes/_button.html" with url='#delete-modal' label="Delete" style="error" %} +
{% csrf_token %} - {% if next_url %}{% endif %} - {% include "v3/includes/_button.html" with label="Approve" type="submit" style="green" %} + {% include 'v3/includes/_dialog.html' with dialog_id="delete-modal" title="Delete Post" primary_label="Delete" submit=True secondary_label="Cancel" primary_style="error" description="Are you sure you want to delete this post? This can't be undone." only %}
- {% else %} - Pending Moderation {% endif %} - {% endif %} - {% if user_can_edit and not object.deleted_at %} - {% url 'news-update' object.slug as edit_url %} - {% include "v3/includes/_button.html" with url=edit_url label="Edit" style="secondary" %} - {% endif %} - {% if user_can_delete and not object.deleted_at %} - {% url 'news-delete' object.slug as delete_url %} - {% include "v3/includes/_button.html" with url=delete_url label="Delete" style="error" %} - {% endif %} -
+
+ {% endif %} {% endif %} {% include "v3/includes/_post_header.html" with title=object.title publish_date=object.publish_at tag=post_tag author=post_author %} diff --git a/templates/v3/includes/_dialog.html b/templates/v3/includes/_dialog.html index 89e6a0d33..20c96deaf 100644 --- a/templates/v3/includes/_dialog.html +++ b/templates/v3/includes/_dialog.html @@ -13,6 +13,7 @@ secondary_label (required): Label for the secondary action button. primary_style (optional): Button style for primary action. Default "secondary-grey". secondary_style (optional): Button style for secondary action. Default "primary". + submit (optional): Renders primary button as a "submit", to allow dialog to be part of a form primary_url (optional): URL for primary button. Defaults to "" (renders as