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,
"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(
User,
blank = False,
blank = True,
help_text = 'Who is the ticket for',
null = False,
null = True,
on_delete = models.PROTECT,
related_name = 'ticket_opened',
verbose_name = 'Opened By',
@ -567,6 +567,16 @@ class TicketBase(
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.project != self.project:
@ -578,17 +588,80 @@ class TicketBase(
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:
self.get_can_resolve( raise_exceptions = True )
if self.is_closed:
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:
@ -789,36 +862,6 @@ class TicketBase(
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(
self.description != ''

View File

@ -278,6 +278,16 @@ class ModelSerializer(
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_external_system( attrs )
@ -290,15 +300,13 @@ class ModelSerializer(
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 = 0
request_user_id = int(self.context['request'].user.id)
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

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:
@ -114,11 +91,8 @@ class TicketBaseSerializerTestCases:
'permission_import_required': False,
},
"opened_by": {
'will_create': False,
'exception_obj': ValidationError,
'exception_code': 'required',
'exception_code_key': None,
'permission_import_required': True,
'will_create': True,
'permission_import_required': False,
},
"subscribed_to": {
'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.other_user = User.objects.create_user(username="cafs_test_user_other", password="password")
yield
with django_db_blocker.unblock():
request.cls.view_user.delete()
request.cls.other_user.delete()
del request.cls.valid_data
@ -308,17 +285,23 @@ class TicketBaseSerializerTestCases:
pass
def test_serializer_valid_data(self, create_serializer):
def test_serializer_valid_data(self, fake_view, create_serializer):
"""Serializer Validation Check
Ensure that when creating an object with valid data, no validation
error occurs.
"""
view_set = MockView()
view_set = fake_view(
user = self.view_user,
_has_import = True,
_has_triage = True
)
serializer = create_serializer(
context = {
'request': view_set.request,
'view': view_set,
},
data = self.valid_data
@ -327,19 +310,22 @@ class TicketBaseSerializerTestCases:
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
Ensure that when creating an object with valid data, no validation
error occurs. when the user has permission import.
"""
view_set = MockView()
view_set._has_import = True
view_set = fake_view(
user = self.view_user,
_has_import = True,
_has_triage = False
)
serializer = create_serializer(
context = {
'request': view_set.request,
'view': view_set,
},
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,
param_value,
param_exception_obj,
@ -369,14 +355,16 @@ class TicketBaseSerializerTestCases:
del valid_data[param_value]
view_set = MockView()
view_set._has_import = True
view_set = fake_view(
user = self.view_user,
_has_import = True,
)
with pytest.raises(param_exception_obj) as err:
serializer = create_serializer(
context = {
'request': view_set.request,
'view': view_set,
},
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,
param_value,
param_will_create,
@ -412,12 +400,14 @@ class TicketBaseSerializerTestCases:
del valid_data[param_value]
view_set = MockView()
view_set._has_import = True
view_set = fake_view(
user = self.view_user,
_has_import = True,
)
serializer = create_serializer(
context = {
'request': view_set.request,
'view': view_set,
},
data = valid_data
@ -510,6 +500,7 @@ class TicketBaseSerializerTestCases:
]
)
def test_serializer_create_validation_status(self,
fake_view,
create_serializer,
name,
param_permission_import,
@ -535,41 +526,26 @@ class TicketBaseSerializerTestCases:
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:
mock_request.user.id = self.view_user.pk
mock_request.user.pk = self.view_user.pk
view_set.request.user = self.view_user
serializer = create_serializer(
context = {
'request': view_set.request,
'view': view_set,
},
data = valid_data
)
serializer.context['request'] = mock_request
if type(expected_result) is not bool:
with pytest.raises(ValidationError) as err:
@ -585,13 +561,13 @@ class TicketBaseSerializerTestCases:
@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._has_import = True
view_set._has_triage = True
view_set = fake_view(
user = self.view_user,
_has_import = True,
_has_triage = True
)
valid_data = self.valid_data.copy()
@ -601,6 +577,7 @@ class TicketBaseSerializerTestCases:
serializer = create_serializer(
context = {
'request': view_set.request,
'view': view_set,
},
data = valid_data
@ -641,7 +618,7 @@ class TicketBaseSerializerTestCases:
in values_validation_status_change_permission
]
)
def test_serializer_update_validation_status(self,
def test_serializer_update_validation_status(self, fake_view,
create_serializer,
existing_ticket,
name,
@ -670,42 +647,28 @@ class TicketBaseSerializerTestCases:
valid_data['status'] = status
view_set = MockView()
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()
view_set = fake_view(
user = self.other_user,
_has_import = param_permission_import,
_has_triage = param_permission_triage,
action = 'partial_update',
)
if param_is_owner:
mock_request.user.id = self.view_user.pk
mock_request.user.pk = self.view_user.pk
view_set.request.user = self.view_user
serializer = create_serializer(
instance = existing_ticket,
existing_ticket,
context = {
'request': view_set.request,
'view': view_set,
},
data = valid_data,
partial = True,
)
serializer.context['request'] = mock_request
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(
TicketBaseSerializerTestCases,
):

View File

@ -62,6 +62,7 @@ class APITestCases(
'external_system': int(request.cls.model.Ticket_ExternalSystem.CUSTOM_1),
'impact': int(request.cls.model.TicketImpact.MEDIUM),
'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_finish_date': '2025-04-16T00:00:04',
'is_solved': True,
'date_solved': '2025-05-12T02:30:01',
'is_closed': True,
'date_closed': '2025-05-12T02:30:02',
}
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
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
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
## Ticket Types
## Re-Opening a Ticket
::: app.core.models.ticket.ticket.Ticket.TicketType
options:
inherited_members: false
members: []
show_bases: false
show_submodules: false
summary: true
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:
- The user who raised the ticket can re-open the ticket only when the status is `SOLVED`
## Ticket Comments
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
- A User with `Triage` permission can re-open a ticket when the status is `SOLVED` or `CLOSED` regardless of who raised the ticket.