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 3b17264ba1..19a1903396 100644 --- a/src/discussion/admin.py +++ b/src/discussion/admin.py @@ -20,16 +20,17 @@ 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",) inlines = [admin_utils.PostInline] 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", @@ -50,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/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/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 c014f4c540..c2b3d3d593 100644 --- a/src/discussion/models.py +++ b/src/discussion/models.py @@ -1,7 +1,25 @@ +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): @@ -31,6 +49,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 +64,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 +89,85 @@ 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 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, 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) + 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. + + 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", @@ -85,21 +179,73 @@ 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") - 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, + ): + is_new = self._state.adding + super().save(*args, **kwargs) + if is_new and 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() + + @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): - 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..4338a8511a --- /dev/null +++ b/src/discussion/partial_views.py @@ -0,0 +1,498 @@ +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 review.models import ReviewAssignment +from copyediting.models import CopyeditAssignment +from typesetting.models import TypesettingAssignment + + +@login_required +def threads_list_partial( + request, + object_type, + object_id, +): + """ + Returns only the list of threads the current user can access. + """ + if object_type == "article": + object_to_get = get_object_or_404( + submission_models.Article, + pk=object_id, + journal=request.journal, + ) + 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 = 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") + 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", + { + "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. + """ + thread = get_object_or_404( + models.Thread, + pk=thread_id, + ) + + 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": object_type, + "object_id": object_id, + }, + ) + + +@editor_user_required +def new_thread_form_partial( + request, + object_type, + object_id, +): + """ + Renders a new thread creation form as a partial. + """ + 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, + ) + + 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, + }, + ) + + +@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 = {} + + 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)) + 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) + 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) + + +@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, 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": accessible_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() + 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, + "admin/discussion/partials/thread_detail.html", + { + "thread": thread, + "posts": thread.posts(), + "object_type": thread.object_string(), + "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 d2a4c1f9e7..7f339cdce4 100644 --- a/src/discussion/urls.py +++ b/src/discussion/urls.py @@ -2,9 +2,9 @@ __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 = [ re_path( @@ -15,11 +15,67 @@ re_path( r"^(?Ppreprint|article)/(?P\d+)/thread/(?P\d+)/$", views.threads, + name="discussion_thread_legacy", + ), + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/(?P\d+)/$", + views.threads, name="discussion_thread", ), re_path( r"^thread/(?P\d+)/post/new/$", - views.add_post, + partial_views.add_post, name="discussion_add_post", ), + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/list/$", + partial_views.threads_list_partial, + name="discussion_threads_list_partial", + ), + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/(?P\d+)/partial/$", + partial_views.thread_detail_partial, + name="discussion_thread_detail_partial", + ), + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/new/$", + partial_views.new_thread_form_partial, + name="discussion_new_thread_modal", + ), + re_path( + r"^(?Ppreprint|article)/(?P\d+)/threads/create/$", + 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(), + name="discussion_invite_search", + ), + re_path( + r"^threads/(?P\d+)/invite/add/$", + 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 4e73fd3d99..fb39c07033 100644 --- a/src/discussion/views.py +++ b/src/discussion/views.py @@ -1,105 +1,34 @@ -from django.shortcuts import render, get_object_or_404, redirect, reverse -from django.http import Http404 -from django.views.decorators.http import require_POST +from django.contrib.auth.decorators import login_required +from django.shortcuts import ( + get_object_or_404, + render, +) -from discussion import models, forms -from submission import models as submission_models from repository import models as repository_models -from security.decorators import editor_or_manager +from submission import models as submission_models -@editor_or_manager +@login_required 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 - - form = forms.ThreadForm( - object=object_to_get, - object_type=object_type, - owner=request.user, - ) - - if request.POST: - form = forms.ThreadForm( - request.POST, - object=object_to_get, - 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, - }, - ) - ) - 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) - -@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, - }, - ) + return render( + request, + "admin/discussion/threads_base.html", + { + "object": obj, + "object_type": object_type, + "active_thread_id": thread_id, + }, ) 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/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 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..600ea9beaa --- /dev/null +++ b/src/static/admin/css/discussions.css @@ -0,0 +1,779 @@ +/* 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; +} + +.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; + 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; +} + +.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/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/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/invite_search.html b/src/templates/admin/discussion/partials/invite_search.html new file mode 100644 index 0000000000..5010420569 --- /dev/null +++ b/src/templates/admin/discussion/partials/invite_search.html @@ -0,0 +1,74 @@ +{% 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 }} + {% if user.article_roles %} + {{ user.article_roles|join:", " }} + {% endif %} + + +
  • + {% 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..305a164fca --- /dev/null +++ b/src/templates/admin/discussion/partials/thread_detail.html @@ -0,0 +1,192 @@ +{% load foundation %} +{% load roles %} + +{% user_has_role request 'editor' as user_is_editor %} + +
+
+

{{ 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" %} + {% if participant != thread.owner and user_is_editor %} + + {% endif %} +
+ {% empty %} + No participants yet + {% endfor %} +
+ + + {% if user_is_editor %} +
+ +
+ {% endif %} +
+
+ + +
+ {% for post in posts %} + {% include "admin/discussion/post.html" %} + {% empty %} +

No posts yet. Start the discussion below.

+ {% endfor %} +
+ + +
+
+ {% csrf_token %} +
+
+ +
+ Markdown supported + + +
+
+
+ +
+
+
+
+ + +
+ + + + +
+ + +
+ +{% 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/post.html b/src/templates/admin/discussion/post.html index 2e4214ae6b..853e54aeca 100644 --- a/src/templates/admin/discussion/post.html +++ b/src/templates/admin/discussion/post.html @@ -1,22 +1,75 @@ +{% load discussion_tags %} +{% if post.is_system_message %} +
+
+
+

{{ post.body }} · {{ post.posted|date:"H:i" }} {{ post.display_date }}

+
+
+
+{% else %}
-
+
{% include "admin/discussion/avatar.html" with user=post.owner %}
-
+
{% if post.owner == request.user %}

- {{ post.owner.full_name }} {{ post.posted|date:"H:i" }} {{ post.display_date }} + + {{ post.owner.full_name }} {{ post.posted|date:"H:i" }} {{ post.display_date }} + {% if post.edited %} + (edited) + {% endif %} + +

-

{{ post.body|safe }}

+
+ {% if post.body %}
{{ post.body_html }}
{% endif %} + {% if post.file %} +
+ + + {{ post.file.original_filename }} + +
+ {% endif %} +
+
{% else %}

- {{ post.owner.full_name }} {{ post.posted|date:"H:i" }} {{ post.display_date }} + + {{ post.owner.full_name }} {{ post.posted|date:"H:i" }} {{ post.display_date }} + {% if post.edited %} + (edited) + {% endif %} +

-

{{ post.body|safe }}

+ {% if post.body %}
{{ post.body_html }}
{% endif %} + {% if post.file %} +
+ + + {{ post.file.original_filename }} + +
+ {% endif %}
{% endif %}
-
\ No newline at end of file +
+{% endif %} \ No newline at end of file diff --git a/src/templates/admin/discussion/thread.html b/src/templates/admin/discussion/thread.html index 85d6dd696a..61f74fd435 100644 --- a/src/templates/admin/discussion/thread.html +++ b/src/templates/admin/discussion/thread.html @@ -5,12 +5,15 @@

- {{ thread.subject }} + {{ thread.subject }}

Posted by {{ thread.owner.full_name }}
Started on {{ thread.started|date:"Y-m-d H:i" }}
Updated on {{ thread.last_updated|date:"Y-m-d H:i" }}

-

{{ thread.posts.count }}

+

{{ thread.user_posts.count }}

+ {% if thread.unread_count %} + {{ thread.unread_count }} + {% endif %}
\ No newline at end of file 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..9bdc9f5e9f --- /dev/null +++ b/src/templates/admin/discussion/threads_base.html @@ -0,0 +1,62 @@ +{% 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 breadcrumbs %} + {{ block.super }} + {% if object_type == "article" %} +
  • + {{ object.title }} +
  • +
  • + Discussions +
  • + {% endif %} +{% endblock %} + +{% block body %} +
    +
    +
    + Loading threads... +
    +
    + +
    +
    + {% if not active_thread_id %} +
    +

    Select a thread to display posts.

    +
    + {% endif %} +
    +
    +
    + +
    +
    +{% endblock body %} + +{% block js %} + {{ block.super }} + + {% include "admin/discussion/partials/htmx_error_toast.html" %} +{% endblock %} diff --git a/src/templates/admin/elements/article_jump.html b/src/templates/admin/elements/article_jump.html index 4ba88097ca..fec7e558e7 100644 --- a/src/templates/admin/elements/article_jump.html +++ b/src/templates/admin/elements/article_jump.html @@ -29,6 +29,7 @@ {% endif %}
  • Identifiers
  • Archive Page
  • +
  • Discussions
  • Manage Workflow Stage
  • {% if article.is_published %}
  • Live Article
  • diff --git a/src/templates/admin/review/in_review.html b/src/templates/admin/review/in_review.html index e5fb18e5d6..9c4702ce94 100644 --- a/src/templates/admin/review/in_review.html +++ b/src/templates/admin/review/in_review.html @@ -273,12 +273,26 @@

    Actions

    + + {% endwith %} {% if article.stage == 'Unassigned' and not article.stage == 'Assigned' %} {% if journal_settings.crosscheck.enable and not article.ithenticate_id %}
    @@ -287,7 +300,7 @@

    Actions

    {% 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 %} 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")