From d037150eb367b516b2bf4f3fc8a91d3568e9e55a Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 16:08:34 +0930 Subject: [PATCH 01/31] test(core): Unit Model Checks for TicketCommentBase ref: #744 #726 --- .../unit/ticket_comment_base/conftest.py | 14 + .../test_unit_ticket_comment_base_model.py | 528 ++++++++++++++++++ 2 files changed, 542 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_base/conftest.py create mode 100644 app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py diff --git a/app/core/tests/unit/ticket_comment_base/conftest.py b/app/core/tests/unit/ticket_comment_base/conftest.py new file mode 100644 index 00000000..daaa25be --- /dev/null +++ b/app/core/tests/unit/ticket_comment_base/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from core.models.ticket_comment_base import TicketCommentBase + + + +@pytest.fixture( scope = 'class') +def model(request): + + request.cls.model = TicketCommentBase + + yield request.cls.model + + del request.cls.model diff --git a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py new file mode 100644 index 00000000..72dfbc28 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py @@ -0,0 +1,528 @@ +import pytest + +from django.contrib.auth.models import User +from django.db import models + +from rest_framework.exceptions import ValidationError + +from access.models.person import Person + +from app.tests.unit.test_unit_models import ( + PyTestTenancyObjectInheritedCases, +) + +from core.models.ticket_comment_base import TicketBase, TicketCommentBase, TicketCommentCategory + + + +class TicketCommentBaseModelTestCases( + PyTestTenancyObjectInheritedCases, +): + + base_model = TicketCommentBase + + sub_model_type = 'comment' + """Sub Model Type + + sub-models must have this attribute defined in `ModelName.Meta.sub_model_type` + """ + + kwargs_create_item: dict = { + 'parent': None, + 'ticket': '', + 'external_ref': 0, + 'external_system': TicketBase.Ticket_ExternalSystem.CUSTOM_1, + 'comment_type': sub_model_type, + 'category': '', + 'body': 'asdasdas', + 'private': False, + 'template': None, + 'source': TicketBase.TicketSource.HELPDESK, + 'user': '', + 'is_closed': True, + 'date_closed': '2025-05-08T171000', + } + + + parameterized_fields: dict = { + "is_global": { + 'field_type': None, + 'field_parameter_default_exists': None, + 'field_parameter_default_value': None, + 'field_parameter_verbose_name_type': None + }, + "model_notes": { + 'field_type': None, + 'field_parameter_default_exists': None, + 'field_parameter_default_value': None, + 'field_parameter_verbose_name_type': None + }, + "parent": { + 'field_type': models.ForeignKey, + 'field_parameter_default_exists': False, + 'field_parameter_verbose_name_type': str, + }, + "ticket": { + 'field_type': models.ForeignKey, + 'field_parameter_default_exists': False, + 'field_parameter_verbose_name_type': str, + }, + "external_ref": { + 'field_type': models.fields.IntegerField, + 'field_parameter_default_exists': False, + 'field_parameter_verbose_name_type': str, + }, + "external_system": { + 'field_type': models.fields.IntegerField, + 'field_parameter_default_exists': False, + 'field_parameter_verbose_name_type': str, + }, + "comment_type": { + 'field_type': models.fields.CharField, + 'field_parameter_default_exists': False, + 'field_parameter_verbose_name_type': str, + }, + "category": { + 'field_type': models.ForeignKey, + 'field_parameter_default_exists': False, + 'field_parameter_verbose_name_type': str, + }, + "body": { + 'field_type': models.fields.TextField, + 'field_parameter_default_exists': False, + 'field_parameter_verbose_name_type': str, + }, + "private": { + 'field_type': models.fields.BooleanField, + 'field_parameter_default_exists': True, + 'field_parameter_default_value': False, + 'field_parameter_verbose_name_type': str, + }, + "duration": { + 'field_type': models.fields.IntegerField, + 'field_parameter_default_exists': True, + 'field_parameter_default_value': 0, + 'field_parameter_verbose_name_type': str, + }, + "estimation": { + 'field_type': models.fields.IntegerField, + 'field_parameter_default_exists': True, + 'field_parameter_default_value': 0, + 'field_parameter_verbose_name_type': str, + }, + "template": { + 'field_type': models.ForeignKey, + 'field_parameter_default_exists': True, + 'field_parameter_verbose_name_type': str, + }, + "source": { + 'field_type': models.fields.IntegerField, + 'field_parameter_default_exists': True, + 'field_parameter_default_value': TicketBase.TicketSource.HELPDESK, + 'field_parameter_verbose_name_type': str, + }, + "user": { + 'field_type': models.ForeignKey, + 'field_parameter_default_exists': False, + 'field_parameter_default_value': None, + 'field_parameter_verbose_name_type': str, + }, + "is_closed": { + 'field_type': models.fields.BooleanField, + 'field_parameter_default_exists': True, + 'field_parameter_default_value': False, + 'field_parameter_verbose_name_type': str, + }, + "date_closed": { + 'field_type': models.fields.DateTimeField, + 'field_parameter_default_exists': False, + 'field_parameter_verbose_name_type': str, + }, + } + + + @pytest.fixture( scope = 'class') + def setup_model(self, + request, + model, + django_db_blocker, + organization_one, + organization_two + ): + + request.cls.model = model + + with django_db_blocker.unblock(): + + request.cls.organization = organization_one + + request.cls.different_organization = organization_two + + kwargs_create_item = {} + + for base in reversed(request.cls.__mro__): + + if hasattr(base, 'kwargs_create_item'): + + if base.kwargs_create_item is None: + + continue + + kwargs_create_item.update(**base.kwargs_create_item) + + + if len(kwargs_create_item) > 0: + + request.cls.kwargs_create_item = kwargs_create_item + + + request.cls.view_user = User.objects.create_user(username="cafs_test_user_view", password="password") + + comment_category = TicketCommentCategory.objects.create( + organization = request.cls.organization, + name = 'test cat comment' + ) + + ticket = TicketBase.objects.create( + organization = request.cls.organization, + title = 'tester comment ticket', + opened_by = request.cls.view_user, + ) + + user = Person.objects.create( + organization = request.cls.organization, + f_name = 'ip', + l_name = 'funny' + ) + + request.cls.kwargs_create_item.update({ + 'category': comment_category, + 'ticket': ticket, + 'user': user, + }) + + + if 'organization' not in request.cls.kwargs_create_item: + + request.cls.kwargs_create_item.update({ + 'organization': request.cls.organization + }) + + yield + + with django_db_blocker.unblock(): + + del request.cls.kwargs_create_item + + comment_category.delete() + + ticket.delete() + + user.delete() + + request.cls.view_user.delete() + + + + @pytest.fixture( scope = 'class', autouse = True) + def class_setup(self, + setup_model, + create_model, + ): + + pass + + + + @pytest.fixture + def ticket(self, request, django_db_blocker): + + with django_db_blocker.unblock(): + + ticket = TicketBase.objects.create( + organization = request.cls.organization, + title = 'per function_ticket', + opened_by = request.cls.view_user, + ) + + yield ticket + + + with django_db_blocker.unblock(): + + for comment in ticket.ticketcommentbase_set.all(): + + comment.delete() + + ticket.delete() + + + def test_create_validation_exception_no_organization(self): + """ Tenancy objects must have an organization + + This test case is an over-ride of a test with the same name. this test + is not required as the organization is derived from the ticket. + + Must not be able to create an item without an organization + """ + + pass + + + def test_class_inherits_ticketcommentbase(self): + """ Class inheritence + + TenancyObject must inherit SaveHistory + """ + + assert issubclass(self.model, TicketCommentBase) + + + def test_attribute_meta_exists_permissions(self): + """Attribute Check + + Ensure attribute `Meta.permissions` exists + """ + + assert hasattr(self.model._meta, 'permissions') + + + def test_attribute_meta_not_none_permissions(self): + """Attribute Check + + Ensure attribute `Meta.permissions` does not have a value of none + """ + + assert self.model._meta.permissions is not None + + + def test_attribute_meta_type_permissions(self): + """Attribute Check + + Ensure attribute `Meta.permissions` value is of type list + """ + + assert type(self.model._meta.permissions) is list + + + def test_attribute_value_permissions_has_import(self): + """Attribute Check + + Ensure attribute `Meta.permissions` value contains permission + `import` + """ + + permission_found = False + + for permission, description in self.model._meta.permissions: + + if permission == 'import_' + self.model._meta.model_name: + + permission_found = True + break + + assert permission_found + + + def test_attribute_value_permissions_has_triage(self): + """Attribute Check + + Ensure attribute `Meta.permissions` value contains permission + `triage` + """ + + permission_found = False + + for permission, description in self.model._meta.permissions: + + if permission == 'triage_' + self.model._meta.model_name: + + permission_found = True + break + + assert permission_found + + + def test_attribute_value_permissions_has_purge(self): + """Attribute Check + + Ensure attribute `Meta.permissions` value contains permission + `purge` + """ + + permission_found = False + + for permission, description in self.model._meta.permissions: + + if permission == 'purge_' + self.model._meta.model_name: + + permission_found = True + break + + assert permission_found + + + def test_attribute_meta_type_sub_model_type(self): + """Attribute Check + + Ensure attribute `Meta.sub_model_type` value is of type str + """ + + assert type(self.model._meta.sub_model_type) is str + + + def test_attribute_meta_value_sub_model_type(self): + """Attribute Check + + Ensure attribute `Meta.sub_model_type` value is correct + """ + + assert self.model._meta.sub_model_type == self.sub_model_type + + + def test_attribute_type_get_comment_type(self): + """Attribute Check + + Ensure attribute `get_comment_type` value is correct + """ + + assert self.item.get_comment_type == self.item._meta.sub_model_type + + + + def test_function_get_related_model(self): + """Function Check + + Confirm function `get_related_model` returns `None` for self + """ + + assert self.item.get_related_model() == None + + + + def test_function_get_related_field_name(self): + """Function Check + + Confirm function `get_related_field_name` returns an empty string + for self + """ + + assert self.item.get_related_field_name() == '' + + + + def test_function_get_url(self): + """Function Check + + Confirm function `get_url` returns the correct url + """ + + if self.item.parent: + + expected_value = '/core/ticket/' + str(self.item.ticket.id) + '/' + self.sub_model_type + '/' + str( + self.item.parent.id) + '/threads/' + str(self.item.id) + + else: + + expected_value = '/core/ticket/' + str( self.item.ticket.id) + '/' + self.sub_model_type + '/' + str(self.item.id) + + assert self.item.get_url() == '/api/v2' + expected_value + + + def test_function_parent_object(self): + """Function Check + + Confirm function `parent_object` returns the ticket + """ + + assert self.item.parent_object == self.item.ticket + + + def test_function_clean_validation_mismatch_comment_type_raises_exception(self): + """Function Check + + Ensure function `clean` does validation + """ + + valid_data = self.kwargs_create_item.copy() + + valid_data['comment_type'] = 'Nope' + + with pytest.raises(ValidationError) as err: + + self.model.objects.create( + **valid_data + ) + + assert err.value.get_codes()['comment_type'] == 'comment_type_wrong_endpoint' + + + + def test_function_called_clean_ticketcommentbase(self, model, mocker, ticket): + """Function Check + + Ensure function `TicketCommentBase.clean` is called + """ + + spy = mocker.spy(TicketCommentBase, 'clean') + + valid_data = self.kwargs_create_item.copy() + + valid_data['ticket'] = ticket + + del valid_data['external_system'] + del valid_data['external_ref'] + + model.objects.create( + **valid_data + ) + + assert spy.assert_called_once + + + +class TicketCommentBaseModelInheritedCases( + TicketCommentBaseModelTestCases, +): + """Sub-Ticket Test Cases + + Test Cases for Ticket models that inherit from model TicketCommentBase + """ + + kwargs_create_item: dict = {} + + model = None + + + sub_model_type = None + """Ticket Sub Model Type + + Ticket sub-models must have this attribute defined in `ModelNam.Meta.sub_model_type` + """ + + + +class TicketCommentBaseModelPyTest( + TicketCommentBaseModelTestCases, +): + + + def test_function_clean_validation_close_raises_exception(self, ticket): + """Function Check + + Ensure function `clean` does validation + """ + + valid_data = self.kwargs_create_item.copy() + + valid_data['ticket'] = ticket + + del valid_data['date_closed'] + + with pytest.raises(ValidationError) as err: + + self.model.objects.create( + **valid_data + ) + + assert err.value.get_codes()['date_closed'] == 'ticket_closed_no_date' From 580820ef441032ec5984e8653aacee1c30c165ad Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 16:11:59 +0930 Subject: [PATCH 02/31] test(core): Unit Model Checks for TicketCommentSolution ref: #744 #728 --- .../unit/ticket_comment_solution/__init__.py | 0 .../unit/ticket_comment_solution/conftest.py | 14 ++++ ...test_unit_ticket_comment_solution_model.py | 84 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_solution/__init__.py create mode 100644 app/core/tests/unit/ticket_comment_solution/conftest.py create mode 100644 app/core/tests/unit/ticket_comment_solution/test_unit_ticket_comment_solution_model.py diff --git a/app/core/tests/unit/ticket_comment_solution/__init__.py b/app/core/tests/unit/ticket_comment_solution/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/core/tests/unit/ticket_comment_solution/conftest.py b/app/core/tests/unit/ticket_comment_solution/conftest.py new file mode 100644 index 00000000..0b91e73a --- /dev/null +++ b/app/core/tests/unit/ticket_comment_solution/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from core.models.ticket_comment_solution import TicketCommentSolution + + + +@pytest.fixture( scope = 'class') +def model(request): + + request.cls.model = TicketCommentSolution + + yield request.cls.model + + del request.cls.model diff --git a/app/core/tests/unit/ticket_comment_solution/test_unit_ticket_comment_solution_model.py b/app/core/tests/unit/ticket_comment_solution/test_unit_ticket_comment_solution_model.py new file mode 100644 index 00000000..516688b0 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_solution/test_unit_ticket_comment_solution_model.py @@ -0,0 +1,84 @@ +import pytest + +from rest_framework.exceptions import ValidationError + +from core.models.ticket_comment_solution import TicketCommentSolution +from core.tests.unit.ticket_comment_base.test_unit_ticket_comment_base_model import ( + TicketCommentBaseModelInheritedCases +) + + +class TicketCommentSolutionModelTestCases( + TicketCommentBaseModelInheritedCases, +): + + sub_model_type = 'solution' + """Sub Model Type + + sub-models must have this attribute defined in `ModelName.Meta.sub_model_type` + """ + + kwargs_create_item: dict = { + 'comment_type': sub_model_type, + } + + + def test_class_inherits_ticketcommentsolution(self): + """ Class inheritence + + TenancyObject must inherit SaveHistory + """ + + assert issubclass(self.model, TicketCommentSolution) + + + + def test_function_called_clean_ticketcommentsolution(self, model, mocker, ticket): + """Function Check + + Ensure function `TicketCommentBase.clean` is called + """ + + spy = mocker.spy(TicketCommentSolution, 'clean') + + valid_data = self.kwargs_create_item.copy() + + valid_data['ticket'] = ticket + + del valid_data['external_system'] + del valid_data['external_ref'] + + model.objects.create( + **valid_data + ) + + assert spy.assert_called_once + + + +class TicketCommentSolutionModelInheritedCases( + TicketCommentSolutionModelTestCases, +): + """Sub-Ticket Test Cases + + Test Cases for Ticket models that inherit from model TicketCommentSolution + """ + + kwargs_create_item: dict = {} + + model = None + + + sub_model_type = None + """Ticket Sub Model Type + + Ticket sub-models must have this attribute defined in `ModelNam.Meta.sub_model_type` + """ + + + +class TicketCommentSolutionModelPyTest( + TicketCommentSolutionModelTestCases, +): + + pass From 7ddb72239e86da3300555fb4f3f5849a524ce080 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 16:13:38 +0930 Subject: [PATCH 03/31] chore(python): Add testing dep pytest-mock ref: #744 --- requirements_test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements_test.txt b/requirements_test.txt index 1c7ccf71..4f7172f6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,6 @@ pytest==8.3.5 pytest-django==4.11.1 +pytest-mock==3.14.0 coverage==7.8.0 pytest-cov==6.1.1 From 45c428d30a2789866ef7fdeef521ea557733a88d Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 16:19:33 +0930 Subject: [PATCH 04/31] fix(core): Correct logic for TicketCommentBase ref: #744 #726 --- app/core/models/ticket_comment_base.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/app/core/models/ticket_comment_base.py b/app/core/models/ticket_comment_base.py index 8216bec0..642108f5 100644 --- a/app/core/models/ticket_comment_base.py +++ b/app/core/models/ticket_comment_base.py @@ -62,7 +62,6 @@ class TicketCommentBase( parent = models.ForeignKey( 'self', blank = True, - default = None, help_text = 'Parent ID for creating discussion threads', null = True, on_delete = models.PROTECT, @@ -80,7 +79,6 @@ class TicketCommentBase( external_ref = models.IntegerField( blank = True, - default = None, help_text = 'External System reference', null = True, verbose_name = 'Reference Number', @@ -89,7 +87,6 @@ class TicketCommentBase( external_system = models.IntegerField( blank = True, choices=TicketBase.Ticket_ExternalSystem, - default=None, help_text = 'External system this item derives', null=True, verbose_name = 'External System', @@ -98,7 +95,7 @@ class TicketCommentBase( @property def get_comment_type(self): - comment_type = str(self.Meta.sub_model_type).lower().replace( + comment_type = str(self._meta.sub_model_type).lower().replace( ' ', '_' ) @@ -134,7 +131,6 @@ class TicketCommentBase( category = models.ForeignKey( TicketCommentCategory, blank = True, - default = None, help_text = 'Category of the comment', null = True, on_delete = models.PROTECT, @@ -411,10 +407,3 @@ class TicketCommentBase( if hasattr(self.ticket, '_ticket_comments'): del self.ticket._ticket_comments - - # if self.comment_type == self.CommentType.SOLUTION: - - # update_ticket = self.ticket.__class__.objects.get(pk=self.ticket.id) - # update_ticket.status = int(TicketBase.TicketStatus.All.SOLVED.value) - - # update_ticket.save() \ No newline at end of file From 457d329b0bfe1ff3b76c68ea71d86ff6eaa24d97 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 16:19:57 +0930 Subject: [PATCH 05/31] fix(core): Correct logic for TicketCommentSolution ref: #744 #728 --- app/core/models/ticket_comment_solution.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/core/models/ticket_comment_solution.py b/app/core/models/ticket_comment_solution.py index f57e7728..8056f982 100644 --- a/app/core/models/ticket_comment_solution.py +++ b/app/core/models/ticket_comment_solution.py @@ -62,6 +62,8 @@ class TicketCommentSolution( 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) + self.ticket.is_solved = self.is_closed self.ticket.date_solved = self.date_closed @@ -70,8 +72,6 @@ class TicketCommentSolution( self.ticket.save() - super().save(force_insert = force_insert, force_update = force_update, using = using, update_fields = update_fields) - # clear comment cache if hasattr(self.ticket, '_ticket_comments'): From d399698eb1162e105b2a90c388b7cfc0c7733448 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 16:20:44 +0930 Subject: [PATCH 06/31] test(core): Unit Model assert save and call are called for TicketBase ref: #744 #723 --- .../test_unit_ticket_base_model.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 44b43f95..2e4f7b9c 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,6 +850,51 @@ class TicketBaseModelTestCases( + def test_function_called_clean_ticketcommentbase(self, model, mocker): + """Function Check + + Ensure function `TicketBase.clean` is called + """ + + spy = mocker.spy(TicketBase, 'clean') + + valid_data = self.kwargs_create_item.copy() + + valid_data['title'] = 'was clean called' + + del valid_data['external_system'] + + model.objects.create( + **valid_data + ) + + assert spy.assert_called_once + + + + def test_function_called_save_ticketcommentbase(self, model, mocker): + """Function Check + + Ensure function `TicketBase.save` is called + """ + + spy = mocker.spy(TicketBase, 'save') + + valid_data = self.kwargs_create_item.copy() + + valid_data['title'] = 'was save called' + + del valid_data['external_system'] + + model.objects.create( + **valid_data + ) + + assert spy.assert_called_once + + + + class TicketBaseModelInheritedCases( TicketBaseModelTestCases, ): From 5900c13e08f27122d45a368a0ced05d2455d583b Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 16:21:42 +0930 Subject: [PATCH 07/31] docs(development): Add initial TicketCommentBase ref: #744 #726 --- .../development/core/ticket_comment.md | 45 +++++++++++++++++++ .../centurion_erp/development/models.md | 2 +- mkdocs.yml | 2 + 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 docs/projects/centurion_erp/development/core/ticket_comment.md diff --git a/docs/projects/centurion_erp/development/core/ticket_comment.md b/docs/projects/centurion_erp/development/core/ticket_comment.md new file mode 100644 index 00000000..790bb31b --- /dev/null +++ b/docs/projects/centurion_erp/development/core/ticket_comment.md @@ -0,0 +1,45 @@ +--- +title: Ticket Comment +description: Centurion ERP Base Model Ticket Comment development documentation +date: 2025-04-16 +template: project.html +about: https://github.com/nofusscomputing/centurion_erp +--- + +Ticket Comments is a base model within Centurion ERP. This base provides the core features for all subsequent sub-ticket_comment models. As such extending Centurion ERP with a new ticket comment type is a simple process. The adding of a ticket comment type only requires that you extend an existing ticket model containing only the changes for your new ticket type. + + +## Core Features + +- ... + + +## History + +Ticketing does not use the standard history model of Centurion ERP. History for a ticket is kept in the form of action comments. As each change to a ticket occurs, an action comment is created denoting the from and to in relation to a change. + + +## Model + +When creating your sub-model, do not re-define any field that is already specified within the model you are inheriting from. This is however, with the exception of the code docs specifying otherwise. + + +## Testing + +As with any other object within Centurion, the addition of a feature requires it be tested. The following Test Suites are available: + +- `Unit` Test Cases + + - `core.tests.unit.ticket_comment_base.<*>.InheritedCases` _(if inheriting from `TicketCommentBase`)_ Test cases for sub-models + + - ViewSet `core.tests.unit.ticket_comment_base.test_unit_ticket_comment_base_viewset.TicketCommentBaseViewsetInheritedCases` + +- `Functional` Test Cases + + - `core.tests.functional.ticket_comment_base.<*>.InheritedCases` _(if inheriting from `TicketCommentBase`)_ Test cases for sub-models + + - API Permissions `core.tests.functional.ticket_comment_base.test_functional_ticket_comment_base_permission.TicketCommentBasePermissionsAPIInheritedCases` + + - Model `app.core.tests.functional.ticket_comment_base.test_functional_ticket_comment_base_model.TicketCommentBaseModelInheritedTestCases` _(if inheriting from `TicketCommentBase`)_ Test cases for sub-models + +The above listed test cases cover **all** tests for objects that are inherited from the base class. To complete the tests, you will need to add test cases for the differences your model introduces. diff --git a/docs/projects/centurion_erp/development/models.md b/docs/projects/centurion_erp/development/models.md index 3b36a31a..53dcf582 100644 --- a/docs/projects/centurion_erp/development/models.md +++ b/docs/projects/centurion_erp/development/models.md @@ -96,7 +96,7 @@ We do have some core sub-models available. There intended purpose is to serve as - [Ticket](./core/ticket.md) -- Ticket Comment +- [Ticket Comment](./core/ticket_comment.md) All sub-models are intended to be extended and contain the core features for ALL models. This aids in extensibility and reduces the work required to add a model. diff --git a/mkdocs.yml b/mkdocs.yml index 3686e255..e4aaa8c4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -153,6 +153,8 @@ nav: - projects/centurion_erp/development/core/ticket.md + - projects/centurion_erp/development/core/ticket_comment.md + - projects/centurion_erp/development/views.md - User: From 806ffb2754aefc011f3849595ef40cc28119f97b Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 16:25:18 +0930 Subject: [PATCH 08/31] chore(python): upgrade django 5.1.8 -> 5.1.9 ref: #744 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0e3142c7..53804067 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -Django==5.1.8 +django==5.1.9 django-cors-headers==4.4.0 django-debug-toolbar==5.1.0 From 626a5ccb11cc08436bc0f2d70163f169f329f6ec Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 20:06:45 +0930 Subject: [PATCH 09/31] refactor(access): when fetching parent object, use the parent_model get function ref: #744 --- app/access/mixins/organization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/access/mixins/organization.py b/app/access/mixins/organization.py index 308ccde1..2d29d313 100644 --- a/app/access/mixins/organization.py +++ b/app/access/mixins/organization.py @@ -130,7 +130,7 @@ class OrganizationMixin: parent_model (Model): with PK from kwargs['pk'] """ - return self.parent_model.objects.get(pk=self.kwargs[self.parent_model_pk_kwarg]) + return self.get_parent_model().objects.get(pk=self.kwargs[self.parent_model_pk_kwarg]) From 85b5bf7b58bb616aa62ad30abb748e7a08b452e9 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 20:08:06 +0930 Subject: [PATCH 10/31] feat(core): Do validate the comment_type field for TicketCommentBase ref: #744 #726 --- app/core/models/ticket_comment_base.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/core/models/ticket_comment_base.py b/app/core/models/ticket_comment_base.py index 642108f5..35856978 100644 --- a/app/core/models/ticket_comment_base.py +++ b/app/core/models/ticket_comment_base.py @@ -45,6 +45,19 @@ class TicketCommentBase( verbose_name_plural = "Ticket Comments" + def field_validation_not_empty(value): + + if value == '' or value is None: + + raise centurion_exception.ValidationError( + detail = { + 'comment_type': 'Comment Type requires a value.' + }, + code = 'comment_type_empty_or_null' + ) + + return True + model_notes = None @@ -125,6 +138,9 @@ class TicketCommentBase( help_text = 'Type this comment is. derived from Meta.verbose_name', max_length = 30, null = False, + validators = [ + field_validation_not_empty + ], verbose_name = 'Type', ) From eb2282efac223642edb431569f774473800b1f09 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 20:40:45 +0930 Subject: [PATCH 11/31] test(core): Unit API Fields Render for TicketCommentBase model ref: #744 #726 --- ...est_unit_ticket_comment_base_api_fields.py | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py diff --git a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py new file mode 100644 index 00000000..7bf2c144 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py @@ -0,0 +1,338 @@ +import pytest + +from django.contrib.auth.models import ContentType, Permission, User + +from rest_framework.relations import Hyperlink + +from app.tests.common import DoesNotExist + +from api.tests.unit.test_unit_api_fields import ( + APIFieldsInheritedCases, +) + +from core.models.ticket_comment_base import ( + Entity, + TicketBase, + TicketCommentBase, + TicketCommentCategory +) + + + +class TicketCommentBaseAPITestCases( + APIFieldsInheritedCases, +): + + base_model = TicketCommentBase + + + @pytest.fixture( scope = 'class') + def setup_model(self, request, django_db_blocker, + model, + ): + + with django_db_blocker.unblock(): + + + ticket_view_permission = Permission.objects.get( + codename = 'view_' + TicketBase._meta.model_name, + content_type = ContentType.objects.get( + app_label = TicketBase._meta.app_label, + model = TicketBase._meta.model_name, + ) + ) + + request.cls.view_team.permissions.add( ticket_view_permission ) + + + + + category = TicketCommentCategory.objects.create( + organization = request.cls.organization, + name = 'comment category' + ) + + ticket_user = User.objects.create_user(username="ticket_user", password="password") + + ticket = TicketBase.objects.create( + organization = request.cls.organization, + title = 'ticket comment title', + opened_by = ticket_user, + ) + + + comment_user = Entity.objects.create( + organization = request.cls.organization, + ) + + + valid_data = request.cls.kwargs_create_item.copy() + + valid_data['body'] = 'the parent comment' + valid_data['user'] = comment_user + valid_data['ticket'] = ticket + valid_data['comment_type'] = TicketCommentBase._meta.sub_model_type + + del valid_data['external_ref'] + del valid_data['external_system'] + del valid_data['category'] + del valid_data['parent'] + del valid_data['template'] + + parent_comment = TicketCommentBase.objects.create( + **valid_data + ) + + + valid_data['body'] = 'the template comment' + + template_comment = TicketCommentBase.objects.create( + **valid_data + ) + + + request.cls.kwargs_create_item.update({ + 'category': category, + 'ticket': ticket, + 'user': comment_user, + 'parent': parent_comment, + 'template': template_comment, + 'comment_type': model._meta.sub_model_type + }) + + + + yield + + + + with django_db_blocker.unblock(): + + parent_comment.delete() + + template_comment.delete() + + category.delete() + + + for comment in ticket.ticketcommentbase_set.all(): + + comment.delete() + + ticket.delete() + + ticket_user.delete() + + + + + + @pytest.fixture( scope = 'class') + def post_model(self, request, model ): + + request.cls.url_view_kwargs.update({ + 'ticket_id': request.cls.item.ticket.id + }) + + if ( + model != self.base_model + or self.item.parent + ): + + request.cls.url_view_kwargs.update({ + 'ticket_comment_model': model._meta.sub_model_type + }) + + if self.item.parent: + + request.cls.url_ns_name = '_api_v2_ticket_comment_base_sub_thread' + + request.cls.url_view_kwargs.update({ + 'parent_id': self.item.parent.id + }) + + + + + + + + @pytest.fixture( scope = 'class', autouse = True) + def class_setup(self, request, django_db_blocker, + setup_pre, + setup_model, + create_model, + post_model, + setup_post, + ): + + pass + + + + @property + def parameterized_test_data(self): + + return { + + 'parent': { + 'expected': dict + }, + 'parent.id': { + 'expected': int + }, + 'parent.display_name': { + 'expected': str + }, + 'parent.url': { + 'expected': str + }, + + + 'ticket': { + 'expected': dict + }, + 'ticket.id': { + 'expected': int + }, + 'ticket.display_name': { + 'expected': str + }, + 'ticket.url': { + 'expected': str + }, + + 'external_ref': { + 'expected': int + }, + 'external_system': { + 'expected': int + }, + 'comment_type': { + 'expected': str + }, + 'category': { + 'expected': dict + }, + 'category.id': { + 'expected': int + }, + 'category.display_name': { + 'expected': str + }, + 'category.url': { + 'expected': Hyperlink + }, + + 'body': { + 'expected': str + }, + 'private': { + 'expected': bool + }, + 'duration': { + 'expected': int + }, + 'estimation': { + 'expected': int + }, + 'template': { + 'expected': dict + }, + 'template.id': { + 'expected': int + }, + 'template.display_name': { + 'expected': str + }, + 'template.url': { + 'expected': str + }, + + 'is_template': { + 'expected': bool + }, + 'source': { + 'expected': int + }, + 'user': { + 'expected': dict + }, + 'user.id': { + 'expected': int + }, + 'user.display_name': { + 'expected': str + }, + 'user.url': { + 'expected': Hyperlink + }, + + 'is_closed': { + 'expected': bool + }, + 'date_closed': { + 'expected': str + }, + + '_urls.threads': { + 'expected': str + }, + # Below fields dont exist. + + 'display_name': { + 'expected': DoesNotExist + }, + 'model_notes': { + 'expected': DoesNotExist + }, + '_urls.notes': { + 'expected': DoesNotExist + }, + } + + + + kwargs_create_item: dict = { + 'parent': '', + 'ticket': '', + 'external_ref': 123, + 'external_system': TicketBase.Ticket_ExternalSystem.CUSTOM_1, + 'comment_type': '', + 'category': '', + 'body': 'the ticket comment', + 'private': False, + 'duration': 1, + 'estimation': 2, + 'template': '', + 'is_template': True, + 'source': TicketBase.TicketSource.HELPDESK, + 'user': '', + 'is_closed': True, + 'date_closed': '2025-05-09T19:32Z', + } + + + + url_ns_name = '_api_v2_ticket_comment_base' + """Url namespace (optional, if not required) and url name""" + + + +class TicketCommentBaseAPIInheritedCases( + TicketCommentBaseAPITestCases, +): + + kwargs_create_item: dict = None + + model = None + + url_ns_name = '_api_v2_ticket_comment_base_sub' + + + +class TicketCommentBaseAPIPyTest( + TicketCommentBaseAPITestCases, +): + + pass From 4f8be435273db6bbb0c01388ad24d73581182c6e Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 20:41:26 +0930 Subject: [PATCH 12/31] test(core): Unit API Fields Render for TicketCommentSolution model ref: #744 #728 --- .../unit/ticket_comment_base/__init__.py | 0 ...st_unit_ticket_solution_base_api_fields.py | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_base/__init__.py create mode 100644 app/core/tests/unit/ticket_comment_solution/test_unit_ticket_solution_base_api_fields.py diff --git a/app/core/tests/unit/ticket_comment_base/__init__.py b/app/core/tests/unit/ticket_comment_base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/core/tests/unit/ticket_comment_solution/test_unit_ticket_solution_base_api_fields.py b/app/core/tests/unit/ticket_comment_solution/test_unit_ticket_solution_base_api_fields.py new file mode 100644 index 00000000..22160ffd --- /dev/null +++ b/app/core/tests/unit/ticket_comment_solution/test_unit_ticket_solution_base_api_fields.py @@ -0,0 +1,31 @@ +from core.tests.unit.ticket_comment_base.test_unit_ticket_comment_base_api_fields import ( + TicketCommentBaseAPIInheritedCases +) + + + +class TicketCommentSolutionAPITestCases( + TicketCommentBaseAPIInheritedCases, +): + + parameterized_test_data = {} + + kwargs_create_item: dict = {} + + + +class TicketCommentSolutionAPIInheritedCases( + TicketCommentSolutionAPITestCases, +): + + kwargs_create_item: dict = {None} + + model = None + + + +class TicketCommentSolutionAPIPyTest( + TicketCommentSolutionAPITestCases, +): + + pass From d4d99772b9a6342459c78351c38d69c5c5dbd3d5 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 20:42:05 +0930 Subject: [PATCH 13/31] test(core): correct field so its valid for unit TicketCommentBase model ref: #744 #726 --- app/api/tests/unit/test_unit_api_fields.py | 2 ++ .../ticket_comment_base/test_unit_ticket_comment_base_model.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/api/tests/unit/test_unit_api_fields.py b/app/api/tests/unit/test_unit_api_fields.py index 93c329ce..184572a8 100644 --- a/app/api/tests/unit/test_unit_api_fields.py +++ b/app/api/tests/unit/test_unit_api_fields.py @@ -135,6 +135,8 @@ class APIFieldsTestCases: organization = request.cls.organization, ) + request.cls.view_team = view_team + view_team.permissions.set([view_permissions]) diff --git a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py index 72dfbc28..95c6c569 100644 --- a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py +++ b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py @@ -40,7 +40,7 @@ class TicketCommentBaseModelTestCases( 'source': TicketBase.TicketSource.HELPDESK, 'user': '', 'is_closed': True, - 'date_closed': '2025-05-08T171000', + 'date_closed': '2025-05-08T17:10Z', } From b6da539fcd831102727fbd94e71da399d400bff7 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 21:02:55 +0930 Subject: [PATCH 14/31] fix(core): Spent slash command is valid for time spent ref: #744 --- app/core/lib/slash_commands/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/core/lib/slash_commands/__init__.py b/app/core/lib/slash_commands/__init__.py index 5bdded1a..79f1eb4e 100644 --- a/app/core/lib/slash_commands/__init__.py +++ b/app/core/lib/slash_commands/__init__.py @@ -67,7 +67,10 @@ class SlashCommands( returned_line = '' - if command == 'spend': + if( + command == 'spend' + or command == 'spent' + ): returned_line = re.sub(self.time_spent, self.command_duration, line) From 0c80b8760603c7472972bd03d720b45658d0c88a Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 21:03:44 +0930 Subject: [PATCH 15/31] test(core): Partial Slash Command re-write ref: #744 #730 --- .../test_slash_command_related.py | 1569 +++++++++++++++++ 1 file changed, 1569 insertions(+) diff --git a/app/core/tests/functional/slash_commands/test_slash_command_related.py b/app/core/tests/functional/slash_commands/test_slash_command_related.py index c8a9996d..b8db51e7 100644 --- a/app/core/tests/functional/slash_commands/test_slash_command_related.py +++ b/app/core/tests/functional/slash_commands/test_slash_command_related.py @@ -1,15 +1,20 @@ import pytest +import re import unittest from django.contrib.auth.models import User from django.test import TestCase from access.models.organization import Organization +from access.models.person import Person from core.models.ticket.ticket import Ticket from core.models.ticket.ticket_comment import TicketComment from core.models.ticket.ticket_linked_items import TicketLinkedItem +from core.models.ticket_comment_base import TicketBase, TicketCommentBase + + from itam.models.device import Device from itam.models.software import Software @@ -1822,3 +1827,1567 @@ def test_slash_command_spend_comment_time_format_comment_correct(test_input, exp assert comment.duration == expected +########################################################################################################### +# +# PyTest re-write +# +########################################################################################################### + + +class SlashCommandsFixtures: + """Common Fixtures + + Fixtures required to setup Ticket and Ticket Comment test cases. + """ + + @pytest.fixture(scope = 'class') + def setup_class(self, request, + organization_one, + django_db_blocker, + ): + + request.cls.organization = organization_one + + with django_db_blocker.unblock(): + + request.cls.ticket_user = User.objects.create_user(username="test_user_for_tickets", password="password") + + + request.cls.entity_user = Person.objects.create( + organization = request.cls.organization, + f_name = 'ip', + l_name = 'funny' + ) + + + request.cls.existing_ticket = Ticket.objects.create( + organization = request.cls.organization, + title = 'an existing ticket', + description = "the ticket body", + ticket_type = Ticket.TicketType.REQUEST, + opened_by = request.cls.ticket_user, + ) + + + + yield + + with django_db_blocker.unblock(): + + request.cls.existing_ticket.delete() + + request.cls.ticket_user.delete() + + request.cls.entity_user.delete() + + + @pytest.fixture( scope = 'class', autouse = True) + def class_setup(self, + setup_class + ): + pass + + + +class SlashCommandsCommon: + """Common Test Case items + + Required for Ticket Comment and Ticket Slash Commands Test cases. + """ + + single_line_with_command = 'A single line comment COMMAND' + + single_line_command_own_line_lf = 'A single line comment\nCOMMAND' + single_line_command_own_line_crlf = 'A single line comment\r\nCOMMAND' + + single_line_blank_line_command_own_line_lf = 'A single line comment\n\nCOMMAND' + single_line_blank_line_command_own_line_crlf = 'A single line comment\r\n\r\nCOMMAND' + + single_line_blank_line_command_own_line_blank_line_lf = 'A single line comment\n\nCOMMAND\n' + single_line_blank_line_command_own_line_blank_line_crlf = 'A single line comment\r\n\r\nCOMMAND\r\n' + + single_line_command_own_line_blank_line_lf = 'A single line comment\nCOMMAND\n' + single_line_command_own_line_blank_line_crlf = 'A single line comment\r\nCOMMAND\r\n' + + + parameterized_slash_command = { + 'relate_existing_ticket': { + 'relate': True, + 'slash_command': 'relate', + 'command_obj': '#EXISTINGTICKET', + }, + + 'blocks_existing_ticket': { + 'blocks': True, + 'slash_command': 'blocks', + 'command_obj': '#EXISTINGTICKET', + }, + + 'blocked_by_existing_ticket': { + 'blocked_by': True, + 'slash_command': 'blocked_by', + 'command_obj': '#EXISTINGTICKET', + }, + + 'spend_full_no_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h2m3s', + }, + 'spend_full_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h 2m 3s', + }, + 'spend_hour_minute_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h 2m', + }, + 'spend_hour_second_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h 3s', + }, + 'spend_minute_second_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '5m 3s', + }, + 'spend_hour': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h', + }, + 'spend_minute': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1m', + }, + 'spend_second': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '4s', + }, + + 'spent_full_no_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h2m3s', + }, + 'spent_full_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h 2m 3s', + }, + 'spent_hour_minute_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h 2m', + }, + 'spent_hour_second_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h 3s', + }, + 'spent_minute_second_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '5m 3s', + }, + 'spent_hour': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h', + }, + 'spent_minute': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1m', + }, + 'spent_second': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '4s', + }, + + } + + + +class SlashCommandsTicketTestCases( + SlashCommandsCommon +): + """Ticket Test Cases for Slash Commands + + Use these test cases to test tickets for Slash Command functionality. + + Requires a fixture called `Ticket` + """ + pass + + # def test_slash_command_spend_ticket_duration_added(self, + # ticket, + # parameterized, param_key_slash_command, param_name, + # param_slash_command, + # param_command_obj, + # param_spend, + # ): + # """Slash command Check + + # Ensure the `spend` slash command adds the duration to a ticket comment + # within the duration field. + # """ + + # comment_text = self.single_line_command_own_line_blank_line_crlf + + # durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + # hour = durations['hour'] + + # if not hour: + # hour = 0 + + # else: + # hour = str(durations['hour']).replace('h', '') + + # hour = (int(hour) * 60) * 60 + + + # minute = durations['minute'] + + # if not minute: + # minute = 0 + + # else: + # minute = str(durations.get('minute', 0)).replace('m', '') + + # minute = int(minute) * 60 + + + # second = durations['second'] + + # if not second: + # second = 0 + # else: + # second = str(durations['second']).replace('s', '') + + # second = int(second) + + # duration_in_seconds = hour + minute + second + + + # assert 'COMMAND' in comment_text + # # COMMAND must be in ticket comment so it can be constructed + + # command_obj = str(param_command_obj).replace( + # 'EXISTINGTICKET', str(self.existing_ticket.id) + # ) + + # ticket.description = str( + # comment_text.replace( + # 'COMMAND', '/' + param_slash_command + ' ' + command_obj + # ) + # ) + + + # ticket.save() + + # ticket_comment = ticket.ticketcommentbase_set.all() + + # assert len(ticket_comment) == 1 + # # A comment should have been created that contains the date, time and + # # duration of the time spent. + + # ticket_comment = ticket_comment[0] + + + # assert ticket_comment.duration == duration_in_seconds + + + +class SlashCommandsTicketCommentTestCases( + SlashCommandsCommon +): + + # existing_ticket = None + + + def test_slash_command_ticket_comment_single_line_with_command_removed_from_comment(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_with_command + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ( + param_slash_command in ticket_comment.body + and command_obj in ticket_comment.body + ) + + + + def test_slash_command_ticket_comment_single_line_command_own_line_lf_command_removed_from_comment(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_command_own_line_lf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ( + param_slash_command not in ticket_comment.body + and command_obj not in ticket_comment.body + ) + + + + def test_slash_command_ticket_comment_single_line_command_own_line_crlf_command_removed_from_comment(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_command_own_line_crlf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ( + param_slash_command not in ticket_comment.body + and command_obj not in ticket_comment.body + ) + + + + + def test_slash_command_ticket_comment_single_line_blank_line_command_own_line_lf_command_removed_from_comment(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_blank_line_command_own_line_lf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ( + param_slash_command not in ticket_comment.body + and command_obj not in ticket_comment.body + ) + + + + + def test_slash_command_ticket_comment_single_line_blank_line_command_own_line_crlf_command_removed_from_comment(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_blank_line_command_own_line_crlf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ( + param_slash_command not in ticket_comment.body + and command_obj not in ticket_comment.body + ) + + + + + + def test_slash_command_ticket_comment_single_line_blank_line_command_own_line_blank_line_lf_command_removed_from_comment(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_blank_line_command_own_line_blank_line_lf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ( + param_slash_command not in ticket_comment.body + and command_obj not in ticket_comment.body + ) + + + + + + def test_slash_command_ticket_comment_single_line_blank_line_command_own_line_blank_line_crlf_command_removed_from_comment(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_blank_line_command_own_line_blank_line_crlf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ( + param_slash_command not in ticket_comment.body + and command_obj not in ticket_comment.body + ) + + + + + + def test_slash_command_ticket_comment_single_line_command_own_line_blank_line_lf_command_removed_from_comment(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_command_own_line_blank_line_lf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ( + param_slash_command not in ticket_comment.body + and command_obj not in ticket_comment.body + ) + + + + + + def test_slash_command_ticket_comment_single_line_command_own_line_blank_line_crlf_command_removed_from_comment(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_command_own_line_blank_line_crlf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ( + param_slash_command not in ticket_comment.body + and command_obj not in ticket_comment.body + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # def test_slash_command_spend_ticket_duration_added(self, + # ticket, + # parameterized, param_key_slash_command, param_name, + # param_slash_command, + # param_command_obj, + # param_spend, + # ): + # """Slash command Check + + # Ensure the `spend` slash command adds the duration to a ticket comment + # within the duration field. + # """ + + # comment_text = self.single_line_command_own_line_blank_line_crlf + + # durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + # hour = durations['hour'] + + # if not hour: + # hour = 0 + + # else: + # hour = str(durations['hour']).replace('h', '') + + # hour = (int(hour) * 60) * 60 + + + # minute = durations['minute'] + + # if not minute: + # minute = 0 + + # else: + # minute = str(durations.get('minute', 0)).replace('m', '') + + # minute = int(minute) * 60 + + + # second = durations['second'] + + # if not second: + # second = 0 + # else: + # second = str(durations['second']).replace('s', '') + + # second = int(second) + + # duration_in_seconds = hour + minute + second + + + # assert 'COMMAND' in comment_text + # # COMMAND must be in ticket comment so it can be constructed + + # command_obj = str(param_command_obj).replace( + # 'EXISTINGTICKET', str(self.existing_ticket.id) + # ) + + # ticket.description = str( + # comment_text.replace( + # 'COMMAND', '/' + param_slash_command + ' ' + command_obj + # ) + # ) + + + # ticket.save() + + # ticket_comment = ticket.ticketcommentbase_set.all() + + # assert len(ticket_comment) == 1 + # # A comment should have been created that contains the date, time and + # # duration of the time spent. + + # ticket_comment = ticket_comment[0] + + + # assert ticket_comment.duration == duration_in_seconds + + + + + def test_slash_command_ticket_comment_single_line_duration_not_added(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_with_command + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + hour = durations['hour'] + + if not hour: + hour = 0 + + else: + hour = str(durations['hour']).replace('h', '') + + hour = (int(hour) * 60) * 60 + + + minute = durations['minute'] + + if not minute: + minute = 0 + + else: + minute = str(durations.get('minute', 0)).replace('m', '') + + minute = int(minute) * 60 + + + second = durations['second'] + + if not second: + second = 0 + else: + second = str(durations['second']).replace('s', '') + + second = int(second) + + duration_in_seconds = hour + minute + second + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ticket_comment.duration == 0 + + + + def test_slash_command_ticket_comment_single_line_command_own_line_lf_duration_added(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_command_own_line_lf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + hour = durations['hour'] + + if not hour: + hour = 0 + + else: + hour = str(durations['hour']).replace('h', '') + + hour = (int(hour) * 60) * 60 + + + minute = durations['minute'] + + if not minute: + minute = 0 + + else: + minute = str(durations.get('minute', 0)).replace('m', '') + + minute = int(minute) * 60 + + + second = durations['second'] + + if not second: + second = 0 + else: + second = str(durations['second']).replace('s', '') + + second = int(second) + + duration_in_seconds = hour + minute + second + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ticket_comment.duration == duration_in_seconds + + + + def test_slash_command_ticket_comment_single_line_command_own_line_crlf_duration_added(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_command_own_line_crlf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + hour = durations['hour'] + + if not hour: + hour = 0 + + else: + hour = str(durations['hour']).replace('h', '') + + hour = (int(hour) * 60) * 60 + + + minute = durations['minute'] + + if not minute: + minute = 0 + + else: + minute = str(durations.get('minute', 0)).replace('m', '') + + minute = int(minute) * 60 + + + second = durations['second'] + + if not second: + second = 0 + else: + second = str(durations['second']).replace('s', '') + + second = int(second) + + duration_in_seconds = hour + minute + second + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ticket_comment.duration == duration_in_seconds + + + + + def test_slash_command_ticket_comment_single_line_blank_line_command_own_line_lf_duration_added(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_blank_line_command_own_line_lf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + hour = durations['hour'] + + if not hour: + hour = 0 + + else: + hour = str(durations['hour']).replace('h', '') + + hour = (int(hour) * 60) * 60 + + + minute = durations['minute'] + + if not minute: + minute = 0 + + else: + minute = str(durations.get('minute', 0)).replace('m', '') + + minute = int(minute) * 60 + + + second = durations['second'] + + if not second: + second = 0 + else: + second = str(durations['second']).replace('s', '') + + second = int(second) + + duration_in_seconds = hour + minute + second + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ticket_comment.duration == duration_in_seconds + + + + + def test_slash_command_ticket_comment_single_line_blank_line_command_own_line_crlf_duration_added(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_blank_line_command_own_line_crlf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + hour = durations['hour'] + + if not hour: + hour = 0 + + else: + hour = str(durations['hour']).replace('h', '') + + hour = (int(hour) * 60) * 60 + + + minute = durations['minute'] + + if not minute: + minute = 0 + + else: + minute = str(durations.get('minute', 0)).replace('m', '') + + minute = int(minute) * 60 + + + second = durations['second'] + + if not second: + second = 0 + else: + second = str(durations['second']).replace('s', '') + + second = int(second) + + duration_in_seconds = hour + minute + second + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ticket_comment.duration == duration_in_seconds + + + + + + def test_slash_command_ticket_comment_single_line_blank_line_command_own_line_blank_line_lf_duration_added(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_blank_line_command_own_line_blank_line_lf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + hour = durations['hour'] + + if not hour: + hour = 0 + + else: + hour = str(durations['hour']).replace('h', '') + + hour = (int(hour) * 60) * 60 + + + minute = durations['minute'] + + if not minute: + minute = 0 + + else: + minute = str(durations.get('minute', 0)).replace('m', '') + + minute = int(minute) * 60 + + + second = durations['second'] + + if not second: + second = 0 + else: + second = str(durations['second']).replace('s', '') + + second = int(second) + + duration_in_seconds = hour + minute + second + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ticket_comment.duration == duration_in_seconds + + + + + + def test_slash_command_ticket_comment_single_line_blank_line_command_own_line_blank_line_crlf_duration_added(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_blank_line_command_own_line_blank_line_crlf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + hour = durations['hour'] + + if not hour: + hour = 0 + + else: + hour = str(durations['hour']).replace('h', '') + + hour = (int(hour) * 60) * 60 + + + minute = durations['minute'] + + if not minute: + minute = 0 + + else: + minute = str(durations.get('minute', 0)).replace('m', '') + + minute = int(minute) * 60 + + + second = durations['second'] + + if not second: + second = 0 + else: + second = str(durations['second']).replace('s', '') + + second = int(second) + + duration_in_seconds = hour + minute + second + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ticket_comment.duration == duration_in_seconds + + + + + + def test_slash_command_ticket_comment_single_line_command_own_line_blank_line_lf_duration_added(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_command_own_line_blank_line_lf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + hour = durations['hour'] + + if not hour: + hour = 0 + + else: + hour = str(durations['hour']).replace('h', '') + + hour = (int(hour) * 60) * 60 + + + minute = durations['minute'] + + if not minute: + minute = 0 + + else: + minute = str(durations.get('minute', 0)).replace('m', '') + + minute = int(minute) * 60 + + + second = durations['second'] + + if not second: + second = 0 + else: + second = str(durations['second']).replace('s', '') + + second = int(second) + + duration_in_seconds = hour + minute + second + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ticket_comment.duration == duration_in_seconds + + + + + + def test_slash_command_ticket_comment_single_line_command_own_line_blank_line_crlf_duration_added(self, + ticket_comment, + parameterized, param_key_slash_command, param_name, + param_slash_command, + param_command_obj, + ): + """Slash command Check + + Ensure the command is removed from a comment + """ + + comment_text = self.single_line_command_own_line_blank_line_crlf + + assert 'COMMAND' in comment_text + # COMMAND must be in ticket comment so it can be constructed + + command_obj = str(param_command_obj).replace( + 'EXISTINGTICKET', str(self.existing_ticket.id) + ) + + durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + hour = durations['hour'] + + if not hour: + hour = 0 + + else: + hour = str(durations['hour']).replace('h', '') + + hour = (int(hour) * 60) * 60 + + + minute = durations['minute'] + + if not minute: + minute = 0 + + else: + minute = str(durations.get('minute', 0)).replace('m', '') + + minute = int(minute) * 60 + + + second = durations['second'] + + if not second: + second = 0 + else: + second = str(durations['second']).replace('s', '') + + second = int(second) + + duration_in_seconds = hour + minute + second + + ticket_comment.body = str( + comment_text.replace( + 'COMMAND', '/' + param_slash_command + ' ' + command_obj + ) + ) + + + ticket_comment.save() + + + assert ticket_comment.duration == duration_in_seconds + + + + + + + + + + + + + + + + + + # def test_slash_command_spend_ticket_comment_duration_added(self, + # ticket_comment, + # parameterized, param_key_slash_command, param_name, + # param_slash_command, + # param_command_obj, + # param_spend, + # ): + # """Slash command Check + + # Ensure the `spend` slash command adds the duration to the tickets + # duration field. + # """ + + # comment_text = self.single_line_command_own_line_blank_line_crlf + + # assert 'COMMAND' in comment_text + # # COMMAND must be in ticket comment so it can be constructed + + # command_obj = str(param_command_obj).replace( + # 'EXISTINGTICKET', str(self.existing_ticket.id) + # ) + + + # durations = re.match('(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s)?', param_command_obj).groupdict() + + # hour = durations['hour'] + + # if not hour: + # hour = 0 + + # else: + # hour = str(durations['hour']).replace('h', '') + + # hour = (int(hour) * 60) * 60 + + + # minute = durations['minute'] + + # if not minute: + # minute = 0 + + # else: + # minute = str(durations.get('minute', 0)).replace('m', '') + + # minute = int(minute) * 60 + + + # second = durations['second'] + + # if not second: + # second = 0 + # else: + # second = str(durations['second']).replace('s', '') + + # second = int(second) + + # duration_in_seconds = hour + minute + second + + # ticket_comment.body = str( + # comment_text.replace( + # 'COMMAND', '/' + param_slash_command + ' ' + command_obj + # ) + # ) + + + # ticket_comment.save() + + + # assert ticket_comment.duration == duration_in_seconds + + + +class SlashCommandsTicketInheritedTestCases( + SlashCommandsFixtures, + SlashCommandsTicketTestCases, +): + + pass + + + +class SlashCommandsTicketCommentInheritedTestCases( + SlashCommandsFixtures, + SlashCommandsTicketCommentTestCases +): + + pass + + + +class SlashCommandsPyTest( + SlashCommandsFixtures, + SlashCommandsTicketTestCases, + SlashCommandsTicketCommentTestCases +): + + + + @pytest.fixture + def ticket(self, request, django_db_blocker): + """ Ticket that requires body + + when using this fixture, set the `description` then call ticket.save() + before use. + """ + + with django_db_blocker.unblock(): + + ticket = TicketBase() + + ticket.organization = request.cls.organization + ticket.title = 'A ticket for slash commands' + ticket.opened_by = request.cls.ticket_user + + ticket = TicketBase.objects.create( + organization = request.cls.organization, + title = 'A ticket for slash commands', + opened_by = request.cls.ticket_user, + ) + + yield ticket + + with django_db_blocker.unblock(): + + ticket.delete() + + + + @pytest.fixture + def ticket_comment(self, request, django_db_blocker, ticket): + """ Ticket Comment that requires body + + when using this fixture, set the `body` then call ticket_comment.save() + before use. + """ + + with django_db_blocker.unblock(): + + ticket.title = 'slash command ticket with comment' + + ticket.save() + + ticket_comment = TicketCommentBase() + + ticket_comment.user = request.cls.entity_user + + ticket_comment.ticket = ticket + + ticket_comment.comment_type = 'comment' + + yield ticket_comment + + ticket_comment.delete() + From 6c3122a3d89e042f7e8d5757b725a3d0f5cc9885 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 21:04:31 +0930 Subject: [PATCH 16/31] test(core): Functional Model test cases (Slash Commands) for TicketBaseModel ref: #744 #723 --- .../test_functional_ticket_base_model.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 app/core/tests/functional/ticket_base/test_functional_ticket_base_model.py diff --git a/app/core/tests/functional/ticket_base/test_functional_ticket_base_model.py b/app/core/tests/functional/ticket_base/test_functional_ticket_base_model.py new file mode 100644 index 00000000..0be828db --- /dev/null +++ b/app/core/tests/functional/ticket_base/test_functional_ticket_base_model.py @@ -0,0 +1,53 @@ +import pytest + +from core.tests.functional.slash_commands.test_slash_command_related import SlashCommandsTicketInheritedTestCases + + + +class TicketBaseModelTestCases( + SlashCommandsTicketInheritedTestCases +): + + + @pytest.fixture + def ticket(self, request, django_db_blocker, model): + """ Ticket that requires body + + when using this fixture, set the `description` then call ticket.save() + before use. + """ + + with django_db_blocker.unblock(): + + ticket = model() + + ticket.organization = request.cls.organization + ticket.title = 'A ticket for slash commands' + ticket.opened_by = request.cls.ticket_user + + # ticket = TicketBase.objects.create( + # organization = request.cls.organization, + # title = 'A ticket for slash commands', + # opened_by = request.cls.ticket_user, + # ) + + yield ticket + + with django_db_blocker.unblock(): + + ticket.delete() + + +class TicketBaseModelInheritedTestCases( + TicketBaseModelTestCases +): + + pass + + + +class TicketBaseModelPyTest( + TicketBaseModelTestCases +): + + pass From a081c6a3715ada187f02b907d3cd23e95ba05189 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 9 May 2025 21:05:54 +0930 Subject: [PATCH 17/31] test(core): Partial Functional Model test cases (Slash Commands) for TicketCommentBase ref: #744 #726 --- .../ticket_comment_base/conftest.py | 14 +++ ...st_functional_ticket_comment_base_model.py | 85 +++++++++++++++++++ .../centurion_erp/development/core/ticket.md | 2 + 3 files changed, 101 insertions(+) create mode 100644 app/core/tests/functional/ticket_comment_base/conftest.py create mode 100644 app/core/tests/functional/ticket_comment_base/test_functional_ticket_comment_base_model.py diff --git a/app/core/tests/functional/ticket_comment_base/conftest.py b/app/core/tests/functional/ticket_comment_base/conftest.py new file mode 100644 index 00000000..daaa25be --- /dev/null +++ b/app/core/tests/functional/ticket_comment_base/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from core.models.ticket_comment_base import TicketCommentBase + + + +@pytest.fixture( scope = 'class') +def model(request): + + request.cls.model = TicketCommentBase + + yield request.cls.model + + del request.cls.model diff --git a/app/core/tests/functional/ticket_comment_base/test_functional_ticket_comment_base_model.py b/app/core/tests/functional/ticket_comment_base/test_functional_ticket_comment_base_model.py new file mode 100644 index 00000000..d94ef273 --- /dev/null +++ b/app/core/tests/functional/ticket_comment_base/test_functional_ticket_comment_base_model.py @@ -0,0 +1,85 @@ +import pytest + +from core.models.ticket.ticket import Ticket +from core.tests.functional.slash_commands.test_slash_command_related import SlashCommandsTicketCommentInheritedTestCases + + + +class TicketCommentBaseModelTestCases( + SlashCommandsTicketCommentInheritedTestCases +): + + + + @pytest.fixture + def ticket(self, request, django_db_blocker): + """ Ticket that requires body + + when using this fixture, set the `description` then call ticket.save() + before use. + """ + + from core.models.ticket_comment_base import TicketBase + + with django_db_blocker.unblock(): + + ticket = TicketBase() + + ticket.organization = request.cls.organization + ticket.title = 'A ticket for slash commands' + ticket.opened_by = request.cls.ticket_user + + ticket = TicketBase.objects.create( + organization = request.cls.organization, + title = 'A ticket for slash commands', + opened_by = request.cls.ticket_user, + ) + + yield ticket + + with django_db_blocker.unblock(): + + ticket.delete() + + + @pytest.fixture + def ticket_comment(self, request, django_db_blocker, ticket, model): + """ Ticket Comment that requires body + + when using this fixture, set the `body` then call ticket_comment.save() + before use. + """ + + with django_db_blocker.unblock(): + + ticket.title = 'slash command ticket with comment' + + ticket.save() + + ticket_comment = model() + + ticket_comment.user = request.cls.entity_user + + ticket_comment.ticket = ticket + + ticket_comment.comment_type = 'comment' + + yield ticket_comment + + ticket_comment.delete() + + + +class TicketCommentBaseModelInheritedTestCases( + TicketCommentBaseModelTestCases +): + + pass + + + +class TicketCommentBaseModelPyTest( + TicketCommentBaseModelTestCases +): + + pass diff --git a/docs/projects/centurion_erp/development/core/ticket.md b/docs/projects/centurion_erp/development/core/ticket.md index 83b2b4ed..9e1fcf38 100644 --- a/docs/projects/centurion_erp/development/core/ticket.md +++ b/docs/projects/centurion_erp/development/core/ticket.md @@ -40,4 +40,6 @@ As with any other object within Centurion, the addition of a feature requires it - API Permissions `core.tests.functional.ticket_base.test_functional_ticket_base_permission.TicketBasePermissionsAPIInheritedCases` + - Model `app.core.tests.functional.ticket_base.test_functional_ticket_base_model.TicketBaseModelInheritedTestCases` _(if inheriting from `TicketBase`)_ Test cases for sub-models + The above listed test cases cover **all** tests for objects that are inherited from the base class. To complete the tests, you will need to add test cases for the differences your model introduces. From 85c6ed84832cac4408a22ae112ee58b76543df64 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 13:26:22 +0930 Subject: [PATCH 18/31] test(core): Add ability to unit api field rendering test case for second api request if required ref: #744 #726 --- app/api/tests/unit/test_unit_api_fields.py | 58 ++++++++++++++- ...est_unit_ticket_comment_base_api_fields.py | 73 +++++++++++++------ 2 files changed, 105 insertions(+), 26 deletions(-) diff --git a/app/api/tests/unit/test_unit_api_fields.py b/app/api/tests/unit/test_unit_api_fields.py index 184572a8..c7745164 100644 --- a/app/api/tests/unit/test_unit_api_fields.py +++ b/app/api/tests/unit/test_unit_api_fields.py @@ -14,6 +14,25 @@ from app.tests.common import DoesNotExist class APIFieldsTestCases: + """ API field Rendering Test Suite + + This test suite tests the rendering of API fieilds. + + ## Additional Items + + You may find a scenario where you are unable to have all fileds available + within a single request. to overcome this this test suite has the features + available wherein you can prepare an additional item for an additional + check. the following is required before the API request is made + (setup_post fixture): + + - additional item created and stored in attribute `self.item_two` + - additional url as a string and stored in attribute `self.url_two` + + Once you have these two objects, an additional check will be done and each + test will check both API requests. if the field is found in either api + request the test will pass + """ @property def parameterized_test_data(self) -> dict: @@ -178,10 +197,25 @@ class APIFieldsTestCases: request.cls.api_data = response.data + item_two = getattr(request.cls, 'url_two', None) + + if item_two: + + response_two = client.get(request.cls.url_two) + + request.cls.api_data_two = response_two.data + + else: + + request.cls.api_data_two = {} + + yield del request.cls.url_view_kwargs['pk'] + del request.cls.api_data_two + @@ -203,13 +237,21 @@ class APIFieldsTestCases: api_data = recursearray(self.api_data, param_value) + api_data_two = recursearray(self.api_data_two, param_value) + if param_expected is DoesNotExist: - assert api_data['key'] not in api_data['obj'] + assert( + api_data['key'] not in api_data['obj'] + and api_data_two['key'] not in api_data_two['obj'] + ) else: - assert api_data['key'] in api_data['obj'] + assert( + api_data['key'] in api_data['obj'] + or api_data_two['key'] in api_data_two['obj'] + ) @@ -221,13 +263,21 @@ class APIFieldsTestCases: api_data = recursearray(self.api_data, param_value) + api_data_two = recursearray(self.api_data_two, param_value) + if param_expected is DoesNotExist: - assert api_data['key'] not in api_data['obj'] + assert( + api_data['key'] not in api_data['obj'] + and api_data_two['key'] not in api_data_two['obj'] + ) else: - assert type( api_data['value'] ) is param_expected + assert( + type( api_data['value'] ) is param_expected + or type( api_data_two.get('value', 'is empty') ) is param_expected + ) diff --git a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py index 7bf2c144..2c61810d 100644 --- a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py +++ b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py @@ -1,6 +1,7 @@ import pytest from django.contrib.auth.models import ContentType, Permission, User +from django.shortcuts import reverse from rest_framework.relations import Hyperlink @@ -65,27 +66,24 @@ class TicketCommentBaseAPITestCases( organization = request.cls.organization, ) + request.cls.comment_user = comment_user + valid_data = request.cls.kwargs_create_item.copy() - valid_data['body'] = 'the parent comment' - valid_data['user'] = comment_user - valid_data['ticket'] = ticket - valid_data['comment_type'] = TicketCommentBase._meta.sub_model_type - + valid_data['body'] = 'the template comment' + del valid_data['external_ref'] del valid_data['external_system'] del valid_data['category'] - del valid_data['parent'] del valid_data['template'] + del valid_data['parent'] - parent_comment = TicketCommentBase.objects.create( - **valid_data - ) + valid_data['comment_type'] = TicketCommentBase._meta.sub_model_type + valid_data['ticket'] = ticket + valid_data['user'] = request.cls.comment_user - valid_data['body'] = 'the template comment' - template_comment = TicketCommentBase.objects.create( **valid_data ) @@ -95,25 +93,22 @@ class TicketCommentBaseAPITestCases( 'category': category, 'ticket': ticket, 'user': comment_user, - 'parent': parent_comment, + 'parent': None, 'template': template_comment, 'comment_type': model._meta.sub_model_type }) - yield - with django_db_blocker.unblock(): - parent_comment.delete() - template_comment.delete() category.delete() + del request.cls.comment_user for comment in ticket.ticketcommentbase_set.all(): @@ -128,7 +123,7 @@ class TicketCommentBaseAPITestCases( @pytest.fixture( scope = 'class') - def post_model(self, request, model ): + def post_model(self, request, model, django_db_blocker ): request.cls.url_view_kwargs.update({ 'ticket_id': request.cls.item.ticket.id @@ -143,14 +138,48 @@ class TicketCommentBaseAPITestCases( 'ticket_comment_model': model._meta.sub_model_type }) - if self.item.parent: - request.cls.url_ns_name = '_api_v2_ticket_comment_base_sub_thread' + valid_data = request.cls.kwargs_create_item.copy() + valid_data['body'] = 'the child comment' - request.cls.url_view_kwargs.update({ - 'parent_id': self.item.parent.id - }) + valid_data['comment_type'] = TicketCommentBase._meta.sub_model_type + valid_data['parent'] = request.cls.item + valid_data['ticket'] = request.cls.item.ticket + valid_data['user'] = request.cls.comment_user + del valid_data['external_ref'] + del valid_data['external_system'] + del valid_data['category'] + del valid_data['template'] + + with django_db_blocker.unblock(): + + request.cls.item_two = TicketCommentBase.objects.create( + **valid_data + ) + + url_ns_name = '_api_v2_ticket_comment_base_sub_thread' + + request.cls.url_two = reverse( + 'v2:' + url_ns_name + '-detail', + kwargs = { + **request.cls.url_view_kwargs, + 'pk': request.cls.item_two.id, + 'parent_id': request.cls.item.id, + 'ticket_comment_model': model._meta.sub_model_type + } + ) + + + yield + + with django_db_blocker.unblock(): + + request.cls.item_two.delete(keep_parents = False) + + del request.cls.item_two + + del request.cls.url_two From 03b752759f0a695c301d8b26aec537b971b2b6f1 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 14:34:39 +0930 Subject: [PATCH 19/31] test(core): Skip Related slash command checks until migrating tickets to new model ref: #744 #746 --- .../test_slash_command_related.py | 208 ++++++++++-------- 1 file changed, 111 insertions(+), 97 deletions(-) diff --git a/app/core/tests/functional/slash_commands/test_slash_command_related.py b/app/core/tests/functional/slash_commands/test_slash_command_related.py index b8db51e7..3e597d88 100644 --- a/app/core/tests/functional/slash_commands/test_slash_command_related.py +++ b/app/core/tests/functional/slash_commands/test_slash_command_related.py @@ -1910,108 +1910,122 @@ class SlashCommandsCommon: single_line_command_own_line_blank_line_crlf = 'A single line comment\r\nCOMMAND\r\n' - parameterized_slash_command = { - 'relate_existing_ticket': { - 'relate': True, - 'slash_command': 'relate', - 'command_obj': '#EXISTINGTICKET', - }, + @property + def parameterized_slash_command(self): + + return { + ############################################################################################### + # + # Sof Skipped due to ticket model re-write. + # model core.models.ticket.ticket.RelatedTickets still uses the old ticket model + # + ############################################################################################### + # 'relate_existing_ticket': { + # 'relate': True, + # 'slash_command': 'relate', + # 'command_obj': '#EXISTINGTICKET', + # }, - 'blocks_existing_ticket': { - 'blocks': True, - 'slash_command': 'blocks', - 'command_obj': '#EXISTINGTICKET', - }, + # 'blocks_existing_ticket': { + # 'blocks': True, + # 'slash_command': 'blocks', + # 'command_obj': '#EXISTINGTICKET', + # }, - 'blocked_by_existing_ticket': { - 'blocked_by': True, - 'slash_command': 'blocked_by', - 'command_obj': '#EXISTINGTICKET', - }, + # 'blocked_by_existing_ticket': { + # 'blocked_by': True, + # 'slash_command': 'blocked_by', + # 'command_obj': '#EXISTINGTICKET', + # }, + ############################################################################################### + # + # Eof Skipped due to ticket model re-write. + # + ############################################################################################### - 'spend_full_no_spaces': { - 'spend': True, - 'slash_command': 'spend', - 'command_obj': '1h2m3s', - }, - 'spend_full_spaces': { - 'spend': True, - 'slash_command': 'spend', - 'command_obj': '1h 2m 3s', - }, - 'spend_hour_minute_spaces': { - 'spend': True, - 'slash_command': 'spend', - 'command_obj': '1h 2m', - }, - 'spend_hour_second_spaces': { - 'spend': True, - 'slash_command': 'spend', - 'command_obj': '1h 3s', - }, - 'spend_minute_second_spaces': { - 'spend': True, - 'slash_command': 'spend', - 'command_obj': '5m 3s', - }, - 'spend_hour': { - 'spend': True, - 'slash_command': 'spend', - 'command_obj': '1h', - }, - 'spend_minute': { - 'spend': True, - 'slash_command': 'spend', - 'command_obj': '1m', - }, - 'spend_second': { - 'spend': True, - 'slash_command': 'spend', - 'command_obj': '4s', - }, + 'spend_full_no_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h2m3s', + }, + 'spend_full_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h 2m 3s', + }, + 'spend_hour_minute_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h 2m', + }, + 'spend_hour_second_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h 3s', + }, + 'spend_minute_second_spaces': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '5m 3s', + }, + 'spend_hour': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1h', + }, + 'spend_minute': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '1m', + }, + 'spend_second': { + 'spend': True, + 'slash_command': 'spend', + 'command_obj': '4s', + }, - 'spent_full_no_spaces': { - 'spend': True, - 'slash_command': 'spent', - 'command_obj': '1h2m3s', - }, - 'spent_full_spaces': { - 'spend': True, - 'slash_command': 'spent', - 'command_obj': '1h 2m 3s', - }, - 'spent_hour_minute_spaces': { - 'spend': True, - 'slash_command': 'spent', - 'command_obj': '1h 2m', - }, - 'spent_hour_second_spaces': { - 'spend': True, - 'slash_command': 'spent', - 'command_obj': '1h 3s', - }, - 'spent_minute_second_spaces': { - 'spend': True, - 'slash_command': 'spent', - 'command_obj': '5m 3s', - }, - 'spent_hour': { - 'spend': True, - 'slash_command': 'spent', - 'command_obj': '1h', - }, - 'spent_minute': { - 'spend': True, - 'slash_command': 'spent', - 'command_obj': '1m', - }, - 'spent_second': { - 'spend': True, - 'slash_command': 'spent', - 'command_obj': '4s', - }, + 'spent_full_no_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h2m3s', + }, + 'spent_full_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h 2m 3s', + }, + 'spent_hour_minute_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h 2m', + }, + 'spent_hour_second_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h 3s', + }, + 'spent_minute_second_spaces': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '5m 3s', + }, + 'spent_hour': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1h', + }, + 'spent_minute': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '1m', + }, + 'spent_second': { + 'spend': True, + 'slash_command': 'spent', + 'command_obj': '4s', + }, - } + } From 55f58bb689ef644f42443308c97bd74a3e4fbe86 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 14:36:16 +0930 Subject: [PATCH 20/31] test(core): Unit ViewSet Test Suite for TicketCommentBase ref: #744 #726 --- .../test_unit_ticket_comment_base_viewset.py | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_viewset.py diff --git a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_viewset.py b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_viewset.py new file mode 100644 index 00000000..3ee30e00 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_viewset.py @@ -0,0 +1,121 @@ +from django.contrib.auth.models import User +from django.test import Client, TestCase + +from rest_framework.reverse import reverse + +from api.tests.unit.test_unit_common_viewset import SubModelViewSetInheritedCases + +from core.viewsets.ticket_comment import ( + NoDocsViewSet, + TicketBase, + TicketCommentBase, + ViewSet +) + + +class TicketCommentBaseViewsetTestCases( + SubModelViewSetInheritedCases, +): + + model = None + + viewset = ViewSet + + base_model = TicketCommentBase + + route_name = None + + + @classmethod + def setUpTestData(self): + + + self.viewset = ViewSet + + + if self.model is None: + + self.model = TicketCommentBase + + + + super().setUpTestData() + + self.ticket = TicketBase.objects.create( + organization = self.organization, + title = 'ticket comment test', + opened_by = self.view_user, + ) + + self.kwargs = { + 'ticket_id': self.ticket.id + } + + if self.model is not TicketCommentBase: + + self.kwargs = { + **self.kwargs, + 'ticket_comment_model': self.model._meta.sub_model_type + } + + self.viewset.kwargs = self.kwargs + + + client = Client() + + url = reverse( + self.route_name + '-list', + kwargs = self.kwargs + ) + + client.force_login(self.view_user) + + self.http_options_response_list = client.options(url) + + + @classmethod + def tearDownClass(cls): + + cls.ticket.delete() + + super().tearDownClass() + + + + def test_view_attr_value_model_kwarg(self): + """Attribute Test + + Attribute `model_kwarg` must be equal to model._meta.sub_model_type + """ + + view_set = self.viewset() + + assert view_set.model_kwarg == 'ticket_comment_model' + + + +class TicketCommentBaseViewsetInheritedCases( + TicketCommentBaseViewsetTestCases, +): + """Test Suite for Sub-Models of TicketCommentBase + + Use this Test suit if your sub-model inherits directly from TicketCommentBase. + """ + + model: str = None + """name of the model to test""" + + route_name = 'v2:_api_v2_ticket_comment_base_sub' + + + +class TicketCommentBaseViewsetTest( + TicketCommentBaseViewsetTestCases, + TestCase, +): + + kwargs = {} + + route_name = 'v2:_api_v2_ticket_comment_base' + + viewset = NoDocsViewSet From f71e304731d599ed42c8ea6a80777730b060e32f Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 15:24:50 +0930 Subject: [PATCH 21/31] test(core): Unit ViewSet Test Suite for TicketCommentSolution ref: #744 #728 --- ...st_unit_ticket_comment_solution_viewset.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_solution/test_unit_ticket_comment_solution_viewset.py diff --git a/app/core/tests/unit/ticket_comment_solution/test_unit_ticket_comment_solution_viewset.py b/app/core/tests/unit/ticket_comment_solution/test_unit_ticket_comment_solution_viewset.py new file mode 100644 index 00000000..fc8cdaf8 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_solution/test_unit_ticket_comment_solution_viewset.py @@ -0,0 +1,39 @@ +from django.test import TestCase + +from core.models.ticket_comment_solution import TicketCommentSolution +from core.tests.unit.ticket_comment_base.test_unit_ticket_comment_base_viewset import TicketCommentBaseViewsetInheritedCases + + + +class TicketCommentSolutionViewsetTestCases( + TicketCommentBaseViewsetInheritedCases, +): + + + @classmethod + def setUpTestData(self): + + self.model = TicketCommentSolution + + super().setUpTestData() + + + +class TicketCommentSolutionViewsetInheritedCases( + TicketCommentSolutionViewsetTestCases, +): + """Test Suite for Sub-Models of TicketBase + + Use this Test suit if your sub-model inherits directly from TicketCommentSolution. + """ + + model: str = None + """name of the model to test""" + + + +class TicketCommentSolutionViewsetTest( + TicketCommentSolutionViewsetTestCases, + TestCase, +): + pass From e366220e8bd13d2400ebc6a09c565b9d2d7863d2 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 16:42:05 +0930 Subject: [PATCH 22/31] fix(core): ensure slash command is called on ticket description ref: #744 #723 --- app/core/models/ticket_base.py | 13 ++++++ .../test_unit_ticket_base_model.py | 44 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/app/core/models/ticket_base.py b/app/core/models/ticket_base.py index 2952879f..70131ef0 100644 --- a/app/core/models/ticket_base.py +++ b/app/core/models/ticket_base.py @@ -819,5 +819,18 @@ class TicketBase( self.date_closed = datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat() + + if( + self.description != '' + and self.description is not None + ): + + description = self.slash_command(self.description) + + if description != self.description: + + self.description = description + + super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) 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 2e4f7b9c..fc1133fb 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 @@ -893,6 +893,26 @@ class TicketBaseModelTestCases( assert spy.assert_called_once + def test_function_save_called_slash_command(self, model, mocker, ticket): + """Function Check + + Ensure function `TicketCommentBase.clean` is called + """ + + spy = mocker.spy(self.model, 'slash_command') + + valid_data = self.kwargs_create_item.copy() + + valid_data['title'] = 'was save called' + + del valid_data['external_system'] + + item = model.objects.create( + **valid_data + ) + + spy.assert_called_with(item, valid_data['description']) + class TicketBaseModelInheritedCases( @@ -945,3 +965,27 @@ class TicketBaseModelPyTest( """ assert type(self.model().get_related_model()) is type(None) + + + def test_function_save_called_slash_command(self, model, mocker, ticket): + """Function Check + + This test case is a duplicate of a test with the same name. This + test is required so that the base class `save()` function can be tested. + + Ensure function `TicketCommentBase.clean` is called + """ + + spy = mocker.spy(self.model, 'slash_command') + + valid_data = self.kwargs_create_item.copy() + + valid_data['title'] = 'was save called' + + del valid_data['external_system'] + + item = model.objects.create( + **valid_data + ) + + spy.assert_called_with(item, valid_data['description']) From a6e0f4e7289357986185b9256c24c82936f5f2dd Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 16:46:13 +0930 Subject: [PATCH 23/31] test(core): ensure slash command is called on ticket comment ref: #744 #726 --- .../test_unit_ticket_comment_base_model.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py index 95c6c569..0457c6c1 100644 --- a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py +++ b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py @@ -186,6 +186,7 @@ class TicketCommentBaseModelTestCases( ticket = TicketBase.objects.create( organization = request.cls.organization, title = 'tester comment ticket', + description = 'aa', opened_by = request.cls.view_user, ) @@ -480,6 +481,28 @@ class TicketCommentBaseModelTestCases( assert spy.assert_called_once + def test_function_save_called_slash_command(self, model, mocker, ticket): + """Function Check + + Ensure function `TicketCommentBase.clean` is called + """ + + spy = mocker.spy(self.model, 'slash_command') + + valid_data = self.kwargs_create_item.copy() + + valid_data['ticket'] = ticket + + del valid_data['external_system'] + del valid_data['external_ref'] + + item = model.objects.create( + **valid_data + ) + + spy.assert_called_with(item, valid_data['body']) + + class TicketCommentBaseModelInheritedCases( TicketCommentBaseModelTestCases, @@ -526,3 +549,28 @@ class TicketCommentBaseModelPyTest( ) assert err.value.get_codes()['date_closed'] == 'ticket_closed_no_date' + + + def test_function_save_called_slash_command(self, model, mocker, ticket): + """Function Check + + This test case is a duplicate of a test with the same name. This + test is required so that the base class `save()` function can be tested. + + Ensure function `TicketCommentBase.clean` is called + """ + + spy = mocker.spy(self.model, 'slash_command') + + valid_data = self.kwargs_create_item.copy() + + valid_data['ticket'] = ticket + + del valid_data['external_system'] + del valid_data['external_ref'] + + item = model.objects.create( + **valid_data + ) + + spy.assert_called_with(item, valid_data['body']) From b22baefa5a789877e2912231cef1ff47d64e0ade Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 17:24:07 +0930 Subject: [PATCH 24/31] test(core): ensure ticket is un-solved for ticketcomment unit api render fields check ref: #744 #726 --- .gitignore | 2 ++ .../test_unit_ticket_comment_base_api_fields.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1b54c405..f262b358 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__ **.sqlite3 **.sqlite **.coverage +.coverage* artifacts/ **.tmp.* volumes/ @@ -19,3 +20,4 @@ package.json feature_flags.json coverage_*.json *-coverage.xml + diff --git a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py index 2c61810d..ef5321aa 100644 --- a/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py +++ b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py @@ -154,7 +154,14 @@ class TicketCommentBaseAPITestCases( with django_db_blocker.unblock(): - request.cls.item_two = TicketCommentBase.objects.create( + request.cls.item.ticket.is_closed = False + request.cls.item.ticket.date_closed = None + request.cls.item.ticket.is_solved = False + request.cls.item.ticket.date_solved = None + request.cls.item.ticket.status = TicketBase.TicketStatus.NEW + request.cls.item.ticket.save() + + request.cls.item_two = model.objects.create( **valid_data ) From 24b6bcfa47194e6954bfe1847534493a51137140 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 21:03:58 +0930 Subject: [PATCH 25/31] feat(base): Enable user to customize log file location ref: #744 #436 #752 --- .gitignore | 2 +- Release-Notes.md | 19 ++++++ app/app/settings.py | 108 ++++++++++++++++++++++++++++++++++ includes/etc/itsm/settings.py | 9 +++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f262b358..201ad60b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,4 @@ package.json feature_flags.json coverage_*.json *-coverage.xml - +log/ diff --git a/Release-Notes.md b/Release-Notes.md index 51589fba..88c08fd9 100644 --- a/Release-Notes.md +++ b/Release-Notes.md @@ -1,3 +1,22 @@ +## Version 1.17.0 + +- Added setting for log files. + + Enables user to specify a default path for centurion's logging. Add the following to your settings file `/etc/itsm/settings.py` + + ``` py + LOG_FILES = { + "centurion": "/var/log/centurion.log", # Normal Centurion Operations + "weblog": "/var/log/weblog.log", # All web requests made to Centurion + "rest_api": "/var/log/rest_api.log", # Rest API + "catch_all":"/var/log/catch-all.log" # A catch all log. Note: does not log anything that has already been logged. + } + + ``` + + With this new setting, the previous setting `LOGGING` will no longer function. + + ## Version 1.16.0 - Employees model added behind feature flag `2025-00002` and will remain behind this flag until production ready. diff --git a/app/app/settings.py b/app/app/settings.py index 43a866ce..6da519f3 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -80,6 +80,95 @@ FEATURE_FLAG_OVERRIDES = None # Feature Flags to override fetched feature flags # PROMETHEUS_METRICS_EXPORT_PORT = 8010 # PROMETHEUS_METRICS_EXPORT_ADDRESS = '' + +LOG_FILES = { # defaults for devopment. docker includes settings has correct locations + "centurion": "../log/centurion.log", + "weblog": "../log/weblog.log", + "rest_api": "../log/rest_api.log", + "catch_all":"../log/catch-all.log" +} + +CENTURION_LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "console": { + "format": "{asctime} {levelname} {message}", + "style": "{", + }, + "verbose": { + "format": "{asctime} {levelname} {name} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + "simple": { + "format": "{levelname} {message}", + "style": "{", + }, + "web_log": { + "format": "{asctime} {levelname} {name} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + }, + "handlers": { + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'console', + }, + "file_centurion": { + "level": "INFO", + "class": "logging.FileHandler", + "filename": "centurion.log", + 'formatter': 'verbose', + }, + "file_weblog": { + "level": "INFO", + "class": "logging.FileHandler", + "filename": "weblog.log", + 'formatter': 'web_log', + }, + "file_rest_api": { + "level": "INFO", + "class": "logging.FileHandler", + "filename": "rest_api.log", + 'formatter': 'verbose', + }, + "file_catch_all": { + "level": "INFO", + "class": "logging.FileHandler", + "filename": "catch-all.log", + 'formatter': 'verbose', + } + }, + "loggers": { + "centurion": { + "handlers": ['console', 'file_centurion'], + "level": "INFO", + "propagate": False, + }, + "django.server": { + "handlers": ["file_weblog", 'console'], + "level": "INFO", + "propagate": False, + }, + "django": { + "handlers": ['console', 'file_catch_all'], + "level": "INFO", + "propagate": False, + }, + 'rest_framework': { + 'handlers': ['file_rest_api', 'console'], + 'level': 'INFO', + 'propagate': False, + }, + '': { + 'handlers': ['file_catch_all'], + 'level': 'INFO', + 'propagate': True, + }, + }, + } + METRICS_ENABLED = False # Enable Metrics METRICS_EXPORT_PORT = 8080 # Port to serve metrics on METRICS_MULTIPROC_DIR = '/tmp/prometheus' # path the metrics from multiple-process' save to @@ -393,7 +482,22 @@ CSRF_TRUSTED_ORIGINS = [ *TRUSTED_ORIGINS ] + +# Add the user specified log files +CENTURION_LOGGING['handlers']['file_centurion']['filename'] = LOG_FILES['centurion'] +CENTURION_LOGGING['handlers']['file_weblog']['filename'] = LOG_FILES['weblog'] +CENTURION_LOGGING['handlers']['file_rest_api']['filename'] = LOG_FILES['rest_api'] +CENTURION_LOGGING['handlers']['file_catch_all']['filename'] = LOG_FILES['catch_all'] + + +if str(CENTURION_LOGGING['handlers']['file_centurion']['filename']).startswith('../log'): + + if not os.path.exists('../log'): # Create log dir + + os.makedirs('../log') + if DEBUG: + INSTALLED_APPS += [ 'debug_toolbar', ] @@ -407,6 +511,10 @@ if DEBUG: ] +# Setup Logging +LOGGING = CENTURION_LOGGING + + if METRICS_ENABLED: INSTALLED_APPS += [ 'django_prometheus', ] diff --git a/includes/etc/itsm/settings.py b/includes/etc/itsm/settings.py index e209b5e9..51abbb59 100644 --- a/includes/etc/itsm/settings.py +++ b/includes/etc/itsm/settings.py @@ -33,6 +33,15 @@ FEATURE_FLAGGING_ENABLED = True # Turn Feature Flagging on/off FEATURE_FLAG_OVERRIDES = [] # Feature Flag Overrides. Takes preceedence over downloaded feature flags. + +LOG_FILES = { # Location where log files will be created + "centurion": "/var/log/centurion.log", + "weblog": "/var/log/weblog.log", + "rest_api": "/var/log/rest_api.log", + "catch_all":"/var/log/catch-all.log" +} + + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_SSL_REDIRECT = True From 1bff76c6371ae208a27ff25dca79d577fc031c8f Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 23:38:58 +0930 Subject: [PATCH 26/31] feat(access): Add Logging function to Tenancy model ref: #744 #436 #752 --- app/access/models/tenancy.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/access/models/tenancy.py b/app/access/models/tenancy.py index ddec70ac..427d9a8e 100644 --- a/app/access/models/tenancy.py +++ b/app/access/models/tenancy.py @@ -1,4 +1,4 @@ -# from django.conf import settings +import logging from django.db import models # from django.contrib.auth.models import User, Group @@ -193,6 +193,16 @@ class TenancyObject(SaveHistory): only be used when there is model inheritence. """ + _log: logging.Logger = None + + def get_log(self): + + if self._log is None: + + self._log = logging.getLogger('centurion.' + self._meta.app_label) + + return self._log + page_layout: list = None note_basename: str = None From 40b51f1a7790ea5a370399737af5fab12cb68d38 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 23:39:22 +0930 Subject: [PATCH 27/31] feat(api): Add Logging function to Common ViewSet ref: #744 #436 #752 --- app/api/viewsets/common.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/api/viewsets/common.py b/app/api/viewsets/common.py index 3e745285..27779c49 100644 --- a/app/api/viewsets/common.py +++ b/app/api/viewsets/common.py @@ -1,4 +1,6 @@ import importlib +import logging + from django.utils.safestring import mark_safe from rest_framework import viewsets, pagination @@ -242,6 +244,8 @@ class Retrieve( status = 501 ) + self.get_log().exception(e) + else: response = Response( @@ -315,6 +319,8 @@ class Update( status = 501 ) + self.get_log().exception(e) + else: response = Response( @@ -382,6 +388,8 @@ class Update( status = 501 ) + self.get_log().exception(e) + else: response = Response( @@ -431,6 +439,16 @@ class CommonViewSet( _Optional_, if specified will be add to list view metadata """ + _log: logging.Logger = None + + def get_log(self): + + if self._log is None: + + self._log = logging.getLogger('centurion.' + self.model._meta.app_label) + + return self._log + metadata_class = ReactUIMetadata """ Metadata Class From 70c835eb93b4cbed3812a0fd4866af69323c120f Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 23:40:41 +0930 Subject: [PATCH 28/31] test(core): Partial functional Model Test Suite covering some slash commande for TicketCommentSolution ref: #744 #728 --- ...st_functional_ticket_comment_base_model.py | 2 +- .../ticket_comment_solution/conftest.py | 14 ++++++++++ ...unctional_ticket_comment_solution_model.py | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 app/core/tests/functional/ticket_comment_solution/conftest.py create mode 100644 app/core/tests/functional/ticket_comment_solution/test_functional_ticket_comment_solution_model.py diff --git a/app/core/tests/functional/ticket_comment_base/test_functional_ticket_comment_base_model.py b/app/core/tests/functional/ticket_comment_base/test_functional_ticket_comment_base_model.py index d94ef273..d48e5244 100644 --- a/app/core/tests/functional/ticket_comment_base/test_functional_ticket_comment_base_model.py +++ b/app/core/tests/functional/ticket_comment_base/test_functional_ticket_comment_base_model.py @@ -62,7 +62,7 @@ class TicketCommentBaseModelTestCases( ticket_comment.ticket = ticket - ticket_comment.comment_type = 'comment' + ticket_comment.comment_type = model._meta.sub_model_type yield ticket_comment diff --git a/app/core/tests/functional/ticket_comment_solution/conftest.py b/app/core/tests/functional/ticket_comment_solution/conftest.py new file mode 100644 index 00000000..0b91e73a --- /dev/null +++ b/app/core/tests/functional/ticket_comment_solution/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from core.models.ticket_comment_solution import TicketCommentSolution + + + +@pytest.fixture( scope = 'class') +def model(request): + + request.cls.model = TicketCommentSolution + + yield request.cls.model + + del request.cls.model diff --git a/app/core/tests/functional/ticket_comment_solution/test_functional_ticket_comment_solution_model.py b/app/core/tests/functional/ticket_comment_solution/test_functional_ticket_comment_solution_model.py new file mode 100644 index 00000000..6fb6d20a --- /dev/null +++ b/app/core/tests/functional/ticket_comment_solution/test_functional_ticket_comment_solution_model.py @@ -0,0 +1,28 @@ +from core.tests.functional.ticket_comment_base.test_functional_ticket_comment_base_model import TicketCommentBaseModelInheritedTestCases + + +class TicketCommentSolutionModelTestCases( + TicketCommentBaseModelInheritedTestCases +): + pass + + + # check closes ticket + + # check ticket status changes to solved + + + +class TicketCommentSolutionModelInheritedTestCases( + TicketCommentSolutionModelTestCases +): + + pass + + + +class TicketCommentSolutionModelPyTest( + TicketCommentSolutionModelTestCases +): + + pass From cb49d0fbf754fdcf523cebee529eae44957a54c6 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 10 May 2025 23:42:40 +0930 Subject: [PATCH 29/31] feat(core): When processing slash command duration, cater for new ticket models ref: #744 #728 #726 #746 #747 --- app/core/lib/slash_commands/duration.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/core/lib/slash_commands/duration.py b/app/core/lib/slash_commands/duration.py index adf8e20c..bef64060 100644 --- a/app/core/lib/slash_commands/duration.py +++ b/app/core/lib/slash_commands/duration.py @@ -82,7 +82,10 @@ For this command to process the following conditions must be met: user = self.opened_by, ) - elif str(self._meta.verbose_name).lower().replace(' ', '_') == 'ticket_comment': + elif( + str(self._meta.verbose_name).lower().replace(' ', '_') == 'ticket_comment' + or str(self.__class__.__name__).lower().startswith('ticketcomment') + ): self.duration = duration From d8ef918a67241e867b13795973ebd0e467123ffd Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 11 May 2025 01:41:30 +0930 Subject: [PATCH 30/31] feat(python): Upgrade DRF 3.15.2 -> 3.16.0 ref: #744 --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 53804067..2547c463 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,11 +5,11 @@ django-cors-headers==4.4.0 django-debug-toolbar==5.1.0 social-auth-app-django==5.4.1 -djangorestframework==3.15.2 -djangorestframework-jsonapi==7.0.2 +djangorestframework==3.16.0 +djangorestframework-jsonapi==7.1.0 # DRF -pyyaml>=6.0.1 +pyyaml>=6.0.2 django-filter==24.2 # OpenAPI Schema From 4d7510ad3a79e1d902cfdd8bdb47f74326627c2e Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 11 May 2025 01:42:08 +0930 Subject: [PATCH 31/31] feat(python): Upgrade DRF Spectacular 0.27.2 -> 0.28.0 ref: #744 --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2547c463..1710772f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,8 +16,8 @@ django-filter==24.2 uritemplate==4.1.1 coreapi==2.3.3 -drf-spectacular==0.27.2 -drf-spectacular[sidecar]==0.27.2 +drf-spectacular==0.28.0 +drf-spectacular[sidecar]==0.28.0 django_split_settings==1.3.1