feat(core): New Model TicketBase

ref: #724 #723
This commit is contained in:
2025-04-16 20:59:36 +09:30
parent 1a5f328a24
commit e80647ffd1

View File

@ -0,0 +1,750 @@
import datetime
from django.contrib.auth.models import User
from django.db import models
from rest_framework.reverse import reverse
from access.fields import AutoCreatedField, AutoLastModifiedField
from access.models.entity import Entity
from access.models.tenancy import TenancyObject
from core import exceptions as centurion_exception
from core.classes.badge import Badge
from core.lib.feature_not_used import FeatureNotUsed
from core.lib.slash_commands import SlashCommands
from core.models.ticket.ticket_category import TicketCategory
from core.models.ticket.ticket_enum_values import TicketValues
from project_management.models.project_milestone import Project, ProjectMilestone
class TicketBase(
SlashCommands,
TenancyObject,
):
save_model_history: bool = False
class Ticket_ExternalSystem(models.IntegerChoices): # <null|github|gitlab>
GITHUB = TicketValues.ExternalSystem._GITHUB_INT, TicketValues.ExternalSystem._GITHUB_VALUE
GITLAB = TicketValues.ExternalSystem._GITLAB_INT, TicketValues.ExternalSystem._GITLAB_VALUE
CUSTOM_1 = TicketValues.ExternalSystem._CUSTOM_1_INT, TicketValues.ExternalSystem._CUSTOM_1_VALUE
CUSTOM_2 = TicketValues.ExternalSystem._CUSTOM_2_INT, TicketValues.ExternalSystem._CUSTOM_2_VALUE
CUSTOM_3 = TicketValues.ExternalSystem._CUSTOM_3_INT, TicketValues.ExternalSystem._CUSTOM_3_VALUE
CUSTOM_4 = TicketValues.ExternalSystem._CUSTOM_4_INT, TicketValues.ExternalSystem._CUSTOM_4_VALUE
CUSTOM_5 = TicketValues.ExternalSystem._CUSTOM_5_INT, TicketValues.ExternalSystem._CUSTOM_5_VALUE
CUSTOM_6 = TicketValues.ExternalSystem._CUSTOM_6_INT, TicketValues.ExternalSystem._CUSTOM_6_VALUE
CUSTOM_7 = TicketValues.ExternalSystem._CUSTOM_7_INT, TicketValues.ExternalSystem._CUSTOM_7_VALUE
CUSTOM_8 = TicketValues.ExternalSystem._CUSTOM_8_INT, TicketValues.ExternalSystem._CUSTOM_8_VALUE
CUSTOM_9 = TicketValues.ExternalSystem._CUSTOM_9_INT, TicketValues.ExternalSystem._CUSTOM_9_VALUE
class TicketStatus(models.IntegerChoices): # <draft|open|closed|in progress|assigned|solved|invalid>
""" Ticket Status
To add more status', override this class within your sub-model
"""
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
# DUPLICATE =
INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR
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 = TicketValues.Priority._VERY_LOW_INT, TicketValues.Priority._VERY_LOW_VALUE
LOW = TicketValues.Priority._LOW_INT, TicketValues.Priority._LOW_VALUE
MEDIUM = TicketValues.Priority._MEDIUM_INT, TicketValues.Priority._MEDIUM_VALUE
HIGH = TicketValues.Priority._HIGH_INT, TicketValues.Priority._HIGH_VALUE
VERY_HIGH = TicketValues.Priority._VERY_HIGH_INT, TicketValues.Priority._VERY_HIGH_VALUE
MAJOR = TicketValues.Priority._MAJOR_INT, TicketValues.Priority._MAJOR_VALUE
class TicketSource(models.IntegerChoices):
"""Source of the comment"""
DIRECT = '1', 'Direct'
EMAIL = '2', 'E-Mail'
HELPDESK = '3', 'Helpdesk'
PHONE = '4', 'Phone'
SERVICE_CATALOG = '5', 'Service Catalog'
SMS = '6', 'SMS Message'
class Meta:
ordering = [
'id'
]
unique_together = ('external_system', 'external_ref',)
verbose_name = "Ticket"
verbose_name_plural = "Tickets"
def validate_not_null(field):
if field is None:
return False
return True
id = models.AutoField(
blank = False,
help_text = 'Ticket ID Number',
primary_key = True,
unique = True,
verbose_name = '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',
)
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
parent_ticket = models.ForeignKey(
'self',
blank = True,
default = None,
help_text = 'Parent of this ticket',
null = True,
on_delete = models.PROTECT,
verbose_name = 'Parent Ticket'
)
model_notes = None
is_global = None
@property
def get_ticket_type(self):
"""Fetch the Ticket Type
You can safely override this function as long as it's called or the
logic is included in your over-ridden function.
Returns:
str: The models `Meta.verbose_name` in lowercase and without spaces
None: The ticket is for the Base class. Used to prevent creating a base ticket.
"""
ticket_type = str(self.Meta.verbose_name).lower().replace(' ', '_')
if ticket_type == 'ticket':
return None
return ticket_type
ticket_type = models.CharField(
blank = True,
default = Meta.verbose_name.lower().replace(' ', '_'),
help_text = 'Ticket Type. (derived from ticket model)',
max_length = 30,
null = False,
validators = [
validate_not_null
],
verbose_name = 'Ticket Type',
)
status = models.IntegerField( # will require validation by ticket type as status for types will be different
blank = False,
choices = TicketStatus,
default = TicketStatus.NEW,
help_text = 'Status of ticket',
null = False,
verbose_name = 'Status',
)
@property
def status_badge(self):
text:str = 'Add'
if self.status:
text:str = str(self.get_status_display())
style:str = text.replace('(', '')
style = style.replace(')', '')
style = style.replace(' ', '_')
return Badge(
icon_name = f'ticket_status_{style.lower()}',
icon_style = f'ticket-status-icon ticket-status-icon-{style.lower()}',
text = text,
text_style = f'ticket-status-text badge-text-ticket_status-{style.lower()}',
)
category = models.ForeignKey(
TicketCategory,
blank = True,
help_text = 'Category for this ticket',
null = True,
on_delete = models.PROTECT,
verbose_name = 'Category',
)
title = models.CharField(
blank = False,
help_text = "Title of the Ticket",
max_length = 150,
unique = True,
verbose_name = 'Title',
)
description = models.TextField(
blank = True,
help_text = 'Description for the ticket.',
null = True,
verbose_name = 'Description',
) # text, markdown
private = models.BooleanField(
blank = False,
default = False,
help_text = 'Is this ticket private',
null = False,
verbose_name = 'Private',
)
@property
def ticket_duration(self) -> int:
comments = self.get_comments()
duration = comments.aggregate(models.Sum('duration'))['duration__sum']
if duration is None:
duration = 0
return str(duration)
@property
def ticket_estimation(self) -> int:
comments = self.get_comments()
estimation = comments.aggregate(models.Sum('estimation'))['estimation__sum']
if estimation is None:
estimation = 0
return str(estimation)
project = models.ForeignKey(
Project,
blank= True,
help_text = 'Assign to a project',
null = True,
on_delete = models.PROTECT,
verbose_name = 'Project',
)
milestone = models.ForeignKey(
ProjectMilestone,
blank = True,
help_text = 'Assign to a milestone',
null = True,
on_delete = models.PROTECT,
verbose_name = 'Project Milestone',
)
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',
)
@property
def urgency_badge(self):
if self.urgency is None:
return None
text = self.get_urgency_display()
return Badge(
icon_name = 'circle',
icon_style = f"status {text.lower().replace(' ', '-')}",
text = text,
text_style = '',
)
impact = models.IntegerField(
blank = True,
choices=TicketImpact,
default=TicketImpact.VERY_LOW,
help_text = 'End user assessed impact',
null=True,
verbose_name = 'Impact',
)
@property
def impact_badge(self):
if self.impact is None:
return None
text = self.get_impact_display()
return Badge(
icon_name = 'circle',
icon_style = f"status {text.lower().replace(' ', '-')}",
text = text,
text_style = '',
)
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',
)
@property
def priority_badge(self):
if self.priority is None:
return None
text = self.get_priority_display()
return Badge(
icon_name = 'circle',
icon_style = f"status {text.lower().replace(' ', '-')}",
text = text,
text_style = '',
)
opened_by = models.ForeignKey(
User,
blank = False,
help_text = 'Who is the ticket for',
null = False,
on_delete = models.PROTECT,
related_name = 'ticket_opened',
verbose_name = 'Opened By',
)
subscribed_to = models.ManyToManyField(
Entity,
blank = True,
help_text = 'Users / Groups subscribed to the ticket',
# on_delete = models.PROTECT,
related_name = 'ticket_subscription',
symmetrical = False,
verbose_name = 'Users / Groups Subscribed',
)
assigned_to = models.ManyToManyField(
Entity,
blank= True,
help_text = 'Users / Groups assigned to the ticket',
# on_delete = models.PROTECT,
related_name = 'ticket_assigned',
symmetrical = False,
verbose_name = 'Users / Groups Assigned',
)
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',
)
is_deleted = models.BooleanField(
blank = True,
default = False,
help_text = 'Is the ticket deleted? And ready to be purged',
null = False,
verbose_name = 'Deleted',
)
is_solved = models.BooleanField(
blank = True,
default = False,
help_text = 'Is this ticket solved?',
null = False,
verbose_name = 'Solved',
)
date_solved = models.DateTimeField(
blank = True,
help_text = 'Date ticket solved',
null = True,
verbose_name = 'Solved Date',
) # update everytime the ticket is solved.
is_closed = models.BooleanField(
blank = True,
default = False,
help_text = 'Is this ticket closed?',
null = False,
verbose_name = 'Closed',
)
date_closed = models.DateTimeField(
blank = True,
help_text = 'Date ticket closed',
null = True,
verbose_name = 'Closed Date',
)
created = AutoCreatedField(
editable = True,
)
modified = AutoLastModifiedField()
# this model uses a custom page layout
page_layout: list = []
table_fields: list = [
'id',
'title',
'status_badge',
'priority_badge',
'impact_badge',
'urgency_badge',
'opened_by',
'organization',
'created'
]
def __str__(self):
return self.title
def clean(self):
"""Model Validation
Raises:
centurion_exception.ValidationError: Milestone project does not
match the project assigned to the ticket.
centurion_exception.ValidationError: Tried to solve a ticket when
there are unresolved ticket comments.
"""
if self.milestone:
if self.milestone.project != self.project:
raise centurion_exception.ValidationError(
detail = {
'milestone': f'Milestone is from project {self.milestone.project} when it should be from project {self.project}.'
},
code = 'milestone_different_project'
)
if self.is_solved:
self.get_can_resolve( raise_exceptions = True )
if self.is_closed:
self.get_can_close( raise_exceptions = True )
def get_can_close(self, raise_exceptions = False ) -> bool:
if not self.is_solved and not raise_exceptions:
return False
elif not self.is_solved and raise_exceptions:
raise centurion_exception.ValidationError(
detail = {
'status': 'You cant close this ticket.'
},
code = 'ticket_close_failed_validation'
)
return True
def get_can_resolve(self, raise_exceptions = False ) -> bool:
ticket_comments = self.get_comments( include_threads = True )
if self.is_solved:
for comment in ticket_comments:
if not comment.is_closed and not raise_exceptions:
return False
elif not comment.is_closed and raise_exceptions:
raise centurion_exception.ValidationError(
detail = {
'status': 'You cant solved a ticket when there are un-resolved comments.'
},
code = 'resolution_with_un_resolved_comment_denied'
)
return True
def get_comments(self, include_threads = False):
if hasattr(self, '_ticket_comments'):
return self._ticket_comments
from core.models.ticket_comment_base import TicketCommentBase
if include_threads:
self._ticket_comments = TicketCommentBase.objects.filter(
ticket = self.id,
).order_by('created')
else:
self._ticket_comments = TicketCommentBase.objects.filter(
ticket = self.id,
parent = None,
).order_by('created')
return self._ticket_comments
def get_related_field_name(self) -> str:
meta = getattr(self, '_meta')
for related_object in getattr(meta, 'related_objects', []):
if not issubclass(related_object.related_model, TicketBase):
continue
if getattr(self, related_object.name, None):
if(
not str(related_object.name).endswith('history')
and not str(related_object.name).endswith('notes')
):
return related_object.name
break
return ''
def get_related_model(self):
"""Recursive model Fetch
Returns the lowest model found in a chain of inherited models.
Args:
model (models.Model, optional): Model to fetch the child model from. Defaults to None.
Returns:
models.Model: Lowset model found in inherited model chain
"""
related_model_name = self.get_related_field_name()
related_model = getattr(self, related_model_name, None)
if related_model_name == '':
related_model = None
elif related_model is None:
related_model = self
elif hasattr(related_model, 'get_related_field_name'):
if related_model.get_related_field_name() != '':
related_model = related_model.get_related_model()
return related_model
def get_url( self, request = None ) -> str:
ticket_type = self.ticket_type
kwargs = self.get_url_kwargs()
if ticket_type == 'project_task':
kwargs.update({
'project_id': self.project.id
})
if request:
return reverse(f"v2:_api_v2_ticket_sub-detail", request=request, kwargs = kwargs )
return reverse(f"v2:_api_v2_ticket_sub-detail", kwargs = kwargs )
def get_url_kwargs(self) -> dict:
model = self.get_related_model()
if len(self._meta.parents) == 0 and model is None:
return {
'pk': self.id
}
if model is None:
model = self
kwargs = {
'ticket_model': str(model._meta.verbose_name).lower().replace(' ', '_'),
}
if model.pk:
kwargs.update({
'pk': model.id
})
return kwargs
def get_url_kwargs_notes(self):
return FeatureNotUsed
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
related_model = self.get_related_model()
if related_model is None:
related_model = self
if self.ticket_type != str(related_model._meta.verbose_name).lower().replace(' ', '_'):
self.ticket_type = str(related_model._meta.verbose_name).lower().replace(' ', '_')
if self.date_solved is None and self.is_solved:
self.date_solved = datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat()
if self.date_closed is None and self.is_closed:
self.date_closed = datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat()
super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)