From 167fd30cb11d2d2cf92d9f3d1f507d25e4b063c2 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 5 Nov 2025 12:53:15 +0000 Subject: [PATCH 1/4] WIP in discussions. --- src/discussion/admin.py | 1 + src/discussion/forms.py | 39 +- ...participants_alter_post_thread_and_more.py | 32 + src/discussion/models.py | 109 +++- src/discussion/partial_views.py | 116 ++++ src/discussion/urls.py | 50 +- src/discussion/views.py | 259 +++++--- src/security/decorators.py | 39 +- src/static/admin/css/discussions.css | 567 ++++++++++++++++++ .../discussion/partials/invite_search.html | 69 +++ .../discussion/partials/new_thread_form.html | 28 + .../discussion/partials/thread_detail.html | 136 +++++ .../discussion/partials/thread_list.html | 26 + src/templates/admin/discussion/threads.html | 2 +- .../admin/discussion/threads_base.html | 50 ++ 15 files changed, 1417 insertions(+), 106 deletions(-) create mode 100644 src/discussion/migrations/0005_thread_participants_alter_post_thread_and_more.py create mode 100644 src/discussion/partial_views.py create mode 100644 src/static/admin/css/discussions.css create mode 100644 src/templates/admin/discussion/partials/invite_search.html create mode 100644 src/templates/admin/discussion/partials/new_thread_form.html create mode 100644 src/templates/admin/discussion/partials/thread_detail.html create mode 100644 src/templates/admin/discussion/partials/thread_list.html create mode 100644 src/templates/admin/discussion/threads_base.html diff --git a/src/discussion/admin.py b/src/discussion/admin.py index 3b17264ba1..318ca18dda 100644 --- a/src/discussion/admin.py +++ b/src/discussion/admin.py @@ -23,6 +23,7 @@ class ThreadAdmin(admin_utils.ArticleFKModelAdmin): "post__body", ) raw_id_fields = ("owner", "article", "preprint") + filter_horizontal = ("participants",) inlines = [admin_utils.PostInline] diff --git a/src/discussion/forms.py b/src/discussion/forms.py index b1d4562f1d..f58086ee0e 100644 --- a/src/discussion/forms.py +++ b/src/discussion/forms.py @@ -6,25 +6,38 @@ class ThreadForm(forms.ModelForm): class Meta: model = models.Thread - fields = ("subject",) - - def __init__(self, *args, **kwargs): + fields = ( + "subject", + ) + + def __init__( + self, + *args, + **kwargs, + ): self.object = kwargs.pop("object") self.object_type = kwargs.pop("object_type") self.owner = kwargs.pop("owner") super(ThreadForm, self).__init__(*args, **kwargs) - def save(self, commit=True): - thread = super(ThreadForm, self).save(commit=False) - + # Attach FK + owner BEFORE validation so model.clean() passes if self.object_type == "article": - thread.article = self.object + self.instance.article = self.object + self.instance.preprint = None else: - thread.preprint = self.object - - thread.owner = self.owner - + self.instance.preprint = self.object + self.instance.article = None + + self.instance.owner = self.owner + + def save( + self, + commit=True, + ): + thread = super(ThreadForm, self).save( + commit=False, + ) + # Instance already has article/preprint/owner set in __init__ if commit: thread.save() - - return thread + return thread \ No newline at end of file diff --git a/src/discussion/migrations/0005_thread_participants_alter_post_thread_and_more.py b/src/discussion/migrations/0005_thread_participants_alter_post_thread_and_more.py new file mode 100644 index 0000000000..95caf2ef3f --- /dev/null +++ b/src/discussion/migrations/0005_thread_participants_alter_post_thread_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.20 on 2025-10-10 12:23 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('discussion', '0004_auto_20200925_1933'), + ] + + operations = [ + migrations.AddField( + model_name='thread', + name='participants', + field=models.ManyToManyField(blank=True, help_text='Users who are allowed to access this thread.', related_name='accessible_threads', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='post', + name='thread', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts_related', to='discussion.thread'), + ), + migrations.AlterField( + model_name='thread', + name='last_updated', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now), + ), + ] diff --git a/src/discussion/models.py b/src/discussion/models.py index c014f4c540..c6ac580cd9 100644 --- a/src/discussion/models.py +++ b/src/discussion/models.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from django.utils import timezone +from django.utils.timesince import timesince class Thread(models.Model): @@ -31,6 +32,13 @@ class Thread(models.Model): ) last_updated = models.DateTimeField( default=timezone.now, + db_index=True, + ) + participants = models.ManyToManyField( + "core.Account", + blank=True, + related_name="accessible_threads", + help_text=_("Users who are allowed to access this thread."), ) class Meta: @@ -39,6 +47,16 @@ class Meta: def __str__(self): return self.subject + def clean(self): + if self.article and self.preprint: + raise ValidationError( + _("A thread can only be attached to either an article or a preprint, not both."), + ) + if not self.article and not self.preprint: + raise ValidationError( + _("A thread must be attached to either an article or a preprint."), + ) + def object_title(self): if self.article: return self.article.safe_title @@ -54,26 +72,70 @@ def object_string(self): def object_id(self): if self.article: return self.article.pk - else: + if self.preprint: return self.preprint.pk + return None def posts(self): - return self.post_set.all() + return self.posts_related.all() - def create_post(self, owner, body): - self.last_updated = timezone.now() - self.save() - return self.post_set.create( + def create_post( + self, + owner, + body, + ): + post = self.posts_related.create( owner=owner, body=body, ) + # 🧭 Ensure the user is a participant if they're allowed to post + if owner not in self.participants.all(): + self.participants.add(owner) + + # 🕒 Update the last_updated timestamp so threads sort correctly + self.last_updated = timezone.now() + self.save(update_fields=["last_updated"]) + + return post + + def user_can_access(self, user): + """ + Check whether a user can access this thread. + + Conditions: + - User is the owner + - User is in participants + - User is editor of the journal + - User is manager of the repository + """ + if not user.is_authenticated: + return False + + # Participant / owner + if self.owner == user: + return True + if self.participants.filter(pk=user.pk).exists(): + return True + + # Editor or manager + if self.article and self.article.journal: + if user in self.article.journal.editors(): + return True + + if self.preprint and self.preprint.repository: + if user in self.preprint.repository.managers.all(): + return True + + return False + class Post(models.Model): thread = models.ForeignKey( Thread, null=True, - on_delete=models.SET_NULL, + on_delete=models.CASCADE, + related_name="posts_related", ) owner = models.ForeignKey( "core.Account", @@ -93,13 +155,32 @@ class Post(models.Model): class Meta: ordering = ("-posted", "pk") - def user_has_read(self, user): - if user in self.read_by.all(): - return True - return False + def __str__(self): + owner_str = self.owner if self.owner else "Unknown" + return f"Post by {owner_str} on {self.thread}" + + def save( + self, + *args, + **kwargs, + ): + super().save(*args, **kwargs) + if self.thread: + self.thread.last_updated = timezone.now() + self.thread.save( + update_fields=["last_updated"], + ) + + def user_has_read( + self, + user, + ): + return self.read_by.filter( + pk=user.pk, + ).exists() def display_date(self): - if self.posted.date() == timezone.now().date(): + now = timezone.now() + if self.posted.date() == now.date(): return "Today" - else: - return self.posted.date() + return f"{timesince(self.posted, now)} ago" diff --git a/src/discussion/partial_views.py b/src/discussion/partial_views.py new file mode 100644 index 0000000000..eac1f1860b --- /dev/null +++ b/src/discussion/partial_views.py @@ -0,0 +1,116 @@ +from django.shortcuts import render, get_object_or_404 +from discussion.models import Thread +from submission import models as submission_models +from repository import models as repository_models +from security.decorators import can_access_thread + + +def threads_list_partial( + request, + object_type, + object_id, +): + """ + Returns only the list of threads the current user can access. + No decorator needed here since we filter per-thread access. + """ + if object_type == "article": + object_to_get = get_object_or_404( + submission_models.Article, + pk=object_id, + journal=request.journal, + ) + qs = Thread.objects.filter(article=object_to_get) + else: + object_to_get = get_object_or_404( + repository_models.Preprint, + pk=object_id, + repository=request.repository, + ) + qs = Thread.objects.filter(preprint=object_to_get) + + # Filter threads by access + accessible_threads = [ + t for t in qs.select_related("owner").prefetch_related("participants") + if t.user_can_access(request.user) + ] + + return render( + request, + "admin/discussion/partials/thread_list.html", + { + "threads": accessible_threads, + "object": object_to_get, + "object_type": object_type, + }, + ) + + +@can_access_thread +def thread_detail_partial( + request, + object_type, + object_id, + thread_id, +): + """ + Returns a single thread detail. + Access control happens via @can_access_thread. + """ + thread = get_object_or_404( + Thread, + pk=thread_id, + ) + + posts = thread.posts() + return render( + request, + "admin/discussion/partials/thread_detail.html", + { + "thread": thread, + "posts": posts, + "object_type": object_type, + "object_id": object_id, + }, + ) + + +def new_thread_form_partial( + request, + object_type, + object_id, +): + """ + Renders a new thread creation form as a partial. + Useful for HTMX modals. + """ + if object_type == "article": + object_to_get = get_object_or_404( + submission_models.Article, + pk=object_id, + journal=request.journal, + ) + else: + object_to_get = get_object_or_404( + repository_models.Preprint, + pk=object_id, + repository=request.repository, + ) + + from discussion import forms + + form = forms.ThreadForm( + object=object_to_get, + object_type=object_type, + owner=request.user, + ) + + return render( + request, + "admin/discussion/partials/new_thread_form.html", + { + "form": form, + "object": object_to_get, + "object_type": object_type, + }, + ) diff --git a/src/discussion/urls.py b/src/discussion/urls.py index d2a4c1f9e7..4947c3030f 100644 --- a/src/discussion/urls.py +++ b/src/discussion/urls.py @@ -2,24 +2,70 @@ __author__ = "Martin Paul Eve, Andy Byers & Mauro Sanchez" __license__ = "AGPL v3" __maintainer__ = "Birkbeck Centre for Technology and Publishing" -from django.urls import re_path -from discussion import views +from django.urls import re_path +from discussion import views, partial_views urlpatterns = [ + # === Full page views === + # Base threads page — shell for HTMX interface re_path( r"^(?Ppreprint|article)/(?P\d+)/$", views.threads, name="discussion_threads", ), + # Legacy thread view re_path( r"^(?Ppreprint|article)/(?P\d+)/thread/(?P\d+)/$", views.threads, + name="discussion_thread_legacy", + ), + # New full page thread view (this is what we push with hx-push-url) + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/(?P\d+)/$", + views.threads, name="discussion_thread", ), + # Post re_path( r"^thread/(?P\d+)/post/new/$", views.add_post, name="discussion_add_post", ), + # === HTMX partials === + # Sidebar list + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/list/$", + partial_views.threads_list_partial, + name="discussion_threads_list_partial", + ), + # Thread detail fragment — now at `/partial/` + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/(?P\d+)/partial/$", + partial_views.thread_detail_partial, + name="discussion_thread_detail_partial", + ), + # Modal for new thread creation + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/new/$", + partial_views.new_thread_form_partial, + name="discussion_new_thread_modal", + ), + # Handle new thread creation (POST) + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/create/$", + views.create_thread, + name="discussion_create_thread", + ), + re_path( + r"^threads/(?P\d+)/invite/search/$", + views.ThreadInviteUserListView.as_view(), + name="discussion_invite_search", + ), + re_path( + r"^threads/(?P\d+)/invite/add/$", + views.add_participant, + name="discussion_add_participant", + ), ] + diff --git a/src/discussion/views.py b/src/discussion/views.py index 4e73fd3d99..91e35a9470 100644 --- a/src/discussion/views.py +++ b/src/discussion/views.py @@ -1,105 +1,216 @@ -from django.shortcuts import render, get_object_or_404, redirect, reverse -from django.http import Http404 +from django.http import HttpResponse +from django.shortcuts import ( + get_object_or_404, + render, +) +from django.template.loader import render_to_string from django.views.decorators.http import require_POST +from django.utils.decorators import method_decorator +from django.http import HttpResponseBadRequest -from discussion import models, forms -from submission import models as submission_models +from core.views import BaseUserList +from core import models as core_models +from discussion import forms, models from repository import models as repository_models -from security.decorators import editor_or_manager +from security.decorators import can_access_thread, editor_user_required +from submission import models as submission_models +from review.models import ReviewAssignment +from copyediting.models import CopyeditAssignment +from typesetting.models import TypesettingAssignment -@editor_or_manager def threads(request, object_type, object_id, thread_id=None): - """ - Grabs threads for an object type. - """ - modal = None - if object_type == "article": - object_to_get = get_object_or_404( + obj = get_object_or_404( submission_models.Article, pk=object_id, journal=request.journal, ) - threads = models.Thread.objects.filter( - article=object_to_get, - ) else: - object_to_get = get_object_or_404( + obj = get_object_or_404( repository_models.Preprint, pk=object_id, repository=request.repository, ) - threads = models.Thread.objects.filter( - preprint=object_to_get, - ) - if thread_id: - try: - thread = threads.get(pk=thread_id) - except models.Thread.DoesNotExist: - raise Http404 - else: - thread = None + return render( + request, + "admin/discussion/threads_base.html", + { + "object": obj, + "object_type": object_type, + "active_thread_id": thread_id, + }, + ) + + +@require_POST +@can_access_thread +def add_post(request, thread_id): + thread = get_object_or_404(models.Thread, pk=thread_id) + + body = request.POST.get("new_post", "").strip() + if body: + thread.create_post(request.user, body) - form = forms.ThreadForm( - object=object_to_get, - object_type=object_type, - owner=request.user, + return render( + request, + "admin/discussion/partials/thread_detail.html", + { + "thread": thread, + "posts": thread.posts(), + "object_type": thread.object_string(), + "object": thread.article or thread.preprint, + }, ) - if request.POST: + +def create_thread(request, object_type, object_id): + if object_type == "article": + obj = get_object_or_404( + submission_models.Article, + pk=object_id, + journal=request.journal, + ) + else: + obj = get_object_or_404( + repository_models.Preprint, + pk=object_id, + repository=request.repository, + ) + + if request.method == "POST": form = forms.ThreadForm( request.POST, - object=object_to_get, + object=obj, object_type=object_type, owner=request.user, ) if form.is_valid(): - thread = form.save() - return redirect( - reverse( - "discussion_thread", - kwargs={ - "object_type": thread.object_string(), - "object_id": thread.object_id(), - "thread_id": thread.pk, - }, - ) + thread = form.save(commit=False) + # 🔸 attach the object here! + if object_type == "article": + thread.article = obj + else: + thread.preprint = obj + thread.owner = request.user + thread.save() + + # ✅ return updated list partial + threads = models.Thread.objects.filter( + article=obj if object_type == "article" else None, + preprint=obj if object_type == "preprint" else None, + ) + html = render_to_string( + "admin/discussion/partials/thread_list.html", + { + "object": obj, + "object_type": object_type, + "threads": threads, + }, + request=request, + ) + return HttpResponse(html) + else: + form = forms.ThreadForm( + object=obj, + object_type=object_type, + owner=request.user, + ) + + return render( + request, + "admin/discussion/partials/new_thread_form.html", + { + "form": form, + "object": obj, + "object_type": object_type, + }, + ) + + +@method_decorator(editor_user_required, name="dispatch") +class ThreadInviteUserListView(BaseUserList): + """ + Reuses the BaseUserList to display potential invitees for a discussion thread. + Only lists active users and excludes participants already in the thread. + """ + template_name = "admin/discussion/partials/invite_search.html" + + def dispatch(self, request, *args, **kwargs): + self.thread = get_object_or_404( + models.Thread, + pk=self.kwargs.get("thread_id"), + ) + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.filter(is_active=True) + + if self.request.journal: + qs = qs.filter(accountrole__journal=self.request.journal) + + participant_ids = self.thread.participants.values_list("id", flat=True) + qs = qs.exclude(pk__in=participant_ids) + + return qs.distinct() + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["thread"] = self.thread + + article = self.thread.article + + # Collect IDs of users in various roles + reviewers = ReviewAssignment.objects.filter( + article=article + ).values_list("reviewer", flat=True) + + copyeditors = CopyeditAssignment.objects.filter( + article=article + ).values_list("copyeditor", flat=True) + + typesetters = TypesettingAssignment.objects.filter( + round__article=article + ).values_list("typesetter", flat=True) + + managers = TypesettingAssignment.objects.filter( + round__article=article + ).values_list("manager", flat=True) + + authors = article.authors.values_list("pk", flat=True) + + role_ids = set( + filter( + None, + list(reviewers) + + list(copyeditors) + + list(typesetters) + + list(managers) + + list(authors), ) - else: - modal = "new_thread" + ) - template = "admin/discussion/threads.html" - context = { - "object": object_to_get, - "object_type": object_type, - "threads": threads, - "active_thread": thread, - "form": form, - "modal": modal, - } - return render(request, template, context) + # Exclude participants and filter only active users here too + role_ids = role_ids.difference(set(self.thread.participants.values_list("pk", flat=True))) + + ctx["role_users"] = self.model.objects.filter( + pk__in=role_ids, + is_active=True + ).distinct() + + return ctx @require_POST -@editor_or_manager -def add_post(request, thread_id): - thread = get_object_or_404( - models.Thread, - pk=thread_id, - ) - thread.create_post( - request.user, - request.POST.get("new_post"), - ) - return redirect( - reverse( - "discussion_thread", - kwargs={ - "object_type": thread.object_string(), - "object_id": thread.object_id(), - "thread_id": thread.pk, - }, - ) - ) +@editor_user_required +def add_participant(request, thread_id): + thread = get_object_or_404(models.Thread, pk=thread_id) + user_id = request.POST.get("user_id") + if not user_id: + return HttpResponseBadRequest("Missing user_id") + user = get_object_or_404(core_models.Account, pk=user_id) + user = get_object_or_404(core_models.Account, pk=user_id) + thread.participants.add(user) + return HttpResponse(status=204) diff --git a/src/security/decorators.py b/src/security/decorators.py index a45b181948..81ef810d92 100755 --- a/src/security/decorators.py +++ b/src/security/decorators.py @@ -28,6 +28,7 @@ from utils import setting_handler from utils.logger import get_logger from repository import models as preprint_models +from discussion import models as discussion_models logger = get_logger(__name__) @@ -142,7 +143,7 @@ def editor_or_manager(func): @base_check_required def wrapper(request, *args, **kwargs): - if request.journal and request.user in request.journal.editor_list(): + if request.journal and request.user in request.journal.editors(): return func(request, *args, **kwargs) if request.repository and request.user in request.repository.managers.all(): @@ -153,6 +154,40 @@ def wrapper(request, *args, **kwargs): return wrapper +def can_access_thread(func): + """ + Checks if the user can access the thread or has global editor/manager access. + """ + @base_check_required + @wraps(func) + def wrapper(request, *args, **kwargs): + user = request.user + + thread_id = kwargs.get("thread_id") + if thread_id: + try: + thread = discussion_models.Thread.objects.get(pk=thread_id) + except discussion_models.Thread.DoesNotExist: + return deny_access(request) + + if thread.user_can_access(user): + return func(request, *args, **kwargs) + + return deny_access(request) + + # If no thread_id provided (e.g. thread list), + # allow access if the user is editor/manager for the object + if request.journal and user in request.journal.editor_list(): + return func(request, *args, **kwargs) + + if request.repository and user in request.repository.managers.all(): + return func(request, *args, **kwargs) + + return deny_access(request) + + return wrapper + + def production_manager_roles(func): """ Checks if the current user has one of the production manager roles. @@ -1419,7 +1454,7 @@ def review_required_wrapper(request, article_id=None, *args, **kwargs): article = get_object_or_404(models.Article, pk=article_id) - if not article.stage in models.REVIEW_STAGES: + if article.stage not in models.REVIEW_STAGES: deny_access(request) else: return func(request, article_id, *args, **kwargs) diff --git a/src/static/admin/css/discussions.css b/src/static/admin/css/discussions.css new file mode 100644 index 0000000000..b343275eb3 --- /dev/null +++ b/src/static/admin/css/discussions.css @@ -0,0 +1,567 @@ +/* Discussion Thread System - Custom Styles */ +/* Color Palette: Dark Slate Sidebar (#3A4556), Light Blue Messages (#7CB9E8) */ + +:root { + --slate-dark: #3a4556; + --slate-darker: #2d3542; + --slate-hover: #434e62; + --blue-light: #7cb9e8; + --blue-lighter: #a8d5f2; + --blue-message: #b8d9f0; + --gray-light: #f5f7fa; + --gray-border: #e1e8ed; + --text-dark: #1a1f2e; + --text-muted: #6b7280; + --white: #ffffff; +} + +/* Base Layout */ +.thread-box-left, +.thread-box-right { + padding: 0; +} + +.thread-box { + background: var(--white); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + height: calc(100vh - 200px); + overflow-y: auto; + padding: 0; +} + +.thread-dark { + background: var(--slate-dark); + color: var(--white); +} + +/* Thread List Sidebar */ +.thread-list-header { + padding: 1.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: var(--slate-dark); + z-index: 1; +} + +.thread-list-header h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--white); +} + +.thread-list-header .button { + margin: 0; + background: var(--blue-light); + color: var(--white); + font-size: 0.875rem; + padding: 0.625rem 1rem; + border-radius: 6px; + transition: all 0.2s ease; + border: none; +} + +.thread-list-header .button:hover { + background: var(--blue-lighter); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(124, 185, 232, 0.3); +} + +/* Thread List Items */ +.thread-list-item { + cursor: pointer; + transition: background-color 0.2s ease; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.thread-list-item:hover { + background: var(--slate-hover); +} + +.thread-list-item:last-child { + border-bottom: none; +} + +.thread-callout { + padding: 1.25rem 1.5rem; + background: transparent; + border: none; + margin: 0; +} + +.thread-active .thread-callout { + background: var(--slate-darker); + border-left: 3px solid var(--blue-light); +} + +.thread-callout .row { + margin: 0; +} + +.thread-callout h2 { + font-size: 1rem; + font-weight: 600; + color: var(--white); + margin: 0 0 0.5rem 0; + line-height: 1.4; +} + +.thread-callout .subheader { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); + margin: 0; + line-height: 1.5; +} + +.thread-callout .subheader .fa-comment-o { + color: var(--blue-light); + margin-right: 0.25rem; +} + +.thread-callout h3.subheader { + font-size: 0.875rem; + color: var(--blue-light); + font-weight: 600; +} + +/* Avatar Styles */ +.avatar-column { + padding-right: 0.75rem; +} + +.thread-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--blue-light); + color: var(--white); + font-weight: 600; + font-size: 0.875rem; + overflow: hidden; + flex-shrink: 0; +} + +.thread-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Thread Detail View */ +.thread-detail-header { + padding: 1.5rem 2rem; + border-bottom: 1px solid var(--gray-border); + background: var(--white); + position: sticky; + top: 0; + z-index: 1; +} + +.thread-detail-header h2 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-dark); + margin: 0 0 0.5rem 0; +} + +.thread-detail-header .subheader { + font-size: 0.875rem; + color: var(--text-muted); + margin: 0; +} + +/* Participants Section Styles */ +.thread-participants { + padding-top: 1rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.participants-label { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-dark); + white-space: nowrap; +} + +.participants-label .fa { + color: var(--blue-light); + margin-right: 0.5rem; +} + +.participants-list { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.participant-item { + position: relative; +} + +.participant-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--blue-light); + color: var(--white); + font-weight: 600; + font-size: 0.75rem; + overflow: hidden; + border: 2px solid var(--white); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease; + cursor: pointer; +} + +.participant-avatar:hover { + transform: scale(1.1); + z-index: 1; +} + +.participant-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.no-participants { + font-size: 0.875rem; + color: var(--text-muted); + font-style: italic; +} + +/* New Post Form */ +.new-post-form { + padding: 1.5rem 2rem; + background: var(--gray-light); + border-bottom: 1px solid var(--gray-border); +} + +.new-post-form-bottom { + position: sticky; + bottom: 0; + border-bottom: none; + border-top: 1px solid var(--gray-border); + background: var(--white); + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05); + z-index: 10; +} + +.new-post-form textarea { + border: 1px solid var(--gray-border); + border-radius: 6px; + padding: 0.75rem; + font-size: 0.9375rem; + resize: vertical; + min-height: 80px; + transition: border-color 0.2s ease; +} + +.new-post-form-bottom textarea { + min-height: 60px; +} + +.new-post-form textarea:focus { + border-color: var(--blue-light); + box-shadow: 0 0 0 3px rgba(124, 185, 232, 0.1); +} + +.new-post-form .button { + background: var(--blue-light); + color: var(--white); + border-radius: 6px; + font-weight: 600; + transition: all 0.2s ease; + border: none; + height: 100%; + min-height: 80px; +} + +.new-post-form-bottom .button { + min-height: 60px; +} + +.new-post-form .button:hover { + background: var(--blue-lighter); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(124, 185, 232, 0.3); +} + +/* Thread Posts */ +.thread-inner { + padding: 1.5rem 2rem; + padding-bottom: 2rem; + min-height: 70%; +} + +.post-row { + margin-bottom: 1.5rem; +} + +.post-row:last-child { + margin-bottom: 0; +} + +.post-avatar-column { + padding-right: 1rem; +} + +.post-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--blue-light); + color: var(--white); + font-weight: 600; + font-size: 0.8125rem; + overflow: hidden; +} + +.post-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Post Callouts */ +.post-callout { + border-radius: 12px; + padding: 1rem 1.25rem; + margin: 0; + border: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + margin-block-end: 10px; +} + +.post-user-callout { + background: var(--blue-message); + color: var(--text-dark); +} + +.post-other-callout { + background: var(--white); + border: 1px solid var(--gray-border); + color: var(--text-dark); +} + +.post-callout .subheader { + font-size: 0.75rem; + color: var(--text-muted); + margin: 0 0 0.5rem 0; + font-weight: 500; +} + +.post-callout p { + margin: 0; + font-size: 0.9375rem; + line-height: 1.6; + color: var(--text-dark); +} + +.post-callout p:last-child { + margin-bottom: 0; +} + +/* New Thread Modal */ +.reveal .card { + border: none; + box-shadow: none; + margin: 0; +} + +.reveal .card-section { + padding: 1.5rem; +} + +.reveal .button[type="submit"] { + background: var(--blue-light); + color: var(--white); + border-radius: 6px; + font-weight: 600; + transition: all 0.2s ease; + border: none; + width: 100%; +} + +.reveal .button[type="submit"]:hover { + background: var(--blue-lighter); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(124, 185, 232, 0.3); +} + +.reveal .close-button { + color: var(--text-muted); + font-size: 2rem; + transition: color 0.2s ease; +} + +.reveal .close-button:hover { + color: var(--text-dark); +} + +/* Empty State */ +.empty-state { + padding: 3rem 2rem; + text-align: center; +} + +.empty-state .subheader { + color: var(--text-muted); + font-size: 0.9375rem; +} + +/* Scrollbar Styling */ +.thread-box::-webkit-scrollbar { + width: 8px; +} + +.thread-box::-webkit-scrollbar-track { + background: transparent; +} + +.thread-box::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; +} + +.thread-dark::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); +} + +.thread-box::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +.thread-dark::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Responsive Adjustments */ +@media screen and (max-width: 1024px) { + .thread-box { + height: auto; + min-height: 400px; + } + + .thread-box-left { + margin-bottom: 1rem; + } +} + +@media screen and (max-width: 640px) { + .thread-callout { + padding: 1rem; + } + + .thread-detail-header, + .new-post-form, + .thread-inner { + padding: 1rem; + } + + .thread-participants { + padding: 0.75rem 1rem; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .participants-label { + font-size: 0.8125rem; + } + + .participant-avatar { + width: 28px; + height: 28px; + font-size: 0.6875rem; + } + + .post-avatar-column { + padding-right: 0.5rem; + } + + .thread-avatar, + .post-avatar { + width: 32px; + height: 32px; + font-size: 0.75rem; + } +} + +.invite-modal-body { + max-height: 70vh; + overflow-y: auto; + padding: 0.75rem; +} + +.section-title { + font-size: 0.95rem; + font-weight: 600; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + color: #333; +} + +.invite-list { + margin: 0; + padding: 0; +} + +.invite-list-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.4rem 0; + border-bottom: 1px solid #eee; +} + +.invite-list-item:last-child { + border-bottom: none; +} + +.invite-name { + flex: 1; + font-size: 0.9rem; + margin-right: 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.invite-btn { + min-width: 60px; +} + +.scrollable-list { + max-height: 250px; + overflow-y: auto; + margin-top: 0.5rem; +} + +.loading-indicator { + padding: 1rem; + text-align: center; + color: #555; + font-size: 0.95rem; +} + +.loading-indicator .fa-spinner { + margin-right: 0.4rem; +} \ No newline at end of file diff --git a/src/templates/admin/discussion/partials/invite_search.html b/src/templates/admin/discussion/partials/invite_search.html new file mode 100644 index 0000000000..e481772763 --- /dev/null +++ b/src/templates/admin/discussion/partials/invite_search.html @@ -0,0 +1,69 @@ +{% load foundation %} + +
+
+

