From c5a5c393a8e83425a73a84650ae0dc7699201903 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 25 Aug 2024 17:45:55 +0930 Subject: [PATCH] feat(core): add basic ticketing system ref: #250 #252 #96 #93 #95 #90 #115 --- app/app/urls.py | 5 +- app/core/forms/related_ticket.py | 37 + app/core/forms/ticket.py | 139 ++++ app/core/forms/ticket_comment.py | 137 ++++ ...005_ticket_relatedtickets_ticketcomment.py | 110 +++ app/core/models/__init__.py | 0 app/core/models/ticket/__init__.py | 1 + app/core/models/ticket/ticket.py | 720 +++++++++++++++++- app/core/models/ticket/ticket_comment.py | 388 ++++++++++ app/core/templates/core/ticket.html.j2 | 142 ++++ .../templates/core/ticket/comment.html.j2 | 55 ++ .../core/ticket/comment/comment.html.j2 | 117 +++ app/core/templates/core/ticket/index.html.j2 | 55 ++ app/core/templates/icons/ticket/add.svg | 1 + app/core/templates/icons/ticket/edit.svg | 1 + app/core/templates/icons/ticket/expanded.svg | 1 + .../templates/icons/ticket/notification.svg | 1 + app/core/templates/icons/ticket/reply.svg | 1 + app/core/templates/icons/ticket/task.svg | 1 + .../icons/ticket/ticket_blocked_by.svg | 1 + .../templates/icons/ticket/ticket_blocks.svg | 1 + .../templates/icons/ticket/ticket_related.svg | 1 + app/core/templatetags/markdown.py | 2 +- app/core/templatetags/tickets.py | 13 + .../tests/unit/ticket/test_ticket_common.py | 63 ++ .../test_ticket_comment_common.py | 25 + app/core/views/related_ticket.py | 81 ++ app/core/views/ticket.py | 188 +++++ app/core/views/ticket_comment.py | 97 +++ app/project-static/code.css | 2 +- app/project-static/ticketing.css | 477 ++++++++++++ app/project_management/models/projects.py | 5 + app/templates/icons/place-holder.svg | 1 + 33 files changed, 2865 insertions(+), 4 deletions(-) create mode 100644 app/core/forms/related_ticket.py create mode 100644 app/core/forms/ticket.py create mode 100644 app/core/forms/ticket_comment.py create mode 100644 app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py create mode 100644 app/core/models/__init__.py create mode 100644 app/core/models/ticket/__init__.py create mode 100644 app/core/models/ticket/ticket_comment.py create mode 100644 app/core/templates/core/ticket.html.j2 create mode 100644 app/core/templates/core/ticket/comment.html.j2 create mode 100644 app/core/templates/core/ticket/comment/comment.html.j2 create mode 100644 app/core/templates/core/ticket/index.html.j2 create mode 100644 app/core/templates/icons/ticket/add.svg create mode 100644 app/core/templates/icons/ticket/edit.svg create mode 100644 app/core/templates/icons/ticket/expanded.svg create mode 100644 app/core/templates/icons/ticket/notification.svg create mode 100644 app/core/templates/icons/ticket/reply.svg create mode 100644 app/core/templates/icons/ticket/task.svg create mode 100644 app/core/templates/icons/ticket/ticket_blocked_by.svg create mode 100644 app/core/templates/icons/ticket/ticket_blocks.svg create mode 100644 app/core/templates/icons/ticket/ticket_related.svg create mode 100644 app/core/templatetags/tickets.py create mode 100644 app/core/tests/unit/ticket/test_ticket_common.py create mode 100644 app/core/tests/unit/ticket_comment/test_ticket_comment_common.py create mode 100644 app/core/views/related_ticket.py create mode 100644 app/core/views/ticket.py create mode 100644 app/core/views/ticket_comment.py create mode 100644 app/project-static/ticketing.css create mode 100644 app/templates/icons/place-holder.svg diff --git a/app/app/urls.py b/app/app/urls.py index 4d9130b4d..3c63c0756 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -24,7 +24,7 @@ from .views import home -from core.views import history +from core.views import history, related_ticket from settings.views import user_settings @@ -50,6 +50,9 @@ path("history//", history.View.as_view(), name='_history'), re_path(r'^static/(?P.*)$', serve,{'document_root': settings.STATIC_ROOT}), + + path('ticket///relate/add', related_ticket.Add.as_view(), name="_ticket_related_add"), + ] diff --git a/app/core/forms/related_ticket.py b/app/core/forms/related_ticket.py new file mode 100644 index 000000000..9933f8a1f --- /dev/null +++ b/app/core/forms/related_ticket.py @@ -0,0 +1,37 @@ +from django import forms +from django.db.models import Q + +from app import settings + +from core.forms.common import CommonModelForm + +from core.models.ticket.ticket import RelatedTickets + + +class RelatedTicketForm(CommonModelForm): + + prefix = 'ticket' + + class Meta: + model = RelatedTickets + fields = '__all__' + + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.fields['from_ticket_id'].widget = self.fields['from_ticket_id'].hidden_widget() + + + def clean(self): + + cleaned_data = super().clean() + + return cleaned_data + + def is_valid(self) -> bool: + + is_valid = super().is_valid() + + return is_valid diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py new file mode 100644 index 000000000..89d57e600 --- /dev/null +++ b/app/core/forms/ticket.py @@ -0,0 +1,139 @@ +from django import forms +from django.db.models import Q + +from app import settings + +from core.forms.common import CommonModelForm + +from core.models.ticket.ticket import Ticket, RelatedTickets + + + +class TicketForm(CommonModelForm): + + prefix = 'ticket' + + class Meta: + model = Ticket + fields = '__all__' + + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.fields['planned_start_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local', 'format': "%Y-%m-%dT%H:%M"}) + self.fields['planned_start_date'].input_formats = settings.DATETIME_FORMAT + self.fields['planned_start_date'].format="%Y-%m-%dT%H:%M" + + self.fields['planned_finish_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'}) + self.fields['planned_finish_date'].input_formats = settings.DATETIME_FORMAT + self.fields['planned_finish_date'].format="%Y-%m-%dT%H:%M" + + self.fields['real_start_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'}) + self.fields['real_start_date'].input_formats = settings.DATETIME_FORMAT + self.fields['real_start_date'].format="%Y-%m-%dT%H:%M" + + self.fields['real_finish_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'}) + self.fields['real_finish_date'].input_formats = settings.DATETIME_FORMAT + self.fields['real_finish_date'].format="%Y-%m-%dT%H:%M" + + self.fields['description'].widget.attrs = {'style': "height: 800px; width: 900px"} + + self.fields['opened_by'].initial = kwargs['user'].pk + + self.fields['ticket_type'].widget = self.fields['ticket_type'].hidden_widget() + + + original_fields = self.fields.copy() + ticket_type = [] + + if kwargs['initial']['type_ticket'] == 'request': + + ticket_type = self.Meta.model.fields_itsm_request + + self.fields['status'].choices = self.Meta.model.TicketStatus.Request + + self.fields['ticket_type'].initial = '1' + + elif kwargs['initial']['type_ticket'] == 'incident': + + ticket_type = self.Meta.model.fields_itsm_incident + + self.fields['status'].choices = self.Meta.model.TicketStatus.Incident + + self.fields['ticket_type'].initial = self.Meta.model.TicketType.INCIDENT + + elif kwargs['initial']['type_ticket'] == 'problem': + + ticket_type = self.Meta.model.fields_itsm_problem + + self.fields['status'].choices = self.Meta.model.TicketStatus.Problem + + self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROBLEM + + elif kwargs['initial']['type_ticket'] == 'change': + + ticket_type = self.Meta.model.fields_itsm_change + + self.fields['status'].choices = self.Meta.model.TicketStatus.Change + + self.fields['ticket_type'].initial = self.Meta.model.TicketType.CHANGE + + elif kwargs['initial']['type_ticket'] == 'issue': + + ticket_type = self.Meta.model.fields_git_issue + + self.fields['status'].choices = self.Meta.model.TicketStatus.Git + + self.fields['ticket_type'].initial = self.Meta.model.TicketType.ISSUE + + elif kwargs['initial']['type_ticket'] == 'merge': + + ticket_type = self.Meta.model.fields_git_merge + + self.fields['status'].choices = self.Meta.model.TicketStatus.Git + + self.fields['ticket_type'].initial = self.Meta.model.TicketType.MERGE_REQUEST + + elif kwargs['initial']['type_ticket'] == 'project_task': + + ticket_type = self.Meta.model.fields_project_task + + self.fields['status'].choices = self.Meta.model.TicketStatus.ProjectTask + + self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROJECT_TASK + + + if kwargs['user'].is_superuser: + + ticket_type += self.Meta.model.tech_fields + + + for field in original_fields: + + if field not in ticket_type: + + del self.fields[field] + + def clean(self): + + cleaned_data = super().clean() + + return cleaned_data + + def is_valid(self) -> bool: + + is_valid = super().is_valid() + + return is_valid + + + +class DetailForm(CommonModelForm): + + prefix = 'ticket' + + class Meta: + model = Ticket + fields = '__all__' diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py new file mode 100644 index 000000000..05e458aaa --- /dev/null +++ b/app/core/forms/ticket_comment.py @@ -0,0 +1,137 @@ +from django import forms +from django.db.models import Q + +from app import settings + +from core.forms.common import CommonModelForm + +from core.models.ticket.ticket_comment import TicketComment + + +class CommentForm(CommonModelForm): + + prefix = 'ticket' + + class Meta: + model = TicketComment + fields = '__all__' + + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.fields['planned_start_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local', 'format': "%Y-%m-%dT%H:%M"}) + self.fields['planned_start_date'].input_formats = settings.DATETIME_FORMAT + self.fields['planned_start_date'].format="%Y-%m-%dT%H:%M" + + self.fields['planned_finish_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'}) + self.fields['planned_finish_date'].input_formats = settings.DATETIME_FORMAT + self.fields['planned_finish_date'].format="%Y-%m-%dT%H:%M" + + self.fields['real_start_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'}) + self.fields['real_start_date'].input_formats = settings.DATETIME_FORMAT + self.fields['real_start_date'].format="%Y-%m-%dT%H:%M" + + self.fields['real_finish_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'}) + self.fields['real_finish_date'].input_formats = settings.DATETIME_FORMAT + self.fields['real_finish_date'].format="%Y-%m-%dT%H:%M" + + self.fields['body'].widget.attrs = {'style': "height: 800px; width: 900px"} + + self.fields['user'].initial = kwargs['user'].pk + self.fields['user'].widget = self.fields['user'].hidden_widget() + + self.fields['ticket'].widget = self.fields['ticket'].hidden_widget() + + if 'qs_comment_type' in kwargs['initial']: + + comment_type = kwargs['initial']['qs_comment_type'] + + else: + + comment_type = str(self.instance.get_comment_type_display()).lower() + + + original_fields = self.fields.copy() + comment_fields = [] + + + if ( + kwargs['initial']['type_ticket'] == 'request' + or + kwargs['initial']['type_ticket'] == 'incident' + or + kwargs['initial']['type_ticket'] == 'problem' + or + kwargs['initial']['type_ticket'] == 'change' + or + kwargs['initial']['type_ticket'] == 'project_task' + ): + + if comment_type == 'task': + + comment_fields = self.Meta.model.fields_itsm_task + + self.fields['comment_type'].initial = self.Meta.model.CommentType.TASK + + elif comment_type == 'comment': + + comment_fields = self.Meta.model.common_itsm_fields + + self.fields['comment_type'].initial = self.Meta.model.CommentType.COMMENT + + + elif comment_type == 'solution': + + comment_fields = self.Meta.model.common_itsm_fields + + self.fields['comment_type'].initial = self.Meta.model.CommentType.SOLUTION + + elif comment_type == 'notification': + + comment_fields = self.Meta.model.fields_itsm_notification + + self.fields['comment_type'].initial = self.Meta.model.CommentType.NOTIFICATION + + elif kwargs['initial']['type_ticket'] == 'issue': + + comment_fields = self.Meta.model.fields_git_issue + + elif kwargs['initial']['type_ticket'] == 'merge': + + comment_fields = self.Meta.model.fields_git_merge + + + for field in original_fields: + + if field not in comment_fields: + + del self.fields[field] + + def clean(self): + + cleaned_data = super().clean() + + return cleaned_data + + def is_valid(self) -> bool: + + is_valid = super().is_valid() + + return is_valid + + + +class DetailForm(CommentForm): + + prefix = 'ticket' + + class Meta: + model = TicketComment + fields = '__all__' + + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py new file mode 100644 index 000000000..4578a2e4c --- /dev/null +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -0,0 +1,110 @@ +# Generated by Django 5.0.7 on 2024-08-25 07:44 + +import access.fields +import access.models +import core.models.ticket.change_ticket +import core.models.ticket.markdown +import core.models.ticket.problem_ticket +import core.models.ticket.request_ticket +import core.models.ticket.ticket +import core.models.ticket.ticket_comment +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('access', '0001_initial'), + ('core', '0004_notes_service'), + ('project_management', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Ticket', + fields=[ + ('id', models.AutoField(help_text='Ticket ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('status', models.IntegerField(choices=[(1, 'Draft'), (2, 'New'), (3, 'Assigned'), (6, 'Assigned (Planning)'), (7, 'Pending'), (8, 'Solved'), (4, 'Closed'), (5, 'Invalid')], default=2, help_text='Status of ticket', verbose_name='Status')), + ('title', models.CharField(help_text='Title of the Ticket', max_length=50, unique=True, verbose_name='Title')), + ('description', models.TextField(default=None, help_text='Ticket Description', verbose_name='Description')), + ('urgency', models.IntegerField(blank=True, choices=[(1, 'Very Low'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Very High')], default=1, help_text='How urgent is this tickets resolution for the user?', null=True, verbose_name='Urgency')), + ('impact', models.IntegerField(blank=True, choices=[(1, 'Very Low'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Very High')], default=1, help_text='End user assessed impact', null=True, verbose_name='Impact')), + ('priority', models.IntegerField(blank=True, choices=[(1, 'Very Low'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Very High'), (6, 'Major')], default=1, help_text='What priority should this ticket for its completion', null=True, verbose_name='Priority')), + ('external_ref', models.IntegerField(blank=True, default=None, help_text='External System reference', null=True, verbose_name='Reference Number')), + ('external_system', models.IntegerField(blank=True, choices=[(1, 'Github'), (2, 'Gitlab'), (9999, 'Custom #1 (Imported)'), (9998, 'Custom #2 (Imported)'), (9997, 'Custom #3 (Imported)'), (9996, 'Custom #4 (Imported)'), (9995, 'Custom #5 (Imported)'), (9994, 'Custom #6 (Imported)'), (9993, 'Custom #7 (Imported)'), (9992, 'Custom #8 (Imported)'), (9991, 'Custom #9 (Imported)')], default=None, help_text='External system this item derives', null=True, verbose_name='External System')), + ('ticket_type', models.IntegerField(choices=[(1, 'Request'), (2, 'Incident'), (3, 'Change'), (4, 'Problem'), (5, 'Issue'), (6, 'Merge Request'), (7, 'Project Task')], help_text='The type of ticket this is', validators=[core.models.ticket.ticket.Ticket.validation_ticket_type], verbose_name='Type')), + ('is_deleted', models.BooleanField(default=False, help_text='Is the ticket deleted? And ready to be purged', verbose_name='Deleted')), + ('date_closed', models.DateTimeField(blank=True, help_text='Date ticket closed', null=True, verbose_name='Closed Date')), + ('planned_start_date', models.DateTimeField(blank=True, help_text='Planned start date.', null=True, verbose_name='Planned Start Date')), + ('planned_finish_date', models.DateTimeField(blank=True, help_text='Planned finish date', null=True, verbose_name='Planned Finish Date')), + ('real_start_date', models.DateTimeField(blank=True, help_text='Real start date', null=True, verbose_name='Real Start Date')), + ('real_finish_date', models.DateTimeField(blank=True, help_text='Real finish date', null=True, verbose_name='Real Finish Date')), + ('assigned_teams', models.ManyToManyField(blank=True, default=True, help_text='Assign the ticket to a Team(s)', related_name='assigned_teams', to='access.team', verbose_name='Assigned Team(s)')), + ('assigned_users', models.ManyToManyField(blank=True, default=True, help_text='Assign the ticket to a User(s)', related_name='assigned_users', to=settings.AUTH_USER_MODEL, verbose_name='Assigned User(s)')), + ('opened_by', models.ForeignKey(help_text='Who is the ticket for', on_delete=django.db.models.deletion.DO_NOTHING, related_name='opened_by', to=settings.AUTH_USER_MODEL, verbose_name='Opened By')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('project', models.ForeignKey(blank=True, help_text='Assign to a project', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='project_management.project', verbose_name='Project')), + ('subscribed_teams', models.ManyToManyField(blank=True, default=True, help_text='Subscribe a Team(s) to the ticket to receive updates', related_name='subscribed_teams', to='access.team', verbose_name='Subscribed Team(s)')), + ('subscribed_users', models.ManyToManyField(blank=True, default=True, help_text='Subscribe a User(s) to the ticket to receive updates', related_name='subscribed_users', to=settings.AUTH_USER_MODEL, verbose_name='Subscribed User(s)')), + ], + options={ + 'verbose_name': 'Ticket', + 'verbose_name_plural': 'Tickets', + 'ordering': ['id'], + 'permissions': [('add_ticket_request', 'Can add a request ticket'), ('change_ticket_request', 'Can change any request ticket'), ('delete_ticket_request', 'Can delete a request ticket'), ('import_ticket_request', 'Can import a request ticket'), ('purge_ticket_request', 'Can purge a request ticket'), ('triage_ticket_request', 'Can triage all request ticket'), ('view_ticket_request', 'Can view all request ticket'), ('add_ticket_incident', 'Can add a incident ticket'), ('change_ticket_incident', 'Can change any incident ticket'), ('delete_ticket_incident', 'Can delete a incident ticket'), ('import_ticket_incident', 'Can import a incident ticket'), ('purge_ticket_incident', 'Can purge a incident ticket'), ('triage_ticket_incident', 'Can triage all incident ticket'), ('view_ticket_incident', 'Can view all incident ticket'), ('add_ticket_problem', 'Can add a problem ticket'), ('change_ticket_problem', 'Can change any problem ticket'), ('delete_ticket_problem', 'Can delete a problem ticket'), ('import_ticket_problem', 'Can import a problem ticket'), ('purge_ticket_problem', 'Can purge a problem ticket'), ('triage_ticket_problem', 'Can triage all problem ticket'), ('view_ticket_problem', 'Can view all problem ticket'), ('add_ticket_change', 'Can add a change ticket'), ('change_ticket_change', 'Can change any change ticket'), ('delete_ticket_change', 'Can delete a change ticket'), ('import_ticket_change', 'Can import a change ticket'), ('purge_ticket_change', 'Can purge a change ticket'), ('triage_ticket_change', 'Can triage all change ticket'), ('view_ticket_change', 'Can view all change ticket')], + }, + bases=(models.Model, core.models.ticket.change_ticket.ChangeTicket, core.models.ticket.problem_ticket.ProblemTicket, core.models.ticket.request_ticket.RequestTicket, core.models.ticket.markdown.TicketMarkdown), + ), + migrations.CreateModel( + name='RelatedTickets', + fields=[ + ('id', models.AutoField(help_text='Ticket ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')), + ('how_related', models.IntegerField(choices=[(1, 'Related'), (2, 'Blocks'), (3, 'Blocked By')], help_text='How is the ticket related', verbose_name='How Related')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('from_ticket_id', models.ForeignKey(help_text='This Ticket', on_delete=django.db.models.deletion.CASCADE, related_name='from_ticket_id', to='core.ticket', verbose_name='Ticket')), + ('to_ticket_id', models.ForeignKey(help_text='The Related Ticket', on_delete=django.db.models.deletion.CASCADE, related_name='to_ticket_id', to='core.ticket', verbose_name='Related Ticket')), + ], + options={ + 'ordering': ['id'], + }, + ), + migrations.CreateModel( + name='TicketComment', + fields=[ + ('id', models.AutoField(help_text='Comment ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')), + ('comment_type', models.IntegerField(choices=[(1, 'Action'), (2, 'Comment'), (3, 'Task'), (4, 'Notification'), (5, 'Solution')], default=2, help_text='The type of comment this is', validators=[core.models.ticket.ticket_comment.TicketComment.validation_comment_type], verbose_name='Type')), + ('body', models.TextField(default=None, help_text='Comment contents', verbose_name='Comment')), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('private', models.BooleanField(default=False, help_text='Is this comment private', verbose_name='Private')), + ('duration', models.IntegerField(default=0, help_text='Time spent in seconds', verbose_name='Duration')), + ('is_template', models.BooleanField(default=False, help_text='Is this comment a template', verbose_name='Template')), + ('source', models.IntegerField(choices=[(1, 'Direct'), (2, 'E-Mail'), (3, 'Helpdesk'), (4, 'Phone')], default=1, help_text='Origin type for this comment', verbose_name='Source')), + ('status', models.IntegerField(choices=[(1, 'To Do'), (2, 'Done')], default=1, help_text='Status of comment', verbose_name='Status')), + ('date_closed', models.DateTimeField(blank=True, help_text='Date ticket closed', null=True, verbose_name='Closed Date')), + ('planned_start_date', models.DateTimeField(blank=True, help_text='Planned start date.', null=True, verbose_name='Planned Start Date')), + ('planned_finish_date', models.DateTimeField(blank=True, help_text='Planned finish date', null=True, verbose_name='Planned Finish Date')), + ('real_start_date', models.DateTimeField(blank=True, help_text='Real start date', null=True, verbose_name='Real Start Date')), + ('real_finish_date', models.DateTimeField(blank=True, help_text='Real finish date', null=True, verbose_name='Real Finish Date')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('parent', models.ForeignKey(blank=True, default=None, help_text='Parent ID for creating discussion threads', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='core.ticketcomment', verbose_name='Parent Comment')), + ('responsible_team', models.ForeignKey(blank=True, default=None, help_text='Team whom is responsible for the completion of comment', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_responsible_team', to='access.team', verbose_name='Responsible Team')), + ('responsible_user', models.ForeignKey(blank=True, default=None, help_text='User whom is responsible for the completion of comment', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_responsible_user', to=settings.AUTH_USER_MODEL, verbose_name='Responsible User')), + ('template', models.ForeignKey(blank=True, default=None, help_text='Comment Template to use', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_template', to='core.ticketcomment', verbose_name='Template')), + ('ticket', models.ForeignKey(blank=True, default=None, help_text='Parent ID for creating discussion threads', null=True, on_delete=django.db.models.deletion.CASCADE, to='core.ticket', validators=[core.models.ticket.ticket_comment.TicketComment.validation_ticket_id], verbose_name='Parent Comment')), + ('user', models.ForeignKey(help_text='Who made the comment', on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_user', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Comment', + 'verbose_name_plural': 'Comments', + 'ordering': ['ticket', 'parent_id'], + }, + bases=(models.Model, core.models.ticket.markdown.TicketMarkdown), + ), + ] diff --git a/app/core/models/__init__.py b/app/core/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/core/models/ticket/__init__.py b/app/core/models/ticket/__init__.py new file mode 100644 index 000000000..b9742821a --- /dev/null +++ b/app/core/models/ticket/__init__.py @@ -0,0 +1 @@ +from . import * \ No newline at end of file diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index e80ef5c65..ff4d35dee 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -1,6 +1,99 @@ +from django.contrib.auth.models import User from django.db import models +from django.db.models import Q +from django.forms import ValidationError -from access.models import TenancyObject +from access.fields import AutoCreatedField +from access.models import TenancyObject, Team + +from .change_ticket import ChangeTicket +from .markdown import TicketMarkdown +from .problem_ticket import ProblemTicket +from .request_ticket import RequestTicket + +from project_management.models.projects import Project + + + +class TicketValues: + + + _DRAFT_INT = '1' + _NEW_INT = '2' + + _ASSIGNED_INT = '3' + _CLOSED_INT = '4' + _INVALID_INT = '5' + + # + # ITSM statuses + # + + # Requests / Incidents / Problems / Changed + _ASSIGNED_PLANNING_INT = '6' + _PENDING_INT = '7' + + # Requests / Incidents / Problems + _SOLVED_INT = '8' + + # Problem + + _OBSERVATION_INT = '9' + + # Problems / Changes + + _ACCEPTED_INT = '10' + + # Changes + + _EVALUATION_INT = '11' + _APPROVALS_INT = '12' + _TESTING_INT = '13' + _QUALIFICATION_INT = '14' + _APPLIED_INT = '15' + _REVIEW_INT = '16' + _CANCELLED_INT = '17' + _REFUSED_INT = '18' + + + + + _DRAFT_STR = 'Draft' + _NEW_STR = 'New' + + _ASSIGNED_STR = 'Assigned' + _CLOSED_STR = 'Closed' + _INVALID_STR = 'Invalid' + + # + # ITSM statuses + # + + # Requests / Incidents / Problems / Changed + _ASSIGNED_PLANNING_STR = 'Assigned (Planning)' + _PENDING_STR = 'Pending' + + # Requests / Incidents / Problems + _SOLVED_STR = 'Solved' + + # Problem + + _OBSERVATION_STR = 'Under Observation' + + # Problems / Changes + + _ACCEPTED_STR = 'Accepted' + + # Changes + + _EVALUATION_STR = 'Evaluation' + _APPROVALS_STR = 'Approvals' + _TESTING_STR = 'Testing' + _QUALIFICATION_STR = 'Qualification' + _APPLIED_STR = 'Applied' + _REVIEW_STR = 'Review' + _CANCELLED_STR = 'Cancelled' + _REFUSED_STR = 'Refused' @@ -26,6 +119,10 @@ class Meta: class Ticket( TenancyObject, TicketCommonFields, + ChangeTicket, + ProblemTicket, + RequestTicket, + TicketMarkdown, ): @@ -35,3 +132,624 @@ class Meta: 'id' ] + permissions = [ + ('add_ticket_request', 'Can add a request ticket'), + ('change_ticket_request', 'Can change any request ticket'), + ('delete_ticket_request', 'Can delete a request ticket'), + ('import_ticket_request', 'Can import a request ticket'), + ('purge_ticket_request', 'Can purge a request ticket'), + ('triage_ticket_request', 'Can triage all request ticket'), + ('view_ticket_request', 'Can view all request ticket'), + + ('add_ticket_incident', 'Can add a incident ticket'), + ('change_ticket_incident', 'Can change any incident ticket'), + ('delete_ticket_incident', 'Can delete a incident ticket'), + ('import_ticket_incident', 'Can import a incident ticket'), + ('purge_ticket_incident', 'Can purge a incident ticket'), + ('triage_ticket_incident', 'Can triage all incident ticket'), + ('view_ticket_incident', 'Can view all incident ticket'), + + ('add_ticket_problem', 'Can add a problem ticket'), + ('change_ticket_problem', 'Can change any problem ticket'), + ('delete_ticket_problem', 'Can delete a problem ticket'), + ('import_ticket_problem', 'Can import a problem ticket'), + ('purge_ticket_problem', 'Can purge a problem ticket'), + ('triage_ticket_problem', 'Can triage all problem ticket'), + ('view_ticket_problem', 'Can view all problem ticket'), + + ('add_ticket_change', 'Can add a change ticket'), + ('change_ticket_change', 'Can change any change ticket'), + ('delete_ticket_change', 'Can delete a change ticket'), + ('import_ticket_change', 'Can import a change ticket'), + ('purge_ticket_change', 'Can purge a change ticket'), + ('triage_ticket_change', 'Can triage all change ticket'), + ('view_ticket_change', 'Can view all change ticket'), + ] + + verbose_name = "Ticket" + + verbose_name_plural = "Tickets" + + + + class Ticket_ExternalSystem(models.IntegerChoices): # + GITHUB = '1', 'Github' + GITLAB = '2', 'Gitlab' + + CUSTOM_1 = '9999', 'Custom #1 (Imported)' + CUSTOM_2 = '9998', 'Custom #2 (Imported)' + CUSTOM_3 = '9997', 'Custom #3 (Imported)' + CUSTOM_4 = '9996', 'Custom #4 (Imported)' + CUSTOM_5 = '9995', 'Custom #5 (Imported)' + CUSTOM_6 = '9994', 'Custom #6 (Imported)' + CUSTOM_7 = '9993', 'Custom #7 (Imported)' + CUSTOM_8 = '9992', 'Custom #8 (Imported)' + CUSTOM_9 = '9991', 'Custom #9 (Imported)' + + + + class TicketStatus: # + """ Ticket Status + + Status of the ticket. By design, not all statuses are available for ALL ticket types. + + ## Request / Incident ticket + + - Draft + - New + - Assigned + - Assigned (Planned) + - Pending + - Solved + - Closed + + + ## Problem Ticket + + - Draft + - New + - Accepted + - Assigned + - Assigned (Planned) + - Pending + - Solved + - Under Observation + - Closed + + ## Change Ticket + + - Draft + - New + - Evaluation + - Approvals + - Accepted + - Pending + - Testing + - Qualification + - Applied + - Review + - Closed + - Cancelled + - Refused + + """ + + class Request(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + SOLVED = TicketValues._SOLVED_INT, TicketValues._SOLVED_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + + class Incident(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + SOLVED = TicketValues._SOLVED_INT, TicketValues._SOLVED_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + + class Problem(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ACCEPTED = TicketValues._ACCEPTED_INT, TicketValues._ACCEPTED_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + SOLVED = TicketValues._SOLVED_INT, TicketValues._SOLVED_STR + OBSERVATION = TicketValues._OBSERVATION_INT, TicketValues._OBSERVATION_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + + class Change(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + EVALUATION = TicketValues._EVALUATION_INT, TicketValues._EVALUATION_STR + APPROVALS = TicketValues._APPROVALS_INT, TicketValues._APPROVALS_STR + ACCEPTED = TicketValues._ACCEPTED_INT, TicketValues._ACCEPTED_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + TESTING = TicketValues._TESTING_INT, TicketValues._TESTING_STR + QUALIFICATION = TicketValues._QUALIFICATION_INT, TicketValues._QUALIFICATION_STR + APPLIED = TicketValues._APPLIED_INT, TicketValues._APPLIED_STR + REVIEW = TicketValues._REVIEW_INT, TicketValues._REVIEW_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + CANCELLED = TicketValues._CANCELLED_INT, TicketValues._CANCELLED_STR + REFUSED = TicketValues._REFUSED_INT, TicketValues._REFUSED_STR + + + class Git(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + class ProjectTask(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + SOLVED = TicketValues._SOLVED_INT, TicketValues._SOLVED_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + + + class TicketType(models.IntegerChoices): + """Type of the ticket""" + + REQUEST = '1', 'Request' + INCIDENT = '2', 'Incident' + CHANGE = '3', 'Change' + PROBLEM = '4', 'Problem' + ISSUE = '5', 'Issue' + MERGE_REQUEST = '6', 'Merge Request' + PROJECT_TASK = '7', 'Project Task' + + + + class TicketUrgency(models.IntegerChoices): # + VERY_LOW = '1', 'Very Low' + LOW = '2', 'Low' + MEDIUM = '3', 'Medium' + HIGH = '4', 'High' + VERY_HIGH = '5', 'Very High' + + + + class TicketImpact(models.IntegerChoices): + VERY_LOW = '1', 'Very Low' + LOW = '2', 'Low' + MEDIUM = '3', 'Medium' + HIGH = '4', 'High' + VERY_HIGH = '5', 'Very High' + + + + class TicketPriority(models.IntegerChoices): + VERY_LOW = '1', 'Very Low' + LOW = '2', 'Low' + MEDIUM = '3', 'Medium' + HIGH = '4', 'High' + VERY_HIGH = '5', 'Very High' + MAJOR = '6', 'Major' + + + + def validation_ticket_type(field): + + if not field: + raise ValidationError('Ticket Type must be set') + + + def validation_title(field): + + if not field: + raise ValueError + + + model_notes = None + + is_global = None + + + status = models.IntegerField( # will require validation by ticket type as status for types will be different + blank = False, + choices=TicketStatus.Request, + default = TicketStatus.Request.NEW, + help_text = 'Status of ticket', + # null=True, + verbose_name = 'Status', + ) + + # category = models.CharField( + # blank = False, + # help_text = "Category of the Ticket", + # max_length = 50, + # unique = True, + # verbose_name = 'Category', + # ) + + title = models.CharField( + blank = False, + help_text = "Title of the Ticket", + max_length = 50, + unique = True, + verbose_name = 'Title', + ) + + description = models.TextField( + blank = False, + default = None, + help_text = 'Ticket Description', + null = False, + verbose_name = 'Description', + ) # text, markdown + + + urgency = models.IntegerField( + blank = True, + choices=TicketUrgency, + default=TicketUrgency.VERY_LOW, + help_text = 'How urgent is this tickets resolution for the user?', + null=True, + verbose_name = 'Urgency', + ) + + impact = models.IntegerField( + blank = True, + choices=TicketImpact, + default=TicketImpact.VERY_LOW, + help_text = 'End user assessed impact', + null=True, + verbose_name = 'Impact', + ) + + priority = models.IntegerField( + blank = True, + choices=TicketPriority, + default=TicketPriority.VERY_LOW, + help_text = 'What priority should this ticket for its completion', + null=True, + verbose_name = 'Priority', + ) + + + external_ref = models.IntegerField( + blank = True, + default=None, + help_text = 'External System reference', + null=True, + verbose_name = 'Reference Number', + ) # external reference or null. i.e. github issue number + + + external_system = models.IntegerField( + blank = True, + choices=Ticket_ExternalSystem, + default=None, + help_text = 'External system this item derives', + null=True, + verbose_name = 'External System', + ) + + + ticket_type = models.IntegerField( + blank = False, + choices=TicketType, + help_text = 'The type of ticket this is', + validators = [ validation_ticket_type ], + verbose_name = 'Type', + ) + + + project = models.ForeignKey( + Project, + blank= True, + help_text = 'Assign to a project', + null = True, + on_delete = models.DO_NOTHING, + verbose_name = 'Project', + ) + + + opened_by = models.ForeignKey( + User, + blank= False, + help_text = 'Who is the ticket for', + null = False, + on_delete = models.DO_NOTHING, + related_name = 'opened_by', + verbose_name = 'Opened By', + ) + + + subscribed_users = models.ManyToManyField( + User, + blank= True, + help_text = 'Subscribe a User(s) to the ticket to receive updates', + related_name = 'subscribed_users', + symmetrical = False, + verbose_name = 'Subscribed User(s)', + ) + + + subscribed_teams = models.ManyToManyField( + Team, + blank= True, + help_text = 'Subscribe a Team(s) to the ticket to receive updates', + related_name = 'subscribed_teams', + symmetrical = False, + verbose_name = 'Subscribed Team(s)', + ) + + assigned_users = models.ManyToManyField( + User, + blank= True, + help_text = 'Assign the ticket to a User(s)', + related_name = 'assigned_users', + symmetrical = False, + verbose_name = 'Assigned User(s)', + ) + + assigned_teams = models.ManyToManyField( + Team, + blank= True, + help_text = 'Assign the ticket to a Team(s)', + related_name = 'assigned_teams', + symmetrical = False, + verbose_name = 'Assigned Team(s)', + ) + + is_deleted = models.BooleanField( + blank = False, + default = False, + help_text = 'Is the ticket deleted? And ready to be purged', + null = False, + verbose_name = 'Deleted', + ) + + date_closed = models.DateTimeField( + blank = True, + help_text = 'Date ticket closed', + null = True, + verbose_name = 'Closed Date', + ) + + planned_start_date = models.DateTimeField( + blank = True, + help_text = 'Planned start date.', + null = True, + verbose_name = 'Planned Start Date', + ) + + planned_finish_date = models.DateTimeField( + blank = True, + help_text = 'Planned finish date', + null = True, + verbose_name = 'Planned Finish Date', + ) + + real_start_date = models.DateTimeField( + blank = True, + help_text = 'Real start date', + null = True, + verbose_name = 'Real Start Date', + ) + + real_finish_date = models.DateTimeField( + blank = True, + help_text = 'Real finish date', + null = True, + verbose_name = 'Real Finish Date', + ) + + + # ?? date_edit date of last edit + + def __str__(self): + + return self.title + + common_fields: list(str()) = [ + 'organization', + 'title', + 'description', + 'opened_by', + 'ticket_type' + ] + + common_itsm_fields: list(str()) = common_fields + [ + 'urgency', + + ] + + fields_itsm_request: list(str()) = common_itsm_fields + [ + + ] + + fields_itsm_incident: list(str()) = common_itsm_fields + [ + + ] + + fields_itsm_problem: list(str()) = common_itsm_fields + [ + + ] + + fields_itsm_change: list(str()) = common_itsm_fields + [ + + ] + + + common_git_fields: list(str()) = common_fields + [ + + ] + + fields_git_issue: list(str()) = common_fields + [ + + ] + + fields_git_merge_request: list(str()) = common_fields + [ + + ] + + fields_project_task: list(str()) = common_fields + [ + 'category', + 'urgency', + 'status', + 'impact', + 'priority', + 'planned_start_date', + 'planned_finish_date', + 'real_start_date', + 'real_finish_date', + ] + + tech_fields = [ + 'category', + 'project', + 'assigned_users', + 'assigned_teams', + 'subscribed_teams', + 'subscribed_users', + 'status', + 'urgency', + 'impact', + 'priority', + 'planned_start_date', + 'planned_finish_date', + ] + + + @property + def markdown_description(self) -> str: + + return self.render_markdown(self.description) + + @property + def related_tickets(self) -> list(dict()): + + related_tickets: list() = [] + + query = RelatedTickets.objects.filter( + Q(from_ticket_id=self.id) + | + Q(to_ticket_id=self.id) + ) + + for related_ticket in query: + + + how_related:str = str(related_ticket.get_how_related_display()).lower() + + + if related_ticket.to_ticket_id_id == self.id: + + if str(related_ticket.get_how_related_display()).lower() == 'blocks': + + how_related = 'blocked by' + + elif str(related_ticket.get_how_related_display()).lower() == 'blocked by': + + how_related = 'blocks' + + + related_tickets += [ + { + 'id': related_ticket.id, + 'type': related_ticket.to_ticket_id.get_ticket_type_display().lower(), + 'title': related_ticket.to_ticket_id.title, + 'how_related': how_related.replace(' ', '_'), + 'icon_filename': str('icons/ticket/ticket_' + how_related.replace(' ', '_') + '.svg') + } + ] + + return related_tickets + + + @property + def comments(self): + + from core.models.ticket.ticket_comment import TicketComment + + return TicketComment.objects.filter( + ticket = self.id, + parent = None, + ) + + + +class RelatedTickets(TenancyObject): + + class Meta: + + ordering = [ + 'id' + ] + + class Related(models.IntegerChoices): + RELATED = '1', 'Related' + + BLOCKS = '2', 'Blocks' + + BLOCKED_BY = '3', 'Blocked By' + + is_global = None + + model_notes = None + + id = models.AutoField( + blank=False, + help_text = 'Ticket ID Number', + primary_key=True, + unique=True, + verbose_name = 'Number', + ) + + from_ticket_id = models.ForeignKey( + Ticket, + blank= False, + help_text = 'This Ticket', + null = False, + on_delete = models.CASCADE, + related_name = 'from_ticket_id', + verbose_name = 'Ticket', + ) + + how_related = models.IntegerField( + blank = False, + choices = Related, + help_text = 'How is the ticket related', + verbose_name = 'How Related', + ) + + to_ticket_id = models.ForeignKey( + Ticket, + blank= False, + help_text = 'The Related Ticket', + null = False, + on_delete = models.CASCADE, + related_name = 'to_ticket_id', + verbose_name = 'Related Ticket', + ) diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py new file mode 100644 index 000000000..15588a609 --- /dev/null +++ b/app/core/models/ticket/ticket_comment.py @@ -0,0 +1,388 @@ +from django.contrib.auth.models import User +from django.db import models +from django.forms import ValidationError + +from access.fields import AutoCreatedField +from access.models import TenancyObject, Team + +from .markdown import TicketMarkdown +from .ticket import Ticket + + + +class TicketComment( + TenancyObject, + TicketMarkdown, +): + + + class Meta: + + ordering = [ + 'ticket', + 'parent_id' + ] + + verbose_name = "Comment" + + verbose_name_plural = "Comments" + + + + class CommentSource(models.IntegerChoices): + """Source of the comment""" + + DIRECT = '1', 'Direct' + EMAIL = '2', 'E-Mail' + HELPDESK = '3', 'Helpdesk' + PHONE = '4', 'Phone' + + + class CommentStatus(models.IntegerChoices): + """Comment Completion Status""" + + TODO = '1', 'To Do' + DONE = '2', 'Done' + + + class CommentType(models.IntegerChoices): + """Type of Comment + + Comment types are as follows: + + - Action + + - Comment + + - Solution + + - Notification + + ## Action + + An action comment is for the tracking of what has occured to the ticket. + + ## Comment + + This is the default comment type and is what would be normally used. + + ## Solution + + This type of comment is an ITSM comment and is used as the means for solving the ticket.\ + + ## Notification + + This type of comment is intended to be used to send a notification to subscribed users. + """ + + ACTION = '1', 'Action' + COMMENT = '2', 'Comment' + TASK = '3', 'Task' + NOTIFICATION = '4', 'Notification' + SOLUTION = '5', 'Solution' + + + def validation_comment_type(field): + + if not field: + raise ValidationError('Comment Type must be set') + + + def validation_ticket_id(field): + + if not field: + raise ValidationError('Ticket ID is required') + + + model_notes = None + + is_global = None + + + id = models.AutoField( + blank=False, + help_text = 'Comment ID Number', + primary_key=True, + unique=True, + verbose_name = 'Number', + ) + + parent = models.ForeignKey( + 'self', + blank= True, + default = None, + help_text = 'Parent ID for creating discussion threads', + null = True, + on_delete = models.DO_NOTHING, + verbose_name = 'Parent Comment', + ) + + ticket = models.ForeignKey( + Ticket, + blank= True, + default = None, + help_text = 'Parent ID for creating discussion threads', + null = True, + on_delete = models.CASCADE, + validators = [ validation_ticket_id ], + verbose_name = 'Parent Comment', + ) + + comment_type = models.IntegerField( + blank = False, + choices =CommentType, + default = CommentType.COMMENT, + help_text = 'The type of comment this is', + validators = [ validation_comment_type ], + verbose_name = 'Type', + ) + + body = models.TextField( + blank = False, + default = None, + help_text = 'Comment contents', + null = False, + verbose_name = 'Comment', + ) + + created = AutoCreatedField() + + modified = AutoCreatedField() + + private = models.BooleanField( + blank = False, + default = False, + help_text = 'Is this comment private', + null = False, + verbose_name = 'Private', + ) + + duration = models.IntegerField( + blank = False, + default = 0, + help_text = 'Time spent in seconds', + null = False, + verbose_name = 'Duration', + ) + + + # category = models.CharField( + # blank = False, + # help_text = "Category of the Ticket", + # max_length = 50, + # unique = True, + # verbose_name = 'Category', + # ) + + template = models.ForeignKey( + 'self', + blank= True, + default = None, + help_text = 'Comment Template to use', + null = True, + on_delete = models.DO_NOTHING, + related_name = 'comment_template', + verbose_name = 'Template', + ) + + is_template = models.BooleanField( + blank = False, + default = False, + help_text = 'Is this comment a template', + null = False, + verbose_name = 'Template', + ) + + source = models.IntegerField( + blank = False, + choices =CommentSource, + default = CommentSource.DIRECT, + help_text = 'Origin type for this comment', + # validators = [ validation_ticket_type ], + verbose_name = 'Source', + ) + + status = models.IntegerField( # will require validation by comment type as status for types will be different + blank = False, + choices=CommentStatus, + default = CommentStatus.TODO, + help_text = 'Status of comment', + # null=True, + verbose_name = 'Status', + ) + + responsible_user = models.ForeignKey( + User, + blank= True, + default = None, + help_text = 'User whom is responsible for the completion of comment', + on_delete = models.DO_NOTHING, + related_name = 'comment_responsible_user', + null = True, + verbose_name = 'Responsible User', + ) + + responsible_team = models.ForeignKey( + Team, + blank= True, + default = None, + help_text = 'Team whom is responsible for the completion of comment', + on_delete = models.DO_NOTHING, + related_name = 'comment_responsible_team', + null = True, + verbose_name = 'Responsible Team', + ) + + user = models.ForeignKey( + User, + blank= False, + help_text = 'Who made the comment', + null = False, + on_delete = models.DO_NOTHING, + related_name = 'comment_user', + verbose_name = 'User', + ) + + date_closed = models.DateTimeField( + blank = True, + help_text = 'Date ticket closed', + null = True, + verbose_name = 'Closed Date', + ) + + planned_start_date = models.DateTimeField( + blank = True, + help_text = 'Planned start date.', + null = True, + verbose_name = 'Planned Start Date', + ) + + planned_finish_date = models.DateTimeField( + blank = True, + help_text = 'Planned finish date', + null = True, + verbose_name = 'Planned Finish Date', + ) + + real_start_date = models.DateTimeField( + blank = True, + help_text = 'Real start date', + null = True, + verbose_name = 'Real Start Date', + ) + + real_finish_date = models.DateTimeField( + blank = True, + help_text = 'Real finish date', + null = True, + verbose_name = 'Real Finish Date', + ) + + + common_fields: list(str()) = [ + 'body', + 'duration', + 'user', + 'ticket', + 'parent', + 'comment_type', + ] + + common_itsm_fields: list(str()) = common_fields + [ + 'category', + 'source', + 'template', + + ] + + fields_itsm_task: list(str()) = common_itsm_fields + [ + 'status', + 'responsible_user', + 'responsible_team', + 'planned_start_date', + 'planned_finish_date', + 'real_start_date', + 'real_finish_date', + ] + + fields_itsm_notification: list(str()) = common_itsm_fields + [ + 'status', + 'responsible_user', + 'responsible_team', + 'planned_start_date', + 'planned_finish_date', + 'real_start_date', + 'real_finish_date', + ] + + fields_itsm_incident: list(str()) = common_itsm_fields + [ + + ] + + fields_itsm_problem: list(str()) = common_itsm_fields + [ + + ] + + fields_itsm_change: list(str()) = common_itsm_fields + [ + + ] + + + common_git_fields: list(str()) = common_fields + [ + + ] + + fields_git_issue: list(str()) = common_fields + [ + + ] + + fields_git_merge_request: list(str()) = common_fields + [ + + ] + + fields_project_task: list(str()) = common_fields + [ + 'category', + 'urgency', + 'status', + 'impact', + 'priority', + 'planned_start_date', + 'planned_finish_date', + 'real_start_date', + 'real_finish_date', + ] + + fields_comment_task: list(str()) = common_itsm_fields + [ + 'status', + 'responsible_user', + 'responsible_team', + 'planned_start_date', + 'planned_finish_date', + 'real_start_date', + 'real_finish_date', + ] + + + + + @property + def markdown_description(self) -> str: + + return self.render_markdown(self.description) + + @property + def comment_template_queryset(self): + + query = TicketComment.objects.filter( + is_template = True, + comment_type = self.comment_type, + ) + + return query + + @property + def threads(self): + + return TicketComment.objects.filter( + parent = self.id + ) diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 new file mode 100644 index 000000000..45f04f355 --- /dev/null +++ b/app/core/templates/core/ticket.html.j2 @@ -0,0 +1,142 @@ +{% extends 'base.html.j2' %} + +{% block additional-stylesheet %} + {% load static %} + +{% endblock additional-stylesheet %} + +{% load tickets %} + +{% block article %} + +
+ +
+ +
+
+ +
+
{{ ticket.description | markdown | safe }}
+
+ +
+ +
+

