diff --git a/.gitignore b/.gitignore index 1b54c405..201ad60b 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 +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/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]) 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 diff --git a/app/api/tests/unit/test_unit_api_fields.py b/app/api/tests/unit/test_unit_api_fields.py index 93c329ce..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: @@ -135,6 +154,8 @@ class APIFieldsTestCases: organization = request.cls.organization, ) + request.cls.view_team = view_team + view_team.permissions.set([view_permissions]) @@ -176,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 + @@ -201,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'] + ) @@ -219,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/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 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/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) 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 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/models/ticket_comment_base.py b/app/core/models/ticket_comment_base.py index 8216bec0..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 @@ -62,7 +75,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 +92,6 @@ class TicketCommentBase( external_ref = models.IntegerField( blank = True, - default = None, help_text = 'External System reference', null = True, verbose_name = 'Reference Number', @@ -89,7 +100,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 +108,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( ' ', '_' ) @@ -128,13 +138,15 @@ 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', ) category = models.ForeignKey( TicketCommentCategory, blank = True, - default = None, help_text = 'Category of the comment', null = True, on_delete = models.PROTECT, @@ -411,10 +423,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 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'): 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..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 @@ -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,1581 @@ 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' + + + @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', + # }, + + # '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', + }, + + '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() + 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 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..d48e5244 --- /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 = model._meta.sub_model_type + + yield ticket_comment + + ticket_comment.delete() + + + +class TicketCommentBaseModelInheritedTestCases( + TicketCommentBaseModelTestCases +): + + pass + + + +class TicketCommentBaseModelPyTest( + TicketCommentBaseModelTestCases +): + + pass 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 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..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 @@ -850,6 +850,71 @@ 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 + + + 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( TicketBaseModelTestCases, ): @@ -900,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']) 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_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_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..ef5321aa --- /dev/null +++ b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_api_fields.py @@ -0,0 +1,374 @@ +import pytest + +from django.contrib.auth.models import ContentType, Permission, User +from django.shortcuts import reverse + +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, + ) + + request.cls.comment_user = comment_user + + + valid_data = request.cls.kwargs_create_item.copy() + + valid_data['body'] = 'the template comment' + + del valid_data['external_ref'] + del valid_data['external_system'] + del valid_data['category'] + del valid_data['template'] + del valid_data['parent'] + + valid_data['comment_type'] = TicketCommentBase._meta.sub_model_type + valid_data['ticket'] = ticket + valid_data['user'] = request.cls.comment_user + + + template_comment = TicketCommentBase.objects.create( + **valid_data + ) + + + request.cls.kwargs_create_item.update({ + 'category': category, + 'ticket': ticket, + 'user': comment_user, + 'parent': None, + 'template': template_comment, + 'comment_type': model._meta.sub_model_type + }) + + + yield + + + with django_db_blocker.unblock(): + + template_comment.delete() + + category.delete() + + del request.cls.comment_user + + for comment in ticket.ticketcommentbase_set.all(): + + comment.delete() + + ticket.delete() + + ticket_user.delete() + + + + + + @pytest.fixture( scope = 'class') + def post_model(self, request, model, django_db_blocker ): + + 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 + }) + + + valid_data = request.cls.kwargs_create_item.copy() + valid_data['body'] = 'the child comment' + + 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.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 + ) + + 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 + + + + + + + @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 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..0457c6c1 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_base/test_unit_ticket_comment_base_model.py @@ -0,0 +1,576 @@ +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-08T17:10Z', + } + + + 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', + description = 'aa', + 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 + + + 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, +): + """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' + + + 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']) 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 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 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 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 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. 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/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 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: diff --git a/requirements.txt b/requirements.txt index 0e3142c7..1710772f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,23 @@ -Django==5.1.8 +django==5.1.9 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 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 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