Invite Participants

+
+ +
+ {% if role_users %} +
People with roles on this article
+
    + {% for user in role_users %} +
  • + {{ user.full_name|default:"[No Name]" }} - {{ user.email }} + +
  • + {% endfor %} +
+ {% endif %} + +
Search and invite any other user
+
+
+ +
+ +
+
+
+ + {% if object_list %} +
+
    + {% for user in object_list %} +
  • + {{ user.full_name|default:"[No Name]" }} - {{ user.email }} + +
  • + {% endfor %} +
+
+ {% else %} +

No users found.

+ {% endif %} +
+
+ + \ No newline at end of file diff --git a/src/templates/admin/discussion/partials/new_thread_form.html b/src/templates/admin/discussion/partials/new_thread_form.html new file mode 100644 index 0000000000..0ff8fa4b61 --- /dev/null +++ b/src/templates/admin/discussion/partials/new_thread_form.html @@ -0,0 +1,28 @@ +{% load foundation %} + +
+
+

Add New Thread

+
+
+ {% include "admin/elements/forms/errors.html" %} +
+ {% csrf_token %} + {{ form|foundation }} +
+
+

You will have the opportunity to invite participants into the thread after it is completed.

+ +
+
+
+
+
+ + \ No newline at end of file diff --git a/src/templates/admin/discussion/partials/thread_detail.html b/src/templates/admin/discussion/partials/thread_detail.html new file mode 100644 index 0000000000..32aaad881a --- /dev/null +++ b/src/templates/admin/discussion/partials/thread_detail.html @@ -0,0 +1,136 @@ +{% load foundation %} + +
+

