diff --git a/.vscode/settings.json b/.vscode/settings.json index e022509a..5497b350 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,4 +23,5 @@ "yellow": 60, "green": 90 }, + "telemetry.feedback.enabled": false, } \ No newline at end of file diff --git a/app/core/migrations/0024_alter_ticketbase_opened_by_and_more.py b/app/core/migrations/0024_alter_ticketbase_opened_by_and_more.py new file mode 100644 index 00000000..18bffbfe --- /dev/null +++ b/app/core/migrations/0024_alter_ticketbase_opened_by_and_more.py @@ -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'), + ), + ] diff --git a/app/core/models/ticket_base.py b/app/core/models/ticket_base.py index 70131ef0..517b9992 100644 --- a/app/core/models/ticket_base.py +++ b/app/core/models/ticket_base.py @@ -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 != '' diff --git a/app/core/serializers/ticket.py b/app/core/serializers/ticket.py index db4e5609..8b90d05d 100644 --- a/app/core/serializers/ticket.py +++ b/app/core/serializers/ticket.py @@ -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 diff --git a/app/core/tests/functional/ticket_base/test_functional_ticket_base_serializer.py b/app/core/tests/functional/ticket_base/test_functional_ticket_base_serializer.py index ad4b03a8..cf2838ed 100644 --- a/app/core/tests/functional/ticket_base/test_functional_ticket_base_serializer.py +++ b/app/core/tests/functional/ticket_base/test_functional_ticket_base_serializer.py @@ -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, ): diff --git a/app/core/tests/unit/ticket_base/test_unit_ticket_base_api_fields.py b/app/core/tests/unit/ticket_base/test_unit_ticket_base_api_fields.py index 73b526e1..7012d70a 100644 --- a/app/core/tests/unit/ticket_base/test_unit_ticket_base_api_fields.py +++ b/app/core/tests/unit/ticket_base/test_unit_ticket_base_api_fields.py @@ -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' diff --git a/app/core/tests/unit/ticket_base/test_unit_ticket_base_model.py b/app/core/tests/unit/ticket_base/test_unit_ticket_base_model.py index fc1133fb..833038b4 100644 --- a/app/core/tests/unit/ticket_base/test_unit_ticket_base_model.py +++ b/app/core/tests/unit/ticket_base/test_unit_ticket_base_model.py @@ -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 diff --git a/docs/projects/centurion_erp/user/core/tickets.md b/docs/projects/centurion_erp/user/core/tickets.md index 107541d3..a5c4c636 100644 --- a/docs/projects/centurion_erp/user/core/tickets.md +++ b/docs/projects/centurion_erp/user/core/tickets.md @@ -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.