test(core): TicketBase Remaining Serializer Chacks

ref: #753 #723
This commit is contained in:
2025-05-12 02:49:17 +09:30
parent e7213d8a70
commit e29a7ec0e2
8 changed files with 518 additions and 154 deletions

View File

@ -23,4 +23,5 @@
"yellow": 60, "yellow": 60,
"green": 90 "green": 90
}, },
"telemetry.feedback.enabled": false,
} }

View File

@ -0,0 +1,47 @@
# Generated by Django 5.1.9 on 2025-05-11 16:49
import core.models.ticket_comment_base
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0023_alter_ticketlinkeditem_item_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='ticketbase',
name='opened_by',
field=models.ForeignKey(blank=True, help_text='Who is the ticket for', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ticket_opened', to=settings.AUTH_USER_MODEL, verbose_name='Opened By'),
),
migrations.AlterField(
model_name='ticketcommentbase',
name='category',
field=models.ForeignKey(blank=True, help_text='Category of the comment', null=True, on_delete=django.db.models.deletion.PROTECT, to='core.ticketcommentcategory', verbose_name='Category'),
),
migrations.AlterField(
model_name='ticketcommentbase',
name='comment_type',
field=models.CharField(choices=core.models.ticket_comment_base.TicketCommentBase.get_comment_type_choices, help_text='Type this comment is. derived from Meta.verbose_name', max_length=30, validators=[core.models.ticket_comment_base.TicketCommentBase.field_validation_not_empty], verbose_name='Type'),
),
migrations.AlterField(
model_name='ticketcommentbase',
name='external_ref',
field=models.IntegerField(blank=True, help_text='External System reference', null=True, verbose_name='Reference Number'),
),
migrations.AlterField(
model_name='ticketcommentbase',
name='external_system',
field=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)')], help_text='External system this item derives', null=True, verbose_name='External System'),
),
migrations.AlterField(
model_name='ticketcommentbase',
name='parent',
field=models.ForeignKey(blank=True, help_text='Parent ID for creating discussion threads', null=True, on_delete=django.db.models.deletion.PROTECT, to='core.ticketcommentbase', verbose_name='Parent Comment'),
),
]

View File