{{ thread.subject }}

+

+ Started on {{ thread.started|date:"F d, Y \\a\\t g:i A" }} +

+ + +
+
+ + Participants ({{ thread.participants.count }}) +
+ +
+ {% for participant in thread.participants.all %} +
+ {% include "admin/discussion/avatar.html" with user=participant avatar_class="participant-avatar" %} +
+ {% empty %} + No participants yet + {% endfor %} +
+ + +
+ +
+
+
+ + +
+ {% for post in posts %} + {% include "admin/discussion/post.html" %} + {% empty %} +

No posts yet. Start the discussion below.

+ {% endfor %} +
+ + +
+
+ {% csrf_token %} +
+
+ +
+
+ +
+
+
+
+ + +
+ + + + +
+ + +
+ +{% block js %} + + + +{% endblock js %} \ No newline at end of file diff --git a/src/templates/admin/discussion/partials/thread_list.html b/src/templates/admin/discussion/partials/thread_list.html new file mode 100644 index 0000000000..9fb6a5609d --- /dev/null +++ b/src/templates/admin/discussion/partials/thread_list.html @@ -0,0 +1,26 @@ +
+

Discussions

+ + New Thread + +
+ +{% if threads %} + {% for thread in threads %} +
+ {% include "admin/discussion/thread.html" %} +
+ {% endfor %} +{% else %} +
+