+
Related Tickets
+ +

+ + {% if ticket.related_tickets %} + {% for related_ticket in ticket.related_tickets %} +
+ +
+ {% if related_ticket.how_related == 'blocked_by' %} + Blocked by + {% elif related_ticket.how_related == 'blocks' %} + Blocks + {% elif related_ticket.how_related == 'related' %} + Related to + {% endif %} +   + {{ related_ticket.title }} +
+
+ {% endfor %} + {% else %} +
Nothing Found
+ {% endif %} + +
+ + +
+

+
Linked Items
+
{% include 'icons/place-holder.svg' %}{% include 'icons/place-holder.svg' %}
+

+
+ An item +
+
+ another item +
+
+ another item +
+
+ another item +
+
+ another item +
+
+ another item +
+
+ +
+ + {% include 'core/ticket/comment.html.j2' %} + +
+ + +
+

{{ ticket_type }}

+ +
+ + + {% if ticket.assigned_users %} + {% for user in ticket.assigned_users.all %} + {{ user }} + {% endfor%} + {% endif %} + {% if ticket.assigned_teams %} + {% for team in ticket.assigned_teams.all %} + {{ team }} + {% endfor%} + {% endif %} + +
+
+ + {{ticket.get_status_display }} +
+
+ + val +
+
+ + + {% if ticket.project %} + {{ ticket.project }} + {% else %} + - + {% endif %} + +
+
+ + U{{ ticket.get_urgency_display }} / I{{ ticket.get_impact_display }} / P{{ ticket.get_priority_display }} +
+
+ + val +
+
+ + val +
+ +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/core/templates/core/ticket/comment.html.j2 b/app/core/templates/core/ticket/comment.html.j2 new file mode 100644 index 000000000..4a6df4c1f --- /dev/null +++ b/app/core/templates/core/ticket/comment.html.j2 @@ -0,0 +1,55 @@ + + +
+
    +
  • John smith add x as related to this ticket
  • + + {% for comment in ticket.comments %} + +
  • + {% include 'core/ticket/comment/comment.html.j2' %} + + {% if comment.threads %} +
    +

    + Replies + {% include 'icons/ticket/expanded.svg' %} +

    +
    +
    + {% if comment.threads %} + {% for thread in comment.threads %} + + + {% include 'core/ticket/comment/comment.html.j2' with comment=thread %} + + + {% endfor %} + + {% endif %} +
    +
    + + +
    +
    + + {% endif %} +
  • + + {% endfor %} + +
  • Jane smith mentioned this ticket in xx
  • +
  • sdasfdgdfgdfg dfg dfg dfg d
  • +