@ -434,9 +434,9 @@ class TicketBase(
opened_by = models.ForeignKey( opened_by = models.ForeignKey(
User, User,
blank = False, blank = True,
help_text = 'Who is the ticket for', help_text = 'Who is the ticket for',
null = False, null = True,
on_delete = models.PROTECT, on_delete = models.PROTECT,
related_name = 'ticket_opened', related_name = 'ticket_opened',
verbose_name = 'Opened By', verbose_name = 'Opened By',
@ -567,6 +567,16 @@ class TicketBase(
there are unresolved ticket comments. there are unresolved ticket comments.
""" """
if self.opened_by is None:
raise centurion_exception.ValidationError(
detail = {
'opened_by': 'This field is required.'
},
code = 'required'
)
if self.milestone: if self.milestone:
if self.milestone.project != self.project: if self.milestone.project != self.project:
@ -578,17 +588,80 @@ class TicketBase(
code = 'milestone_different_project' code = 'milestone_different_project'
) )
if(
(
self.status == self.TicketStatus.SOLVED
and self.get_can_resolve( raise_exceptions = False )
)
or self.status == self.TicketStatus.INVALID
):
self.is_solved = True
elif( # Re-Open Ticket
( self.is_solved or self.is_closed )
and self.status != self.TicketStatus.CLOSED
and self.status != self.TicketStatus.INVALID
and self.status != self.TicketStatus.SOLVED
):
if self.is_closed:
self.is_closed = False
if self.is_solved:
self.is_solved = False
elif not self.is_closed and self.status == self.TicketStatus.CLOSED: # Close Ticket
self.is_solved = True
self.is_closed = True
if self.is_solved: if self.is_solved:
self.get_can_resolve( raise_exceptions = True ) self.get_can_resolve( raise_exceptions = True )
if self.is_closed: if self.is_closed:
self.get_can_close( raise_exceptions = True ) self.get_can_close( raise_exceptions = True )
related_model = self.get_related_model()
if related_model is None:
related_model = self
if self.ticket_type != str(related_model._meta.sub_model_type).lower().replace(' ', '_'):
self.ticket_type = str(related_model._meta.sub_model_type).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()
elif self.date_solved is not None and not self.is_solved:
self.date_solved = None
if self.date_closed is None and self.is_closed:
self.date_closed = datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat()
if self.date_closed is not None and not self.is_closed:
self.date_closed = None
def get_can_close(self, raise_exceptions = False ) -> bool: def get_can_close(self, raise_exceptions = False ) -> bool:
@ -789,36 +862,6 @@ class TicketBase(
def save(self, force_insert=False, force_update=False, using=None, update_fields=None): 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.sub_model_type).lower().replace(' ', '_'):
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()
if( if(
self.description != '' self.description != ''

View File

@ -278,6 +278,16 @@ class ModelSerializer(
def validate(self, attrs): def validate(self, attrs):
if getattr(self.context['view'], 'action', '') in [ 'create' ]:
# Always set that the ticket was opened by user ho is making the request
try:
attrs['opened_by'] = self.context['request'].user
except KeyError:
pass
attrs = self.validate_field_milestone( attrs ) attrs = self.validate_field_milestone( attrs )
attrs = self.validate_field_external_system( attrs ) attrs = self.validate_field_external_system( attrs )
@ -290,15 +300,13 @@ class ModelSerializer(
status = int(attrs.get('status', 0)) status = int(attrs.get('status', 0))
opened_by_id = int(attrs.get('opened_by_id', 0)) opened_by_id = attrs.get('opened_by', 0)
if self.context.get('request', None): if opened_by_id != 0:
request_user_id = int(self.context['request'].user.id) opened_by_id = opened_by_id.id
else: request_user_id = int(self.context['request'].user.id)
request_user_id = 0
if opened_by_id == 0: if opened_by_id == 0:
@ -364,6 +372,21 @@ class ModelSerializer(
) )
elif (
has_triage_permission
or has_import_permission
):
if(
(
'status' not in attrs
or attrs.get('status', 0) == self.Meta.model.TicketStatus.NEW
)
and 'assigned_to' in attrs
):
attrs['status'] = self.Meta.model.TicketStatus.ASSIGNED
return attrs return attrs

View File

@ -18,29 +18,6 @@ from project_management.models.project_milestone import (
class MockView:
_has_import: bool = False
"""User Permission
get_permission_required() sets this to `True` when user has import permission.
"""
_has_purge: bool = False
"""User Permission
get_permission_required() sets this to `True` when user has purge permission.
"""
_has_triage: bool = False
"""User Permission
get_permission_required() sets this to `True` when user has triage permission.
"""
class TicketBaseSerializerTestCases: class TicketBaseSerializerTestCases:
@ -114,11 +91,8 @@ class TicketBaseSerializerTestCases:
'permission_import_required': False, 'permission_import_required': False,
}, },
"opened_by": { "opened_by": {
'will_create': False, 'will_create': True,
'exception_obj': ValidationError, 'permission_import_required': False,
'exception_code': 'required',
'exception_code_key': None,
'permission_import_required': True,
}, },
"subscribed_to": { "subscribed_to": {
'will_create': True, 'will_create': True,
@ -224,12 +198,15 @@ class TicketBaseSerializerTestCases:
request.cls.view_user = User.objects.create_user(username="cafs_test_user_view", password="password") request.cls.view_user = User.objects.create_user(username="cafs_test_user_view", password="password")
request.cls.other_user = User.objects.create_user(username="cafs_test_user_other", password="password")
yield yield
with django_db_blocker.unblock(): with django_db_blocker.unblock():
request.cls.view_user.delete() request.cls.view_user.delete()
request.cls.other_user.delete()
del request.cls.valid_data del request.cls.valid_data
@ -308,17 +285,23 @@ class TicketBaseSerializerTestCases:
pass pass
def test_serializer_valid_data(self, create_serializer): def test_serializer_valid_data(self, fake_view, create_serializer):
"""Serializer Validation Check """Serializer Validation Check
Ensure that when creating an object with valid data, no validation Ensure that when creating an object with valid data, no validation
error occurs. error occurs.
""" """
view_set = MockView() view_set = fake_view(
user = self.view_user,
_has_import = True,
_has_triage = True
)
serializer = create_serializer( serializer = create_serializer(
context = { context = {
'request': view_set.request,
'view': view_set, 'view': view_set,
}, },
data = self.valid_data data = self.valid_data
@ -327,19 +310,22 @@ class TicketBaseSerializerTestCases:
assert serializer.is_valid(raise_exception = True) assert serializer.is_valid(raise_exception = True)
def test_serializer_valid_data_permission_import(self, create_serializer): def test_serializer_valid_data_permission_import(self, fake_view, create_serializer):
"""Serializer Validation Check """Serializer Validation Check
Ensure that when creating an object with valid data, no validation Ensure that when creating an object with valid data, no validation
error occurs. when the user has permission import. error occurs. when the user has permission import.
""" """
view_set = MockView() view_set = fake_view(
user = self.view_user,
view_set._has_import = True _has_import = True,
_has_triage = False
)
serializer = create_serializer( serializer = create_serializer(
context = { context = {
'request': view_set.request,
'view': view_set, 'view': view_set,
}, },
data = self.valid_data data = self.valid_data
@ -349,7 +335,7 @@ class TicketBaseSerializerTestCases:
def test_serializer_valid_data_missing_field_raises_exception(self, parameterized, param_key_test_data, def test_serializer_valid_data_missing_field_raises_exception(self, fake_view, parameterized, param_key_test_data,
create_serializer, create_serializer,
param_value, param_value,
param_exception_obj, param_exception_obj,
@ -369,14 +355,16 @@ class TicketBaseSerializerTestCases:
del valid_data[param_value] del valid_data[param_value]
view_set = MockView() view_set = fake_view(
user = self.view_user,
view_set._has_import = True _has_import = True,
)
with pytest.raises(param_exception_obj) as err: with pytest.raises(param_exception_obj) as err:
serializer = create_serializer( serializer = create_serializer(
context = { context = {
'request': view_set.request,
'view': view_set, 'view': view_set,
}, },
data = valid_data, data = valid_data,
@ -396,7 +384,7 @@ class TicketBaseSerializerTestCases:
def test_serializer_valid_data_missing_field_is_valid_permission_import(self, parameterized, param_key_test_data, def test_serializer_valid_data_missing_field_is_valid_permission_import(self, fake_view, parameterized, param_key_test_data,
create_serializer, create_serializer,
param_value, param_value,
param_will_create, param_will_create,
@ -412,12 +400,14 @@ class TicketBaseSerializerTestCases:
del valid_data[param_value] del valid_data[param_value]
view_set = MockView() view_set = fake_view(
user = self.view_user,
view_set._has_import = True _has_import = True,
)
serializer = create_serializer( serializer = create_serializer(
context = { context = {
'request': view_set.request,
'view': view_set, 'view': view_set,
}, },
data = valid_data data = valid_data
@ -510,6 +500,7 @@ class TicketBaseSerializerTestCases:
] ]
) )
def test_serializer_create_validation_status(self, def test_serializer_create_validation_status(self,
fake_view,
create_serializer, create_serializer,
name, name,
param_permission_import, param_permission_import,
@ -535,41 +526,26 @@ class TicketBaseSerializerTestCases:
valid_data['status'] = status valid_data['status'] = status
view_set = MockView() view_set = fake_view(
user = self.other_user,
_has_import = param_permission_import,
_has_triage = param_permission_triage
)
view_set._has_import = param_permission_import
view_set._has_triage = param_permission_triage
class MockRequest:
class MockUser:
id = 999999
pk = 999999
user = MockUser()
mock_request = MockRequest()
if param_is_owner: if param_is_owner:
mock_request.user.id = self.view_user.pk view_set.request.user = self.view_user
mock_request.user.pk = self.view_user.pk
serializer = create_serializer( serializer = create_serializer(
context = { context = {
'request': view_set.request,
'view': view_set, 'view': view_set,
}, },
data = valid_data data = valid_data
) )
serializer.context['request'] = mock_request
if type(expected_result) is not bool: if type(expected_result) is not bool:
with pytest.raises(ValidationError) as err: with pytest.raises(ValidationError) as err:
@ -585,13 +561,13 @@ class TicketBaseSerializerTestCases:
@pytest.fixture( scope = 'function' ) @pytest.fixture( scope = 'function' )
def existing_ticket(self, db, create_serializer): def existing_ticket(self, db, fake_view, create_serializer):
view_set = MockView() view_set = fake_view(
user = self.view_user,
view_set._has_import = True _has_import = True,
_has_triage = True
view_set._has_triage = True )
valid_data = self.valid_data.copy() valid_data = self.valid_data.copy()
@ -601,6 +577,7 @@ class TicketBaseSerializerTestCases:
serializer = create_serializer( serializer = create_serializer(
context = { context = {
'request': view_set.request,
'view': view_set, 'view': view_set,
}, },
data = valid_data data = valid_data
@ -641,7 +618,7 @@ class TicketBaseSerializerTestCases:
in values_validation_status_change_permission in values_validation_status_change_permission
] ]
) )
def test_serializer_update_validation_status(self, def test_serializer_update_validation_status(self, fake_view,
create_serializer, create_serializer,
existing_ticket, existing_ticket,
name, name,
@ -670,42 +647,28 @@ class TicketBaseSerializerTestCases:
valid_data['status'] = status valid_data['status'] = status
view_set = MockView() view_set = fake_view(
user = self.other_user,
view_set._has_import = param_permission_import _has_import = param_permission_import,
_has_triage = param_permission_triage,
view_set._has_triage = param_permission_triage action = 'partial_update',
)
class MockRequest:
class MockUser:
id = 999999
pk = 999999
user = MockUser()
mock_request = MockRequest()
if param_is_owner: if param_is_owner:
mock_request.user.id = self.view_user.pk view_set.request.user = self.view_user
mock_request.user.pk = self.view_user.pk
serializer = create_serializer( serializer = create_serializer(
instance = existing_ticket, existing_ticket,
context = { context = {
'request': view_set.request,
'view': view_set, 'view': view_set,
}, },
data = valid_data, data = valid_data,
partial = True, partial = True,
) )
serializer.context['request'] = mock_request
if type(expected_result) is not bool: if type(expected_result) is not bool:
@ -721,6 +684,305 @@ class TicketBaseSerializerTestCases:
@pytest.fixture( scope = 'function' )
def fresh_ticket_serializer(self, request, django_db_blocker, fake_view, create_serializer):
view_set = fake_view(
user = request.cls.view_user
)
valid_data = request.cls.valid_data.copy()
valid_data['title'] = 'ticktet with minimum fields'
valid_data_keep_fields = [
'title',
'organization',
'description',
]
for field, value in valid_data.copy().items():
if field not in valid_data_keep_fields:
del valid_data[field]
serializer = create_serializer(
context = {
'request': view_set.request,
'view': view_set,
},
data = valid_data
)
yield {
'serializer': serializer,
'valid_data': valid_data,
}
with django_db_blocker.unblock():
if serializer.instance:
serializer.instance.delete()
def test_action_triage_user_assign_user_sets_status_assigned(self, model, fresh_ticket_serializer):
"""Ticket Function Check
Assigning the ticket must set the status to new
"""
serializer = fresh_ticket_serializer['serializer']
serializer.initial_data['assigned_to'] = [ self.entity_user.id ]
serializer.context['view']._has_triage = True
serializer.is_valid(raise_exception = True)
serializer.save()
ticket = serializer.instance
assert ticket.status == model.TicketStatus.ASSIGNED
def test_action_triage_user_assign_user_and_status_no_status_update(self, model, fresh_ticket_serializer):
"""Ticket Function Check
Assigning the ticket and setting the status, does not set the status to assigned
"""
serializer = fresh_ticket_serializer['serializer']
serializer.initial_data['assigned_to'] = [ self.entity_user.id ]
serializer.initial_data['status'] = model.TicketStatus.PENDING
serializer.context['view']._has_triage = True
serializer.is_valid(raise_exception = True)
serializer.save()
ticket = serializer.instance
assert ticket.status == model.TicketStatus.PENDING
date_action_clear_solved_ticket = [
('triage', True, False, 'is_solved', False),
('triage', True, False, 'date_solved', None),
('ticket_owner', False, False, 'is_solved', False),
('ticket_owner', False, False, 'date_solved', None),
('import', False, True, 'is_solved', False),
('import', False, True, 'date_solved', None),
]
date_action_clear_closed_ticket = [
('triage', True, False, 'is_closed', False),
('triage', True, False, 'date_closed', None),
('ticket_owner', False, False, 'is_closed', False),
('ticket_owner', False, False, 'date_closed', None),
('import', False, True, 'is_closed', False),
('import', False, True, 'date_closed', None),
]
data_action_reopen_solved_ticket = [
*date_action_clear_solved_ticket,
]
date_action_reopen_closed_ticket = [
*date_action_clear_solved_ticket,
*date_action_clear_closed_ticket
]
@pytest.mark.parametrize(
argnames = [
'name',
'triage_user',
'import_user',
'field_name',
'expected',
],
argvalues = data_action_reopen_solved_ticket,
ids = [
name +'_'+ field_name +'_'+str(expected).lower() for
name,
triage_user,
import_user,
field_name,
expected
in data_action_reopen_solved_ticket
]
)
def test_action_reopen_solved_ticket(self,
model,
fresh_ticket_serializer,
create_serializer,
name,
triage_user,
import_user,
field_name,
expected
):
"""Ticket Action Check
When a ticket is reopened the following should occur:
- is_solved = False
- date_closed = None
Only the following are supposed to be able to re-open a solved ticket:
- ticket owner
- triage user
- import user
"""
# Create Solved Ticket
serializer = fresh_ticket_serializer['serializer']
serializer.initial_data['status'] = model.TicketStatus.SOLVED
serializer.context['view']._has_triage = True
serializer.context['view']._has_import = True
serializer.is_valid(raise_exception = True)
serializer.save()
ticket = serializer.instance
# Confirm State
assert ticket.status == model.TicketStatus.SOLVED
assert ticket.is_solved
assert ticket.date_solved is not None
# Re-Open Ticket
edit_serializer = create_serializer(
ticket,
context = serializer.context,
data = {
'status': model.TicketStatus.NEW
},
partial = True
)
edit_serializer.context['view']._has_triage = triage_user
edit_serializer.context['view']._has_import = import_user
edit_serializer.is_valid(raise_exception = True)
edit_serializer.save()
ticket = edit_serializer.instance
assert getattr(ticket, field_name) == expected
@pytest.mark.parametrize(
argnames = [
'name',
'triage_user',
'import_user',
'field_name',
'expected',
],
argvalues = date_action_reopen_closed_ticket,
ids = [
name +'_'+ field_name +'_'+str(expected).lower() for
name,
triage_user,
import_user,
field_name,
expected
in date_action_reopen_closed_ticket
]
)
def test_action_reopen_closed_ticket(self,
model,
fresh_ticket_serializer,
create_serializer,
name,
triage_user,
import_user,
field_name,
expected
):
"""Ticket Action Check
When a ticket is reopened the following should occur:
- is_closed = False
- date_closed = None
- is_solved = False
- date_closed = None
Only the following are supposed to be able to re-open a closed ticket:
- ticket owner
- triage user
- import user
"""
# Create Closed Ticket
serializer = fresh_ticket_serializer['serializer']
serializer.initial_data['status'] = model.TicketStatus.CLOSED
serializer.context['view']._has_triage = True
serializer.context['view']._has_import = True
serializer.is_valid(raise_exception = True)
serializer.save()
ticket = serializer.instance
# Confirm State
assert ticket.status == model.TicketStatus.CLOSED
assert ticket.is_closed
assert ticket.date_closed is not None
assert ticket.is_solved
assert ticket.date_solved is not None
# Re-Open Ticket
edit_serializer = create_serializer(
ticket,
context = serializer.context,
data = {
'status': model.TicketStatus.NEW
},
partial = True
)
edit_serializer.context['view']._has_triage = triage_user
edit_serializer.context['view']._has_import = import_user
edit_serializer.is_valid(raise_exception = True)
edit_serializer.save()
ticket = edit_serializer.instance
assert getattr(ticket, field_name) == expected
class TicketBaseSerializerInheritedCases( class TicketBaseSerializerInheritedCases(
TicketBaseSerializerTestCases, TicketBaseSerializerTestCases,
): ):

View File

@ -62,6 +62,7 @@ class APITestCases(
'external_system': int(request.cls.model.Ticket_ExternalSystem.CUSTOM_1), 'external_system': int(request.cls.model.Ticket_ExternalSystem.CUSTOM_1),
'impact': int(request.cls.model.TicketImpact.MEDIUM), 'impact': int(request.cls.model.TicketImpact.MEDIUM),
'priority': int(request.cls.model.TicketPriority.HIGH), 'priority': int(request.cls.model.TicketPriority.HIGH),
'status': request.cls.model.TicketStatus.CLOSED,
}) })
@ -379,7 +380,9 @@ class APITestCases(
'real_start_date': '2025-04-16T00:00:03', 'real_start_date': '2025-04-16T00:00:03',
'real_finish_date': '2025-04-16T00:00:04', 'real_finish_date': '2025-04-16T00:00:04',
'is_solved': True, 'is_solved': True,
'date_solved': '2025-05-12T02:30:01',
'is_closed': True, 'is_closed': True,
'date_closed': '2025-05-12T02:30:02',
} }
url_ns_name = '_api_v2_ticket' url_ns_name = '_api_v2_ticket'

View File

@ -850,7 +850,7 @@ class TicketBaseModelTestCases(
def test_function_called_clean_ticketcommentbase(self, model, mocker): def test_function_called_clean_ticketbase(self, model, mocker):
"""Function Check """Function Check
Ensure function `TicketBase.clean` is called Ensure function `TicketBase.clean` is called
@ -872,7 +872,7 @@ class TicketBaseModelTestCases(
def test_function_called_save_ticketcommentbase(self, model, mocker): def test_function_called_save_ticketbase(self, model, mocker):
"""Function Check """Function Check
Ensure function `TicketBase.save` is called Ensure function `TicketBase.save` is called

View File

@ -157,25 +157,10 @@ When using slash commands, there is only to be one slash command per line. All s
summary: true summary: true
## Ticket Types ## Re-Opening a Ticket
::: app.core.models.ticket.ticket.Ticket.TicketType To re-open a ticket is as simple as changing the status and saving. Not everyone can re-open a ticket, it depends upon the following:
options:
inherited_members: false
members: []
show_bases: false
show_submodules: false
summary: true
- The user who raised the ticket can re-open the ticket only when the status is `SOLVED`
## Ticket Comments - A User with `Triage` permission can re-open a ticket when the status is `SOLVED` or `CLOSED` regardless of who raised the ticket.
Within Centurion ERP the ticket comment model is common to all comment types. As with tickets the differences are the available fields, which depend upon comment type and permissions.
::: app.core.models.ticket.ticket_comment.TicketComment.CommentType
options:
inherited_members: false
members: []
show_bases: false
show_submodules: false
summary: true