No threads yet. Start a new discussion!

+
+{% endif %} diff --git a/src/templates/admin/discussion/threads.html b/src/templates/admin/discussion/threads.html index bd73155dc5..f3af50c30d 100644 --- a/src/templates/admin/discussion/threads.html +++ b/src/templates/admin/discussion/threads.html @@ -1,4 +1,4 @@ -{% extends "admin/core/base.html" %} +{% extends "admin/discussion/threads_base.html" %} {% load foundation %} {% block title %}{{ object.title }} Discussions{% endblock %} diff --git a/src/templates/admin/discussion/threads_base.html b/src/templates/admin/discussion/threads_base.html new file mode 100644 index 0000000000..ae8ddf94c6 --- /dev/null +++ b/src/templates/admin/discussion/threads_base.html @@ -0,0 +1,50 @@ +{% extends "admin/core/base.html" %} +{% load foundation static %} + +{% block title %}{{ object.title }} Discussions{% endblock %} +{% block title-section %}{{ object_type|capfirst }}: {{ object.title }} Discussions{% endblock %} + +{% block css %} + {{ block.super }} + +{% endblock %} + +{% block body %} +
+
+
+ Thread list loads here +
+
+ +
+
+ {% if not active_thread_id %} +
+

Select a thread to display posts.