+ +
+ + + + + + +
+ +
diff --git a/app/core/templates/core/ticket/comment/comment.html.j2 b/app/core/templates/core/ticket/comment/comment.html.j2 new file mode 100644 index 000000000..4998d9569 --- /dev/null +++ b/app/core/templates/core/ticket/comment/comment.html.j2 @@ -0,0 +1,117 @@ +{% load tickets %} + +{% if comment %} + +{% if comment.get_comment_type_display == 'Action' %} + + {{ comment.body | markdown | safe }} + +{% elif comment.get_comment_type_display == 'Comment' or comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' or comment.get_comment_type_display == 'Solution' %} +
+ +

+
+ {{ comment.user }} + {% if comment.get_comment_type_display == 'Task' %} + created a task + {% elif comment.get_comment_type_display == 'Solution' %} + solved + {% else %} + wrote + {% endif %} + on {{ comment.created }} +
+ + +

+ +
+ {% if comment.get_comment_type_display != 'Notification' %} +
+ + {{ comment.get_source_display }} +
+ {% endif %} + {% if comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' %} +
+ + {{ comment.get_status_display }} +
+ {% if comment.get_comment_type_display == 'Task' %} +
+ + {{ comment.responsible_user }} +
+ {% endif %} +
+ + {{ comment.responsible_team }} +
+ {% endif %} +
+ + {{ comment.category }} +
+
+ +
+ +
+ {{ comment.body | markdown | safe }} +
+ +
+ +
+ {% if comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' %} +
+ + {{ comment.planned_start_date }} +
+ {% if comment.get_comment_type_display == 'Task' %} +
+ + {{ comment.planned_finish_date }} +
+ {% endif %} +
+ + {{ comment.real_start_date }} +
+
+ + {{ comment.real_finish_date }} +
+ {% endif %} +
+ + {{ comment.duration }} +
+
+ +
+ +{% endif %} + +{% endif %} diff --git a/app/core/templates/core/ticket/index.html.j2 b/app/core/templates/core/ticket/index.html.j2 new file mode 100644 index 000000000..443b3bb26 --- /dev/null +++ b/app/core/templates/core/ticket/index.html.j2 @@ -0,0 +1,55 @@ +{% extends 'base.html.j2' %} + +{% block content %} + + + + + + + + + + + + + {% for ticket in tickets %} + + + + + + + + {% endfor %} +
 IDTitleStatusCreated
+   + {{ ticket.id }} + {% if ticket_type == 'change' %} + + {% elif ticket_type == 'incident' %} + + {% elif ticket_type == 'problem' %} + + {% elif ticket_type == 'request' %} + + {% else %} + + {% endif %} + {{ ticket.title }} + + {{ ticket.get_status_display }}{{ ticket.created }}
+ +{% endblock %} \ No newline at end of file diff --git a/app/core/templates/icons/ticket/add.svg b/app/core/templates/icons/ticket/add.svg new file mode 100644 index 000000000..33bdf2e0c --- /dev/null +++ b/app/core/templates/icons/ticket/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/edit.svg b/app/core/templates/icons/ticket/edit.svg new file mode 100644 index 000000000..7bd753ae6 --- /dev/null +++ b/app/core/templates/icons/ticket/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/expanded.svg b/app/core/templates/icons/ticket/expanded.svg new file mode 100644 index 000000000..0924e2cba --- /dev/null +++ b/app/core/templates/icons/ticket/expanded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/notification.svg b/app/core/templates/icons/ticket/notification.svg new file mode 100644 index 000000000..005a59573 --- /dev/null +++ b/app/core/templates/icons/ticket/notification.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/reply.svg b/app/core/templates/icons/ticket/reply.svg new file mode 100644 index 000000000..30dfce286 --- /dev/null +++ b/app/core/templates/icons/ticket/reply.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/task.svg b/app/core/templates/icons/ticket/task.svg new file mode 100644 index 000000000..c271e3a94 --- /dev/null +++ b/app/core/templates/icons/ticket/task.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/ticket_blocked_by.svg b/app/core/templates/icons/ticket/ticket_blocked_by.svg new file mode 100644 index 000000000..2885488d7 --- /dev/null +++ b/app/core/templates/icons/ticket/ticket_blocked_by.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/ticket_blocks.svg b/app/core/templates/icons/ticket/ticket_blocks.svg new file mode 100644 index 000000000..771a1c658 --- /dev/null +++ b/app/core/templates/icons/ticket/ticket_blocks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/ticket_related.svg b/app/core/templates/icons/ticket/ticket_related.svg new file mode 100644 index 000000000..de59f944f --- /dev/null +++ b/app/core/templates/icons/ticket/ticket_related.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index 867b2f80c..35e00bcc5 100644 --- a/app/core/templatetags/markdown.py +++ b/app/core/templatetags/markdown.py @@ -9,4 +9,4 @@ @register.filter() @stringfilter def markdown(value): - return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) \ No newline at end of file + return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) diff --git a/app/core/templatetags/tickets.py b/app/core/templatetags/tickets.py new file mode 100644 index 000000000..49e3e7cab --- /dev/null +++ b/app/core/templatetags/tickets.py @@ -0,0 +1,13 @@ +from django import template +from django.template.defaultfilters import stringfilter + +import markdown as md + +register = template.Library() + + +@register.filter() +@stringfilter +def markdown(value): + + return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) diff --git a/app/core/tests/unit/ticket/test_ticket_common.py b/app/core/tests/unit/ticket/test_ticket_common.py new file mode 100644 index 000000000..3a336da5b --- /dev/null +++ b/app/core/tests/unit/ticket/test_ticket_common.py @@ -0,0 +1,63 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import ModelDisplay, ModelIndex + + + +class TicketCommon( + TestCase +): + + def text_ticket_field_type_opened_by(self): + """Ensure field is of a certain type + + opened_by_field must be of type int + """ + pass + + def text_ticket_field_value_not_null_opened_by(self): + """Ensure field is not null + + opened_by_field must be set and not null + """ + pass + + + def text_ticket_field_value_auto_set_opened_by(self): + """Ensure field is auto set within code + + opened_by_field must be set by code with non-tech user not being able to change + """ + pass + + + def text_ticket_field_value_tech_set_opened_by(self): + """Ensure field can be set by a technician + + opened_by_field can be set by a technician + """ + pass + + + + def text_ticket_type_fields(self): + """Placeholder test + + following tests to be written: + + - only tech can change tech fields (same org) + - non-tech cant see tech fields (same org) during creation + - non-tech cant change tech fields (same org) + - only tech can change tech fields (different org) + - non-tech cant see tech fields (different org) during creation + - non-tech cant change tech fields (different org) + + - itsm ticket has the itsm related fields + - non-itsm ticket does not have any itsm related fields + + """ + pass diff --git a/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py b/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py new file mode 100644 index 000000000..cb41b7fa0 --- /dev/null +++ b/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py @@ -0,0 +1,25 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import ModelDisplay, ModelIndex + +from core.models.ticket.ticket_comment import TicketComment + + + +class TicketCommentCommon( + TestCase +): + + model = TicketComment + + + def text_ticket_field_type_opened_by(self): + """Replies to comments only to occur on primary comment + + If a comment has a 'parent_id' set, ensure the comment can't be replied to + """ + pass diff --git a/app/core/views/related_ticket.py b/app/core/views/related_ticket.py new file mode 100644 index 000000000..e20943ff4 --- /dev/null +++ b/app/core/views/related_ticket.py @@ -0,0 +1,81 @@ +import markdown + +from django.urls import reverse +from django.views import generic + +from django_celery_results.models import TaskResult + +from access.mixin import OrganizationPermission + +from core.forms.related_ticket import RelatedTicketForm +from core.models.ticket.ticket import RelatedTickets +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = RelatedTicketForm + + model = RelatedTickets + + permission_required = [ + 'itam.add_device', + ] + + template_name = 'form.html.j2' + + + def get_initial(self): + + initial_values: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization, + 'from_ticket_id': self.kwargs['ticket_id'], + } + + return initial_values + + + def get_success_url(self, **kwargs): + + if self.kwargs['ticket_type'] == 'request': + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.object.id,)) + + else: + + return reverse('ITIM:_ticket_' + str(self.kwargs['ticket_type']).lower() + '_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Ticket Comment' + + return context + + + +class Delete(DeleteView): + + model = RelatedTickets + + permission_required = [ + 'itim.delete_cluster', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + str(self.object) + + return context + + + def get_success_url(self, **kwargs): + + return reverse('ITIM:Clusters') diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py new file mode 100644 index 000000000..c794f04f8 --- /dev/null +++ b/app/core/views/ticket.py @@ -0,0 +1,188 @@ +import markdown + +from django.http import Http404 +from django.urls import reverse +from django.views import generic + +from django_celery_results.models import TaskResult + +from access.mixin import OrganizationPermission + +from core.forms.ticket import DetailForm, TicketForm +from core.models.ticket.ticket import Ticket +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = TicketForm + + model = Ticket + permission_required = [ + 'itam.add_device', + ] + template_name = 'form.html.j2' + + + def get_initial(self): + return { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization, + 'type_ticket': self.kwargs['ticket_type'], + } + + def form_valid(self, form): + form.instance.is_global = False + return super().form_valid(form) + + + def get_success_url(self, **kwargs): + + if self.kwargs['ticket_type'] == 'request': + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.object.id,)) + + else: + + return reverse('ITIM:_ticket_' + str(self.kwargs['ticket_type']).lower() + '_view', args=(self.kwargs['ticket_type'],self.object.id,)) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'New Ticket' + + return context + + + +class Change(ChangeView): + + form_class = TicketForm + + model = Ticket + + permission_required = [ + 'itim.change_cluster', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = str(self.object) + + return context + + + def get_initial(self): + return { + 'type_ticket': self.kwargs['ticket_type'], + } + + + def get_success_url(self, **kwargs): + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'], self.kwargs['pk'],)) + + + +class Index(OrganizationPermission, generic.ListView): + + context_object_name = "tickets" + + fields = [ + "id", + 'title', + 'status', + 'date_created', + ] + + model = Ticket + + permission_required = [ + 'django_celery_results.view_taskresult', + ] + + template_name = 'core/ticket/index.html.j2' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + if self.kwargs['ticket_type'] == 'request': + + context['new_ticket_url'] = reverse('Assistance:_ticket_request_add', args=(self.kwargs['ticket_type'],)) + + else: + + context['new_ticket_url'] = reverse(str('ITIM:_ticket_' + self.kwargs['ticket_type'] + '_add'), args=(self.kwargs['ticket_type'],)) + + + context['ticket_type'] = self.kwargs['ticket_type'] + + context['content_title'] = 'Tickets' + + return context + + + def get_queryset(self): + + if not hasattr(Ticket.TicketType, str(self.kwargs['ticket_type']).upper()): + raise Http404 + + queryset = super().get_queryset() + + queryset = queryset.filter( + ticket_type = Ticket.TicketType[str(self.kwargs['ticket_type']).upper()] + ) + + return queryset + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_device_model_view', args=(self.kwargs['pk'],)) + + + +class View(ChangeView): + + model = Ticket + + permission_required = [ + 'itam.view_device', + ] + + template_name = 'core/ticket.html.j2' + + form_class = DetailForm + + context_object_name = "ticket" + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + + context['ticket_type'] = self.kwargs['ticket_type'] + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.verbose_name.replace(' ', '') + + # context['model_delete_url'] = reverse('ITAM:_device_delete', args=(self.kwargs['pk'],)) + + context['edit_url'] = reverse('Assistance:_ticket_request_change', args=(self.kwargs['ticket_type'], self.kwargs['pk'])) #/assistance/ticket/{{ ticket_type }}/{{ ticket.id }} + + context['content_title'] = self.object.title + + return context + + + def get_initial(self): + return { + 'type_ticket': self.kwargs['ticket_type'], + } diff --git a/app/core/views/ticket_comment.py b/app/core/views/ticket_comment.py new file mode 100644 index 000000000..1f486ffda --- /dev/null +++ b/app/core/views/ticket_comment.py @@ -0,0 +1,97 @@ +import markdown + +from django.urls import reverse +from django.views import generic + +from django_celery_results.models import TaskResult + +from access.mixin import OrganizationPermission + +from core.forms.ticket_comment import CommentForm, DetailForm +from core.models.ticket.ticket_comment import TicketComment +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = CommentForm + + model = TicketComment + permission_required = [ + 'itam.add_device', + ] + template_name = 'form.html.j2' + + + def get_initial(self): + + initial_values: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization, + 'type_ticket': self.kwargs['ticket_type'], + 'ticket': self.kwargs['ticket_id'], + } + + if 'comment_type' in self.request.GET: + + initial_values.update({ + 'qs_comment_type': self.request.GET['comment_type'] + }) + + if 'parent_id' in self.kwargs: + + initial_values.update({ + 'parent': self.kwargs['parent_id'] + }) + + return initial_values + + + def get_success_url(self, **kwargs): + + if self.kwargs['ticket_type'] == 'request': + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'])) + + return f"/ticket/" + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Ticket Comment' + + return context + + + +class Change(ChangeView): + + form_class = CommentForm + + model = TicketComment + + permission_required = [ + 'itim.change_cluster', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = str(self.object) + + return context + + + def get_initial(self): + return { + 'type_ticket': self.kwargs['ticket_type'], + } + + + def get_success_url(self, **kwargs): + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'], self.kwargs['ticket_id'],)) diff --git a/app/project-static/code.css b/app/project-static/code.css index c4b2fd9c9..5557a0149 100644 --- a/app/project-static/code.css +++ b/app/project-static/code.css @@ -4,7 +4,7 @@ span.linenos { color: inherit; background-color: transparent; padding-left: 5px; td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } .codehilite .hll { background-color: #ffffcc } -.codehilite { background: #f8f8f8; } +.codehilite { background: #f8f8f8; padding: 5px; border: 1px solid #ccc;} .codehilite .c { color: #3D7B7B; font-style: italic } /* Comment */ .codehilite .err { border: 1px solid #FF0000 } /* Error */ .codehilite .k { color: #008000; font-weight: bold } /* Keyword */ diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css new file mode 100644 index 000000000..06fd2537f --- /dev/null +++ b/app/project-static/ticketing.css @@ -0,0 +1,477 @@ + +#linked-tickets { + display: table; + padding: 0px; + margin: 0px; +} + +#linked-tickets .ticket { + display: inline-block; + line-height: 30px; + vertical-align: middle; + margin: 0px; + padding: 0px; +} + + +#linked-tickets .ticket svg{ + display: inline; + width: 20px; + height: 30px; + +} + + +#linked-tickets .icon { + display: inline-block; + width: 20px; + line-height: 30px; + vertical-align: middle; + padding: 0px; + margin: 0px; +} + +#linked-tickets .icon svg{ + display: inline; + width: 20px; + height: 30px; + +} + + +#linked-tickets .icon.icon-related svg{ + background-color: #afdbff; + border-radius: 10px; + fill: #0b91ff; + height: 20px; +} + +#linked-tickets .icon.icon-blocks svg{ + background-color: #fcd5b1; + border-radius: 10px; + fill: #e79b37; + height: 20px; +} + +#linked-tickets .icon.icon-blocked_by svg{ + background-color: #f3c6c6; + border-radius: 10px; + fill: #ff1c1c; + height: 20px; +} + + +#ticket-additional-data { + padding-right: 10px; + font-size: 12pt; +} + + +#ticket-additional-data div { + margin-top: 10px; + padding: 0px; +} + + +#ticket-content { + display: flex; + height: auto; + margin: 20px; + padding: 0px; + width: auto; +} + + +#ticket-content div { + display: inline-block; +} + + +#ticket-data { + margin: 0px; + padding: 0px; + width: 100%; +} + + +#ticket-data div { + display: block; +} + + +#ticket-description { + background-color: #fff; + border: 1px solid #ccc; + margin: 0px 10px 0px 0px; + padding: 10px; +} + + + +#data-block { + border: 1px solid #ccc; + padding: 0px; +} + + +#data-block h3 button { + float: right; + height: 20px; + margin-bottom: auto; + margin-top: auto; + + +} + + +#data-block div { + margin: 0px; + padding: 0px; + padding-left: 10px; +} + + +#data-block.linked-item div#item { + display: inline-block; + text-align: center; + width: 33%; +} + + +#ticket-comments { + padding: 10px; +} + + +#ticket-comments ul { + padding: 0px; + padding-left: 30px; + margin: 0px; +} + + +#ticket-comments li { + line-height: 30px; + margin: 0px; + margin-bottom: 30px; + padding-left: 10px; +} + + +#data-block h3 { + background-color: #177ee6; + color: #fff; + display: flex; + font-size: 16px; + height: 30px; + line-height: 30px; + margin: 0px; + padding-left: 5px; +} + + +#data-block h3 #text { + height: inherit; + line-height: inherit; + padding: 0px; + width: 100%; + +} + + +#data-block h3 #icons { + height: inherit; + line-height: inherit; + margin-right: 0px; + margin-left: auto; + text-align: right; + width: 200px; +} + +#data-block h3 #icons svg { + height: 30px; + margin: 0px; + margin-right: 5px; + width: 20px; + fill: #fff; +} + + +#ticket-comments #comment { + border: 1px solid #177ee6; +} + +#ticket-comments #comment h4 { + background-color: #177ee6; + border: none; + color: #fff; + display: flex; + font-size: 14px; + height: 30px; + line-height: 30px; + margin: 0px; + padding-left: 5px; +} + + +#comment h4 #text { + height: inherit; + line-height: inherit; + padding: 0px; + text-align: left; + width: 100%; +} + + +#comment h4 #icons { + border: none; + display: inline-block; + height: inherit; + line-height: inherit; + margin-right: 0px; + margin-left: auto; + text-align: right; + width: 200px; +} + + +#ticket-comments #comment h4 #icons svg { + fill: #fff; + height: 30px; + margin: 0px; + margin-right: 5px; + width: 20px; +} + + +#discussion { + margin: 0px; +} + + +#discussion h4 { + border-left: 1px solid #177fe66e; + border-right: 1px solid #177fe66e; + display: block; + margin: 0px; + margin-right: 0px; + margin-left: auto; + height: 30px; + line-height: inherit; + text-align: right; + padding: 0px; +} + + +#discussion svg { + width: 20px; + height: 30px; + margin: 0px; + margin-right: 5px; + margin-left: 5px; +} + + +#ticket-meta { + background-color: #fff; + width: 400px; + border: 1px solid #ccc; + margin-right: 0px; + margin-left: auto; + padding: 0px; +} + + +#ticket-meta h3 { + line-height: 30px; + height: 30px; + margin: 0px; + text-align: center; +} + + +#ticket-meta fieldset { + display: block; + margin: 10px; + line-height: 30px; + border: none; +} + + +#ticket-meta fieldset label { + width: 100%; + display: block; + line-height: inherit; + font-weight: bold; +} + + +#ticket-meta fieldset span { + width: 100%; + display: block; + line-height: inherit; + border: none; + border-bottom: 1px solid #ccc; +} + + +#ticket-meta h3.incident-ticket { + background-color: #f7baba; +} + + +#ticket-meta h3.request-ticket { + background-color: #f7e9ba; +} + + +#ticket-meta h3.change-ticket { + background-color: #badff7; +} + + +#ticket-meta h3.problem-ticket { + background-color: #f7d0ba; +} + + +#ticket-meta h3.issue-ticket { + background-color: #baf7db; +} + + +#ticket-meta h3.project_task-ticket { + background-color: #c5baf7; +} + + +.comment-type-default { + background-color: #fff; +} + + +.comment-type-Notification { + background-color: #96c7ff; +} + + +.comment-type-Solution { + background-color: #b7ff96; +} + + +.comment-type-Task { + background-color: #f8ff96; +} + + +#comment fieldset { + border: none; + display: inline-block; + line-height: 14pt; + width: 200px; + font-size: 10pt; +} + + +#comment fieldset label { + display: block; + font-weight: bold; + line-height: inherit; +} + + +#comment fieldset span { + display: block; + line-height: inherit; + +} + + +#comment hr { + border: none; + border-bottom: 1px solid #ccc; + margin: 0px 5px 0px 5px; +} + + +#ticket-content #markdown h1 { + background-color: inherit; + color: inherit; + font-size: 24px; + line-height: 24px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown h2 { + background-color: inherit; + color: inherit; + font-size: 20px; + line-height: 20px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown h3 { + background-color: inherit; + color: inherit; + font-size: 18px; + line-height: 18px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown h4 { + background-color: inherit; + color: #000; + font-size: 16px; + line-height: 16px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown h5 { + background-color: inherit; + color: #000; + font-size: 14px; + line-height: 14px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown li { + background-color: inherit; + font-size: 14px; + line-height: 25px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown p { + background-color: inherit; + font-size: 14px; + line-height: 25px; + padding: 0px; + margin: 0px; + text-align: left; + +} diff --git a/app/project_management/models/projects.py b/app/project_management/models/projects.py index 9424f36a3..ece5b7850 100644 --- a/app/project_management/models/projects.py +++ b/app/project_management/models/projects.py @@ -100,6 +100,11 @@ class Meta: ) + def __str__(self): + + return self.name + + @property def percent_completed(self) -> str: # Auto-Calculate """ How much of the project is completed. diff --git a/app/templates/icons/place-holder.svg b/app/templates/icons/place-holder.svg new file mode 100644 index 000000000..312a10e75 --- /dev/null +++ b/app/templates/icons/place-holder.svg @@ -0,0 +1 @@ + \ No newline at end of file