feat(core): add basic ticketing system

ref: #250 #252 #96 #93 #95 #90 #115
This commit is contained in:
2024-08-25 17:45:55 +09:30
parent 52db44eac7
commit c5a5c393a8
33 changed files with 2865 additions and 4 deletions

View File

@ -24,7 +24,7 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
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 @@ urlpatterns = [
path("history/<str:model_name>/<int:model_pk>", history.View.as_view(), name='_history'),
re_path(r'^static/(?P<path>.*)$', serve,{'document_root': settings.STATIC_ROOT}),
path('ticket/<str:ticket_type>/<int:ticket_id>/relate/add', related_ticket.Add.as_view(), name="_ticket_related_add"),
]

View File

@ -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

139
app/core/forms/ticket.py Normal file
View File

@ -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__'

View File

@ -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)

View File

@ -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),
),
]

View File

View File

@ -0,0 +1 @@
from . import *

View File

@ -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 TicketCommonFields(models.Model):
class Ticket(
TenancyObject,
TicketCommonFields,
ChangeTicket,
ProblemTicket,
RequestTicket,
TicketMarkdown,
):
@ -35,3 +132,624 @@ class Ticket(
'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): # <null|github|gitlab>
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: # <draft|open|closed|in progress|assigned|solved|invalid>
""" 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): # <null|github|gitlab>
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',
)

View File

@ -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
)

View File

@ -0,0 +1,142 @@
{% extends 'base.html.j2' %}
{% block additional-stylesheet %}
{% load static %}
<link rel="stylesheet" href="{% static 'ticketing.css' %}">
{% endblock additional-stylesheet %}
{% load tickets %}
{% block article %}
<div id="ticket-content">
<div id="ticket-data">
<div id="ticket-description">
<div style="/*background-color:yellow;*/ align-items:center; height: 30px; padding: 0px;">
<input style="float: right; position: relative; margin: 0px;" type="button" value="Edit" onclick="window.location='{{ edit_url }}';">
</div>
<div id="markdown" style="padding: 0px; margin: 0px">{{ ticket.description | markdown | safe }}</div>
</div>
<div id="ticket-additional-data">
<div id="data-block">
<h3>
<div id="text">Related Tickets</div>
<div id="icons">
<a href="{% url '_ticket_related_add' ticket_type=ticket_type ticket_id=ticket.id %}">{% include 'icons/ticket/add.svg' %}</a>
</div>
</h3>
{% if ticket.related_tickets %}
{% for related_ticket in ticket.related_tickets %}
<div id="linked-tickets">
<div class="icon icon-{{ related_ticket.how_related }}">{% include related_ticket.icon_filename %}</div>
<div class="ticket">
{% 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 %}
&nbsp;
{{ related_ticket.title }}
</div>
</div>
{% endfor %}
{% else %}
<div>Nothing Found</div>
{% endif %}
</div>
<div id="data-block" class="linked-item">
<h3>
<div id="text">Linked Items</div>
<div id="icons">{% include 'icons/place-holder.svg' %}{% include 'icons/place-holder.svg' %}</div>
</h3>
<div id="item">
An item
</div>
<div id="item">
another item
</div>
<div id="item">
another item
</div>
<div id="item">
another item
</div>
<div id="item">
another item
</div>
<div id="item">
another item
</div>
</div>
</div>
{% include 'core/ticket/comment.html.j2' %}
</div>
<div id="ticket-meta">
<h3 class="{{ ticket_type }}-ticket">{{ ticket_type }}</h3>
<fieldset>
<label>Assigned</label>
<span>
{% 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 %}
</span>
</fieldset>
<fieldset>
<label>Status</label>
<span>{{ticket.get_status_display }}</span>
</fieldset>
<fieldset>
<label>Labels</label>
<span>val</span>
</fieldset>
<fieldset>
<label>Project</label>
<span>
{% if ticket.project %}
<a href="{% url 'Project Management:_project_view' pk=ticket.project_id %}">{{ ticket.project }}</a>
{% else %}
-
{% endif %}
</span>
</fieldset>
<fieldset>
<label>Priority</label>
<span>U{{ ticket.get_urgency_display }} / I{{ ticket.get_impact_display }} / P{{ ticket.get_priority_display }}</span>
</fieldset>
<fieldset>
<label>Milestone</label>
<span>val</span>
</fieldset>
<fieldset>
<label>Roadmap(s)</label>
<span>val</span>
</fieldset>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,55 @@
<div id="ticket-comments">
<ul>
<li>John smith add x as related to this ticket</li>
{% for comment in ticket.comments %}
<li>
{% include 'core/ticket/comment/comment.html.j2' %}
{% if comment.threads %}
<div id="discussion" style="/*background-color: #fff;*/">
<h4 style="display: flex; padding-left: 5px;">
Replies
{% include 'icons/ticket/expanded.svg' %}
</h4>
</div>
<div style="padding-left: 40px; border-left: 1px solid #177fe66e; border-bottom: 1px solid #177fe66e;">
{% if comment.threads %}
{% for thread in comment.threads %}
{% include 'core/ticket/comment/comment.html.j2' with comment=thread %}
{% endfor %}
{% endif %}
<div >
<div style="padding: 10px; padding-top: 10px">
<input type="button" value="Comment" onclick="window.location='{% url 'Assistance:_ticket_comment_request_reply_add' ticket_type ticket.id comment.id %}?comment_type=comment';">
<input type="button" value="Task" onclick="window.location='{% url 'Assistance:_ticket_comment_request_reply_add' ticket_type ticket.id comment.id %}?comment_type=task';">
</div>
</div>
{% endif %}
</li>
{% endfor %}
<li class="mention">Jane smith mentioned this ticket in xx</li>
<li class="mention">sdasfdgdfgdfg dfg dfg dfg d</li>
</ul>
<div id="comment" style="padding: 20px;">
<input type="button" value="Comment" onclick="window.location='{% url 'Assistance:_ticket_comment_request_add' ticket_type ticket.id %}?comment_type=comment';">
<input type="button" value="Task" onclick="window.location='{% url 'Assistance:_ticket_comment_request_add' ticket_type ticket.id %}?comment_type=task';">
<input type="button" value="Notification" onclick="window.location='{% url 'Assistance:_ticket_comment_request_add' ticket_type ticket.id %}?comment_type=notification';">
<input type="button" value="Resolve" onclick="window.location='{% url 'Assistance:_ticket_comment_request_add' ticket_type ticket.id %}?comment_type=solution';">
</div>
</div>

View File

@ -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' %}
<div id="comment" class="comment-type-default comment-type-{{ comment.get_comment_type_display }}">
<h4>
<div id="text">
{{ 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 }}
</div>
<div id="icons">
{%if not comment.parent_id %}
<a title="Reply with Comment" href="{% url 'Assistance:_ticket_comment_request_reply_add' ticket_type ticket.id comment.id %}?comment_type=comment">
{% include 'icons/ticket/reply.svg' %}
</a>
<a title="Reply with Task" href="{% url 'Assistance:_ticket_comment_request_reply_add' ticket_type ticket.id comment.id %}?comment_type=task">
{% include 'icons/ticket/task.svg' %}
</a>
<a title="Reply with Notification" href="{% url 'Assistance:_ticket_comment_request_reply_add' ticket_type ticket.id comment.id %}?comment_type=notification">
{% include 'icons/ticket/notification.svg' %}
</a>
{% endif %}
<a title="Edit Comment" href="{% url 'Assistance:_ticket_comment_request_change' ticket_type ticket.id comment.id %}">
{% include 'icons/ticket/edit.svg' %}
</a>
</div>
</h4>
<div style="line-height:30px;">
{% if comment.get_comment_type_display != 'Notification' %}
<fieldset>
<label>Source</label>
<span>{{ comment.get_source_display }}</span>
</fieldset>
{% endif %}
{% if comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' %}
<fieldset>
<label>Status</label>
<span>{{ comment.get_status_display }}</span>
</fieldset>
{% if comment.get_comment_type_display == 'Task' %}
<fieldset>
<label>Responsible User</label>
<span>{{ comment.responsible_user }}</span>
</fieldset>
{% endif %}
<fieldset>
<label>
{% if comment.get_comment_type_display == 'Task' %}
Responsible Team
{% elif comment.get_comment_type_display == 'Notification' %}
Notify Team
{% endif %}
</label>
<span>{{ comment.responsible_team }}</span>
</fieldset>
{% endif %}
<fieldset>
<label>Category</label>
<span>{{ comment.category }}</span>
</fieldset>
</div>
<hr />
<div id="markdown" style="margin: 15px; padding: 10px; background-color: #fff;">
{{ comment.body | markdown | safe }}
</div>
<hr />
<div>
{% if comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' %}
<fieldset>
<label>Planned Start</label>
<span>{{ comment.planned_start_date }}</span>
</fieldset>
{% if comment.get_comment_type_display == 'Task' %}
<fieldset>
<label>Planned Finish</label>
<span>{{ comment.planned_finish_date }}</span>
</fieldset>
{% endif %}
<fieldset>
<label>Actual Start</label>
<span>{{ comment.real_start_date }}</span>
</fieldset>
<fieldset>
<label>Actual Finish</label>
<span>{{ comment.real_finish_date }}</span>
</fieldset>
{% endif %}
<fieldset>
<label>Duration</label>
<span>{{ comment.duration }}</span>
</fieldset>
</div>
</div>
{% endif %}
{% endif %}

View File

@ -0,0 +1,55 @@
{% extends 'base.html.j2' %}
{% block content %}
<input type="button" value="New Ticket" onclick="window.location='{{ new_ticket_url }}';">
<style>
#status-icon {
margin: 0px;
}
#status-icon svg{
width: 22px;
}
</style>
<table style="max-width: 100%;">
<thead>
<th>&nbsp;</th>
<th>ID</th>
<th>Title</th>
<th>Status</th>
<th>Created</th>
</thead>
{% for ticket in tickets %}
<tr class="clicker">
<td id="status-icon">
&nbsp;
</td>
<td>{{ ticket.id }}</td>
<td>
{% if ticket_type == 'change' %}
<a href="{% url 'ITIM:_ticket_change_view' ticket_type='change' pk=ticket.id %}">
{% elif ticket_type == 'incident' %}
<a href="{% url 'ITIM:_ticket_incident_view' ticket_type='incident' pk=ticket.id %}">
{% elif ticket_type == 'problem' %}
<a href="{% url 'ITIM:_ticket_problem_view' ticket_type='problem' pk=ticket.id %}">
{% elif ticket_type == 'request' %}
<a href="{% url 'Assistance:_ticket_request_view' ticket_type='request' pk=ticket.id %}">
{% else %}
<a href=""></a>
{% endif %}
{{ ticket.title }}
</a>
</td>
<td>{{ ticket.get_status_display }}</td>
<td>{{ ticket.created }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M440-280h80v-160h160v-80H520v-160h-80v160H280v80h160v160Zm40 200q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-200h57l391-391-57-57-391 391v57Zm-80 80v-170l528-527q12-11 26.5-17t30.5-6q16 0 31 6t26 18l55 56q12 11 17.5 26t5.5 30q0 16-5.5 30.5T817-647L290-120H120Zm640-584-56-56 56 56Zm-141 85-28-29 57 57-29-28Z"/></svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M480-345 240-585l56-56 184 183 184-183 56 56-240 240Z"/></svg>

After

Width:  |  Height:  |  Size: 136 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M448-201.33h66.67V-391l76 76 46.66-47L480-516.67l-156 156L371-314l77-77v189.67ZM226.67-80q-27 0-46.84-19.83Q160-119.67 160-146.67v-666.66q0-27 19.83-46.84Q199.67-880 226.67-880H574l226 226v507.33q0 27-19.83 46.84Q760.33-80 733.33-80H226.67Zm314-542.67v-190.66h-314v666.66h506.66v-476H540.67Zm-314-190.66v190.66-190.66 666.66-666.66Z"/></svg>

After

Width:  |  Height:  |  Size: 415 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M760-200v-160q0-50-35-85t-85-35H273l144 144-57 56-240-240 240-240 57 56-144 144h367q83 0 141.5 58.5T840-360v160h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m435.33-250 228-228L618-523.33l-183 183L338.33-437l-45 45 142 142ZM226.67-80q-27 0-46.84-19.83Q160-119.67 160-146.67v-666.66q0-27 19.83-46.84Q199.67-880 226.67-880H574l226 226v507.33q0 27-19.83 46.84Q760.33-80 733.33-80H226.67Zm314-542.67v-190.66h-314v666.66h506.66v-476H540.67Zm-314-190.66v190.66-190.66 666.66-666.66Z"/></svg>

After

Width:  |  Height:  |  Size: 402 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M320-320h320v-320H320v320ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 394 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M360-320h80v-320h-80v320Zm160 0h80v-320h-80v320ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M240-400h80q0-59 43-99.5T466-540q36 0 67 16.5t51 43.5h-64v80h200v-200h-80v62q-32-38-76.5-60T466-620q-95 0-160.5 64T240-400ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 491 B

View File

@ -9,4 +9,4 @@ register = template.Library()
@register.filter()
@stringfilter
def markdown(value):
return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite'])
return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite'])

View File

@ -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'])

View File

@ -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

View File

@ -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

View File

@ -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')

188
app/core/views/ticket.py Normal file
View File

@ -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'],
}

View File

@ -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'],))

View File

@ -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 */

View File

@ -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;
}

View File

@ -100,6 +100,11 @@ class Project(ProjectCommonFieldsName):
)
def __str__(self):
return self.name
@property
def percent_completed(self) -> str: # Auto-Calculate
""" How much of the project is completed.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#EA3323"><path d="M256-200h447l-84-84q-29 21-64.5 32.5T480-240q-39 0-74.5-12T341-285l-85 85Zm-56-57 84-84q-21-29-32.5-64.5T240-480q0-39 12-74.5t33-64.5l-85-85v447Zm142-142 82-81-82-81q-11 18-16.5 38t-5.5 43q0 23 5.5 43t16.5 38Zm138 79q23 0 43-5.5t38-16.5l-81-82-82 82q18 11 38.5 16.5T480-320Zm0-217 81-81q-18-11-38-16.5t-43-5.5q-23 0-43 5.5T399-618l81 81Zm138 138q11-18 16.5-38t5.5-43q0-23-5.5-43.5T618-562l-81 81 81 82Zm142 142v-447l-85 85q21 29 33 64.5t12 74.5q0 39-11.5 74.5T676-341l84 84ZM619-675l85-85H257l84 84q29-21 64.5-32.5T480-720q39 0 74.5 12t64.5 33ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Z"/></svg>

After

Width:  |  Height:  |  Size: 761 B