+
+ {% endif %} +
+
+
+ + Modal for new thread +
+
+{% endblock body %} + +{% block js %} + {{ block.super }} + +{% endblock %} From 1c81c42173b4fbad0169354d3d9239cfbd9aec6a Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 17 Dec 2025 12:26:36 +0000 Subject: [PATCH 2/4] feat: adds more to the updated discussion system. --- src/discussion/partial_views.py | 211 +++++++++++++++++- src/discussion/urls.py | 18 +- src/discussion/views.py | 184 --------------- src/submission/models.py | 11 + .../discussion/partials/invite_search.html | 7 +- src/templates/admin/discussion/post.html | 4 +- src/templates/admin/discussion/thread.html | 2 +- .../admin/discussion/threads_base.html | 15 +- .../admin/elements/article_jump.html | 1 + src/templates/admin/review/in_review.html | 22 +- .../admin/review/unassigned_article.html | 15 +- 11 files changed, 271 insertions(+), 219 deletions(-) diff --git a/src/discussion/partial_views.py b/src/discussion/partial_views.py index eac1f1860b..b0504bac49 100644 --- a/src/discussion/partial_views.py +++ b/src/discussion/partial_views.py @@ -1,10 +1,24 @@ from django.shortcuts import render, get_object_or_404 -from discussion.models import Thread -from submission import models as submission_models +from django.contrib.auth.decorators import login_required +from django.utils.decorators import method_decorator +from django.http import HttpResponse, HttpResponseBadRequest +from django.template.loader import render_to_string +from django.views.decorators.http import require_POST + +from discussion import forms, models from repository import models as repository_models -from security.decorators import can_access_thread +from security.decorators import can_access_thread, editor_user_required +from submission import models as submission_models +from core import models as core_models + + +from core.views import BaseUserList +from review.models import ReviewAssignment +from copyediting.models import CopyeditAssignment +from typesetting.models import TypesettingAssignment +@login_required def threads_list_partial( request, object_type, @@ -12,7 +26,6 @@ def threads_list_partial( ): """ Returns only the list of threads the current user can access. - No decorator needed here since we filter per-thread access. """ if object_type == "article": object_to_get = get_object_or_404( @@ -20,18 +33,19 @@ def threads_list_partial( pk=object_id, journal=request.journal, ) - qs = Thread.objects.filter(article=object_to_get) + qs = models.Thread.objects.filter(article=object_to_get) else: object_to_get = get_object_or_404( repository_models.Preprint, pk=object_id, repository=request.repository, ) - qs = Thread.objects.filter(preprint=object_to_get) + qs = models.Thread.objects.filter(preprint=object_to_get) # Filter threads by access accessible_threads = [ - t for t in qs.select_related("owner").prefetch_related("participants") + t + for t in qs.select_related("owner").prefetch_related("participants") if t.user_can_access(request.user) ] @@ -55,10 +69,9 @@ def thread_detail_partial( ): """ Returns a single thread detail. - Access control happens via @can_access_thread. """ thread = get_object_or_404( - Thread, + models.Thread, pk=thread_id, ) @@ -75,6 +88,7 @@ def thread_detail_partial( ) +@editor_user_required def new_thread_form_partial( request, object_type, @@ -82,7 +96,6 @@ def new_thread_form_partial( ): """ Renders a new thread creation form as a partial. - Useful for HTMX modals. """ if object_type == "article": object_to_get = get_object_or_404( @@ -114,3 +127,181 @@ def new_thread_form_partial( "object_type": object_type, }, ) + + +@method_decorator(editor_user_required, name="dispatch") +class ThreadInviteUserListView(BaseUserList): + """ + Reuses the BaseUserList to display potential invitees for a discussion thread. + Only lists active users and excludes participants already in the thread. + """ + + template_name = "admin/discussion/partials/invite_search.html" + + def dispatch(self, request, *args, **kwargs): + self.thread = get_object_or_404( + models.Thread, + pk=self.kwargs.get("thread_id"), + ) + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.filter(is_active=True) + + if self.request.journal: + qs = qs.filter(accountrole__journal=self.request.journal) + + participant_ids = self.thread.participants.values_list("id", flat=True) + qs = qs.exclude(pk__in=participant_ids) + + return qs.distinct() + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["thread"] = self.thread + + article = self.thread.article + + # Build a mapping of user_id -> list of roles + role_mapping = {} + + for user_id in ReviewAssignment.objects.filter(article=article).values_list( + "reviewer", flat=True + ): + if user_id: + role_mapping.setdefault(user_id, []).append("Reviewer") + + for user_id in CopyeditAssignment.objects.filter(article=article).values_list( + "copyeditor", flat=True + ): + if user_id: + role_mapping.setdefault(user_id, []).append("Copyeditor") + + for user_id in TypesettingAssignment.objects.filter( + round__article=article + ).values_list("typesetter", flat=True): + if user_id: + role_mapping.setdefault(user_id, []).append("Typesetter") + for user_id in TypesettingAssignment.objects.filter( + round__article=article + ).values_list("manager", flat=True): + if user_id: + role_mapping.setdefault(user_id, []).append("Production Manager") + + for user_id in article.authors.values_list("pk", flat=True): + if user_id: + role_mapping.setdefault(user_id, []).append("Author") + + # Exclude participants + participant_ids = set(self.thread.participants.values_list("pk", flat=True)) + role_ids = set(role_mapping.keys()).difference(participant_ids) + + # Get users and attach their roles + role_users = self.model.objects.filter( + pk__in=role_ids, is_active=True + ).distinct() + + for user in role_users: + user.article_roles = role_mapping.get(user.pk, []) + + ctx["role_users"] = role_users + + return ctx + + +@require_POST +@editor_user_required +def add_participant(request, thread_id): + thread = get_object_or_404(models.Thread, pk=thread_id) + user_id = request.POST.get("user_id") + if not user_id: + return HttpResponseBadRequest("Missing user_id") + user = get_object_or_404(core_models.Account, pk=user_id) + thread.participants.add(user) + return HttpResponse(status=204) + + +@editor_user_required +def create_thread(request, object_type, object_id): + if object_type == "article": + obj = get_object_or_404( + submission_models.Article, + pk=object_id, + journal=request.journal, + ) + else: + obj = get_object_or_404( + repository_models.Preprint, + pk=object_id, + repository=request.repository, + ) + + if request.method == "POST": + form = forms.ThreadForm( + request.POST, + object=obj, + object_type=object_type, + owner=request.user, + ) + if form.is_valid(): + thread = form.save(commit=False) + if object_type == "article": + thread.article = obj + else: + thread.preprint = obj + thread.owner = request.user + thread.save() + + # return updated list partial + threads = models.Thread.objects.filter( + article=obj if object_type == "article" else None, + preprint=obj if object_type == "preprint" else None, + ) + html = render_to_string( + "admin/discussion/partials/thread_list.html", + { + "object": obj, + "object_type": object_type, + "threads": threads, + }, + request=request, + ) + return HttpResponse(html) + else: + form = forms.ThreadForm( + object=obj, + object_type=object_type, + owner=request.user, + ) + + return render( + request, + "admin/discussion/partials/new_thread_form.html", + { + "form": form, + "object": obj, + "object_type": object_type, + }, + ) + + +@require_POST +@can_access_thread +def add_post(request, thread_id): + thread = get_object_or_404(models.Thread, pk=thread_id) + + body = request.POST.get("new_post", "").strip() + if body: + thread.create_post(request.user, body) + + return render( + request, + "admin/discussion/partials/thread_detail.html", + { + "thread": thread, + "posts": thread.posts(), + "object_type": thread.object_string(), + "object": thread.article or thread.preprint, + }, + ) diff --git a/src/discussion/urls.py b/src/discussion/urls.py index 4947c3030f..3460a5cf2a 100644 --- a/src/discussion/urls.py +++ b/src/discussion/urls.py @@ -7,64 +7,54 @@ from discussion import views, partial_views urlpatterns = [ - # === Full page views === - # Base threads page — shell for HTMX interface re_path( r"^(?Ppreprint|article)/(?P\d+)/$", views.threads, name="discussion_threads", ), - # Legacy thread view re_path( r"^(?Ppreprint|article)/(?P\d+)/thread/(?P\d+)/$", views.threads, name="discussion_thread_legacy", ), - # New full page thread view (this is what we push with hx-push-url) re_path( r"^(?Ppreprint|article)/(?P\d+)/threads/(?P\d+)/$", views.threads, name="discussion_thread", ), - # Post re_path( r"^thread/(?P\d+)/post/new/$", - views.add_post, + partial_views.add_post, name="discussion_add_post", ), - # === HTMX partials === - # Sidebar list re_path( r"^(?Ppreprint|article)/(?P\d+)/threads/list/$", partial_views.threads_list_partial, name="discussion_threads_list_partial", ), - # Thread detail fragment — now at `/partial/` re_path( r"^(?Ppreprint|article)/(?P\d+)/threads/(?P\d+)/partial/$", partial_views.thread_detail_partial, name="discussion_thread_detail_partial", ), - # Modal for new thread creation re_path( r"^(?Ppreprint|article)/(?P\d+)/threads/new/$", partial_views.new_thread_form_partial, name="discussion_new_thread_modal", ), - # Handle new thread creation (POST) re_path( r"^(?Ppreprint|article)/(?P\d+)/threads/create/$", - views.create_thread, + partial_views.create_thread, name="discussion_create_thread", ), re_path( r"^threads/(?P\d+)/invite/search/$", - views.ThreadInviteUserListView.as_view(), + partial_views.ThreadInviteUserListView.as_view(), name="discussion_invite_search", ), re_path( r"^threads/(?P\d+)/invite/add/$", - views.add_participant, + partial_views.add_participant, name="discussion_add_participant", ), ] diff --git a/src/discussion/views.py b/src/discussion/views.py index 91e35a9470..a62b78e3cf 100644 --- a/src/discussion/views.py +++ b/src/discussion/views.py @@ -1,22 +1,10 @@ -from django.http import HttpResponse from django.shortcuts import ( get_object_or_404, render, ) -from django.template.loader import render_to_string -from django.views.decorators.http import require_POST -from django.utils.decorators import method_decorator -from django.http import HttpResponseBadRequest -from core.views import BaseUserList -from core import models as core_models -from discussion import forms, models from repository import models as repository_models -from security.decorators import can_access_thread, editor_user_required from submission import models as submission_models -from review.models import ReviewAssignment -from copyediting.models import CopyeditAssignment -from typesetting.models import TypesettingAssignment def threads(request, object_type, object_id, thread_id=None): @@ -42,175 +30,3 @@ def threads(request, object_type, object_id, thread_id=None): "active_thread_id": thread_id, }, ) - - -@require_POST -@can_access_thread -def add_post(request, thread_id): - thread = get_object_or_404(models.Thread, pk=thread_id) - - body = request.POST.get("new_post", "").strip() - if body: - thread.create_post(request.user, body) - - return render( - request, - "admin/discussion/partials/thread_detail.html", - { - "thread": thread, - "posts": thread.posts(), - "object_type": thread.object_string(), - "object": thread.article or thread.preprint, - }, - ) - - -def create_thread(request, object_type, object_id): - if object_type == "article": - obj = get_object_or_404( - submission_models.Article, - pk=object_id, - journal=request.journal, - ) - else: - obj = get_object_or_404( - repository_models.Preprint, - pk=object_id, - repository=request.repository, - ) - - if request.method == "POST": - form = forms.ThreadForm( - request.POST, - object=obj, - object_type=object_type, - owner=request.user, - ) - if form.is_valid(): - thread = form.save(commit=False) - # 🔸 attach the object here! - if object_type == "article": - thread.article = obj - else: - thread.preprint = obj - thread.owner = request.user - thread.save() - - # ✅ return updated list partial - threads = models.Thread.objects.filter( - article=obj if object_type == "article" else None, - preprint=obj if object_type == "preprint" else None, - ) - html = render_to_string( - "admin/discussion/partials/thread_list.html", - { - "object": obj, - "object_type": object_type, - "threads": threads, - }, - request=request, - ) - return HttpResponse(html) - else: - form = forms.ThreadForm( - object=obj, - object_type=object_type, - owner=request.user, - ) - - return render( - request, - "admin/discussion/partials/new_thread_form.html", - { - "form": form, - "object": obj, - "object_type": object_type, - }, - ) - - -@method_decorator(editor_user_required, name="dispatch") -class ThreadInviteUserListView(BaseUserList): - """ - Reuses the BaseUserList to display potential invitees for a discussion thread. - Only lists active users and excludes participants already in the thread. - """ - template_name = "admin/discussion/partials/invite_search.html" - - def dispatch(self, request, *args, **kwargs): - self.thread = get_object_or_404( - models.Thread, - pk=self.kwargs.get("thread_id"), - ) - return super().dispatch(request, *args, **kwargs) - - def get_queryset(self): - qs = super().get_queryset() - qs = qs.filter(is_active=True) - - if self.request.journal: - qs = qs.filter(accountrole__journal=self.request.journal) - - participant_ids = self.thread.participants.values_list("id", flat=True) - qs = qs.exclude(pk__in=participant_ids) - - return qs.distinct() - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx["thread"] = self.thread - - article = self.thread.article - - # Collect IDs of users in various roles - reviewers = ReviewAssignment.objects.filter( - article=article - ).values_list("reviewer", flat=True) - - copyeditors = CopyeditAssignment.objects.filter( - article=article - ).values_list("copyeditor", flat=True) - - typesetters = TypesettingAssignment.objects.filter( - round__article=article - ).values_list("typesetter", flat=True) - - managers = TypesettingAssignment.objects.filter( - round__article=article - ).values_list("manager", flat=True) - - authors = article.authors.values_list("pk", flat=True) - - role_ids = set( - filter( - None, - list(reviewers) - + list(copyeditors) - + list(typesetters) - + list(managers) - + list(authors), - ) - ) - - # Exclude participants and filter only active users here too - role_ids = role_ids.difference(set(self.thread.participants.values_list("pk", flat=True))) - - ctx["role_users"] = self.model.objects.filter( - pk__in=role_ids, - is_active=True - ).distinct() - - return ctx - - -@require_POST -@editor_user_required -def add_participant(request, thread_id): - thread = get_object_or_404(models.Thread, pk=thread_id) - user_id = request.POST.get("user_id") - if not user_id: - return HttpResponseBadRequest("Missing user_id") - user = get_object_or_404(core_models.Account, pk=user_id) - user = get_object_or_404(core_models.Account, pk=user_id) - thread.participants.add(user) - return HttpResponse(status=204) diff --git a/src/submission/models.py b/src/submission/models.py index 65c0e9095b..c0958a707c 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -2561,6 +2561,17 @@ def iso639_1_lang_code(self): lang = Lang(self.language) return lang.pt1 or "en" + def discussion_stats(self): + """Returns thread and post counts for this article's discussions.""" + threads = self.thread_set.annotate(post_count=models.Count("posts_related")).aggregate( + thread_count=models.Count("id"), + total_posts=models.Sum("post_count"), + ) + return { + "threads": threads["thread_count"] or 0, + "posts": threads["total_posts"] or 0, + } + class FrozenAuthorQueryset(model_utils.AffiliationCompatibleQueryset): AFFILIATION_RELATED_NAME = "frozen_author" diff --git a/src/templates/admin/discussion/partials/invite_search.html b/src/templates/admin/discussion/partials/invite_search.html index e481772763..5010420569 100644 --- a/src/templates/admin/discussion/partials/invite_search.html +++ b/src/templates/admin/discussion/partials/invite_search.html @@ -11,7 +11,12 @@
People with roles on this article
{% else %}
- Before you can perform actions on an article you must assign an Editor. + Before you can perform any other actions on an article you must assign an Editor.
{% endif %} {% else %} From 1579c1b7479c6a1f2a4d15a118aa49cdb434e993 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 17 Dec 2025 13:06:53 +0000 Subject: [PATCH 3/4] fix: carousel items should be a list. --- src/journal/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/journal/models.py b/src/journal/models.py index b441b05cfe..75dd70d843 100644 --- a/src/journal/models.py +++ b/src/journal/models.py @@ -593,7 +593,7 @@ def active_carousel(self): if self.carousel.current_issue and self.current_issue: items = chain([self.current_issue], items) - return self.carousel, items + return self.carousel, list(items) def next_pa_seq(self): "Works out what the next pinned article sequence should be." @@ -1103,7 +1103,7 @@ def manage_issue_list(self): ) for article in articles: - if not article in article_list: + if article not in article_list: article_list.append(article) section_article_dict[ordered_section.section] = article_list @@ -1131,7 +1131,7 @@ def all_sections(self): articles = self.articles.all().order_by("section") for article in articles: - if not article.section in ordered_sections: + if article.section not in ordered_sections: ordered_sections.append(article.section) return ordered_sections @@ -1196,7 +1196,7 @@ def structure(self): article_list.append(order.article) for article in articles.filter(section=section): - if not article in article_list: + if article not in article_list: article_list.append(article) structure[section] = article_list From ee1886e5afb1b06ad9bb56fd3bc7bbceb6c26dac Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 10 Feb 2026 09:47:12 +0000 Subject: [PATCH 4/4] feat: adds readby, file attachments, edit posts, edit thread name and notification messages and events. --- .gitignore | 1 + src/discussion/admin.py | 10 +- .../migrations/0006_post_is_system_message.py | 19 ++ src/discussion/migrations/0007_post_file.py | 24 ++ src/discussion/migrations/0008_post_edited.py | 20 ++ src/discussion/models.py | 75 ++++- src/discussion/partial_views.py | 271 +++++++++++++++--- src/discussion/templatetags/__init__.py | 0 .../templatetags/discussion_tags.py | 12 + src/discussion/urls.py | 20 ++ src/discussion/views.py | 2 + src/events/logic.py | 14 + src/events/registration.py | 14 + src/static/admin/css/discussions.css | 212 ++++++++++++++ src/templates/admin/discussion/avatar.html | 3 +- .../discussion/partials/htmx_error_toast.html | 8 + .../discussion/partials/thread_detail.html | 72 ++++- src/templates/admin/discussion/post.html | 63 +++- src/templates/admin/discussion/thread.html | 5 +- .../admin/discussion/threads_base.html | 3 +- src/utils/install/journal_defaults.json | 114 ++++++++ src/utils/transactional_emails.py | 94 ++++++ 22 files changed, 991 insertions(+), 65 deletions(-) create mode 100644 src/discussion/migrations/0006_post_is_system_message.py create mode 100644 src/discussion/migrations/0007_post_file.py create mode 100644 src/discussion/migrations/0008_post_edited.py create mode 100644 src/discussion/templatetags/__init__.py create mode 100644 src/discussion/templatetags/discussion_tags.py create mode 100644 src/templates/admin/discussion/partials/htmx_error_toast.html diff --git a/.gitignore b/.gitignore index a401b14998..eb81c88660 100755 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,7 @@ mydatabase node_modules/ src/plugins/* src/media/profile_images/* +src/files/discussions # PyCharm .idea diff --git a/src/discussion/admin.py b/src/discussion/admin.py index 318ca18dda..19a1903396 100644 --- a/src/discussion/admin.py +++ b/src/discussion/admin.py @@ -20,7 +20,7 @@ class ThreadAdmin(admin_utils.ArticleFKModelAdmin): "owner__first_name", "owner__last_name", "owner__email", - "post__body", + "posts_related__body", ) raw_id_fields = ("owner", "article", "preprint") filter_horizontal = ("participants",) @@ -29,8 +29,8 @@ class ThreadAdmin(admin_utils.ArticleFKModelAdmin): class PostAdmin(admin.ModelAdmin): - list_display = ("_post", "thread", "owner", "posted", "_journal") - list_filter = ("thread__article__journal", "posted") + list_display = ("_post", "thread", "owner", "posted", "edited", "is_system_message", "_journal") + list_filter = ("thread__article__journal", "posted", "is_system_message") search_fields = ( "pk", "body", @@ -51,7 +51,9 @@ def _post(self, obj): return truncatewords_html(obj.body, 10) if obj else "" def _journal(self, obj): - return obj.thread.article.journal if obj else "" + if obj and obj.thread and obj.thread.article: + return obj.thread.article.journal + return "" admin_list = [ diff --git a/src/discussion/migrations/0006_post_is_system_message.py b/src/discussion/migrations/0006_post_is_system_message.py new file mode 100644 index 0000000000..7d3580a7c4 --- /dev/null +++ b/src/discussion/migrations/0006_post_is_system_message.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('discussion', '0005_thread_participants_alter_post_thread_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='is_system_message', + field=models.BooleanField( + default=False, + help_text='System-generated message, e.g. title change or participant added.', + ), + ), + ] diff --git a/src/discussion/migrations/0007_post_file.py b/src/discussion/migrations/0007_post_file.py new file mode 100644 index 0000000000..0c065bd7c9 --- /dev/null +++ b/src/discussion/migrations/0007_post_file.py @@ -0,0 +1,24 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ('discussion', '0006_post_is_system_message'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='file', + field=models.ForeignKey( + blank=True, + help_text='Optional file attachment.', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to='core.file', + ), + ), + ] diff --git a/src/discussion/migrations/0008_post_edited.py b/src/discussion/migrations/0008_post_edited.py new file mode 100644 index 0000000000..761645d490 --- /dev/null +++ b/src/discussion/migrations/0008_post_edited.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('discussion', '0007_post_file'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='edited', + field=models.DateTimeField( + blank=True, + help_text='Timestamp of the last edit, if any.', + null=True, + ), + ), + ] diff --git a/src/discussion/models.py b/src/discussion/models.py index c6ac580cd9..c2b3d3d593 100644 --- a/src/discussion/models.py +++ b/src/discussion/models.py @@ -1,9 +1,26 @@ +import bleach +import markdown as markdown_lib + from django.db import models from django.core.exceptions import ValidationError +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django.utils import timezone from django.utils.timesince import timesince +MARKDOWN_ALLOWED_TAGS = [ + "p", "br", "strong", "em", "a", "code", "pre", + "ul", "ol", "li", "blockquote", + "h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", + "del", "sub", "sup", +] + +MARKDOWN_ALLOWED_ATTRIBUTES = { + "a": ["href", "title"], + "img": ["src", "alt"], + "code": ["class"], +} + class Thread(models.Model): article = models.ForeignKey( @@ -79,6 +96,17 @@ def object_id(self): def posts(self): return self.posts_related.all() + def user_posts(self): + return self.posts_related.filter(is_system_message=False) + + def create_system_post(self, actor, body): + """Create a system message in this thread.""" + return self.posts_related.create( + owner=actor, + body=body, + is_system_message=True, + ) + def create_post( self, owner, @@ -92,13 +120,17 @@ def create_post( # 🧭 Ensure the user is a participant if they're allowed to post if owner not in self.participants.all(): self.participants.add(owner) - - # 🕒 Update the last_updated timestamp so threads sort correctly - self.last_updated = timezone.now() - self.save(update_fields=["last_updated"]) + self.posts_related.create( + owner=owner, + body=f"{owner.full_name()} joined the discussion", + is_system_message=True, + ) return post + def unread_count_for(self, user): + return self.user_posts().exclude(read_by=user).count() + def user_can_access(self, user): """ Check whether a user can access this thread. @@ -147,10 +179,26 @@ class Post(models.Model): default=timezone.now, ) body = models.TextField() + is_system_message = models.BooleanField( + default=False, + help_text=_("System-generated message, e.g. title change or participant added."), + ) + file = models.ForeignKey( + "core.File", + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text=_("Optional file attachment."), + ) read_by = models.ManyToManyField( "core.Account", blank=True, ) + edited = models.DateTimeField( + null=True, + blank=True, + help_text=_("Timestamp of the last edit, if any."), + ) class Meta: ordering = ("-posted", "pk") @@ -164,8 +212,9 @@ def save( *args, **kwargs, ): + is_new = self._state.adding super().save(*args, **kwargs) - if self.thread: + if is_new and self.thread: self.thread.last_updated = timezone.now() self.thread.save( update_fields=["last_updated"], @@ -179,6 +228,22 @@ def user_has_read( pk=user.pk, ).exists() + @property + def body_html(self): + """Return the post body as sanitized HTML, converting markdown.""" + raw_html = markdown_lib.markdown( + self.body, + extensions=["nl2br", "fenced_code", "sane_lists"], + ) + return mark_safe( + bleach.clean( + raw_html, + tags=MARKDOWN_ALLOWED_TAGS, + attributes=MARKDOWN_ALLOWED_ATTRIBUTES, + strip=True, + ) + ) + def display_date(self): now = timezone.now() if self.posted.date() == now.date(): diff --git a/src/discussion/partial_views.py b/src/discussion/partial_views.py index b0504bac49..4338a8511a 100644 --- a/src/discussion/partial_views.py +++ b/src/discussion/partial_views.py @@ -1,18 +1,23 @@ +import os +from uuid import uuid4 + +from django.conf import settings from django.shortcuts import render, get_object_or_404 from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator from django.http import HttpResponse, HttpResponseBadRequest from django.template.loader import render_to_string +from django.utils import timezone from django.views.decorators.http import require_POST +from core import files as core_files +from core import models as core_models +from core.views import BaseUserList from discussion import forms, models +from events import logic as event_logic from repository import models as repository_models from security.decorators import can_access_thread, editor_user_required from submission import models as submission_models -from core import models as core_models - - -from core.views import BaseUserList from review.models import ReviewAssignment from copyediting.models import CopyeditAssignment from typesetting.models import TypesettingAssignment @@ -49,6 +54,9 @@ def threads_list_partial( if t.user_can_access(request.user) ] + for t in accessible_threads: + t.unread_count = t.unread_count_for(request.user) + return render( request, "admin/discussion/partials/thread_list.html", @@ -76,6 +84,11 @@ def thread_detail_partial( ) posts = thread.posts() + + # Mark all posts as read for this user + for post in posts: + post.read_by.add(request.user) + return render( request, "admin/discussion/partials/thread_detail.html", @@ -110,8 +123,6 @@ def new_thread_form_partial( repository=request.repository, ) - from discussion import forms - form = forms.ThreadForm( object=object_to_get, object_type=object_type, @@ -166,32 +177,33 @@ def get_context_data(self, **kwargs): # Build a mapping of user_id -> list of roles role_mapping = {} - for user_id in ReviewAssignment.objects.filter(article=article).values_list( - "reviewer", flat=True - ): - if user_id: - role_mapping.setdefault(user_id, []).append("Reviewer") - - for user_id in CopyeditAssignment.objects.filter(article=article).values_list( - "copyeditor", flat=True - ): - if user_id: - role_mapping.setdefault(user_id, []).append("Copyeditor") - - for user_id in TypesettingAssignment.objects.filter( - round__article=article - ).values_list("typesetter", flat=True): - if user_id: - role_mapping.setdefault(user_id, []).append("Typesetter") - for user_id in TypesettingAssignment.objects.filter( - round__article=article - ).values_list("manager", flat=True): - if user_id: - role_mapping.setdefault(user_id, []).append("Production Manager") - - for user_id in article.authors.values_list("pk", flat=True): - if user_id: - role_mapping.setdefault(user_id, []).append("Author") + if article: + for user_id in ReviewAssignment.objects.filter(article=article).values_list( + "reviewer", flat=True + ): + if user_id: + role_mapping.setdefault(user_id, []).append("Reviewer") + + for user_id in CopyeditAssignment.objects.filter( + article=article + ).values_list("copyeditor", flat=True): + if user_id: + role_mapping.setdefault(user_id, []).append("Copyeditor") + + for user_id in TypesettingAssignment.objects.filter( + round__article=article + ).values_list("typesetter", flat=True): + if user_id: + role_mapping.setdefault(user_id, []).append("Typesetter") + for user_id in TypesettingAssignment.objects.filter( + round__article=article + ).values_list("manager", flat=True): + if user_id: + role_mapping.setdefault(user_id, []).append("Production Manager") + + for user_id in article.authors.values_list("pk", flat=True): + if user_id: + role_mapping.setdefault(user_id, []).append("Author") # Exclude participants participant_ids = set(self.thread.participants.values_list("pk", flat=True)) @@ -219,6 +231,17 @@ def add_participant(request, thread_id): return HttpResponseBadRequest("Missing user_id") user = get_object_or_404(core_models.Account, pk=user_id) thread.participants.add(user) + event_logic.Events.raise_event( + event_logic.Events.ON_DISCUSSION_PARTICIPANT_ADDED, + thread=thread, + participant=user, + added_by=request.user, + request=request, + ) + thread.create_system_post( + actor=request.user, + body=f"{request.user.full_name()} added {user.full_name()} to the discussion", + ) return HttpResponse(status=204) @@ -253,17 +276,24 @@ def create_thread(request, object_type, object_id): thread.owner = request.user thread.save() - # return updated list partial - threads = models.Thread.objects.filter( - article=obj if object_type == "article" else None, - preprint=obj if object_type == "preprint" else None, - ) + # return updated list partial, filtered by access + if object_type == "article": + qs = models.Thread.objects.filter(article=obj) + else: + qs = models.Thread.objects.filter(preprint=obj) + accessible_threads = [ + t + for t in qs.select_related("owner").prefetch_related("participants") + if t.user_can_access(request.user) + ] + for t in accessible_threads: + t.unread_count = t.unread_count_for(request.user) html = render_to_string( "admin/discussion/partials/thread_list.html", { "object": obj, "object_type": object_type, - "threads": threads, + "threads": accessible_threads, }, request=request, ) @@ -292,8 +322,95 @@ def add_post(request, thread_id): thread = get_object_or_404(models.Thread, pk=thread_id) body = request.POST.get("new_post", "").strip() - if body: - thread.create_post(request.user, body) + uploaded_file = request.FILES.get("file") + + if body or uploaded_file: + post = thread.create_post(request.user, body or "") + + if uploaded_file: + file_obj = save_file_to_discussion(uploaded_file, thread, request.user) + post.file = file_obj + post.save(update_fields=["file"]) + + event_logic.Events.raise_event( + event_logic.Events.ON_DISCUSSION_POST_CREATED, + thread=thread, + post=post, + request=request, + ) + + posts = thread.posts() + + # Mark all posts as read for this user + for post in posts: + post.read_by.add(request.user) + + return render( + request, + "admin/discussion/partials/thread_detail.html", + { + "thread": thread, + "posts": posts, + "object_type": thread.object_string(), + "object": thread.article or thread.preprint, + }, + ) + + +@require_POST +@editor_user_required +def remove_participant(request, thread_id): + thread = get_object_or_404(models.Thread, pk=thread_id) + user_id = request.POST.get("user_id") + if not user_id: + return HttpResponseBadRequest("Missing user_id") + user = get_object_or_404(core_models.Account, pk=user_id) + + # Cannot remove the thread owner + if user == thread.owner: + return HttpResponseBadRequest("Cannot remove the thread owner") + + thread.participants.remove(user) + event_logic.Events.raise_event( + event_logic.Events.ON_DISCUSSION_PARTICIPANT_REMOVED, + thread=thread, + participant=user, + removed_by=request.user, + request=request, + ) + thread.create_system_post( + actor=request.user, + body=f"{request.user.full_name()} removed {user.full_name()} from the discussion", + ) + return render( + request, + "admin/discussion/partials/thread_detail.html", + { + "thread": thread, + "posts": thread.posts(), + "object_type": thread.object_string(), + "object": thread.article or thread.preprint, + }, + ) + + +@require_POST +@can_access_thread +def edit_subject(request, thread_id): + thread = get_object_or_404(models.Thread, pk=thread_id) + new_subject = request.POST.get("subject", "").strip() + + if not new_subject or len(new_subject) > 300: + return HttpResponseBadRequest("Subject must be between 1 and 300 characters.") + + old_subject = thread.subject + if new_subject != old_subject: + thread.subject = new_subject + thread.save(update_fields=["subject"]) + thread.create_system_post( + actor=request.user, + body=f'{request.user.full_name()} changed the title from "{old_subject}" to "{new_subject}"', + ) return render( request, @@ -305,3 +422,77 @@ def add_post(request, thread_id): "object": thread.article or thread.preprint, }, ) + + +def save_file_to_discussion(uploaded_file, thread, owner): + """Save an uploaded file into the discussion folder and return a core.File.""" + original_filename = str(uploaded_file.name) + filename = str(uuid4()) + str(os.path.splitext(original_filename)[1]) + folder_structure = os.path.join( + settings.BASE_DIR, + "files", + "discussions", + str(thread.pk), + ) + core_files.save_file_to_disk(uploaded_file, filename, folder_structure) + file_mime = core_files.file_path_mime(os.path.join(folder_structure, filename)) + + new_file = core_models.File( + mime_type=file_mime, + original_filename=original_filename, + uuid_filename=filename, + owner=owner, + label="Discussion attachment", + ) + new_file.save() + return new_file + + +@require_POST +@can_access_thread +def edit_post(request, thread_id, post_id): + thread = get_object_or_404(models.Thread, pk=thread_id) + post = get_object_or_404(models.Post, pk=post_id, thread=thread) + + # Only the post owner can edit + if post.owner != request.user: + return HttpResponseBadRequest("You can only edit your own posts.") + + # System messages cannot be edited + if post.is_system_message: + return HttpResponseBadRequest("System messages cannot be edited.") + + body = request.POST.get("body", "").strip() + if not body: + return HttpResponseBadRequest("Post body cannot be empty.") + + post.body = body + post.edited = timezone.now() + post.save(update_fields=["body", "edited"]) + + return render( + request, + "admin/discussion/partials/thread_detail.html", + { + "thread": thread, + "posts": thread.posts(), + "object_type": thread.object_string(), + "object": thread.article or thread.preprint, + }, + ) + + +@can_access_thread +def serve_discussion_file(request, thread_id, file_id): + thread = get_object_or_404(models.Thread, pk=thread_id) + file_obj = get_object_or_404(core_models.File, pk=file_id) + + # Verify this file actually belongs to a post in this thread + if not thread.posts_related.filter(file=file_obj).exists(): + return HttpResponseBadRequest("File not found in this thread.") + + return core_files.serve_any_file( + request, + file_obj, + path_parts=("discussions", str(thread.pk)), + ) diff --git a/src/discussion/templatetags/__init__.py b/src/discussion/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/discussion/templatetags/discussion_tags.py b/src/discussion/templatetags/discussion_tags.py new file mode 100644 index 0000000000..2a183ee13b --- /dev/null +++ b/src/discussion/templatetags/discussion_tags.py @@ -0,0 +1,12 @@ +from django import template +from django.urls import reverse + +register = template.Library() + + +@register.simple_tag +def discussion_file_url(thread_pk, file_pk): + return reverse( + "discussion_serve_file", + kwargs={"thread_id": thread_pk, "file_id": file_pk}, + ) diff --git a/src/discussion/urls.py b/src/discussion/urls.py index 3460a5cf2a..7f339cdce4 100644 --- a/src/discussion/urls.py +++ b/src/discussion/urls.py @@ -47,6 +47,21 @@ partial_views.create_thread, name="discussion_create_thread", ), + re_path( + r"^thread/(?P\d+)/post/(?P\d+)/edit/$", + partial_views.edit_post, + name="discussion_edit_post", + ), + re_path( + r"^thread/(?P\d+)/edit-subject/$", + partial_views.edit_subject, + name="discussion_edit_subject", + ), + re_path( + r"^thread/(?P\d+)/file/(?P\d+)/$", + partial_views.serve_discussion_file, + name="discussion_serve_file", + ), re_path( r"^threads/(?P\d+)/invite/search/$", partial_views.ThreadInviteUserListView.as_view(), @@ -57,5 +72,10 @@ partial_views.add_participant, name="discussion_add_participant", ), + re_path( + r"^threads/(?P\d+)/invite/remove/$", + partial_views.remove_participant, + name="discussion_remove_participant", + ), ] diff --git a/src/discussion/views.py b/src/discussion/views.py index a62b78e3cf..fb39c07033 100644 --- a/src/discussion/views.py +++ b/src/discussion/views.py @@ -1,3 +1,4 @@ +from django.contrib.auth.decorators import login_required from django.shortcuts import ( get_object_or_404, render, @@ -7,6 +8,7 @@ from submission import models as submission_models +@login_required def threads(request, object_type, object_id, thread_id=None): if object_type == "article": obj = get_object_or_404( diff --git a/src/events/logic.py b/src/events/logic.py index cdf1088258..5c830e144f 100755 --- a/src/events/logic.py +++ b/src/events/logic.py @@ -297,6 +297,20 @@ class Events: ON_PROOFREADER_ASSIGN_RESET = "on_proofreader_assign_reset" ON_PROOFREADER_ASSIGN_COMPLETE = "on_proofreader_assign_complete" + # Discussion Events + + # kwargs: thread, participant, added_by, request + # raised when a user is added as a participant to a discussion thread + ON_DISCUSSION_PARTICIPANT_ADDED = "on_discussion_participant_added" + + # kwargs: thread, participant, removed_by, request + # raised when a user is removed from a discussion thread + ON_DISCUSSION_PARTICIPANT_REMOVED = "on_discussion_participant_removed" + + # kwargs: thread, post, request + # raised when a new post is made in a discussion thread + ON_DISCUSSION_POST_CREATED = "on_discussion_post_created" + DEPRECATED_EVENTS = { ON_AUTHOR_PUBLICATION, } diff --git a/src/events/registration.py b/src/events/registration.py index 0ab23a0952..a5b238c475 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -255,6 +255,20 @@ transactional_emails.preprint_review_status_change, ) +# Discussion +event_logic.Events.register_for_event( + event_logic.Events.ON_DISCUSSION_PARTICIPANT_ADDED, + transactional_emails.send_discussion_participant_added, +) +event_logic.Events.register_for_event( + event_logic.Events.ON_DISCUSSION_PARTICIPANT_REMOVED, + transactional_emails.send_discussion_participant_removed, +) +event_logic.Events.register_for_event( + event_logic.Events.ON_DISCUSSION_POST_CREATED, + transactional_emails.send_discussion_new_post, +) + # wire up task-creation events event_logic.Events.register_for_event( event_logic.Events.ON_ARTICLE_SUBMITTED, workflow_tasks.assign_editors diff --git a/src/static/admin/css/discussions.css b/src/static/admin/css/discussions.css index b343275eb3..600ea9beaa 100644 --- a/src/static/admin/css/discussions.css +++ b/src/static/admin/css/discussions.css @@ -244,6 +244,27 @@ font-style: italic; } +.participant-remove-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + margin: 0; + padding: 2px 4px; + font-size: 0.7rem; + line-height: 1; + opacity: 0; + transition: opacity 0.2s ease, color 0.2s ease; +} + +.participant-item:hover .participant-remove-btn { + opacity: 1; +} + +.participant-remove-btn:hover { + color: #cc4b37; +} + /* New Post Form */ .new-post-form { padding: 1.5rem 2rem; @@ -564,4 +585,195 @@ .loading-indicator .fa-spinner { margin-right: 0.4rem; +} + +.system-message { + text-align: center; + padding: 0.5rem 1rem; + margin: 0.75rem 0; +} + +.system-message p { + margin: 0; + color: var(--text-muted); + font-size: 0.8125rem; + font-style: italic; +} + +.thread-subject-display { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.subject-edit-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0.25rem; + font-size: 0.875rem; + opacity: 0; + transition: opacity 0.2s ease; +} + +.thread-subject-display:hover .subject-edit-btn { + opacity: 1; +} + +.subject-edit-btn:hover { + color: var(--blue-light); +} + +.markdown-hint { + display: block; + color: var(--text-muted); + font-size: 0.75rem; + margin-top: 0.25rem; +} + +.post-callout p:last-child, +.post-callout ul:last-child, +.post-callout ol:last-child, +.post-callout pre:last-child, +.post-callout blockquote:last-child { + margin-bottom: 0; +} + +.post-callout code { + background: rgba(0, 0, 0, 0.06); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.85em; +} + +.post-callout pre { + background: rgba(0, 0, 0, 0.06); + padding: 0.75rem 1rem; + border-radius: 6px; + overflow-x: auto; +} + +.post-callout pre code { + background: none; + padding: 0; +} + +.post-callout blockquote { + border-left: 3px solid var(--gray-border); + padding-left: 1rem; + margin-left: 0; + color: var(--text-muted); +} + +.post-form-extras { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 0.25rem; +} + +.file-attach-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + color: var(--text-muted); + font-size: 0.75rem; + margin: 0; +} + +.file-attach-btn:hover { + color: var(--blue-light); +} + +.file-attach-input { + display: none; +} + +.file-attach-name { + font-size: 0.75rem; + color: var(--text-dark); + font-style: italic; +} + +.post-attachment { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid rgba(0, 0, 0, 0.08); +} + +.post-attachment a { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + color: var(--text-dark); + text-decoration: none; +} + +.post-attachment a:hover { + text-decoration: underline; +} + +.post-other-callout .post-attachment a { + color: var(--blue-light); +} + +.send-btn-loading { + display: none; +} + +.send-btn[disabled] .send-btn-label { + display: none; +} + +.send-btn[disabled] .send-btn-loading { + display: inline; +} + +.send-btn[disabled] { + opacity: 0.7; + cursor: not-allowed; +} + +.unread-badge { + background: var(--blue-light); + color: var(--white); + font-size: 0.6875rem; + font-weight: 700; + padding: 0.125rem 0.5rem; + border-radius: 10px; + display: inline-block; +} + +.post-edit-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0 0.25rem; + font-size: 0.75rem; + opacity: 0; + transition: opacity 0.2s ease; +} + +.post-callout:hover .post-edit-btn { + opacity: 1; +} + +.post-edit-btn:hover { + color: var(--blue-light); +} + +.post-callout form textarea { + min-height: 120px; + width: 100%; + margin-bottom: 0.5rem; +} + +.post-edited-tag { + color: var(--text-muted); + font-size: 0.6875rem; + font-style: italic; } \ No newline at end of file diff --git a/src/templates/admin/discussion/avatar.html b/src/templates/admin/discussion/avatar.html index c0b393789b..b9de446688 100644 --- a/src/templates/admin/discussion/avatar.html +++ b/src/templates/admin/discussion/avatar.html @@ -1,4 +1,5 @@ - {% if user.profile_image %} diff --git a/src/templates/admin/discussion/partials/htmx_error_toast.html b/src/templates/admin/discussion/partials/htmx_error_toast.html new file mode 100644 index 0000000000..299f82fc4a --- /dev/null +++ b/src/templates/admin/discussion/partials/htmx_error_toast.html @@ -0,0 +1,8 @@ + diff --git a/src/templates/admin/discussion/partials/thread_detail.html b/src/templates/admin/discussion/partials/thread_detail.html index 32aaad881a..305a164fca 100644 --- a/src/templates/admin/discussion/partials/thread_detail.html +++ b/src/templates/admin/discussion/partials/thread_detail.html @@ -1,7 +1,24 @@ {% load foundation %} +{% load roles %} + +{% user_has_role request 'editor' as user_is_editor %}
-

