test(core): Prevent Closing / Solving of TicketBase Model if not ready

ref: #734 #723 closes #325
This commit is contained in:
2025-05-04 00:00:36 +09:30
parent 8c84ff7c52
commit c773fbc3a5
6 changed files with 354 additions and 48 deletions

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1.8 on 2025-05-03 17:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0028_alter_ticketcommentsolution_options'),
]
operations = [
migrations.AlterField(
model_name='ticketbase',
name='parent_ticket',
field=models.ForeignKey(blank=True, help_text='Parent of this ticket', null=True, on_delete=django.db.models.deletion.PROTECT, to='core.ticketbase', verbose_name='Parent Ticket'),
),
]

View File

@ -139,6 +139,7 @@ class TicketBase(
external_system = models.IntegerField(
blank = True,
choices = Ticket_ExternalSystem,
default = None,
help_text = 'External system this item derives',
null = True,
verbose_name = 'External System',
@ -146,6 +147,7 @@ class TicketBase(
external_ref = models.IntegerField(
blank = True,
default = None,
help_text = 'External System reference',
null = True,
verbose_name = 'Reference Number',
@ -591,11 +593,23 @@ class TicketBase(
def get_can_close(self, raise_exceptions = False ) -> bool:
if not self.is_solved and not raise_exceptions:
if(
(
not self.get_can_resolve( raise_exceptions = False)
or not self.is_solved
)
and not raise_exceptions
):
return False
elif not self.is_solved and raise_exceptions:
elif(
(
not self.get_can_resolve( raise_exceptions = False)
or not self.is_solved
)
and raise_exceptions
):
raise centurion_exception.ValidationError(
detail = {
@ -611,22 +625,25 @@ class TicketBase(
ticket_comments = self.get_comments( include_threads = True )
if self.is_solved:
for comment in ticket_comments:
for comment in ticket_comments:
if self.status == self.TicketStatus.INVALID:
if not comment.is_closed and not raise_exceptions:
return True
return False
elif not comment.is_closed and raise_exceptions:
if not comment.is_closed and not 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 False
elif not comment.is_closed and raise_exceptions:
raise centurion_exception.ValidationError(
detail = {
'status': 'You cant solve a ticket when there are un-resolved comments.'
},
code = 'resolution_with_un_resolved_comment_denied'
)
return True
@ -782,10 +799,22 @@ class TicketBase(
self.ticket_type = str(related_model._meta.sub_model_type).lower().replace(' ', '_')
if(
(
self.status == self.TicketStatus.SOLVED
and self.get_can_resolve( raise_exceptions = False )
)
or self.status == self.TicketStatus.INVALID
):
self.is_solved = True
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()

View File

@ -254,20 +254,6 @@ class TicketCommentBase(
code = 'ticket_closed_no_date'
)
try:
self.ticket.get_can_resolve(raise_exceptions = True)
except centurion_exception.ValidationError as err:
raise centurion_exception.ValidationError(
detail = {
'body': err.detail['status']
},
code = err.code
)
if self.comment_type != self._meta.sub_model_type:

View File

@ -31,6 +31,8 @@ class TicketCommentSolution(
def clean(self):
super().clean()
if self.ticket.is_solved:
raise centurion_exception.ValidationError(
@ -38,8 +40,20 @@ class TicketCommentSolution(
code = 'ticket_already_solved'
)
try:
self.ticket.get_can_resolve(raise_exceptions = True)
except centurion_exception.ValidationError as err:
raise centurion_exception.ValidationError(
detail = {
'body': err.detail['status']
},
code = err.code
)
super().clean()
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):

View File

@ -53,12 +53,14 @@ class TicketBaseModelTestCases(
},
"external_system": {
'field_type': models.fields.IntegerField,
'field_parameter_default_exists': False,
'field_parameter_default_exists': True,
'field_parameter_default_value': None,
'field_parameter_verbose_name_type': str,
},
"external_ref": {
'field_type': models.fields.IntegerField,
'field_parameter_default_exists': False,
'field_parameter_default_exists': True,
'field_parameter_default_value': None,
'field_parameter_verbose_name_type': str
},
"parent_ticket": {
@ -544,14 +546,145 @@ class TicketBaseModelTestCases(
assert type(self.model().get_can_close()) is bool
def test_function_get_can_close_value_false(self):
@pytest.fixture( scope = 'function' )
def ticket(self, db, model):
kwargs = self.kwargs_create_item.copy()
kwargs['title'] = 'can close ticket'
ticket = self.model.objects.create(
**kwargs,
status = self.model._meta.get_field('status').default,
)
yield ticket
if ticket.pk is not None:
ticket.delete()
@pytest.fixture( scope = 'function' )
def ticket_comment(self, db, ticket):
comment = TicketCommentBase.objects.create(
ticket = ticket,
body = 'comment body',
comment_type = TicketCommentBase._meta.sub_model_type,
)
yield comment
if comment.pk is not None:
comment.delete()
values_function_get_can_close = [
('no_comments_default_status', False, None, True, None, False),
('no_comments_set_draft', False, None, True, TicketBase.TicketStatus.DRAFT, False),
('no_comments_set_new', False, None, True, TicketBase.TicketStatus.NEW, False),
('no_comments_set_assigned', False, None, True, TicketBase.TicketStatus.ASSIGNED, False),
('no_comments_set_assigned_planning', False, None, True, TicketBase.TicketStatus.ASSIGNED_PLANNING, False),
('no_comments_set_pending', False, None, True, TicketBase.TicketStatus.PENDING, False),
('no_comments_set_solved', False, None, True, TicketBase.TicketStatus.SOLVED, True),
('no_comments_set_invalid', False, None, True, TicketBase.TicketStatus.INVALID, True),
('comment_closed_default_status', True, True, True, True, False),
('comment_closed_set_draft', True, True, True, TicketBase.TicketStatus.DRAFT, False),
('comment_closed_set_new', True, True, True, TicketBase.TicketStatus.NEW, False),
('comment_closed_set_assigned', True, True, True, TicketBase.TicketStatus.ASSIGNED, False),
('comment_closed_set_assigned_planning', True, True, True, TicketBase.TicketStatus.ASSIGNED_PLANNING, False),
('comment_closed_set_pending', True, True, True, TicketBase.TicketStatus.PENDING, False),
('comment_closed_set_solved', True, True, True, TicketBase.TicketStatus.SOLVED, True),
('comment_closed_set_invalid', True, True, True, TicketBase.TicketStatus.INVALID, True),
('comment_not_closed_default_status', True, False, False, None, False),
('comment_not_closed_set_draft', True, False, False, TicketBase.TicketStatus.DRAFT, False),
('comment_not_closed_set_new', True, False, False, TicketBase.TicketStatus.NEW, False),
('comment_not_closed_set_assigned', True, False, False, TicketBase.TicketStatus.ASSIGNED, False),
('comment_not_closed_set_assigned_planning', True, False, False, TicketBase.TicketStatus.ASSIGNED_PLANNING, False),
('comment_not_closed_set_pending', True, False, False, TicketBase.TicketStatus.PENDING, False),
('comment_not_closed_set_solved', True, False, False, TicketBase.TicketStatus.SOLVED, False),
('comment_not_closed_set_invalid', True, False, True, TicketBase.TicketStatus.INVALID, True),
]
@pytest.mark.parametrize(
argnames = [
'name',
'param_has_comment',
'param_comment_is_closed',
'expected_value_solve',
'param_ticket_status',
'expected_value_close',
],
argvalues = values_function_get_can_close,
ids = [
name +'_'+ str(param_has_comment).lower() +'_'+ str(param_ticket_status).lower() +'_'+str(expected_value_close).lower() for
name,
param_has_comment,
param_comment_is_closed,
expected_value_solve,
param_ticket_status,
expected_value_close,
in values_function_get_can_close
]
)
def test_function_get_can_close(self, ticket_comment,
name,
param_has_comment,
param_comment_is_closed,
expected_value_solve,
param_ticket_status,
expected_value_close,
):
"""Function test
Ensure that function `get_can_close` returns a value of `False` when
the ticket can not be closed
Ensure that function `get_can_close` works as intended:
- can't close ticket with unresolved comments
- can't close ticket when ticket not solved
- can close ticket with no comments when ticket solved.
- can close ticket if status invalid regardless of comment status
"""
assert self.model().get_can_close() == False
ticket = ticket_comment.ticket
if param_has_comment:
if param_comment_is_closed is not None:
ticket_comment.is_closed = param_comment_is_closed
if type(param_comment_is_closed) is bool and param_comment_is_closed:
ticket_comment.date_closed = datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat()
ticket_comment.save()
else:
ticket_comment.delete()
if param_ticket_status is not None:
try:
ticket.status = param_ticket_status
ticket.save()
except centurion_exceptions.ValidationError:
pass
assert ticket.get_can_close() == expected_value_close
@ -564,15 +697,78 @@ class TicketBaseModelTestCases(
assert type(self.model().get_can_resolve()) is bool
@pytest.mark.skip( reason = 'write test')
def test_function_get_can_resolve_value_false(self):
@pytest.mark.parametrize(
argnames = [
'name',
'param_has_comment',
'param_comment_is_closed',
'expected_value_solve',
'param_ticket_status',
'expected_value_close',
],
argvalues = values_function_get_can_close,
ids = [
name +'_'+ str(param_has_comment).lower() +'_'+ str(param_ticket_status).lower() +'_'+str(expected_value_solve).lower() for
name,
param_has_comment,
param_comment_is_closed,
expected_value_solve,
param_ticket_status,
expected_value_close,
in values_function_get_can_close
]
)
def test_function_get_can_resolve(self, ticket_comment,
name,
param_has_comment,
param_comment_is_closed,
expected_value_solve,
param_ticket_status,
expected_value_close,
):
"""Function test
Ensure that function `get_can_resolve` returns a value of `False` when
the ticket can not be closed
Ensure that function `get_can_resolve` works as intended:
- can't solve ticket with unresolved comments
- can solve ticket with no comments.
- can solve ticket if status invalid regardless of comment status
"""
assert self.model().get_can_resolve() == False
ticket = ticket_comment.ticket
if param_has_comment:
if param_comment_is_closed is not None:
ticket_comment.is_closed = param_comment_is_closed
if type(param_comment_is_closed) is bool and param_comment_is_closed:
ticket_comment.date_closed = datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat()
ticket_comment.save()
else:
ticket_comment.delete()
if param_ticket_status is not None:
try:
ticket.status = param_ticket_status
ticket.save()
except centurion_exceptions.ValidationError:
pass
assert ticket.get_can_resolve() == expected_value_solve
def test_function_get_can_resolve_value_true(self):

View File

@ -15,8 +15,12 @@ The ticketing system within Centurion ERP is common to all ticket types. The dif
- Linked Items to ticket
- [Markdown](./markdown.md) support within the ticket description and comment(s)
- Milestone
- Parent / Child Tickets
- Project
- Related Tickets
@ -31,33 +35,91 @@ The ticketing system within Centurion ERP is common to all ticket types. The dif
- Problems
- Request
- Service Request or Request for short
- Project Task
## Ticket Status'
Tickets have status' which are a simple way of denoting the stage a ticket is at. The common status' available are:
- Draft
A ticket with a draft status denotes the ticket has not been submitted and that the ticket raiser is still working on the ticket.
- New
A ticket with a new status denotes the ticket is submitted and ready for triage.
- Assigned
A ticket with an assigned status is being worked on. The assigned field will also have the person/team who is assigned to work on the ticket.
- Assigned (planning)
A ticket with an assigned-planning status is a ticket that has been scheduled to work on. like the assigned status, the tickets assigned field will contain the person / team whom is working on the ticket.
- Pending
A ticket with a pending status means that the ticket is on hold for some reason.
- Solved
A ticket with the solved status means that all work has been completed on the ticket.
- Invalid
A ticket with a status of invalid, means that the ticket was raised in error.
- Closed
A ticket with a closed status means that no more changes can be made to the ticket as it has been solved with all work complete.
Some of the status' above auto-magic change when a field changes on a ticket or some other action related to a ticket. for example:
- Assigning a ticket to a person/team will set the ticket status to `Assigned`
- Posting a solution comment will set the ticket status to solved. This is on the proviso that the ticket is solvable.
## Solving a ticket
Solving a ticket is not as simple as setting the status of the ticket to `Solved`. There is validation to ensure that you cant solve a ticket that is considered incomplete. An incomplete ticket is so if it meets any of the following criteria:
- Any comment is not in a closed state
When all of the incomplete criteria is complete, you can set the ticket to `Solved` or post a solution comment.
## Commenting
Comment types are:
Ticket comments support [markdown](./markdown.md) as well as slash commands. Comments are broken down into different types, they are:
- Standard _All Ticket types_
- Standard
- Notification _Change, Incident, Problem, Project Tasks and Request tickets._
A typical comment that has the ability to track time spent, have a category assigned as well as a source for the comment.
- Solution _Change, Incident, Problem, Project Tasks and Request tickets._
- ~~Notification _Change, Incident, Problem, Project Tasks and Request tickets._~~ _awaiting [github-564](https://github.com/nofusscomputing/centurion_erp/issues/564)_
- Task _Change, Incident, Problem, Project Tasks and Request tickets._
- Solution
A solution comment has all of the features a standard comment has. In addition, leaving this type of comment will mark the ticket as solved as long as it meets the [criteria](#solving-a-ticket).
- ~~Task _Change, Incident, Problem, Project Tasks and Request tickets._~~ _awaiting [github-564](https://github.com/nofusscomputing/centurion_erp/issues/564)_
## Slash Commands
Slash commands are a quick action that is specified after a slash command. As the name implies, the command starts with a slash `/`. The following slash commands are available:
- Linked Item `/link`
- Linked Item `/link` i.e. to link device 22 (`id`/`number` in device details page url ) the command would be `/link $device-22`. to see the available model references / model tag, please see the [markdown docs](./markdown.md#model-reference--model-tag).
- Related `/blocked_by`, `/blocks` and `/relate`
Enables you to link different objects from Centurion ERP to a ticket. Once an object has been linked to ticket you will see the ticket within the objects details page under the ticket tab.
- Time Spent `/spend`, `/spent`
- Related `/blocked_by`, `/blocks` and `/relate` i.e. to mark ticket 22 as related, use `/relate #22`
- Time Spent `/spend`, `/spent` i.e. to record 3 hours and 5 mins of time spent, use `/spend 3h5m`
When using slash commands, there is only to be one slash command per line. All slash commands support reference stacking (more than one reference) as long as they are separated by a space. i.e. `/<command> $<model>-<pk> $<model>-<pk> $<model>-<pk>`