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.

๐Ÿ‘Œ IMPROVE: add file upload to ticket detail view

+134 -196
+1
paw/templates/placeholder.html
··· 12 12 <label class="label cursor-pointer"> 13 13 <span class="label-text">Remember me</span> 14 14 <input type="checkbox" class="toggle toggle-secondary" checked /> 15 + <input type="checkbox" class="checkbox checkbox-secondary" checked /> 15 16 </label> 16 17 </div>
+1 -1
paw/templates/ticketing/create_ticket.html
··· 24 24 {{ form.description.errors }} 25 25 <div class="label"> 26 26 <span for="{{ form.description.id_for_label }}" class="label-text font-semibold text-base-content">{% trans 'Description' %}</span> 27 - </div> 27 + </div> 28 28 {{ form.description }} 29 29 </label> 30 30
+18 -10
paw/templates/ticketing/ticket_detail.html
··· 9 9 <h2 class="font-semibold mb-2">{% trans 'Attachments' %}</h2> 10 10 <div class="flex flex-wrap mb-6"> 11 11 {% for attachment in attachments %} 12 - <a href="{{ attachment.url }}" target="_blank" class="badge badge-lg badge-accent flex items-center mr-2"> 12 + <a href="{{ attachment.url }}" target="_blank" class="badge badge-lg badge-accent flex items-center mr-2 mb-2"> 13 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 14 {% trans 'Attachment' %} {{ forloop.counter }} 15 15 </a> 16 16 {% endfor %} 17 17 </div> 18 18 {% endif %} 19 - <h1 class="text-xl font-bold mb-4">Activity</h1> 19 + <h1 class="text-xl font-bold mb-4">{% trans 'Activity' %}</h1> 20 20 <div class="mb-4"> 21 21 {% for comment in comments %} 22 22 {% if not comment.is_only_for_staff or comment.is_only_for_staff and request.user.is_staff %} ··· 56 56 </div> 57 57 </form> 58 58 {% endif %} 59 - <form action="" method="post"> 59 + <form action="" method="post" enctype="multipart/form-data"> 60 60 {% csrf_token %} 61 61 {{ form.non_field_errors }} 62 62 ··· 64 64 {{ form.text.errors }} 65 65 {{ form.text }} 66 66 </label> 67 - <div class="flex justify-end items-center mt-4"> 67 + 68 + <div class="flex items-center mt-4"> 69 + <div class="form-control w-full max-w-lg mr-4"> 70 + {{ form.attachments }} 71 + </div> 72 + <div class="grow"></div> 73 + <button type="submit" name="submit" class="btn btn-success">{% trans 'Add Comment' %}</button> 68 74 {% if request.user.is_staff %} 69 - <div class="form-control w-52 mr-4"> 75 + <button type="submit" name="close" class="btn btn-error ml-4">{% trans 'Close Ticket' %}</button> 76 + {% endif %} 77 + </div> 78 + 79 + <div class="flex justify-end"> 80 + {% if request.user.is_staff %} 81 + <div class="form-control mt-2"> 70 82 <label for="{{ form.hidden_from_client.id_for_label }}" class="cursor-pointer label"> 71 - <span class="label-text">{% trans 'Hidden from client' %}</span> 72 83 {{ form.hidden_from_client }} 84 + <span class="ml-2 label-text">{% trans 'Make this an internal comment' %}</span> 73 85 </label> 74 86 </div> 75 - {% endif %} 76 - <button type="submit" name="submit" class="btn btn-success">{% trans 'Add Comment' %}</button> 77 - {% if request.user.is_staff %} 78 - <button type="submit" name="close" class="btn btn-error ml-4">{% trans 'Comment and close' %}</button> 79 87 {% endif %} 80 88 </div> 81 89 </form>
+73 -156
static/css/paw.css
··· 822 822 } 823 823 824 824 @media (hover:hover) { 825 + .checkbox-secondary:hover { 826 + --tw-border-opacity: 1; 827 + border-color: var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity))); 828 + } 829 + 825 830 .label a:hover { 826 831 --tw-text-opacity: 1; 827 832 color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); ··· 1108 1113 } 1109 1114 1110 1115 @media (hover: hover) { 1111 - .btm-nav > *.disabled:hover, 1112 - .btm-nav > *[disabled]:hover { 1113 - pointer-events: none; 1114 - --tw-border-opacity: 0; 1115 - background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); 1116 - --tw-bg-opacity: 0.1; 1117 - color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); 1118 - --tw-text-opacity: 0.2; 1119 - } 1120 - 1121 1116 .btn:hover { 1122 1117 --tw-border-opacity: 1; 1123 1118 border-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity))); ··· 1159 1154 @supports (color: oklch(0 0 0)) { 1160 1155 .btn-ghost:hover { 1161 1156 background-color: var(--fallback-bc,oklch(var(--bc)/0.2)); 1162 - } 1163 - } 1164 - 1165 - .btn-outline.btn-primary:hover { 1166 - --tw-text-opacity: 1; 1167 - color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); 1168 - } 1169 - 1170 - @supports (color: color-mix(in oklab, black, black)) { 1171 - .btn-outline.btn-primary:hover { 1172 - background-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); 1173 - border-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); 1174 1157 } 1175 1158 } 1176 1159 ··· 1337 1320 margin-inline-end: -1rem; 1338 1321 } 1339 1322 1340 - .input-lg[type="number"]::-webkit-inner-spin-button { 1341 - margin-top: -1.5rem; 1342 - margin-bottom: -1.5rem; 1343 - margin-inline-end: -1.5rem; 1344 - } 1345 - 1346 1323 .join { 1347 1324 display: inline-flex; 1348 1325 align-items: stretch; ··· 1409 1386 .link { 1410 1387 cursor: pointer; 1411 1388 text-decoration-line: underline; 1412 - } 1413 - 1414 - .menu li.disabled { 1415 - cursor: not-allowed; 1416 - -webkit-user-select: none; 1417 - -moz-user-select: none; 1418 - user-select: none; 1419 - color: var(--fallback-bc,oklch(var(--bc)/0.3)); 1420 1389 } 1421 1390 1422 1391 :where(.menu li) .badge { ··· 1684 1653 color: var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity))); 1685 1654 } 1686 1655 1687 - .btm-nav > *.disabled, 1688 - .btm-nav > *[disabled] { 1689 - pointer-events: none; 1690 - --tw-border-opacity: 0; 1691 - background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); 1692 - --tw-bg-opacity: 0.1; 1693 - color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); 1694 - --tw-text-opacity: 0.2; 1695 - } 1696 - 1697 1656 .btm-nav > * .label { 1698 1657 font-size: 1rem; 1699 1658 line-height: 1.5rem; ··· 1709 1668 .btn { 1710 1669 background-color: var(--btn-color, var(--fallback-b2)); 1711 1670 border-color: var(--btn-color, var(--fallback-b2)); 1712 - } 1713 - 1714 - .btn-primary { 1715 - --btn-color: var(--fallback-p); 1716 1671 } 1717 1672 1718 1673 .btn-accent { ··· 1737 1692 } 1738 1693 1739 1694 @supports (color: color-mix(in oklab, black, black)) { 1740 - .btn-outline.btn-primary.btn-active { 1741 - background-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); 1742 - border-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); 1743 - } 1744 - 1745 1695 .btn-outline.btn-accent.btn-active { 1746 1696 background-color: color-mix(in oklab, var(--fallback-a,oklch(var(--a)/1)) 90%, black); 1747 1697 border-color: color-mix(in oklab, var(--fallback-a,oklch(var(--a)/1)) 90%, black); ··· 1769 1719 outline-offset: 2px; 1770 1720 } 1771 1721 1772 - .btn-primary { 1773 - --tw-text-opacity: 1; 1774 - color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); 1775 - outline-color: var(--fallback-p,oklch(var(--p)/1)); 1776 - } 1777 - 1778 1722 @supports (color: oklch(0 0 0)) { 1779 - .btn-primary { 1780 - --btn-color: var(--p); 1781 - } 1782 - 1783 1723 .btn-accent { 1784 1724 --btn-color: var(--a); 1785 1725 } ··· 1857 1797 .btn-ghost.btn-active { 1858 1798 border-color: transparent; 1859 1799 background-color: var(--fallback-bc,oklch(var(--bc)/0.2)); 1860 - } 1861 - 1862 - .btn-outline.btn-primary { 1863 - --tw-text-opacity: 1; 1864 - color: var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity))); 1865 - } 1866 - 1867 - .btn-outline.btn-primary.btn-active { 1868 - --tw-text-opacity: 1; 1869 - color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); 1870 1800 } 1871 1801 1872 1802 .btn-outline.btn-accent { ··· 2025 1955 linear-gradient(0deg, var(--chkbg) 43%, var(--chkfg) 43%, var(--chkfg) 57%, var(--chkbg) 57%); 2026 1956 } 2027 1957 1958 + .checkbox-secondary { 1959 + --chkbg: var(--fallback-s,oklch(var(--s)/1)); 1960 + --chkfg: var(--fallback-sc,oklch(var(--sc)/1)); 1961 + --tw-border-opacity: 1; 1962 + border-color: var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity))); 1963 + } 1964 + 1965 + .checkbox-secondary:focus-visible { 1966 + outline-color: var(--fallback-s,oklch(var(--s)/1)); 1967 + } 1968 + 1969 + .checkbox-secondary:checked, 1970 + .checkbox-secondary[checked="true"], 1971 + .checkbox-secondary[aria-checked="true"] { 1972 + --tw-border-opacity: 1; 1973 + border-color: var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity))); 1974 + --tw-bg-opacity: 1; 1975 + background-color: var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity))); 1976 + --tw-text-opacity: 1; 1977 + color: var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity))); 1978 + } 1979 + 2028 1980 .checkbox:disabled { 2029 1981 cursor: not-allowed; 2030 1982 border-color: transparent; ··· 2169 2121 2170 2122 .join-item:focus { 2171 2123 isolation: isolate; 2172 - } 2173 - 2174 - @supports (color:color-mix(in oklab,black,black)) { 2175 - @media (hover:hover) { 2176 - .link-accent:hover { 2177 - color: color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 80%,black); 2178 - } 2179 - } 2180 - } 2181 - 2182 - .link-accent { 2183 - --tw-text-opacity: 1; 2184 - color: var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity))); 2185 2124 } 2186 2125 2187 2126 .link:focus { ··· 2696 2635 padding: 0px; 2697 2636 } 2698 2637 2699 - .input-lg { 2700 - height: 4rem; 2701 - padding-left: 1.5rem; 2702 - padding-right: 1.5rem; 2703 - font-size: 1.125rem; 2704 - line-height: 1.75rem; 2705 - line-height: 2; 2706 - } 2707 - 2708 2638 .join.join-vertical { 2709 2639 flex-direction: column; 2710 2640 } ··· 2928 2858 margin-right: 1rem; 2929 2859 } 2930 2860 2861 + .mt-2 { 2862 + margin-top: 0.5rem; 2863 + } 2864 + 2931 2865 .mt-4 { 2932 2866 margin-top: 1rem; 2933 2867 } 2934 2868 2935 2869 .mt-8 { 2936 2870 margin-top: 2rem; 2937 - } 2938 - 2939 - .mt-2 { 2940 - margin-top: 0.5rem; 2941 2871 } 2942 2872 2943 2873 .block { ··· 2956 2886 display: table; 2957 2887 } 2958 2888 2959 - .grid { 2960 - display: grid; 2961 - } 2962 - 2963 2889 .hidden { 2964 2890 display: none; 2965 2891 } ··· 2994 2920 2995 2921 .h-full { 2996 2922 height: 100%; 2997 - } 2998 - 2999 - .h-3 { 3000 - height: 0.75rem; 3001 2923 } 3002 2924 3003 2925 .min-h-screen { ··· 3044 2966 width: 100%; 3045 2967 } 3046 2968 3047 - .w-3 { 3048 - width: 0.75rem; 2969 + .max-w-4xl { 2970 + max-width: 56rem; 3049 2971 } 3050 2972 3051 - .max-w-4xl { 3052 - max-width: 56rem; 2973 + .max-w-lg { 2974 + max-width: 32rem; 3053 2975 } 3054 2976 3055 2977 .max-w-xl { ··· 3080 3002 cursor: pointer; 3081 3003 } 3082 3004 3083 - .grid-cols-1 { 3084 - grid-template-columns: repeat(1, minmax(0, 1fr)); 3085 - } 3086 - 3087 3005 .flex-row { 3088 3006 flex-direction: row; 3089 3007 } ··· 3094 3012 3095 3013 .flex-wrap { 3096 3014 flex-wrap: wrap; 3015 + } 3016 + 3017 + .items-start { 3018 + align-items: flex-start; 3097 3019 } 3098 3020 3099 3021 .items-center { ··· 3112 3034 justify-content: center; 3113 3035 } 3114 3036 3115 - .justify-between { 3116 - justify-content: space-between; 3117 - } 3118 - 3119 3037 .gap-2 { 3120 3038 gap: 0.5rem; 3121 - } 3122 - 3123 - .gap-4 { 3124 - gap: 1rem; 3125 3039 } 3126 3040 3127 3041 .self-end { ··· 3152 3066 border-left-width: 2px; 3153 3067 } 3154 3068 3069 + .border-\[\#4285F4\] { 3070 + --tw-border-opacity: 1; 3071 + border-color: rgb(66 133 244 / var(--tw-border-opacity)); 3072 + } 3073 + 3155 3074 .border-base-300 { 3156 3075 --tw-border-opacity: 1; 3157 3076 border-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity))); 3158 3077 } 3159 3078 3160 - .border-\[\#4285F4\] { 3161 - --tw-border-opacity: 1; 3162 - border-color: rgb(66 133 244 / var(--tw-border-opacity)); 3079 + .bg-\[\#4285F4\] { 3080 + --tw-bg-opacity: 1; 3081 + background-color: rgb(66 133 244 / var(--tw-bg-opacity)); 3163 3082 } 3164 3083 3165 3084 .bg-base-200 { ··· 3175 3094 .bg-neutral { 3176 3095 --tw-bg-opacity: 1; 3177 3096 background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); 3178 - } 3179 - 3180 - .bg-\[\#4285F4\] { 3181 - --tw-bg-opacity: 1; 3182 - background-color: rgb(66 133 244 / var(--tw-bg-opacity)); 3183 - } 3184 - 3185 - .bg-slate-300 { 3186 - --tw-bg-opacity: 1; 3187 - background-color: rgb(203 213 225 / var(--tw-bg-opacity)); 3188 3097 } 3189 3098 3190 3099 .stroke-current { ··· 3259 3168 line-height: 1rem; 3260 3169 } 3261 3170 3262 - .text-lg { 3263 - font-size: 1.125rem; 3264 - line-height: 1.75rem; 3265 - } 3266 - 3267 3171 .font-bold { 3268 3172 font-weight: 700; 3269 3173 } ··· 3315 3219 color: rgb(255 255 255 / var(--tw-text-opacity)); 3316 3220 } 3317 3221 3318 - .text-gray-900 { 3319 - --tw-text-opacity: 1; 3320 - color: rgb(17 24 39 / var(--tw-text-opacity)); 3321 - } 3322 - 3323 3222 .underline { 3324 3223 text-decoration-line: underline; 3325 3224 } ··· 3330 3229 3331 3230 .opacity-70 { 3332 3231 opacity: 0.7; 3333 - } 3334 - 3335 - .shadow { 3336 - --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 3337 - --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 3338 - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 3339 3232 } 3340 3233 3341 3234 @media (min-width: 1024px) { ··· 3390 3283 .sm\:block { 3391 3284 display: block; 3392 3285 } 3286 + 3287 + .sm\:max-w-lg { 3288 + max-width: 32rem; 3289 + } 3393 3290 } 3394 3291 3395 3292 @media (min-width: 768px) { 3396 - .md\:block { 3397 - display: block; 3293 + .md\:max-w-lg { 3294 + max-width: 32rem; 3398 3295 } 3399 3296 } 3400 3297 ··· 3446 3343 .lg\:px-3 { 3447 3344 padding-left: 0.75rem; 3448 3345 padding-right: 0.75rem; 3346 + } 3347 + } 3348 + 3349 + @media (min-width: 1280px) { 3350 + .xl\:max-w-lg { 3351 + max-width: 32rem; 3352 + } 3353 + } 3354 + 3355 + @media (min-width: 1536px) { 3356 + .\32xl\:mr-4 { 3357 + margin-right: 1rem; 3358 + } 3359 + 3360 + .\32xl\:max-w-lg { 3361 + max-width: 32rem; 3362 + } 3363 + 3364 + .\32xl\:flex-row { 3365 + flex-direction: row; 3449 3366 } 3450 3367 }
+35 -28
ticketing/forms.py
··· 4 4 import magic 5 5 6 6 7 + ATTACHMENT_CONTENT_TYPES = [ 8 + 'image/jpeg', 9 + 'image/png', 10 + 'application/pdf' 11 + ] 12 + 13 + 7 14 class MultipleFileInput(forms.ClearableFileInput): 8 15 allow_multiple_selected = True 9 16 ··· 23 30 return result 24 31 25 32 33 + def clean_attachments(files): 34 + if files: 35 + for file in files: 36 + # Check file size 37 + if file.size > 1024 * 1024 * 5: 38 + raise forms.ValidationError( 39 + _('File size must be under 5MB.')) 40 + 41 + # Check file type 42 + uploaded_content_type = file.content_type 43 + mg = magic.Magic(mime=True) 44 + content_type = mg.from_buffer(file.read(1024)) 45 + file.seek(0) 46 + 47 + if content_type != uploaded_content_type: 48 + uploaded_content_type = content_type 49 + 50 + if uploaded_content_type not in ATTACHMENT_CONTENT_TYPES: 51 + raise forms.ValidationError( 52 + _('File type not supported. Supported types are: .jpg, .png, .pdf')) 53 + return files 54 + 55 + 26 56 class CommentForm(forms.Form): 27 57 text = forms.CharField(widget=forms.Textarea( 28 58 attrs={'class': 'textarea textarea-bordered h-32', 'placeholder': 'Enter your comment here...'})) 29 59 hidden_from_client = forms.BooleanField(widget=forms.CheckboxInput( 30 - attrs={'class': 'toggle toggle-error'}), required=False) 60 + attrs={'class': 'checkbox checkbox-secondary'}), required=False) 31 61 62 + attachments = MultipleFileField(required=False) 32 63 33 - ATTACHMENT_CONTENT_TYPES = [ 34 - 'image/jpeg', 35 - 'image/png', 36 - 'application/pdf' 37 - ] 64 + def clean_attachments(self): 65 + return clean_attachments(self.cleaned_data.get('attachments')) 38 66 39 67 40 68 class TicketForm(forms.ModelForm): ··· 58 86 attachments = MultipleFileField(required=False) 59 87 60 88 def clean_attachments(self): 61 - files = self.cleaned_data.get('attachments') 62 - if files: 63 - for file in files: 64 - # Check file size 65 - if file.size > 1024 * 1024 * 5: 66 - raise forms.ValidationError( 67 - _('File size must be under 5MB.')) 68 - 69 - # Check file type 70 - uploaded_content_type = file.content_type 71 - mg = magic.Magic(mime=True) 72 - content_type = mg.from_buffer(file.read(1024)) 73 - file.seek(0) 74 - 75 - if content_type != uploaded_content_type: 76 - uploaded_content_type = content_type 77 - 78 - if uploaded_content_type not in ATTACHMENT_CONTENT_TYPES: 79 - raise forms.ValidationError( 80 - _('File type not supported. Supported types are: .jpg, .png, .pdf')) 81 - 82 - return files 89 + return clean_attachments(self.cleaned_data.get('attachments')) 83 90 84 91 85 92 class TemplateForm(forms.Form):
+6 -1
ticketing/views.py
··· 66 66 ticket.status = Ticket.Status.IN_PROGRESS 67 67 ticket.save() 68 68 else: 69 - form = CommentForm(request.POST) 69 + form = CommentForm(request.POST, request.FILES) 70 70 if form.is_valid(): 71 71 ticket.comment_set.create( 72 72 user=request.user, ticket=ticket, 73 73 text=form.cleaned_data["text"], is_only_for_staff=form.cleaned_data["hidden_from_client"] 74 74 ) 75 + # Add attachments 76 + if form.cleaned_data["attachments"]: 77 + for file in form.cleaned_data["attachments"]: 78 + ticket.fileattachment_set.create(file=file) 79 + 75 80 if 'close' in request.POST and request.user.is_staff: 76 81 ticket.close_ticket() 77 82