{{ thread.subject }}

+
+

{{ thread.subject }}

+ +
+

Started on {{ thread.started|date:"F d, Y \\a\\t g:i A" }}

@@ -18,15 +35,29 @@

{{ thread.subject }}

+ title="{{ participant.full_name }}" + style="display: inline-flex; align-items: center;"> {% include "admin/discussion/avatar.html" with user=participant avatar_class="participant-avatar" %} + {% if participant != thread.owner and user_is_editor %} + + {% endif %}
{% empty %} No participants yet {% endfor %}
- + + {% if user_is_editor %}
+ {% endif %} @@ -54,18 +86,29 @@

{{ thread.subject }}

+ hx-swap="innerHTML" + hx-encoding="multipart/form-data" + hx-disabled-elt="find button[type='submit']" + enctype="multipart/form-data"> {% csrf_token %}
+ placeholder="Write your message..."> +
+ Markdown supported + + +
-
@@ -91,6 +134,19 @@

{{ thread.subject }}

+ {% include "admin/discussion/partials/htmx_error_toast.html" %} {% endblock %} diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index 321648e08e..be7bf22993 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5596,5 +5596,119 @@ "editor", "journal-manager" ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to a user when they are added as a participant to a discussion thread.", + "is_translatable": true, + "name": "discussion_participant_added", + "pretty_name": "Discussion Participant Added", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ participant.salutation_name }},

