Merge pull request #744 from nofusscomputing/test-ticket-comment-base

This commit is contained in:
Jon
2025-05-11 02:31:54 +09:30
committed by GitHub
36 changed files with 3440 additions and 29 deletions

2
.gitignore vendored
View File

@ -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/

View File

@ -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.

View File

@ -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])

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -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', ]

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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'):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'])

View File

@ -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

View File

@ -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

View File

@ -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'])

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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.<*>.<Inherited class name>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.<*>.<Inherited class name>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.

View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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