diff --git a/.flake8 b/.flake8 index 0daf1543..80233cdf 100644 --- a/.flake8 +++ b/.flake8 @@ -2,3 +2,11 @@ max-line-length = 120 exclude = .git,__pycache__,.tox,.eggs,*.egg,node_modules,.venv,migrations,docs,demo,tests,setup.py import-order-style = pep8 +max-complexity = 20 + +[pycodestyle] +max-line-length = 120 +exclude = "migrations" +in-place = true +recursive = true + diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml new file mode 100644 index 00000000..dfd82e51 --- /dev/null +++ b/.github/workflows/pythonpackage.yml @@ -0,0 +1,53 @@ +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10"] + django-version: ["32","4"] + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-testing.txt')}}-${{ hashFiles('tox.ini') }}-${{ matrix.python-version }}-${{ matrix.django-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-testing.txt -c constraints-Django${{ matrix.django-version }}.txt + + - name: Format style check with 'autopep8' + run: | + pip install autopep8 + autopep8 --exit-code --global-config .flake8 helpdesk + + - name: Lint with 'flake8' + run: | + pip install flake8 + # stop the build if there are Python syntax errors or undefined names + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 helpdesk --count --show-source --statistics --exit-zero --max-complexity=20 + + - name: Sort style check with 'isort' + run: | + isort --line-length=120 --src helpdesk . --check + + - name: Test with pytest + run: | + pip install pytest + cd ${GITHUB_WORKSPACE} && python quicktest.py + env: + DJANGO_SETTINGS_MODULE: helpdesk.settings + diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000..d854b4fc --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,19 @@ +[settings] +src_paths=helpdesk +skip_glob=migrations/* +skip=migrations +multi_line_output=3 +line_length=120 +use_parentheses=true +sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +lines_after_imports=2 +lines_before_imports=1 +balanced_wrapping=true +lines_between_types=1 +combine_as_imports=true +force_alphabetical_sort=true +skip_gitignore=true +force_sort_within_sections=true +group_by_package=false +from_first=true + diff --git a/demo/demodesk/config/settings.py b/demo/demodesk/config/settings.py index 5aaccfbf..55ab94f2 100644 --- a/demo/demodesk/config/settings.py +++ b/demo/demodesk/config/settings.py @@ -8,8 +8,10 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/1.11/ref/settings/ """ + import os + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/demo/demodesk/config/urls.py b/demo/demodesk/config/urls.py index 22d4f803..2fb2a3d7 100644 --- a/demo/demodesk/config/urls.py +++ b/demo/demodesk/config/urls.py @@ -13,10 +13,10 @@ Including another URLconf 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.urls import include, path -from django.contrib import admin from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path # The following uses the static() helper function, diff --git a/demo/demodesk/config/wsgi.py b/demo/demodesk/config/wsgi.py index f8e39f59..3fb82a46 100644 --- a/demo/demodesk/config/wsgi.py +++ b/demo/demodesk/config/wsgi.py @@ -7,9 +7,10 @@ For more information on this file, see https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ """ -import os from django.core.wsgi import get_wsgi_application +import os + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demodesk.config.settings") diff --git a/demo/setup.py b/demo/setup.py index 2cab27a8..bacc93a2 100644 --- a/demo/setup.py +++ b/demo/setup.py @@ -1,10 +1,8 @@ # -*- coding: utf-8 -*- """Python packaging.""" - -from __future__ import unicode_literals - -from setuptools import setup import os +from setuptools import setup + here = os.path.abspath(os.path.dirname(__file__)) project_root = os.path.dirname(here) @@ -13,7 +11,7 @@ project_root = os.path.dirname(here) NAME = 'django-helpdesk-demodesk' DESCRIPTION = 'A demo Django project using django-helpdesk' README = open(os.path.join(here, 'README.rst')).read() -VERSION = '0.4.1' +VERSION = '0.5.0a1' #VERSION = open(os.path.join(project_root, 'VERSION')).read().strip() AUTHOR = 'django-helpdesk team' URL = 'https://github.com/django-helpdesk/django-helpdesk' diff --git a/docs/conf.py b/docs/conf.py index 6e384cb3..f79715bb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,8 +11,9 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys import os +import sys + # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the diff --git a/docs/install.rst b/docs/install.rst index 743d34e8..249645d8 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -63,6 +63,7 @@ errors with trying to create User settings. 'pinax.teams', # Team support 'reversion', # Required by pinax-teams 'rest_framework', # required for the API + 'django_cleanup.apps.CleanupConfig', # Remove this if you do NOT want to delete files on the file system when the associated record is deleted in the database 'helpdesk', # This is us! ) diff --git a/docs/teams.rst b/docs/teams.rst index 5fe88437..64be9747 100644 --- a/docs/teams.rst +++ b/docs/teams.rst @@ -13,4 +13,4 @@ You can assign a knowledge-base item to a team on the Helpdesk admin page. Once you have set up teams. Unassigned tickets which are associated with a knowledge-base item will only be shown on the dashboard to those users who are members of the team which is associated with that knowledge-base item. -Note: It is possible that pinax-teams will interfere with other packages that you already use in your project. If you do not wish to use team functionality, you can dissable teams by setting the following settings: ``HELPDESK_TEAMS_MODEL`` to any random model, ``HELPDESK_TEAMS_MIGRATION_DEPENDENCIES`` to ``[]``, and ``HELPDESK_KBITEM_TEAM_GETTER`` to ``lambda _: None``. You can also use a different library in place of pinax teams by setting those settings appropriately. ``HELPDESK_KBITEM_TEAM_GETTER`` should take a ``kbitem`` and return a team object with a ``name`` property and a method ``is_member(self, user)`` which returns true if user is a member of the team. +Note: It is possible that pinax-teams will interfere with other packages that you already use in your project. If you do not wish to use team functionality, you can disable teams by setting the following settings: ``HELPDESK_TEAMS_MODEL`` to any random model, ``HELPDESK_TEAMS_MIGRATION_DEPENDENCIES`` to ``[]``, and ``HELPDESK_KBITEM_TEAM_GETTER`` to ``lambda _: None``. You can also use a different library in place of pinax teams by setting those settings appropriately. ``HELPDESK_KBITEM_TEAM_GETTER`` should take a ``kbitem`` and return a team object with a ``name`` property and a method ``is_member(self, user)`` which returns true if user is a member of the team. diff --git a/helpdesk/admin.py b/helpdesk/admin.py index 6fbd352d..d51cf209 100644 --- a/helpdesk/admin.py +++ b/helpdesk/admin.py @@ -1,13 +1,24 @@ + from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from helpdesk.models import Queue, Ticket, FollowUp, PreSetReply, KBCategory -from helpdesk.models import EscalationExclusion, EmailTemplate, KBItem -from helpdesk.models import TicketChange, KBIAttachment, FollowUpAttachment, IgnoreEmail -from helpdesk.models import CustomField from helpdesk import settings as helpdesk_settings +from helpdesk.models import ( + CustomField, + EmailTemplate, + EscalationExclusion, + FollowUp, + FollowUpAttachment, + IgnoreEmail, + KBIAttachment, + PreSetReply, + Queue, + Ticket, + TicketChange +) + + if helpdesk_settings.HELPDESK_KB_ENABLED: - from helpdesk.models import KBCategory - from helpdesk.models import KBItem + from helpdesk.models import KBCategory, KBItem @admin.register(Queue) @@ -82,9 +93,10 @@ if helpdesk_settings.HELPDESK_KB_ENABLED: list_display_links = ('title',) - @admin.register(KBCategory) - class KBCategoryAdmin(admin.ModelAdmin): - list_display = ('name', 'title', 'slug', 'public') + if helpdesk_settings.HELPDESK_KB_ENABLED: + @admin.register(KBCategory) + class KBCategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'title', 'slug', 'public') @admin.register(CustomField) diff --git a/helpdesk/decorators.py b/helpdesk/decorators.py index 1dedbc02..e003440a 100644 --- a/helpdesk/decorators.py +++ b/helpdesk/decorators.py @@ -1,12 +1,8 @@ -from functools import wraps - +from django.contrib.auth.decorators import user_passes_test from django.core.exceptions import PermissionDenied from django.http import Http404 from django.shortcuts import redirect - -from django.contrib.auth.decorators import user_passes_test - - +from functools import wraps from helpdesk import settings as helpdesk_settings diff --git a/helpdesk/email.py b/helpdesk/email.py index 6fe347ac..1a0b7d50 100644 --- a/helpdesk/email.py +++ b/helpdesk/email.py @@ -4,23 +4,11 @@ Django Helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. Copyright 2018 Timothy Hobbs. All Rights Reserved. See LICENSE for details. """ + # import base64 -import email -import imaplib -import logging -import mimetypes -import os -import poplib -import re -import socket -import ssl -import sys -from datetime import timedelta -from email.utils import getaddresses -from os.path import isfile, join -from time import ctime from bs4 import BeautifulSoup +from datetime import timedelta from django.conf import settings as django_settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError @@ -28,11 +16,24 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.db.models import Q from django.utils import encoding, timezone from django.utils.translation import gettext as _ +import email +from email.utils import getaddresses from email_reply_parser import EmailReplyParser - from helpdesk import settings -from helpdesk.lib import safe_template_context, process_attachments -from helpdesk.models import Queue, Ticket, TicketCC, FollowUp, IgnoreEmail +from helpdesk.lib import process_attachments, safe_template_context +from helpdesk.models import FollowUp, IgnoreEmail, Queue, Ticket +import imaplib +import logging +import mimetypes +import os +from os.path import isfile, join +import poplib +import re +import socket +import ssl +import sys +from time import ctime +import typing # import User model, which may be a custom model @@ -176,13 +177,13 @@ def imap_sync(q, logger, server): sys.exit() try: - status, data = server.search(None, 'NOT', 'DELETED') + data = server.search(None, 'NOT', 'DELETED')[1] if data: msgnums = data[0].split() logger.info("Received %d messages from IMAP server" % len(msgnums)) for num in msgnums: logger.info("Processing message %s" % num) - status, data = server.fetch(num, '(RFC822)') + data = server.fetch(num, '(RFC822)')[1] full_message = encoding.force_str(data[0][1], errors='replace') try: ticket = object_from_message( @@ -342,10 +343,10 @@ def create_ticket_cc(ticket, cc_list): return [] # Local import to deal with non-defined / circular reference problem - from helpdesk.views.staff import User, subscribe_to_ticket_updates + from helpdesk.views.staff import subscribe_to_ticket_updates, User new_ticket_ccs = [] - for cced_name, cced_email in cc_list: + for __, cced_email in cc_list: cced_email = cced_email.strip() if cced_email == ticket.queue.email_address: @@ -354,7 +355,7 @@ def create_ticket_cc(ticket, cc_list): user = None try: - user = User.objects.get(email=cced_email) + user = User.objects.get(email=cced_email) # @UndefinedVariable except User.DoesNotExist: pass @@ -466,16 +467,6 @@ def create_object_from_email_message(message, ticket_id, payload, files, logger) new_ticket_ccs = [] new_ticket_ccs.append(create_ticket_cc(ticket, to_list + cc_list)) - notifications_to_be_sent = [sender_email] - - if queue.enable_notifications_on_email_events and len(notifications_to_be_sent): - - ticket_cc_list = TicketCC.objects.filter( - ticket=ticket).all().values_list('email', flat=True) - - for email_address in ticket_cc_list: - notifications_to_be_sent.append(email_address) - autoreply = is_autoreply(message) if autoreply: logger.info( @@ -516,7 +507,77 @@ def create_object_from_email_message(message, ticket_id, payload, files, logger) return ticket -def object_from_message(message, queue, logger): +def get_ticket_id_from_subject_slug( + queue_slug: str, + subject: str, + logger: logging.Logger +) -> typing.Optional[int]: + """Get a ticket id from the subject string + + Performs a match on the subject using the queue_slug as reference, + returning the ticket id if a match is found. + """ + matchobj = re.match(r".*\[" + queue_slug + r"-(?P\d+)\]", subject) + ticket_id = None + if matchobj: + # This is a reply or forward. + ticket_id = matchobj.group('id') + logger.info("Matched tracking ID %s-%s" % (queue_slug, ticket_id)) + else: + logger.info("No tracking ID matched.") + return ticket_id + + +def add_file_if_always_save_incoming_email_message( + files_, + message: str +) -> None: + """When `settings.HELPDESK_ALWAYS_SAVE_INCOMING_EMAIL_MESSAGE` is `True` + add a file to the files_ list""" + if getattr(django_settings, 'HELPDESK_ALWAYS_SAVE_INCOMING_EMAIL_MESSAGE', False): + # save message as attachment in case of some complex markup renders + # wrong + files_.append( + SimpleUploadedFile( + _("original_message.eml").replace( + ".eml", + timezone.localtime().strftime("_%d-%m-%Y_%H:%M") + ".eml" + ), + str(message).encode("utf-8"), + 'text/plain' + ) + ) + + +def get_encoded_body(body: str) -> str: + try: + return body.encode('ascii').decode('unicode_escape') + except UnicodeEncodeError: + return body + + +def get_body_from_fragments(body) -> str: + """Gets a body from the fragments, joined by a double line break""" + return "\n\n".join(f.content for f in EmailReplyParser.read(body).fragments) + + +def get_email_body_from_part_payload(part) -> str: + """Gets an decoded body from the payload part, if the decode fails, + returns without encoding""" + try: + return encoding.smart_str( + part.get_payload(decode=True) + ) + except UnicodeDecodeError: + return encoding.smart_str( + part.get_payload(decode=False) + ) + + +def object_from_message(message: str, + queue: Queue, + logger: logging.Logger + ) -> Ticket: # 'message' must be an RFC822 formatted message. message = email.message_from_string(message) @@ -539,35 +600,17 @@ def object_from_message(message, queue, logger): sender_email = email.utils.getaddresses( ['\"' + sender.replace('<', '\" <')])[0][1] - cc = message.get_all('cc', None) - if cc: - # first, fixup the encoding if necessary - cc = [decode_mail_headers(decodeUnknown( - message.get_charset(), x)) for x in cc] - # get_all checks if multiple CC headers, but individual emails may be - # comma separated too - tempcc = [] - for hdr in cc: - tempcc.extend(hdr.split(',')) - # use a set to ensure no duplicates - cc = set([x.strip() for x in tempcc]) - for ignore in IgnoreEmail.objects.filter(Q(queues=queue) | Q(queues__isnull=True)): if ignore.test(sender_email): - if ignore.keep_in_mailbox: - # By returning 'False' the message will be kept in the mailbox, - # and the 'True' will cause the message to be deleted. - return False - return True + # By returning 'False' the message will be kept in the mailbox, + # and the 'True' will cause the message to be deleted. + return not ignore.keep_in_mailbox - matchobj = re.match(r".*\[" + queue.slug + r"-(?P\d+)\]", subject) - if matchobj: - # This is a reply or forward. - ticket = matchobj.group('id') - logger.info("Matched tracking ID %s-%s" % (queue.slug, ticket)) - else: - logger.info("No tracking ID matched.") - ticket = None + ticket_id: typing.Optional[int] = get_ticket_id_from_subject_slug( + queue.slug, + subject, + logger + ) body = None full_body = None @@ -591,13 +634,10 @@ def object_from_message(message, queue, logger): body = decodeUnknown(part.get_content_charset(), body) # have to use django_settings here so overwritting it works in tests # the default value is False anyway - if ticket is None and getattr(django_settings, 'HELPDESK_FULL_FIRST_MESSAGE_FROM_EMAIL', False): + if ticket_id is None and getattr(django_settings, 'HELPDESK_FULL_FIRST_MESSAGE_FROM_EMAIL', False): # first message in thread, we save full body to avoid # losing forwards and things like that - body_parts = [] - for f in EmailReplyParser.read(body).fragments: - body_parts.append(f.content) - full_body = '\n\n'.join(body_parts) + full_body = get_body_from_fragments(body) body = EmailReplyParser.parse_reply(body) else: # second and other reply, save only first part of the @@ -605,18 +645,10 @@ def object_from_message(message, queue, logger): body = EmailReplyParser.parse_reply(body) full_body = body # workaround to get unicode text out rather than escaped text - try: - body = body.encode('ascii').decode('unicode_escape') - except UnicodeEncodeError: - body.encode('utf-8') + body = get_encoded_body(body) logger.debug("Discovered plain text MIME part") else: - try: - email_body = encoding.smart_str( - part.get_payload(decode=True)) - except UnicodeDecodeError: - email_body = encoding.smart_str( - part.get_payload(decode=False)) + email_body = get_email_body_from_part_payload(part) if not body and not full_body: # no text has been parsed so far - try such deep parsing @@ -683,19 +715,7 @@ def object_from_message(message, queue, logger): if not body: body = "" - if getattr(django_settings, 'HELPDESK_ALWAYS_SAVE_INCOMING_EMAIL_MESSAGE', False): - # save message as attachment in case of some complex markup renders - # wrong - files.append( - SimpleUploadedFile( - _("original_message.eml").replace( - ".eml", - timezone.localtime().strftime("_%d-%m-%Y_%H:%M") + ".eml" - ), - str(message).encode("utf-8"), - 'text/plain' - ) - ) + add_file_if_always_save_incoming_email_message(files, message) smtp_priority = message.get('priority', '') smtp_importance = message.get('importance', '') @@ -713,4 +733,4 @@ def object_from_message(message, queue, logger): 'files': files, } - return create_object_from_email_message(message, ticket, payload, files, logger=logger) + return create_object_from_email_message(message, ticket_id, payload, files, logger=logger) diff --git a/helpdesk/forms.py b/helpdesk/forms.py index 9aa32a69..11ec89ed 100644 --- a/helpdesk/forms.py +++ b/helpdesk/forms.py @@ -6,25 +6,38 @@ django-helpdesk - A Django powered ticket tracker for small enterprise. forms.py - Definitions of newforms-based forms for creating and maintaining tickets. """ -import logging -from datetime import datetime, date, time -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from datetime import datetime from django import forms from django.conf import settings -from django.utils.translation import gettext_lazy as _ from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils import timezone - -from helpdesk.lib import safe_template_context, process_attachments, convert_value -from helpdesk.models import (Ticket, Queue, FollowUp, IgnoreEmail, TicketCC, - CustomField, TicketCustomFieldValue, TicketDependency, UserSettings) +from django.utils.translation import gettext_lazy as _ from helpdesk import settings as helpdesk_settings -from helpdesk.settings import CUSTOMFIELD_TO_FIELD_DICT, CUSTOMFIELD_DATETIME_FORMAT, \ - CUSTOMFIELD_DATE_FORMAT, CUSTOMFIELD_TIME_FORMAT +from helpdesk.lib import convert_value, process_attachments, safe_template_context +from helpdesk.models import ( + CustomField, + FollowUp, + IgnoreEmail, + Queue, + Ticket, + TicketCC, + TicketCustomFieldValue, + TicketDependency, + UserSettings +) +from helpdesk.settings import ( + CUSTOMFIELD_DATE_FORMAT, + CUSTOMFIELD_DATETIME_FORMAT, + CUSTOMFIELD_TIME_FORMAT, + CUSTOMFIELD_TO_FIELD_DICT +) +import logging + if helpdesk_settings.HELPDESK_KB_ENABLED: - from helpdesk.models import (KBItem) + from helpdesk.models import KBItem logger = logging.getLogger(__name__) User = get_user_model() @@ -226,7 +239,10 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form): widget=forms.FileInput(attrs={'class': 'form-control-file'}), required=False, label=_('Attach File'), - help_text=_('You can attach a file to this ticket. Only file types such as plain text (.txt), a document (.pdf, .docx, or .odt), or screenshot (.png or .jpg) may be uploaded.'), + help_text=_('You can attach a file to this ticket. ' + 'Only file types such as plain text (.txt), ' + 'a document (.pdf, .docx, or .odt), ' + 'or screenshot (.png or .jpg) may be uploaded.'), ) class Media: diff --git a/helpdesk/lib.py b/helpdesk/lib.py index 911c7a96..028d6353 100644 --- a/helpdesk/lib.py +++ b/helpdesk/lib.py @@ -6,14 +6,14 @@ django-helpdesk - A Django powered ticket tracker for small enterprise. lib.py - Common functions (eg multipart e-mail) """ -import logging -import mimetypes -from datetime import datetime, date, time +from datetime import date, datetime, time from django.conf import settings from django.utils.encoding import smart_str +from helpdesk.settings import CUSTOMFIELD_DATE_FORMAT, CUSTOMFIELD_DATETIME_FORMAT, CUSTOMFIELD_TIME_FORMAT +import logging +import mimetypes -from helpdesk.settings import CUSTOMFIELD_DATETIME_FORMAT, CUSTOMFIELD_DATE_FORMAT, CUSTOMFIELD_TIME_FORMAT logger = logging.getLogger('helpdesk') diff --git a/helpdesk/management/commands/create_escalation_exclusions.py b/helpdesk/management/commands/create_escalation_exclusions.py index 9e2148d5..6d3e249f 100644 --- a/helpdesk/management/commands/create_escalation_exclusions.py +++ b/helpdesk/management/commands/create_escalation_exclusions.py @@ -8,12 +8,11 @@ scripts/create_escalation_exclusion.py - Easy way to routinely add particular days to the list of days on which no escalation should take place. """ + +from datetime import date, timedelta from django.core.management.base import BaseCommand, CommandError - -from helpdesk.models import EscalationExclusion, Queue - -from datetime import timedelta, date import getopt +from helpdesk.models import EscalationExclusion, Queue from optparse import make_option import sys diff --git a/helpdesk/management/commands/create_queue_permissions.py b/helpdesk/management/commands/create_queue_permissions.py index 91f064dc..1da97851 100644 --- a/helpdesk/management/commands/create_queue_permissions.py +++ b/helpdesk/management/commands/create_queue_permissions.py @@ -13,15 +13,13 @@ scripts/create_queue_permissions.py - existing permissions. """ -from optparse import make_option - from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand, CommandError from django.db.utils import IntegrityError from django.utils.translation import gettext_lazy as _ - from helpdesk.models import Queue +from optparse import make_option class Command(BaseCommand): diff --git a/helpdesk/management/commands/create_usersettings.py b/helpdesk/management/commands/create_usersettings.py index 2ed628a1..7f5203cf 100644 --- a/helpdesk/management/commands/create_usersettings.py +++ b/helpdesk/management/commands/create_usersettings.py @@ -8,12 +8,12 @@ create_usersettings.py - Easy way to create helpdesk-specific settings for users who don't yet have them. """ -from django.utils.translation import gettext as _ -from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model - +from django.core.management.base import BaseCommand +from django.utils.translation import gettext as _ from helpdesk.models import UserSettings + User = get_user_model() diff --git a/helpdesk/management/commands/escalate_tickets.py b/helpdesk/management/commands/escalate_tickets.py index 020a3b78..d0a920a8 100644 --- a/helpdesk/management/commands/escalate_tickets.py +++ b/helpdesk/management/commands/escalate_tickets.py @@ -8,18 +8,17 @@ scripts/escalate_tickets.py - Easy way to escalate tickets based on their age, designed to be run from Cron or similar. """ -from datetime import timedelta, date -import getopt -from optparse import make_option -import sys +from datetime import date, timedelta from django.core.management.base import BaseCommand, CommandError from django.db.models import Q -from django.utils.translation import gettext as _ from django.utils import timezone - -from helpdesk.models import Queue, Ticket, FollowUp, EscalationExclusion, TicketChange +from django.utils.translation import gettext as _ +import getopt from helpdesk.lib import safe_template_context +from helpdesk.models import EscalationExclusion, FollowUp, Queue, Ticket, TicketChange +from optparse import make_option +import sys class Command(BaseCommand): diff --git a/helpdesk/management/commands/get_email.py b/helpdesk/management/commands/get_email.py index ce344bd5..0111b93b 100755 --- a/helpdesk/management/commands/get_email.py +++ b/helpdesk/management/commands/get_email.py @@ -11,7 +11,6 @@ scripts/get_email.py - Designed to be run from cron, this script checks the adding to existing tickets if needed) """ from django.core.management.base import BaseCommand - from helpdesk.email import process_email diff --git a/helpdesk/models.py b/helpdesk/models.py index 6a20091d..ab19c9bd 100644 --- a/helpdesk/models.py +++ b/helpdesk/models.py @@ -7,35 +7,29 @@ models.py - Model (and hence database) definitions. This is the core of the helpdesk structure. """ -from django.contrib.auth.models import Permission + +from .lib import convert_value +from .templated_email import send_templated_mail +from .validators import validate_file_extension +import datetime +from django.conf import settings from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models -from django.conf import settings from django.utils import timezone -from django.utils.translation import gettext_lazy as _, gettext -from io import StringIO -import re -import os -import mimetypes -import datetime - from django.utils.safestring import mark_safe +from django.utils.translation import gettext, gettext_lazy as _ +from helpdesk import settings as helpdesk_settings +from io import StringIO from markdown import markdown from markdown.extensions import Extension - - -import uuid - +import mimetypes +import os +import re from rest_framework import serializers - -from helpdesk import settings as helpdesk_settings -from .lib import convert_value - -from .validators import validate_file_extension - -from .templated_email import send_templated_mail +import uuid def format_time_spent(time_spent): @@ -59,7 +53,7 @@ def get_markdown(text): if not text: return "" - pattern = fr'([\[\s\S\]]*?)\(([\s\S]*?):([\s\S]*?)\)' + pattern = r'([\[\s\S\]]*?)\(([\s\S]*?):([\s\S]*?)\)' # Regex check if re.match(pattern, text): # get get value of group regex @@ -1969,6 +1963,10 @@ class TicketCustomFieldValue(models.Model): def __str__(self): return '%s / %s' % (self.ticket, self.field) + @property + def default_value(self) -> str: + return _("Not defined") + class Meta: unique_together = (('ticket', 'field'),) verbose_name = _('Ticket custom field value') diff --git a/helpdesk/query.py b/helpdesk/query.py index 14da72a8..9ebef3e9 100644 --- a/helpdesk/query.py +++ b/helpdesk/query.py @@ -1,15 +1,12 @@ + +from base64 import b64decode, b64encode from django.db.models import Q from django.urls import reverse from django.utils.html import escape from django.utils.translation import gettext as _ - -from base64 import b64encode -from base64 import b64decode -import json - -from model_utils import Choices - from helpdesk.serializers import DatatablesTicketSerializer +import json +from model_utils import Choices def query_to_base64(query): diff --git a/helpdesk/serializers.py b/helpdesk/serializers.py index c4134fa2..6474060d 100644 --- a/helpdesk/serializers.py +++ b/helpdesk/serializers.py @@ -1,13 +1,12 @@ -from rest_framework import serializers +from .forms import TicketForm +from .lib import format_time_spent, process_attachments +from .models import CustomField, FollowUp, FollowUpAttachment, Ticket +from .user import HelpdeskUser from django.contrib.auth.models import User from django.contrib.humanize.templatetags import humanize +from rest_framework import serializers from rest_framework.exceptions import ValidationError -from .forms import TicketForm -from .models import Ticket, CustomField, FollowUp, FollowUpAttachment -from .lib import format_time_spent, process_attachments -from .user import HelpdeskUser - class DatatablesTicketSerializer(serializers.ModelSerializer): """ diff --git a/helpdesk/settings.py b/helpdesk/settings.py index 2724c01b..4594a510 100644 --- a/helpdesk/settings.py +++ b/helpdesk/settings.py @@ -2,12 +2,13 @@ Default settings for django-helpdesk. """ -import os -import warnings from django import forms from django.conf import settings from django.core.exceptions import ImproperlyConfigured +import os +import warnings + DEFAULT_USER_SETTINGS = { 'login_view_ticketlist': True, diff --git a/helpdesk/tasks.py b/helpdesk/tasks.py index 2fdee3e4..d7ce1d68 100644 --- a/helpdesk/tasks.py +++ b/helpdesk/tasks.py @@ -1,6 +1,5 @@ -from celery import shared_task - from .email import process_email +from celery import shared_task @shared_task diff --git a/helpdesk/templated_email.py b/helpdesk/templated_email.py index 2fc83973..5c98cdc7 100644 --- a/helpdesk/templated_email.py +++ b/helpdesk/templated_email.py @@ -1,9 +1,10 @@ -import os -import logging -from smtplib import SMTPException from django.conf import settings from django.utils.safestring import mark_safe +import logging +import os +from smtplib import SMTPException + logger = logging.getLogger('helpdesk') @@ -50,8 +51,7 @@ def send_templated_mail(template_name, from_string = engines['django'].from_string from helpdesk.models import EmailTemplate - from helpdesk.settings import HELPDESK_EMAIL_SUBJECT_TEMPLATE, \ - HELPDESK_EMAIL_FALLBACK_LOCALE + from helpdesk.settings import HELPDESK_EMAIL_FALLBACK_LOCALE, HELPDESK_EMAIL_SUBJECT_TEMPLATE headers = extra_headers or {} diff --git a/helpdesk/templatetags/helpdesk_staff.py b/helpdesk/templatetags/helpdesk_staff.py index ab1ea3cf..621398cc 100644 --- a/helpdesk/templatetags/helpdesk_staff.py +++ b/helpdesk/templatetags/helpdesk_staff.py @@ -4,10 +4,10 @@ django-helpdesk - A Django powered ticket tracker for small enterprise. The is_helpdesk_staff template filter returns True if the user qualifies as Helpdesk staff. templatetags/helpdesk_staff.py """ -import logging -from django.template import Library +from django.template import Library from helpdesk.decorators import is_helpdesk_staff +import logging logger = logging.getLogger(__name__) diff --git a/helpdesk/templatetags/helpdesk_util.py b/helpdesk/templatetags/helpdesk_util.py index b096824c..2498e2e5 100644 --- a/helpdesk/templatetags/helpdesk_util.py +++ b/helpdesk/templatetags/helpdesk_util.py @@ -1,10 +1,9 @@ +from datetime import datetime +from django.conf import settings from django.template import Library from django.template.defaultfilters import date as date_filter -from django.conf import settings +from helpdesk.forms import CUSTOMFIELD_DATE_FORMAT, CUSTOMFIELD_DATETIME_FORMAT, CUSTOMFIELD_TIME_FORMAT -from datetime import datetime - -from helpdesk.forms import CUSTOMFIELD_DATE_FORMAT, CUSTOMFIELD_TIME_FORMAT, CUSTOMFIELD_DATETIME_FORMAT register = Library() diff --git a/helpdesk/templatetags/saved_queries.py b/helpdesk/templatetags/saved_queries.py index 533e13a6..5956fc86 100644 --- a/helpdesk/templatetags/saved_queries.py +++ b/helpdesk/templatetags/saved_queries.py @@ -7,7 +7,6 @@ templatetags/saved_queries.py - This template tag returns previously saved """ from django import template from django.db.models import Q - from helpdesk.models import SavedSearch diff --git a/helpdesk/templatetags/ticket_to_link.py b/helpdesk/templatetags/ticket_to_link.py index 39683bfb..9111ff11 100644 --- a/helpdesk/templatetags/ticket_to_link.py +++ b/helpdesk/templatetags/ticket_to_link.py @@ -10,12 +10,11 @@ templatetags/ticket_to_link.py - Used in ticket comments to allow wiki-style to show the status of that ticket (eg a closed ticket would have a strikethrough). """ + from django import template from django.urls import reverse from django.utils.safestring import mark_safe - from helpdesk.models import Ticket - import re diff --git a/helpdesk/tests/helpers.py b/helpdesk/tests/helpers.py index 57775c76..fe4d65de 100644 --- a/helpdesk/tests/helpers.py +++ b/helpdesk/tests/helpers.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- -import sys -from django.contrib.auth import get_user_model -from helpdesk.models import Ticket, Queue, UserSettings +from django.contrib.auth import get_user_model +from helpdesk.models import Queue, Ticket, UserSettings +import sys + User = get_user_model() diff --git a/helpdesk/tests/test_api.py b/helpdesk/tests/test_api.py index 45c4bcfe..75bf895e 100644 --- a/helpdesk/tests/test_api.py +++ b/helpdesk/tests/test_api.py @@ -1,18 +1,22 @@ + import base64 from collections import OrderedDict from datetime import datetime - +from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from freezegun import freeze_time - -from django.contrib.auth.models import User +from helpdesk.models import CustomField, Queue, Ticket from rest_framework import HTTP_HEADER_ENCODING from rest_framework.exceptions import ErrorDetail -from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN +) from rest_framework.test import APITestCase -from helpdesk.models import Queue, Ticket, CustomField - class TicketTest(APITestCase): due_date = datetime(2022, 4, 10, 15, 6) diff --git a/helpdesk/tests/test_attachments.py b/helpdesk/tests/test_attachments.py index 46e7151d..b8f16b28 100644 --- a/helpdesk/tests/test_attachments.py +++ b/helpdesk/tests/test_attachments.py @@ -1,15 +1,13 @@ # vim: set fileencoding=utf-8 : + from django.core.files.uploadedfile import SimpleUploadedFile -from django.urls import reverse from django.test import override_settings, TestCase +from django.urls import reverse from django.utils.encoding import smart_str - from helpdesk import lib, models - import os import shutil from tempfile import gettempdir - from unittest import mock from unittest.case import skip diff --git a/helpdesk/tests/test_get_email.py b/helpdesk/tests/test_get_email.py index f1cf010e..89afa95d 100644 --- a/helpdesk/tests/test_get_email.py +++ b/helpdesk/tests/test_get_email.py @@ -1,24 +1,23 @@ # -*- coding: utf-8 -*- -from django.test import TestCase, override_settings + +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import User from django.core.management import call_command from django.shortcuts import get_object_or_404 -from django.contrib.auth.models import User -from django.contrib.auth.hashers import make_password - -from helpdesk.models import Queue, Ticket, TicketCC, FollowUp, FollowUpAttachment -from helpdesk.management.commands.get_email import Command +from django.test import override_settings, TestCase import helpdesk.email - -import six +from helpdesk.management.commands.get_email import Command +from helpdesk.models import FollowUp, FollowUpAttachment, Queue, Ticket, TicketCC import itertools -from shutil import rmtree -import sys -import os -from tempfile import mkdtemp import logging - +import os +from shutil import rmtree +import six +import sys +from tempfile import mkdtemp from unittest import mock + THIS_DIR = os.path.dirname(os.path.abspath(__file__)) # class A addresses can't have first octet of 0 diff --git a/helpdesk/tests/test_kb.py b/helpdesk/tests/test_kb.py index ed1ab7ba..511aae67 100644 --- a/helpdesk/tests/test_kb.py +++ b/helpdesk/tests/test_kb.py @@ -1,11 +1,8 @@ # -*- coding: utf-8 -*- -from django.urls import reverse from django.test import TestCase - +from django.urls import reverse from helpdesk.models import KBCategory, KBItem, Queue, Ticket - -from helpdesk.tests.helpers import ( - get_staff_user, reload_urlconf, User, create_ticket, print_response) +from helpdesk.tests.helpers import create_ticket, get_staff_user, print_response, reload_urlconf, User class KBTests(TestCase): diff --git a/helpdesk/tests/test_login.py b/helpdesk/tests/test_login.py index 800ae71d..ad0ebddc 100644 --- a/helpdesk/tests/test_login.py +++ b/helpdesk/tests/test_login.py @@ -1,4 +1,4 @@ -from django.test import TestCase, override_settings +from django.test import override_settings, TestCase from django.urls import reverse diff --git a/helpdesk/tests/test_navigation.py b/helpdesk/tests/test_navigation.py index 21e51ab9..fef74fce 100644 --- a/helpdesk/tests/test_navigation.py +++ b/helpdesk/tests/test_navigation.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -import sys -from importlib import reload -from django.urls import reverse -from django.test import TestCase + +from django.test import TestCase +from django.test.utils import override_settings +from django.urls import reverse from helpdesk import settings as helpdesk_settings from helpdesk.models import Queue -from helpdesk.tests.helpers import ( - get_staff_user, reload_urlconf, User, create_ticket, print_response) -from django.test.utils import override_settings +from helpdesk.tests.helpers import create_ticket, get_staff_user, print_response, reload_urlconf, User +from importlib import reload +import sys class KBDisabledTestCase(TestCase): diff --git a/helpdesk/tests/test_per_queue_staff_permission.py b/helpdesk/tests/test_per_queue_staff_permission.py index c16dd7a2..b04f53bc 100644 --- a/helpdesk/tests/test_per_queue_staff_permission.py +++ b/helpdesk/tests/test_per_queue_staff_permission.py @@ -1,11 +1,10 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission -from django.urls import reverse from django.test import TestCase from django.test.client import Client - -from helpdesk.models import Queue, Ticket +from django.urls import reverse from helpdesk import settings +from helpdesk.models import Queue, Ticket from helpdesk.query import __Query__ from helpdesk.user import HelpdeskUser diff --git a/helpdesk/tests/test_public_actions.py b/helpdesk/tests/test_public_actions.py index feba1a66..f07c2fdf 100644 --- a/helpdesk/tests/test_public_actions.py +++ b/helpdesk/tests/test_public_actions.py @@ -1,7 +1,7 @@ -from helpdesk.models import Queue, Ticket from django.test import TestCase from django.test.client import Client from django.urls import reverse +from helpdesk.models import Queue, Ticket class PublicActionsTestCase(TestCase): diff --git a/helpdesk/tests/test_query.py b/helpdesk/tests/test_query.py index 43ede439..9e7d8842 100644 --- a/helpdesk/tests/test_query.py +++ b/helpdesk/tests/test_query.py @@ -1,12 +1,9 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse - from helpdesk.models import KBCategory, KBItem, Queue, Ticket from helpdesk.query import query_to_base64 - -from helpdesk.tests.helpers import ( - get_staff_user, reload_urlconf, User, create_ticket, print_response) +from helpdesk.tests.helpers import create_ticket, get_staff_user, print_response, reload_urlconf, User class QueryTests(TestCase): diff --git a/helpdesk/tests/test_savequery.py b/helpdesk/tests/test_savequery.py index 5916782b..b7130d25 100644 --- a/helpdesk/tests/test_savequery.py +++ b/helpdesk/tests/test_savequery.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from django.urls import reverse from django.test import TestCase +from django.urls import reverse from helpdesk.models import Queue from helpdesk.tests.helpers import get_user diff --git a/helpdesk/tests/test_ticket_actions.py b/helpdesk/tests/test_ticket_actions.py index b0b9daec..d3809005 100644 --- a/helpdesk/tests/test_ticket_actions.py +++ b/helpdesk/tests/test_ticket_actions.py @@ -1,22 +1,21 @@ from django.contrib.auth import get_user_model from django.contrib.sites.models import Site from django.core import mail -from django.urls import reverse from django.test import TestCase from django.test.client import Client +from django.urls import reverse from django.utils import timezone - -from helpdesk.models import CustomField, Queue, Ticket from helpdesk import settings as helpdesk_settings +from helpdesk.models import CustomField, Queue, Ticket +from helpdesk.templatetags.ticket_to_link import num_to_link +from helpdesk.user import HelpdeskUser + try: # python 3 from urllib.parse import urlparse except ImportError: # python 2 from urlparse import urlparse -from helpdesk.templatetags.ticket_to_link import num_to_link -from helpdesk.user import HelpdeskUser - class TicketActionsTestCase(TestCase): fixtures = ['emailtemplate.json'] diff --git a/helpdesk/tests/test_ticket_lookup.py b/helpdesk/tests/test_ticket_lookup.py index e2891f95..8b3fcaba 100644 --- a/helpdesk/tests/test_ticket_lookup.py +++ b/helpdesk/tests/test_ticket_lookup.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- from django.contrib.auth import get_user_model -from django.urls import reverse from django.test import TestCase -from helpdesk.models import Ticket, Queue from django.test.utils import override_settings +from django.urls import reverse +from helpdesk.models import Queue, Ticket User = get_user_model() diff --git a/helpdesk/tests/test_ticket_submission.py b/helpdesk/tests/test_ticket_submission.py index 90923967..2e1da9ac 100644 --- a/helpdesk/tests/test_ticket_submission.py +++ b/helpdesk/tests/test_ticket_submission.py @@ -1,22 +1,19 @@ -import email -import uuid -from helpdesk.models import Queue, CustomField, FollowUp, Ticket, TicketCC, KBCategory, KBItem -from django.test import TestCase from django.contrib.auth import get_user_model from django.core import mail from django.core.exceptions import ObjectDoesNotExist from django.forms import ValidationError +from django.test import TestCase from django.test.client import Client from django.urls import reverse - -from helpdesk.email import object_from_message, create_ticket_cc +import email +from helpdesk.email import create_ticket_cc, object_from_message +from helpdesk.models import CustomField, FollowUp, KBCategory, KBItem, Queue, Ticket, TicketCC from helpdesk.tests.helpers import print_response - -from urllib.parse import urlparse - import logging +from urllib.parse import urlparse +import uuid logger = logging.getLogger('helpdesk') diff --git a/helpdesk/tests/test_time_spent.py b/helpdesk/tests/test_time_spent.py index 5caf7df5..f89214e3 100644 --- a/helpdesk/tests/test_time_spent.py +++ b/helpdesk/tests/test_time_spent.py @@ -1,23 +1,24 @@ + +import datetime from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.core import mail -from django.urls import reverse from django.test import TestCase from django.test.client import Client -from helpdesk.models import Queue, Ticket, FollowUp +from django.urls import reverse from helpdesk import settings as helpdesk_settings -from django.contrib.auth.models import User -from django.contrib.auth.hashers import make_password +from helpdesk.models import FollowUp, Queue, Ticket +from helpdesk.templatetags.ticket_to_link import num_to_link import uuid -import datetime + try: # python 3 from urllib.parse import urlparse except ImportError: # python 2 from urlparse import urlparse -from helpdesk.templatetags.ticket_to_link import num_to_link - class TimeSpentTestCase(TestCase): diff --git a/helpdesk/tests/test_usersettings.py b/helpdesk/tests/test_usersettings.py index 293adbf6..b7d72866 100644 --- a/helpdesk/tests/test_usersettings.py +++ b/helpdesk/tests/test_usersettings.py @@ -1,10 +1,11 @@ from django.contrib.auth import get_user_model from django.core import mail -from django.urls import reverse from django.test import TestCase from django.test.client import Client +from django.urls import reverse from helpdesk.models import CustomField, Queue, Ticket + try: # python 3 from urllib.parse import urlparse except ImportError: # python 2 diff --git a/helpdesk/tests/urls.py b/helpdesk/tests/urls.py index 9a96671c..e07fd6c8 100644 --- a/helpdesk/tests/urls.py +++ b/helpdesk/tests/urls.py @@ -1,5 +1,6 @@ -from django.urls import include, path from django.contrib import admin +from django.urls import include, path + urlpatterns = [ path('', include('helpdesk.urls', namespace='helpdesk')), diff --git a/helpdesk/urls.py b/helpdesk/urls.py index 9ccd4d38..f0fcceab 100644 --- a/helpdesk/urls.py +++ b/helpdesk/urls.py @@ -7,17 +7,16 @@ urls.py - Mapping of URL's to our various views. Note we always used NAMED views for simplicity in linking later on. """ -from django.urls import path, re_path -from django.contrib.auth.decorators import login_required from django.contrib.auth import views as auth_views -from django.urls import include +from django.contrib.auth.decorators import login_required +from django.urls import include, path, re_path from django.views.generic import TemplateView +from helpdesk import settings as helpdesk_settings +from helpdesk.decorators import helpdesk_staff_member_required, protect_view +from helpdesk.views import feeds, login, public, staff +from helpdesk.views.api import CreateUserView, FollowUpAttachmentViewSet, FollowUpViewSet, TicketViewSet from rest_framework.routers import DefaultRouter -from helpdesk.decorators import helpdesk_staff_member_required, protect_view -from helpdesk.views import feeds, staff, public, login -from helpdesk import settings as helpdesk_settings -from helpdesk.views.api import TicketViewSet, CreateUserView, FollowUpViewSet, FollowUpAttachmentViewSet if helpdesk_settings.HELPDESK_KB_ENABLED: from helpdesk.views import kb diff --git a/helpdesk/user.py b/helpdesk/user.py index eb11d5d0..e21fd064 100644 --- a/helpdesk/user.py +++ b/helpdesk/user.py @@ -1,15 +1,10 @@ -from helpdesk.models import ( - Ticket, - Queue -) from helpdesk import settings as helpdesk_settings +from helpdesk.models import Queue, Ticket + if helpdesk_settings.HELPDESK_KB_ENABLED: - from helpdesk.models import ( - KBCategory, - KBItem - ) + from helpdesk.models import KBCategory, KBItem def huser_from_request(req): diff --git a/helpdesk/validators.py b/helpdesk/validators.py index bd2cd522..08086e1f 100644 --- a/helpdesk/validators.py +++ b/helpdesk/validators.py @@ -2,16 +2,18 @@ # # validators for file uploads, etc. + from django.conf import settings + # TODO: can we use the builtin Django validator instead? # see: # https://docs.djangoproject.com/en/4.0/ref/validators/#fileextensionvalidator def validate_file_extension(value): - import os from django.core.exceptions import ValidationError + import os ext = os.path.splitext(value.name)[1] # [0] returns path+filename # TODO: we might improve this with more thorough checks of file types # rather than just the extensions. diff --git a/helpdesk/views/api.py b/helpdesk/views/api.py index d217a1a4..7e3f0448 100644 --- a/helpdesk/views/api.py +++ b/helpdesk/views/api.py @@ -1,11 +1,10 @@ +from django.contrib.auth import get_user_model +from helpdesk.models import FollowUp, FollowUpAttachment, Ticket +from helpdesk.serializers import FollowUpAttachmentSerializer, FollowUpSerializer, TicketSerializer, UserSerializer from rest_framework import viewsets +from rest_framework.mixins import CreateModelMixin from rest_framework.permissions import IsAdminUser from rest_framework.viewsets import GenericViewSet -from rest_framework.mixins import CreateModelMixin -from django.contrib.auth import get_user_model - -from helpdesk.models import Ticket, FollowUp, FollowUpAttachment -from helpdesk.serializers import TicketSerializer, UserSerializer, FollowUpSerializer, FollowUpAttachmentSerializer class TicketViewSet(viewsets.ModelViewSet): diff --git a/helpdesk/views/feeds.py b/helpdesk/views/feeds.py index 7975fa4e..cccf4b19 100644 --- a/helpdesk/views/feeds.py +++ b/helpdesk/views/feeds.py @@ -9,12 +9,12 @@ views/feeds.py - A handful of staff-only RSS feeds to provide ticket details from django.contrib.auth import get_user_model from django.contrib.syndication.views import Feed -from django.urls import reverse from django.db.models import Q -from django.utils.translation import gettext as _ from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.utils.translation import gettext as _ +from helpdesk.models import FollowUp, Queue, Ticket -from helpdesk.models import Ticket, FollowUp, Queue User = get_user_model() diff --git a/helpdesk/views/kb.py b/helpdesk/views/kb.py index 55b3424d..1f619a65 100644 --- a/helpdesk/views/kb.py +++ b/helpdesk/views/kb.py @@ -8,12 +8,10 @@ views/kb.py - Public-facing knowledgebase views. The knowledgebase is a resolutions to common problems. """ -from django.http import HttpResponseRedirect, Http404 -from django.shortcuts import render, get_object_or_404 +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render from django.views.decorators.clickjacking import xframe_options_exempt - -from helpdesk import settings as helpdesk_settings -from helpdesk import user +from helpdesk import settings as helpdesk_settings, user from helpdesk.models import KBCategory, KBItem diff --git a/helpdesk/views/permissions.py b/helpdesk/views/permissions.py index 955e71c9..3ebd556c 100644 --- a/helpdesk/views/permissions.py +++ b/helpdesk/views/permissions.py @@ -1,5 +1,4 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin - from helpdesk.decorators import is_helpdesk_staff diff --git a/helpdesk/views/public.py b/helpdesk/views/public.py index 7a0b28bf..dfe42e53 100644 --- a/helpdesk/views/public.py +++ b/helpdesk/views/public.py @@ -6,30 +6,29 @@ django-helpdesk - A Django powered ticket tracker for small enterprise. views/public.py - All public facing views, eg non-staff (no authentication required) views. """ -import logging -from importlib import import_module -from django.core.exceptions import ( - ObjectDoesNotExist, PermissionDenied, ImproperlyConfigured, -) -from django.urls import reverse + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist, PermissionDenied from django.http import HttpResponseRedirect from django.shortcuts import render -from urllib.parse import quote +from django.urls import reverse from django.utils.translation import gettext as _ -from django.conf import settings from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from django.views.generic.base import TemplateView from django.views.generic.edit import FormView - from helpdesk import settings as helpdesk_settings -from helpdesk.decorators import protect_view, is_helpdesk_staff -import helpdesk.views.staff as staff -import helpdesk.views.abstract_views as abstract_views +from helpdesk.decorators import is_helpdesk_staff, protect_view from helpdesk.lib import text_is_spam -from helpdesk.models import Ticket, Queue, UserSettings +from helpdesk.models import Queue, Ticket, UserSettings from helpdesk.user import huser_from_request +import helpdesk.views.abstract_views as abstract_views +import helpdesk.views.staff as staff +from importlib import import_module +import logging +from urllib.parse import quote + logger = logging.getLogger(__name__) @@ -212,6 +211,7 @@ def view_ticket(request): if 'close' in request.GET and ticket.status == Ticket.RESOLVED_STATUS: from helpdesk.views.staff import update_ticket + # Trick the update_ticket() view into thinking it's being called with # a valid POST. request.POST = { diff --git a/helpdesk/views/staff.py b/helpdesk/views/staff.py index 94117637..f31b89f6 100644 --- a/helpdesk/views/staff.py +++ b/helpdesk/views/staff.py @@ -6,67 +6,80 @@ django-helpdesk - A Django powered ticket tracker for small enterprise. views/staff.py - The bulk of the application - provides most business logic and renders all staff-facing views. """ +from ..lib import format_time_spent +from ..templated_email import send_templated_mail +from collections import defaultdict from copy import deepcopy -import json - +from datetime import date, datetime, timedelta from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.decorators import user_passes_test from django.contrib.contenttypes.models import ContentType -from django.urls import reverse, reverse_lazy -from django.core.exceptions import ValidationError, PermissionDenied -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.exceptions import PermissionDenied, ValidationError +from django.core.handlers.wsgi import WSGIRequest +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q -from django.http import HttpResponseRedirect, Http404, HttpResponse, JsonResponse -from django.shortcuts import render, get_object_or_404, redirect -from django.utils.translation import gettext as _ -from django.utils.html import escape +from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.html import escape +from django.utils.translation import gettext as _ from django.views.decorators.csrf import requires_csrf_token from django.views.generic.edit import FormView, UpdateView - -from helpdesk.forms import CUSTOMFIELD_DATE_FORMAT -from helpdesk.query import ( - get_query_class, - query_to_base64, - query_from_base64, -) - -from helpdesk.user import HelpdeskUser - +from helpdesk import settings as helpdesk_settings from helpdesk.decorators import ( - helpdesk_staff_member_required, helpdesk_superuser_required, - is_helpdesk_staff + helpdesk_staff_member_required, + helpdesk_superuser_required, + is_helpdesk_staff, + superuser_required ) from helpdesk.forms import ( - TicketForm, UserSettingsForm, EmailIgnoreForm, EditTicketForm, TicketCCForm, - TicketCCEmailForm, TicketCCUserForm, EditFollowUpForm, TicketDependencyForm, MultipleTicketSelectForm -) -from helpdesk.decorators import superuser_required -from helpdesk.lib import ( - safe_template_context, - process_attachments, - queue_template_context, + CUSTOMFIELD_DATE_FORMAT, + EditFollowUpForm, + EditTicketForm, + EmailIgnoreForm, + MultipleTicketSelectForm, + TicketCCEmailForm, + TicketCCForm, + TicketCCUserForm, + TicketDependencyForm, + TicketForm, + UserSettingsForm ) +from helpdesk.lib import process_attachments, queue_template_context, safe_template_context from helpdesk.models import ( - Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch, - IgnoreEmail, TicketCC, TicketDependency, UserSettings, CustomField, TicketCustomFieldValue, + CustomField, + FollowUp, + FollowUpAttachment, + IgnoreEmail, + PreSetReply, + Queue, + SavedSearch, + Ticket, + TicketCC, + TicketChange, + TicketCustomFieldValue, + TicketDependency, + UserSettings ) -from helpdesk import settings as helpdesk_settings -if helpdesk_settings.HELPDESK_KB_ENABLED: - from helpdesk.models import (KBItem) - +from helpdesk.query import get_query_class, query_from_base64, query_to_base64 +from helpdesk.user import HelpdeskUser import helpdesk.views.abstract_views as abstract_views from helpdesk.views.permissions import MustBeStaffMixin -from ..lib import format_time_spent - +import json +import re from rest_framework import status from rest_framework.decorators import api_view +import typing -from datetime import date, datetime, timedelta -import re -from ..templated_email import send_templated_mail +if helpdesk_settings.HELPDESK_KB_ENABLED: + from helpdesk.models import KBItem + +DATE_RE: re.Pattern = re.compile( + r'(?P\d{1,2})/(?P\d{1,2})/(?P\d{4})$' +) User = get_user_model() Query = get_query_class() @@ -260,7 +273,7 @@ def followup_edit(request, ticket_id, followup_id): 'time_spent': format_time_spent(followup.time_spent), }) - ticketcc_string, show_subscribe = \ + ticketcc_string, __ = \ return_ticketccstring_and_show_subscribe(request.user, ticket) return render(request, 'helpdesk/followup_edit.html', { @@ -338,8 +351,10 @@ def view_ticket(request, ticket_id): if 'subscribe' in request.GET: # Allow the user to subscribe him/herself to the ticket whilst viewing # it. - ticket_cc, show_subscribe = \ - return_ticketccstring_and_show_subscribe(request.user, ticket) + show_subscribe = return_ticketccstring_and_show_subscribe( + request.user, ticket + )[1] + if show_subscribe: subscribe_staff_member_to_ticket(ticket, request.user) return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket.id])) @@ -473,10 +488,20 @@ def subscribe_staff_member_to_ticket(ticket, user, email='', can_view=True, can_ return subscribe_to_ticket_updates(ticket=ticket, user=user, email=email, can_view=can_view, can_update=can_update) -def update_ticket(request, ticket_id, public=False): +def get_ticket_from_request_with_authorisation( + request: WSGIRequest, + ticket_id: str, + public: bool +) -> typing.Union[ + Ticket, typing.NoReturn +]: + """Gets a ticket from the public status and if the user is authenticated and + has permissions to update tickets - ticket = None + Raises: + Http404 when the ticket can not be found or the user lacks permission + """ if not (public or ( request.user.is_authenticated and request.user.is_active and ( @@ -498,41 +523,29 @@ def update_ticket(request, ticket_id, public=False): '%s?next=%s' % (reverse('helpdesk:login'), request.path) ) - if not ticket: - ticket = get_object_or_404(Ticket, id=ticket_id) + return get_object_or_404(Ticket, id=ticket_id) - date_re = re.compile( - r'(?P\d{1,2})/(?P\d{1,2})/(?P\d{4})$' - ) - comment = request.POST.get('comment', '') - new_status = int(request.POST.get('new_status', ticket.status)) - title = request.POST.get('title', '') - public = request.POST.get('public', False) - owner = int(request.POST.get('owner', -1)) - priority = int(request.POST.get('priority', ticket.priority)) - due_date_year = int(request.POST.get('due_date_year', 0)) - due_date_month = int(request.POST.get('due_date_month', 0)) - due_date_day = int(request.POST.get('due_date_day', 0)) - if request.POST.get("time_spent"): - (hours, minutes) = [int(f) - for f in request.POST.get("time_spent").split(":")] - time_spent = timedelta(hours=hours, minutes=minutes) - else: - time_spent = None - # NOTE: jQuery's default for dates is mm/dd/yy - # very US-centric but for now that's the only format supported - # until we clean up code to internationalize a little more +def get_due_date_from_request_or_ticket( + request: WSGIRequest, + ticket: Ticket +) -> typing.Optional[datetime.date]: + """Tries to locate the due date for a ticket from the `request.POST` + 'due_date' parameter or the `due_date_*` paramaters. + """ due_date = request.POST.get('due_date', None) or None if due_date is not None: # based on Django code to parse dates: # https://docs.djangoproject.com/en/2.0/_modules/django/utils/dateparse/ - match = date_re.match(due_date) + match = DATE_RE.match(due_date) if match: kw = {k: int(v) for k, v in match.groupdict().items()} due_date = date(**kw) else: + due_date_year = int(request.POST.get('due_date_year', 0)) + due_date_month = int(request.POST.get('due_date_month', 0)) + due_date_day = int(request.POST.get('due_date_day', 0)) # old way, probably deprecated? if not (due_date_year and due_date_month and due_date_day): due_date = ticket.due_date @@ -543,9 +556,146 @@ def update_ticket(request, ticket_id, public=False): due_date = ticket.due_date else: due_date = timezone.now() - due_date = due_date.replace( - due_date_year, due_date_month, due_date_day) + due_date = due_date.replace( + due_date_year, due_date_month, due_date_day) + return due_date + +def get_and_set_ticket_status( + new_status: str, + ticket: Ticket, + follow_up: FollowUp +) -> typing.Tuple[str, str]: + """Performs comparision on previous status to new status, + updating the title as required. + + Returns: + The old status as a display string, old status code string + """ + old_status_str = ticket.get_status_display() + old_status = ticket.status + if new_status != ticket.status: + ticket.status = new_status + ticket.save() + follow_up.new_status = new_status + if follow_up.title: + follow_up.title += ' and %s' % ticket.get_status_display() + else: + follow_up.title = '%s' % ticket.get_status_display() + + if not follow_up.title: + if follow_up.comment: + follow_up.title = _('Comment') + else: + follow_up.title = _('Updated') + + follow_up.save() + return (old_status_str, old_status) + + +def get_time_spent_from_request(request: WSGIRequest) -> typing.Optional[timedelta]: + if request.POST.get("time_spent"): + (hours, minutes) = [int(f) + for f in request.POST.get("time_spent").split(":")] + return timedelta(hours=hours, minutes=minutes) + return None + + +def update_messages_sent_to_by_public_and_status( + public: bool, + ticket: Ticket, + follow_up: FollowUp, + context: str, + messages_sent_to: typing.List[str], + files: typing.List[typing.Tuple[str, str]] +) -> Ticket: + """Sets the status of the ticket""" + if public and ( + follow_up.comment or ( + follow_up.new_status in ( + Ticket.RESOLVED_STATUS, + Ticket.CLOSED_STATUS + ) + ) + ): + if follow_up.new_status == Ticket.RESOLVED_STATUS: + template = 'resolved_' + elif follow_up.new_status == Ticket.CLOSED_STATUS: + template = 'closed_' + else: + template = 'updated_' + + roles = { + 'submitter': (template + 'submitter', context), + 'ticket_cc': (template + 'cc', context), + } + if ticket.assigned_to and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change: + roles['assigned_to'] = (template + 'cc', context) + messages_sent_to.update( + ticket.send( + roles, + dont_send_to=messages_sent_to, + fail_silently=True, + files=files + ) + ) + return ticket + + +def add_staff_subscription( + request: WSGIRequest, + ticket: Ticket +) -> None: + """Auto subscribe the staff member if that's what the settigs say and the + user is authenticated and a staff member""" + if helpdesk_settings.HELPDESK_AUTO_SUBSCRIBE_ON_TICKET_RESPONSE and request.user.is_authenticated: + SHOW_SUBSCRIBE = return_ticketccstring_and_show_subscribe( + request.user, ticket + )[1] + + if SHOW_SUBSCRIBE: + subscribe_staff_member_to_ticket(ticket, request.user) + + +def get_template_staff_and_template_cc( + reassigned, follow_up: FollowUp +) -> typing.Tuple[str, str]: + if reassigned: + template_staff = 'assigned_owner' + elif follow_up.new_status == Ticket.RESOLVED_STATUS: + template_staff = 'resolved_owner' + elif follow_up.new_status == Ticket.CLOSED_STATUS: + template_staff = 'closed_owner' + else: + template_staff = 'updated_owner' + if reassigned: + template_cc = 'assigned_cc' + elif follow_up.new_status == Ticket.RESOLVED_STATUS: + template_cc = 'resolved_cc' + elif follow_up.new_status == Ticket.CLOSED_STATUS: + template_cc = 'closed_cc' + else: + template_cc = 'updated_cc' + + return template_staff, template_cc + + +def update_ticket(request, ticket_id, public=False): + + ticket = get_ticket_from_request_with_authorisation(request, ticket_id, public) + + comment = request.POST.get('comment', '') + new_status = int(request.POST.get('new_status', ticket.status)) + title = request.POST.get('title', '') + public = request.POST.get('public', False) + owner = int(request.POST.get('owner', -1)) + priority = int(request.POST.get('priority', ticket.priority)) + + time_spent = get_time_spent_from_request(request) + # NOTE: jQuery's default for dates is mm/dd/yy + # very US-centric but for now that's the only format supported + # until we clean up code to internationalize a little more + due_date = get_due_date_from_request_or_ticket(request, ticket) no_changes = all([ not request.FILES, not comment, @@ -605,28 +755,9 @@ def update_ticket(request, ticket_id, public=False): f.title = _('Unassigned') ticket.assigned_to = None - old_status_str = ticket.get_status_display() - old_status = ticket.status - if new_status != ticket.status: - ticket.status = new_status - ticket.save() - f.new_status = new_status - if f.title: - f.title += ' and %s' % ticket.get_status_display() - else: - f.title = '%s' % ticket.get_status_display() + old_status_str, old_status = get_and_set_ticket_status(new_status, ticket, f) - if not f.title: - if f.comment: - f.title = _('Comment') - else: - f.title = _('Updated') - - f.save() - - files = [] - if request.FILES: - files = process_attachments(f, request.FILES.getlist('attachment')) + files = process_attachments(f, request.FILES.getlist('attachment')) if request.FILES else [] if title and title != ticket.title: c = TicketChange( @@ -676,9 +807,12 @@ def update_ticket(request, ticket_id, public=False): c.save() ticket.due_date = due_date - if new_status in (Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS): - if new_status == Ticket.RESOLVED_STATUS or ticket.resolution is None: - ticket.resolution = comment + if new_status in ( + Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS + ) and ( + new_status == Ticket.RESOLVED_STATUS or ticket.resolution is None + ): + ticket.resolution = comment # ticket might have changed above, so we re-instantiate context with the # (possibly) updated ticket. @@ -693,34 +827,16 @@ def update_ticket(request, ticket_id, public=False): messages_sent_to.add(request.user.email) except AttributeError: pass - if public and (f.comment or ( - f.new_status in (Ticket.RESOLVED_STATUS, - Ticket.CLOSED_STATUS))): - if f.new_status == Ticket.RESOLVED_STATUS: - template = 'resolved_' - elif f.new_status == Ticket.CLOSED_STATUS: - template = 'closed_' - else: - template = 'updated_' - - roles = { - 'submitter': (template + 'submitter', context), - 'ticket_cc': (template + 'cc', context), - } - if ticket.assigned_to and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change: - roles['assigned_to'] = (template + 'cc', context) - messages_sent_to.update(ticket.send( - roles, dont_send_to=messages_sent_to, fail_silently=True, files=files,)) - - if reassigned: - template_staff = 'assigned_owner' - elif f.new_status == Ticket.RESOLVED_STATUS: - template_staff = 'resolved_owner' - elif f.new_status == Ticket.CLOSED_STATUS: - template_staff = 'closed_owner' - else: - template_staff = 'updated_owner' + ticket = update_messages_sent_to_by_public_and_status( + public, + ticket, + f, + context, + messages_sent_to, + files + ) + template_staff, template_cc = get_template_staff_and_template_cc(reassigned, f) if ticket.assigned_to and ( ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change or (reassigned and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_assign) @@ -732,30 +848,16 @@ def update_ticket(request, ticket_id, public=False): files=files, )) - if reassigned: - template_cc = 'assigned_cc' - elif f.new_status == Ticket.RESOLVED_STATUS: - template_cc = 'resolved_cc' - elif f.new_status == Ticket.CLOSED_STATUS: - template_cc = 'closed_cc' - else: - template_cc = 'updated_cc' - messages_sent_to.update(ticket.send( {'ticket_cc': (template_cc, context)}, dont_send_to=messages_sent_to, fail_silently=True, files=files, )) - ticket.save() # auto subscribe user if enabled - if helpdesk_settings.HELPDESK_AUTO_SUBSCRIBE_ON_TICKET_RESPONSE and request.user.is_authenticated: - ticketcc_string, SHOW_SUBSCRIBE = return_ticketccstring_and_show_subscribe( - request.user, ticket) - if SHOW_SUBSCRIBE: - subscribe_staff_member_to_ticket(ticket, request.user) + add_staff_subscription(request, ticket) return return_to_ticket(request.user, helpdesk_settings, ticket) @@ -887,7 +989,7 @@ mass_update = staff_member_required(mass_update) # Prepare ticket attributes which will be displayed in the table to choose # which value to keep when merging -ticket_attributes = ( +TICKET_ATTRIBUTES = ( ('created', _('Created date')), ('due_date', _('Due on')), ('get_status_display', _('Status')), @@ -898,6 +1000,133 @@ ticket_attributes = ( ) +def merge_ticket_values( + request: WSGIRequest, + tickets: typing.List[Ticket], + custom_fields +) -> None: + for ticket in tickets: + ticket.values = {} + # Prepare the value for each attributes of this ticket + for attribute, __ in TICKET_ATTRIBUTES: + value = getattr(ticket, attribute, TicketCustomFieldValue.default_value) + # Check if attr is a get_FIELD_display + if attribute.startswith('get_') and attribute.endswith('_display'): + # Hack to call methods like get_FIELD_display() + value = getattr(ticket, attribute, TicketCustomFieldValue.default_value)() + ticket.values[attribute] = { + 'value': value, + 'checked': str(ticket.id) == request.POST.get(attribute) + } + # Prepare the value for each custom fields of this ticket + for custom_field in custom_fields: + try: + value = ticket.ticketcustomfieldvalue_set.get( + field=custom_field).value + except (TicketCustomFieldValue.DoesNotExist, ValueError): + value = TicketCustomFieldValue.default_value + ticket.values[custom_field.name] = { + 'value': value, + 'checked': str(ticket.id) == request.POST.get(custom_field.name) + } + + +def redirect_from_chosen_ticket( + request, + chosen_ticket, + tickets, + custom_fields +) -> HttpResponseRedirect: + # Save ticket fields values + for attribute, __ in TICKET_ATTRIBUTES: + id_for_attribute = request.POST.get(attribute) + if id_for_attribute != chosen_ticket.id: + try: + selected_ticket = tickets.get(id=id_for_attribute) + except (Ticket.DoesNotExist, ValueError): + continue + + # Check if attr is a get_FIELD_display + if attribute.startswith('get_') and attribute.endswith('_display'): + # Keep only the FIELD part + attribute = attribute[4:-8] + # Get value from selected ticket and then save it on + # the chosen ticket + value = getattr(selected_ticket, attribute) + setattr(chosen_ticket, attribute, value) + # Save custom fields values + for custom_field in custom_fields: + id_for_custom_field = request.POST.get(custom_field.name) + if id_for_custom_field != chosen_ticket.id: + try: + selected_ticket = tickets.get( + id=id_for_custom_field) + except (Ticket.DoesNotExist, ValueError): + continue + + # Check if the value for this ticket custom field + # exists + try: + value = selected_ticket.ticketcustomfieldvalue_set.get( + field=custom_field).value + except TicketCustomFieldValue.DoesNotExist: + continue + + # Create the custom field value or update it with the + # value from the selected ticket + custom_field_value, created = chosen_ticket.ticketcustomfieldvalue_set.get_or_create( + field=custom_field, + defaults={'value': value} + ) + if not created: + custom_field_value.value = value + custom_field_value.save(update_fields=['value']) + # Save changes + chosen_ticket.save() + + # For other tickets, save the link to the ticket in which they have been merged to + # and set status to DUPLICATE + for ticket in tickets.exclude(id=chosen_ticket.id): + ticket.merged_to = chosen_ticket + ticket.status = Ticket.DUPLICATE_STATUS + ticket.save() + + # Send mail to submitter email and ticket CC to let them + # know ticket has been merged + context = safe_template_context(ticket) + if ticket.submitter_email: + send_templated_mail( + template_name='merged', + context=context, + recipients=[ticket.submitter_email], + bcc=[ + cc.email_address for cc in ticket.ticketcc_set.select_related('user')], + sender=ticket.queue.from_address, + fail_silently=True + ) + + # Move all followups and update their title to know they + # come from another ticket + ticket.followup_set.update( + ticket=chosen_ticket, + # Next might exceed maximum 200 characters limit + title=_('[Merged from #%(id)d] %(title)s') % { + 'id': ticket.id, 'title': ticket.title} + ) + + # Add submitter_email, assigned_to email and ticketcc to + # chosen ticket if necessary + chosen_ticket.add_email_to_ticketcc_if_not_in( + email=ticket.submitter_email) + if ticket.assigned_to and ticket.assigned_to.email: + chosen_ticket.add_email_to_ticketcc_if_not_in( + email=ticket.assigned_to.email) + for ticketcc in ticket.ticketcc_set.all(): + chosen_ticket.add_email_to_ticketcc_if_not_in( + ticketcc=ticketcc) + return redirect(chosen_ticket) + + @staff_member_required def merge_tickets(request): """ @@ -912,31 +1141,8 @@ def merge_tickets(request): tickets = ticket_select_form.cleaned_data.get('tickets') custom_fields = CustomField.objects.all() - default = _('Not defined') - for ticket in tickets: - ticket.values = {} - # Prepare the value for each attributes of this ticket - for attribute, display_name in ticket_attributes: - value = getattr(ticket, attribute, default) - # Check if attr is a get_FIELD_display - if attribute.startswith('get_') and attribute.endswith('_display'): - # Hack to call methods like get_FIELD_display() - value = getattr(ticket, attribute, default)() - ticket.values[attribute] = { - 'value': value, - 'checked': str(ticket.id) == request.POST.get(attribute) - } - # Prepare the value for each custom fields of this ticket - for custom_field in custom_fields: - try: - value = ticket.ticketcustomfieldvalue_set.get( - field=custom_field).value - except (TicketCustomFieldValue.DoesNotExist, ValueError): - value = default - ticket.values[custom_field.name] = { - 'value': value, - 'checked': str(ticket.id) == request.POST.get(custom_field.name) - } + + merge_ticket_values(request, tickets, custom_fields) if request.method == 'POST': # Find which ticket has been chosen to be the main one @@ -950,103 +1156,58 @@ def merge_tickets(request): 'Please choose a ticket in which the others will be merged into.') ) else: - # Save ticket fields values - for attribute, display_name in ticket_attributes: - id_for_attribute = request.POST.get(attribute) - if id_for_attribute != chosen_ticket.id: - try: - selected_ticket = tickets.get(id=id_for_attribute) - except (Ticket.DoesNotExist, ValueError): - continue - - # Check if attr is a get_FIELD_display - if attribute.startswith('get_') and attribute.endswith('_display'): - # Keep only the FIELD part - attribute = attribute[4:-8] - # Get value from selected ticket and then save it on - # the chosen ticket - value = getattr(selected_ticket, attribute) - setattr(chosen_ticket, attribute, value) - # Save custom fields values - for custom_field in custom_fields: - id_for_custom_field = request.POST.get(custom_field.name) - if id_for_custom_field != chosen_ticket.id: - try: - selected_ticket = tickets.get( - id=id_for_custom_field) - except (Ticket.DoesNotExist, ValueError): - continue - - # Check if the value for this ticket custom field - # exists - try: - value = selected_ticket.ticketcustomfieldvalue_set.get( - field=custom_field).value - except TicketCustomFieldValue.DoesNotExist: - continue - - # Create the custom field value or update it with the - # value from the selected ticket - custom_field_value, created = chosen_ticket.ticketcustomfieldvalue_set.get_or_create( - field=custom_field, - defaults={'value': value} - ) - if not created: - custom_field_value.value = value - custom_field_value.save(update_fields=['value']) - # Save changes - chosen_ticket.save() - - # For other tickets, save the link to the ticket in which they have been merged to - # and set status to DUPLICATE - for ticket in tickets.exclude(id=chosen_ticket.id): - ticket.merged_to = chosen_ticket - ticket.status = Ticket.DUPLICATE_STATUS - ticket.save() - - # Send mail to submitter email and ticket CC to let them - # know ticket has been merged - context = safe_template_context(ticket) - if ticket.submitter_email: - send_templated_mail( - template_name='merged', - context=context, - recipients=[ticket.submitter_email], - bcc=[ - cc.email_address for cc in ticket.ticketcc_set.select_related('user')], - sender=ticket.queue.from_address, - fail_silently=True - ) - - # Move all followups and update their title to know they - # come from another ticket - ticket.followup_set.update( - ticket=chosen_ticket, - # Next might exceed maximum 200 characters limit - title=_('[Merged from #%(id)d] %(title)s') % { - 'id': ticket.id, 'title': ticket.title} - ) - - # Add submitter_email, assigned_to email and ticketcc to - # chosen ticket if necessary - chosen_ticket.add_email_to_ticketcc_if_not_in( - email=ticket.submitter_email) - if ticket.assigned_to and ticket.assigned_to.email: - chosen_ticket.add_email_to_ticketcc_if_not_in( - email=ticket.assigned_to.email) - for ticketcc in ticket.ticketcc_set.all(): - chosen_ticket.add_email_to_ticketcc_if_not_in( - ticketcc=ticketcc) - return redirect(chosen_ticket) + return redirect_from_chosen_ticket( + request, + chosen_ticket, + tickets, + custom_fields + ) return render(request, 'helpdesk/ticket_merge.html', { 'tickets': tickets, - 'ticket_attributes': ticket_attributes, + 'ticket_attributes': TICKET_ATTRIBUTES, 'custom_fields': custom_fields, 'ticket_select_form': ticket_select_form }) +def check_redirect_on_user_query(request, huser): + """If the user is coming from the header/navigation search box, lets' first + look at their query to see if they have entered a valid ticket number. If + they have, just redirect to that ticket number. Otherwise, we treat it as + a keyword search. + """ + if request.GET.get('search_type', None) == 'header': + query = request.GET.get('q') + filter_ = None + if query.find('-') > 0: + try: + queue, id_ = Ticket.queue_and_id_from_query(query) + id_ = int(id) + except ValueError: + id_ = None + + if id_: + filter_ = {'queue__slug': queue, 'id': id_} + else: + try: + query = int(query) + except ValueError: + query = None + + if query: + filter_ = {'id': int(query)} + + if filter_: + try: + ticket = huser.get_tickets_in_queues().get(**filter_) + return HttpResponseRedirect(ticket.staff_url) + except Ticket.DoesNotExist: + # Go on to standard keyword searching + pass + return None + + @helpdesk_staff_member_required def ticket_list(request): context = {} @@ -1071,40 +1232,10 @@ def ticket_list(request): 'sortreverse': False, } - # If the user is coming from the header/navigation search box, lets' first - # look at their query to see if they have entered a valid ticket number. If - # they have, just redirect to that ticket number. Otherwise, we treat it as - # a keyword search. - - if request.GET.get('search_type', None) == 'header': - query = request.GET.get('q') - filter = None - if query.find('-') > 0: - try: - queue, id = Ticket.queue_and_id_from_query(query) - id = int(id) - except ValueError: - id = None - - if id: - filter = {'queue__slug': queue, 'id': id} - else: - try: - query = int(query) - except ValueError: - query = None - - if query: - filter = {'id': int(query)} - - if filter: - try: - ticket = huser.get_tickets_in_queues().get(**filter) - return HttpResponseRedirect(ticket.staff_url) - except Ticket.DoesNotExist: - # Go on to standard keyword searching - pass - + #: check for a redirect, see function doc for details + redirect = check_redirect_on_user_query(request, huser) + if redirect: + return redirect try: saved_query, query_params = load_saved_query(request, query_params) except QueryLoadError: @@ -1300,15 +1431,15 @@ class CreateTicketView(MustBeStaffMixin, abstract_views.AbstractCreateTicketMixi @helpdesk_staff_member_required -def raw_details(request, type): +def raw_details(request, type_): # TODO: This currently only supports spewing out 'PreSetReply' objects, # in the future it needs to be expanded to include other items. All it # does is return a plain-text representation of an object. - if type not in ('preset',): + if type_ not in ('preset',): raise Http404 - if type == 'preset' and request.GET.get('id', False): + if type_ == 'preset' and request.GET.get('id', False): try: preset = PreSetReply.objects.get(id=request.GET.get('id')) return HttpResponse(preset.body) @@ -1408,12 +1539,18 @@ def report_index(request): report_index = staff_member_required(report_index) -@helpdesk_staff_member_required -def run_report(request, report): +def get_report_queryset_or_redirect(request, report): if Ticket.objects.all().count() == 0 or report not in ( - 'queuemonth', 'usermonth', 'queuestatus', 'queuepriority', 'userstatus', - 'userpriority', 'userqueue', 'daysuntilticketclosedbymonth'): - return HttpResponseRedirect(reverse("helpdesk:report_index")) + "queuemonth", + "usermonth", + "queuestatus", + "queuepriority", + "userstatus", + "userpriority", + "userqueue", + "daysuntilticketclosedbymonth" + ): + return None, None, HttpResponseRedirect(reverse("helpdesk:report_index")) report_queryset = Ticket.objects.all().select_related().filter( queue__in=HelpdeskUser(request.user).get_queues() @@ -1422,12 +1559,79 @@ def run_report(request, report): try: saved_query, query_params = load_saved_query(request) except QueryLoadError: - return HttpResponseRedirect(reverse('helpdesk:report_index')) + return None, HttpResponseRedirect(reverse('helpdesk:report_index')) + return report_queryset, query_params, saved_query, None + +def get_report_table_and_totals(header1, summarytable, possible_options): + table = [] + totals = {} + for item in header1: + data = [] + for hdr in possible_options: + if hdr not in totals.keys(): + totals[hdr] = summarytable[item, hdr] + else: + totals[hdr] += summarytable[item, hdr] + data.append(summarytable[item, hdr]) + table.append([item] + data) + return table, totals + + +def update_summary_tables(report_queryset, report, summarytable, summarytable2): + metric3 = False + for ticket in report_queryset: + if report == 'userpriority': + metric1 = u'%s' % ticket.get_assigned_to + metric2 = u'%s' % ticket.get_priority_display() + + elif report == 'userqueue': + metric1 = u'%s' % ticket.get_assigned_to + metric2 = u'%s' % ticket.queue.title + + elif report == 'userstatus': + metric1 = u'%s' % ticket.get_assigned_to + metric2 = u'%s' % ticket.get_status_display() + + elif report == 'usermonth': + metric1 = u'%s' % ticket.get_assigned_to + metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month) + + elif report == 'queuepriority': + metric1 = u'%s' % ticket.queue.title + metric2 = u'%s' % ticket.get_priority_display() + + elif report == 'queuestatus': + metric1 = u'%s' % ticket.queue.title + metric2 = u'%s' % ticket.get_status_display() + + elif report == 'queuemonth': + metric1 = u'%s' % ticket.queue.title + metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month) + + elif report == 'daysuntilticketclosedbymonth': + metric1 = u'%s' % ticket.queue.title + metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month) + metric3 = ticket.modified - ticket.created + metric3 = metric3.days + + summarytable[metric1, metric2] += 1 + if metric3: + if report == 'daysuntilticketclosedbymonth': + summarytable2[metric1, metric2] += metric3 + + +@helpdesk_staff_member_required +def run_report(request, report): + + report_queryset, query_params, saved_query, redirect = get_report_queryset_or_redirect( + request, report + ) + if redirect: + return redirect if request.GET.get('saved_query', None): Query(report_queryset, query_to_base64(query_params)) - from collections import defaultdict summarytable = defaultdict(int) # a second table for more complex queries summarytable2 = defaultdict(int) @@ -1502,50 +1706,7 @@ def run_report(request, report): col1heading = _('Queue') possible_options = periods charttype = 'date' - - metric3 = False - for ticket in report_queryset: - if report == 'userpriority': - metric1 = u'%s' % ticket.get_assigned_to - metric2 = u'%s' % ticket.get_priority_display() - - elif report == 'userqueue': - metric1 = u'%s' % ticket.get_assigned_to - metric2 = u'%s' % ticket.queue.title - - elif report == 'userstatus': - metric1 = u'%s' % ticket.get_assigned_to - metric2 = u'%s' % ticket.get_status_display() - - elif report == 'usermonth': - metric1 = u'%s' % ticket.get_assigned_to - metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month) - - elif report == 'queuepriority': - metric1 = u'%s' % ticket.queue.title - metric2 = u'%s' % ticket.get_priority_display() - - elif report == 'queuestatus': - metric1 = u'%s' % ticket.queue.title - metric2 = u'%s' % ticket.get_status_display() - - elif report == 'queuemonth': - metric1 = u'%s' % ticket.queue.title - metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month) - - elif report == 'daysuntilticketclosedbymonth': - metric1 = u'%s' % ticket.queue.title - metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month) - metric3 = ticket.modified - ticket.created - metric3 = metric3.days - - summarytable[metric1, metric2] += 1 - if metric3: - if report == 'daysuntilticketclosedbymonth': - summarytable2[metric1, metric2] += metric3 - - table = [] - + update_summary_tables(report_queryset, report, summarytable, summarytable2) if report == 'daysuntilticketclosedbymonth': for key in summarytable2.keys(): summarytable[key] = summarytable2[key] / summarytable[key] @@ -1555,18 +1716,9 @@ def run_report(request, report): column_headings = [col1heading] + possible_options # Prepare a dict to store totals for each possible option - totals = {} + table, totals = get_report_table_and_totals(header1, summarytable, possible_options) # Pivot the data so that 'header1' fields are always first column # in the row, and 'possible_options' are always the 2nd - nth columns. - for item in header1: - data = [] - for hdr in possible_options: - if hdr not in totals.keys(): - totals[hdr] = summarytable[item, hdr] - else: - totals[hdr] += summarytable[item, hdr] - data.append(summarytable[item, hdr]) - table.append([item] + data) # Zip data and headers together in one list for Morris.js charts # will get a list like [(Header1, Data1), (Header2, Data2)...] @@ -1626,8 +1778,8 @@ save_query = staff_member_required(save_query) @helpdesk_staff_member_required -def delete_saved_query(request, id): - query = get_object_or_404(SavedSearch, id=id, user=request.user) +def delete_saved_query(request, pk): + query = get_object_or_404(SavedSearch, id=pk, user=request.user) if request.method == 'POST': query.delete() @@ -1676,8 +1828,8 @@ email_ignore_add = superuser_required(email_ignore_add) @helpdesk_superuser_required -def email_ignore_del(request, id): - ignore = get_object_or_404(IgnoreEmail, id=id) +def email_ignore_del(request, pk): + ignore = get_object_or_404(IgnoreEmail, id=pk) if request.method == 'POST': ignore.delete() return HttpResponseRedirect(reverse('helpdesk:email_ignore')) diff --git a/quicktest.py b/quicktest.py old mode 100644 new mode 100755 index dc576596..e387be6e --- a/quicktest.py +++ b/quicktest.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python """ Usage: $ python -m venv .venv @@ -5,15 +6,15 @@ $ source .venv/bin/activate $ pip install -r requirements-testing.txt -r requirements.txt $ python ./quicktest.py """ -import os -import sys -import argparse +import argparse import django from django.conf import settings +import os +import sys -class QuickDjangoTest(object): +class QuickDjangoTest: """ A quick way to run the Django test suite without a fully-configured project. @@ -35,14 +36,14 @@ class QuickDjangoTest(object): 'django.contrib.sites', 'django.contrib.staticfiles', 'bootstrap4form', - # The following commented apps are optional, - # related to teams functionalities - #'account', - #'pinax.invitations', - #'pinax.teams', + # The following commented apps are optional, + # related to teams functionalities + # 'account', + # 'pinax.invitations', + # 'pinax.teams', 'rest_framework', 'helpdesk', - #'reversion', + # 'reversion', ) MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', @@ -77,6 +78,7 @@ class QuickDjangoTest(object): def __init__(self, *args, **kwargs): self.tests = args + self.kwargs = kwargs or {"verbosity": 1} self._tests() def _tests(self): @@ -111,7 +113,7 @@ class QuickDjangoTest(object): ) from django.test.runner import DiscoverRunner - test_runner = DiscoverRunner(verbosity=1) + test_runner = DiscoverRunner(verbosity=self.kwargs["verbosity"]) django.setup() failures = test_runner.run_tests(self.tests) @@ -133,7 +135,8 @@ if __name__ == '__main__': description="Run Django tests." ) parser.add_argument('tests', nargs="*", type=str) + parser.add_argument("--verbosity", "-v", nargs="?", type=int, default=1) args = parser.parse_args() if not args.tests: args.tests = ['helpdesk'] - QuickDjangoTest(*args.tests) + QuickDjangoTest(*args.tests, verbosity=args.verbosity) diff --git a/requirements-testing.txt b/requirements-testing.txt index c07a8ced..12adeae7 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -6,3 +6,4 @@ argparse pbr mock freezegun +isort diff --git a/requirements.txt b/requirements.txt index 5093ae89..3a88fd1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django>=2.2,<4.0 +Django>=3.2,<4.0 django-bootstrap4-form celery email-reply-parser @@ -12,3 +12,4 @@ six pinax_teams djangorestframework django-model-utils +django-cleanup diff --git a/setup.py b/setup.py index a248621d..720de9da 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,13 @@ -import os -import sys +"""django-helpdesk setup""" from distutils.util import convert_path from fnmatch import fnmatchcase -from setuptools import setup, find_packages +import os +from setuptools import find_packages, setup +import sys + + +version = '0.5.0a1' -version = '0.4.1' # Provided as an attribute, so you can append to these instead # of replicating them: diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..05309446 --- /dev/null +++ b/tox.ini @@ -0,0 +1,9 @@ +[tox] +minversion = 3.25.1 +requires = pytest + freezegun + + +[testenv:release] +commands = pip install -r requirements-testing.txt + python quicktest.py