You have been added to the discussion \"{{ thread.subject }}\" on {{ object_type }} \"{{ object_title }}\" by {{ added_by.full_name }}.

You can view this discussion at: {{ thread_url }}

Regards,
" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to a user when they are removed from a discussion thread.", + "is_translatable": true, + "name": "discussion_participant_removed", + "pretty_name": "Discussion Participant Removed", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ participant.salutation_name }},

You have been removed from the discussion \"{{ thread.subject }}\" on {{ object_type }} \"{{ object_title }}\".

You will no longer receive notifications for this discussion.

Regards,
" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to discussion participants when a new post is created.", + "is_translatable": true, + "name": "discussion_new_post", + "pretty_name": "Discussion New Post", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ recipient.salutation_name }},

{{ post.owner.full_name }} has posted a new message in the discussion \"{{ thread.subject }}\" on {{ object_type }} \"{{ object_title }}\":

{{ post.body_html }}

You can view this discussion at: {{ thread_url }}

Regards,
" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for Discussion Participant Added Email", + "is_translatable": true, + "name": "subject_discussion_participant_added", + "pretty_name": "Subject Discussion Participant Added", + "type": "char" + }, + "value": { + "default": "You have been added to a discussion" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for Discussion Participant Removed Email", + "is_translatable": true, + "name": "subject_discussion_participant_removed", + "pretty_name": "Subject Discussion Participant Removed", + "type": "char" + }, + "value": { + "default": "You have been removed from a discussion" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for Discussion New Post Email", + "is_translatable": true, + "name": "subject_discussion_new_post", + "pretty_name": "Subject Discussion New Post", + "type": "char" + }, + "value": { + "default": "New post in a discussion" + }, + "editable_by": [ + "editor", + "journal-manager" + ] } ] diff --git a/src/utils/transactional_emails.py b/src/utils/transactional_emails.py index 9be1476210..8478f63f5d 100644 --- a/src/utils/transactional_emails.py +++ b/src/utils/transactional_emails.py @@ -2017,6 +2017,100 @@ def preprint_review_notification(**kwargs): ) +def _get_discussion_thread_url(request, thread): + """Build the full URL for a discussion thread.""" + path = reverse( + "discussion_thread", + kwargs={ + "object_type": thread.object_string(), + "object_id": thread.object_id(), + "thread_id": thread.pk, + }, + ) + if request.journal: + return request.journal.site_url(path=path) + elif request.repository: + return request.repository.site_url(path) + return path + + +def send_discussion_participant_added(**kwargs): + request = kwargs["request"] + thread = kwargs["thread"] + participant = kwargs["participant"] + added_by = kwargs["added_by"] + + context = { + "participant": participant, + "thread": thread, + "added_by": added_by, + "object_type": thread.object_string(), + "object_title": thread.object_title(), + "thread_url": _get_discussion_thread_url(request, thread), + } + notify_helpers.send_email_with_body_from_setting_template( + request, + "discussion_participant_added", + "subject_discussion_participant_added", + participant.email, + context, + ) + + +def send_discussion_participant_removed(**kwargs): + request = kwargs["request"] + thread = kwargs["thread"] + participant = kwargs["participant"] + removed_by = kwargs["removed_by"] + + context = { + "participant": participant, + "thread": thread, + "removed_by": removed_by, + "object_type": thread.object_string(), + "object_title": thread.object_title(), + } + notify_helpers.send_email_with_body_from_setting_template( + request, + "discussion_participant_removed", + "subject_discussion_participant_removed", + participant.email, + context, + ) + + +def send_discussion_new_post(**kwargs): + request = kwargs["request"] + thread = kwargs["thread"] + post = kwargs["post"] + + thread_url = _get_discussion_thread_url(request, thread) + + # All participants except the post author + recipients = set(thread.participants.exclude(pk=post.owner.pk)) + + # Include thread owner if not already a participant and not the author + if thread.owner and thread.owner != post.owner: + recipients.add(thread.owner) + + for recipient in recipients: + context = { + "recipient": recipient, + "post": post, + "thread": thread, + "object_type": thread.object_string(), + "object_title": thread.object_title(), + "thread_url": thread_url, + } + notify_helpers.send_email_with_body_from_setting_template( + request, + "discussion_new_post", + "subject_discussion_new_post", + recipient.email, + context, + ) + + def preprint_review_status_change(**kwargs): request = kwargs.get("request") review = kwargs.get("review")