Merge pull request #342 from nofusscomputing/development

This commit is contained in:
Jon
2024-10-12 00:29:00 +09:30
committed by GitHub
260 changed files with 22131 additions and 338 deletions

6
.gitignore vendored
View File

@ -9,3 +9,9 @@ artifacts/
volumes/
build/
pages/
node_modules/
.markdownlint-cli2.jsonc
.markdownlint.json
package-lock.json
package.json
**.junit.xml

View File

@ -1,6 +1,76 @@
# Contribution Guide
Development of this project has been setup to be done from VSCodium. The following additional requirements need to be met:
- npm has been installed. _required for `markdown` linting_
`sudo apt install -y --no-install-recommends npm`
- setup of other requirements can be done with `make prepare`
- **ALL** Linting must pass for Merge to be conducted.
_`make lint`_
## TL;DR
from the root of the project to start a test server use:
``` bash
# activate python venv
source /tmp/centurion_erp/bin/activate
# enter app dir
cd app
# Start dev server can be viewed at http://127.0.0.1:8002
python manage.py runserver 8002
# Run any migrations, if required
python manage.py migrate
# Create a super suer if required
python manage.py createsuperuser
```
## Makefile
!!! tip "TL;DR"
Common make commands are `make prepare` then `make docs` and `make lint`
Included within the root of the repository is a makefile that can be used during development to check/run different items as is required during development. The following make targets are available:
- `prepare`
_prepare the repository. init's all git submodules and sets up a python virtual env and other make targets_
- `docs`
_builds the docs and places them within a directory called build, which can be viewed within a web browser_
- `lint`
_conducts all required linting_
- `docs-lint`
_lints the markdown documents within the docs directory for formatting errors that MKDocs may/will have an issue with._
- `clean`
_cleans up build artifacts and removes the python virtual environment_
> this doc is yet to receive a re-write
# Old working docs
## Dev Environment
It's advised to setup a python virtual env for development. this can be done with the following commands.

View File

@ -21,6 +21,7 @@ def permission_queryset():
exclude_models = [
'appsettings',
'chordcounter',
'comment',
'groupresult',
'organization'
'settings',

View File

@ -10,6 +10,19 @@ from .models import Organization, Team
class OrganizationMixin():
"""Base Organization class"""
parent_model: str = None
""" Parent Model
This attribute defines the parent model for the model in question. The parent model when defined
will be used as the object to obtain the permissions from.
"""
parent_model_pk_kwarg: str = 'pk'
"""Parent Model kwarg
This value is used to define the kwarg that is used as the parent objects primary key (pk).
"""
request = None
user_groups = []
@ -26,20 +39,24 @@ class OrganizationMixin():
parent_model (Model): with PK from kwargs['pk']
"""
return self.parent_model.objects.get(pk=self.kwargs['pk'])
return self.parent_model.objects.get(pk=self.kwargs[self.parent_model_pk_kwarg])
def object_organization(self) -> int:
id = None
if hasattr(self, '_object_organization'):
return self._object_organization
try:
if hasattr(self, 'get_queryset'):
self.get_queryset()
if hasattr(self, 'parent_model'):
if self.parent_model:
obj = self.get_parent_obj()
id = obj.get_organization().id
@ -61,6 +78,10 @@ class OrganizationMixin():
id = 0
if hasattr(self, 'instance') and id is None: # Form Instance
id = self.instance.get_organization()
except AttributeError:
@ -84,6 +105,10 @@ class OrganizationMixin():
pass
if id is not None:
self._object_organization = id
return id
@ -147,6 +172,10 @@ class OrganizationMixin():
user_organizations = []
if hasattr(self, '_user_organizations'):
return self._user_organizations
teams = Team.objects
for group in self.request.user.groups.all():
@ -157,14 +186,32 @@ class OrganizationMixin():
user_organizations = user_organizations + [team.organization.id]
if len(user_organizations) > 0:
self._user_organizations = user_organizations
return user_organizations
# ToDo: Ensure that the group has access to item
def has_organization_permission(self, organization: int=None) -> bool:
def has_organization_permission(self, organization: int = None, permissions_required: list = None) -> bool:
""" Check if user has permission within organization.
Args:
organization (int, optional): Organization to check. Defaults to None.
permissions_required (list, optional): if doing object level permissions, pass in required permission. Defaults to None.
Returns:
bool: True for yes.
"""
has_permission = False
if permissions_required is None:
permissions_required = self.get_permission_required()
if not organization:
organization = self.object_organization()
@ -182,7 +229,7 @@ class OrganizationMixin():
assembled_permission = str(permission["content_type__app_label"]) + '.' + str(permission["codename"])
if assembled_permission in self.get_permission_required() and (team['organization_id'] == organization or organization == 0):
if assembled_permission in permissions_required and (team['organization_id'] == organization or organization == 0):
return True
@ -242,15 +289,23 @@ class OrganizationMixin():
return True
perms = self.get_permission_required()
if permissions_required:
if self.has_organization_permission():
perms = permissions_required
else:
perms = self.get_permission_required()
if self.has_organization_permission(permissions_required = perms):
return True
if self.request.user.has_perms(perms) and len(self.kwargs) == 0 and str(self.request.method).lower() == 'get':
if self.request.user.has_perms(perms) and str(self.request.method).lower() == 'get':
return True
if len(self.kwargs) == 0 or (len(self.kwargs) == 1 and 'ticket_type' in self.kwargs):
return True
for required_permission in self.permission_required:
@ -328,6 +383,12 @@ class OrganizationPermission(AccessMixin, OrganizationMixin):
if not request.user.is_authenticated:
return self.handle_no_permission()
if len(self.permission_required) == 0:
if hasattr(self, 'get_dynamic_permissions'):
self.permission_required = self.get_dynamic_permissions()
if len(self.permission_required) > 0:
non_organization_models = [

View File

@ -124,13 +124,21 @@ class TenancyManager(models.Manager):
user_organizations += [ team_user.team.organization.id ]
if len(user_organizations) > 0 and not user.is_superuser:
if len(user_organizations) > 0 and not user.is_superuser and self.model.is_global is not None:
return super().get_queryset().filter(
models.Q(organization__in=user_organizations)
|
models.Q(is_global = True)
)
if self.model.is_global:
return super().get_queryset().filter(
models.Q(organization__in=user_organizations)
|
models.Q(is_global = True)
)
else:
return super().get_queryset().filter(
models.Q(organization__in=user_organizations)
)
return super().get_queryset()
@ -189,6 +197,15 @@ class TenancyObject(SaveHistory):
return self.organization
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
if self.organization is None:
raise ValidationError('Organization not defined')
super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
class Team(Group, TenancyObject):
class Meta:

View File

@ -11,6 +11,19 @@ class TenancyObject:
model = None
""" Model to be tested """
should_model_history_be_saved: bool = True
""" Should model history be saved.
By default this should always be 'True', however in special
circumstances, this may not be desired.
"""
def test_history_save(self):
"""Confirm the desired intent for saving model history."""
assert self.model.save_model_history == self.should_model_history_be_saved
def test_has_attr_get_organization(self):
""" TenancyObject attribute check

View File

@ -91,3 +91,13 @@ class TenancyObjectTests(TestCase):
"""
assert self.item.objects is not None
@pytest.mark.skip(reason="write test")
def test_field_not_none_organzation(self):
""" Ensure field is set
Field organization must be defined for all tenancy objects
"""
assert self.item.objects is not None

View File

@ -0,0 +1,63 @@
from rest_framework.fields import empty
from api.serializers.core.ticket import TicketSerializer
from core.models.ticket.ticket import Ticket
class RequestTicketSerializer(
TicketSerializer,
):
class Meta:
model = Ticket
fields = [
'id',
'assigned_teams',
'assigned_users',
'category',
'created',
'modified',
'status',
'title',
'description',
'estimate',
'urgency',
'impact',
'priority',
'external_ref',
'external_system',
'ticket_type',
'is_deleted',
'date_closed',
# 'planned_start_date',
# 'planned_finish_date',
# 'real_start_date',
# 'real_finish_date',
'opened_by',
'organization',
'project',
'milestone',
'subscribed_teams',
'subscribed_users',
'ticket_comments',
'url',
]
read_only_fields = [
'id',
'ticket_type',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
super().__init__(instance=instance, data=data, **kwargs)
self.fields.fields['category'].queryset = self.fields.fields['category'].queryset.filter(
request = True
)

View File

@ -0,0 +1,195 @@
from django.urls import reverse
from rest_framework import serializers
from rest_framework.fields import empty
from api.serializers.core.ticket_comment import TicketCommentSerializer
from core.forms.validate_ticket import TicketValidation
from core.models.ticket.ticket import Ticket
class TicketSerializer(
serializers.ModelSerializer,
TicketValidation,
):
url = serializers.SerializerMethodField('get_url_ticket')
def get_url_ticket(self, item):
request = self.context.get('request')
kwargs: dict = {
'pk': item.id
}
if item.ticket_type == self.Meta.model.TicketType.CHANGE.value:
view_name = '_api_itim_change'
elif item.ticket_type == self.Meta.model.TicketType.INCIDENT.value:
view_name = '_api_itim_incident'
elif item.ticket_type == self.Meta.model.TicketType.PROBLEM.value:
view_name = '_api_itim_problem'
elif item.ticket_type == self.Meta.model.TicketType.REQUEST.value:
view_name = '_api_assistance_request'
elif item.ticket_type == self.Meta.model.TicketType.PROJECT_TASK.value:
view_name = '_api_project_tasks'
kwargs.update({'project_id': item.project.id})
else:
raise ValueError('Serializer unable to obtain ticket type')
return request.build_absolute_uri(
reverse(
'API:' + view_name + '-detail',
kwargs = kwargs
)
)
ticket_comments = serializers.SerializerMethodField('get_url_ticket_comments')
def get_url_ticket_comments(self, item):
request = self.context.get('request')
kwargs: dict = {
'ticket_id': item.id
}
if item.ticket_type == self.Meta.model.TicketType.CHANGE.value:
view_name = '_api_itim_change_ticket_comments'
elif item.ticket_type == self.Meta.model.TicketType.INCIDENT.value:
view_name = '_api_itim_incident_ticket_comments'
elif item.ticket_type == self.Meta.model.TicketType.PROBLEM.value:
view_name = '_api_itim_problem_ticket_comments'
elif item.ticket_type == self.Meta.model.TicketType.REQUEST.value:
view_name = '_api_assistance_request_ticket_comments'
elif item.ticket_type == self.Meta.model.TicketType.PROJECT_TASK.value:
view_name = '_api_project_tasks_comments'
kwargs.update({'project_id': item.project.id})
else:
raise ValueError('Serializer unable to obtain ticket type')
return request.build_absolute_uri(
reverse(
'API:' + view_name + '-list',
kwargs = kwargs
)
)
class Meta:
model = Ticket
fields = [
'id',
'assigned_teams',
'assigned_users',
'category',
'created',
'modified',
'status',
'title',
'description',
'urgency',
'impact',
'priority',
'external_ref',
'external_system',
'ticket_type',
'is_deleted',
'date_closed',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
'opened_by',
'organization',
'project',
'subscribed_teams',
'subscribed_users',
'ticket_comments',
'url',
]
read_only_fields = [
'id',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
self.fields.fields['status'].initial = Ticket.TicketStatus.All.NEW
self.fields.fields['status'].default = Ticket.TicketStatus.All.NEW
self.ticket_type_fields = self.Meta.fields
super().__init__(instance=instance, data=data, **kwargs)
self.fields['organization'].required = True
def is_valid(self, *, raise_exception=True) -> bool:
is_valid = False
try:
self.request = self._context['request']
is_valid = super().is_valid(raise_exception=raise_exception)
self._ticket_type = str(self.fields['ticket_type'].choices[self._context['view']._ticket_type_value]).lower().replace(' ', '_')
is_valid = self.validate_ticket()
self.validated_data['ticket_type'] = int(self._context['view']._ticket_type_value)
if self.instance is None:
subscribed_users: list = []
if 'subscribed_users' in self.validated_data:
subscribed_users = self.validated_data['subscribed_users']
self.validated_data['subscribed_users'] = subscribed_users + [ self.validated_data['opened_by'] ]
except Exception as unhandled_exception:
serializers.ParseError(
detail=f"Server encountered an error during validation, Traceback: {unhandled_exception.with_traceback}"
)
return is_valid

View File

@ -0,0 +1,44 @@
from django.urls import reverse
from rest_framework import serializers
from rest_framework.fields import empty
from api.serializers.core.ticket_comment import TicketCommentSerializer
from core.forms.validate_ticket import TicketValidation
from core.models.ticket.ticket_category import TicketCategory
class TicketCategorySerializer(
serializers.ModelSerializer,
):
url = serializers.HyperlinkedIdentityField(
view_name="API:_api_ticket_category-detail", format="html"
)
class Meta:
model = TicketCategory
fields = '__all__'
read_only_fields = [
'id',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
if instance is not None:
if hasattr(instance, 'id'):
self.fields.fields['parent'].queryset = self.fields.fields['parent'].queryset.exclude(
id=instance.id
)
super().__init__(instance=instance, data=data, **kwargs)

View File

@ -0,0 +1,74 @@
from django.urls import reverse
from rest_framework import serializers
from rest_framework.fields import empty
from core.models.ticket.ticket_comment import Ticket, TicketComment
class TicketCommentSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField('get_url_ticket_comment')
def get_url_ticket_comment(self, item):
request = self.context.get('request')
if item.ticket.ticket_type == item.ticket.__class__.TicketType.CHANGE:
view_name = '_api_itim_change_ticket_comments'
elif item.ticket.ticket_type == item.ticket.__class__.TicketType.INCIDENT:
view_name = '_api_itim_incident_ticket_comments'
elif item.ticket.ticket_type == item.ticket.__class__.TicketType.PROBLEM:
view_name = '_api_itim_problem_ticket_comments'
elif item.ticket.ticket_type == item.ticket.__class__.TicketType.REQUEST:
view_name = '_api_assistance_request_ticket_comments'
else:
raise ValueError('Serializer unable to obtain ticket type')
return request.build_absolute_uri(
reverse('API:' + view_name + '-detail',
kwargs={
'ticket_id': item.ticket.id,
'pk': item.id
}
)
)
class Meta:
model = TicketComment
fields = '__all__'
def __init__(self, instance=None, data=empty, **kwargs):
if 'context' in self._kwargs:
if 'view' in self._kwargs['context']:
if 'ticket_id' in self._kwargs['context']['view'].kwargs:
ticket = Ticket.objects.get(pk=int(self._kwargs['context']['view'].kwargs['ticket_id']))
self.fields.fields['organization'].initial = ticket.organization.id
self.fields.fields['ticket'].initial = int(self._kwargs['context']['view'].kwargs['ticket_id'])
self.fields.fields['comment_type'].initial = TicketComment.CommentType.COMMENT
self.fields.fields['user'].initial = kwargs['context']['request']._user.id
super().__init__(instance=instance, data=data, **kwargs)

View File

@ -0,0 +1,42 @@
from django.urls import reverse
from rest_framework import serializers
from rest_framework.fields import empty
from core.models.ticket.ticket_comment_category import TicketCommentCategory
class TicketCommentCategorySerializer(
serializers.ModelSerializer,
):
url = serializers.HyperlinkedIdentityField(
view_name="API:_api_ticket_comment_category-detail", format="html"
)
class Meta:
model = TicketCommentCategory
fields = '__all__'
read_only_fields = [
'id',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
if instance is not None:
if hasattr(instance, 'id'):
self.fields.fields['parent'].queryset = self.fields.fields['parent'].queryset.exclude(
id=instance.id
)
super().__init__(instance=instance, data=data, **kwargs)

View File

@ -53,7 +53,6 @@ class DeviceSerializer(serializers.ModelSerializer):
class Meta:
model = Device
depth = 1
fields = [
'id',
'is_global',

View File

@ -0,0 +1,63 @@
from rest_framework.fields import empty
from api.serializers.core.ticket import TicketSerializer
from core.models.ticket.ticket import Ticket
class ChangeTicketSerializer(
TicketSerializer,
):
class Meta:
model = Ticket
fields = [
'id',
'assigned_teams',
'assigned_users',
'category',
'created',
'modified',
'status',
'title',
'description',
'estimate',
'urgency',
'impact',
'priority',
'external_ref',
'external_system',
'ticket_type',
'is_deleted',
'date_closed',
# 'planned_start_date',
# 'planned_finish_date',
# 'real_start_date',
# 'real_finish_date',
'opened_by',
'organization',
'project',
'milestone',
'subscribed_teams',
'subscribed_users',
'ticket_comments',
'url',
]
read_only_fields = [
'id',
'ticket_type',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
super().__init__(instance=instance, data=data, **kwargs)
self.fields.fields['category'].queryset = self.fields.fields['category'].queryset.filter(
project_task = True
)

View File

@ -0,0 +1,63 @@
from rest_framework.fields import empty
from api.serializers.core.ticket import TicketSerializer
from core.models.ticket.ticket import Ticket
class IncidentTicketSerializer(
TicketSerializer,
):
class Meta:
model = Ticket
fields = [
'id',
'assigned_teams',
'assigned_users',
'category',
'created',
'modified',
'status',
'title',
'description',
'estimate',
'urgency',
'impact',
'priority',
'external_ref',
'external_system',
'ticket_type',
'is_deleted',
'date_closed',
# 'planned_start_date',
# 'planned_finish_date',
# 'real_start_date',
# 'real_finish_date',
'opened_by',
'organization',
'project',
'milestone',
'subscribed_teams',
'subscribed_users',
'ticket_comments',
'url',
]
read_only_fields = [
'id',
'ticket_type',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
super().__init__(instance=instance, data=data, **kwargs)
self.fields.fields['category'].queryset = self.fields.fields['category'].queryset.filter(
incident = True
)

View File

@ -0,0 +1,63 @@
from rest_framework.fields import empty
from api.serializers.core.ticket import TicketSerializer
from core.models.ticket.ticket import Ticket
class ProblemTicketSerializer(
TicketSerializer,
):
class Meta:
model = Ticket
fields = [
'id',
'assigned_teams',
'assigned_users',
'category',
'created',
'modified',
'status',
'title',
'description',
'estimate',
'urgency',
'impact',
'priority',
'external_ref',
'external_system',
'ticket_type',
'is_deleted',
'date_closed',
# 'planned_start_date',
# 'planned_finish_date',
# 'real_start_date',
# 'real_finish_date',
'opened_by',
'organization',
'project',
'milestone',
'subscribed_teams',
'subscribed_users',
'ticket_comments',
'url',
]
read_only_fields = [
'id',
'ticket_type',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
super().__init__(instance=instance, data=data, **kwargs)
self.fields.fields['category'].queryset = self.fields.fields['category'].queryset.filter(
problem = True
)

View File

@ -0,0 +1,74 @@
from django.urls import reverse
from rest_framework import serializers
from rest_framework.fields import empty
from project_management.models.projects import Project
from project_management.models.project_milestone import ProjectMilestone
class ProjectMilestoneSerializer(
serializers.ModelSerializer,
):
url = serializers.SerializerMethodField('get_url_project_milestone')
def get_url_project_milestone(self, item):
request = self.context.get('request')
return request.build_absolute_uri(
reverse('API:_api_project_milestone-detail',
kwargs={
'project_id': item.project.id,
'pk': item.id
}
)
)
class Meta:
model = ProjectMilestone
fields = [
'name',
'description',
'organization',
'project',
'start_date',
'finish_date',
'created',
'modified',
'url',
]
read_only_fields = [
'id',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
self.fields.fields['organization'].read_only = True
self.fields.fields['project'].read_only = True
super().__init__(instance=instance, data=data, **kwargs)
def is_valid(self, *, raise_exception=False):
is_valid = super().is_valid(raise_exception=raise_exception)
project = Project.objects.get(
pk = int(self._kwargs['context']['view'].kwargs['project_id'])
)
self._validated_data.update({
'organization': project.organization,
'project': project
})
return is_valid

View File

@ -0,0 +1,33 @@
from django.urls import reverse
from rest_framework import serializers
from rest_framework.fields import empty
from project_management.models.project_states import ProjectState
class ProjectStateSerializer(
serializers.ModelSerializer,
):
url = serializers.HyperlinkedIdentityField(
view_name="API:_api_project_state-detail", format="html"
)
class Meta:
model = ProjectState
fields = '__all__'
read_only_fields = [
'id',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
super().__init__(instance=instance, data=data, **kwargs)

View File

@ -0,0 +1,63 @@
from rest_framework.fields import empty
from api.serializers.core.ticket import TicketSerializer
from core.models.ticket.ticket import Ticket
class ProjectTaskSerializer(
TicketSerializer,
):
class Meta:
model = Ticket
fields = [
'id',
'assigned_teams',
'assigned_users',
'category',
'created',
'modified',
'status',
'title',
'description',
'estimate',
'urgency',
'impact',
'priority',
'external_ref',
'external_system',
'ticket_type',
'is_deleted',
'date_closed',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
'opened_by',
'organization',
'project',
'milestone',
'subscribed_teams',
'subscribed_users',
'ticket_comments',
'url',
]
read_only_fields = [
'id',
'ticket_type',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
super().__init__(instance=instance, data=data, **kwargs)
self.fields.fields['category'].queryset = self.fields.fields['category'].queryset.filter(
project_task = True
)

View File

@ -0,0 +1,33 @@
from django.urls import reverse
from rest_framework import serializers
from rest_framework.fields import empty
from project_management.models.project_types import ProjectType
class ProjectTypeSerializer(
serializers.ModelSerializer,
):
url = serializers.HyperlinkedIdentityField(
view_name="API:_api_project_state-detail", format="html"
)
class Meta:
model = ProjectType
fields = '__all__'
read_only_fields = [
'id',
'url',
]
def __init__(self, instance=None, data=empty, **kwargs):
super().__init__(instance=instance, data=data, **kwargs)

View File

@ -0,0 +1,134 @@
from django.urls import reverse
from rest_framework import serializers
from rest_framework.fields import empty
from project_management.models.projects import Project
class ProjectSerializer(
serializers.ModelSerializer,
):
percent_completed = serializers.CharField(
read_only = True,
)
url = serializers.SerializerMethodField('get_url')
def get_url(self, item):
request = self.context.get('request')
return request.build_absolute_uri(reverse("API:_api_projects-detail", args=[item.pk]))
project_tasks_url = serializers.SerializerMethodField('get_url_project_tasks')
def get_url_project_tasks(self, item):
request = self.context.get('request')
return request.build_absolute_uri(
reverse(
'API:_api_project_tasks-list',
kwargs={
'project_id': item.id
}
)
)
project_milestone_url = serializers.SerializerMethodField('get_url_project_milestone')
def get_url_project_milestone(self, item):
request = self.context.get('request')
return request.build_absolute_uri(
reverse(
'API:_api_project_milestone-list',
kwargs={
'project_id': item.id
}
)
)
class Meta:
model = Project
fields = [
'id',
'organization',
'state',
'project_type',
'priority',
'name',
'description',
'code',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
'manager_user',
'manager_team',
'team_members',
'project_tasks_url',
'project_milestone_url',
'percent_completed',
'created',
'modified',
'url',
]
read_only_fields = [
'id',
'url',
'created',
'modified',
]
class ProjectImportSerializer(ProjectSerializer):
class Meta:
model = Project
fields = [
'id',
'organization',
'state',
'project_type',
'priority',
'name',
'description',
'code',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
'manager_user',
'manager_team',
'team_members',
'project_tasks_url',
'project_milestone_url',
'percent_completed',
'created',
'modified',
'external_ref',
'external_system',
'is_deleted',
'url',
]
read_only_fields = [
'id',
'url',
]

View File

@ -194,7 +194,7 @@ class APIPermissionAdd:
def test_add_has_permission(self):
""" Check correct permission for add
Attempt to add as user with no permission
Attempt to add as user with permission
"""
client = Client()

View File

@ -502,7 +502,8 @@ class InventoryAPIDifferentNameSerialNumberMatch(TestCase):
Device.objects.create(
name='random device name',
serial_number='serial_number_123'
serial_number='serial_number_123',
organization = organization,
)
add_permissions = Permission.objects.get(
@ -537,7 +538,7 @@ class InventoryAPIDifferentNameSerialNumberMatch(TestCase):
process_inventory(json.dumps(self.inventory), organization.id)
self.device = Device.objects.get(name=self.inventory['details']['name'])
self.device = Device.objects.get(name=self.inventory['details']['name'], organization = organization)
self.operating_system = OperatingSystem.objects.get(name=self.inventory['os']['name'])
@ -778,7 +779,8 @@ class InventoryAPIDifferentNameUUIDMatch(TestCase):
Device.objects.create(
name='random device name',
uuid='123-456-789'
uuid='123-456-789',
organization = organization,
)
add_permissions = Permission.objects.get(

View File

@ -8,6 +8,22 @@ from .views import access, config, index
from api.views.settings import permissions
from api.views.settings import index as settings
from api.views import assistance, itim, project_management
from api.views.assistance import request_ticket
from api.views.core import (
ticket_categories,
ticket_comment_categories,
ticket_comments as core_ticket_comments
)
from api.views.itim import change_ticket, incident_ticket, problem_ticket
from api.views.project_management import (
projects,
project_milestone,
project_state,
project_type,
project_task
)
from .views.itam import software, config as itam_config
from .views.itam.device import DeviceViewSet
from .views.itam import inventory
@ -16,15 +32,47 @@ from .views.itam import inventory
app_name = "API"
router = DefaultRouter()
router = DefaultRouter(trailing_slash=False)
router.register('', index.Index, basename='_api_home')
router.register('assistance/request', request_ticket.View, basename='_api_assistance_request')
router.register('assistance/request/(?P<ticket_id>[0-9]+)/comments', core_ticket_comments.View, basename='_api_assistance_request_ticket_comments')
router.register('device', DeviceViewSet, basename='device')
router.register('itim/change', change_ticket.View, basename='_api_itim_change')
router.register('itim/change/(?P<ticket_id>[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_change_ticket_comments')
router.register('itim/incident', incident_ticket.View, basename='_api_itim_incident')
router.register('itim/incident/(?P<ticket_id>[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_incident_ticket_comments')
router.register('itim/problem', problem_ticket.View, basename='_api_itim_problem')
router.register('itim/problem/(?P<ticket_id>[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_problem_ticket_comments')
router.register('project_management/projects', projects.View, basename='_api_projects')
router.register('project_management/projects/(?P<project_id>[0-9]+)/milestones', project_milestone.View, basename='_api_project_milestone')
router.register('project_management/projects/(?P<project_id>[0-9]+)/tasks', project_task.View, basename='_api_project_tasks')
router.register('project_management/projects/(?P<project_id>[0-9]+)/tasks/(?P<ticket_id>[0-9]+)/comments', core_ticket_comments.View, basename='_api_project_tasks_comments')
router.register('settings/ticket_categories', ticket_categories.View, basename='_api_ticket_category')
router.register('settings/project_state', project_state.View, basename='_api_project_state')
router.register('settings/project_type', project_type.View, basename='_api_project_type')
router.register('settings/ticket_comment_categories', ticket_comment_categories.View, basename='_api_ticket_comment_category')
router.register('software', software.SoftwareViewSet, basename='software')
urlpatterns = [
path("assistance", assistance.index.Index.as_view(), name="_api_assistance"),
#
# Sof Old Paths to be refactored
#
path("config/<slug:slug>/", itam_config.View.as_view(), name="_api_device_config"),
path("configuration/", config.ConfigGroupsList.as_view(), name='_api_config_groups'),
@ -32,6 +80,8 @@ urlpatterns = [
path("device/inventory", inventory.Collect.as_view(), name="_api_device_inventory"),
path("itim", itim.index.Index.as_view(), name="_api_itim"),
path("organization/", access.OrganizationList.as_view(), name='_api_orgs'),
path("organization/<int:pk>/", access.OrganizationDetail.as_view(), name='_api_organization'),
path("organization/<int:organization_id>/team", access.TeamList.as_view(), name='_api_organization_teams'),
@ -39,6 +89,8 @@ urlpatterns = [
path("organization/<int:organization_id>/team/<int:group_ptr_id>/permissions", access.TeamPermissionDetail.as_view(), name='_api_team_permission'),
path("organization/team/", access.TeamList.as_view(), name='_api_teams'),
path("project_management", project_management.index.Index.as_view(), name="_api_project_management"),
path("settings", settings.View.as_view(), name='_settings'),
path("settings/permissions", permissions.View.as_view(), name='_settings_permissions'),

View File

@ -0,0 +1 @@
from .index import *

View File

@ -0,0 +1,35 @@
from django.utils.safestring import mark_safe
from rest_framework import generics, permissions, routers, views
# from rest_framework.decorators import api_view
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.reverse import reverse
class Index(views.APIView):
permission_classes = [
IsAuthenticated,
]
def get_view_name(self):
return "Assistance"
def get_view_description(self, html=False) -> str:
text = "Assistance Module"
if html:
return mark_safe(f"<p>{text}</p>")
else:
return text
def get(self, request, *args, **kwargs):
body: dict = {
'requests': reverse('API:_api_assistance_request-list', request=request)
}
return Response(body)

View File

@ -0,0 +1,77 @@
from drf_spectacular.utils import extend_schema, OpenApiResponse
from api.serializers.assistance.request import RequestTicketSerializer
from api.views.core.tickets import View
class View(View):
_ticket_type:str = 'request'
@extend_schema(
summary='Create a ticket',
description = """This model includes all of the ticket types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
request = RequestTicketSerializer,
responses = {
201: OpenApiResponse(
response = RequestTicketSerializer,
),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all tickets',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = RequestTicketSerializer
)
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected ticket',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = RequestTicketSerializer
)
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return "Request Ticket"
return 'Request Tickets'

