Free and open source ticket system written in python
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

📦 NEW: Add file attachments to ticket creation form and display attachments in ticket detail view

+107 -5
+1 -1
paw/__init__.py
··· 1 1 from django import get_version 2 2 3 - VERSION = (0, 1, 0, "beta", 13) 3 + VERSION = (0, 1, 0, "beta", 14) 4 4 5 5 __version__ = get_version(VERSION)
+9 -1
paw/templates/ticketing/create_ticket.html
··· 3 3 {% load i18n %} 4 4 <div class="w-full max-w-4xl mx-auto p-8"> 5 5 <h1 class="text-2xl font-bold mb-4">{% trans 'Create a new ticket' %}</h1> 6 - <form action="" method="post"> 6 + <form action="" method="post" enctype="multipart/form-data"> 7 7 {% csrf_token %} 8 8 {{ form.non_field_errors }} 9 9 <label class="form-control mb-2"> ··· 26 26 <span for="{{ form.description.id_for_label }}" class="label-text font-semibold text-base-content">{% trans 'Description' %}</span> 27 27 </div> 28 28 {{ form.description }} 29 + </label> 30 + 31 + <label class="form-control mb-2"> 32 + {{ form.attachments.errors }} 33 + <div class="label"> 34 + <span for="{{ form.attachments.id_for_label }}" class="label-text font-semibold text-base-content">{% trans 'Attachments' %}</span> 35 + </div> 36 + {{ form.attachments }} 29 37 </label> 30 38 31 39 {% if has_closed_tickets %}
+12 -1
paw/templates/ticketing/ticket_detail.html
··· 4 4 <div class="flex flex-col lg:flex-row w-full h-full"> 5 5 <div class="flex-grow p-8"> 6 6 <h1 class="text-2xl font-bold mb-4">{{ ticket.title }}</h1> 7 - <div class="card bg-base-300 rounded-box p-4 mb-6 whitespace-pre-line">{{ ticket.description }}</div> 7 + <div class="card bg-base-300 rounded-box p-4 mb-4 whitespace-pre-line">{{ ticket.description }}</div> 8 + {% if attachments %} 9 + <h2 class="font-semibold mb-2">{% trans 'Attachments' %}</h2> 10 + <div class="flex flex-wrap mb-6"> 11 + {% for attachment in attachments %} 12 + <a href="{{ attachment.url }}" target="_blank" class="badge badge-lg badge-accent flex items-center mr-2"> 13 + <svg xmlns="http://www.w3.org/2000/svg"class="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5" /></svg> 14 + {% trans 'Attachment' %} {{ forloop.counter }} 15 + </a> 16 + {% endfor %} 17 + </div> 18 + {% endif %} 8 19 <h1 class="text-xl font-bold mb-4">Activity</h1> 9 20 <div class="mb-4"> 10 21 {% for comment in comments %}
+55
static/css/paw.css
··· 1626 1626 color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); 1627 1627 } 1628 1628 1629 + .badge-accent { 1630 + --tw-border-opacity: 1; 1631 + border-color: var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity))); 1632 + --tw-bg-opacity: 1; 1633 + background-color: var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity))); 1634 + --tw-text-opacity: 1; 1635 + color: var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity))); 1636 + } 1637 + 1629 1638 .badge-success { 1630 1639 border-color: transparent; 1631 1640 --tw-bg-opacity: 1; ··· 1653 1662 .badge-outline.badge-neutral { 1654 1663 --tw-text-opacity: 1; 1655 1664 color: var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity))); 1665 + } 1666 + 1667 + .badge-outline.badge-accent { 1668 + --tw-text-opacity: 1; 1669 + color: var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity))); 1656 1670 } 1657 1671 1658 1672 .badge-outline.badge-success { ··· 2620 2634 padding-right: 0.438rem; 2621 2635 } 2622 2636 2637 + .badge-lg { 2638 + height: 1.5rem; 2639 + font-size: 1rem; 2640 + line-height: 1.5rem; 2641 + padding-left: 0.688rem; 2642 + padding-right: 0.688rem; 2643 + } 2644 + 2623 2645 .btn-xs { 2624 2646 height: 1.5rem; 2625 2647 min-height: 1.5rem; ··· 2934 2956 display: table; 2935 2957 } 2936 2958 2959 + .grid { 2960 + display: grid; 2961 + } 2962 + 2937 2963 .hidden { 2938 2964 display: none; 2939 2965 } ··· 2968 2994 2969 2995 .h-full { 2970 2996 height: 100%; 2997 + } 2998 + 2999 + .h-3 { 3000 + height: 0.75rem; 2971 3001 } 2972 3002 2973 3003 .min-h-screen { ··· 3014 3044 width: 100%; 3015 3045 } 3016 3046 3047 + .w-3 { 3048 + width: 0.75rem; 3049 + } 3050 + 3017 3051 .max-w-4xl { 3018 3052 max-width: 56rem; 3019 3053 } ··· 3046 3080 cursor: pointer; 3047 3081 } 3048 3082 3083 + .grid-cols-1 { 3084 + grid-template-columns: repeat(1, minmax(0, 1fr)); 3085 + } 3086 + 3049 3087 .flex-row { 3050 3088 flex-direction: row; 3051 3089 } ··· 3054 3092 flex-direction: column; 3055 3093 } 3056 3094 3095 + .flex-wrap { 3096 + flex-wrap: wrap; 3097 + } 3098 + 3057 3099 .items-center { 3058 3100 align-items: center; 3059 3101 } ··· 3070 3112 justify-content: center; 3071 3113 } 3072 3114 3115 + .justify-between { 3116 + justify-content: space-between; 3117 + } 3118 + 3073 3119 .gap-2 { 3074 3120 gap: 0.5rem; 3121 + } 3122 + 3123 + .gap-4 { 3124 + gap: 1rem; 3075 3125 } 3076 3126 3077 3127 .self-end { ··· 3207 3257 .text-xs { 3208 3258 font-size: 0.75rem; 3209 3259 line-height: 1rem; 3260 + } 3261 + 3262 + .text-lg { 3263 + font-size: 1.125rem; 3264 + line-height: 1.75rem; 3210 3265 } 3211 3266 3212 3267 .font-bold {
+21
ticketing/forms.py
··· 3 3 from django.utils.translation import gettext_lazy as _ 4 4 5 5 6 + class MultipleFileInput(forms.ClearableFileInput): 7 + allow_multiple_selected = True 8 + 9 + 10 + class MultipleFileField(forms.FileField): 11 + def __init__(self, *args, **kwargs): 12 + kwargs.setdefault("widget", MultipleFileInput( 13 + attrs={'class': 'file-input file-input-bordered w-full'})) 14 + super().__init__(*args, **kwargs) 15 + 16 + def clean(self, data, initial=None): 17 + single_file_clean = super().clean 18 + if isinstance(data, (list, tuple)): 19 + result = [single_file_clean(d, initial) for d in data] 20 + else: 21 + result = single_file_clean(data, initial) 22 + return result 23 + 24 + 6 25 class CommentForm(forms.Form): 7 26 text = forms.CharField(widget=forms.Textarea( 8 27 attrs={'class': 'textarea textarea-bordered h-32', 'placeholder': 'Enter your comment here...'})) ··· 27 46 self.fields['follow_up_to'].empty_label = _('No Follow-up') 28 47 self.fields['follow_up_to'].queryset = Ticket.objects.filter( 29 48 status=Ticket.Status.CLOSED, user=user) 49 + 50 + attachments = MultipleFileField(required=False) 30 51 31 52 32 53 class TemplateForm(forms.Form):
+9 -2
ticketing/views.py
··· 77 77 78 78 comments = ticket.comment_set.all() 79 79 context = { 80 - "ticket": ticket, "comments": comments, "form": form, "template_form": template_form, 80 + "ticket": ticket, "comments": comments, "attachments": [attachment.file for attachment in ticket.fileattachment_set.all()], 81 + "form": form, "template_form": template_form, 81 82 "team_assignment_form": team_assignment_form, "category_assignment_form": category_assignment_form 82 83 } 83 84 return render(request, "ticketing/ticket_detail.html", context) ··· 90 91 user=request.user, status=Ticket.Status.CLOSED).exists() 91 92 92 93 if request.method == "POST": 93 - form = TicketForm(request.user, request.POST) 94 + form = TicketForm(request.user, request.POST, request.FILES) 94 95 if form.is_valid(): 95 96 ticket = form.save(commit=False) 96 97 ticket.user = request.user 97 98 ticket.save() 99 + 100 + # Add attachments 101 + if form.cleaned_data["attachments"]: 102 + for file in form.cleaned_data["attachments"]: 103 + ticket.fileattachment_set.create(file=file) 104 + 98 105 return redirect("ticket_detail", ticket_id=ticket.id) 99 106 else: 100 107 form = TicketForm(request.user)