From 5c9ec4abbdee28ef66e0b24cfe9b247d3e610b92 Mon Sep 17 00:00:00 2001 From: Bee mines Date: Sat, 26 Sep 2026 17:24:26 +0800 Subject: [PATCH 1/5] feat: copy follow-ups to related tickets --- src/helpdesk/copy_followup.py | 50 +++++ src/helpdesk/forms.py | 13 ++ .../templates/helpdesk/followup_copy.html | 35 ++++ .../include/ticket_followup_list.html | 10 +- .../templatetags/followup_permissions.py | 8 + src/helpdesk/urls.py | 5 + src/helpdesk/views/staff.py | 49 +++++ tests/test_followup_copy.py | 172 ++++++++++++++++++ tests/test_followup_menu.py | 37 +++- 9 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 src/helpdesk/copy_followup.py create mode 100644 src/helpdesk/templates/helpdesk/followup_copy.html create mode 100644 tests/test_followup_copy.py diff --git a/src/helpdesk/copy_followup.py b/src/helpdesk/copy_followup.py new file mode 100644 index 00000000..5b881b8b --- /dev/null +++ b/src/helpdesk/copy_followup.py @@ -0,0 +1,50 @@ +"""Copy follow-up content without duplicating ticket history or work logs.""" + +from pathlib import PurePath + +from django.db import transaction +from django.utils.translation import gettext as _ + +from helpdesk.models import FollowUp, FollowUpAttachment + + +def copy_followup(source, ticket, user, *, public=False): + """The caller must authorize access to both source and destination tickets.""" + saved_files = [] + try: + with transaction.atomic(): + title = _("Copied from ticket #%(ticket)s: %(title)s") % { + "ticket": source.ticket_id, + "title": source.title, + } + copied = FollowUp.objects.create( + ticket=ticket, + title=title[:200], + comment=source.comment, + user=user, + public=public, + email_recipients=[], + ) + # Saving a follow-up may calculate work time automatically. Copying + # existing content is not additional work to bill to this ticket. + FollowUp.objects.filter(pk=copied.pk).update(time_spent=None) + copied.time_spent = None + for attachment in source.followupattachment_set.all(): + duplicate = FollowUpAttachment( + followup=copied, + filename=attachment.filename, + mime_type=attachment.mime_type, + size=attachment.size, + ) + with attachment.file.open("rb") as content: + duplicate.file.save( + PurePath(attachment.file.name).name, content, save=False + ) + saved_files.append((duplicate.file.storage, duplicate.file.name)) + duplicate.save() + return copied + except Exception: + # Storage writes are not rolled back by the database transaction. + for storage, name in saved_files: + storage.delete(name) + raise diff --git a/src/helpdesk/forms.py b/src/helpdesk/forms.py index f637aae9..a5ee71c6 100644 --- a/src/helpdesk/forms.py +++ b/src/helpdesk/forms.py @@ -267,6 +267,19 @@ class EditTicketCustomFieldForm(EditTicketForm): ) +class CopyFollowUpForm(forms.Form): + ticket = forms.ModelChoiceField( + queryset=Ticket.objects.none(), + label=_("Destination ticket"), + widget=forms.Select(attrs={"class": "form-select"}), + ) + public = forms.BooleanField( + required=False, + label=_("Visible to the destination ticket submitter"), + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + ) + + class EditFollowUpForm(forms.ModelForm): class Meta: model = FollowUp diff --git a/src/helpdesk/templates/helpdesk/followup_copy.html b/src/helpdesk/templates/helpdesk/followup_copy.html new file mode 100644 index 00000000..403da8eb --- /dev/null +++ b/src/helpdesk/templates/helpdesk/followup_copy.html @@ -0,0 +1,35 @@ +{% extends "helpdesk/base.html" %}{% load i18n %} +{% block helpdesk_title %}{% translate "Copy follow-up" %}{% endblock %} +{% block helpdesk_breadcrumb %} + + + +{% endblock %} +{% block helpdesk_body %} +
+
{% translate "Copy to another ticket" %}
+
+

{% translate "Copy this comment and its attachments to an open ticket. The original follow-up stays here. Ticket changes and time spent are not copied, and no email is sent." %}

+
+
{{ followup.title }}
+
{{ followup.comment|linebreaksbr }}
+
+
+ {% csrf_token %} + {% if form.non_field_errors %}{% endif %} +
+ + {{ form.ticket }} + {{ form.ticket.errors }} +
+
+ {{ form.public }} + +
+

{% translate "The copy is private unless you choose to share it with the destination ticket submitter. Check that the comment and attachments are suitable for that audience." %}

+ + {% translate "Cancel" %} +
+
+
+{% endblock %} diff --git a/src/helpdesk/templates/helpdesk/include/ticket_followup_list.html b/src/helpdesk/templates/helpdesk/include/ticket_followup_list.html index ddfb2a74..2d394798 100644 --- a/src/helpdesk/templates/helpdesk/include/ticket_followup_list.html +++ b/src/helpdesk/templates/helpdesk/include/ticket_followup_list.html @@ -30,7 +30,7 @@ {% endif %} {# Quick actions dropdown #} - {% if followup|can_edit_followup:request.user or followup|can_delete_followup:request.user %} + {% if followup|can_edit_followup:request.user or followup|can_delete_followup:request.user or followup|can_copy_followup:request.user %}