View File

@ -0,0 +1,79 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema, OpenApiResponse
from rest_framework import generics, viewsets
from access.mixin import OrganizationMixin
from api.serializers.core.ticket_category import TicketCategory, TicketCategorySerializer
from api.views.mixin import OrganizationPermissionAPI
class View(OrganizationMixin, viewsets.ModelViewSet):
permission_classes = [
OrganizationPermissionAPI
]
queryset = TicketCategory.objects.all()
serializer_class = TicketCategorySerializer
@extend_schema(
summary='Create a ticket category',
request = TicketCategorySerializer,
responses = {
201: OpenApiResponse(description='Ticket category created', response=TicketCategorySerializer),
403: OpenApiResponse(description='User tried to edit field they dont have access to'),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all of a tickets category',
methods=["GET"],
responses = {
200: OpenApiResponse(description='Success', response=TicketCategorySerializer),
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected ticket category',
methods=["GET"],
responses = {
200: OpenApiResponse(description='Success', response=TicketCategorySerializer),
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(
summary='Update a ticket category',
methods=["PUT"],
responses = {
200: OpenApiResponse(description='Ticket comment updated', response=TicketCategorySerializer),
403: OpenApiResponse(description='User tried to edit field they dont have access to'),
}
)
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return "Ticket Category"
return 'Ticket Categories'

View File

@ -0,0 +1,79 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema, OpenApiResponse
from rest_framework import generics, viewsets
from access.mixin import OrganizationMixin
from api.serializers.core.ticket_comment_category import TicketCommentCategory, TicketCommentCategorySerializer
from api.views.mixin import OrganizationPermissionAPI
class View(OrganizationMixin, viewsets.ModelViewSet):
permission_classes = [
OrganizationPermissionAPI
]
queryset = TicketCommentCategory.objects.all()
serializer_class = TicketCommentCategorySerializer
@extend_schema(
summary='Create a ticket comment category',
request = TicketCommentCategorySerializer,
responses = {
201: OpenApiResponse(description='Ticket category created', response=TicketCommentCategorySerializer),
403: OpenApiResponse(description='User tried to edit field they dont have access to'),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all of the ticket comment categories',
methods=["GET"],
responses = {
200: OpenApiResponse(description='Success', response=TicketCommentCategorySerializer),
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected ticket comment category',
methods=["GET"],
responses = {
200: OpenApiResponse(description='Success', response=TicketCommentCategorySerializer),
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(
summary='Update a ticket comment category',
methods=["PUT"],
responses = {
200: OpenApiResponse(description='Ticket comment updated', response=TicketCommentCategorySerializer),
403: OpenApiResponse(description='User tried to edit field they dont have access to'),
}
)
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return "Ticket Comment Category"
return 'Ticket Comment Categories'

View File

@ -0,0 +1,102 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema, OpenApiResponse
from rest_framework import generics, viewsets
from access.mixin import OrganizationMixin
from api.serializers.core.ticket_comment import TicketCommentSerializer
from api.views.mixin import OrganizationPermissionAPI
from core.models.ticket.ticket_comment import TicketComment
class View(OrganizationMixin, viewsets.ModelViewSet):
permission_classes = [
OrganizationPermissionAPI
]
queryset = TicketComment.objects.all()
serializer_class = TicketCommentSerializer
@extend_schema(
summary='Create a ticket comment',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type.
""",
request = TicketCommentSerializer,
responses = {
201: OpenApiResponse(description='Ticket comment created', response=TicketCommentSerializer),
403: OpenApiResponse(description='User tried to edit field they dont have access to'),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all of a tickets comments',
methods=["GET"],
responses = {
200: OpenApiResponse(description='Success', response=TicketCommentSerializer),
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected ticket Comment',
methods=["GET"],
responses = {
200: OpenApiResponse(description='Success', response=TicketCommentSerializer),
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(
summary='Update a ticket Comment',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type.
""",
methods=["PUT"],
responses = {
200: OpenApiResponse(description='Ticket comment updated', response=TicketCommentSerializer),
403: OpenApiResponse(description='User tried to edit field they dont have access to'),
}
)
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
def get_queryset(self):
if 'ticket_id' in self.kwargs:
self.queryset = self.queryset.filter(ticket=self.kwargs['ticket_id']).order_by('created')
if 'pk' in self.kwargs:
self.queryset = self.queryset.filter(pk = self.kwargs['pk'])
return self.queryset
def get_view_name(self):
if self.detail:
return "Ticket Comment"
return 'Ticket Comments'

View File

@ -0,0 +1,145 @@
from django.db.models import Q
from rest_framework import generics, viewsets
from access.mixin import OrganizationMixin
from api.serializers.assistance.request import RequestTicketSerializer
from api.serializers.itim.change import ChangeTicketSerializer
from api.serializers.itim.incident import IncidentTicketSerializer
from api.serializers.itim.problem import ProblemTicketSerializer
from api.serializers.project_management.project_task import ProjectTaskSerializer
from api.views.mixin import OrganizationPermissionAPI
from core.models.ticket.ticket import Ticket
class View(OrganizationMixin, viewsets.ModelViewSet):
filterset_fields = [
'external_system',
'external_ref',
]
search_fields = [
'title',
'description',
]
permission_classes = [
OrganizationPermissionAPI
]
def get_dynamic_permissions(self):
if self.action == 'create':
action_keyword = 'add'
elif self.action == 'destroy':
action_keyword = 'delete'
elif self.action == 'list':
action_keyword = 'view'
elif self.action == 'partial_update':
action_keyword = 'change'
elif self.action == 'retrieve':
action_keyword = 'view'
elif self.action == 'update':
action_keyword = 'change'
elif self.action is None:
action_keyword = 'view'
else:
raise ValueError('unable to determin the action_keyword')
self.permission_required = [
'core.' + action_keyword + '_ticket_' + self._ticket_type,
]
return super().get_permission_required()
queryset = Ticket.objects.all()
def get_serializer(self, *args, **kwargs):
if self._ticket_type == 'change':
self.serializer_class = ChangeTicketSerializer
self._ticket_type_value = Ticket.TicketType.CHANGE.value
elif self._ticket_type == 'incident':
self.serializer_class = IncidentTicketSerializer
self._ticket_type_value = Ticket.TicketType.INCIDENT.value
elif self._ticket_type == 'problem':
self.serializer_class = ProblemTicketSerializer
self._ticket_type_value = Ticket.TicketType.PROBLEM.value
elif self._ticket_type == 'request':
self.serializer_class = RequestTicketSerializer
self._ticket_type_value = Ticket.TicketType.REQUEST.value
elif self._ticket_type == 'project_task':
self.serializer_class = ProjectTaskSerializer
self._ticket_type_value = Ticket.TicketType.PROJECT_TASK.value
else:
raise ValueError('unable to determin the serializer_class')
return super().get_serializer(*args, **kwargs)
def get_queryset(self):
if self._ticket_type == 'change':
ticket_type = self.queryset.model.TicketType.CHANGE.value
elif self._ticket_type == 'incident':
ticket_type = self.queryset.model.TicketType.INCIDENT.value
elif self._ticket_type == 'problem':
ticket_type = self.queryset.model.TicketType.PROBLEM.value
elif self._ticket_type == 'request':
ticket_type = self.queryset.model.TicketType.REQUEST.value
elif self._ticket_type == 'project_task':
ticket_type = self.queryset.model.TicketType.REQUEST.value
return self.queryset.filter(
project = self.kwargs['project_id']
)
else:
raise ValueError('Unknown ticket type. kwarg `ticket_type` must be set')
return self.queryset.filter(
ticket_type = ticket_type
)

View File

@ -1,4 +1,3 @@
from django.contrib.auth.models import User
from django.utils.safestring import mark_safe
from rest_framework import generics, permissions, routers, viewsets
@ -31,9 +30,12 @@ class Index(viewsets.ViewSet):
return Response(
{
# "teams": reverse("_api_teams", request=request),
'assistance': reverse("API:_api_assistance", request=request),
"devices": reverse("API:device-list", request=request),
"config_groups": reverse("API:_api_config_groups", request=request),
'itim': reverse("API:_api_itim", request=request),
"organizations": reverse("API:_api_orgs", request=request),
'project_management': reverse("API:_api_project_management", request=request),
"settings": reverse('API:_settings', request=request),
"software": reverse("API:software-list", request=request),
}

View File

@ -1,9 +1,10 @@
from django.db.models import Q
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema, OpenApiResponse
from rest_framework import generics, viewsets
from rest_framework.response import Response
from access.mixin import OrganizationMixin
@ -24,6 +25,46 @@ class DeviceViewSet(OrganizationMixin, viewsets.ModelViewSet):
serializer_class = DeviceSerializer
@extend_schema(
summary = 'Create a device',
description="""Add a new device to the ITAM database.
If you attempt to create a device and a device with a matching name and uuid or name and serial number
is found within the database, it will not re-create it. The device will be returned within the message body.
""",
methods=["POST"],
responses = {
200: OpenApiResponse(description='Device allready exists', response=DeviceSerializer),
201: OpenApiResponse(description='Device created', response=DeviceSerializer),
400: OpenApiResponse(description='Validation failed.'),
403: OpenApiResponse(description='User is missing create permissions'),
}
)
def create(self, request, *args, **kwargs):
current_device = []
if 'uuid' in self.request.POST:
current_device = self.serializer_class.Meta.model.objects.filter(
organization = int(self.request.POST['organization']),
uuid = str(self.request.POST['uuid'])
)
if 'serial_number' in self.request.POST and len(current_device) == 0:
current_device = self.serializer_class.Meta.model.objects.filter(
organization = int(self.request.POST['organization']),
serial_number = str(self.request.POST['serial_number'])
)
if len(current_device) == 1:
instance = current_device.get()
serializer = self.get_serializer(instance)
return Response(serializer.data)
return super().create(request, *args, **kwargs)
@extend_schema( description='Fetch devices that are from the users assigned organization(s)', methods=["GET"])
def list(self, request):

View File

@ -0,0 +1 @@
from .index import *

View File

@ -0,0 +1,82 @@
from drf_spectacular.utils import extend_schema, OpenApiResponse
from api.serializers.itim.change import ChangeTicketSerializer
from api.views.core.tickets import View
class View(View):
_ticket_type:str = 'change'
@extend_schema(
summary='Create a ticket',
description = """This model includes all of the ticket types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
request = ChangeTicketSerializer,
responses = {
201: OpenApiResponse(
response = ChangeTicketSerializer,
),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all tickets',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ChangeTicketSerializer
)
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected ticket',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ChangeTicketSerializer
)
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return "Change Ticket"
return 'Change Tickets'

View File

@ -0,0 +1,81 @@
from drf_spectacular.utils import extend_schema, OpenApiResponse
from api.serializers.itim.incident import IncidentTicketSerializer
from api.views.core.tickets import View
class View(View):
_ticket_type:str = 'incident'
@extend_schema(
summary='Create a ticket',
description = """This model includes all of the ticket types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
request = IncidentTicketSerializer,
responses = {
201: OpenApiResponse(
response = IncidentTicketSerializer,
),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all tickets',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = IncidentTicketSerializer
)
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected ticket',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = IncidentTicketSerializer
)
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return "Incident Ticket"
return 'Incident Tickets'

View File

@ -0,0 +1,36 @@
from django.utils.safestring import mark_safe
from rest_framework import views
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.reverse import reverse
class Index(views.APIView):
permission_classes = [
IsAuthenticated,
]
def get_view_name(self):
return "ITIM"
def get_view_description(self, html=False) -> str:
text = "ITIM Module"
if html:
return mark_safe(f"<p>{text}</p>")
else:
return text
def get(self, request, *args, **kwargs):
body: dict = {
'changes': reverse('API:_api_itim_change-list', request=request),
'incidents': reverse('API:_api_itim_incident-list', request=request),
'problems': reverse('API:_api_itim_problem-list', request=request),
}
return Response(body)

View File

@ -0,0 +1,81 @@
from drf_spectacular.utils import extend_schema, OpenApiResponse
from api.serializers.itim.problem import ProblemTicketSerializer
from api.views.core.tickets import View
class View(View):
_ticket_type:str = 'problem'
@extend_schema(
summary='Create a ticket',
description = """This model includes all of the ticket types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
request = ProblemTicketSerializer,
responses = {
201: OpenApiResponse(
response = ProblemTicketSerializer,
),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all tickets',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProblemTicketSerializer
)
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected ticket',
description = """This model includes all of the ticket comment types.
Due to this not all fields will be available and what fields are available
depends upon the comment type. see
[administration docs](https://nofusscomputing.com/projects/centurion_erp/administration/core/ticketing/index.html) for more info.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProblemTicketSerializer
)
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return "Problem Ticket"
return 'Problem Tickets'

View File

@ -75,6 +75,12 @@ class OrganizationPermissionAPI(DjangoObjectPermissions, OrganizationMixin):
self.permission_required = [ permission ]
if hasattr(view, 'get_dynamic_permissions'):
self.permission_required = view.get_dynamic_permissions()
if view:
if 'organization_id' in view.kwargs:

View File

@ -0,0 +1 @@
from .index import *

View File

@ -0,0 +1,34 @@
from django.utils.safestring import mark_safe
from rest_framework import generics, permissions, routers, views
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.reverse import reverse
class Index(views.APIView):
permission_classes = [
IsAuthenticated,
]
def get_view_name(self):
return "Projects"
def get_view_description(self, html=False) -> str:
text = "Projects Managementn Module"
if html:
return mark_safe(f"<p>{text}</p>")
else:
return text
def get(self, request, *args, **kwargs):
body: dict = {
'projects': reverse('API:_api_projects-list', request=request)
}
return Response(body)

View File

@ -0,0 +1,90 @@
from drf_spectacular.utils import extend_schema, OpenApiResponse
from rest_framework import viewsets
from access.mixin import OrganizationMixin
from api.serializers.project_management.project_milestone import ProjectMilestone, ProjectMilestoneSerializer
# from api.views.core.tickets import View
from api.views.mixin import OrganizationPermissionAPI
class View(OrganizationMixin, viewsets.ModelViewSet):
permission_classes = [
OrganizationPermissionAPI
]
queryset = ProjectMilestone.objects.all()
serializer_class = ProjectMilestoneSerializer
@extend_schema(
summary='Create a project milestone',
request = ProjectMilestoneSerializer,
responses = {
201: OpenApiResponse(
response = ProjectMilestoneSerializer,
),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all project milestones',
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProjectMilestoneSerializer
)
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected project milestone',
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProjectMilestoneSerializer
)
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return ProjectMilestone._meta.verbose_name
return ProjectMilestone._meta.verbose_name_plural
def get_queryset(self):
if 'project_id' in self.kwargs:
self.queryset = self.queryset.filter(
project=self.kwargs['project_id']
)
if 'pk' in self.kwargs:
self.queryset = self.queryset.filter(
pk = self.kwargs['pk']
)
return self.queryset

View File

@ -0,0 +1,73 @@
from drf_spectacular.utils import extend_schema, OpenApiResponse
from rest_framework import viewsets
from access.mixin import OrganizationMixin
from api.serializers.project_management.project_state import ProjectState, ProjectStateSerializer
from api.views.core.tickets import View
from api.views.mixin import OrganizationPermissionAPI
class View(OrganizationMixin, viewsets.ModelViewSet):
permission_classes = [
OrganizationPermissionAPI
]
queryset = ProjectState.objects.all()
serializer_class = ProjectStateSerializer
@extend_schema(
summary='Create a project state',
request = ProjectStateSerializer,
responses = {
201: OpenApiResponse(
response = ProjectStateSerializer,
),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all project states',
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProjectStateSerializer
)
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected project state',
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProjectStateSerializer
)
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return ProjectState._meta.verbose_name
return ProjectState._meta.verbose_name_plural

View File

@ -0,0 +1,64 @@
from drf_spectacular.utils import extend_schema, OpenApiResponse
from api.serializers.project_management.project_task import ProjectTaskSerializer
from api.views.core.tickets import View
class View(View):
_ticket_type:str = 'project_task'
@extend_schema(
summary='Create a Project Task',
request = ProjectTaskSerializer,
responses = {
201: OpenApiResponse(
response = ProjectTaskSerializer,
),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all project tasks',
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProjectTaskSerializer
)
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected project task',
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProjectTaskSerializer
)
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return "Project Task"
return 'Project Tasks'

View File

@ -0,0 +1,72 @@
from drf_spectacular.utils import extend_schema, OpenApiResponse
from rest_framework import viewsets
from access.mixin import OrganizationMixin
from api.serializers.project_management.project_type import ProjectType, ProjectTypeSerializer
from api.views.mixin import OrganizationPermissionAPI
class View(OrganizationMixin, viewsets.ModelViewSet):
permission_classes = [
OrganizationPermissionAPI
]
queryset = ProjectType.objects.all()
serializer_class = ProjectTypeSerializer
@extend_schema(
summary='Create a project type',
request = ProjectTypeSerializer,
responses = {
201: OpenApiResponse(
response = ProjectTypeSerializer,
),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch all project types',
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProjectTypeSerializer
)
}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@extend_schema(
summary='Fetch the selected project type',
methods=["GET"],
responses = {
200: OpenApiResponse(
description='Success',
response = ProjectTypeSerializer
)
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return ProjectType._meta.verbose_name
return ProjectType._meta.verbose_name_plural

View File

@ -0,0 +1,105 @@
from django.db.models import Q
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiRequest, PolymorphicProxySerializer
from rest_framework import generics, viewsets
from rest_framework.response import Response
from access.mixin import OrganizationMixin
from api.serializers.project_management.projects import ProjectSerializer, ProjectImportSerializer
from api.views.mixin import OrganizationPermissionAPI
from project_management.models.projects import Project
from settings.models.user_settings import UserSettings
class View(OrganizationMixin, viewsets.ModelViewSet):
filterset_fields = [
'external_system',
'external_ref',
]
search_fields = [
'name',
'description',
]
permission_classes = [
OrganizationPermissionAPI
]
queryset = Project.objects.all()
# serializer_class = ProjectSerializer
def get_serializer_class(self):
if self.has_organization_permission(
organization = UserSettings.objects.get(user = self.request.user).default_organization,
permissions_required = ['project_management.import_project']
) or self.request.user.is_superuser:
return ProjectImportSerializer
return ProjectSerializer
@extend_schema(
summary = 'Create a project',
description = """**Note:** Users whom lack permssion `import_project`,
will be unable to add, edit and view fields: `created`, `external_ref`, `external_system`,
and `is_deleted`.
""",
methods=["POST"],
request = ProjectImportSerializer,
responses = {
201: OpenApiResponse(description='project created', response=ProjectImportSerializer),
403: OpenApiResponse(description='User is missing create permissions'),
}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@extend_schema(
summary='Fetch projects',
description = """**Note:** Users whom lack permssion `import_project`,
will be unable to add, edit and view fields: `created`, `external_ref`, `external_system`,
and `is_deleted`.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(description='projects', response=ProjectImportSerializer)
}
)
def list(self, request):
return super().list(request)
@extend_schema(
summary='Fetch the selected project',
description = """**Note:** Users whom lack permssion `import_project`,
will be unable to add, edit and view fields: `created`, `external_ref`, `external_system`,
and `is_deleted`.
""",
methods=["GET"],
responses = {
200: OpenApiResponse(description='projects', response=ProjectImportSerializer)
}
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
def get_view_name(self):
if self.detail:
return "Project"
return 'Projects'

View File

@ -37,7 +37,11 @@ class View(views.APIView):
status = Http.Status.OK
response_data: dict = {
"permissions": reverse('API:_settings_permissions', request=request)
"permissions": reverse('API:_settings_permissions', request=request),
"project_state": reverse('API:_api_project_state-list', request=request),
"project_type": reverse('API:_api_project_type-list', request=request),
"ticket_categories": reverse('API:_api_ticket_category-list', request=request),
"ticket_comment_categories": reverse('API:_api_ticket_comment_category-list', request=request)
}
return Response(data=response_data,status=status)

View File

@ -108,6 +108,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_json_api',
'django_filters',
'social_django',
'django_celery_results',
'core.apps.CoreConfig',
@ -119,6 +120,7 @@ INSTALLED_APPS = [
'drf_spectacular',
'drf_spectacular_sidecar',
'config_management.apps.ConfigManagementConfig',
'project_management.apps.ProjectManagementConfig',
]
MIDDLEWARE = [
@ -257,7 +259,9 @@ if API_ENABLED:
# ),
'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata',
'DEFAULT_FILTER_BACKENDS': (
'rest_framework_json_api.filters.QueryParameterValidationFilter',
# 'rest_framework_json_api.filters.QueryParameterValidationFilter',
'rest_framework.filters.SearchFilter',
'rest_framework_json_api.django_filters.DjangoFilterBackend',
'rest_framework_json_api.filters.OrderingFilter',
'rest_framework_json_api.django_filters.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
@ -357,11 +361,6 @@ if DEBUG:
"127.0.0.1",
]
# Apps Under Development
INSTALLED_APPS += [
'project_management.apps.ProjectManagementConfig',
]
if SSO_ENABLED:

View File

@ -135,7 +135,14 @@ class ModelPermissionsAdd:
"""
client = Client()
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
if self.app_namespace:
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
else:
url = reverse(self.url_name_add, kwargs=self.url_add_kwargs)
response = client.put(url, data=self.add_data)
@ -150,7 +157,14 @@ class ModelPermissionsAdd:
"""
client = Client()
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
if self.app_namespace:
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
else:
url = reverse(self.url_name_add, kwargs=self.url_add_kwargs)
client.force_login(self.no_permissions_user)
@ -167,7 +181,14 @@ class ModelPermissionsAdd:
"""
client = Client()
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
if self.app_namespace:
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
else:
url = reverse(self.url_name_add, kwargs=self.url_add_kwargs)
client.force_login(self.different_organization_user)
@ -183,7 +204,14 @@ class ModelPermissionsAdd:
"""
client = Client()
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
if self.app_namespace:
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
else:
url = reverse(self.url_name_add, kwargs=self.url_add_kwargs)
client.force_login(self.view_user)
@ -195,11 +223,18 @@ class ModelPermissionsAdd:
def test_model_add_has_permission(self):
""" Check correct permission for add
Attempt to add as user with no permission
Attempt to add as user with permission
"""
client = Client()
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
if self.app_namespace:
url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs)
else:
url = reverse(self.url_name_add, kwargs=self.url_add_kwargs)
client.force_login(self.add_user)

View File

@ -134,6 +134,34 @@ class AddView:
assert type(viewclass.template_name) is str
def test_view_add_function_get_initial_exists(self):
"""Ensure that get_initial exists
Field `get_initial` must be defined as the base class is used for setup.
"""
module = __import__(self.add_module, fromlist=[self.add_view])
view_class = getattr(module, 'Add')
assert hasattr(view_class, 'get_initial')
def test_view_add_function_get_initial_callable(self):
"""Ensure that get_initial is a function
Field `get_initial` must be callable as it's used for setup.
"""
module = __import__(self.add_module, fromlist=[self.add_view])
view_class = getattr(module, 'Add')
func = getattr(view_class, 'get_initial')
assert callable(func)
class ChangeView:
""" Testing of Display view """
@ -524,6 +552,9 @@ class IndexView:
class AllViews(
AddView,
ChangeView,

View File

@ -24,7 +24,7 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from .views import home
from core.views import history
from core.views import history, related_ticket, ticket_linked_item
from settings.views import user_settings
@ -50,6 +50,11 @@ urlpatterns = [
path("history/<str:model_name>/<int:model_pk>", history.View.as_view(), name='_history'),
re_path(r'^static/(?P<path>.*)$', serve,{'document_root': settings.STATIC_ROOT}),
path('ticket/<str:ticket_type>/<int:ticket_id>/relate/add', related_ticket.Add.as_view(), name="_ticket_related_add"),
path('ticket/<str:ticket_type>/<int:ticket_id>/linked_item/add', ticket_linked_item.Add.as_view(), name="_ticket_linked_item_add"),
]
@ -74,12 +79,13 @@ if settings.DEBUG:
urlpatterns += [
path("__debug__/", include("debug_toolbar.urls"), name='_debug'),
path("project_management/", include("project_management.urls")),
]
# must be after above
urlpatterns += [
path("project_management/", include("project_management.urls")),
path("settings/", include("settings.urls")),
]

View File

@ -2,6 +2,8 @@ from django.urls import path
from assistance.views import knowledge_base
from core.views import ticket, ticket_comment
app_name = "Assistance"
urlpatterns = [
@ -12,4 +14,14 @@ urlpatterns = [
path("information/<int:pk>/delete", knowledge_base.Delete.as_view(), name="_knowledge_base_delete"),
path("information/<int:pk>", knowledge_base.View.as_view(), name="_knowledge_base_view"),
path('ticket/request', ticket.Index.as_view(), kwargs={'ticket_type': 'request'}, name="Requests"),
path('ticket/<str:ticket_type>/add', ticket.Add.as_view(), name="_ticket_request_add"),
path('ticket/<str:ticket_type>/<int:pk>/edit', ticket.Change.as_view(), name="_ticket_request_change"),
path('ticket/<str:ticket_type>/<int:pk>/delete', ticket.Delete.as_view(), name="_ticket_request_delete"),
path('ticket/<str:ticket_type>/<int:pk>', ticket.View.as_view(), name="_ticket_request_view"),
path('ticket/<str:ticket_type>/<int:ticket_id>/comment/add', ticket_comment.Add.as_view(), name="_ticket_comment_request_add"),
path('ticket/<str:ticket_type>/<int:ticket_id>/comment/<int:pk>/edit', ticket_comment.Change.as_view(), name="_ticket_comment_request_change"),
path('ticket/<str:ticket_type>/<int:ticket_id>/comment/<int:parent_id>/add', ticket_comment.Add.as_view(), name="_ticket_comment_request_reply_add"),
]

View File

@ -87,6 +87,11 @@ class DetailForm(ConfigGroupForm):
"slug": "configuration",
"sections": []
},
"tickets": {
"name": "Tickets",
"slug": "tickets",
"sections": []
},
"notes": {
"name": "Notes",
"slug": "notes",

View File

@ -130,6 +130,34 @@
</div>
<div id="tickets" class="content-tab">
{% include 'content/section.html.j2' with tab=form.tabs.tickets %}
<table>
<thead>
<th>Name</th>
<th>Status</th>
<th>&nbsp</th>
</thead>
{% if tickets %}
{% for ticket in tickets %}
<tr>
<td>{% concat_strings "#" ticket.ticket.id as ticket_ref %}{{ ticket_ref | markdown | safe}}</td>
<td>{% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.ticket.get_status_display ticket_status=ticket.ticket.get_status_display|ticket_status %}</td>
<td>&nbsp;</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="3">No related tickets exist</td>
</tr>
{% endif %}
</table>
</div>
<div id="notes" class="content-tab">
{% include 'content/section.html.j2' with tab=form.tabs.notes %}

View File

@ -14,16 +14,16 @@ class ConfigManagementViews(
):
add_module = 'config_management.views.groups.groups'
add_view = 'GroupAdd'
add_view = 'Add'
change_module = add_module
change_view = 'GroupView'
change_view = 'View'
delete_module = add_module
delete_view = 'GroupDelete'
delete_view = 'Delete'
display_module = add_module
display_view = 'GroupView'
display_view = 'View'
index_module = add_module
index_view = 'GroupIndexView'
index_view = 'Index'

View File

@ -16,13 +16,13 @@ class ConfigGroupsSoftwareViews(
):
add_module = 'config_management.views.groups.software'
add_view = 'GroupSoftwareAdd'
add_view = 'Add'
change_module = add_module
change_view = 'GroupSoftwareChange'
change_view = 'Change'
delete_module = add_module
delete_view = 'GroupSoftwareDelete'
delete_view = 'Delete'
# display_module = add_module
# display_view = 'GroupView'

View File

@ -1,22 +1,25 @@
from django.urls import path
from config_management.views.groups.groups import GroupIndexView, GroupAdd, GroupChange, GroupDelete, GroupView, GroupHostAdd, GroupHostDelete
from config_management.views.groups.software import GroupSoftwareAdd, GroupSoftwareChange, GroupSoftwareDelete
from config_management.views.groups import groups
from config_management.views.groups.groups import GroupHostAdd, GroupHostDelete
from config_management.views.groups import software
# from config_management.views.groups.software import GroupSoftwareAdd, GroupSoftwareChange, GroupSoftwareDelete
app_name = "Config Management"
urlpatterns = [
path('group', GroupIndexView.as_view(), name='Groups'),
path('group/add', GroupAdd.as_view(), name='_group_add'),
path('group/<int:pk>', GroupView.as_view(), name='_group_view'),
path('group/<int:pk>/edit', GroupChange.as_view(), name='_group_change'),
path('group', groups.Index.as_view(), name='Groups'),
path('group/add', groups.Add.as_view(), name='_group_add'),
path('group/<int:pk>', groups.View.as_view(), name='_group_view'),
path('group/<int:pk>/edit', groups.Change.as_view(), name='_group_change'),
path('group/<int:pk>/child', GroupAdd.as_view(), name='_group_add_child'),
path('group/<int:pk>/delete', GroupDelete.as_view(), name='_group_delete'),
path('group/<int:pk>/child', groups.Add.as_view(), name='_group_add_child'),
path('group/<int:pk>/delete', groups.Delete.as_view(), name='_group_delete'),
path("group/<int:pk>/software/add", GroupSoftwareAdd.as_view(), name="_group_software_add"),
path("group/<int:group_id>/software/<int:pk>", GroupSoftwareChange.as_view(), name="_group_software_change"),
path("group/<int:group_id>/software/<int:pk>/delete", GroupSoftwareDelete.as_view(), name="_group_software_delete"),
path("group/<int:pk>/software/add", software.Add.as_view(), name="_group_software_add"),
path("group/<int:group_id>/software/<int:pk>", software.Change.as_view(), name="_group_software_change"),
path("group/<int:group_id>/software/<int:pk>/delete", software.Delete.as_view(), name="_group_software_delete"),
path('group/<int:pk>/host', GroupHostAdd.as_view(), name='_group_add_host'),
path('group/<int:group_id>/host/<int:pk>/delete', GroupHostDelete.as_view(), name='_group_delete_host'),

View File

@ -6,6 +6,7 @@ from django.utils.decorators import method_decorator
from core.forms.comment import AddNoteForm
from core.models.notes import Notes
from core.models.ticket.ticket_linked_items import Ticket, TicketLinkedItem
from core.views.common import AddView, ChangeView, DeleteView, IndexView
from itam.models.device import Device
@ -18,7 +19,7 @@ from config_management.models.groups import ConfigGroups, ConfigGroupHosts, Conf
class GroupIndexView(IndexView):
class Index(IndexView):
context_object_name = "groups"
@ -50,7 +51,7 @@ class GroupIndexView(IndexView):
class GroupAdd(AddView):
class Add(AddView):
organization_field = 'organization'
@ -67,9 +68,11 @@ class GroupAdd(AddView):
def get_initial(self):
initial: dict = {
'organization': UserSettings.objects.get(user = self.request.user).default_organization
}
# initial: dict = {
# 'organization': UserSettings.objects.get(user = self.request.user).default_organization
# }
initial = super().get_initial()
if 'pk' in self.kwargs:
@ -102,7 +105,7 @@ class GroupAdd(AddView):
class GroupChange(ChangeView):
class Change(ChangeView):
context_object_name = "group"
@ -132,7 +135,7 @@ class GroupChange(ChangeView):
class GroupView(ChangeView):
class View(ChangeView):
context_object_name = "group"
@ -157,6 +160,11 @@ class GroupView(ChangeView):
context['config_group_hosts'] = ConfigGroupHosts.objects.filter(group_id = self.kwargs['pk']).order_by('-host')
context['tickets'] = TicketLinkedItem.objects.filter(
item = int(self.kwargs['pk']),
item_type = TicketLinkedItem.Modules.CONFIG_GROUP
)
context['notes_form'] = AddNoteForm(prefix='note')
context['notes'] = Notes.objects.filter(config_group=self.kwargs['pk'])
@ -215,7 +223,7 @@ class GroupView(ChangeView):
class GroupDelete(DeleteView):
class Delete(DeleteView):
model = ConfigGroups

View File

@ -9,7 +9,7 @@ from config_management.models.groups import ConfigGroups, ConfigGroupSoftware
from core.views.common import AddView, ChangeView, DeleteView
class GroupSoftwareAdd(AddView):
class Add(AddView):
form_class = SoftwareAdd
@ -65,7 +65,7 @@ class GroupSoftwareAdd(AddView):
class GroupSoftwareChange(ChangeView):
class Change(ChangeView):
form_class = SoftwareUpdate
@ -104,7 +104,7 @@ class GroupSoftwareChange(ChangeView):
class GroupSoftwareDelete(DeleteView):
class Delete(DeleteView):
model = ConfigGroupSoftware

View File

@ -46,9 +46,6 @@ class CommonModelForm(forms.ModelForm):
if team_user.team.organization.name not in user_organizations:
if not user_organizations:
self.user_organizations = []
user_organizations += [ team_user.team.organization.name ]
user_organizations_id += [ team_user.team.organization.id ]
@ -76,11 +73,13 @@ class CommonModelForm(forms.ModelForm):
if hasattr(field.queryset.model, 'is_global'):
self.fields[field_name].queryset = field.queryset.filter(
Q(organization__in=user_organizations_id)
|
Q(is_global = True)
)
if field.queryset.model.is_global is not None:
self.fields[field_name].queryset = field.queryset.filter(
Q(organization__in=user_organizations_id)
|
Q(is_global = True)
)
else:

View File

@ -0,0 +1,55 @@
from django import forms
from django.db.models import Q
from django.forms import ValidationError
from app import settings
from core.forms.common import CommonModelForm
from core.models.ticket.ticket import RelatedTickets
class RelatedTicketForm(CommonModelForm):
prefix = 'ticket'
class Meta:
model = RelatedTickets
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['from_ticket_id'].widget = self.fields['from_ticket_id'].hidden_widget()
def clean(self):
cleaned_data = super().clean()
return cleaned_data
def is_valid(self) -> bool:
is_valid = super().is_valid()
check_db = self.Meta.model.objects.filter(
to_ticket_id = self.cleaned_data['to_ticket_id'].id,
from_ticket_id = self.cleaned_data['from_ticket_id'].id,
)
check_db_inverse = self.Meta.model.objects.filter(
to_ticket_id = self.cleaned_data['from_ticket_id'].id,
from_ticket_id = self.cleaned_data['to_ticket_id'].id,
)
if check_db.count() > 0 or check_db_inverse.count() > 0:
raise ValidationError(f"Ticket is already related to #{self.cleaned_data['to_ticket_id'].id}")
is_valid = False
return is_valid

249
app/core/forms/ticket.py Normal file
View File

@ -0,0 +1,249 @@
from django import forms
from django.db.models import Q
from django.forms import ValidationError
from app import settings
from core.forms.common import CommonModelForm
from core.forms.validate_ticket import TicketValidation
from core.models.ticket.ticket import Ticket, RelatedTickets
class TicketForm(
CommonModelForm,
TicketValidation,
):
class Meta:
model = Ticket
fields = '__all__'
def __init__(self, request, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
self.fields['planned_start_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local', 'format': "%Y-%m-%dT%H:%M"})
self.fields['planned_start_date'].input_formats = settings.DATETIME_FORMAT
self.fields['planned_start_date'].format="%Y-%m-%dT%H:%M"
self.fields['planned_finish_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'})
self.fields['planned_finish_date'].input_formats = settings.DATETIME_FORMAT
self.fields['planned_finish_date'].format="%Y-%m-%dT%H:%M"
self.fields['real_start_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'})
self.fields['real_start_date'].input_formats = settings.DATETIME_FORMAT
self.fields['real_start_date'].format="%Y-%m-%dT%H:%M"
self.fields['real_finish_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'})
self.fields['real_finish_date'].input_formats = settings.DATETIME_FORMAT
self.fields['real_finish_date'].format="%Y-%m-%dT%H:%M"
self.fields['description'].widget.attrs = {'style': "height: 800px; width: 900px"}
self.fields['opened_by'].initial = kwargs['user'].pk
self.fields['opened_by'].widget = self.fields['opened_by'].hidden_widget()
self.fields['ticket_type'].widget = self.fields['ticket_type'].hidden_widget()
self.fields['organization'].initial = self.initial['organization']
if self.instance.pk is not None:
del self.fields['organization']
if self.instance.project is not None:
self.fields['milestone'].queryset = self.fields['milestone'].queryset.filter(
project=self.instance.project
)
else:
self.fields['milestone'].queryset = self.fields['milestone'].queryset.filter(
id=0
)
original_fields = self.fields.copy()
ticket_type = []
if kwargs['initial']['type_ticket'] == 'request':
ticket_type = self.Meta.model.fields_itsm_request
self.fields['status'].choices = self.Meta.model.TicketStatus.Request
self.fields['ticket_type'].initial = '1'
self.fields['category'].queryset = self.fields['category'].queryset.filter(
request=True
)
elif kwargs['initial']['type_ticket'] == 'incident':
ticket_type = self.Meta.model.fields_itsm_incident
self.fields['status'].choices = self.Meta.model.TicketStatus.Incident
self.fields['ticket_type'].initial = self.Meta.model.TicketType.INCIDENT.value
self.fields['category'].queryset = self.fields['category'].queryset.filter(
incident=True
)
elif kwargs['initial']['type_ticket'] == 'problem':
ticket_type = self.Meta.model.fields_itsm_problem
self.fields['status'].choices = self.Meta.model.TicketStatus.Problem
self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROBLEM.value
self.fields['category'].queryset = self.fields['category'].queryset.filter(
problem=True
)
elif kwargs['initial']['type_ticket'] == 'change':
ticket_type = self.Meta.model.fields_itsm_change
self.fields['status'].choices = self.Meta.model.TicketStatus.Change
self.fields['ticket_type'].initial = self.Meta.model.TicketType.CHANGE.value
self.fields['category'].queryset = self.fields['category'].queryset.filter(
change=True
)
elif kwargs['initial']['type_ticket'] == 'issue':
ticket_type = self.Meta.model.fields_git_issue
self.fields['status'].choices = self.Meta.model.TicketStatus.Git
self.fields['ticket_type'].initial = self.Meta.model.TicketType.ISSUE.value
elif kwargs['initial']['type_ticket'] == 'merge':
ticket_type = self.Meta.model.fields_git_merge
self.fields['status'].choices = self.Meta.model.TicketStatus.Git
self.fields['ticket_type'].initial = self.Meta.model.TicketType.MERGE_REQUEST.value
elif kwargs['initial']['type_ticket'] == 'project_task':
ticket_type = self.Meta.model.fields_project_task
self.fields['status'].choices = self.Meta.model.TicketStatus.ProjectTask
self._project: int = kwargs['initial']['project']
self.fields['project'].initial = self._project
self.fields['project'].widget = self.fields['project'].hidden_widget()
self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROJECT_TASK.value
self.fields['category'].queryset = self.fields['category'].queryset.filter(
project_task=True
)
if kwargs['user'].is_superuser:
ticket_type += self.Meta.model.tech_fields
self.ticket_type_fields = ticket_type
fields_allowed_by_permission = self.get_fields_allowed_by_permission
allowed_ticket_fields: list = []
for field in fields_allowed_by_permission: # Remove fields not intended for the ticket type
if field in ticket_type:
allowed_ticket_fields = allowed_ticket_fields + [ field ]
for field in original_fields: # Remove fields user cant edit unless field is hidden
if (
(
field not in allowed_ticket_fields and not self.fields[field].widget.is_hidden
)
or
field not in ticket_type
):
# self.fields[field].widget = self.fields[field].hidden_widget()
del self.fields[field]
def clean(self):
cleaned_data = super().clean()
return cleaned_data
def is_valid(self) -> bool:
is_valid = super().is_valid()
self.validate_ticket()
if self._ticket_type == 'change':
self.validate_change_ticket()
elif self._ticket_type == 'incident':
self.validate_incident_ticket()
elif self._ticket_type == 'issue':
# self.validate_issue_ticket()
raise ValidationError(
'This Ticket type is not yet available'
)
elif self._ticket_type == 'merge_request':
# self.validate_merge_request_ticket()
raise ValidationError(
'This Ticket type is not yet available'
)
elif self._ticket_type == 'problem':
self.validate_problem_ticket()
elif self._ticket_type == 'project_task':
self.validate_project_task_ticket()
elif self._ticket_type == 'request':
self.validate_request_ticket()
else:
raise ValidationError('Ticket Type must be set')
return is_valid
class DetailForm(CommonModelForm):
prefix = 'ticket'
class Meta:
model = Ticket
fields = '__all__'

View File

@ -0,0 +1,120 @@
from django import forms
from django.forms import ValidationError
from django.urls import reverse
from app import settings
from core.forms.common import CommonModelForm
from core.models.ticket.ticket_category import TicketCategory
class TicketCategoryForm(CommonModelForm):
class Meta:
fields = '__all__'
model = TicketCategory
prefix = 'ticket_category'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['parent'].queryset = self.fields['parent'].queryset.exclude(
id=self.instance.pk
)
def clean(self):
cleaned_data = super().clean()
pk = self.instance.id
parent = cleaned_data.get("parent")
if pk:
if parent == pk:
raise ValidationError("Category can't have itself as its parent category")
return cleaned_data
class DetailForm(TicketCategoryForm):
tabs: dict = {
"details": {
"name": "Details",
"slug": "details",
"sections": [
{
"layout": "double",
"left": [
'parent',
'name',
'runbook',
'organization',
'c_created',
'c_modified'
],
"right": [
'model_notes',
]
},
{
"layout": "double",
"name": "Ticket Types",
"left": [
'change',
'problem',
'request'
],
"right": [
'incident',
'project_task'
]
},
]
},
"notes": {
"name": "Notes",
"slug": "notes",
"sections": []
}
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['c_created'] = forms.DateTimeField(
label = 'Created',
input_formats=settings.DATETIME_FORMAT,
disabled = True,
initial = self.instance.created,
)
self.fields['c_modified'] = forms.DateTimeField(
label = 'Modified',
input_formats=settings.DATETIME_FORMAT,
disabled = True,
initial = self.instance.modified,
)
self.tabs['details'].update({
"edit_url": reverse('Settings:_ticket_category_change', kwargs={'pk': self.instance.pk})
})
self.url_index_view = reverse('Settings:_ticket_categories')

View File

@ -0,0 +1,163 @@
from django import forms
from django.db.models import Q
from app import settings
from core.forms.common import CommonModelForm
from core.forms.validate_ticket_comment import TicketCommentValidation
from core.models.ticket.ticket_comment import TicketComment
class CommentForm(
CommonModelForm,
TicketCommentValidation
):
prefix = 'ticket'
class Meta:
model = TicketComment
fields = '__all__'
def __init__(self, request, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
self._ticket_organization = self.fields['ticket'].queryset.model.objects.get(pk=int(self.initial['ticket'])).organization
self._ticket_type = kwargs['initial']['type_ticket']
if 'qs_comment_type' in kwargs['initial']:
self._comment_type = kwargs['initial']['qs_comment_type']
else:
self._comment_type = str(self.instance.get_comment_type_display()).lower()
self.ticket_comment_permissions
self.fields['planned_start_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local', 'format': "%Y-%m-%dT%H:%M"})
self.fields['planned_start_date'].input_formats = settings.DATETIME_FORMAT
self.fields['planned_start_date'].format="%Y-%m-%dT%H:%M"
self.fields['planned_finish_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'})
self.fields['planned_finish_date'].input_formats = settings.DATETIME_FORMAT
self.fields['planned_finish_date'].format="%Y-%m-%dT%H:%M"
self.fields['real_start_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'})
self.fields['real_start_date'].input_formats = settings.DATETIME_FORMAT
self.fields['real_start_date'].format="%Y-%m-%dT%H:%M"
self.fields['real_finish_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local'})
self.fields['real_finish_date'].input_formats = settings.DATETIME_FORMAT
self.fields['real_finish_date'].format="%Y-%m-%dT%H:%M"
self.fields['body'].widget.attrs = {'style': "height: 800px; width: 900px"}
self.fields['duration'].widget = self.fields['duration'].hidden_widget()
self.fields['user'].initial = kwargs['user'].pk
self.fields['user'].widget = self.fields['user'].hidden_widget()
self.fields['ticket'].widget = self.fields['ticket'].hidden_widget()
self.fields['parent'].widget = self.fields['parent'].hidden_widget()
self.fields['comment_type'].widget = self.fields['comment_type'].hidden_widget()
if not( self._has_import_permission or self._has_triage_permission or request.user.is_superuser ):
self.fields['source'].initial = TicketComment.CommentSource.HELPDESK
self.fields['source'].widget = self.fields['source'].hidden_widget()
else:
self.fields['source'].initial = TicketComment.CommentSource.DIRECT
if self._comment_type == 'task':
self.fields['comment_type'].initial = self.Meta.model.CommentType.TASK
self.fields['category'].queryset = self.fields['category'].queryset.filter(
task = True
)
elif self._comment_type == 'comment':
self.fields['comment_type'].initial = self.Meta.model.CommentType.COMMENT
self.fields['category'].queryset = self.fields['category'].queryset.filter(
comment = True
)
elif self._comment_type == 'solution':
self.fields['comment_type'].initial = self.Meta.model.CommentType.SOLUTION
self.fields['category'].queryset = self.fields['category'].queryset.filter(
solution = True
)
elif self._comment_type == 'notification':
self.fields['comment_type'].initial = self.Meta.model.CommentType.NOTIFICATION
self.fields['category'].queryset = self.fields['category'].queryset.filter(
notification = True
)
allowed_fields = self.fields_allowed
original_fields = self.fields.copy()
for field in original_fields:
if field not in allowed_fields and not self.fields[field].widget.is_hidden:
del self.fields[field]
def clean(self):
cleaned_data = super().clean()
return cleaned_data
def is_valid(self) -> bool:
is_valid = super().is_valid()
validate_ticket_comment: bool = self.validate_ticket_comment()
if not validate_ticket_comment:
is_valid = validate_ticket_comment
return is_valid
class DetailForm(CommentForm):
prefix = 'ticket'
class Meta:
model = TicketComment
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@ -0,0 +1,119 @@
from django import forms
from django.forms import ValidationError
from django.urls import reverse
from app import settings
from core.forms.common import CommonModelForm
from core.models.ticket.ticket_comment_category import TicketCommentCategory
class TicketCommentCategoryForm(CommonModelForm):
class Meta:
fields = '__all__'
model = TicketCommentCategory
prefix = 'ticket_comment_category'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['parent'].queryset = self.fields['parent'].queryset.exclude(
id=self.instance.pk
)
def clean(self):
cleaned_data = super().clean()
pk = self.instance.id
parent = cleaned_data.get("parent")
if pk:
if parent == pk:
raise ValidationError("Category can't have itself as its parent category")
return cleaned_data
class DetailForm(TicketCommentCategoryForm):
tabs: dict = {
"details": {
"name": "Details",
"slug": "details",
"sections": [
{
"layout": "double",
"left": [
'parent',
'name',
'runbook',
'organization',
'c_created',
'c_modified'
],
"right": [
'model_notes',
]
},
{
"layout": "double",
"name": "Comment Types",
"left": [
'comment',
'solution'
],
"right": [
'notification',
'task'
]
},
]
},
"notes": {
"name": "Notes",
"slug": "notes",
"sections": []
}
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['c_created'] = forms.DateTimeField(
label = 'Created',
input_formats=settings.DATETIME_FORMAT,
disabled = True,
initial = self.instance.created,
)
self.fields['c_modified'] = forms.DateTimeField(
label = 'Modified',
input_formats=settings.DATETIME_FORMAT,
disabled = True,
initial = self.instance.modified,
)
self.tabs['details'].update({
"edit_url": reverse('Settings:_ticket_comment_category_change', kwargs={'pk': self.instance.pk})
})
self.url_index_view = reverse('Settings:_ticket_comment_categories')

View File

@ -0,0 +1,26 @@
from django import forms
from django.db.models import Q
from django.forms import ValidationError
from app import settings
from core.forms.common import CommonModelForm
from core.models.ticket.ticket_linked_items import TicketLinkedItem
class TicketLinkedItemForm(CommonModelForm):
prefix = 'ticket_linked_item'
class Meta:
model = TicketLinkedItem
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['organization'].widget = self.fields['organization'].hidden_widget()
self.fields['ticket'].widget = self.fields['ticket'].hidden_widget()

View File

@ -0,0 +1,668 @@
from django.core.exceptions import PermissionDenied, ValidationError
from rest_framework import serializers
from access.mixin import OrganizationMixin
class TicketValidation(
OrganizationMixin,
):
"""Ticket Form/Serializer Validation
Validate a ticket form or api viewset
## Class requirements
- attribute `ticket_type_fields` is set to a list of fields for the ticket type
- attribute `ticket_type` is set to a string value (lowercase) of the ticket type
Raises:
PermissionDenied: User has no allowable fields to edit
PermissionDenied: User is lacking permission to edit a field
serializers.ValidationError: Status field has a value set that does not meet the ticket type
ValidationError: Status field has a value set that does not meet the ticket type
"""
original_object = None
add_fields: list = [
'title',
'description',
'urgency',
'organization'
]
change_fields: list = []
delete_fields: list = [
'is_deleted',
]
import_fields: list = [
'assigned_users',
'assigned_teams',
'category',
'created',
'date_closed',
'estimate',
'external_ref',
'external_system',
'status',
'impact',
'opened_by',
'planned_start_date',
'planned_finish_date',
'priority',
'project',
'milestone',
'real_start_date',
'real_finish_date',
'subscribed_users',
'subscribed_teams',
'ticket_type',
]
triage_fields: list = [
'category',
'assigned_users',
'assigned_teams',
'estimate',
'status',
'impact',
'opened_by',
'planned_start_date',
'planned_finish_date',
'priority',
'project',
'milestone',
'real_start_date',
'real_finish_date',
'subscribed_users',
'subscribed_teams',
]
def combined_validation_error(self, message:str, code:str = None) -> None:
if 'serializers' in self.Meta.__module__:
raise serializers.ValidationError(
detail = message,
code = code
)
else:
raise ValidationError(
message = message,
code = code
)
@property
def get_fields_allowed_by_permission(self):
if hasattr(self, '_fields_allowed_by_permission'):
return self._fields_allowed_by_permission
if not hasattr(self, '_ticket_type'):
self._ticket_type = self.initial['type_ticket']
fields_allowed: list = []
if self.instance is not None:
ticket_organization = self.instance.organization
else:
ticket_organization = self.validated_data['organization']
if ticket_organization is None:
ticket_organization = self.initial['organization']
if ticket_organization is None:
if 'organization' in self.data:
ticket_organization = self.fields['organization'].queryset.model.objects.get(pk=self.data['organization'])
if self.has_organization_permission(
organization=ticket_organization.id,
permissions_required = [ 'core.add_ticket_'+ self._ticket_type ],
) and not self.request.user.is_superuser:
fields_allowed = self.add_fields
if self.has_organization_permission(
organization=ticket_organization.id,
permissions_required = [ 'core.change_ticket_'+ self._ticket_type ],
) and not self.request.user.is_superuser:
if len(fields_allowed) == 0:
fields_allowed = self.add_fields + self.change_fields
else:
fields_allowed = fields_allowed + self.change_fields
if self.has_organization_permission(
organization=ticket_organization.id,
permissions_required = [ 'core.delete_ticket_'+ self._ticket_type ],
) and not self.request.user.is_superuser:
fields_allowed = fields_allowed + self.delete_fields
if self.has_organization_permission(
organization=ticket_organization.id,
permissions_required = [ 'core.import_ticket_'+ self._ticket_type ],
) and not self.request.user.is_superuser:
if hasattr(self, 'serializer_choice_field'):
fields_allowed = fields_allowed + self.import_fields
if self.has_organization_permission(
organization=ticket_organization.id,
permissions_required = [ 'core.triage_ticket_'+ self._ticket_type ],
) and not self.request.user.is_superuser:
fields_allowed = fields_allowed + self.triage_fields
if self.request.user.is_superuser:
all_fields: list = self.add_fields
all_fields = all_fields + self.change_fields
all_fields = all_fields + self.delete_fields
all_fields = all_fields + self.import_fields
all_fields = all_fields + self.triage_fields
fields_allowed = fields_allowed + all_fields
self._fields_allowed_by_permission = fields_allowed
return fields_allowed
@property
def get_user_changed_data(self) -> dict:
"""Create an object with the user 'changed' data.
Due to forms having fields deleted, this function is required
as attribute `cleaned_data` no longer functions per normal.
Returns:
_user_changed_data (dict): Changed data.
"""
if hasattr(self, '_user_changed_data'):
return self._user_changed_data
changed_data: dict = {}
for field in self.get_user_changed_fields:
if hasattr(self.Meta.model, field):
changed_data.update({
field: self.request.POST.dict()[field]
})
if len(changed_data) > 0:
self._user_changed_data = changed_data
return changed_data
@property
def get_user_changed_fields(self) -> list(str()):
"""List of fields the user changed.
This data is sourced from the HTTP/POST data.
Returns:
list: All of the fields that have changed.
"""
if hasattr(self, '_user_changed_fields'):
return self._user_changed_fields
changed_data: list = []
changed_data_exempt = [
'_state',
'csrfmiddlewaretoken',
'ticket_comments',
'url',
]
post_data: dict = self.request.POST.dict().copy()
for field in post_data:
if hasattr(self.Meta.model, field):
changed_data = changed_data + [ field ]
if len(changed_data) > 0:
self._user_changed_fields = changed_data
return changed_data
@property
def validate_field_permission(self):
""" Check field permissions
Users can't edit all fields. They can only adjust fields that they
have the permissions to adjust.
## Required fields
A field marked as required when the instance has no pk, the field will have
it's permission marked as allowed. This is not the case for items thaat are being
edited, i.e. have a pk.
Raises:
ValidationError: Access Denied when user has no ticket permissions assigned
ValidationError: User tried to edit a field they dont have permission to edit.
"""
fields_allowed = self.get_fields_allowed_by_permission
if len(fields_allowed) == 0:
self.combined_validation_error('Access Denied to all fields', code='access_denied_all_fields')
for field in self.get_user_changed_fields:
allowed: bool = False
if (
field in self.fields
and field in self.ticket_type_fields
and (
field in fields_allowed
)
):
allowed = True
if hasattr(self.instance, 'pk'):
if (
field in self.fields
and field in self.ticket_type_fields
and self.instance.pk is None
):
if self.fields[field].required:
allowed = True
elif self.instance is None:
if self.fields[field].required:
allowed = True
if not allowed:
if (
self.field_edited(field)
or (
field not in fields_allowed
and hasattr(self.Meta.model, field)
)
):
self.combined_validation_error(
f'cant edit field: {field}',
code=f'cant_edit_field_{field}',
)
return False
return True
def field_edited(self, field:str) -> bool:
if hasattr(self, 'cleaned_data'): # initial avail in ui
initial_data: dict = self.initial
changed_data: dict = self.get_user_changed_data
elif hasattr(self, 'validated_data'): # API
initial_data:dict = self.instance.__dict__
changed_data: dict = self.validated_data
if field in initial_data:
value = initial_data[field]
elif str(field) + '_id' in initial_data:
value = initial_data[str(field) + '_id']
else:
return True
if field in changed_data:
if changed_data[field] == value:
return False
if hasattr(changed_data[field], 'id'):
if value is None:
return True
if int(value) == changed_data[field].id:
return False
else:
val = value
if value is None:
return True
if type(changed_data[field]) is int:
val = int(value)
elif type(changed_data[field]) is bool:
val = bool(value)
elif type(changed_data[field]) is str:
val = str(value)
if val == changed_data[field]:
return False
else:
return False
return True
def validate_field_milestone(self):
is_valid: bool = False
if self.instance is not None:
if self.instance.milestone is None:
return True
else:
if self.instance.project is None:
self.combined_validation_error(
f'Milestones require a project',
code=f'milestone_requires_project',
)
return False
if self.instance.project.id == self.instance.milestone.project.id:
return True
else:
self.combined_validation_error(
f'Milestone must be from the same project',
code=f'milestone_same_project',
)
return is_valid
def validate_field_organization(self) -> bool:
"""Check `organization field`
Raises:
ValidationError: user tried to change the organization
Returns:
True (bool): OK
False (bool): User tried to edit the organization
"""
is_valid: bool = True
if self.instance is not None:
if self.instance.pk is not None:
if 'organization' in self.get_user_changed_fields:
if self.field_edited('organization'):
is_valid = False
self.combined_validation_error(
f'cant edit field: organization',
code=f'cant_edit_field_organization',
)
return is_valid
def validate_field_status(self):
"""Validate status field
Ticket status depends upon ticket type.
Ensure that the corrent status is used.
"""
is_valid = False
if not hasattr(self, '_ticket_type'):
self._ticket_type = self.initial['type_ticket']
try:
if hasattr(self, 'cleaned_data'):
field = self.cleaned_data['status']
else:
field = self.validated_data['status']
except KeyError:
# field = self.fields['status'].default.value
field = getattr(self.Meta.model, 'status').field.default.value
if self._ticket_type == 'request':
if field in self.Meta.model.TicketStatus.Request._value2member_map_:
is_valid = True
elif self._ticket_type == 'incident':
if field in self.Meta.model.TicketStatus.Incident._value2member_map_:
is_valid = True
elif self._ticket_type == 'problem':
if field in self.Meta.model.TicketStatus.Problem._value2member_map_:
is_valid = True
elif self._ticket_type == 'change':
if field in self.Meta.model.TicketStatus.Change._value2member_map_:
is_valid = True
elif self._ticket_type == 'issue':
if field in self.Meta.model.TicketStatus.Issue._value2member_map_:
is_valid = True
elif self._ticket_type == 'merge':
if field in self.Meta.model.TicketStatus.Merge._value2member_map_:
is_valid = True
elif self._ticket_type == 'project_task':
if field in self.Meta.model.TicketStatus.ProjectTask._value2member_map_:
is_valid = True
if not is_valid:
if hasattr(self, 'validated_data'):
raise serializers.ValidationError('Incorrect Status set')
else:
self.combined_validation_error('Incorrect Status set')
return is_valid
def validate_ticket(self):
"""Validations common to all ticket types."""
is_valid = False
fields: list = []
if hasattr(self, 'validated_data'):
fields = self.validated_data
else:
fields = self.cleaned_data
validate_field_permission = False
if self.validate_field_permission:
validate_field_permission = True
validate_field_organization: bool = False
if self.validate_field_organization():
validate_field_organization = True
validate_field_milestone: bool = False
if self.validate_field_milestone():
validate_field_milestone: bool = True
validate_field_status = False
if self.validate_field_status():
validate_field_status = True
if (
validate_field_permission
and validate_field_status
and validate_field_milestone
and validate_field_organization
):
is_valid = True
return is_valid
def validate_change_ticket(self):
# check status
# check type
pass
def validate_incident_ticket(self):
# check status
# check type
pass
def validate_problem_ticket(self):
# check status
# check type
pass
def validate_request_ticket(self):
# check status
# check type
# self.combined_validation_error('Test to see what it looks like')
pass
def validate_project_task_ticket(self):
if hasattr(self,'_project'):
self.cleaned_data.update({
'project': self._project
})
if self.cleaned_data['project'] is None:
self.combined_validation_error('A project task requires a project')

View File

@ -0,0 +1,276 @@
from django.core.exceptions import PermissionDenied
from django.forms import ValidationError
from rest_framework import serializers
from access.mixin import OrganizationMixin
class TicketCommentValidation(
OrganizationMixin,
):
original_object = None
_comment_type:str = None
"""Human readable comment type. i.e. `request` in lowercase"""
_has_add_permission: bool = False
_has_change_permission: bool = False
_has_delete_permission: bool = False
_has_import_permission: bool = False
_has_triage_permission: bool = False
_ticket_organization = None
"""Ticket Organization as a organization object"""
_ticket_type: str = None
"""Human readable type of ticket. i.e. `request` in lowercase"""
request = None
add_fields: list = [
'body',
'duration'
]
change_fields: list = [
'body',
]
delete_fields: list = [
'is_deleted',
]
import_fields: list = [
'organization',
'parent',
'ticket',
'external_ref',
'external_system',
'comment_type',
'body',
'category',
'created',
'modified',
'private',
'duration',
'template',
'is_template',
'source',
'status',
'responsible_user',
'responsible_team',
'user',
'date_closed',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
]
triage_fields: list = [
'category',
'body',
'private',
'duration',
'template',
'is_template',
'source',
'status',
'responsible_user',
'responsible_team',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
]
@property
def fields_allowed(self) -> list(str()):
""" Get the allowed fields for a ticket ccomment
Returns:
list(str): A list of allowed fields for the user
"""
if self.request is None:
raise ValueError('Attribute self.request must be set')
fields_allowed: list = []
if self._has_add_permission and not self.request.user.is_superuser:
fields_allowed = self.add_fields
if self._has_change_permission and not self.request.user.is_superuser:
fields_allowed = self.change_fields
if self._has_delete_permission and not self.request.user.is_superuser:
fields_allowed = fields_allowed + self.delete_fields
if self._has_import_permission and not self.request.user.is_superuser:
fields_allowed = fields_allowed + self.import_fields
if self._has_triage_permission and not self.request.user.is_superuser:
fields_allowed = fields_allowed + self.triage_fields
if self.request.user.is_superuser:
all_fields: list = self.add_fields
all_fields = all_fields + self.change_fields
all_fields = all_fields + self.delete_fields
all_fields = all_fields + self.import_fields
all_fields = all_fields + self.triage_fields
fields_allowed = fields_allowed + all_fields
comment_fields = []
if (
self._ticket_type == 'request'
or
self._ticket_type == 'incident'
or
self._ticket_type == 'problem'
or
self._ticket_type == 'change'
or
self._ticket_type == 'project_task'
):
if self._comment_type == 'task':
comment_fields = self.Meta.model.fields_itsm_task
self.fields['comment_type'].initial = self.Meta.model.CommentType.TASK
elif self._comment_type == 'comment':
comment_fields = self.Meta.model.common_itsm_fields
self.fields['comment_type'].initial = self.Meta.model.CommentType.COMMENT
elif self._comment_type == 'solution':
comment_fields = self.Meta.model.common_itsm_fields
self.fields['comment_type'].initial = self.Meta.model.CommentType.SOLUTION
elif self._comment_type == 'notification':
comment_fields = self.Meta.model.fields_itsm_notification
self.fields['comment_type'].initial = self.Meta.model.CommentType.NOTIFICATION
elif self._ticket_type == 'issue':
comment_fields = self.Meta.model.fields_git_issue
elif self._ticket_type == 'merge':
comment_fields = self.Meta.model.fields_git_merge
for comment_field in comment_fields:
if comment_field not in fields_allowed:
comment_fields.remove(comment_field)
return comment_fields
@property
def ticket_comment_permissions(self):
if self._ticket_organization is None:
raise ValueError('Attribute self._ticket_organization must be set')
if self.request is None:
raise ValueError('Attribute self.request must be set')
if self.has_organization_permission(
organization=self._ticket_organization.id,
permissions_required = [ 'core.add_ticket_'+ self._ticket_type ],
) and not self.request.user.is_superuser:
self._has_add_permission = True
if (
self.has_organization_permission(
organization=self._ticket_organization.id,
permissions_required = [ 'core.change_ticketcomment' ],
) or
self.request.user.id == self.instance.user_id
) and not self.request.user.is_superuser:
self._has_change_permission = True
if self.has_organization_permission(
organization=self._ticket_organization.id,
permissions_required = [ 'core.delete_ticketcomment' ],
) and not self.request.user.is_superuser:
self._has_delete_permission = True
if self.has_organization_permission(
organization=self._ticket_organization.id,
permissions_required = [ 'core.import_ticketcomment' ],
) and not self.request.user.is_superuser:
self._has_import_permission = True
if self.has_organization_permission(
organization=self._ticket_organization.id,
permissions_required = [ 'core.triage_ticket_'+ self._ticket_type ],
) and not self.request.user.is_superuser:
self._has_triage_permission = True
if (
not self._has_triage_permission and (
self._comment_type == 'notification' or
self._comment_type == 'task' or
self._comment_type == 'solution'
)
) and not self.request.user.is_superuser:
raise PermissionDenied("You dont have permission for comment types: notification, task and solution")
def validate_ticket_comment(self) -> bool:
is_valid: bool = True
self.ticket_comment_permissions
fields_allowed = self.fields_allowed
for field in self.change_fields:
if field not in fields_allowed:
raise PermissionDenied(f'You tried to edit a field ({field}) that you dont have access to edit')
return is_valid

0
app/core/lib/__init__.py Normal file
View File

114
app/core/lib/markdown.py Normal file
View File

@ -0,0 +1,114 @@
import re
from markdown_it import MarkdownIt
from mdit_py_plugins import admon, anchors, footnote, tasklists
from pygments import highlight
from pygments.formatters.html import HtmlFormatter
from pygments.lexers import get_lexer_by_name
from django.template.loader import render_to_string
from .markdown_plugins import ticket_number, model_reference
class Markdown:
"""Ticket and Comment markdown functions
Intended to be used for all areas of a tickets, projects and comments.
"""
def highlight_func(self, code: str, lang: str, _) -> str | None:
"""Use pygments for code high lighting"""
if not lang:
return None
lexer = get_lexer_by_name(lang)
formatter = HtmlFormatter(style='vs', cssclass='codehilite')
return highlight(code, lexer, formatter)
def render_markdown(self, markdown_text):
"""Render Markdown
implemented using https://markdown-it-py.readthedocs.io/en/latest/index.html
Args:
markdown_text (str): Markdown text
Returns:
str: HTML text
"""
md = (
MarkdownIt(
config = "js-default",
options_update={
'linkify': True,
'highlight': self.highlight_func,
}
)
.enable([
'linkify',
'strikethrough',
'table',
])
.use(admon.admon_plugin)
.use(anchors.anchors_plugin, permalink=True)
.use(footnote.footnote_plugin)
.use(tasklists.tasklists_plugin)
.use(ticket_number.plugin, enabled=True)
.use(model_reference.plugin, enabled=True)
)
return md.render(markdown_text)
def build_ticket_html(self, match):
ticket_id = match.group(1)
try:
if hasattr(self, 'ticket'):
ticket = self.ticket.__class__.objects.get(pk=ticket_id)
else:
ticket = self.__class__.objects.get(pk=ticket_id)
project_id = str('0')
if ticket.project:
project_id = str(ticket.project.id).lower()
context: dict = {
'id': ticket.id,
'name': ticket,
'ticket_type': str(ticket.get_ticket_type_display()).lower(),
'ticket_status': str(ticket.get_status_display()).lower(),
'project_id': project_id,
}
html_link = render_to_string('core/ticket/renderers/ticket_link.html.j2', context)
return str(html_link)
except:
return str('#' + ticket_id)
def ticket_reference(self, text):
return re.sub('#(\d+)', self.build_ticket_html, text)

View File

@ -0,0 +1,155 @@
import re
from django.template import Context, Template
from django.template.loader import render_to_string
from django.urls import reverse
from markdown_it import MarkdownIt
from markdown_it.rules_core import StateCore
from markdown_it.token import Token
# Regex string to match a whitespace character, as specified in
# https://github.github.com/gfm/#whitespace-character
# (spec version 0.29-gfm (2019-04-06))
_GFM_WHITESPACE_RE = r"[ \t\n\v\f\r]"
def plugin(
md: MarkdownIt,
enabled: bool = False,
) -> None:
"""markdown_it plugin to render model references
Placing `$<type>-<number>` within markdown will be rendered as a pretty link to the model.
Args:
md (MarkdownIt): markdown object
enabled (bool, optional): Enable the parsing of model references. Defaults to False.
Returns:
None: nada
"""
def main(state: StateCore) -> None:
tokens = state.tokens
for i in range(0, len(tokens) - 1):
if is_tag_item(tokens, i):
tag_render(tokens[i])
def is_inline(token: Token) -> bool:
return token.type == "inline"
def is_tag_item(tokens: list[Token], index: int) -> bool:
return (
is_inline(tokens[index])
and contains_tag_item(tokens[index])
)
def tag_html(match):
id = match.group('id')
item_type = match.group('type')
try:
if item_type == 'cluster':
from itim.models.clusters import Cluster
model = Cluster
url = reverse('ITIM:_cluster_view', kwargs={'pk': int(id)})
elif item_type == 'config_group':
from config_management.models.groups import ConfigGroups
model = ConfigGroups
url = reverse('Config Management:_group_view', kwargs={'pk': int(id)})
elif item_type == 'device':
from itam.models.device import Device
model = Device
url = reverse('ITAM:_device_view', kwargs={'pk': int(id)})
elif item_type == 'operating_system':
from itam.models.operating_system import OperatingSystem
model = OperatingSystem
url = reverse('ITAM:_operating_system_view', kwargs={'pk': int(id)})
elif item_type == 'service':
from itim.models.services import Service
model = Service
url = reverse('ITIM:_service_view', kwargs={'pk': int(id)})
elif item_type == 'software':
from itam.models.software import Software
model = Software
url = reverse('ITAM:_software_view', kwargs={'pk': int(id)})
if url:
item = model.objects.get(
pk = int(id)
)
html_template = Template('''
<a href="{{ url }}">
{{ name }}, <span style="color: #777; font-size: smaller;">{{ item_type }}</span>
</a>
''')
context = Context({
'url': url,
'item_type': item_type,
'name': item.name
})
html = html_template.render(context)
return html
except Exception as e:
return str(f'${item_type}-{id}')
def tag_render(token: Token) -> None:
assert token.children is not None
checkbox = Token("html_inline", "", 0)
checkbox.content = tag_replace(token.content)
token.children[0] = checkbox
def tag_replace(text):
return re.sub('\$(?P<type>[a-z_]+)-(?P<id>\d+)', tag_html, text)
def contains_tag_item(token: Token) -> bool:
return re.match(rf"(.+)?\$[a-z_]+-\d+{_GFM_WHITESPACE_RE}?(.+)?", token.content) is not None
if enabled:
md.core.ruler.after("inline", "links", fn=main)

View File

@ -0,0 +1,103 @@
import re
from django.template.loader import render_to_string
from markdown_it import MarkdownIt
from markdown_it.rules_core import StateCore
from markdown_it.token import Token
# Regex string to match a whitespace character, as specified in
# https://github.github.com/gfm/#whitespace-character
# (spec version 0.29-gfm (2019-04-06))
_GFM_WHITESPACE_RE = r"[ \t\n\v\f\r]"
def plugin(
md: MarkdownIt,
enabled: bool = False,
) -> None:
"""markdown_it plugin to render ticket numbers
Placing `#<number>` within markdown will be rendered as a pretty link to the ticket.
Args:
md (MarkdownIt): markdown object
enabled (bool, optional): Enable the parsing of ticket references. Defaults to False.
Returns:
None: nada
"""
def main(state: StateCore) -> None:
tokens = state.tokens
for i in range(0, len(tokens) - 1):
if is_tag_item(tokens, i):
tag_render(tokens[i])
def is_inline(token: Token) -> bool:
return token.type == "inline"
def is_tag_item(tokens: list[Token], index: int) -> bool:
return (
is_inline(tokens[index])
and contains_tag_item(tokens[index])
)
def tag_html(match):
ticket_id = match.group(1)
try:
from core.models.ticket.ticket import Ticket
ticket = Ticket.objects.get(pk=ticket_id)
project_id = str('0')
if ticket.project:
project_id = str(ticket.project.id).lower()
context: dict = {
'id': ticket.id,
'name': ticket,
'ticket_type': str(ticket.get_ticket_type_display()).lower(),
'ticket_status': str(ticket.get_status_display()).lower(),
'project_id': project_id,
}
html_link = render_to_string('core/ticket/renderers/ticket_link.html.j2', context)
return html_link
except:
return str('#' + ticket_id)
def tag_render(token: Token) -> None:
assert token.children is not None
checkbox = Token("html_inline", "", 0)
checkbox.content = tag_replace(token.content)
token.children[0] = checkbox
def tag_replace(text):
return re.sub('#(\d+)', tag_html, text)
def contains_tag_item(token: Token) -> bool:
return re.match(rf"(.+)?#(\d+){_GFM_WHITESPACE_RE}?(.+)?", token.content) is not None
if enabled:
md.core.ruler.after("inline", "links", fn=main)

View File

@ -0,0 +1,47 @@
import re
from .duration import Duration
from .related_ticket import CommandRelatedTicket
from .linked_model import CommandLinkedModel
class SlashCommands(
Duration,
CommandRelatedTicket,
CommandLinkedModel,
):
"""Slash Commands Base Class
This class in intended to be included in the following models:
- Ticket
- TicketComment
Testing of regex can be done at https://pythex.org/
"""
def slash_command(self, markdown:str) -> str:
""" Slash Commands Processor
Markdown text that contains a slash command is passed to this function and on the processing
of any valid slash command, the slash command will be removed from the markdown.
If any error occurs when attempting to process the slash command, it will not be removed from
the markdown. This is by design so that the "errored" slash command can be inspected.
Args:
markdown (str): un-processed Markdown
Returns:
str: Markdown without the slash command text.
"""
markdown = re.sub(self.time_spent, self.command_duration, markdown)
markdown = re.sub(self.linked_item, self.command_linked_model, markdown)
markdown = re.sub(self.related_ticket, self.command_related_ticket, markdown)
return markdown

View File

@ -0,0 +1,96 @@
import re
class Duration:
# This summary is used for the user documentation
"""The command keyword is `spend` and you can also use `spent`. The formatting for the time
after the command, is `<digit>` then either `h`, `m`, `s` for hours, minutes and seconds respectively.
Valid commands are as follows:
- /spend 1h1ms
- /spend 1h 1m 1s
For this command to process the following conditions must be met:
- There is either a `<new line>` (`\\n`) or a `<space>` char immediatly before the slash `/`
- There is a `<space>` char after the command keyword, i.e. `/spend<space>1h`
- _Optional_ `<space>` char between the time blocks.
"""
time_spent: str = r'[\s|\n]\/(?P<command>[spend|spent]+)\s(?P<time>(?P<hours>\d+h)?\s?(?P<minutes>[\d]{1,2}m)?\s?(?P<seconds>\d+[s])?)[\s|\n]?'
def command_duration(self, match) -> str:
"""/spend, /spent processor
Slash command usage within a ticket description will add an action comment with the
time spent. For a ticket comment, it's duration field is set to the duration valuee calculated.
Args:
match (re.Match): Grouped matches
Returns:
str: The matched string if the duration calculation is `0`
None: On successfully processing the command
"""
a = 'a'
command = match.group('command')
time:str = str(match.group('time')).replace(' ', '')
hours = match.group('hours')
minutes = match.group('minutes')
seconds = match.group('seconds')
duration: int = 0
if hours is not None:
duration += int(hours[:-1])*60*60
if minutes is not None:
duration += int(minutes[:-1])*60
if seconds is not None:
duration += int(seconds[:-1])
if duration == 0:
#ToDo: Add logging that the slash command could not be processed.
return str(match.string[match.start():match.end()])
if str(self._meta.verbose_name).lower() == 'ticket':
from core.models.ticket.ticket_comment import TicketComment
comment_text = f'added {time} of time spent'
TicketComment.objects.create(
ticket = self,
comment_type = TicketComment.CommentType.ACTION,
body = comment_text,
duration = duration,
user = self.opened_by,
)
elif str(self._meta.verbose_name).lower() == 'comment':
self.duration = duration
else:
#ToDo: Add logging that the slash command could not be processed.
return str(match.string[match.start():match.end()])
return None

View File

@ -0,0 +1,148 @@
import re
class CommandLinkedModel:
# This summary is used for the user documentation
"""Link an item to the current ticket. Supports all ticket
relations: blocked by, blocks and related.
The command keyword is `link` along with the model reference, i.e. `$<type>-<number>`.
Valid commands are as follows:
- /link $device-1
- /link $cluster-55
Available model types for linking are as follows:
- cluster
- config_group
- device
- operating_system
- service
- software
For this command to process the following conditions must be met:
- There is either a `<new line>` (`\\n`) or a `<space>` char immediatly before the slash `/`
- There is a `<space>` char after the command keyword, i.e. `/link<space>$device-101`
"""
linked_item: str = r'[\s|\n]\/(?P<command>[link]+)\s\$(?P<type>[a-z_]+)-(?P<id>\d+)[\s|\n]?'
def command_linked_model(self, match) -> str:
"""/link processor
Slash command usage within a ticket description will add an action comment with the
time spent. For a ticket comment, it's duration field is set to the duration valuee calculated.
Args:
match (re.Match): Named group matches
Returns:
str: The matched string if the duration calculation is `0`
None: On successfully processing the command
"""
a = 'a'
command = match.group('command')
model_type:int = str(match.group('type'))
model_id:int = int(match.group('id'))
try:
from core.models.ticket.ticket_linked_items import TicketLinkedItem
if model_type == 'cluster':
from itim.models.clusters import Cluster
model = Cluster
item_type = TicketLinkedItem.Modules.CLUSTER
elif model_type == 'config_group':
from config_management.models.groups import ConfigGroups
model = ConfigGroups
item_type = TicketLinkedItem.Modules.CONFIG_GROUP
elif model_type == 'device':
from itam.models.device import Device
model = Device
item_type = TicketLinkedItem.Modules.DEVICE
elif model_type == 'operating_system':
from itam.models.operating_system import OperatingSystem
model = OperatingSystem
item_type = TicketLinkedItem.Modules.OPERATING_SYSTEM
elif model_type == 'service':
from itim.models.services import Service
model = Service
item_type = TicketLinkedItem.Modules.SERVICE
elif model_type == 'software':
from itam.models.software import Software
model = Software
item_type = TicketLinkedItem.Modules.SOFTWARE
else:
return str(match.string[match.start():match.end()])
if str(self._meta.verbose_name).lower() == 'ticket':
ticket = self
elif str(self._meta.verbose_name).lower() == 'comment':
ticket = self.ticket
if model:
item = model.objects.get(
pk = model_id
)
TicketLinkedItem.objects.create(
organization = self.organization,
ticket = ticket,
item_type = item_type,
item = item.id
)
return None
except Exception as e:
return str(match.string[match.start():match.end()])
return None

View File

@ -0,0 +1,98 @@
import re
class CommandRelatedTicket:
# This summary is used for the user documentation
"""Add to the current ticket a relationship to another ticket. Supports all ticket
relations: blocked by, blocks and related.
The command keywords are `relate`, `blocks` and `blocked_by` along with the ticket
reference, i.e. `#<ticket-number>`.
Valid commands are as follows:
- /relate #1
- /blocks #1
- /blocked_by #1
For this command to process the following conditions must be met:
- There is either a `<new line>` (`\\n`) or a `<space>` char immediatly before the slash `/`
- There is a `<space>` char after the command keyword, i.e. `/relate<space>#1`
"""
related_ticket: str = r'[\s|\n]\/(?P<command>[relate|blocks|blocked_by]+)\s\#(?P<ticket>\d+)[\s|\n]?'
def command_related_ticket(self, match) -> str:
"""/relate, /blocks and /blocked_by processor
Slash command usage within a ticket description will add an action comment with the
time spent. For a ticket comment, it's duration field is set to the duration valuee calculated.
Args:
match (re.Match): Named group matches
Returns:
str: The matched string if the duration calculation is `0`
None: On successfully processing the command
"""
a = 'a'
command = match.group('command')
ticket_id:int = str(match.group('ticket'))
if ticket_id is not None:
from core.models.ticket.ticket import RelatedTickets
if command == 'relate':
how_related = RelatedTickets.Related.RELATED.value
elif command == 'blocks':
how_related = RelatedTickets.Related.BLOCKS.value
elif command == 'blocked_by':
how_related = RelatedTickets.Related.BLOCKED_BY.value
else:
#ToDo: Add logging that the slash command could not be processed.
return str(match.string[match.start():match.end()])
if str(self._meta.verbose_name).lower() == 'ticket':
from_ticket = self
to_ticket = self.__class__.objects.get(pk = ticket_id)
elif str(self._meta.verbose_name).lower() == 'comment':
from_ticket = self.ticket
to_ticket = self.ticket.__class__.objects.get(pk = ticket_id)
RelatedTickets.objects.create(
from_ticket_id = from_ticket,
how_related = how_related,
to_ticket_id = to_ticket,
organization = self.organization
)
else:
#ToDo: Add logging that the slash command could not be processed.
return str(match.string[match.start():match.end()])
return None

View File

@ -0,0 +1,149 @@
# Generated by Django 5.0.8 on 2024-10-11 14:09
import access.fields
import access.models
import core.lib.slash_commands
import core.models.ticket.ticket
import core.models.ticket.ticket_comment
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0001_initial'),
('core', '0004_notes_service'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TicketCategory',
fields=[
('is_global', models.BooleanField(default=False)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('id', models.AutoField(help_text='Category ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('name', models.CharField(help_text='Category Name', max_length=50, verbose_name='Name')),
('change', models.BooleanField(default=True, help_text='Use category for change tickets', verbose_name='Change Tickets')),
('incident', models.BooleanField(default=True, help_text='Use category for incident tickets', verbose_name='Incident Tickets')),
('problem', models.BooleanField(default=True, help_text='Use category for problem tickets', verbose_name='Problem Tickets')),
('project_task', models.BooleanField(default=True, help_text='Use category for Project tasks', verbose_name='Project Tasks')),
('request', models.BooleanField(default=True, help_text='Use category for request tickets', verbose_name='Request Tickets')),
],
options={
'verbose_name': 'Ticket Category',
'verbose_name_plural': 'Ticket Categories',
'ordering': ['parent__name', 'name'],
},
),
migrations.CreateModel(
name='TicketComment',
fields=[
('id', models.AutoField(help_text='Comment ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')),
('external_ref', models.IntegerField(blank=True, default=None, help_text='External System reference', null=True, verbose_name='Reference Number')),
('external_system', models.IntegerField(blank=True, choices=[(1, 'Github'), (2, 'Gitlab'), (9999, 'Custom #1 (Imported)'), (9998, 'Custom #2 (Imported)'), (9997, 'Custom #3 (Imported)'), (9996, 'Custom #4 (Imported)'), (9995, 'Custom #5 (Imported)'), (9994, 'Custom #6 (Imported)'), (9993, 'Custom #7 (Imported)'), (9992, 'Custom #8 (Imported)'), (9991, 'Custom #9 (Imported)')], default=None, help_text='External system this item derives', null=True, verbose_name='External System')),
('comment_type', models.IntegerField(choices=[(1, 'Action'), (2, 'Comment'), (3, 'Task'), (4, 'Notification'), (5, 'Solution')], default=2, help_text='The type of comment this is', validators=[core.models.ticket.ticket_comment.TicketComment.validation_comment_type], verbose_name='Type')),
('body', models.TextField(help_text='Comment contents', verbose_name='Comment')),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('private', models.BooleanField(default=False, help_text='Is this comment private', verbose_name='Private')),
('duration', models.IntegerField(default=0, help_text='Time spent in seconds', verbose_name='Duration')),
('is_template', models.BooleanField(default=False, help_text='Is this comment a template', verbose_name='Template')),
('source', models.IntegerField(choices=[(1, 'Direct'), (2, 'E-Mail'), (3, 'Helpdesk'), (4, 'Phone')], default=1, help_text='Origin type for this comment', verbose_name='Source')),
('status', models.IntegerField(choices=[(1, 'To Do'), (2, 'Done')], default=1, help_text='Status of comment', verbose_name='Status')),
('date_closed', models.DateTimeField(blank=True, help_text='Date ticket closed', null=True, verbose_name='Closed Date')),
('planned_start_date', models.DateTimeField(blank=True, help_text='Planned start date.', null=True, verbose_name='Planned Start Date')),
('planned_finish_date', models.DateTimeField(blank=True, help_text='Planned finish date', null=True, verbose_name='Planned Finish Date')),
('real_start_date', models.DateTimeField(blank=True, help_text='Real start date', null=True, verbose_name='Real Start Date')),
('real_finish_date', models.DateTimeField(blank=True, help_text='Real finish date', null=True, verbose_name='Real Finish Date')),
],
options={
'verbose_name': 'Comment',
'verbose_name_plural': 'Comments',
'ordering': ['ticket', 'parent_id'],
},
bases=(core.lib.slash_commands.SlashCommands, models.Model),
),
migrations.CreateModel(
name='TicketCommentCategory',
fields=[
('is_global', models.BooleanField(default=False)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('id', models.AutoField(help_text='Category ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('name', models.CharField(help_text='Category Name', max_length=50, verbose_name='Name')),
('comment', models.BooleanField(default=True, help_text='Use category for standard comment', verbose_name='Comment')),
('notification', models.BooleanField(default=True, help_text='Use category for notification comment', verbose_name='Notification Comment')),
('solution', models.BooleanField(default=True, help_text='Use category for solution comment', verbose_name='Solution Comment')),
('task', models.BooleanField(default=True, help_text='Use category for task comment', verbose_name='Task Comment')),
],
options={
'verbose_name': 'Ticket Comment Category',
'verbose_name_plural': 'Ticket Comment Categories',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='TicketLinkedItem',
fields=[
('id', models.AutoField(help_text='ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')),
('item_type', models.IntegerField(choices=[(1, 'Cluster'), (2, 'Config Group'), (3, 'Device'), (4, 'Operating System'), (5, 'Service'), (6, 'Software')], help_text='Python Model location for linked item', verbose_name='Item Type')),
('item', models.IntegerField(help_text='Item ID to link to ticket', verbose_name='Item ID')),
],
options={
'verbose_name': 'Ticket Linked Item',
'verbose_name_plural': 'Ticket linked Items',
'ordering': ['id'],
},
),
migrations.CreateModel(
name='RelatedTickets',
fields=[
('id', models.AutoField(help_text='Ticket ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')),
('how_related', models.IntegerField(choices=[(1, 'Related'), (2, 'Blocks'), (3, 'Blocked By')], help_text='How is the ticket related', verbose_name='How Related')),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])),
],
options={
'ordering': ['id'],
},
),
migrations.CreateModel(
name='Ticket',
fields=[
('id', models.AutoField(help_text='Ticket ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('status', models.IntegerField(choices=[(1, 'Draft'), (2, 'New'), (3, 'Assigned'), (6, 'Assigned (Planning)'), (7, 'Pending'), (8, 'Solved'), (4, 'Closed'), (5, 'Invalid'), (10, 'Accepted'), (9, 'Under Observation'), (11, 'Evaluation'), (12, 'Approvals'), (13, 'Testing'), (14, 'Qualification'), (15, 'Applied'), (16, 'Review'), (17, 'Cancelled'), (18, 'Refused')], default=2, help_text='Status of ticket', verbose_name='Status')),
('title', models.CharField(help_text='Title of the Ticket', max_length=100, unique=True, verbose_name='Title')),
('description', models.TextField(help_text='Ticket Description', verbose_name='Description')),
('urgency', models.IntegerField(blank=True, choices=[(1, 'Very Low'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Very High')], default=1, help_text='How urgent is this tickets resolution for the user?', null=True, verbose_name='Urgency')),
('impact', models.IntegerField(blank=True, choices=[(1, 'Very Low'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Very High')], default=1, help_text='End user assessed impact', null=True, verbose_name='Impact')),
('priority', models.IntegerField(blank=True, choices=[(1, 'Very Low'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Very High'), (6, 'Major')], default=1, help_text='What priority should this ticket for its completion', null=True, verbose_name='Priority')),
('external_ref', models.IntegerField(blank=True, default=None, help_text='External System reference', null=True, verbose_name='Reference Number')),
('external_system', models.IntegerField(blank=True, choices=[(1, 'Github'), (2, 'Gitlab'), (9999, 'Custom #1 (Imported)'), (9998, 'Custom #2 (Imported)'), (9997, 'Custom #3 (Imported)'), (9996, 'Custom #4 (Imported)'), (9995, 'Custom #5 (Imported)'), (9994, 'Custom #6 (Imported)'), (9993, 'Custom #7 (Imported)'), (9992, 'Custom #8 (Imported)'), (9991, 'Custom #9 (Imported)')], default=None, help_text='External system this item derives', null=True, verbose_name='External System')),
('ticket_type', models.IntegerField(choices=[(1, 'Request'), (2, 'Incident'), (3, 'Change'), (4, 'Problem'), (5, 'Issue'), (6, 'Merge Request'), (7, 'Project Task')], help_text='The type of ticket this is', validators=[core.models.ticket.ticket.Ticket.validation_ticket_type], verbose_name='Type')),
('is_deleted', models.BooleanField(default=False, help_text='Is the ticket deleted? And ready to be purged', verbose_name='Deleted')),
('date_closed', models.DateTimeField(blank=True, help_text='Date ticket closed', null=True, verbose_name='Closed Date')),
('planned_start_date', models.DateTimeField(blank=True, help_text='Planned start date.', null=True, verbose_name='Planned Start Date')),
('planned_finish_date', models.DateTimeField(blank=True, help_text='Planned finish date', null=True, verbose_name='Planned Finish Date')),
('estimate', models.IntegerField(default=0, help_text='Time Eastimated to complete this ticket in seconds', verbose_name='Estimation')),
('real_start_date', models.DateTimeField(blank=True, help_text='Real start date', null=True, verbose_name='Real Start Date')),
('real_finish_date', models.DateTimeField(blank=True, help_text='Real finish date', null=True, verbose_name='Real Finish Date')),
('assigned_teams', models.ManyToManyField(blank=True, help_text='Assign the ticket to a Team(s)', related_name='assigned_teams', to='access.team', verbose_name='Assigned Team(s)')),
('assigned_users', models.ManyToManyField(blank=True, help_text='Assign the ticket to a User(s)', related_name='assigned_users', to=settings.AUTH_USER_MODEL, verbose_name='Assigned User(s)')),
],
options={
'verbose_name': 'Ticket',
'verbose_name_plural': 'Tickets',
'ordering': ['id'],
'permissions': [('add_ticket_request', 'Can add a request ticket'), ('change_ticket_request', 'Can change any request ticket'), ('delete_ticket_request', 'Can delete a request ticket'), ('import_ticket_request', 'Can import a request ticket'), ('purge_ticket_request', 'Can purge a request ticket'), ('triage_ticket_request', 'Can triage all request ticket'), ('view_ticket_request', 'Can view all request ticket'), ('add_ticket_incident', 'Can add a incident ticket'), ('change_ticket_incident', 'Can change any incident ticket'), ('delete_ticket_incident', 'Can delete a incident ticket'), ('import_ticket_incident', 'Can import a incident ticket'), ('purge_ticket_incident', 'Can purge a incident ticket'), ('triage_ticket_incident', 'Can triage all incident ticket'), ('view_ticket_incident', 'Can view all incident ticket'), ('add_ticket_problem', 'Can add a problem ticket'), ('change_ticket_problem', 'Can change any problem ticket'), ('delete_ticket_problem', 'Can delete a problem ticket'), ('import_ticket_problem', 'Can import a problem ticket'), ('purge_ticket_problem', 'Can purge a problem ticket'), ('triage_ticket_problem', 'Can triage all problem ticket'), ('view_ticket_problem', 'Can view all problem ticket'), ('add_ticket_change', 'Can add a change ticket'), ('change_ticket_change', 'Can change any change ticket'), ('delete_ticket_change', 'Can delete a change ticket'), ('import_ticket_change', 'Can import a change ticket'), ('purge_ticket_change', 'Can purge a change ticket'), ('triage_ticket_change', 'Can triage all change ticket'), ('view_ticket_change', 'Can view all change ticket'), ('add_ticket_project_task', 'Can add a project task'), ('change_ticket_project_task', 'Can change any project task'), ('delete_ticket_project_task', 'Can delete a project task'), ('import_ticket_project_task', 'Can import a project task'), ('purge_ticket_project_task', 'Can purge a project task'), ('triage_ticket_project_task', 'Can triage all project task'), ('view_ticket_project_task', 'Can view all project task')],
},
bases=(core.lib.slash_commands.SlashCommands, models.Model),
),
]

View File

@ -0,0 +1,154 @@
# Generated by Django 5.0.8 on 2024-10-11 14:09
import access.models
import core.models.ticket.ticket_comment
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0001_initial'),
('assistance', '0001_initial'),
('core', '0005_ticketcategory_ticketcomment_ticketcommentcategory_and_more'),
('project_management', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='ticket',
name='milestone',
field=models.ForeignKey(blank=True, help_text='Assign to a milestone', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.projectmilestone', verbose_name='Project Milestone'),
),
migrations.AddField(
model_name='ticket',
name='opened_by',
field=models.ForeignKey(help_text='Who is the ticket for', on_delete=django.db.models.deletion.DO_NOTHING, related_name='opened_by', to=settings.AUTH_USER_MODEL, verbose_name='Opened By'),
),
migrations.AddField(
model_name='ticket',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
migrations.AddField(
model_name='ticket',
name='project',
field=models.ForeignKey(blank=True, help_text='Assign to a project', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.project', verbose_name='Project'),
),
migrations.AddField(
model_name='ticket',
name='subscribed_teams',
field=models.ManyToManyField(blank=True, help_text='Subscribe a Team(s) to the ticket to receive updates', related_name='subscribed_teams', to='access.team', verbose_name='Subscribed Team(s)'),
),
migrations.AddField(
model_name='ticket',
name='subscribed_users',
field=models.ManyToManyField(blank=True, help_text='Subscribe a User(s) to the ticket to receive updates', related_name='subscribed_users', to=settings.AUTH_USER_MODEL, verbose_name='Subscribed User(s)'),
),
migrations.AddField(
model_name='relatedtickets',
name='from_ticket_id',
field=models.ForeignKey(help_text='This Ticket', on_delete=django.db.models.deletion.CASCADE, related_name='from_ticket_id', to='core.ticket', verbose_name='Ticket'),
),
migrations.AddField(
model_name='relatedtickets',
name='to_ticket_id',
field=models.ForeignKey(help_text='The Related Ticket', on_delete=django.db.models.deletion.CASCADE, related_name='to_ticket_id', to='core.ticket', verbose_name='Related Ticket'),
),
migrations.AddField(
model_name='ticketcategory',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
migrations.AddField(
model_name='ticketcategory',
name='parent',
field=models.ForeignKey(blank=True, help_text='The Parent Category', null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.ticketcategory', verbose_name='Parent Category'),
),
migrations.AddField(
model_name='ticketcategory',
name='runbook',
field=models.ForeignKey(blank=True, help_text='The runbook for this category', null=True, on_delete=django.db.models.deletion.SET_NULL, to='assistance.knowledgebase', verbose_name='Runbook'),
),
migrations.AddField(
model_name='ticket',
name='category',
field=models.ForeignKey(blank=True, help_text='Category for this ticket', null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.ticketcategory', verbose_name='Category'),
),
migrations.AddField(
model_name='ticketcomment',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
migrations.AddField(
model_name='ticketcomment',
name='parent',
field=models.ForeignKey(blank=True, default=None, help_text='Parent ID for creating discussion threads', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='core.ticketcomment', verbose_name='Parent Comment'),
),
migrations.AddField(
model_name='ticketcomment',
name='responsible_team',
field=models.ForeignKey(blank=True, default=None, help_text='Team whom is responsible for the completion of comment', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_responsible_team', to='access.team', verbose_name='Responsible Team'),
),
migrations.AddField(
model_name='ticketcomment',
name='responsible_user',
field=models.ForeignKey(blank=True, default=None, help_text='User whom is responsible for the completion of comment', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_responsible_user', to=settings.AUTH_USER_MODEL, verbose_name='Responsible User'),
),
migrations.AddField(
model_name='ticketcomment',
name='template',
field=models.ForeignKey(blank=True, default=None, help_text='Comment Template to use', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comment_template', to='core.ticketcomment', verbose_name='Template'),
),
migrations.AddField(
model_name='ticketcomment',
name='ticket',
field=models.ForeignKey(blank=True, default=None, help_text='Ticket this comment belongs to', null=True, on_delete=django.db.models.deletion.CASCADE, to='core.ticket', validators=[core.models.ticket.ticket_comment.TicketComment.validation_ticket_id], verbose_name='Ticket'),
),
migrations.AddField(
model_name='ticketcomment',
name='user',
field=models.ForeignKey(blank=True, help_text='Who made the comment', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_user', to=settings.AUTH_USER_MODEL, verbose_name='User'),
),
migrations.AddField(
model_name='ticketcommentcategory',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
migrations.AddField(
model_name='ticketcommentcategory',
name='parent',
field=models.ForeignKey(blank=True, help_text='The Parent Category', null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.ticketcommentcategory', verbose_name='Parent Category'),
),
migrations.AddField(
model_name='ticketcommentcategory',
name='runbook',
field=models.ForeignKey(blank=True, help_text='The runbook for this category', null=True, on_delete=django.db.models.deletion.SET_NULL, to='assistance.knowledgebase', verbose_name='Runbook'),
),
migrations.AddField(
model_name='ticketcomment',
name='category',
field=models.ForeignKey(blank=True, default=None, help_text='Category of the comment', null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.ticketcommentcategory', verbose_name='Category'),
),
migrations.AddField(
model_name='ticketlinkeditem',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
migrations.AddField(
model_name='ticketlinkeditem',
name='ticket',
field=models.ForeignKey(help_text='Ticket the item will be linked to', on_delete=django.db.models.deletion.CASCADE, to='core.ticket', verbose_name='Ticket'),
),
migrations.AlterUniqueTogether(
name='ticket',
unique_together={('external_system', 'external_ref')},
),
migrations.AlterUniqueTogether(
name='ticketcomment',
unique_together={('external_system', 'external_ref')},
),
]

View File

@ -8,6 +8,11 @@ from core.models.history import History
class SaveHistory(models.Model):
save_model_history: bool = True
"""When set, history will be saved.
By default, ALL models must save history.
"""
class Meta:
abstract = True
@ -25,6 +30,7 @@ class SaveHistory(models.Model):
"""
remove_keys = [
'_django_version',
'_state',
'created',
'modified'
@ -176,9 +182,11 @@ class SaveHistory(models.Model):
# Process the save
super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
after = self.__dict__.copy()
if self.save_model_history:
self.save_history(before, after)
after = self.__dict__.copy()
self.save_history(before, after)
def delete_history(self, item_pk, item_class):

View File

View File

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,121 @@
from django.db import models
from access.fields import AutoCreatedField, AutoLastModifiedField
from access.models import TenancyObject, Team
from assistance.models.knowledge_base import KnowledgeBase
class TicketCategoryCommonFields(TenancyObject):
class Meta:
abstract = True
id = models.AutoField(
blank=False,
help_text = 'Category ID Number',
primary_key=True,
unique=True,
verbose_name = 'Number',
)
created = AutoCreatedField()
modified = AutoLastModifiedField()
class TicketCategory(TicketCategoryCommonFields):
class Meta:
ordering = [
'parent__name',
'name',
]
verbose_name = "Ticket Category"
verbose_name_plural = "Ticket Categories"
parent = models.ForeignKey(
'self',
blank= True,
help_text = 'The Parent Category',
null = True,
on_delete = models.SET_NULL,
verbose_name = 'Parent Category',
)
name = models.CharField(
blank = False,
help_text = "Category Name",
max_length = 50,
verbose_name = 'Name',
)
runbook = models.ForeignKey(
KnowledgeBase,
blank= True,
help_text = 'The runbook for this category',
null = True,
on_delete = models.SET_NULL,
verbose_name = 'Runbook',
)
change = models.BooleanField(
blank = False,
default = True,
help_text = 'Use category for change tickets',
null = False,
verbose_name = 'Change Tickets',
)
incident = models.BooleanField(
blank = False,
default = True,
help_text = 'Use category for incident tickets',
null = False,
verbose_name = 'Incident Tickets',
)
problem = models.BooleanField(
blank = False,
default = True,
help_text = 'Use category for problem tickets',
null = False,
verbose_name = 'Problem Tickets',
)
project_task = models.BooleanField(
blank = False,
default = True,
help_text = 'Use category for Project tasks',
null = False,
verbose_name = 'Project Tasks',
)
request = models.BooleanField(
blank = False,
default = True,
help_text = 'Use category for request tickets',
null = False,
verbose_name = 'Request Tickets',
)
@property
def recusive_name(self):
if self.parent:
return str(self.parent.recusive_name + ' > ' + self.name )
return self.name
def __str__(self):
return self.recusive_name

View File

@ -0,0 +1,435 @@
from django.contrib.auth.models import User
from django.db import models
from django.forms import ValidationError
from access.fields import AutoCreatedField, AutoLastModifiedField
from access.models import TenancyObject, Team
from core.lib.slash_commands import SlashCommands
from .ticket import Ticket
from .ticket_comment_category import TicketCommentCategory
class TicketComment(
SlashCommands,
TenancyObject,
):
save_model_history: bool = False
class Meta:
ordering = [
'ticket',
'parent_id'
]
unique_together = ('external_system', 'external_ref',)
verbose_name = "Comment"
verbose_name_plural = "Comments"
class CommentSource(models.IntegerChoices):
"""Source of the comment"""
DIRECT = '1', 'Direct'
EMAIL = '2', 'E-Mail'
HELPDESK = '3', 'Helpdesk'
PHONE = '4', 'Phone'
class CommentStatus(models.IntegerChoices):
"""Comment Completion Status"""
TODO = '1', 'To Do'
DONE = '2', 'Done'
class CommentType(models.IntegerChoices):
"""
Comment types are as follows:
- Action
- Comment
- Solution
- Notification
## Action
An action comment is for the tracking of what has occured to the ticket.
## Comment
This is the default comment type and is what would be normally used.
## Solution
This type of comment is an ITSM comment and is used as the means for solving the ticket.\
## Notification
This type of comment is intended to be used to send a notification to subscribed users.
"""
ACTION = '1', 'Action'
COMMENT = '2', 'Comment'
TASK = '3', 'Task'
NOTIFICATION = '4', 'Notification'
SOLUTION = '5', 'Solution'
def validation_comment_type(field):
if not field:
raise ValidationError('Comment Type must be set')
def validation_ticket_id(field):
if not field:
raise ValidationError('Ticket ID is required')
model_notes = None
is_global = None
id = models.AutoField(
blank=False,
help_text = 'Comment ID Number',
primary_key=True,
unique=True,
verbose_name = 'Number',
)
parent = models.ForeignKey(
'self',
blank= True,
default = None,
help_text = 'Parent ID for creating discussion threads',
null = True,
on_delete = models.DO_NOTHING,
verbose_name = 'Parent Comment',
)
ticket = models.ForeignKey(
Ticket,
blank= True,
default = None,
help_text = 'Ticket this comment belongs to',
null = True,
on_delete = models.CASCADE,
validators = [ validation_ticket_id ],
verbose_name = 'Ticket',
)
external_ref = models.IntegerField(
blank = True,
default=None,
help_text = 'External System reference',
null=True,
verbose_name = 'Reference Number',
) # external reference or null. i.e. github issue number
external_system = models.IntegerField(
blank = True,
choices=Ticket.Ticket_ExternalSystem,
default=None,
help_text = 'External system this item derives',
null=True,
verbose_name = 'External System',
)
comment_type = models.IntegerField(
blank = False,
choices =CommentType,
default = CommentType.COMMENT,
help_text = 'The type of comment this is',
validators = [ validation_comment_type ],
verbose_name = 'Type',
)
body = models.TextField(
blank = False,
help_text = 'Comment contents',
null = False,
verbose_name = 'Comment',
)
created = AutoCreatedField(
editable = True,
)
modified = AutoLastModifiedField()
private = models.BooleanField(
blank = False,
default = False,
help_text = 'Is this comment private',
null = False,
verbose_name = 'Private',
)
duration = models.IntegerField(
blank = False,
default = 0,
help_text = 'Time spent in seconds',
null = False,
verbose_name = 'Duration',
)
category = models.ForeignKey(
TicketCommentCategory,
blank= True,
default = None,
help_text = 'Category of the comment',
null = True,
on_delete = models.SET_NULL,
verbose_name = 'Category',
)
template = models.ForeignKey(
'self',
blank= True,
default = None,
help_text = 'Comment Template to use',
null = True,
on_delete = models.SET_NULL,
related_name = 'comment_template',
verbose_name = 'Template',
)
is_template = models.BooleanField(
blank = False,
default = False,
help_text = 'Is this comment a template',
null = False,
verbose_name = 'Template',
)
source = models.IntegerField(
blank = False,
choices =CommentSource,
default = CommentSource.DIRECT,
help_text = 'Origin type for this comment',
# validators = [ validation_ticket_type ],
verbose_name = 'Source',
)
status = models.IntegerField( # will require validation by comment type as status for types will be different
blank = False,
choices=CommentStatus,
default = CommentStatus.TODO,
help_text = 'Status of comment',
# null=True,
verbose_name = 'Status',
)
responsible_user = models.ForeignKey(
User,
blank= True,
default = None,
help_text = 'User whom is responsible for the completion of comment',
on_delete = models.DO_NOTHING,
related_name = 'comment_responsible_user',
null = True,
verbose_name = 'Responsible User',
)
responsible_team = models.ForeignKey(
Team,
blank= True,
default = None,
help_text = 'Team whom is responsible for the completion of comment',
on_delete = models.DO_NOTHING,
related_name = 'comment_responsible_team',
null = True,
verbose_name = 'Responsible Team',
)
user = models.ForeignKey(
User,
blank= True,
help_text = 'Who made the comment',
null = True,
on_delete = models.DO_NOTHING,
related_name = 'comment_user',
verbose_name = 'User',
)
date_closed = models.DateTimeField(
blank = True,
help_text = 'Date ticket closed',
null = True,
verbose_name = 'Closed Date',
)
planned_start_date = models.DateTimeField(
blank = True,
help_text = 'Planned start date.',
null = True,
verbose_name = 'Planned Start Date',
)
planned_finish_date = models.DateTimeField(
blank = True,
help_text = 'Planned finish date',
null = True,
verbose_name = 'Planned Finish Date',
)
real_start_date = models.DateTimeField(
blank = True,
help_text = 'Real start date',
null = True,
verbose_name = 'Real Start Date',
)
real_finish_date = models.DateTimeField(
blank = True,
help_text = 'Real finish date',
null = True,
verbose_name = 'Real Finish Date',
)
common_fields: list(str()) = [
'body',
'duration',
'user',
'ticket',
'parent',
'comment_type',
]
common_itsm_fields: list(str()) = common_fields + [
'category',
'source',
'template',
]
fields_itsm_task: list(str()) = common_itsm_fields + [
'status',
'responsible_user',
'responsible_team',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
]
fields_itsm_notification: list(str()) = common_itsm_fields + [
'status',
'responsible_user',
'responsible_team',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
]
fields_itsm_incident: list(str()) = common_itsm_fields + [
]
fields_itsm_problem: list(str()) = common_itsm_fields + [
]
fields_itsm_change: list(str()) = common_itsm_fields + [
]
common_git_fields: list(str()) = common_fields + [
]
fields_git_issue: list(str()) = common_fields + [
]
fields_git_merge_request: list(str()) = common_fields + [
]
fields_project_task: list(str()) = common_fields + [
'category',
'urgency',
'status',
'impact',
'priority',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
]
fields_comment_task: list(str()) = common_itsm_fields + [
'status',
'responsible_user',
'responsible_team',
'planned_start_date',
'planned_finish_date',
'real_start_date',
'real_finish_date',
]
@property
def action_comment(self):
return self.user.username + ' ' + self.body + ' on ' + str(self.created)
@property
def comment_template_queryset(self):
query = TicketComment.objects.filter(
is_template = True,
comment_type = self.comment_type,
)
return query
@property
def parent_object(self):
""" Fetch the parent object """
return self.ticket
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
self.organization = self.ticket.organization
self.body = self.slash_command(self.body)
super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
if self.comment_type == self.CommentType.SOLUTION:
update_ticket = self.ticket.__class__.objects.get(pk=self.ticket.id)
update_ticket.status = int(Ticket.TicketStatus.All.SOLVED.value)
update_ticket.save()
@property
def threads(self):
return TicketComment.objects.filter(
parent = self.id
)

View File

@ -0,0 +1,103 @@
from django.db import models
from access.fields import AutoCreatedField, AutoLastModifiedField
from access.models import TenancyObject, Team
from assistance.models.knowledge_base import KnowledgeBase
class TicketCommentCategoryCommonFields(TenancyObject):
class Meta:
abstract = True
id = models.AutoField(
blank=False,
help_text = 'Category ID Number',
primary_key=True,
unique=True,
verbose_name = 'Number',
)
created = AutoCreatedField()
modified = AutoLastModifiedField()
class TicketCommentCategory(TicketCommentCategoryCommonFields):
class Meta:
ordering = [
'name'
]
verbose_name = "Ticket Comment Category"
verbose_name_plural = "Ticket Comment Categories"
parent = models.ForeignKey(
'self',
blank= True,
help_text = 'The Parent Category',
null = True,
on_delete = models.SET_NULL,
verbose_name = 'Parent Category',
)
name = models.CharField(
blank = False,
help_text = "Category Name",
max_length = 50,
verbose_name = 'Name',
)
runbook = models.ForeignKey(
KnowledgeBase,
blank= True,
help_text = 'The runbook for this category',
null = True,
on_delete = models.SET_NULL,
verbose_name = 'Runbook',
)
comment = models.BooleanField(
blank = False,
default = True,
help_text = 'Use category for standard comment',
null = False,
verbose_name = 'Comment',
)
notification = models.BooleanField(
blank = False,
default = True,
help_text = 'Use category for notification comment',
null = False,
verbose_name = 'Notification Comment',
)
solution = models.BooleanField(
blank = False,
default = True,
help_text = 'Use category for solution comment',
null = False,
verbose_name = 'Solution Comment',
)
task = models.BooleanField(
blank = False,
default = True,
help_text = 'Use category for task comment',
null = False,
verbose_name = 'Task Comment',
)
def __str__(self):
return self.name

View File

@ -0,0 +1,126 @@
class TicketValues:
_DRAFT_INT = '1'
_NEW_INT = '2'
_ASSIGNED_INT = '3'
_CLOSED_INT = '4'
_INVALID_INT = '5'
#
# ITSM statuses
#
# Requests / Incidents / Problems / Changed
_ASSIGNED_PLANNING_INT = '6'
_PENDING_INT = '7'
# Requests / Incidents / Problems
_SOLVED_INT = '8'
# Problem
_OBSERVATION_INT = '9'
# Problems / Changes
_ACCEPTED_INT = '10'
# Changes
_EVALUATION_INT = '11'
_APPROVALS_INT = '12'
_TESTING_INT = '13'
_QUALIFICATION_INT = '14'
_APPLIED_INT = '15'
_REVIEW_INT = '16'
_CANCELLED_INT = '17'
_REFUSED_INT = '18'
_DRAFT_STR = 'Draft'
_NEW_STR = 'New'
_ASSIGNED_STR = 'Assigned'
_CLOSED_STR = 'Closed'
_INVALID_STR = 'Invalid'
#
# ITSM statuses
#
# Requests / Incidents / Problems / Changed
_ASSIGNED_PLANNING_STR = 'Assigned (Planning)'
_PENDING_STR = 'Pending'
# Requests / Incidents / Problems
_SOLVED_STR = 'Solved'
# Problem
_OBSERVATION_STR = 'Under Observation'
# Problems / Changes
_ACCEPTED_STR = 'Accepted'
# Changes
_EVALUATION_STR = 'Evaluation'
_APPROVALS_STR = 'Approvals'
_TESTING_STR = 'Testing'
_QUALIFICATION_STR = 'Qualification'
_APPLIED_STR = 'Applied'
_REVIEW_STR = 'Review'
_CANCELLED_STR = 'Cancelled'
_REFUSED_STR = 'Refused'
class ExternalSystem:
_GITHUB_INT = '1'
_GITHUB_VALUE = 'Github'
_GITLAB_INT = '2'
_GITLAB_VALUE = 'Gitlab'
_CUSTOM_1_INT = '9999'
_CUSTOM_1_VALUE = 'Custom #1 (Imported)'
_CUSTOM_2_INT = '9998'
_CUSTOM_2_VALUE = 'Custom #2 (Imported)'
_CUSTOM_3_INT = '9997'
_CUSTOM_3_VALUE = 'Custom #3 (Imported)'
_CUSTOM_4_INT = '9996'
_CUSTOM_4_VALUE = 'Custom #4 (Imported)'
_CUSTOM_5_INT = '9995'
_CUSTOM_5_VALUE = 'Custom #5 (Imported)'
_CUSTOM_6_INT = '9994'
_CUSTOM_6_VALUE = 'Custom #6 (Imported)'
_CUSTOM_7_INT = '9993'
_CUSTOM_7_VALUE = 'Custom #7 (Imported)'
_CUSTOM_8_INT = '9992'
_CUSTOM_8_VALUE = 'Custom #8 (Imported)'
_CUSTOM_9_INT = '9991'
_CUSTOM_9_VALUE = 'Custom #9 (Imported)'
class Priority:
_VERY_LOW_INT = '1'
_VERY_LOW_VALUE = 'Very Low'
_LOW_INT = '2'
_LOW_VALUE = 'Low'
_MEDIUM_INT = '3'
_MEDIUM_VALUE = 'Medium'
_HIGH_INT = '4'
_HIGH_VALUE = 'High'
_VERY_HIGH_INT = '5'
_VERY_HIGH_VALUE = 'Very High'
_MAJOR_INT = '6'
_MAJOR_VALUE = 'Major'

View File

@ -0,0 +1,142 @@
from django.db import models
from .ticket_enum_values import TicketValues
from access.models import TenancyObject
from core.middleware.get_request import get_request
from core.models.ticket.ticket import Ticket
class TicketLinkedItem(TenancyObject):
class Meta:
ordering = [
'id'
]
verbose_name = 'Ticket Linked Item'
verbose_name_plural = 'Ticket linked Items'
class Modules(models.IntegerChoices):
CLUSTER = 1, 'Cluster'
CONFIG_GROUP = 2, 'Config Group'
DEVICE = 3, 'Device'
OPERATING_SYSTEM = 4, 'Operating System'
SERVICE = 5, 'Service'
SOFTWARE = 6, 'Software'
is_global = None
model_notes = None
id = models.AutoField(
blank=False,
help_text = 'ID Number',
primary_key=True,
unique=True,
verbose_name = 'Number',
)
ticket = models.ForeignKey(
Ticket,
blank= False,
help_text = 'Ticket the item will be linked to',
null = False,
on_delete = models.CASCADE,
verbose_name = 'Ticket',
)
item_type = models.IntegerField(
blank= False,
choices = Modules,
help_text = 'Python Model location for linked item',
null = False,
verbose_name = 'Item Type',
)
item = models.IntegerField(
blank = False,
help_text = 'Item ID to link to ticket',
null = False,
verbose_name = 'Item ID',
)
def __str__(self) -> str:
item_type: str = None
if self.item_type == TicketLinkedItem.Modules.CLUSTER:
item_type = 'cluster'
elif self.item_type == TicketLinkedItem.Modules.CONFIG_GROUP:
item_type = 'config_group'
elif self.item_type == TicketLinkedItem.Modules.DEVICE:
item_type = 'device'
elif self.item_type == TicketLinkedItem.Modules.OPERATING_SYSTEM:
item_type = 'operating_system'
elif self.item_type == TicketLinkedItem.Modules.SERVICE:
item_type = 'service'
elif self.item_type == TicketLinkedItem.Modules.SOFTWARE:
item_type = 'software'
if item_type:
return f'${item_type}-{int(self.item)}'
return str(self.item)
@property
def parent_object(self):
""" Fetch the parent object """
return self.ticket
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
request = get_request()
if request:
if request.user.pk:
comment_user = request.user
else:
comment_user = None
else:
comment_user = None
from core.models.ticket.ticket_comment import TicketComment
comment = TicketComment.objects.create(
ticket = self.ticket,
comment_type = TicketComment.CommentType.ACTION,
body = f'linked {self}',
source = TicketComment.CommentSource.DIRECT,
user = comment_user,
)
comment.save()

View File

@ -0,0 +1,47 @@
{% extends 'base.html.j2' %}
{% block content %}
<input type="button" value="<< Back to settings" onclick="window.location='{% url 'Settings:Settings' %}';">
<input type="button" value="New Category" onclick="window.location='{% url 'Settings:_ticket_category_add' %}';">
<table style="max-width: 100%;">
<thead>
<th>Name</th>
<th>Organization</th>
<th>created</th>
<th>modified</th>
<th>&nbsp;</th>
</thead>
{% if items %}
{% for category in items %}
<tr>
<td><a href="{% url 'Settings:_ticket_category_view' pk=category.id %}">{{ category.name }}</a></td>
<td>{{ category.organization }}</td>
<td>{{ category.created }}</td>
<td>{{ category.modified }}</td>
<td>&nbsp;</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="5">Nothing Found</td></tr>
{% endif%}
</table>
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</span>
</div>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends 'base.html.j2' %}
{% block content %}
<input type="button" value="<< Back to settings" onclick="window.location='{% url 'Settings:Settings' %}';">
<input type="button" value="New Category" onclick="window.location='{% url 'Settings:_ticket_comment_category_add' %}';">
<table style="max-width: 100%;">
<thead>
<th>Name</th>
<th>Organization</th>
<th>created</th>
<th>modified</th>
<th>&nbsp;</th>
</thead>
{% if items %}
{% for category in items %}
<tr>
<td><a href="{% url 'Settings:_ticket_comment_category_view' pk=category.id %}">{{ category.name }}</a></td>
<td>{{ category.organization }}</td>
<td>{{ category.created }}</td>
<td>{{ category.modified }}</td>
<td>&nbsp;</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="5">Nothing Found</td></tr>
{% endif%}
</table>
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</span>
</div>
{% endblock %}

View File

@ -0,0 +1,163 @@
{% extends 'base.html.j2' %}
{% block additional-stylesheet %}
{% load static %}
<link rel="stylesheet" href="{% static 'ticketing.css' %}">
{% endblock additional-stylesheet %}
{% load markdown %}
{% block article %}
<div id="ticket-content">
<div id="ticket-data">
<div id="ticket-description">
<h3 class="{{ ticket_type }}-ticket">
<span class="sub-script">opened by</span> {{ ticket.opened_by }} <span class="sub-script">on</span> {{ ticket.created }}
{% if ticket.created|date_time_seconds != ticket.modified|date_time_seconds %}
<span class="sub-script">Updated</span> {{ ticket.modified }}
{% endif %}
</h3>
<div>
<input style="float: right; position: relative; margin: 0px;" type="button" value="Edit" onclick="window.location='{{ edit_url }}';">
</div>
<div id="markdown">{{ ticket.description | markdown | safe }}</div>
</div>
<div id="ticket-additional-data">
<div id="data-block">
<h3>
<div id="text">Related Tickets</div>
<div id="icons">
<a href="{% url '_ticket_related_add' ticket_type=ticket_type ticket_id=ticket.id %}">{% include 'icons/ticket/add.svg' %}</a>
</div>
</h3>
{% if ticket.related_tickets %}
{% for related_ticket in ticket.related_tickets %}
<div id="linked-tickets" class="{{ related_ticket.type }}-ticket">
<div class="icon icon-{{ related_ticket.how_related }}">{% include related_ticket.icon_filename %}</div>
<div id="markdown" class="ticket">
{% if related_ticket.how_related == 'blocked_by' %}
Blocked by
{% elif related_ticket.how_related == 'blocks' %}
Blocks
{% elif related_ticket.how_related == 'related' %}
Related to
{% endif %}
<span style="display: inline-block;">{{ related_ticket.markdown | markdown | safe }}</span>
</div>
</div>
{% endfor %}
{% else %}
<div>Nothing Found</div>
{% endif %}
</div>
<div id="data-block" class="linked-item">
<h3>
<div id="text">Linked Items</div>
<div id="icons">
<a href="{% url '_ticket_linked_item_add' ticket_type=ticket_type ticket_id=ticket.id %}">{% include 'icons/ticket/add.svg' %}</a>
</div>
</h3>
{% if ticket.linked_items %}
{% for linked_item in ticket.linked_items %}
<div id="item">{{ linked_item | markdown | safe }}</div>
{% endfor %}
{% else %}
<div style="text-align:center;%">Nothing found</div>
{% endif%}
</div>
</div>
{% include 'core/ticket/comment.html.j2' %}
</div>
<div id="ticket-meta">
<h3 class="{{ ticket_type }}-ticket">
{{ ticket_type }} <span style="font-size: smaller;">#{{ ticket.id }}
{% if ticket.external_ref %}
<span style="display: inline-block;" title="External system {{ ticket.get_external_system_display }}">(#{{ ticket.external_ref }})</span>
{% endif %}</span>
</h3>
<fieldset>
<label>Assigned</label>
<span class="text">
{% if ticket.assigned_users %}
{% for user in ticket.assigned_users.all %}
{{ user }}
{% endfor%}
{% endif %}
{% if ticket.assigned_teams %}
{% for team in ticket.assigned_teams.all %}
{{ team }}
{% endfor%}
{% endif %}
</span>
</fieldset>
<fieldset>
<label>Status</label>
<span>{% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.get_status_display ticket_status=ticket.get_status_display|ticket_status %}</span>
</fieldset>
<fieldset>
<label>Labels</label>
<span class="text">val</span>
</fieldset>
{% if ticket.category %}
<fieldset>
<label>Category</label>
<span class="text">
<a href="{% url 'Settings:_ticket_category_view' pk=ticket.category.id %}">
{{ ticket.category }}
</a>
</span>
</fieldset>
{% endif %}
{% if ticket.project %}
<fieldset>
<label>Project</label>
<span class="text">
<a href="{% url 'Project Management:_project_view' pk=ticket.project_id %}">{{ ticket.project }}</a>
</span>
</fieldset>
{% endif %}
{% if ticket.milestone %}
<fieldset>
<label>Milestone</label>
<span class="text">
<a href="{% url 'Project Management:_project_milestone_view' project_id=ticket.project_id pk=ticket.milestone.id %}">{{ ticket.milestone }}</a>
</span>
</fieldset>
{% endif %}
<fieldset>
<label>Priority</label>
<span class="text">U{{ ticket.get_urgency_display }} / I{{ ticket.get_impact_display }} / P{{ ticket.get_priority_display }}</span>
</fieldset>
<fieldset>
<label>Duration</label>
<span class="text">{{ ticket.duration_ticket|to_duration }}</span>
</fieldset>
<fieldset>
<label>Milestone</label>
<span class="text">val</span>
</fieldset>
<fieldset>
<label>Roadmap(s)</label>
<span class="text">val</span>
</fieldset>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,6 @@
<span id="badge">
<span id="icon" class="ticket-status ticket-status-{{ ticket_status }}">
{% include 'core/ticket/icon_status.html.j2' %}
</span>
<span id="text">{{ ticket_status_text }}</span>
</span>

View File

@ -0,0 +1,89 @@
{% load i18n %}
{% if ticket_type == 'change'%}
{% translate 'ITIM:_ticket_comment_change_reply_add' as comment_reply_url %}
{% elif ticket_type == 'incident'%}
{% translate 'ITIM:_ticket_comment_incident_reply_add' as comment_reply_url %}
{% elif ticket_type == 'project_task'%}
{% translate 'Project Management:_project_task_comment_reply_add' as comment_reply_url %}
{% elif ticket_type == 'problem'%}
{% translate 'ITIM:_ticket_comment_problem_reply_add' as comment_reply_url %}
{% elif ticket_type == 'request'%}
{% translate 'Assistance:_ticket_comment_request_reply_add' as comment_reply_url %}
{% endif %}
<div id="ticket-comments">
<ul>
{% for comment in ticket.comments %}
<li {% if comment.get_comment_type_display == 'Action' %}id="{{ comment.get_comment_type_display }}"{% endif %}>
{% include 'core/ticket/comment/comment.html.j2' %}
{% if comment.threads %}
<div id="discussion" style="/*background-color: #fff;*/">
<h4 style="display: flex; padding-left: 5px;">
Replies
{% include 'icons/ticket/expanded.svg' %}
</h4>
</div>
<div style="padding-left: 40px; border-left: 1px solid #177fe66e; border-bottom: 1px solid #177fe66e;">
{% if comment.threads %}
{% for thread in comment.threads %}
{% include 'core/ticket/comment/comment.html.j2' with comment=thread %}
{% endfor %}
{% endif %}
<div >
<div style="padding: 10px; padding-top: 10px">
{% if ticket_type == 'project_task'%}
<input type="button" value="Comment" onclick="window.location='{% url comment_reply_url ticket.project.id ticket_type ticket.id comment.id %}?comment_type=comment';">
<input type="button" value="Task" onclick="window.location='{% url comment_reply_url ticket.project.id ticket_type ticket.id comment.id %}?comment_type=task';">
{% else %}
<input type="button" value="Comment" onclick="window.location='{% url comment_reply_url ticket_type ticket.id comment.id %}?comment_type=comment';">
<input type="button" value="Task" onclick="window.location='{% url comment_reply_url ticket_type ticket.id comment.id %}?comment_type=task';">
{% endif %}
</div>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
<div id="comment" style="padding: 20px;">
{% if ticket_type == 'change'%}
{% translate 'ITIM:_ticket_comment_change_add' as comment_url %}
{% elif ticket_type == 'incident'%}
{% translate 'ITIM:_ticket_comment_incident_add' as comment_url %}
{% elif ticket_type == 'project_task'%}
{% translate 'Project Management:_project_task_comment_add' as comment_url %}
{% elif ticket_type == 'problem'%}
{% translate 'ITIM:_ticket_comment_problem_add' as comment_url %}
{% elif ticket_type == 'request'%}
{% translate 'Assistance:_ticket_comment_request_add' as comment_url %}
{% endif %}
{% if ticket_type == 'project_task'%}
<input type="button" value="Comment" onclick="window.location='{% url comment_url ticket.project.id ticket_type ticket.id%}?comment_type=comment';">
<input type="button" value="Task" onclick="window.location='{% url comment_url ticket.project.id ticket_type ticket.id %}?comment_type=task';">
<input type="button" value="Notification" onclick="window.location='{% url comment_url ticket.project.id ticket_type ticket.id %}?comment_type=notification';">
<input type="button" value="Resolve" onclick="window.location='{% url comment_url ticket.project.id ticket_type ticket.id %}?comment_type=solution';">
{% else %}
<input type="button" value="Comment" onclick="window.location='{% url comment_url ticket_type ticket.id%}?comment_type=comment';">
<input type="button" value="Task" onclick="window.location='{% url comment_url ticket_type ticket.id %}?comment_type=task';">
<input type="button" value="Notification" onclick="window.location='{% url comment_url ticket_type ticket.id %}?comment_type=notification';">
<input type="button" value="Resolve" onclick="window.location='{% url comment_url ticket_type ticket.id %}?comment_type=solution';">
{% endif %}
</div>
</div>

View File

@ -0,0 +1,132 @@
{% if comment %}
{% load markdown %}
{% if comment.get_comment_type_display == 'Action' %}
<div style="padding: 0px; margin: 0px;"><span style="display: inline-block;">{{ comment.user.username }}&nbsp;</span><span style="display: inline-block;">{{ comment.body | markdown | safe }}</span></div>
{% elif comment.get_comment_type_display == 'Comment' or comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' or comment.get_comment_type_display == 'Solution' %}
<div id="comment" class="comment-type-default comment-type-{{ comment.get_comment_type_display }}">
<h4>
<div id="text">
{{ comment.user }}
<span class="sub-script">{% if comment.get_comment_type_display == 'Task' %}
created a task
{% elif comment.get_comment_type_display == 'Solution' %}
solved
{% else %}
wrote
{% endif %}
on</span> {{ comment.created }}
{% if comment.created|date_time_seconds != comment.modified|date_time_seconds %}
<span class="sub-script">Updated</span> {{ comment.modified }}
{% endif %}
</div>
<div id="icons">
{%if not comment.parent_id %}
{% if ticket_type == 'project_task'%}
<a title="Reply with Comment" href="{% url comment_reply_url ticket.project.id ticket_type ticket.id comment.id %}?comment_type=comment">
{% include 'icons/ticket/reply.svg' %}
</a>
<a title="Reply with Task" href="{% url comment_reply_url ticket.project.id ticket_type ticket.id comment.id %}?comment_type=task">
{% include 'icons/ticket/task.svg' %}
</a>
<a title="Reply with Notification" href="{% url comment_reply_url ticket.project.id ticket_type ticket.id comment.id %}?comment_type=notification">
{% include 'icons/ticket/notification.svg' %}
</a>
{% else %}
<a title="Reply with Comment" href="{% url comment_reply_url ticket_type ticket.id comment.id %}?comment_type=comment">
{% include 'icons/ticket/reply.svg' %}
</a>
<a title="Reply with Task" href="{% url comment_reply_url ticket_type ticket.id comment.id %}?comment_type=task">
{% include 'icons/ticket/task.svg' %}
</a>
<a title="Reply with Notification" href="{% url comment_reply_url ticket_type ticket.id comment.id %}?comment_type=notification">
{% include 'icons/ticket/notification.svg' %}
</a>
{% endif %}
{% endif %}
<a title="Edit Comment" href="{% url 'Assistance:_ticket_comment_request_change' ticket_type ticket.id comment.id %}">
{% include 'icons/ticket/edit.svg' %}
</a>
</div>
</h4>
<div style="line-height:30px;">
{% if comment.get_comment_type_display != 'Notification' %}
<fieldset>
<label>Source</label>
<span>{{ comment.get_source_display }}</span>
</fieldset>
{% endif %}
{% if comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' %}
<fieldset>
<label>Status</label>
<span>{{ comment.get_status_display }}</span>
</fieldset>
{% if comment.get_comment_type_display == 'Task' %}
<fieldset>
<label>Responsible User</label>
<span>{{ comment.responsible_user }}</span>
</fieldset>
{% endif %}
<fieldset>
<label>
{% if comment.get_comment_type_display == 'Task' %}
Responsible Team
{% elif comment.get_comment_type_display == 'Notification' %}
Notify Team
{% endif %}
</label>
<span>{{ comment.responsible_team }}</span>
</fieldset>
{% endif %}
<fieldset>
<label>Category</label>
<span>{{ comment.category }}</span>
</fieldset>
</div>
<hr />
<div id="markdown" style="margin: 15px; padding: 10px; background-color: #fff;">
{{ comment.body | markdown | safe }}
</div>
<hr />
<div>
{% if comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' %}
<fieldset>
<label>Planned Start</label>
<span>{{ comment.planned_start_date }}</span>
</fieldset>
{% if comment.get_comment_type_display == 'Task' %}
<fieldset>
<label>Planned Finish</label>
<span>{{ comment.planned_finish_date }}</span>
</fieldset>
{% endif %}
<fieldset>
<label>Actual Start</label>
<span>{{ comment.real_start_date }}</span>
</fieldset>
<fieldset>
<label>Actual Finish</label>
<span>{{ comment.real_finish_date }}</span>
</fieldset>
{% endif %}
<fieldset>
<label>Duration</label>
<span>{{ comment.duration|to_duration }}</span>
</fieldset>
</div>
</div>
{% endif %}
{% endif %}

View File

@ -0,0 +1,25 @@
{% if ticket_status == 'new' %}
{% include 'icons/ticket/add.svg' %}
{% elif ticket_status == 'assigned' %}
{% include 'icons/ticket/status_assigned.svg' %}
{% elif ticket_status == 'assigned_planning' %}
{% include 'icons/ticket/status_assigned_planning.svg' %}
{% elif ticket_status == 'closed' %}
{% include 'icons/ticket/status_closed.svg' %}
{% elif ticket_status == 'draft' %}
{% include 'icons/ticket/add.svg' %}
{% elif ticket_status == 'pending' %}
{% include 'icons/ticket/status_pending.svg' %}
{% elif ticket_status == 'solved' %}
{% include 'icons/ticket/status_solved.svg' %}
{% elif ticket_status == 'invalid' %}
{% include 'icons/ticket/status_invalid.svg' %}
{% elif ticket_status == 'approvals' %}
{% include 'icons/ticket/status_approvals.svg' %}
{% elif ticket_status == 'accepted' %}
{% include 'icons/ticket/status_accepted.svg' %}
{% elif ticket_status == 'evaluation' %}
{% include 'icons/ticket/status_evaluation.svg' %}
{% elif ticket_status == 'testing' %}
{% include 'icons/ticket/status_testing.svg' %}
{% endif %}

View File

@ -0,0 +1,67 @@
{% extends 'base.html.j2' %}
{% block additional-stylesheet %}
{% load static %}
<link rel="stylesheet" href="{% static 'ticketing.css' %}">
{% endblock additional-stylesheet %}
{% load markdown %}
{% block content %}
<input type="button" value="New Ticket" onclick="window.location='{{ new_ticket_url }}';">
<style>
#status-icon {
margin: 0px;
}
#status-icon svg{
width: 22px;
}
</style>
<table style="max-width: 100%;">
<thead>
<th>&nbsp;</th>
<th>ID</th>
<th>Title</th>
<th>Status</th>
<th>Opened By</th>
<th>organization</th>
<th>Created</th>
</thead>
{% for ticket in tickets %}
<tr class="clicker">
<td id="status-icon">
&nbsp;
</td>
<td>{{ ticket.id }}</td>
<td>
{% if ticket_type == 'change' %}
<a href="{% url 'ITIM:_ticket_change_view' ticket_type='change' pk=ticket.id %}">
{% elif ticket_type == 'incident' %}
<a href="{% url 'ITIM:_ticket_incident_view' ticket_type='incident' pk=ticket.id %}">
{% elif ticket_type == 'problem' %}
<a href="{% url 'ITIM:_ticket_problem_view' ticket_type='problem' pk=ticket.id %}">
{% elif ticket_type == 'request' %}
<a href="{% url 'Assistance:_ticket_request_view' ticket_type='request' pk=ticket.id %}">
{% else %}
<a href=""></a>
{% endif %}
{{ ticket.title }}
</a>
</td>
<td>{% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.get_status_display ticket_status=ticket.get_status_display|ticket_status %}</td>
<td>{{ ticket.opened_by }}</td>
<td>{{ ticket.organization.name }}</td>
<td>{{ ticket.created }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@ -0,0 +1,17 @@
<span id="rendered-ticket-link">
<span id="icon" class="ticket-status ticket-status-{{ ticket_status }}">{% include 'core/ticket/icon_status.html.j2' %}</span>
<span id="text">
<a href=" {% if ticket_type == 'change' %}
{% url 'ITIM:_ticket_change_view' ticket_type='change' pk=id %}
{% elif ticket_type == 'incident' %}
{% url 'ITIM:_ticket_incident_view' ticket_type='incident' pk=id %}
{% elif ticket_type == 'problem' %}
{% url 'ITIM:_ticket_problem_view' ticket_type='problem' pk=id %}
{% elif ticket_type == 'request' %}
{% url 'Assistance:_ticket_request_view' ticket_type='request' pk=id %}
{% elif ticket_type == 'project task' %}
{% url 'Project Management:_project_task_view' project_id=project_id ticket_type='project_task' pk=id %}
{% endif %}"><span style="color: #777;">#{{ id }}</span> {{ name }}, <span style="color: #777;">{{ ticket_type }}</span></a>
</span>
</span>

View File

@ -0,0 +1,8 @@
<span id="ticket-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" class="{{ ticket_type }}-ticket">
<path d="M319.33-246.67h321.34v-66.66H319.33v66.66Zm0-166.66h321.34V-480H319.33v66.67ZM226.67-80q-27 0-46.84-19.83Q160-119.67 160-146.67v-666.66q0-27 19.83-46.84Q199.67-880 226.67-880H574l226 226v507.33q0 27-19.83 46.84Q760.33-80 733.33-80H226.67Zm314-542.67v-190.66h-314v666.66h506.66v-476H540.67Zm-314-190.66v190.66-190.66 666.66-666.66Z"/>
</svg>
</span>

View File

@ -0,0 +1,12 @@
{% extends 'detail.html.j2' %}
{% block tabs %}
<div id="details" class="content-tab">
{% include 'content/section.html.j2' with tab=form.tabs.details %}
</div>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'detail.html.j2' %}
{% block tabs %}
<div id="details" class="content-tab">
{% include 'content/section.html.j2' with tab=form.tabs.details %}
</div>
{% endblock %}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M440-280h80v-160h160v-80H520v-160h-80v160H280v80h160v160Zm40 200q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 425 B

Some files were not shown because too many files have changed in this diff Show More