From 5f3a778002267949e2701707e7ea280ec297f8fe Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 04:16:51 +0930 Subject: [PATCH 001/321] feat(project_management): add interim project model !30 --- .../migrations/0001_initial.py | 42 +++++++++ .../models/project_common.py | 35 ++++++++ app/project_management/models/projects.py | 85 ++++++++++++++----- 3 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 app/project_management/migrations/0001_initial.py create mode 100644 app/project_management/models/project_common.py diff --git a/app/project_management/migrations/0001_initial.py b/app/project_management/migrations/0001_initial.py new file mode 100644 index 00000000..b0b1f30c --- /dev/null +++ b/app/project_management/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 5.0.6 on 2024-06-17 18:45 + +import access.fields +import access.models +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('access', '0005_organization_manager_organization_model_notes'), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('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(max_length=50, unique=True)), + ('slug', access.fields.AutoSlugField()), + ('description', models.TextField(blank=True, default=None, null=True)), + ('code', models.CharField(help_text='Project Code', max_length=25, unique=True)), + ('planned_start_date', models.DateTimeField(blank=True, help_text='When the project is planned to have been started by.', null=True, verbose_name='Planned Start Date')), + ('planned_finish_date', models.DateTimeField(blank=True, help_text='When the project is planned to be finished by.', null=True, verbose_name='Planned Finish Date')), + ('real_start_date', models.DateTimeField(blank=True, help_text='When work commenced on the project.', null=True, verbose_name='Real Start Date')), + ('real_finish_date', models.DateTimeField(blank=True, help_text='When work was completed for the project', null=True, verbose_name='Real Finish Date')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ], + options={ + 'verbose_name': 'Project', + 'verbose_name_plural': 'Projects', + 'ordering': ['code', 'name'], + }, + ), + ] diff --git a/app/project_management/models/project_common.py b/app/project_management/models/project_common.py new file mode 100644 index 00000000..31e36def --- /dev/null +++ b/app/project_management/models/project_common.py @@ -0,0 +1,35 @@ +from django.db import models + +from access.fields import * +from access.models import TenancyObject + + +class ProjectCommonFields(TenancyObject, models.Model): + + class Meta: + abstract = True + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + created = AutoCreatedField() + + modified = AutoLastModifiedField() + + + +class ProjectCommonFieldsName(ProjectCommonFields): + + class Meta: + abstract = True + + name = models.CharField( + blank = False, + max_length = 50, + unique = True, + ) + + slug = AutoSlugField() diff --git a/app/project_management/models/projects.py b/app/project_management/models/projects.py index 2a87c55b..b6ce95c7 100644 --- a/app/project_management/models/projects.py +++ b/app/project_management/models/projects.py @@ -1,9 +1,9 @@ from django.db import models -from access.models import TenancyObject +from .project_common import ProjectCommonFieldsName -class ProjectModel(TenancyObject): +class Project(ProjectCommonFieldsName): class Meta: @@ -18,30 +18,69 @@ class ProjectModel(TenancyObject): verbose_name_plural = 'Projects' - - class ProjectStates(enum): - OPEN = 1 - CLOSED = 1 + # class ProjectStates(enum): + # OPEN = 1 + # CLOSED = 1 - name + description = models.TextField( + blank = True, + default = None, + null= True, + ) - description + # priority - priority - - state - - percent_done # Auto-Calculate - - project_type - - code - - planned_start_date - - planned_finish_date - - real_start_date + # state + # project_type + + code = models.CharField( + blank = False, + help_text = 'Project Code', + max_length = 25, + unique = True, + ) + + planned_start_date = models.DateTimeField( + blank = True, + help_text = 'When the project is planned to have been started by.', + null = True, + verbose_name = 'Planned Start Date', + ) + + planned_finish_date = models.DateTimeField( + blank = True, + help_text = 'When the project is planned to be finished by.', + null = True, + verbose_name = 'Planned Finish Date', + ) + + real_start_date = models.DateTimeField( + blank = True, + help_text = 'When work commenced on the project.', + null = True, + verbose_name = 'Real Start Date', + ) + + real_finish_date = models.DateTimeField( + blank = True, + help_text = 'When work was completed for the project', + null = True, + verbose_name = 'Real Finish Date', + ) + + model_notes = None + + + @property + def percent_completed(self) -> str: # Auto-Calculate + """ How much of the project is completed. + + Returns: + str: Calculated percentage of project completion. + """ + + return 'xx %' + From e35ccd360b9e1bca295504b88f3f960b39bd1950 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 04:20:19 +0930 Subject: [PATCH 002/321] feat(project_management): add project index page !30 --- .../project_management/project_index.html.j2 | 54 +++++++++++++++++++ app/project_management/urls.py | 3 +- app/project_management/views.py | 18 ------- app/project_management/views/project.py | 52 ++++++++++++++++++ 4 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 app/project_management/templates/project_management/project_index.html.j2 delete mode 100644 app/project_management/views.py create mode 100644 app/project_management/views/project.py diff --git a/app/project_management/templates/project_management/project_index.html.j2 b/app/project_management/templates/project_management/project_index.html.j2 new file mode 100644 index 00000000..b9aac824 --- /dev/null +++ b/app/project_management/templates/project_management/project_index.html.j2 @@ -0,0 +1,54 @@ +{% extends 'base.html.j2' %} + +{% block content_header_icon %}{% endblock %} + +{% block content %} + + + + + + + + + + + {% for project in projects %} + + + + + + + + + {% endfor %} + +
CodeNameTypeStateOrganization 
+ {{ project.code }} + + {{ project.name }} + +   + +   + {% if project.is_global %}Global{% else %}{{ project.organization }}{% endif %} 
+ + +{% endblock %} \ No newline at end of file diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 2fd33c64..5fc77a90 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,6 +1,7 @@ from django.urls import path -from .views import ProjectIndex +from .views.project import ProjectView + app_name = "Project Management" urlpatterns = [ diff --git a/app/project_management/views.py b/app/project_management/views.py deleted file mode 100644 index a8929a67..00000000 --- a/app/project_management/views.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.shortcuts import render -from django.views import generic - - -class ProjectIndex(generic.View): - - permission_required = 'itam.view_device' - - template_name = 'form.html.j2' - - - def get(self, request): - - context = {} - - context['content_title'] = 'Project Management' - - return render(request, self.template_name, context) diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py new file mode 100644 index 00000000..b0880234 --- /dev/null +++ b/app/project_management/views/project.py @@ -0,0 +1,52 @@ +import json +import markdown + +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.paginator import Paginator +from django.db.models import Q +from django.urls import reverse +from django.views import generic + +from access.mixin import OrganizationPermission + +from project_management.forms.project import ProjectForm +from project_management.models.projects import Project + +from settings.models.user_settings import UserSettings + + + +class ProjectIndex(OrganizationPermission, generic.ListView): + + model = Project + + permission_required = 'project_management.view_project' + + template_name = 'project_management/project_index.html.j2' + + context_object_name = "projects" + + paginate_by = 10 + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Projects' + + return context + + + def get_queryset(self): + + if self.request.user.is_superuser: + + return self.model.objects.filter().order_by('name') + + else: + + return self.model.objects.filter(Q(organization__in=self.user_organizations()) | Q(is_global = True)).order_by('name') + + return context + + return context From f7d61696d13e216cf1484fb7616d662b32298fbf Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 04:23:17 +0930 Subject: [PATCH 003/321] feat(project_management): add project add page !30 #14 --- app/project_management/urls.py | 6 +++-- app/project_management/views/project.py | 36 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 5fc77a90..3512036c 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,10 +1,12 @@ from django.urls import path -from .views.project import ProjectView +from .views.project import ProjectIndex, ProjectAdd, ProjectView app_name = "Project Management" urlpatterns = [ - # path('', ProjectIndex.as_view(), name='Projects'), + path('', ProjectIndex.as_view(), name='Projects'), + + path("project/add", ProjectAdd.as_view(), name="_project_add"), ] diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index b0880234..981d2a11 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -47,6 +47,42 @@ class ProjectIndex(OrganizationPermission, generic.ListView): return self.model.objects.filter(Q(organization__in=self.user_organizations()) | Q(is_global = True)).order_by('name') + + + +class ProjectAdd(generic.CreateView): + + form_class = ProjectForm + + model = Project + + permission_required = [ + 'project_management.add_project', + ] + + template_name = 'form.html.j2' + + + def get_initial(self): + return { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization + } + + def form_valid(self, form): + form.instance.is_global = False + return super().form_valid(form) + + + def get_success_url(self, **kwargs): + + return reverse('Project Management:Projects') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Create a Project' + return context return context From de78a30a5d789d1153feadd404e10cb2e20d5a75 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 04:26:39 +0930 Subject: [PATCH 004/321] feat(project_management): add project view page !30 #14 --- app/project_management/forms/project.py | 36 ++++ .../project_management/project.html.j2 | 189 ++++++++++++++++++ .../project_management/project_index.html.j2 | 2 + app/project_management/urls.py | 1 + app/project_management/views/project.py | 28 +++ 5 files changed, 256 insertions(+) create mode 100644 app/project_management/forms/project.py create mode 100644 app/project_management/templates/project_management/project.html.j2 diff --git a/app/project_management/forms/project.py b/app/project_management/forms/project.py new file mode 100644 index 00000000..2583b88b --- /dev/null +++ b/app/project_management/forms/project.py @@ -0,0 +1,36 @@ +from django import forms +from django.db.models import Q + +from app import settings +from project_management.models.projects import Project + +class ProjectForm(forms.ModelForm): + + prefix = 'project' + + class Meta: + fields = '__all__' + + + model = Project + + + def __init__(self, *args, **kwargs): + 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" + diff --git a/app/project_management/templates/project_management/project.html.j2 b/app/project_management/templates/project_management/project.html.j2 new file mode 100644 index 00000000..f384698d --- /dev/null +++ b/app/project_management/templates/project_management/project.html.j2 @@ -0,0 +1,189 @@ +{% extends 'base.html.j2' %} + +{% load markdown %} + + +{% block content %} + + + +
+ + + + +
+ +
+ {% csrf_token %} + + +
+

+ Details +

+
+ +
+ +
+ + {{ form.code.value }} +
+ +
+ + {{ form.name.value }} +
+ +
+ + project type +
+ +
+ + project state +
+ +
+ + {{ project.percent_completed }} +
+ +
+ + {{ form.organization }} +
+ +
+ + +
+ +
+ + {{ form.planned_start_date.value }} +
+ +
+ + {{ form.planned_finish_date.value }} +
+ +
+ + {{ form.real_start_date.value }} +
+ +
+ + {{ form.real_finish_date.value }} +
+ +
+ +
+ + +
+ +
+

Description

+ {{ form.description.value | markdown | safe }} +
+ + + + + + +
+ + +
+

Tasks

+ +
+ + +
+

+ Notes +

+ {{ notes_form }} + +
+ {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ +
+{% endblock %} \ No newline at end of file diff --git a/app/project_management/templates/project_management/project_index.html.j2 b/app/project_management/templates/project_management/project_index.html.j2 index b9aac824..e3ba180c 100644 --- a/app/project_management/templates/project_management/project_index.html.j2 +++ b/app/project_management/templates/project_management/project_index.html.j2 @@ -4,6 +4,8 @@ {% block content %} + + diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 3512036c..086d9a0d 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -8,5 +8,6 @@ urlpatterns = [ path('', ProjectIndex.as_view(), name='Projects'), path("project/add", ProjectAdd.as_view(), name="_project_add"), + path("project/", ProjectView.as_view(), name="_project_view"), ] diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index 981d2a11..2a6cb831 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -49,6 +49,34 @@ class ProjectIndex(OrganizationPermission, generic.ListView): +class ProjectView(OrganizationPermission, generic.UpdateView): + + model = Project + + permission_required = [ + 'itam.view_device', + 'itam.change_device' + ] + + template_name = 'project_management/project.html.j2' + + form_class = ProjectForm + + context_object_name = "project" + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.verbose_name.replace(' ', '') + + context['content_title'] = context['project'].name + + return context + + class ProjectAdd(generic.CreateView): From 58466fa490129ede32cec804b006529700310e0b Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 04:28:46 +0930 Subject: [PATCH 005/321] feat(project_management): add project edit page !30 #14 --- app/project_management/urls.py | 3 ++- app/project_management/views/project.py | 30 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 086d9a0d..63f10d63 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .views.project import ProjectIndex, ProjectAdd, ProjectView +from .views.project import ProjectIndex, ProjectAdd, ProjectChange, ProjectView app_name = "Project Management" @@ -9,5 +9,6 @@ urlpatterns = [ path("project/add", ProjectAdd.as_view(), name="_project_add"), path("project/", ProjectView.as_view(), name="_project_view"), + path("project//edit", ProjectChange.as_view(), name="_project_change"), ] diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index 2a6cb831..b59cdd0a 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -113,4 +113,34 @@ class ProjectAdd(generic.CreateView): return context + + +class ProjectChange(generic.UpdateView): + + form_class = ProjectForm + + model = Project + + permission_required = [ + 'project_management.change_project', + ] + + template_name = 'form.html.j2' + + + def form_valid(self, form): + form.instance.is_global = False + return super().form_valid(form) + + + def get_success_url(self, **kwargs): + + return reverse('Project Management:_project_view', kwargs={'pk': self.kwargs['pk']}) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Edit' + return context From a91ae337c49c66a7370d14ab979b9a298d22769c Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 04:49:32 +0930 Subject: [PATCH 006/321] feat(project_management): add project delete page !30 #14 --- app/project_management/urls.py | 3 ++- app/project_management/views/project.py | 27 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 63f10d63..f8a442c0 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .views.project import ProjectIndex, ProjectAdd, ProjectChange, ProjectView +from .views.project import ProjectIndex, ProjectAdd, ProjectDelete, ProjectChange, ProjectView app_name = "Project Management" @@ -10,5 +10,6 @@ urlpatterns = [ path("project/add", ProjectAdd.as_view(), name="_project_add"), path("project/", ProjectView.as_view(), name="_project_view"), path("project//edit", ProjectChange.as_view(), name="_project_change"), + path("project//delete", ProjectDelete.as_view(), name="_project_delete"), ] diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index b59cdd0a..ec7ab2d5 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -72,6 +72,8 @@ class ProjectView(OrganizationPermission, generic.UpdateView): context['model_pk'] = self.kwargs['pk'] context['model_name'] = self.model._meta.verbose_name.replace(' ', '') + context['model_delete_url'] = reverse('Project Management:_project_delete', args=(self.kwargs['pk'],)) + context['content_title'] = context['project'].name return context @@ -144,3 +146,28 @@ class ProjectChange(generic.UpdateView): context['content_title'] = 'Edit' return context + + + +class ProjectDelete(OrganizationPermission, generic.DeleteView): + model = Project + + permission_required = [ + 'project_management.delete_project', + ] + + template_name = 'form.html.j2' + + + def get_success_url(self, **kwargs): + + return reverse('Project Management:Projects') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + self.object.name + + return context + From 86fc1448a6408aa915d74a58b97b540a2387f9f0 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 04:51:53 +0930 Subject: [PATCH 007/321] feat(project_management): save project history !30 #14 --- app/core/views/history.py | 9 ++++++++- app/project_management/models/projects.py | 4 +++- app/templates/base.html.j2 | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/core/views/history.py b/app/core/views/history.py index 5f24a009..d8f68be4 100644 --- a/app/core/views/history.py +++ b/app/core/views/history.py @@ -43,9 +43,12 @@ class View(OrganizationPermission, generic.View): from settings.models.external_link import ExternalLink + from project_management.models.projects import Project + + if not hasattr(self, 'model'): - match self.kwargs['model_name']: + match str(self.kwargs['model_name']).lower(): case 'cluster': @@ -127,6 +130,10 @@ class View(OrganizationPermission, generic.View): self.model = Service + case 'project': + + self.model = Project + case _: raise Exception('Unable to determine history items model') diff --git a/app/project_management/models/projects.py b/app/project_management/models/projects.py index b6ce95c7..e220f97c 100644 --- a/app/project_management/models/projects.py +++ b/app/project_management/models/projects.py @@ -1,9 +1,11 @@ from django.db import models +from core.mixin.history_save import SaveHistory + from .project_common import ProjectCommonFieldsName -class Project(ProjectCommonFieldsName): +class Project(ProjectCommonFieldsName, SaveHistory): class Meta: diff --git a/app/templates/base.html.j2 b/app/templates/base.html.j2 index 712f2e90..7e74b0fe 100644 --- a/app/templates/base.html.j2 +++ b/app/templates/base.html.j2 @@ -103,7 +103,7 @@ section h2 span svg { {% endif %} {% if model_name and model_pk %} {% block content_header_icon %} - + From 47f95ddae29fb0806137ab52ed398dde606953b0 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 04:52:30 +0930 Subject: [PATCH 008/321] chore(project_management): add placeholder code for project notes !30 #14 --- app/project_management/views/project.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index ec7ab2d5..9dae04bb 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -9,6 +9,9 @@ from django.views import generic from access.mixin import OrganizationPermission +from core.forms.comment import AddNoteForm +from core.models.notes import Notes + from project_management.forms.project import ProjectForm from project_management.models.projects import Project @@ -69,6 +72,10 @@ class ProjectView(OrganizationPermission, generic.UpdateView): context = super().get_context_data(**kwargs) + # context['notes_form'] = AddNoteForm(prefix='note') + # context['notes'] = Notes.objects.filter(project=self.kwargs['pk']) + + context['model_pk'] = self.kwargs['pk'] context['model_name'] = self.model._meta.verbose_name.replace(' ', '') @@ -79,6 +86,25 @@ class ProjectView(OrganizationPermission, generic.UpdateView): return context + # def post(self, request, *args, **kwargs): + + # project = self.model.objects.get(pk=self.kwargs['pk']) + + # notes = AddNoteForm(request.POST, prefix='note') + + # if notes.is_bound and notes.is_valid() and notes.instance.note != '': + + # if request.user.has_perm('core.add_notes'): + + # notes.instance.organization = device.organization + # notes.instance.project = project + # notes.instance.usercreated = request.user + + # notes.save() + + # return super().post(request, *args, **kwargs) + + class ProjectAdd(generic.CreateView): From 326753d0ff2125b69fdcca20441d0a9d53111938 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 05:04:25 +0930 Subject: [PATCH 009/321] chore(project_management): Add doc link to project view page !30 #14 --- app/project_management/views/project.py | 2 ++ .../user/project_management/index.md | 14 ++++++++++++++ .../user/project_management/project.md | 8 ++++++++ 3 files changed, 24 insertions(+) create mode 100644 docs/projects/django-template/user/project_management/index.md create mode 100644 docs/projects/django-template/user/project_management/project.md diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index 9dae04bb..f9ef2254 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -76,6 +76,8 @@ class ProjectView(OrganizationPermission, generic.UpdateView): # context['notes'] = Notes.objects.filter(project=self.kwargs['pk']) + context['model_docs_path'] = self.model._meta.app_label + '/' + self.model._meta.model_name + '/' + context['model_pk'] = self.kwargs['pk'] context['model_name'] = self.model._meta.verbose_name.replace(' ', '') diff --git a/docs/projects/django-template/user/project_management/index.md b/docs/projects/django-template/user/project_management/index.md new file mode 100644 index 00000000..75645488 --- /dev/null +++ b/docs/projects/django-template/user/project_management/index.md @@ -0,0 +1,14 @@ +--- +title: Project Management +description: No Fuss Computings Project Management User Documentation for Django ITSM +date: 2024-06-17 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/django_app +--- + +Project Management Module. Documentation for specific items can be found in the navigation menu to the left. + + +## Features + +- History Saving diff --git a/docs/projects/django-template/user/project_management/project.md b/docs/projects/django-template/user/project_management/project.md new file mode 100644 index 00000000..eada5972 --- /dev/null +++ b/docs/projects/django-template/user/project_management/project.md @@ -0,0 +1,8 @@ +--- +title: Project +description: No Fuss Computings Project User Documentation for Django ITSM +date: 2024-06-17 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/django_app +--- + From 6c0ca9cb86f5ee8c3e904650ac148e3527f334b4 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 05:15:45 +0930 Subject: [PATCH 010/321] chore(project_management): add urls to include !30 --- app/app/settings.py | 2 ++ app/app/urls.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/app/app/settings.py b/app/app/settings.py index efdb64a7..27ca8e51 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -119,6 +119,7 @@ INSTALLED_APPS = [ 'drf_spectacular', 'drf_spectacular_sidecar', 'config_management.apps.ConfigManagementConfig', + 'project_management.apps.ProjectManagementConfig', ] MIDDLEWARE = [ @@ -360,6 +361,7 @@ if DEBUG: # Apps Under Development INSTALLED_APPS += [ 'project_management.apps.ProjectManagementConfig', + 'information.apps.InformationConfig', ] diff --git a/app/app/urls.py b/app/app/urls.py index 348bfbca..3c4501a2 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -75,11 +75,16 @@ if settings.DEBUG: path("__debug__/", include("debug_toolbar.urls"), name='_debug'), path("project_management/", include("project_management.urls")), + # Apps Under Development + path("itim/", include("itim.urls")), + path("information/", include("information.urls")), ] # must be after above urlpatterns += [ + path("project_management/", include("project_management.urls")), + path("settings/", include("settings.urls")), ] From 376faf3d5ac29292f1d02685692a1757b1d898e3 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 06:01:18 +0930 Subject: [PATCH 011/321] feat(project_management): Add project task model !30 #14 --- .../migrations/0002_projecttask.py | 44 +++++++++++ .../models/project_tasks.py | 79 ++++++++++++++----- 2 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 app/project_management/migrations/0002_projecttask.py diff --git a/app/project_management/migrations/0002_projecttask.py b/app/project_management/migrations/0002_projecttask.py new file mode 100644 index 00000000..0e909884 --- /dev/null +++ b/app/project_management/migrations/0002_projecttask.py @@ -0,0 +1,44 @@ +# Generated by Django 5.0.6 on 2024-06-17 20:29 + +import access.fields +import access.models +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('access', '0005_organization_manager_organization_model_notes'), + ('project_management', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectTask', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('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(max_length=50, unique=True)), + ('slug', access.fields.AutoSlugField()), + ('description', models.TextField(blank=True, default=None, null=True)), + ('code', models.CharField(help_text='Project Code', max_length=25, unique=True)), + ('planned_start_date', models.DateTimeField(blank=True, help_text='When the task is planned to have been started by.', null=True, verbose_name='Planned Start Date')), + ('planned_finish_date', models.DateTimeField(blank=True, help_text='When the task is planned to be finished by.', null=True, verbose_name='Planned Finish Date')), + ('real_start_date', models.DateTimeField(blank=True, help_text='When work commenced on the task.', null=True, verbose_name='Real Start Date')), + ('real_finish_date', models.DateTimeField(blank=True, help_text='When work was completed for the task', null=True, verbose_name='Real Finish Date')), + ('milestone', models.BooleanField(default=False, help_text='Is this task a milestone?')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('parent_task', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='project_management.projecttask')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project_management.project')), + ], + options={ + 'verbose_name': 'Project Task', + 'verbose_name_plural': 'Project Tasks', + 'ordering': ['code', 'name'], + }, + ), + ] diff --git a/app/project_management/models/project_tasks.py b/app/project_management/models/project_tasks.py index 67c5cf84..4408fdcd 100644 --- a/app/project_management/models/project_tasks.py +++ b/app/project_management/models/project_tasks.py @@ -1,12 +1,13 @@ from django.db import models -from .projects import ProjectModel +from .projects import Project +from .project_common import ProjectCommonFieldsName -from access.models import TenancyObject +from core.mixin.history_save import SaveHistory -class ProjectTaskModel(model.Model, TenancyObject): +class ProjectTask(ProjectCommonFieldsName, SaveHistory): class Meta: @@ -22,38 +23,80 @@ class ProjectTaskModel(model.Model, TenancyObject): - class ProjectTaskStates(enum): - OPEN = 1 - CLOSED = 1 + # class ProjectTaskStates(enum): + # OPEN = 1 + # CLOSED = 1 - project + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + null = False, + blank= False + ) - parent_task + parent_task = models.ForeignKey( + 'self', + blank= True, + default = None, + on_delete=models.CASCADE, + null = True, + ) - name + description = models.TextField( + blank = True, + default = None, + null= True, + ) - description + # priority - priority + # state - state + # percent_done - percent_done + # task_type - task_type + code = models.CharField( + blank = False, + help_text = 'Project Code', + max_length = 25, + unique = True, + ) - code + planned_start_date = models.DateTimeField( + blank = True, + help_text = 'When the task is planned to have been started by.', + null = True, + verbose_name = 'Planned Start Date', + ) - planned_start_date + planned_finish_date = models.DateTimeField( + blank = True, + help_text = 'When the task is planned to be finished by.', + null = True, + verbose_name = 'Planned Finish Date', + ) - planned_finish_date + real_start_date = models.DateTimeField( + blank = True, + help_text = 'When work commenced on the task.', + null = True, + verbose_name = 'Real Start Date', + ) - real_start_date + real_finish_date = models.DateTimeField( + blank = True, + help_text = 'When work was completed for the task', + null = True, + verbose_name = 'Real Finish Date', + ) milestone = models.BooleanField( blank = False, + help_text = 'Is this task a milestone?', default = False, ) + model_notes = None From e4d1bb4d3c089b37326560ba5418f74ae036ab93 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 06:03:40 +0930 Subject: [PATCH 012/321] feat(project_management): Project task add view !30 #14 --- app/project_management/urls.py | 7 ++- app/project_management/views/project_task.py | 54 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 app/project_management/views/project_task.py diff --git a/app/project_management/urls.py b/app/project_management/urls.py index f8a442c0..1cc4764a 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,7 +1,7 @@ from django.urls import path from .views.project import ProjectIndex, ProjectAdd, ProjectDelete, ProjectChange, ProjectView - +from .views.project_task import ProjectTaskAdd app_name = "Project Management" urlpatterns = [ @@ -11,5 +11,8 @@ urlpatterns = [ path("project/", ProjectView.as_view(), name="_project_view"), path("project//edit", ProjectChange.as_view(), name="_project_change"), path("project//delete", ProjectDelete.as_view(), name="_project_delete"), - + + path("project//task/add", ProjectTaskAdd.as_view(), name="_project_task_add"), + + ] diff --git a/app/project_management/views/project_task.py b/app/project_management/views/project_task.py new file mode 100644 index 00000000..5932d2c3 --- /dev/null +++ b/app/project_management/views/project_task.py @@ -0,0 +1,54 @@ +import json +import markdown + +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.paginator import Paginator +from django.db.models import Q +from django.urls import reverse +from django.views import generic + +from access.mixin import OrganizationPermission + +from core.forms.comment import AddNoteForm +from core.models.notes import Notes + +from project_management.models.project_tasks import ProjectTask + +from settings.models.user_settings import UserSettings + + + +class ProjectTaskAdd(generic.CreateView): + + # form_class = ProjectForm + + model = ProjectTask + + permission_required = [ + 'project_management.add_project', + ] + + template_name = 'form.html.j2' + + + def get_initial(self): + return { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization + } + + def form_valid(self, form): + form.instance.is_global = False + return super().form_valid(form) + + + def get_success_url(self, **kwargs): + + return reverse('Project Management:Projects') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Create a Project' + + return context From c6fc2d3e7cec11297b5104ee19a65bba98581ac1 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 06:07:57 +0930 Subject: [PATCH 013/321] feat(project_management): Project task delete view !30 #14 --- app/project_management/urls.py | 3 ++- app/project_management/views/project_task.py | 24 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 1cc4764a..d242fac4 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,7 +1,7 @@ from django.urls import path from .views.project import ProjectIndex, ProjectAdd, ProjectDelete, ProjectChange, ProjectView -from .views.project_task import ProjectTaskAdd +from .views.project_task import ProjectTaskAdd, ProjectTaskDelete app_name = "Project Management" urlpatterns = [ @@ -13,6 +13,7 @@ urlpatterns = [ path("project//delete", ProjectDelete.as_view(), name="_project_delete"), path("project//task/add", ProjectTaskAdd.as_view(), name="_project_task_add"), + path("project//task//delete", ProjectTaskDelete.as_view(), name="_project_task_delete"), ] diff --git a/app/project_management/views/project_task.py b/app/project_management/views/project_task.py index 5932d2c3..0970538e 100644 --- a/app/project_management/views/project_task.py +++ b/app/project_management/views/project_task.py @@ -52,3 +52,27 @@ class ProjectTaskAdd(generic.CreateView): context['content_title'] = 'Create a Project' return context + + + +class ProjectTaskDelete(OrganizationPermission, generic.DeleteView): + model = ProjectTask + + permission_required = [ + 'project_management.delete_projecttask', + ] + + template_name = 'form.html.j2' + + + def get_success_url(self, **kwargs): + + return reverse('Project Management:_project_view', kwargs={'pk': self.kwargs['project_id']}) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + self.object.name + + return context From f883d4190a7acd19a50a8e6fb0e907bac57b0d82 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 06:12:39 +0930 Subject: [PATCH 014/321] feat(project_management): Project task edit view !30 #14 --- app/project_management/urls.py | 3 +- app/project_management/views/project_task.py | 38 +++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/project_management/urls.py b/app/project_management/urls.py index d242fac4..1b70007f 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,7 +1,7 @@ from django.urls import path from .views.project import ProjectIndex, ProjectAdd, ProjectDelete, ProjectChange, ProjectView -from .views.project_task import ProjectTaskAdd, ProjectTaskDelete +from .views.project_task import ProjectTaskAdd, ProjectTaskDelete, ProjectTaskView app_name = "Project Management" urlpatterns = [ @@ -13,6 +13,7 @@ urlpatterns = [ path("project//delete", ProjectDelete.as_view(), name="_project_delete"), path("project//task/add", ProjectTaskAdd.as_view(), name="_project_task_add"), + path("project//task/", ProjectTaskView.as_view(), name="_project_task_change"), path("project//task//delete", ProjectTaskDelete.as_view(), name="_project_task_delete"), diff --git a/app/project_management/views/project_task.py b/app/project_management/views/project_task.py index 0970538e..0caee218 100644 --- a/app/project_management/views/project_task.py +++ b/app/project_management/views/project_task.py @@ -20,7 +20,7 @@ from settings.models.user_settings import UserSettings class ProjectTaskAdd(generic.CreateView): - # form_class = ProjectForm + # form_class = form_class = ProjectTaskForm model = ProjectTask @@ -55,6 +55,42 @@ class ProjectTaskAdd(generic.CreateView): +class ProjectTaskChange(OrganizationPermission, generic.UpdateView): + + model = ProjectTask + + permission_required = [ + 'project_management.change_projecttask' + ] + + template_name = 'project_management/project.html.j2' + + # form_class = ProjectTaskForm + + context_object_name = "project_task" + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + # context['notes_form'] = AddNoteForm(prefix='note') + # context['notes'] = Notes.objects.filter(project=self.kwargs['pk']) + + + context['model_docs_path'] = self.model._meta.app_label + '/' + self.model._meta.model_name + '/' + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.verbose_name.replace(' ', '') + + context['model_delete_url'] = reverse('Project Management:_project_task_delete', args=(self.kwargs['project_id'],self.kwargs['pk'])) + + context['content_title'] = context['project_task'].name + + return context + + + class ProjectTaskDelete(OrganizationPermission, generic.DeleteView): model = ProjectTask From 9bc4f186d53d1d72da9107a7d20f39399411e917 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 06:21:02 +0930 Subject: [PATCH 015/321] feat(project_management): Project task view "view" !30 #14 --- app/project_management/urls.py | 5 +- app/project_management/views/project_task.py | 61 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 1b70007f..71fc20f7 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,7 +1,7 @@ from django.urls import path from .views.project import ProjectIndex, ProjectAdd, ProjectDelete, ProjectChange, ProjectView -from .views.project_task import ProjectTaskAdd, ProjectTaskDelete, ProjectTaskView +from .views.project_task import ProjectTaskAdd, ProjectTaskChange, ProjectTaskDelete, ProjectTaskView app_name = "Project Management" urlpatterns = [ @@ -13,8 +13,9 @@ urlpatterns = [ path("project//delete", ProjectDelete.as_view(), name="_project_delete"), path("project//task/add", ProjectTaskAdd.as_view(), name="_project_task_add"), - path("project//task/", ProjectTaskView.as_view(), name="_project_task_change"), + path("project//task//edit", ProjectTaskChange.as_view(), name="_project_task_change"), path("project//task//delete", ProjectTaskDelete.as_view(), name="_project_task_delete"), + path("project//task/", ProjectTaskView.as_view(), name="_project_task_view"), ] diff --git a/app/project_management/views/project_task.py b/app/project_management/views/project_task.py index 0caee218..0cd93cd7 100644 --- a/app/project_management/views/project_task.py +++ b/app/project_management/views/project_task.py @@ -57,6 +57,38 @@ class ProjectTaskAdd(generic.CreateView): class ProjectTaskChange(OrganizationPermission, generic.UpdateView): + # form_class = ProjectTaskForm + + model = ProjectTask + + permission_required = [ + 'project_management.change_projecttask', + ] + + template_name = 'form.html.j2' + + + def form_valid(self, form): + form.instance.is_global = False + return super().form_valid(form) + + + def get_success_url(self, **kwargs): + + return reverse('Project Management:_project_task_view', kwargs={'pk': self.kwargs['pk'], 'project_id': self.kwargs['project_id']}) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = self.object.name + + return context + + + +class ProjectTaskView(OrganizationPermission, generic.UpdateView): + model = ProjectTask permission_required = [ @@ -70,6 +102,11 @@ class ProjectTaskChange(OrganizationPermission, generic.UpdateView): context_object_name = "project_task" + def form_valid(self, form): + form.instance.is_global = False + return super().form_valid(form) + + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -90,6 +127,30 @@ class ProjectTaskChange(OrganizationPermission, generic.UpdateView): return context + def get_success_url(self, **kwargs): + + return reverse('Project Management:_project_task_view', kwargs={'pk': self.kwargs['pk'], 'project_id': self.kwargs['project_id']}) + + + # def post(self, request, *args, **kwargs): + + # project = self.model.objects.get(pk=self.kwargs['pk']) + + # notes = AddNoteForm(request.POST, prefix='note') + + # if notes.is_bound and notes.is_valid() and notes.instance.note != '': + + # if request.user.has_perm('core.add_notes'): + + # notes.instance.organization = device.organization + # notes.instance.project = project + # notes.instance.usercreated = request.user + + # notes.save() + + # return super().post(request, *args, **kwargs) + + class ProjectTaskDelete(OrganizationPermission, generic.DeleteView): model = ProjectTask From 3c206f5aef756693c07dec74f69d11acd437892f Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Jun 2024 08:37:54 +0930 Subject: [PATCH 016/321] feat(project_management): Add manager and users for projects and tasks !30 #14 --- app/project_management/forms/project.py | 2 + ...ager_team_project_manager_user_and_more.py | 42 +++++++++++++++++++ .../models/project_tasks.py | 19 +++++++++ app/project_management/models/projects.py | 26 +++++++++++- .../project_management/project.html.j2 | 34 +++++++++++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 app/project_management/migrations/0003_project_manager_team_project_manager_user_and_more.py diff --git a/app/project_management/forms/project.py b/app/project_management/forms/project.py index 2583b88b..59634d12 100644 --- a/app/project_management/forms/project.py +++ b/app/project_management/forms/project.py @@ -34,3 +34,5 @@ class ProjectForm(forms.ModelForm): 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: 1000px"} + diff --git a/app/project_management/migrations/0003_project_manager_team_project_manager_user_and_more.py b/app/project_management/migrations/0003_project_manager_team_project_manager_user_and_more.py new file mode 100644 index 00000000..ecf00928 --- /dev/null +++ b/app/project_management/migrations/0003_project_manager_team_project_manager_user_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.0.6 on 2024-06-17 23:06 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('access', '0005_organization_manager_organization_model_notes'), + ('project_management', '0002_projecttask'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='manager_team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='access.team'), + ), + migrations.AddField( + model_name='project', + name='manager_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='manager_user', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='project', + name='team_members', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='projecttask', + name='task_members', + field=models.ManyToManyField(help_text='User whom is responsible for completing the task.', related_name='task_members', to=settings.AUTH_USER_MODEL, verbose_name='Team Members'), + ), + migrations.AddField( + model_name='projecttask', + name='task_owner', + field=models.ForeignKey(blank=True, help_text='User whom is considered the task owner.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Task Owner'), + ), + ] diff --git a/app/project_management/models/project_tasks.py b/app/project_management/models/project_tasks.py index 4408fdcd..21eab802 100644 --- a/app/project_management/models/project_tasks.py +++ b/app/project_management/models/project_tasks.py @@ -1,8 +1,11 @@ +from django.contrib.auth.models import User from django.db import models from .projects import Project from .project_common import ProjectCommonFieldsName +from access.models import Team + from core.mixin.history_save import SaveHistory @@ -100,3 +103,19 @@ class ProjectTask(ProjectCommonFieldsName, SaveHistory): model_notes = None + task_owner = models.ForeignKey( + User, + blank= True, + help_text = 'User whom is considered the task owner.', + on_delete=models.SET_NULL, + null = True, + verbose_name = 'Task Owner', + ) + + task_members = models.ManyToManyField( + to = User, + blank = False, + help_text = 'User whom is responsible for completing the task.', + related_name = 'task_members', + verbose_name = 'Team Members', + ) \ No newline at end of file diff --git a/app/project_management/models/projects.py b/app/project_management/models/projects.py index e220f97c..4450daab 100644 --- a/app/project_management/models/projects.py +++ b/app/project_management/models/projects.py @@ -1,5 +1,8 @@ +from django.contrib.auth.models import User from django.db import models +from access.models import Team + from core.mixin.history_save import SaveHistory from .project_common import ProjectCommonFieldsName @@ -35,7 +38,6 @@ class Project(ProjectCommonFieldsName, SaveHistory): # state - # project_type code = models.CharField( @@ -73,8 +75,30 @@ class Project(ProjectCommonFieldsName, SaveHistory): verbose_name = 'Real Finish Date', ) + manager_user = models.ForeignKey( + User, + blank= True, + help_text = '', + on_delete=models.SET_NULL, + null = True, + related_name = 'manager_user' + ) + + manager_team = models.ForeignKey( + Team, + blank= True, + help_text = '', + on_delete=models.SET_NULL, + null = True, + ) + model_notes = None + team_members = models.ManyToManyField( + to = User, + blank = True, + ) + @property def percent_completed(self) -> str: # Auto-Calculate diff --git a/app/project_management/templates/project_management/project.html.j2 b/app/project_management/templates/project_management/project.html.j2 index f384698d..3923c5b5 100644 --- a/app/project_management/templates/project_management/project.html.j2 +++ b/app/project_management/templates/project_management/project.html.j2 @@ -71,6 +71,10 @@ line-height: 30px; } + + hr { + border: 0; border-top: 1px solid #ccc; + }
{% csrf_token %} @@ -146,10 +150,40 @@ +
+ +
+

Manager

+
+ +
+ + {{ form.manager_user.value }} +
+ +
+ +
+ +
+ + {{ form.team_members.value }} +
+ +
+
+
+
+
+

Description

+
{{ form.description.value | markdown | safe }} +
+
+
Date: Tue, 16 Jul 2024 15:47:24 +0930 Subject: [PATCH 017/321] chore: update to cater for recent dev changes and class inheritance !31 !40 !42 --- app/project_management/forms/project.py | 7 ++- .../migrations/0001_initial.py | 37 +++++++++++++++- .../migrations/0002_projecttask.py | 44 ------------------- ...ager_team_project_manager_user_and_more.py | 42 ------------------ .../models/project_common.py | 2 +- .../models/project_tasks.py | 2 +- app/project_management/models/projects.py | 2 +- app/project_management/views/project.py | 11 ++--- app/project_management/views/project_task.py | 9 ++-- 9 files changed, 55 insertions(+), 101 deletions(-) delete mode 100644 app/project_management/migrations/0002_projecttask.py delete mode 100644 app/project_management/migrations/0003_project_manager_team_project_manager_user_and_more.py diff --git a/app/project_management/forms/project.py b/app/project_management/forms/project.py index 59634d12..913b36b2 100644 --- a/app/project_management/forms/project.py +++ b/app/project_management/forms/project.py @@ -2,9 +2,14 @@ from django import forms from django.db.models import Q from app import settings + +from core.forms.common import CommonModelForm + from project_management.models.projects import Project -class ProjectForm(forms.ModelForm): + + +class ProjectForm(CommonModelForm): prefix = 'project' diff --git a/app/project_management/migrations/0001_initial.py b/app/project_management/migrations/0001_initial.py index b0b1f30c..2da0a8e1 100644 --- a/app/project_management/migrations/0001_initial.py +++ b/app/project_management/migrations/0001_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 5.0.6 on 2024-06-17 18:45 +# Generated by Django 5.0.7 on 2024-07-16 05:57 import access.fields import access.models import django.db.models.deletion import django.utils.timezone +from django.conf import settings from django.db import migrations, models @@ -12,7 +13,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('access', '0005_organization_manager_organization_model_notes'), + ('access', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -31,7 +33,10 @@ class Migration(migrations.Migration): ('planned_finish_date', models.DateTimeField(blank=True, help_text='When the project is planned to be finished by.', null=True, verbose_name='Planned Finish Date')), ('real_start_date', models.DateTimeField(blank=True, help_text='When work commenced on the project.', null=True, verbose_name='Real Start Date')), ('real_finish_date', models.DateTimeField(blank=True, help_text='When work was completed for the project', null=True, verbose_name='Real Finish Date')), + ('manager_team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='access.team')), + ('manager_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='manager_user', to=settings.AUTH_USER_MODEL)), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('team_members', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'Project', @@ -39,4 +44,32 @@ class Migration(migrations.Migration): 'ordering': ['code', 'name'], }, ), + migrations.CreateModel( + name='ProjectTask', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('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(max_length=50, unique=True)), + ('slug', access.fields.AutoSlugField()), + ('description', models.TextField(blank=True, default=None, null=True)), + ('code', models.CharField(help_text='Project Code', max_length=25, unique=True)), + ('planned_start_date', models.DateTimeField(blank=True, help_text='When the task is planned to have been started by.', null=True, verbose_name='Planned Start Date')), + ('planned_finish_date', models.DateTimeField(blank=True, help_text='When the task is planned to be finished by.', null=True, verbose_name='Planned Finish Date')), + ('real_start_date', models.DateTimeField(blank=True, help_text='When work commenced on the task.', null=True, verbose_name='Real Start Date')), + ('real_finish_date', models.DateTimeField(blank=True, help_text='When work was completed for the task', null=True, verbose_name='Real Finish Date')), + ('milestone', models.BooleanField(default=False, help_text='Is this task a milestone?')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('parent_task', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='project_management.projecttask')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project_management.project')), + ('task_members', models.ManyToManyField(help_text='User whom is responsible for completing the task.', related_name='task_members', to=settings.AUTH_USER_MODEL, verbose_name='Team Members')), + ('task_owner', models.ForeignKey(blank=True, help_text='User whom is considered the task owner.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Task Owner')), + ], + options={ + 'verbose_name': 'Project Task', + 'verbose_name_plural': 'Project Tasks', + 'ordering': ['code', 'name'], + }, + ), ] diff --git a/app/project_management/migrations/0002_projecttask.py b/app/project_management/migrations/0002_projecttask.py deleted file mode 100644 index 0e909884..00000000 --- a/app/project_management/migrations/0002_projecttask.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-17 20:29 - -import access.fields -import access.models -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('access', '0005_organization_manager_organization_model_notes'), - ('project_management', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='ProjectTask', - fields=[ - ('is_global', models.BooleanField(default=False)), - ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), - ('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(max_length=50, unique=True)), - ('slug', access.fields.AutoSlugField()), - ('description', models.TextField(blank=True, default=None, null=True)), - ('code', models.CharField(help_text='Project Code', max_length=25, unique=True)), - ('planned_start_date', models.DateTimeField(blank=True, help_text='When the task is planned to have been started by.', null=True, verbose_name='Planned Start Date')), - ('planned_finish_date', models.DateTimeField(blank=True, help_text='When the task is planned to be finished by.', null=True, verbose_name='Planned Finish Date')), - ('real_start_date', models.DateTimeField(blank=True, help_text='When work commenced on the task.', null=True, verbose_name='Real Start Date')), - ('real_finish_date', models.DateTimeField(blank=True, help_text='When work was completed for the task', null=True, verbose_name='Real Finish Date')), - ('milestone', models.BooleanField(default=False, help_text='Is this task a milestone?')), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('parent_task', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='project_management.projecttask')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project_management.project')), - ], - options={ - 'verbose_name': 'Project Task', - 'verbose_name_plural': 'Project Tasks', - 'ordering': ['code', 'name'], - }, - ), - ] diff --git a/app/project_management/migrations/0003_project_manager_team_project_manager_user_and_more.py b/app/project_management/migrations/0003_project_manager_team_project_manager_user_and_more.py deleted file mode 100644 index ecf00928..00000000 --- a/app/project_management/migrations/0003_project_manager_team_project_manager_user_and_more.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-17 23:06 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('access', '0005_organization_manager_organization_model_notes'), - ('project_management', '0002_projecttask'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='manager_team', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='access.team'), - ), - migrations.AddField( - model_name='project', - name='manager_user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='manager_user', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='project', - name='team_members', - field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='projecttask', - name='task_members', - field=models.ManyToManyField(help_text='User whom is responsible for completing the task.', related_name='task_members', to=settings.AUTH_USER_MODEL, verbose_name='Team Members'), - ), - migrations.AddField( - model_name='projecttask', - name='task_owner', - field=models.ForeignKey(blank=True, help_text='User whom is considered the task owner.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Task Owner'), - ), - ] diff --git a/app/project_management/models/project_common.py b/app/project_management/models/project_common.py index 31e36def..da62d011 100644 --- a/app/project_management/models/project_common.py +++ b/app/project_management/models/project_common.py @@ -4,7 +4,7 @@ from access.fields import * from access.models import TenancyObject -class ProjectCommonFields(TenancyObject, models.Model): +class ProjectCommonFields(TenancyObject): class Meta: abstract = True diff --git a/app/project_management/models/project_tasks.py b/app/project_management/models/project_tasks.py index 21eab802..11848af8 100644 --- a/app/project_management/models/project_tasks.py +++ b/app/project_management/models/project_tasks.py @@ -10,7 +10,7 @@ from core.mixin.history_save import SaveHistory -class ProjectTask(ProjectCommonFieldsName, SaveHistory): +class ProjectTask(ProjectCommonFieldsName): class Meta: diff --git a/app/project_management/models/projects.py b/app/project_management/models/projects.py index 4450daab..9424f36a 100644 --- a/app/project_management/models/projects.py +++ b/app/project_management/models/projects.py @@ -8,7 +8,7 @@ from core.mixin.history_save import SaveHistory from .project_common import ProjectCommonFieldsName -class Project(ProjectCommonFieldsName, SaveHistory): +class Project(ProjectCommonFieldsName): class Meta: diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index f9ef2254..8ec57f6e 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -11,6 +11,7 @@ from access.mixin import OrganizationPermission from core.forms.comment import AddNoteForm from core.models.notes import Notes +from core.views.common import AddView, ChangeView, DeleteView, DisplayView, IndexView from project_management.forms.project import ProjectForm from project_management.models.projects import Project @@ -19,7 +20,7 @@ from settings.models.user_settings import UserSettings -class ProjectIndex(OrganizationPermission, generic.ListView): +class ProjectIndex(IndexView): model = Project @@ -52,7 +53,7 @@ class ProjectIndex(OrganizationPermission, generic.ListView): -class ProjectView(OrganizationPermission, generic.UpdateView): +class ProjectView(ChangeView): model = Project @@ -108,7 +109,7 @@ class ProjectView(OrganizationPermission, generic.UpdateView): -class ProjectAdd(generic.CreateView): +class ProjectAdd(AddView): form_class = ProjectForm @@ -145,7 +146,7 @@ class ProjectAdd(generic.CreateView): -class ProjectChange(generic.UpdateView): +class ProjectChange(ChangeView): form_class = ProjectForm @@ -177,7 +178,7 @@ class ProjectChange(generic.UpdateView): -class ProjectDelete(OrganizationPermission, generic.DeleteView): +class ProjectDelete(DeleteView): model = Project permission_required = [ diff --git a/app/project_management/views/project_task.py b/app/project_management/views/project_task.py index 0cd93cd7..fa129c97 100644 --- a/app/project_management/views/project_task.py +++ b/app/project_management/views/project_task.py @@ -11,6 +11,7 @@ from access.mixin import OrganizationPermission from core.forms.comment import AddNoteForm from core.models.notes import Notes +from core.views.common import AddView, ChangeView, DeleteView, DisplayView, IndexView from project_management.models.project_tasks import ProjectTask @@ -18,7 +19,7 @@ from settings.models.user_settings import UserSettings -class ProjectTaskAdd(generic.CreateView): +class ProjectTaskAdd(AddView): # form_class = form_class = ProjectTaskForm @@ -55,7 +56,7 @@ class ProjectTaskAdd(generic.CreateView): -class ProjectTaskChange(OrganizationPermission, generic.UpdateView): +class ProjectTaskChange(ChangeView): # form_class = ProjectTaskForm @@ -87,7 +88,7 @@ class ProjectTaskChange(OrganizationPermission, generic.UpdateView): -class ProjectTaskView(OrganizationPermission, generic.UpdateView): +class ProjectTaskView(ChangeView): model = ProjectTask @@ -152,7 +153,7 @@ class ProjectTaskView(OrganizationPermission, generic.UpdateView): -class ProjectTaskDelete(OrganizationPermission, generic.DeleteView): +class ProjectTaskDelete(DeleteView): model = ProjectTask permission_required = [ From 64fd8b56868adf57dcf05466261cd0c328dc063d Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 16 Jul 2024 15:47:44 +0930 Subject: [PATCH 018/321] feat(ui): add project management icon !31 --- app/project_management/templates/icons/project.svg | 1 + app/templates/navigation.html.j2 | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 app/project_management/templates/icons/project.svg diff --git a/app/project_management/templates/icons/project.svg b/app/project_management/templates/icons/project.svg new file mode 100644 index 00000000..4eda42b4 --- /dev/null +++ b/app/project_management/templates/icons/project.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/navigation.html.j2 b/app/templates/navigation.html.j2 index 6db7d474..b0b147a2 100644 --- a/app/templates/navigation.html.j2 +++ b/app/templates/navigation.html.j2 @@ -36,6 +36,8 @@ span.navigation_icon { {% include 'icons/itim.svg' %} {% elif group.name == 'Settings' %} {% include 'icons/settings.svg' %} + {% elif group.name == 'Project Management' %} + {% include 'icons/project.svg' %} {% endif %}
{{ group.name }} From 73ca0feb55ff2674bf53574477d5eb872a194b11 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 20 Aug 2024 22:48:13 +0930 Subject: [PATCH 019/321] chore(core): Add initial ticket model file ref: #252 #250 --- app/core/models/ticket/ticket.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 app/core/models/ticket/ticket.py diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py new file mode 100644 index 00000000..e80ef5c6 --- /dev/null +++ b/app/core/models/ticket/ticket.py @@ -0,0 +1,37 @@ +from django.db import models + +from access.models import TenancyObject + + + +class TicketCommonFields(models.Model): + + class Meta: + abstract = True + + id = models.AutoField( + blank=False, + help_text = 'Ticket ID Number', + primary_key=True, + unique=True, + verbose_name = 'Number', + ) + + created = AutoCreatedField() + + modified = AutoCreatedField() + + + +class Ticket( + TenancyObject, + TicketCommonFields, +): + + + class Meta: + + ordering = [ + 'id' + ] + From 91d85d93c7711f392b79e62040344cc1469ba05d Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 21 Aug 2024 00:51:48 +0930 Subject: [PATCH 020/321] chore(core): Add initial comment model file ref: #252 #250 --- app/core/models/comment.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 app/core/models/comment.py diff --git a/app/core/models/comment.py b/app/core/models/comment.py new file mode 100644 index 00000000..3b329771 --- /dev/null +++ b/app/core/models/comment.py @@ -0,0 +1,38 @@ +from django.db import models + +from access.models import TenancyObject + + + +class CommentCommonFields(models.Model): + + class Meta: + abstract = True + + id = models.AutoField( + blank=False, + help_text = 'Comment ID Number', + primary_key=True, + unique=True, + verbose_name = 'Number', + ) + + created = AutoCreatedField() + + modified = AutoCreatedField() + + + +class Comment( + TenancyObject, + CommentCommonFields, +): + + + class Meta: + + ordering = [ + 'ticket', + 'created', + 'id', + ] From b8c4a540fa2403fb245d00fa7ed33f6c07100949 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 24 Aug 2024 17:00:59 +0930 Subject: [PATCH 021/321] fix(core): ensure is_global check does not process null value ref: #252 --- app/core/forms/common.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/core/forms/common.py b/app/core/forms/common.py index b88c64c0..9f243986 100644 --- a/app/core/forms/common.py +++ b/app/core/forms/common.py @@ -76,11 +76,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: From cb9c782d0c4a7fe40a308dfc922a5a801753c150 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 24 Aug 2024 17:03:01 +0930 Subject: [PATCH 022/321] chore(base): remove dev apps from debug added urls ref: #252 --- app/app/settings.py | 2 -- app/app/urls.py | 4 ---- 2 files changed, 6 deletions(-) diff --git a/app/app/settings.py b/app/app/settings.py index 27ca8e51..deac2587 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -360,8 +360,6 @@ if DEBUG: # Apps Under Development INSTALLED_APPS += [ - 'project_management.apps.ProjectManagementConfig', - 'information.apps.InformationConfig', ] diff --git a/app/app/urls.py b/app/app/urls.py index 3c4501a2..4d9130b4 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -74,10 +74,6 @@ if settings.DEBUG: urlpatterns += [ path("__debug__/", include("debug_toolbar.urls"), name='_debug'), - path("project_management/", include("project_management.urls")), - # Apps Under Development - path("itim/", include("itim.urls")), - path("information/", include("information.urls")), ] # must be after above From 52db44eac70e5456bf880133654f0a5c4c7b0eca Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 25 Aug 2024 11:31:43 +0930 Subject: [PATCH 023/321] feat(development): add option for including additional stylesheets ref: #252 --- app/templates/base.html.j2 | 1 + 1 file changed, 1 insertion(+) diff --git a/app/templates/base.html.j2 b/app/templates/base.html.j2 index 7e74b0fe..603f485e 100644 --- a/app/templates/base.html.j2 +++ b/app/templates/base.html.j2 @@ -14,6 +14,7 @@ + {% block additional-stylesheet %}{% endblock additional-stylesheet %} {% endif %} From c5a5c393a8e83425a73a84650ae0dc7699201903 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 25 Aug 2024 17:45:55 +0930 Subject: [PATCH 024/321] feat(core): add basic ticketing system ref: #250 #252 #96 #93 #95 #90 #115 --- app/app/urls.py | 5 +- app/core/forms/related_ticket.py | 37 + app/core/forms/ticket.py | 139 ++++ app/core/forms/ticket_comment.py | 137 ++++ ...005_ticket_relatedtickets_ticketcomment.py | 110 +++ app/core/models/__init__.py | 0 app/core/models/ticket/__init__.py | 1 + app/core/models/ticket/ticket.py | 720 +++++++++++++++++- app/core/models/ticket/ticket_comment.py | 388 ++++++++++ app/core/templates/core/ticket.html.j2 | 142 ++++ .../templates/core/ticket/comment.html.j2 | 55 ++ .../core/ticket/comment/comment.html.j2 | 117 +++ app/core/templates/core/ticket/index.html.j2 | 55 ++ app/core/templates/icons/ticket/add.svg | 1 + app/core/templates/icons/ticket/edit.svg | 1 + app/core/templates/icons/ticket/expanded.svg | 1 + .../templates/icons/ticket/notification.svg | 1 + app/core/templates/icons/ticket/reply.svg | 1 + app/core/templates/icons/ticket/task.svg | 1 + .../icons/ticket/ticket_blocked_by.svg | 1 + .../templates/icons/ticket/ticket_blocks.svg | 1 + .../templates/icons/ticket/ticket_related.svg | 1 + app/core/templatetags/markdown.py | 2 +- app/core/templatetags/tickets.py | 13 + .../tests/unit/ticket/test_ticket_common.py | 63 ++ .../test_ticket_comment_common.py | 25 + app/core/views/related_ticket.py | 81 ++ app/core/views/ticket.py | 188 +++++ app/core/views/ticket_comment.py | 97 +++ app/project-static/code.css | 2 +- app/project-static/ticketing.css | 477 ++++++++++++ app/project_management/models/projects.py | 5 + app/templates/icons/place-holder.svg | 1 + 33 files changed, 2865 insertions(+), 4 deletions(-) create mode 100644 app/core/forms/related_ticket.py create mode 100644 app/core/forms/ticket.py create mode 100644 app/core/forms/ticket_comment.py create mode 100644 app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py create mode 100644 app/core/models/__init__.py create mode 100644 app/core/models/ticket/__init__.py create mode 100644 app/core/models/ticket/ticket_comment.py create mode 100644 app/core/templates/core/ticket.html.j2 create mode 100644 app/core/templates/core/ticket/comment.html.j2 create mode 100644 app/core/templates/core/ticket/comment/comment.html.j2 create mode 100644 app/core/templates/core/ticket/index.html.j2 create mode 100644 app/core/templates/icons/ticket/add.svg create mode 100644 app/core/templates/icons/ticket/edit.svg create mode 100644 app/core/templates/icons/ticket/expanded.svg create mode 100644 app/core/templates/icons/ticket/notification.svg create mode 100644 app/core/templates/icons/ticket/reply.svg create mode 100644 app/core/templates/icons/ticket/task.svg create mode 100644 app/core/templates/icons/ticket/ticket_blocked_by.svg create mode 100644 app/core/templates/icons/ticket/ticket_blocks.svg create mode 100644 app/core/templates/icons/ticket/ticket_related.svg create mode 100644 app/core/templatetags/tickets.py create mode 100644 app/core/tests/unit/ticket/test_ticket_common.py create mode 100644 app/core/tests/unit/ticket_comment/test_ticket_comment_common.py create mode 100644 app/core/views/related_ticket.py create mode 100644 app/core/views/ticket.py create mode 100644 app/core/views/ticket_comment.py create mode 100644 app/project-static/ticketing.css create mode 100644 app/templates/icons/place-holder.svg diff --git a/app/app/urls.py b/app/app/urls.py index 4d9130b4..3c63c075 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -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 from settings.views import user_settings @@ -50,6 +50,9 @@ urlpatterns = [ path("history//", history.View.as_view(), name='_history'), re_path(r'^static/(?P.*)$', serve,{'document_root': settings.STATIC_ROOT}), + + path('ticket///relate/add', related_ticket.Add.as_view(), name="_ticket_related_add"), + ] diff --git a/app/core/forms/related_ticket.py b/app/core/forms/related_ticket.py new file mode 100644 index 00000000..9933f8a1 --- /dev/null +++ b/app/core/forms/related_ticket.py @@ -0,0 +1,37 @@ +from django import forms +from django.db.models import Q + +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() + + return is_valid diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py new file mode 100644 index 00000000..89d57e60 --- /dev/null +++ b/app/core/forms/ticket.py @@ -0,0 +1,139 @@ +from django import forms +from django.db.models import Q + +from app import settings + +from core.forms.common import CommonModelForm + +from core.models.ticket.ticket import Ticket, RelatedTickets + + + +class TicketForm(CommonModelForm): + + prefix = 'ticket' + + class Meta: + model = Ticket + fields = '__all__' + + + def __init__(self, *args, **kwargs): + + 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['ticket_type'].widget = self.fields['ticket_type'].hidden_widget() + + + 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' + + 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 + + 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 + + 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 + + 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 + + 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 + + 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.fields['ticket_type'].initial = self.Meta.model.TicketType.PROJECT_TASK + + + if kwargs['user'].is_superuser: + + ticket_type += self.Meta.model.tech_fields + + + for field in original_fields: + + if field not in ticket_type: + + del self.fields[field] + + def clean(self): + + cleaned_data = super().clean() + + return cleaned_data + + def is_valid(self) -> bool: + + is_valid = super().is_valid() + + return is_valid + + + +class DetailForm(CommonModelForm): + + prefix = 'ticket' + + class Meta: + model = Ticket + fields = '__all__' diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py new file mode 100644 index 00000000..05e458aa --- /dev/null +++ b/app/core/forms/ticket_comment.py @@ -0,0 +1,137 @@ +from django import forms +from django.db.models import Q + +from app import settings + +from core.forms.common import CommonModelForm + +from core.models.ticket.ticket_comment import TicketComment + + +class CommentForm(CommonModelForm): + + prefix = 'ticket' + + class Meta: + model = TicketComment + fields = '__all__' + + + def __init__(self, *args, **kwargs): + + 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['body'].widget.attrs = {'style': "height: 800px; width: 900px"} + + 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() + + if 'qs_comment_type' in kwargs['initial']: + + comment_type = kwargs['initial']['qs_comment_type'] + + else: + + comment_type = str(self.instance.get_comment_type_display()).lower() + + + original_fields = self.fields.copy() + comment_fields = [] + + + if ( + kwargs['initial']['type_ticket'] == 'request' + or + kwargs['initial']['type_ticket'] == 'incident' + or + kwargs['initial']['type_ticket'] == 'problem' + or + kwargs['initial']['type_ticket'] == 'change' + or + kwargs['initial']['type_ticket'] == 'project_task' + ): + + if comment_type == 'task': + + comment_fields = self.Meta.model.fields_itsm_task + + self.fields['comment_type'].initial = self.Meta.model.CommentType.TASK + + elif comment_type == 'comment': + + comment_fields = self.Meta.model.common_itsm_fields + + self.fields['comment_type'].initial = self.Meta.model.CommentType.COMMENT + + + elif comment_type == 'solution': + + comment_fields = self.Meta.model.common_itsm_fields + + self.fields['comment_type'].initial = self.Meta.model.CommentType.SOLUTION + + elif comment_type == 'notification': + + comment_fields = self.Meta.model.fields_itsm_notification + + self.fields['comment_type'].initial = self.Meta.model.CommentType.NOTIFICATION + + elif kwargs['initial']['type_ticket'] == 'issue': + + comment_fields = self.Meta.model.fields_git_issue + + elif kwargs['initial']['type_ticket'] == 'merge': + + comment_fields = self.Meta.model.fields_git_merge + + + for field in original_fields: + + if field not in comment_fields: + + del self.fields[field] + + def clean(self): + + cleaned_data = super().clean() + + return cleaned_data + + def is_valid(self) -> bool: + + is_valid = super().is_valid() + + return is_valid + + + +class DetailForm(CommentForm): + + prefix = 'ticket' + + class Meta: + model = TicketComment + fields = '__all__' + + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py new file mode 100644 index 00000000..4578a2e4 --- /dev/null +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -0,0 +1,110 @@ +# Generated by Django 5.0.7 on 2024-08-25 07:44 + +import access.fields +import access.models +import core.models.ticket.change_ticket +import core.models.ticket.markdown +import core.models.ticket.problem_ticket +import core.models.ticket.request_ticket +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'), + ('project_management', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + 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.AutoCreatedField(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')], default=2, help_text='Status of ticket', verbose_name='Status')), + ('title', models.CharField(help_text='Title of the Ticket', max_length=50, unique=True, verbose_name='Title')), + ('description', models.TextField(default=None, 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')), + ('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, default=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, default=True, help_text='Assign the ticket to a User(s)', related_name='assigned_users', to=settings.AUTH_USER_MODEL, verbose_name='Assigned User(s)')), + ('opened_by', 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')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('project', models.ForeignKey(blank=True, help_text='Assign to a project', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='project_management.project', verbose_name='Project')), + ('subscribed_teams', models.ManyToManyField(blank=True, default=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)')), + ('subscribed_users', models.ManyToManyField(blank=True, default=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)')), + ], + 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')], + }, + bases=(models.Model, core.models.ticket.change_ticket.ChangeTicket, core.models.ticket.problem_ticket.ProblemTicket, core.models.ticket.request_ticket.RequestTicket, core.models.ticket.markdown.TicketMarkdown), + ), + 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])), + ('from_ticket_id', models.ForeignKey(help_text='This Ticket', on_delete=django.db.models.deletion.CASCADE, related_name='from_ticket_id', to='core.ticket', verbose_name='Ticket')), + ('to_ticket_id', 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')), + ], + options={ + 'ordering': ['id'], + }, + ), + migrations.CreateModel( + name='TicketComment', + fields=[ + ('id', models.AutoField(help_text='Comment ID Number', primary_key=True, serialize=False, unique=True, verbose_name='Number')), + ('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(default=None, help_text='Comment contents', verbose_name='Comment')), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoCreatedField(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')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('parent', 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')), + ('responsible_team', 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')), + ('responsible_user', 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')), + ('template', models.ForeignKey(blank=True, default=None, help_text='Comment Template to use', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_template', to='core.ticketcomment', verbose_name='Template')), + ('ticket', models.ForeignKey(blank=True, default=None, help_text='Parent ID for creating discussion threads', null=True, on_delete=django.db.models.deletion.CASCADE, to='core.ticket', validators=[core.models.ticket.ticket_comment.TicketComment.validation_ticket_id], verbose_name='Parent Comment')), + ('user', models.ForeignKey(help_text='Who made the comment', on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_user', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Comment', + 'verbose_name_plural': 'Comments', + 'ordering': ['ticket', 'parent_id'], + }, + bases=(models.Model, core.models.ticket.markdown.TicketMarkdown), + ), + ] diff --git a/app/core/models/__init__.py b/app/core/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/core/models/ticket/__init__.py b/app/core/models/ticket/__init__.py new file mode 100644 index 00000000..b9742821 --- /dev/null +++ b/app/core/models/ticket/__init__.py @@ -0,0 +1 @@ +from . import * \ No newline at end of file diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index e80ef5c6..ff4d35de 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -1,6 +1,99 @@ +from django.contrib.auth.models import User from django.db import models +from django.db.models import Q +from django.forms import ValidationError -from access.models import TenancyObject +from access.fields import AutoCreatedField +from access.models import TenancyObject, Team + +from .change_ticket import ChangeTicket +from .markdown import TicketMarkdown +from .problem_ticket import ProblemTicket +from .request_ticket import RequestTicket + +from project_management.models.projects import Project + + + +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' @@ -26,6 +119,10 @@ class TicketCommonFields(models.Model): class Ticket( TenancyObject, TicketCommonFields, + ChangeTicket, + ProblemTicket, + RequestTicket, + TicketMarkdown, ): @@ -35,3 +132,624 @@ class Ticket( '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'), + ] + + verbose_name = "Ticket" + + verbose_name_plural = "Tickets" + + + + class Ticket_ExternalSystem(models.IntegerChoices): # + GITHUB = '1', 'Github' + GITLAB = '2', 'Gitlab' + + CUSTOM_1 = '9999', 'Custom #1 (Imported)' + CUSTOM_2 = '9998', 'Custom #2 (Imported)' + CUSTOM_3 = '9997', 'Custom #3 (Imported)' + CUSTOM_4 = '9996', 'Custom #4 (Imported)' + CUSTOM_5 = '9995', 'Custom #5 (Imported)' + CUSTOM_6 = '9994', 'Custom #6 (Imported)' + CUSTOM_7 = '9993', 'Custom #7 (Imported)' + CUSTOM_8 = '9992', 'Custom #8 (Imported)' + CUSTOM_9 = '9991', 'Custom #9 (Imported)' + + + + class TicketStatus: # + """ Ticket Status + + Status of the ticket. By design, not all statuses are available for ALL ticket types. + + ## Request / Incident ticket + + - Draft + - New + - Assigned + - Assigned (Planned) + - Pending + - Solved + - Closed + + + ## Problem Ticket + + - Draft + - New + - Accepted + - Assigned + - Assigned (Planned) + - Pending + - Solved + - Under Observation + - Closed + + ## Change Ticket + + - Draft + - New + - Evaluation + - Approvals + - Accepted + - Pending + - Testing + - Qualification + - Applied + - Review + - Closed + - Cancelled + - Refused + + """ + + class Request(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + SOLVED = TicketValues._SOLVED_INT, TicketValues._SOLVED_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + + class Incident(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + SOLVED = TicketValues._SOLVED_INT, TicketValues._SOLVED_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + + class Problem(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ACCEPTED = TicketValues._ACCEPTED_INT, TicketValues._ACCEPTED_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + SOLVED = TicketValues._SOLVED_INT, TicketValues._SOLVED_STR + OBSERVATION = TicketValues._OBSERVATION_INT, TicketValues._OBSERVATION_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + + class Change(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + EVALUATION = TicketValues._EVALUATION_INT, TicketValues._EVALUATION_STR + APPROVALS = TicketValues._APPROVALS_INT, TicketValues._APPROVALS_STR + ACCEPTED = TicketValues._ACCEPTED_INT, TicketValues._ACCEPTED_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + TESTING = TicketValues._TESTING_INT, TicketValues._TESTING_STR + QUALIFICATION = TicketValues._QUALIFICATION_INT, TicketValues._QUALIFICATION_STR + APPLIED = TicketValues._APPLIED_INT, TicketValues._APPLIED_STR + REVIEW = TicketValues._REVIEW_INT, TicketValues._REVIEW_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + CANCELLED = TicketValues._CANCELLED_INT, TicketValues._CANCELLED_STR + REFUSED = TicketValues._REFUSED_INT, TicketValues._REFUSED_STR + + + class Git(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + class ProjectTask(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + SOLVED = TicketValues._SOLVED_INT, TicketValues._SOLVED_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + + + + class TicketType(models.IntegerChoices): + """Type of the ticket""" + + REQUEST = '1', 'Request' + INCIDENT = '2', 'Incident' + CHANGE = '3', 'Change' + PROBLEM = '4', 'Problem' + ISSUE = '5', 'Issue' + MERGE_REQUEST = '6', 'Merge Request' + PROJECT_TASK = '7', 'Project Task' + + + + class TicketUrgency(models.IntegerChoices): # + VERY_LOW = '1', 'Very Low' + LOW = '2', 'Low' + MEDIUM = '3', 'Medium' + HIGH = '4', 'High' + VERY_HIGH = '5', 'Very High' + + + + class TicketImpact(models.IntegerChoices): + VERY_LOW = '1', 'Very Low' + LOW = '2', 'Low' + MEDIUM = '3', 'Medium' + HIGH = '4', 'High' + VERY_HIGH = '5', 'Very High' + + + + class TicketPriority(models.IntegerChoices): + VERY_LOW = '1', 'Very Low' + LOW = '2', 'Low' + MEDIUM = '3', 'Medium' + HIGH = '4', 'High' + VERY_HIGH = '5', 'Very High' + MAJOR = '6', 'Major' + + + + def validation_ticket_type(field): + + if not field: + raise ValidationError('Ticket Type must be set') + + + def validation_title(field): + + if not field: + raise ValueError + + + model_notes = None + + is_global = None + + + status = models.IntegerField( # will require validation by ticket type as status for types will be different + blank = False, + choices=TicketStatus.Request, + default = TicketStatus.Request.NEW, + help_text = 'Status of ticket', + # null=True, + verbose_name = 'Status', + ) + + # category = models.CharField( + # blank = False, + # help_text = "Category of the Ticket", + # max_length = 50, + # unique = True, + # verbose_name = 'Category', + # ) + + title = models.CharField( + blank = False, + help_text = "Title of the Ticket", + max_length = 50, + unique = True, + verbose_name = 'Title', + ) + + description = models.TextField( + blank = False, + default = None, + help_text = 'Ticket Description', + null = False, + verbose_name = 'Description', + ) # text, markdown + + + urgency = models.IntegerField( + blank = True, + choices=TicketUrgency, + default=TicketUrgency.VERY_LOW, + help_text = 'How urgent is this tickets resolution for the user?', + null=True, + verbose_name = 'Urgency', + ) + + impact = models.IntegerField( + blank = True, + choices=TicketImpact, + default=TicketImpact.VERY_LOW, + help_text = 'End user assessed impact', + null=True, + verbose_name = 'Impact', + ) + + priority = models.IntegerField( + blank = True, + choices=TicketPriority, + default=TicketPriority.VERY_LOW, + 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 reference or null. i.e. github issue number + + + external_system = models.IntegerField( + blank = True, + choices=Ticket_ExternalSystem, + default=None, + help_text = 'External system this item derives', + null=True, + verbose_name = 'External System', + ) + + + ticket_type = models.IntegerField( + blank = False, + choices=TicketType, + help_text = 'The type of ticket this is', + validators = [ validation_ticket_type ], + verbose_name = 'Type', + ) + + + project = models.ForeignKey( + Project, + blank= True, + help_text = 'Assign to a project', + null = True, + on_delete = models.DO_NOTHING, + verbose_name = 'Project', + ) + + + opened_by = models.ForeignKey( + User, + blank= False, + help_text = 'Who is the ticket for', + null = False, + on_delete = models.DO_NOTHING, + related_name = 'opened_by', + verbose_name = 'Opened By', + ) + + + subscribed_users = models.ManyToManyField( + User, + blank= True, + help_text = 'Subscribe a User(s) to the ticket to receive updates', + related_name = 'subscribed_users', + symmetrical = False, + verbose_name = 'Subscribed User(s)', + ) + + + subscribed_teams = models.ManyToManyField( + Team, + blank= True, + help_text = 'Subscribe a Team(s) to the ticket to receive updates', + related_name = 'subscribed_teams', + symmetrical = False, + verbose_name = 'Subscribed Team(s)', + ) + + assigned_users = models.ManyToManyField( + User, + blank= True, + help_text = 'Assign the ticket to a User(s)', + related_name = 'assigned_users', + symmetrical = False, + verbose_name = 'Assigned User(s)', + ) + + assigned_teams = models.ManyToManyField( + Team, + blank= True, + help_text = 'Assign the ticket to a Team(s)', + related_name = 'assigned_teams', + symmetrical = False, + verbose_name = 'Assigned Team(s)', + ) + + is_deleted = models.BooleanField( + blank = False, + default = False, + help_text = 'Is the ticket deleted? And ready to be purged', + null = False, + 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', + ) + + 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', + ) + + + # ?? date_edit date of last edit + + def __str__(self): + + return self.title + + common_fields: list(str()) = [ + 'organization', + 'title', + 'description', + 'opened_by', + 'ticket_type' + ] + + common_itsm_fields: list(str()) = common_fields + [ + 'urgency', + + ] + + fields_itsm_request: list(str()) = common_itsm_fields + [ + + ] + + 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', + ] + + tech_fields = [ + 'category', + 'project', + 'assigned_users', + 'assigned_teams', + 'subscribed_teams', + 'subscribed_users', + 'status', + 'urgency', + 'impact', + 'priority', + 'planned_start_date', + 'planned_finish_date', + ] + + + @property + def markdown_description(self) -> str: + + return self.render_markdown(self.description) + + @property + def related_tickets(self) -> list(dict()): + + related_tickets: list() = [] + + query = RelatedTickets.objects.filter( + Q(from_ticket_id=self.id) + | + Q(to_ticket_id=self.id) + ) + + for related_ticket in query: + + + how_related:str = str(related_ticket.get_how_related_display()).lower() + + + if related_ticket.to_ticket_id_id == self.id: + + if str(related_ticket.get_how_related_display()).lower() == 'blocks': + + how_related = 'blocked by' + + elif str(related_ticket.get_how_related_display()).lower() == 'blocked by': + + how_related = 'blocks' + + + related_tickets += [ + { + 'id': related_ticket.id, + 'type': related_ticket.to_ticket_id.get_ticket_type_display().lower(), + 'title': related_ticket.to_ticket_id.title, + 'how_related': how_related.replace(' ', '_'), + 'icon_filename': str('icons/ticket/ticket_' + how_related.replace(' ', '_') + '.svg') + } + ] + + return related_tickets + + + @property + def comments(self): + + from core.models.ticket.ticket_comment import TicketComment + + return TicketComment.objects.filter( + ticket = self.id, + parent = None, + ) + + + +class RelatedTickets(TenancyObject): + + class Meta: + + ordering = [ + 'id' + ] + + class Related(models.IntegerChoices): + RELATED = '1', 'Related' + + BLOCKS = '2', 'Blocks' + + BLOCKED_BY = '3', 'Blocked By' + + is_global = None + + model_notes = None + + id = models.AutoField( + blank=False, + help_text = 'Ticket ID Number', + primary_key=True, + unique=True, + verbose_name = 'Number', + ) + + from_ticket_id = models.ForeignKey( + Ticket, + blank= False, + help_text = 'This Ticket', + null = False, + on_delete = models.CASCADE, + related_name = 'from_ticket_id', + verbose_name = 'Ticket', + ) + + how_related = models.IntegerField( + blank = False, + choices = Related, + help_text = 'How is the ticket related', + verbose_name = 'How Related', + ) + + to_ticket_id = models.ForeignKey( + Ticket, + blank= False, + help_text = 'The Related Ticket', + null = False, + on_delete = models.CASCADE, + related_name = 'to_ticket_id', + verbose_name = 'Related Ticket', + ) diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py new file mode 100644 index 00000000..15588a60 --- /dev/null +++ b/app/core/models/ticket/ticket_comment.py @@ -0,0 +1,388 @@ +from django.contrib.auth.models import User +from django.db import models +from django.forms import ValidationError + +from access.fields import AutoCreatedField +from access.models import TenancyObject, Team + +from .markdown import TicketMarkdown +from .ticket import Ticket + + + +class TicketComment( + TenancyObject, + TicketMarkdown, +): + + + class Meta: + + ordering = [ + 'ticket', + 'parent_id' + ] + + 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): + """Type of Comment + + 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 = 'Parent ID for creating discussion threads', + null = True, + on_delete = models.CASCADE, + validators = [ validation_ticket_id ], + verbose_name = 'Parent Comment', + ) + + 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, + default = None, + help_text = 'Comment contents', + null = False, + verbose_name = 'Comment', + ) + + created = AutoCreatedField() + + modified = AutoCreatedField() + + 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.CharField( + # blank = False, + # help_text = "Category of the Ticket", + # max_length = 50, + # unique = True, + # verbose_name = 'Category', + # ) + + template = models.ForeignKey( + 'self', + blank= True, + default = None, + help_text = 'Comment Template to use', + null = True, + on_delete = models.DO_NOTHING, + 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= False, + help_text = 'Who made the comment', + null = False, + 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 markdown_description(self) -> str: + + return self.render_markdown(self.description) + + @property + def comment_template_queryset(self): + + query = TicketComment.objects.filter( + is_template = True, + comment_type = self.comment_type, + ) + + return query + + @property + def threads(self): + + return TicketComment.objects.filter( + parent = self.id + ) diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 new file mode 100644 index 00000000..45f04f35 --- /dev/null +++ b/app/core/templates/core/ticket.html.j2 @@ -0,0 +1,142 @@ +{% extends 'base.html.j2' %} + +{% block additional-stylesheet %} + {% load static %} + +{% endblock additional-stylesheet %} + +{% load tickets %} + +{% block article %} + +
+ +
+ +
+
+ +
+
{{ ticket.description | markdown | safe }}
+
+ +
+ +
+

+
Related Tickets
+ +

+ + {% if ticket.related_tickets %} + {% for related_ticket in ticket.related_tickets %} +
+ +
+ {% 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 %} +   + {{ related_ticket.title }} +
+
+ {% endfor %} + {% else %} +
Nothing Found
+ {% endif %} + +
+ + +
+

+
Linked Items
+
{% include 'icons/place-holder.svg' %}{% include 'icons/place-holder.svg' %}
+

+
+ An item +
+
+ another item +
+
+ another item +
+
+ another item +
+
+ another item +
+
+ another item +
+
+ +
+ + {% include 'core/ticket/comment.html.j2' %} + +
+ + +
+

{{ ticket_type }}

+ +
+ + + {% 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 %} + +
+
+ + {{ticket.get_status_display }} +
+
+ + val +
+
+ + + {% if ticket.project %} + {{ ticket.project }} + {% else %} + - + {% endif %} + +
+
+ + U{{ ticket.get_urgency_display }} / I{{ ticket.get_impact_display }} / P{{ ticket.get_priority_display }} +
+
+ + val +
+
+ + val +
+ +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/core/templates/core/ticket/comment.html.j2 b/app/core/templates/core/ticket/comment.html.j2 new file mode 100644 index 00000000..4a6df4c1 --- /dev/null +++ b/app/core/templates/core/ticket/comment.html.j2 @@ -0,0 +1,55 @@ + + +
+
    +
  • John smith add x as related to this ticket
  • + + {% for comment in ticket.comments %} + +
  • + {% include 'core/ticket/comment/comment.html.j2' %} + + {% if comment.threads %} +
    +

    + Replies + {% include 'icons/ticket/expanded.svg' %} +

    +
    +
    + {% if comment.threads %} + {% for thread in comment.threads %} + + + {% include 'core/ticket/comment/comment.html.j2' with comment=thread %} + + + {% endfor %} + + {% endif %} +
    +
    + + +
    +
    + + {% endif %} +
  • + + {% endfor %} + +
  • Jane smith mentioned this ticket in xx
  • +
  • sdasfdgdfgdfg dfg dfg dfg d
  • +
+ +
+ + + + + + +
+ +
diff --git a/app/core/templates/core/ticket/comment/comment.html.j2 b/app/core/templates/core/ticket/comment/comment.html.j2 new file mode 100644 index 00000000..4998d956 --- /dev/null +++ b/app/core/templates/core/ticket/comment/comment.html.j2 @@ -0,0 +1,117 @@ +{% load tickets %} + +{% if comment %} + +{% if comment.get_comment_type_display == 'Action' %} + + {{ comment.body | markdown | safe }} + +{% 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' %} +
+ +

+
+ {{ comment.user }} + {% if comment.get_comment_type_display == 'Task' %} + created a task + {% elif comment.get_comment_type_display == 'Solution' %} + solved + {% else %} + wrote + {% endif %} + on {{ comment.created }} +
+ + +

+ +
+ {% if comment.get_comment_type_display != 'Notification' %} +
+ + {{ comment.get_source_display }} +
+ {% endif %} + {% if comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' %} +
+ + {{ comment.get_status_display }} +
+ {% if comment.get_comment_type_display == 'Task' %} +
+ + {{ comment.responsible_user }} +
+ {% endif %} +
+ + {{ comment.responsible_team }} +
+ {% endif %} +
+ + {{ comment.category }} +
+
+ +
+ +
+ {{ comment.body | markdown | safe }} +
+ +
+ +
+ {% if comment.get_comment_type_display == 'Task' or comment.get_comment_type_display == 'Notification' %} +
+ + {{ comment.planned_start_date }} +
+ {% if comment.get_comment_type_display == 'Task' %} +
+ + {{ comment.planned_finish_date }} +
+ {% endif %} +
+ + {{ comment.real_start_date }} +
+
+ + {{ comment.real_finish_date }} +
+ {% endif %} +
+ + {{ comment.duration }} +
+
+ +
+ +{% endif %} + +{% endif %} diff --git a/app/core/templates/core/ticket/index.html.j2 b/app/core/templates/core/ticket/index.html.j2 new file mode 100644 index 00000000..443b3bb2 --- /dev/null +++ b/app/core/templates/core/ticket/index.html.j2 @@ -0,0 +1,55 @@ +{% extends 'base.html.j2' %} + +{% block content %} + + + + +
Code
+ + + + + + + + {% for ticket in tickets %} + + + + + + + + {% endfor %} +
 IDTitleStatusCreated
+   + {{ ticket.id }} + {% if ticket_type == 'change' %} + + {% elif ticket_type == 'incident' %} + + {% elif ticket_type == 'problem' %} + + {% elif ticket_type == 'request' %} + + {% else %} + + {% endif %} + {{ ticket.title }} + + {{ ticket.get_status_display }}{{ ticket.created }}
+ +{% endblock %} \ No newline at end of file diff --git a/app/core/templates/icons/ticket/add.svg b/app/core/templates/icons/ticket/add.svg new file mode 100644 index 00000000..33bdf2e0 --- /dev/null +++ b/app/core/templates/icons/ticket/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/edit.svg b/app/core/templates/icons/ticket/edit.svg new file mode 100644 index 00000000..7bd753ae --- /dev/null +++ b/app/core/templates/icons/ticket/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/expanded.svg b/app/core/templates/icons/ticket/expanded.svg new file mode 100644 index 00000000..0924e2cb --- /dev/null +++ b/app/core/templates/icons/ticket/expanded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/notification.svg b/app/core/templates/icons/ticket/notification.svg new file mode 100644 index 00000000..005a5957 --- /dev/null +++ b/app/core/templates/icons/ticket/notification.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/reply.svg b/app/core/templates/icons/ticket/reply.svg new file mode 100644 index 00000000..30dfce28 --- /dev/null +++ b/app/core/templates/icons/ticket/reply.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/task.svg b/app/core/templates/icons/ticket/task.svg new file mode 100644 index 00000000..c271e3a9 --- /dev/null +++ b/app/core/templates/icons/ticket/task.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/ticket_blocked_by.svg b/app/core/templates/icons/ticket/ticket_blocked_by.svg new file mode 100644 index 00000000..2885488d --- /dev/null +++ b/app/core/templates/icons/ticket/ticket_blocked_by.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/ticket_blocks.svg b/app/core/templates/icons/ticket/ticket_blocks.svg new file mode 100644 index 00000000..771a1c65 --- /dev/null +++ b/app/core/templates/icons/ticket/ticket_blocks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/ticket_related.svg b/app/core/templates/icons/ticket/ticket_related.svg new file mode 100644 index 00000000..de59f944 --- /dev/null +++ b/app/core/templates/icons/ticket/ticket_related.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index 867b2f80..35e00bcc 100644 --- a/app/core/templatetags/markdown.py +++ b/app/core/templatetags/markdown.py @@ -9,4 +9,4 @@ register = template.Library() @register.filter() @stringfilter def markdown(value): - return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) \ No newline at end of file + return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) diff --git a/app/core/templatetags/tickets.py b/app/core/templatetags/tickets.py new file mode 100644 index 00000000..49e3e7ca --- /dev/null +++ b/app/core/templatetags/tickets.py @@ -0,0 +1,13 @@ +from django import template +from django.template.defaultfilters import stringfilter + +import markdown as md + +register = template.Library() + + +@register.filter() +@stringfilter +def markdown(value): + + return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) diff --git a/app/core/tests/unit/ticket/test_ticket_common.py b/app/core/tests/unit/ticket/test_ticket_common.py new file mode 100644 index 00000000..3a336da5 --- /dev/null +++ b/app/core/tests/unit/ticket/test_ticket_common.py @@ -0,0 +1,63 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import ModelDisplay, ModelIndex + + + +class TicketCommon( + TestCase +): + + def text_ticket_field_type_opened_by(self): + """Ensure field is of a certain type + + opened_by_field must be of type int + """ + pass + + def text_ticket_field_value_not_null_opened_by(self): + """Ensure field is not null + + opened_by_field must be set and not null + """ + pass + + + def text_ticket_field_value_auto_set_opened_by(self): + """Ensure field is auto set within code + + opened_by_field must be set by code with non-tech user not being able to change + """ + pass + + + def text_ticket_field_value_tech_set_opened_by(self): + """Ensure field can be set by a technician + + opened_by_field can be set by a technician + """ + pass + + + + def text_ticket_type_fields(self): + """Placeholder test + + following tests to be written: + + - only tech can change tech fields (same org) + - non-tech cant see tech fields (same org) during creation + - non-tech cant change tech fields (same org) + - only tech can change tech fields (different org) + - non-tech cant see tech fields (different org) during creation + - non-tech cant change tech fields (different org) + + - itsm ticket has the itsm related fields + - non-itsm ticket does not have any itsm related fields + + """ + pass diff --git a/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py b/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py new file mode 100644 index 00000000..cb41b7fa --- /dev/null +++ b/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py @@ -0,0 +1,25 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import ModelDisplay, ModelIndex + +from core.models.ticket.ticket_comment import TicketComment + + + +class TicketCommentCommon( + TestCase +): + + model = TicketComment + + + def text_ticket_field_type_opened_by(self): + """Replies to comments only to occur on primary comment + + If a comment has a 'parent_id' set, ensure the comment can't be replied to + """ + pass diff --git a/app/core/views/related_ticket.py b/app/core/views/related_ticket.py new file mode 100644 index 00000000..e20943ff --- /dev/null +++ b/app/core/views/related_ticket.py @@ -0,0 +1,81 @@ +import markdown + +from django.urls import reverse +from django.views import generic + +from django_celery_results.models import TaskResult + +from access.mixin import OrganizationPermission + +from core.forms.related_ticket import RelatedTicketForm +from core.models.ticket.ticket import RelatedTickets +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = RelatedTicketForm + + model = RelatedTickets + + permission_required = [ + 'itam.add_device', + ] + + template_name = 'form.html.j2' + + + def get_initial(self): + + initial_values: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization, + 'from_ticket_id': self.kwargs['ticket_id'], + } + + return initial_values + + + def get_success_url(self, **kwargs): + + if self.kwargs['ticket_type'] == 'request': + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.object.id,)) + + else: + + return reverse('ITIM:_ticket_' + str(self.kwargs['ticket_type']).lower() + '_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Ticket Comment' + + return context + + + +class Delete(DeleteView): + + model = RelatedTickets + + permission_required = [ + 'itim.delete_cluster', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + str(self.object) + + return context + + + def get_success_url(self, **kwargs): + + return reverse('ITIM:Clusters') diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py new file mode 100644 index 00000000..c794f04f --- /dev/null +++ b/app/core/views/ticket.py @@ -0,0 +1,188 @@ +import markdown + +from django.http import Http404 +from django.urls import reverse +from django.views import generic + +from django_celery_results.models import TaskResult + +from access.mixin import OrganizationPermission + +from core.forms.ticket import DetailForm, TicketForm +from core.models.ticket.ticket import Ticket +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = TicketForm + + model = Ticket + permission_required = [ + 'itam.add_device', + ] + template_name = 'form.html.j2' + + + def get_initial(self): + return { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization, + 'type_ticket': self.kwargs['ticket_type'], + } + + def form_valid(self, form): + form.instance.is_global = False + return super().form_valid(form) + + + def get_success_url(self, **kwargs): + + if self.kwargs['ticket_type'] == 'request': + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.object.id,)) + + else: + + return reverse('ITIM:_ticket_' + str(self.kwargs['ticket_type']).lower() + '_view', args=(self.kwargs['ticket_type'],self.object.id,)) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'New Ticket' + + return context + + + +class Change(ChangeView): + + form_class = TicketForm + + model = Ticket + + permission_required = [ + 'itim.change_cluster', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = str(self.object) + + return context + + + def get_initial(self): + return { + 'type_ticket': self.kwargs['ticket_type'], + } + + + def get_success_url(self, **kwargs): + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'], self.kwargs['pk'],)) + + + +class Index(OrganizationPermission, generic.ListView): + + context_object_name = "tickets" + + fields = [ + "id", + 'title', + 'status', + 'date_created', + ] + + model = Ticket + + permission_required = [ + 'django_celery_results.view_taskresult', + ] + + template_name = 'core/ticket/index.html.j2' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + if self.kwargs['ticket_type'] == 'request': + + context['new_ticket_url'] = reverse('Assistance:_ticket_request_add', args=(self.kwargs['ticket_type'],)) + + else: + + context['new_ticket_url'] = reverse(str('ITIM:_ticket_' + self.kwargs['ticket_type'] + '_add'), args=(self.kwargs['ticket_type'],)) + + + context['ticket_type'] = self.kwargs['ticket_type'] + + context['content_title'] = 'Tickets' + + return context + + + def get_queryset(self): + + if not hasattr(Ticket.TicketType, str(self.kwargs['ticket_type']).upper()): + raise Http404 + + queryset = super().get_queryset() + + queryset = queryset.filter( + ticket_type = Ticket.TicketType[str(self.kwargs['ticket_type']).upper()] + ) + + return queryset + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_device_model_view', args=(self.kwargs['pk'],)) + + + +class View(ChangeView): + + model = Ticket + + permission_required = [ + 'itam.view_device', + ] + + template_name = 'core/ticket.html.j2' + + form_class = DetailForm + + context_object_name = "ticket" + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + + context['ticket_type'] = self.kwargs['ticket_type'] + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.verbose_name.replace(' ', '') + + # context['model_delete_url'] = reverse('ITAM:_device_delete', args=(self.kwargs['pk'],)) + + context['edit_url'] = reverse('Assistance:_ticket_request_change', args=(self.kwargs['ticket_type'], self.kwargs['pk'])) #/assistance/ticket/{{ ticket_type }}/{{ ticket.id }} + + context['content_title'] = self.object.title + + return context + + + def get_initial(self): + return { + 'type_ticket': self.kwargs['ticket_type'], + } diff --git a/app/core/views/ticket_comment.py b/app/core/views/ticket_comment.py new file mode 100644 index 00000000..1f486ffd --- /dev/null +++ b/app/core/views/ticket_comment.py @@ -0,0 +1,97 @@ +import markdown + +from django.urls import reverse +from django.views import generic + +from django_celery_results.models import TaskResult + +from access.mixin import OrganizationPermission + +from core.forms.ticket_comment import CommentForm, DetailForm +from core.models.ticket.ticket_comment import TicketComment +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = CommentForm + + model = TicketComment + permission_required = [ + 'itam.add_device', + ] + template_name = 'form.html.j2' + + + def get_initial(self): + + initial_values: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization, + 'type_ticket': self.kwargs['ticket_type'], + 'ticket': self.kwargs['ticket_id'], + } + + if 'comment_type' in self.request.GET: + + initial_values.update({ + 'qs_comment_type': self.request.GET['comment_type'] + }) + + if 'parent_id' in self.kwargs: + + initial_values.update({ + 'parent': self.kwargs['parent_id'] + }) + + return initial_values + + + def get_success_url(self, **kwargs): + + if self.kwargs['ticket_type'] == 'request': + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'])) + + return f"/ticket/" + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Ticket Comment' + + return context + + + +class Change(ChangeView): + + form_class = CommentForm + + model = TicketComment + + permission_required = [ + 'itim.change_cluster', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = str(self.object) + + return context + + + def get_initial(self): + return { + 'type_ticket': self.kwargs['ticket_type'], + } + + + def get_success_url(self, **kwargs): + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'], self.kwargs['ticket_id'],)) diff --git a/app/project-static/code.css b/app/project-static/code.css index c4b2fd9c..5557a014 100644 --- a/app/project-static/code.css +++ b/app/project-static/code.css @@ -4,7 +4,7 @@ span.linenos { color: inherit; background-color: transparent; padding-left: 5px; td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } .codehilite .hll { background-color: #ffffcc } -.codehilite { background: #f8f8f8; } +.codehilite { background: #f8f8f8; padding: 5px; border: 1px solid #ccc;} .codehilite .c { color: #3D7B7B; font-style: italic } /* Comment */ .codehilite .err { border: 1px solid #FF0000 } /* Error */ .codehilite .k { color: #008000; font-weight: bold } /* Keyword */ diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css new file mode 100644 index 00000000..06fd2537 --- /dev/null +++ b/app/project-static/ticketing.css @@ -0,0 +1,477 @@ + +#linked-tickets { + display: table; + padding: 0px; + margin: 0px; +} + +#linked-tickets .ticket { + display: inline-block; + line-height: 30px; + vertical-align: middle; + margin: 0px; + padding: 0px; +} + + +#linked-tickets .ticket svg{ + display: inline; + width: 20px; + height: 30px; + +} + + +#linked-tickets .icon { + display: inline-block; + width: 20px; + line-height: 30px; + vertical-align: middle; + padding: 0px; + margin: 0px; +} + +#linked-tickets .icon svg{ + display: inline; + width: 20px; + height: 30px; + +} + + +#linked-tickets .icon.icon-related svg{ + background-color: #afdbff; + border-radius: 10px; + fill: #0b91ff; + height: 20px; +} + +#linked-tickets .icon.icon-blocks svg{ + background-color: #fcd5b1; + border-radius: 10px; + fill: #e79b37; + height: 20px; +} + +#linked-tickets .icon.icon-blocked_by svg{ + background-color: #f3c6c6; + border-radius: 10px; + fill: #ff1c1c; + height: 20px; +} + + +#ticket-additional-data { + padding-right: 10px; + font-size: 12pt; +} + + +#ticket-additional-data div { + margin-top: 10px; + padding: 0px; +} + + +#ticket-content { + display: flex; + height: auto; + margin: 20px; + padding: 0px; + width: auto; +} + + +#ticket-content div { + display: inline-block; +} + + +#ticket-data { + margin: 0px; + padding: 0px; + width: 100%; +} + + +#ticket-data div { + display: block; +} + + +#ticket-description { + background-color: #fff; + border: 1px solid #ccc; + margin: 0px 10px 0px 0px; + padding: 10px; +} + + + +#data-block { + border: 1px solid #ccc; + padding: 0px; +} + + +#data-block h3 button { + float: right; + height: 20px; + margin-bottom: auto; + margin-top: auto; + + +} + + +#data-block div { + margin: 0px; + padding: 0px; + padding-left: 10px; +} + + +#data-block.linked-item div#item { + display: inline-block; + text-align: center; + width: 33%; +} + + +#ticket-comments { + padding: 10px; +} + + +#ticket-comments ul { + padding: 0px; + padding-left: 30px; + margin: 0px; +} + + +#ticket-comments li { + line-height: 30px; + margin: 0px; + margin-bottom: 30px; + padding-left: 10px; +} + + +#data-block h3 { + background-color: #177ee6; + color: #fff; + display: flex; + font-size: 16px; + height: 30px; + line-height: 30px; + margin: 0px; + padding-left: 5px; +} + + +#data-block h3 #text { + height: inherit; + line-height: inherit; + padding: 0px; + width: 100%; + +} + + +#data-block h3 #icons { + height: inherit; + line-height: inherit; + margin-right: 0px; + margin-left: auto; + text-align: right; + width: 200px; +} + +#data-block h3 #icons svg { + height: 30px; + margin: 0px; + margin-right: 5px; + width: 20px; + fill: #fff; +} + + +#ticket-comments #comment { + border: 1px solid #177ee6; +} + +#ticket-comments #comment h4 { + background-color: #177ee6; + border: none; + color: #fff; + display: flex; + font-size: 14px; + height: 30px; + line-height: 30px; + margin: 0px; + padding-left: 5px; +} + + +#comment h4 #text { + height: inherit; + line-height: inherit; + padding: 0px; + text-align: left; + width: 100%; +} + + +#comment h4 #icons { + border: none; + display: inline-block; + height: inherit; + line-height: inherit; + margin-right: 0px; + margin-left: auto; + text-align: right; + width: 200px; +} + + +#ticket-comments #comment h4 #icons svg { + fill: #fff; + height: 30px; + margin: 0px; + margin-right: 5px; + width: 20px; +} + + +#discussion { + margin: 0px; +} + + +#discussion h4 { + border-left: 1px solid #177fe66e; + border-right: 1px solid #177fe66e; + display: block; + margin: 0px; + margin-right: 0px; + margin-left: auto; + height: 30px; + line-height: inherit; + text-align: right; + padding: 0px; +} + + +#discussion svg { + width: 20px; + height: 30px; + margin: 0px; + margin-right: 5px; + margin-left: 5px; +} + + +#ticket-meta { + background-color: #fff; + width: 400px; + border: 1px solid #ccc; + margin-right: 0px; + margin-left: auto; + padding: 0px; +} + + +#ticket-meta h3 { + line-height: 30px; + height: 30px; + margin: 0px; + text-align: center; +} + + +#ticket-meta fieldset { + display: block; + margin: 10px; + line-height: 30px; + border: none; +} + + +#ticket-meta fieldset label { + width: 100%; + display: block; + line-height: inherit; + font-weight: bold; +} + + +#ticket-meta fieldset span { + width: 100%; + display: block; + line-height: inherit; + border: none; + border-bottom: 1px solid #ccc; +} + + +#ticket-meta h3.incident-ticket { + background-color: #f7baba; +} + + +#ticket-meta h3.request-ticket { + background-color: #f7e9ba; +} + + +#ticket-meta h3.change-ticket { + background-color: #badff7; +} + + +#ticket-meta h3.problem-ticket { + background-color: #f7d0ba; +} + + +#ticket-meta h3.issue-ticket { + background-color: #baf7db; +} + + +#ticket-meta h3.project_task-ticket { + background-color: #c5baf7; +} + + +.comment-type-default { + background-color: #fff; +} + + +.comment-type-Notification { + background-color: #96c7ff; +} + + +.comment-type-Solution { + background-color: #b7ff96; +} + + +.comment-type-Task { + background-color: #f8ff96; +} + + +#comment fieldset { + border: none; + display: inline-block; + line-height: 14pt; + width: 200px; + font-size: 10pt; +} + + +#comment fieldset label { + display: block; + font-weight: bold; + line-height: inherit; +} + + +#comment fieldset span { + display: block; + line-height: inherit; + +} + + +#comment hr { + border: none; + border-bottom: 1px solid #ccc; + margin: 0px 5px 0px 5px; +} + + +#ticket-content #markdown h1 { + background-color: inherit; + color: inherit; + font-size: 24px; + line-height: 24px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown h2 { + background-color: inherit; + color: inherit; + font-size: 20px; + line-height: 20px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown h3 { + background-color: inherit; + color: inherit; + font-size: 18px; + line-height: 18px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown h4 { + background-color: inherit; + color: #000; + font-size: 16px; + line-height: 16px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown h5 { + background-color: inherit; + color: #000; + font-size: 14px; + line-height: 14px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown li { + background-color: inherit; + font-size: 14px; + line-height: 25px; + padding: 0px; + margin: 0px; + text-align: left; + +} + + +#ticket-content #markdown p { + background-color: inherit; + font-size: 14px; + line-height: 25px; + padding: 0px; + margin: 0px; + text-align: left; + +} diff --git a/app/project_management/models/projects.py b/app/project_management/models/projects.py index 9424f36a..ece5b785 100644 --- a/app/project_management/models/projects.py +++ b/app/project_management/models/projects.py @@ -100,6 +100,11 @@ class Project(ProjectCommonFieldsName): ) + def __str__(self): + + return self.name + + @property def percent_completed(self) -> str: # Auto-Calculate """ How much of the project is completed. diff --git a/app/templates/icons/place-holder.svg b/app/templates/icons/place-holder.svg new file mode 100644 index 00000000..312a10e7 --- /dev/null +++ b/app/templates/icons/place-holder.svg @@ -0,0 +1 @@ + \ No newline at end of file From 7b3a00786205ab98fd08862c486a54549660b456 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 25 Aug 2024 17:48:17 +0930 Subject: [PATCH 025/321] feat(assistance): Add Request ticket to navigation ref: #250 #252 #96 --- app/assistance/urls.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/assistance/urls.py b/app/assistance/urls.py index 9d0e17be..4eb7bdec 100644 --- a/app/assistance/urls.py +++ b/app/assistance/urls.py @@ -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,13 @@ urlpatterns = [ path("information//delete", knowledge_base.Delete.as_view(), name="_knowledge_base_delete"), path("information/", knowledge_base.View.as_view(), name="_knowledge_base_view"), + path('ticket/request', ticket.Index.as_view(), kwargs={'ticket_type': 'request'}, name="Requests"), + path('ticket//add', ticket.Add.as_view(), name="_ticket_request_add"), + path('ticket///edit', ticket.Change.as_view(), name="_ticket_request_change"), + path('ticket//', ticket.View.as_view(), name="_ticket_request_view"), + + path('ticket///comment/add', ticket_comment.Add.as_view(), name="_ticket_comment_request_add"), + path('ticket///comment//edit', ticket_comment.Change.as_view(), name="_ticket_comment_request_change"), + path('ticket///comment//add', ticket_comment.Add.as_view(), name="_ticket_comment_request_reply_add"), + ] From 31067aab95ce21e9539f0e6d881fc6f17daaa8f9 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 25 Aug 2024 17:51:53 +0930 Subject: [PATCH 026/321] feat(itim): Add Change ticket to navigation ref: #250 #252 #90 --- app/itim/urls.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/itim/urls.py b/app/itim/urls.py index 440d4960..76204cf1 100644 --- a/app/itim/urls.py +++ b/app/itim/urls.py @@ -6,11 +6,13 @@ from itim.views import clusters, services app_name = "ITIM" urlpatterns = [ - path("services", services.Index.as_view(), name="Services"), - path("service/add", services.Add.as_view(), name="_service_add"), - path("service//edit", services.Change.as_view(), name="_service_change"), - path("service//delete", services.Delete.as_view(), name="_service_delete"), - path("service/", services.View.as_view(), name="_service_view"), + path('ticket/change', ticket.Index.as_view(), kwargs={'ticket_type': 'change'}, name="Changes"), + path('ticket//add', ticket.Add.as_view(), name="_ticket_change_add"), + path('ticket///edit', ticket.Change.as_view(), name="_ticket_change_change"), + path('ticket//', ticket.View.as_view(), name="_ticket_change_view"), + path('ticket///comment/add', ticket_comment.Add.as_view(), name="_ticket_comment_change_add"), + path('ticket///comment//edit', ticket_comment.Change.as_view(), name="_ticket_comment_change_change"), + path('ticket///comment//add', ticket_comment.Add.as_view(), name="_ticket_comment_change_reply_add"), path("clusters", clusters.Index.as_view(), name="Clusters"), path("clusters/add", clusters.Add.as_view(), name="_cluster_add"), @@ -18,4 +20,10 @@ urlpatterns = [ path("clusters//delete", clusters.Delete.as_view(), name="_cluster_delete"), path("clusters/", clusters.View.as_view(), name="_cluster_view"), + path("services", services.Index.as_view(), name="Services"), + path("service/add", services.Add.as_view(), name="_service_add"), + path("service//edit", services.Change.as_view(), name="_service_change"), + path("service//delete", services.Delete.as_view(), name="_service_delete"), + path("service/", services.View.as_view(), name="_service_view"), + ] From 6ff3fe59496d980acc49743c8e8520af29c6c567 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 25 Aug 2024 17:52:20 +0930 Subject: [PATCH 027/321] feat(itim): Add Incident ticket to navigation ref: #250 #252 #93 --- app/itim/urls.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/itim/urls.py b/app/itim/urls.py index 76204cf1..412d5699 100644 --- a/app/itim/urls.py +++ b/app/itim/urls.py @@ -20,6 +20,14 @@ urlpatterns = [ path("clusters//delete", clusters.Delete.as_view(), name="_cluster_delete"), path("clusters/", clusters.View.as_view(), name="_cluster_view"), + path('ticket/incident', ticket.Index.as_view(), kwargs={'ticket_type': 'incident'}, name="Incidents"), + path('ticket//add', ticket.Add.as_view(), name="_ticket_incident_add"), + path('ticket///edit', ticket.Change.as_view(), name="_ticket_incident_change"), + path('ticket//', ticket.View.as_view(), name="_ticket_incident_view"), + path('ticket///comment/add', ticket_comment.Add.as_view(), name="_ticket_comment_incident_add"), + path('ticket///comment//edit', ticket_comment.Change.as_view(), name="_ticket_comment_incident_change"), + path('ticket///comment//add', ticket_comment.Add.as_view(), name="_ticket_comment_incident_reply_add"), + path("services", services.Index.as_view(), name="Services"), path("service/add", services.Add.as_view(), name="_service_add"), path("service//edit", services.Change.as_view(), name="_service_change"), From 81bd635ca4b3fd23f478faf124f5da248cef0c76 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 25 Aug 2024 17:52:41 +0930 Subject: [PATCH 028/321] feat(itim): Add Problem ticket to navigation ref: #250 #252 #93 --- app/itim/urls.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/itim/urls.py b/app/itim/urls.py index 412d5699..a7ff9aa4 100644 --- a/app/itim/urls.py +++ b/app/itim/urls.py @@ -1,5 +1,6 @@ from django.urls import path +from core.views import ticket, ticket_comment from itim.views import clusters, services @@ -28,6 +29,14 @@ urlpatterns = [ path('ticket///comment//edit', ticket_comment.Change.as_view(), name="_ticket_comment_incident_change"), path('ticket///comment//add', ticket_comment.Add.as_view(), name="_ticket_comment_incident_reply_add"), + path('ticket/problem', ticket.Index.as_view(), kwargs={'ticket_type': 'problem'}, name="Problems"), + path('ticket//add', ticket.Add.as_view(), name="_ticket_problem_add"), + path('ticket///edit', ticket.Change.as_view(), name="_ticket_problem_change"), + path('ticket//', ticket.View.as_view(), name="_ticket_problem_view"), + path('ticket///comment/add', ticket_comment.Add.as_view(), name="_ticket_comment_problem_add"), + path('ticket///comment//edit', ticket_comment.Change.as_view(), name="_ticket_comment_problem_change"), + path('ticket///comment//add', ticket_comment.Add.as_view(), name="_ticket_comment_problem_reply_add"), + path("services", services.Index.as_view(), name="Services"), path("service/add", services.Add.as_view(), name="_service_add"), path("service//edit", services.Change.as_view(), name="_service_change"), From 8edb209d165d2792b11cf532350f0befff699b90 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 26 Aug 2024 14:46:27 +0930 Subject: [PATCH 029/321] feat(api): Add Tickets endpoint ref: #252 #248 --- app/api/serializers/itim/ticket.py | 61 +++++++++++++++++++ app/api/serializers/itim/ticket_comment.py | 23 +++++++ app/api/urls.py | 12 ++++ app/api/views/core/index.py | 35 +++++++++++ app/api/views/core/ticket_comments.py | 52 ++++++++++++++++ app/api/views/core/tickets.py | 53 ++++++++++++++++ app/api/views/index.py | 2 +- ...005_ticket_relatedtickets_ticketcomment.py | 14 +++-- app/core/models/ticket/ticket_comment.py | 22 ++++++- 9 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 app/api/serializers/itim/ticket.py create mode 100644 app/api/serializers/itim/ticket_comment.py create mode 100644 app/api/views/core/index.py create mode 100644 app/api/views/core/ticket_comments.py create mode 100644 app/api/views/core/tickets.py diff --git a/app/api/serializers/itim/ticket.py b/app/api/serializers/itim/ticket.py new file mode 100644 index 00000000..13ff0275 --- /dev/null +++ b/app/api/serializers/itim/ticket.py @@ -0,0 +1,61 @@ +from django.urls import reverse + +from rest_framework import serializers + +from api.serializers.itim.ticket_comment import TicketCommentSerializer + +from core.models.ticket.ticket import Ticket + + + +class TicketSerializer(serializers.ModelSerializer): + + url = serializers.HyperlinkedIdentityField( + view_name="API:_api_core_tickets-detail", format="html" + ) + + ticket_comments = serializers.SerializerMethodField('get_url_ticket_comments') + + + def get_url_ticket_comments(self, item): + + request = self.context.get('request') + return request.build_absolute_uri(reverse('API:_api_core_ticket_comments-list', args=[item.id])) + + + class Meta: + model = Ticket + fields = [ + 'id', + 'assigned_teams', + 'assigned_users', + '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', + ] diff --git a/app/api/serializers/itim/ticket_comment.py b/app/api/serializers/itim/ticket_comment.py new file mode 100644 index 00000000..ede46ca6 --- /dev/null +++ b/app/api/serializers/itim/ticket_comment.py @@ -0,0 +1,23 @@ +from django.urls import reverse + +from rest_framework import serializers + +from core.models.ticket.ticket_comment import TicketComment + + + +class TicketCommentSerializer(serializers.ModelSerializer): + + + url = serializers.SerializerMethodField('get_url_ticket_comment') + + def get_url_ticket_comment(self, item): + + request = self.context.get('request') + return request.build_absolute_uri(reverse('API:_api_core_ticket_comments-detail', args=[item.ticket_id, item.pk])) + + + class Meta: + model = TicketComment + + fields = '__all__' diff --git a/app/api/urls.py b/app/api/urls.py index 5d2a8900..e593cdfa 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -7,6 +7,9 @@ from .views import access, config, index from api.views.settings import permissions from api.views.settings import index as settings +from api.views.core import index as core +from api.views.core import tickets as core_tickets +from api.views.core import ticket_comments as core_ticket_comments from .views.itam import software, config as itam_config from .views.itam.device import DeviceViewSet @@ -21,10 +24,19 @@ router = DefaultRouter() router.register('', index.Index, basename='_api_home') router.register('device', DeviceViewSet, basename='device') router.register('software', software.SoftwareViewSet, basename='software') +router.register('core/tickets', core_tickets.View, basename='_api_core_tickets') +router.register('core/tickets/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_core_ticket_comments') urlpatterns = [ + + path("core", core.Index.as_view(), name="_api_core"), + + # + # Sof Old Paths to be refactored + # + path("config//", itam_config.View.as_view(), name="_api_device_config"), path("configuration/", config.ConfigGroupsList.as_view(), name='_api_config_groups'), diff --git a/app/api/views/core/index.py b/app/api/views/core/index.py new file mode 100644 index 00000000..a5f79aca --- /dev/null +++ b/app/api/views/core/index.py @@ -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 "Core" + + def get_view_description(self, html=False) -> str: + text = "Core Module" + if html: + return mark_safe(f"

{text}

") + else: + return text + + + def get(self, request, *args, **kwargs): + + body: dict = { + 'tickets': reverse('API:_api_core_tickets-list', request=request) + } + + return Response(body) diff --git a/app/api/views/core/ticket_comments.py b/app/api/views/core/ticket_comments.py new file mode 100644 index 00000000..17e8a631 --- /dev/null +++ b/app/api/views/core/ticket_comments.py @@ -0,0 +1,52 @@ +from django.shortcuts import get_object_or_404 + +from drf_spectacular.utils import extend_schema + +from rest_framework import generics, viewsets + +from access.mixin import OrganizationMixin + +from api.serializers.itim.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( description='Fetch all tickets', methods=["GET"]) + def list(self, request, ticket_id): + + return super().list(request) + + + @extend_schema( description='Fetch the selected ticket', methods=["GET"]) + def retrieve(self, request, *args, **kwargs): + + return super().retrieve(request, *args, **kwargs) + + + def get_queryset(self): + + 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' diff --git a/app/api/views/core/tickets.py b/app/api/views/core/tickets.py new file mode 100644 index 00000000..e3891dbb --- /dev/null +++ b/app/api/views/core/tickets.py @@ -0,0 +1,53 @@ +from django.shortcuts import get_object_or_404 + +from drf_spectacular.utils import extend_schema + +from rest_framework import generics, viewsets + +from access.mixin import OrganizationMixin + +from api.serializers.itim.ticket import TicketSerializer +from api.views.mixin import OrganizationPermissionAPI + +from core.models.ticket.ticket import Ticket + + + +class View(OrganizationMixin, viewsets.ModelViewSet): + + permission_classes = [ + OrganizationPermissionAPI + ] + + queryset = Ticket.objects.all() + + serializer_class = TicketSerializer + + + def get_object(self, queryset=None, **kwargs): + item = self.kwargs.get('pk') + return get_object_or_404(Ticket, pk=item) + + + @extend_schema( description='Fetch all tickets', methods=["GET"]) + def list(self, request): + + return super().list(request) + + + @extend_schema( description='Fetch the selected ticket', methods=["GET"]) + def retrieve(self, request, *args, **kwargs): + + return super().retrieve(request, *args, **kwargs) + + + def get_queryset(self): + + return self.queryset + + + def get_view_name(self): + if self.detail: + return "Ticket" + + return 'Tickets' diff --git a/app/api/views/index.py b/app/api/views/index.py index 0bbce58a..c8215b6b 100644 --- a/app/api/views/index.py +++ b/app/api/views/index.py @@ -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,6 +30,7 @@ class Index(viewsets.ViewSet): return Response( { # "teams": reverse("_api_teams", request=request), + 'core': reverse("API:_api_core", request=request), "devices": reverse("API:device-list", request=request), "config_groups": reverse("API:_api_config_groups", request=request), "organizations": reverse("API:_api_orgs", request=request), diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py index 4578a2e4..32cc7199 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-08-25 07:44 +# Generated by Django 5.0.7 on 2024-08-26 05:21 import access.fields import access.models @@ -45,13 +45,13 @@ class Migration(migrations.Migration): ('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')), - ('assigned_teams', models.ManyToManyField(blank=True, default=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, default=True, help_text='Assign the ticket to a User(s)', related_name='assigned_users', to=settings.AUTH_USER_MODEL, verbose_name='Assigned User(s)')), + ('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)')), ('opened_by', 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')), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), ('project', models.ForeignKey(blank=True, help_text='Assign to a project', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='project_management.project', verbose_name='Project')), - ('subscribed_teams', models.ManyToManyField(blank=True, default=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)')), - ('subscribed_users', models.ManyToManyField(blank=True, default=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)')), + ('subscribed_teams', 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)')), + ('subscribed_users', 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)')), ], options={ 'verbose_name': 'Ticket', @@ -78,6 +78,8 @@ class Migration(migrations.Migration): 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(default=None, help_text='Comment contents', verbose_name='Comment')), ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), @@ -97,7 +99,7 @@ class Migration(migrations.Migration): ('responsible_team', 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')), ('responsible_user', 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')), ('template', models.ForeignKey(blank=True, default=None, help_text='Comment Template to use', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_template', to='core.ticketcomment', verbose_name='Template')), - ('ticket', models.ForeignKey(blank=True, default=None, help_text='Parent ID for creating discussion threads', null=True, on_delete=django.db.models.deletion.CASCADE, to='core.ticket', validators=[core.models.ticket.ticket_comment.TicketComment.validation_ticket_id], verbose_name='Parent Comment')), + ('ticket', 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')), ('user', models.ForeignKey(help_text='Who made the comment', on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_user', to=settings.AUTH_USER_MODEL, verbose_name='User')), ], options={ diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 15588a60..514db43a 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -121,13 +121,31 @@ class TicketComment( Ticket, blank= True, default = None, - help_text = 'Parent ID for creating discussion threads', + help_text = 'Ticket this comment belongs to', null = True, on_delete = models.CASCADE, validators = [ validation_ticket_id ], - verbose_name = 'Parent Comment', + 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, From 3c44561b19499195a17ff26328ac73150ea96408 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 26 Aug 2024 15:14:04 +0930 Subject: [PATCH 030/321] chore(development): Add makefile ref: #252 #248 --- CONTRIBUTING.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ makefile | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 makefile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b451e128..8dfe0f99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 +/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. diff --git a/makefile b/makefile new file mode 100644 index 00000000..ebf67ea4 --- /dev/null +++ b/makefile @@ -0,0 +1,47 @@ +.ONESHELL: + +PATH_VENV := /tmp/centurion_erp + +ACTIVATE_VENV :=. ${PATH_VENV}/bin/activate + +.PHONY: clean prepare docs ansible-lint lint + + +prepare: + git submodule update --init; + git submodule foreach git submodule update --init; + python3 -m venv ${PATH_VENV}; + ${ACTIVATE_VENV}; + pip install -r website-template/gitlab-ci/mkdocs/requirements.txt; + pip install -r gitlab-ci/lint/requirements.txt; + pip install -r requirements.txt; + pip install -r requirements_test.txt; + npm install markdownlint-cli2; + npm install markdownlint-cli2-formatter-junit; + cp -f "website-template/.markdownlint.json" ".markdownlint.json"; + cp -f "gitlab-ci/lint/.markdownlint-cli2.jsonc" ".markdownlint-cli2.jsonc"; + + +markdown-mkdocs-lint: + PATH=${PATH}:node_modules/.bin markdownlint-cli2 "docs/*.md docs/**/*.md docs/**/**/*.md docs/**/**/**/*.md docs/**/**/**/**/**/*.md #CHANGELOG.md !gitlab-ci !website-template" + + +docs-lint: markdown-mkdocs-lint + + +docs: docs-lint + ${ACTIVATE_VENV} + mkdocs build --clean + + + +lint: markdown-mkdocs-lint + + +clean: + rm -rf ${PATH_VENV} + rm -rf pages + rm -rf build + rm -rf node_modules + rm -f package-lock.json + rm -f package.json \ No newline at end of file From 5d74ddfee510c016bedd93dde6c150b6a6aed9e6 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 27 Aug 2024 17:05:50 +0930 Subject: [PATCH 031/321] fix(access): Don't query for `is_global=None` within `TenancyManager` ref: #252 --- app/access/models.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/access/models.py b/app/access/models.py index fa4119a6..03c91a73 100644 --- a/app/access/models.py +++ b/app/access/models.py @@ -126,11 +126,19 @@ class TenancyManager(models.Manager): if len(user_organizations) > 0 and not user.is_superuser: - 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() From e63bec83e817fcf156eb91df2b588f37b50b04c2 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 27 Aug 2024 17:09:57 +0930 Subject: [PATCH 032/321] feat(access): add dynamic permissions to Tenancy Permissions ref: #252 #250 --- app/access/mixin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/access/mixin.py b/app/access/mixin.py index fd2dc4da..e36536b0 100644 --- a/app/access/mixin.py +++ b/app/access/mixin.py @@ -327,6 +327,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: From 09afd7f165e8ed79e33bb470ecbf2c2928033303 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 27 Aug 2024 17:18:19 +0930 Subject: [PATCH 033/321] feat(core): Add permission checking to Tickets form ref: #250 #252 #96 #93 #95 #90 #115 --- .gitignore | 6 + app/access/mixin.py | 6 +- app/core/forms/ticket.py | 66 ++++++- app/core/forms/ticket_comment.py | 3 + app/core/forms/validate_ticket.py | 172 ++++++++++++++++++ ...005_ticket_relatedtickets_ticketcomment.py | 7 +- app/core/models/comment.py | 38 ---- app/core/models/ticket/markdown.py | 19 ++ app/core/models/ticket/ticket.py | 14 +- app/core/models/ticket/ticket_comment.py | 8 + app/core/views/ticket.py | 67 +++++-- makefile | 1 + 12 files changed, 336 insertions(+), 71 deletions(-) create mode 100644 app/core/forms/validate_ticket.py delete mode 100644 app/core/models/comment.py create mode 100644 app/core/models/ticket/markdown.py diff --git a/.gitignore b/.gitignore index ce96ad09..3a1e0969 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ artifacts/ volumes/ build/ pages/ +node_modules/ +.markdownlint-cli2.jsonc +.markdownlint.json +package-lock.json +package.json +**.junit.xml diff --git a/app/access/mixin.py b/app/access/mixin.py index e36536b0..0a0d339c 100644 --- a/app/access/mixin.py +++ b/app/access/mixin.py @@ -248,9 +248,11 @@ class OrganizationMixin(): 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: diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index 89d57e60..257111e7 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -1,15 +1,20 @@ 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): +class TicketForm( + CommonModelForm, + TicketValidation, +): prefix = 'ticket' @@ -18,7 +23,9 @@ class TicketForm(CommonModelForm): fields = '__all__' - def __init__(self, *args, **kwargs): + def __init__(self, request, *args, **kwargs): + + self.request = request super().__init__(*args, **kwargs) @@ -41,6 +48,7 @@ class TicketForm(CommonModelForm): 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() @@ -116,16 +124,70 @@ class TicketForm(CommonModelForm): del self.fields[field] + def clean(self): cleaned_data = super().clean() return cleaned_data + def is_valid(self) -> bool: is_valid = super().is_valid() + ticket_type_choice_id = int(self.cleaned_data['ticket_type'] - 1) + + ticket_type = str(self.fields['ticket_type'].choices.choices.pop(ticket_type_choice_id)[1]).lower().replace(' ', '_') + + if self.instance.pk: + + self.original_object = self.Meta.model.objects.get(pk=self.instance.pk) + + self.validate_ticket() + + if ticket_type == 'change': + + self.validate_change_ticket() + + elif ticket_type == 'incident': + + self.validate_incident_ticket() + + elif ticket_type == 'issue': + + # self.validate_issue_ticket() + raise ValidationError( + 'This Ticket type is not yet available' + ) + + elif ticket_type == 'merge_request': + + # self.validate_merge_request_ticket() + raise ValidationError( + 'This Ticket type is not yet available' + ) + + elif ticket_type == 'problem': + + self.validate_problem_ticket() + + elif ticket_type == 'project_task': + + # self.validate_project_task_ticket() + raise ValidationError( + 'This Ticket type is not yet available' + ) + + elif ticket_type == 'request': + + self.validate_request_ticket() + + else: + + raise ValidationError('Ticket Type must be set') + + return is_valid diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 05e458aa..7243abf4 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -44,6 +44,9 @@ class CommentForm(CommonModelForm): 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 'qs_comment_type' in kwargs['initial']: comment_type = kwargs['initial']['qs_comment_type'] diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py new file mode 100644 index 00000000..99bd5865 --- /dev/null +++ b/app/core/forms/validate_ticket.py @@ -0,0 +1,172 @@ +from django.core.exceptions import PermissionDenied +from django.forms import ValidationError + +from access.mixin import OrganizationMixin + + +class TicketValidation( + OrganizationMixin, +): + + original_object = None + + add_fields: list = [ + 'title', + 'description', + 'urgency', + ] + + change_fields: list = [] + + delete_fields: list = [ + 'is_deleted', + ] + + import_fields: list = [ + 'assigned_users', + 'assigned_teams', + 'created', + 'date_closed', + 'external_ref', + 'external_system', + 'impact', + 'opened_by', + 'planned_start_date', + 'planned_finish_date', + 'priority', + 'project', + 'real_start_date', + 'real_finish_date', + 'subscribed_users', + 'subscribed_teams', + ] + + triage_fields: list = [ + 'assigned_users', + 'assigned_teams', + 'impact', + 'opened_by', + 'planned_start_date', + 'planned_finish_date', + 'priority', + 'project', + 'real_start_date', + 'real_finish_date', + 'subscribed_users', + 'subscribed_teams', + ] + + + 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. + + Raises: + PermissionDenied: Access Denied when user has no ticket permissions assigned + PermissionDenied: _description_ + """ + + fields_allowed: list = [] + + + if self.permission_check( + request = self.request, + permissions_required = [ 'add_ticket_'+ self.initial['type_ticket'] ] + ) and not self.request.user.is_superuser: + + fields_allowed = fields_allowed + self.add_fields + + if self.permission_check( + request = self.request, + permissions_required = [ 'change_ticket_'+ self.initial['type_ticket'] ] + ) and not self.request.user.is_superuser: + + fields_allowed = fields_allowed + self.change_fields + + if self.permission_check( + request = self.request, + permissions_required = [ 'delete_ticket_'+ self.initial['type_ticket'] ] + ) and not self.request.user.is_superuser: + + fields_allowed = fields_allowed + self.delete_fields + + if self.permission_check( + request = self.request, + permissions_required = [ 'import_ticket_'+ self.initial['type_ticket'] ] + ) and not self.request.user.is_superuser: + + fields_allowed = fields_allowed + self.import_fields + + if self.permission_check( + request = self.request, + permissions_required = [ 'triage_ticket_'+ self.initial['type_ticket'] ] + ) 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 + + if len(fields_allowed) == 0: + + raise PermissionDenied('Access Denied') + + for field in self.changed_data: + + if field not in fields_allowed: + + raise PermissionDenied(f'cant edit field: {field}') + + + + def validate_ticket(self): + """Validations common to all ticket types.""" + + self.validate_field_permission() + + + + 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 + + # raise ValidationError('Test to see what it looks like') + pass diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py index 32cc7199..d546057e 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -1,11 +1,8 @@ -# Generated by Django 5.0.7 on 2024-08-26 05:21 +# Generated by Django 5.0.7 on 2024-08-27 07:46 import access.fields import access.models -import core.models.ticket.change_ticket import core.models.ticket.markdown -import core.models.ticket.problem_ticket -import core.models.ticket.request_ticket import core.models.ticket.ticket import core.models.ticket.ticket_comment import django.db.models.deletion @@ -59,7 +56,7 @@ class Migration(migrations.Migration): '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')], }, - bases=(models.Model, core.models.ticket.change_ticket.ChangeTicket, core.models.ticket.problem_ticket.ProblemTicket, core.models.ticket.request_ticket.RequestTicket, core.models.ticket.markdown.TicketMarkdown), + bases=(models.Model, core.models.ticket.markdown.TicketMarkdown), ), migrations.CreateModel( name='RelatedTickets', diff --git a/app/core/models/comment.py b/app/core/models/comment.py deleted file mode 100644 index 3b329771..00000000 --- a/app/core/models/comment.py +++ /dev/null @@ -1,38 +0,0 @@ -from django.db import models - -from access.models import TenancyObject - - - -class CommentCommonFields(models.Model): - - class Meta: - abstract = True - - id = models.AutoField( - blank=False, - help_text = 'Comment ID Number', - primary_key=True, - unique=True, - verbose_name = 'Number', - ) - - created = AutoCreatedField() - - modified = AutoCreatedField() - - - -class Comment( - TenancyObject, - CommentCommonFields, -): - - - class Meta: - - ordering = [ - 'ticket', - 'created', - 'id', - ] diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py new file mode 100644 index 00000000..e845a680 --- /dev/null +++ b/app/core/models/ticket/markdown.py @@ -0,0 +1,19 @@ + + + +class TicketMarkdown: + """Ticket and Comment markdown functions + + Intended to be used for all areas of a tickets, projects and comments. + """ + + + def render_markdown(self, markdown): + + # Requires context of ticket for ticket markdown + + # Requires context of ticket for comment + + # requires context of project for project task comment + + pass diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index ff4d35de..005a3f6a 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -6,10 +6,8 @@ from django.forms import ValidationError from access.fields import AutoCreatedField from access.models import TenancyObject, Team -from .change_ticket import ChangeTicket + from .markdown import TicketMarkdown -from .problem_ticket import ProblemTicket -from .request_ticket import RequestTicket from project_management.models.projects import Project @@ -119,9 +117,6 @@ class TicketCommonFields(models.Model): class Ticket( TenancyObject, TicketCommonFields, - ChangeTicket, - ProblemTicket, - RequestTicket, TicketMarkdown, ): @@ -753,3 +748,10 @@ class RelatedTickets(TenancyObject): related_name = 'to_ticket_id', verbose_name = 'Related Ticket', ) + + + @property + def parent_object(self): + """ Fetch the parent object """ + + return self.from_ticket_id diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 514db43a..904783b2 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -398,6 +398,14 @@ class TicketComment( return query + + @property + def parent_object(self): + """ Fetch the parent object """ + + return self.ticket + + @property def threads(self): diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index c794f04f..c2e20751 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -21,23 +21,37 @@ class Add(AddView): form_class = TicketForm model = Ticket - permission_required = [ - 'itam.add_device', - ] - template_name = 'form.html.j2' + + + def get_dynamic_permissions(self): + + return [ + str('core.add_ticket_' + self.kwargs['ticket_type']), + ] def get_initial(self): - return { - 'organization': UserSettings.objects.get(user = self.request.user).default_organization, + + initial = super().get_initial() + + initial.update({ 'type_ticket': self.kwargs['ticket_type'], - } + }) + + return initial + def form_valid(self, form): form.instance.is_global = False return super().form_valid(form) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def get_success_url(self, **kwargs): if self.kwargs['ticket_type'] == 'request': @@ -64,9 +78,12 @@ class Change(ChangeView): model = Ticket - permission_required = [ - 'itim.change_cluster', - ] + + def get_dynamic_permissions(self): + + return [ + str('core.change_ticket_' + self.kwargs['ticket_type']), + ] def get_context_data(self, **kwargs): @@ -78,6 +95,12 @@ class Change(ChangeView): return context + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def get_initial(self): return { 'type_ticket': self.kwargs['ticket_type'], @@ -103,12 +126,16 @@ class Index(OrganizationPermission, generic.ListView): model = Ticket - permission_required = [ - 'django_celery_results.view_taskresult', - ] - template_name = 'core/ticket/index.html.j2' + + def get_dynamic_permissions(self): + + return [ + str('core.view_ticket_' + self.kwargs['ticket_type']), + ] + + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -152,10 +179,6 @@ class View(ChangeView): model = Ticket - permission_required = [ - 'itam.view_device', - ] - template_name = 'core/ticket.html.j2' form_class = DetailForm @@ -163,6 +186,14 @@ class View(ChangeView): context_object_name = "ticket" + def get_dynamic_permissions(self): + + return [ + str('core.view_ticket_' + self.kwargs['ticket_type']), + ] + + + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/makefile b/makefile index ebf67ea4..39260936 100644 --- a/makefile +++ b/makefile @@ -14,6 +14,7 @@ prepare: ${ACTIVATE_VENV}; pip install -r website-template/gitlab-ci/mkdocs/requirements.txt; pip install -r gitlab-ci/lint/requirements.txt; + pip install -r gitlab-ci/mkdocs/requirements.txt; pip install -r requirements.txt; pip install -r requirements_test.txt; npm install markdownlint-cli2; From 2a7857b60db6923631077ed371db60043346bfe8 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 28 Aug 2024 17:38:29 +0930 Subject: [PATCH 034/321] refactor(access): cache object_organization on lookup ref: #252 --- app/access/mixin.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/access/mixin.py b/app/access/mixin.py index 0a0d339c..c31c949c 100644 --- a/app/access/mixin.py +++ b/app/access/mixin.py @@ -33,6 +33,10 @@ class OrganizationMixin(): id = None + if hasattr(self, '_object_organization'): + + return self._object_organization + try: if hasattr(self, 'get_queryset'): @@ -61,6 +65,10 @@ class OrganizationMixin(): id = 0 + if hasattr(self, 'instance') and id is None: # Form Instance + + id = self.instance.get_organization() + except AttributeError: @@ -84,6 +92,10 @@ class OrganizationMixin(): pass + if id is not None: + + self._object_organization = id + return id From e59a08b35178fd3a946cb78c971eda86b4ee26df Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 28 Aug 2024 17:39:16 +0930 Subject: [PATCH 035/321] refactor(access): cache user_organizations on lookup ref: #252 --- app/access/mixin.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/access/mixin.py b/app/access/mixin.py index c31c949c..e82c2698 100644 --- a/app/access/mixin.py +++ b/app/access/mixin.py @@ -159,6 +159,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(): @@ -169,6 +173,11 @@ class OrganizationMixin(): user_organizations = user_organizations + [team.organization.id] + if len(user_organizations) > 0: + + self._user_organizations = user_organizations + + return user_organizations From 5c4a8020173884817ab359cf006c7042907e5c84 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 28 Aug 2024 17:42:46 +0930 Subject: [PATCH 036/321] feat(core): Add field level permission and validation checks ref: #250 #252 #96 #93 #95 #90 #11 --- app/access/mixin.py | 27 ++++++++++-- app/core/forms/common.py | 3 -- app/core/forms/ticket.py | 11 ++++- app/core/forms/validate_ticket.py | 69 +++++++++++++++++++------------ 4 files changed, 75 insertions(+), 35 deletions(-) diff --git a/app/access/mixin.py b/app/access/mixin.py index e82c2698..bc95e021 100644 --- a/app/access/mixin.py +++ b/app/access/mixin.py @@ -182,10 +182,23 @@ class OrganizationMixin(): # 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() @@ -203,7 +216,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 @@ -263,9 +276,15 @@ 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 diff --git a/app/core/forms/common.py b/app/core/forms/common.py index 9f243986..e49860fd 100644 --- a/app/core/forms/common.py +++ b/app/core/forms/common.py @@ -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 ] diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index 257111e7..4722733b 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -117,11 +117,20 @@ class TicketForm( ticket_type += self.Meta.model.tech_fields + fields_allowed = self.fields_allowed - for field in original_fields: + + for field in fields_allowed: # Remove fields not intended for the ticket type if field not in ticket_type: + fields_allowed.remove(field) + + + for field in original_fields: # Remove fields user cant edit unless field is hidden + + if field not in fields_allowed and not self.fields[field].widget.is_hidden: + del self.fields[field] diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 99bd5865..36aaeff1 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -56,52 +56,51 @@ class TicketValidation( 'subscribed_teams', ] + @property + def fields_allowed(self): - 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. - - Raises: - PermissionDenied: Access Denied when user has no ticket permissions assigned - PermissionDenied: _description_ - """ fields_allowed: list = [] - if self.permission_check( - request = self.request, - permissions_required = [ 'add_ticket_'+ self.initial['type_ticket'] ] + if self.has_organization_permission( + organization=self.instance.organization.id, + permissions_required = [ 'core.add_ticket_'+ self.initial['type_ticket'] ], ) and not self.request.user.is_superuser: - fields_allowed = fields_allowed + self.add_fields + fields_allowed = self.add_fields - if self.permission_check( - request = self.request, - permissions_required = [ 'change_ticket_'+ self.initial['type_ticket'] ] + + if self.has_organization_permission( + organization=self.instance.organization.id, + permissions_required = [ 'core.change_ticket_'+ self.initial['type_ticket'] ], ) and not self.request.user.is_superuser: - fields_allowed = fields_allowed + self.change_fields + if len(fields_allowed) == 0: - if self.permission_check( - request = self.request, - permissions_required = [ 'delete_ticket_'+ self.initial['type_ticket'] ] + fields_allowed = self.add_fields + self.change_fields + + else: + + fields_allowed = fields_allowed + self.change_fields + + if self.has_organization_permission( + organization=self.instance.organization.id, + permissions_required = [ 'core.delete_ticket_'+ self.initial['type_ticket'] ], ) and not self.request.user.is_superuser: fields_allowed = fields_allowed + self.delete_fields - if self.permission_check( - request = self.request, - permissions_required = [ 'import_ticket_'+ self.initial['type_ticket'] ] + if self.has_organization_permission( + organization=self.instance.organization.id, + permissions_required = [ 'core.import_ticket_'+ self.initial['type_ticket'] ], ) and not self.request.user.is_superuser: fields_allowed = fields_allowed + self.import_fields - if self.permission_check( - request = self.request, - permissions_required = [ 'triage_ticket_'+ self.initial['type_ticket'] ] + if self.has_organization_permission( + organization=self.instance.organization.id, + permissions_required = [ 'core.triage_ticket_'+ self.initial['type_ticket'] ], ) and not self.request.user.is_superuser: fields_allowed = fields_allowed + self.triage_fields @@ -116,6 +115,22 @@ class TicketValidation( fields_allowed = fields_allowed + all_fields + return fields_allowed + + + 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. + + Raises: + PermissionDenied: Access Denied when user has no ticket permissions assigned + PermissionDenied: _description_ + """ + + fields_allowed = self.fields_allowed + if len(fields_allowed) == 0: raise PermissionDenied('Access Denied') From 6a52730b494f29e5628edf2190877c6d00bdda5b Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 30 Aug 2024 12:23:05 +0930 Subject: [PATCH 037/321] feat(core): Add Title bar to ticket form ref: #250 #252 #96 #93 #95 #90 #115 --- app/api/urls.py | 2 +- app/core/templates/core/ticket.html.j2 | 5 +++-- app/project-static/ticketing.css | 28 +++++++++++++++++++------- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/api/urls.py b/app/api/urls.py index e593cdfa..f060e79e 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -19,7 +19,7 @@ from .views.itam import inventory app_name = "API" -router = DefaultRouter() +router = DefaultRouter(trailing_slash=False) router.register('', index.Index, basename='_api_home') router.register('device', DeviceViewSet, basename='device') diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index 45f04f35..7044048a 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -14,10 +14,11 @@
-
+

opened by {{ ticket.opened_by }} on {{ ticket.created }}

+
-
{{ ticket.description | markdown | safe }}
+
{{ ticket.description | markdown | safe }}
diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 06fd2537..73123c86 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -103,10 +103,24 @@ background-color: #fff; border: 1px solid #ccc; margin: 0px 10px 0px 0px; - padding: 10px; + padding: 0px; } +#ticket-description h3 { + font-size: inherit; + font-weight: normal; + height: 30px; + line-height: 30px; + margin: 0px; + padding: 0px 10px 0px 10px; + text-align: left; +} + +#ticket-description div { + /*background-color: tomato;*/ + padding: 10px; +} #data-block { border: 1px solid #ccc; @@ -315,32 +329,32 @@ } -#ticket-meta h3.incident-ticket { +h3.incident-ticket { background-color: #f7baba; } -#ticket-meta h3.request-ticket { +h3.request-ticket { background-color: #f7e9ba; } -#ticket-meta h3.change-ticket { +h3.change-ticket { background-color: #badff7; } -#ticket-meta h3.problem-ticket { +h3.problem-ticket { background-color: #f7d0ba; } -#ticket-meta h3.issue-ticket { +h3.issue-ticket { background-color: #baf7db; } -#ticket-meta h3.project_task-ticket { +h3.project_task-ticket { background-color: #c5baf7; } From 96ed198efc71f4d61ab01af0b742fb4d1ef8af49 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 30 Aug 2024 12:30:14 +0930 Subject: [PATCH 038/321] fix(core): use from ticket title for "blocked by" ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/models/ticket/ticket.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 005a3f6a..a9c9eb41 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -657,6 +657,7 @@ class Ticket( how_related:str = str(related_ticket.get_how_related_display()).lower() + ticket_title: str = related_ticket.to_ticket_id.title if related_ticket.to_ticket_id_id == self.id: @@ -664,6 +665,7 @@ class Ticket( if str(related_ticket.get_how_related_display()).lower() == 'blocks': how_related = 'blocked by' + ticket_title = related_ticket.from_ticket_id.title elif str(related_ticket.get_how_related_display()).lower() == 'blocked by': @@ -674,7 +676,7 @@ class Ticket( { 'id': related_ticket.id, 'type': related_ticket.to_ticket_id.get_ticket_type_display().lower(), - 'title': related_ticket.to_ticket_id.title, + 'title': ticket_title, 'how_related': how_related.replace(' ', '_'), 'icon_filename': str('icons/ticket/ticket_' + how_related.replace(' ', '_') + '.svg') } From 3f1f2fd8d41be26a8381d659c974b4512b4d34e1 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 30 Aug 2024 15:00:36 +0930 Subject: [PATCH 039/321] feat(core): Add ticket action comments on ticket update ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/models/ticket/ticket.py | 127 +++++++++++++++++- app/core/models/ticket/ticket_comment.py | 4 + .../templates/core/ticket/comment.html.j2 | 3 - .../core/ticket/comment/comment.html.j2 | 2 +- 4 files changed, 126 insertions(+), 10 deletions(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index a9c9eb41..4e1cd4cf 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -6,6 +6,7 @@ from django.forms import ValidationError from access.fields import AutoCreatedField from access.models import TenancyObject, Team +from core.middleware.get_request import get_request from .markdown import TicketMarkdown @@ -637,6 +638,17 @@ class Ticket( ] + @property + def comments(self): + + from core.models.ticket.ticket_comment import TicketComment + + return TicketComment.objects.filter( + ticket = self.id, + parent = None, + ) + + @property def markdown_description(self) -> str: @@ -685,16 +697,68 @@ class Ticket( return related_tickets - @property - def comments(self): + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): + + before = {} + + try: + before = self.__class__.objects.get(pk=self.pk).__dict__.copy() + except Exception: + pass + + super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) + + after = self.__dict__.copy() + + changed_fields: list = [] + + for field, value in before.items(): + + if before[field] != after[field] and field != '_state': + + changed_fields = changed_fields + [ field ] + + request = get_request() from core.models.ticket.ticket_comment import TicketComment - return TicketComment.objects.filter( - ticket = self.id, - parent = None, - ) + for field in changed_fields: + comment_field_value: str = None + + if field == 'impact': + + comment_field_value = f"changed {field} to {self.get_impact_display()}" + + if field == 'urgency': + + comment_field_value = f"changed {field} to {self.get_urgency_display()}" + + if field == 'priority': + + comment_field_value = f"changed {field} to {self.get_priority_display()}" + + + if field == 'status': + + comment_field_value = f"changed {field} to {self.get_status_display()}" + + if field == 'project_id': + + comment_field_value = f"changed {field.replace('_id','')} to {self.project}" + + + if comment_field_value: + + comment = TicketComment.objects.create( + ticket = self, + comment_type = TicketComment.CommentType.ACTION, + body = comment_field_value, + source = TicketComment.CommentSource.DIRECT, + user = request.user, + ) + + comment.save() class RelatedTickets(TenancyObject): @@ -757,3 +821,54 @@ class RelatedTickets(TenancyObject): """ Fetch the parent object """ return self.from_ticket_id + + + 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) + + if self.how_related == self.Related.BLOCKED_BY: + + comment_field_value_from = f" added #{self.from_ticket_id.id} as blocked by #{self.to_ticket_id.id}" + comment_field_value_to = f" added #{self.to_ticket_id.id} as blocking #{self.from_ticket_id.id}" + + elif self.how_related == self.Related.BLOCKS: + + comment_field_value_from = f" added #{self.from_ticket_id.id} as blocking #{self.to_ticket_id.id}" + comment_field_value_to = f" added #{self.to_ticket_id.id} as blocked by #{self.from_ticket_id.id}" + + elif self.how_related == self.Related.RELATED: + + comment_field_value_from = f" added #{self.from_ticket_id.id} as related to #{self.to_ticket_id.id}" + comment_field_value_to = f" added #{self.to_ticket_id.id} as related to #{self.from_ticket_id.id}" + + + request = get_request() + + from core.models.ticket.ticket_comment import TicketComment + + if comment_field_value_from: + + comment = TicketComment.objects.create( + ticket = self.from_ticket_id, + comment_type = TicketComment.CommentType.ACTION, + body = comment_field_value_from, + source = TicketComment.CommentSource.DIRECT, + user = request.user, + ) + + comment.save() + + + if comment_field_value_to: + + comment = TicketComment.objects.create( + ticket = self.to_ticket_id, + comment_type = TicketComment.CommentType.ACTION, + body = comment_field_value_to, + source = TicketComment.CommentSource.DIRECT, + user = request.user, + ) + + comment.save() + diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 904783b2..7c82d5cf 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -381,6 +381,10 @@ class TicketComment( ] + @property + def action_comment(self): + + return self.user.username + ' ' + self.body + ' on ' + str(self.created) @property diff --git a/app/core/templates/core/ticket/comment.html.j2 b/app/core/templates/core/ticket/comment.html.j2 index 4a6df4c1..5194eb4f 100644 --- a/app/core/templates/core/ticket/comment.html.j2 +++ b/app/core/templates/core/ticket/comment.html.j2 @@ -2,7 +2,6 @@
    -
  • John smith add x as related to this ticket
  • {% for comment in ticket.comments %} @@ -39,8 +38,6 @@ {% endfor %} -
  • Jane smith mentioned this ticket in xx
  • -
  • sdasfdgdfgdfg dfg dfg dfg d
diff --git a/app/core/templates/core/ticket/comment/comment.html.j2 b/app/core/templates/core/ticket/comment/comment.html.j2 index 4998d956..06ccb139 100644 --- a/app/core/templates/core/ticket/comment/comment.html.j2 +++ b/app/core/templates/core/ticket/comment/comment.html.j2 @@ -4,7 +4,7 @@ {% if comment.get_comment_type_display == 'Action' %} - {{ comment.body | markdown | safe }} + {{ comment.action_comment | markdown | safe }} {% 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' %}
From 8b004466d1357fa736fa8cc98fa32b6426468236 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 30 Aug 2024 15:45:15 +0930 Subject: [PATCH 040/321] feat(core): Validate ticket status field for all ticket types ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/forms/validate_ticket.py | 80 +++++++++++++++++++++++++++++-- app/core/models/ticket/ticket.py | 31 +++++++++++- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 36aaeff1..748dd0fb 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -29,6 +29,7 @@ class TicketValidation( 'date_closed', 'external_ref', 'external_system', + 'status', 'impact', 'opened_by', 'planned_start_date', @@ -44,6 +45,7 @@ class TicketValidation( triage_fields: list = [ 'assigned_users', 'assigned_teams', + 'status', 'impact', 'opened_by', 'planned_start_date', @@ -59,13 +61,17 @@ class TicketValidation( @property def fields_allowed(self): + if not hasattr(self, '_ticket_type'): + + self._ticket_type = self.initial['type_ticket'] + fields_allowed: list = [] if self.has_organization_permission( organization=self.instance.organization.id, - permissions_required = [ 'core.add_ticket_'+ self.initial['type_ticket'] ], + permissions_required = [ 'core.add_ticket_'+ self._ticket_type ], ) and not self.request.user.is_superuser: fields_allowed = self.add_fields @@ -73,7 +79,7 @@ class TicketValidation( if self.has_organization_permission( organization=self.instance.organization.id, - permissions_required = [ 'core.change_ticket_'+ self.initial['type_ticket'] ], + permissions_required = [ 'core.change_ticket_'+ self._ticket_type ], ) and not self.request.user.is_superuser: if len(fields_allowed) == 0: @@ -86,21 +92,21 @@ class TicketValidation( if self.has_organization_permission( organization=self.instance.organization.id, - permissions_required = [ 'core.delete_ticket_'+ self.initial['type_ticket'] ], + 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=self.instance.organization.id, - permissions_required = [ 'core.import_ticket_'+ self.initial['type_ticket'] ], + permissions_required = [ 'core.import_ticket_'+ self._ticket_type ], ) and not self.request.user.is_superuser: fields_allowed = fields_allowed + self.import_fields if self.has_organization_permission( organization=self.instance.organization.id, - permissions_required = [ 'core.triage_ticket_'+ self.initial['type_ticket'] ], + permissions_required = [ 'core.triage_ticket_'+ self._ticket_type ], ) and not self.request.user.is_superuser: fields_allowed = fields_allowed + self.triage_fields @@ -143,11 +149,75 @@ class TicketValidation( + 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'] + + + + if self._ticket_type == 'request': + + if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Request._value2member_map_: + + is_valid = True + + elif self._ticket_type == 'incident': + + if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Incident._value2member_map_: + + is_valid = True + + elif self._ticket_type == 'problem': + + if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Problem._value2member_map_: + + is_valid = True + + elif self._ticket_type == 'change': + + if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Change._value2member_map_: + + is_valid = True + + elif self._ticket_type == 'issue': + + if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Issue._value2member_map_: + + is_valid = True + + elif self._ticket_type == 'merge': + + if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Merge._value2member_map_: + + is_valid = True + + elif self._ticket_type == 'project_task': + + if self.cleaned_data['status'] in self.Meta.model.TicketStatus.ProjectTask._value2member_map_: + + is_valid = True + + + + return is_valid + + def validate_ticket(self): """Validations common to all ticket types.""" self.validate_field_permission() + self.validate_field_status() + def validate_change_ticket(self): diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 4e1cd4cf..92cd8c51 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -230,6 +230,33 @@ class Ticket( """ + class All(models.IntegerChoices): + + DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR + NEW = TicketValues._NEW_INT, TicketValues._NEW_STR + ASSIGNED = TicketValues._ASSIGNED_INT, TicketValues._ASSIGNED_STR + ASSIGNED_PLANNING = TicketValues._ASSIGNED_PLANNING_INT, TicketValues._ASSIGNED_PLANNING_STR + PENDING = TicketValues._PENDING_INT, TicketValues._PENDING_STR + SOLVED = TicketValues._SOLVED_INT, TicketValues._SOLVED_STR + CLOSED = TicketValues._CLOSED_INT, TicketValues._CLOSED_STR + INVALID = TicketValues._INVALID_INT, TicketValues._INVALID_STR + + # Problem + ACCEPTED = TicketValues._ACCEPTED_INT, TicketValues._ACCEPTED_STR + OBSERVATION = TicketValues._OBSERVATION_INT, TicketValues._OBSERVATION_STR + + # change + EVALUATION = TicketValues._EVALUATION_INT, TicketValues._EVALUATION_STR + APPROVALS = TicketValues._APPROVALS_INT, TicketValues._APPROVALS_STR + TESTING = TicketValues._TESTING_INT, TicketValues._TESTING_STR + QUALIFICATION = TicketValues._QUALIFICATION_INT, TicketValues._QUALIFICATION_STR + APPLIED = TicketValues._APPLIED_INT, TicketValues._APPLIED_STR + REVIEW = TicketValues._REVIEW_INT, TicketValues._REVIEW_STR + CANCELLED = TicketValues._CANCELLED_INT, TicketValues._CANCELLED_STR + REFUSED = TicketValues._REFUSED_INT, TicketValues._REFUSED_STR + + + class Request(models.IntegerChoices): DRAFT = TicketValues._DRAFT_INT, TicketValues._DRAFT_STR @@ -372,8 +399,8 @@ class Ticket( status = models.IntegerField( # will require validation by ticket type as status for types will be different blank = False, - choices=TicketStatus.Request, - default = TicketStatus.Request.NEW, + choices=TicketStatus.All, + default = TicketStatus.All.NEW, help_text = 'Status of ticket', # null=True, verbose_name = 'Status', From 95979c60952cb986990930bbbd06531e015d4234 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 30 Aug 2024 15:49:42 +0930 Subject: [PATCH 041/321] feat(core): Validate ticket related and prevent duel related entries ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/forms/related_ticket.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/core/forms/related_ticket.py b/app/core/forms/related_ticket.py index 9933f8a1..08289b85 100644 --- a/app/core/forms/related_ticket.py +++ b/app/core/forms/related_ticket.py @@ -1,5 +1,6 @@ from django import forms from django.db.models import Q +from django.forms import ValidationError from app import settings @@ -34,4 +35,21 @@ class RelatedTicketForm(CommonModelForm): 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 From 1665e519a4ca1426018b6d82806844b0f7e54a12 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 30 Aug 2024 15:50:19 +0930 Subject: [PATCH 042/321] fix(core): return correct redirect path for related ticket form ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/views/related_ticket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/views/related_ticket.py b/app/core/views/related_ticket.py index e20943ff..c215bfc3 100644 --- a/app/core/views/related_ticket.py +++ b/app/core/views/related_ticket.py @@ -42,7 +42,7 @@ class Add(AddView): if self.kwargs['ticket_type'] == 'request': - return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.object.id,)) + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) else: From 6ec16cbeb067854f5a84231f841ae9149a965699 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 30 Aug 2024 18:20:47 +0930 Subject: [PATCH 043/321] feat(core): colour code related ticket background to ticket type ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/templates/core/ticket.html.j2 | 14 ++++-- .../templates/core/ticket/type_icon.html.j2 | 8 ++++ app/core/templates/icons/ticket/ticket.svg | 1 + app/project-static/ticketing.css | 48 ++++++++++++++++--- 4 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 app/core/templates/core/ticket/type_icon.html.j2 create mode 100644 app/core/templates/icons/ticket/ticket.svg diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index 7044048a..19006109 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -33,7 +33,7 @@ {% if ticket.related_tickets %} {% for related_ticket in ticket.related_tickets %} -
+ {% endfor %} diff --git a/app/core/templates/core/ticket/type_icon.html.j2 b/app/core/templates/core/ticket/type_icon.html.j2 new file mode 100644 index 00000000..751ec410 --- /dev/null +++ b/app/core/templates/core/ticket/type_icon.html.j2 @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/ticket.svg b/app/core/templates/icons/ticket/ticket.svg new file mode 100644 index 00000000..2330d6c3 --- /dev/null +++ b/app/core/templates/icons/ticket/ticket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 73123c86..89125016 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -38,7 +38,6 @@ } - #linked-tickets .icon.icon-related svg{ background-color: #afdbff; border-radius: 10px; @@ -61,6 +60,41 @@ } +#linked-tickets .ticket #ticket-icon { + display: inline-block; + width: 20px; + line-height: 30px; + vertical-align: middle; + padding: 0px; + margin: 0px 5px 0px 0px; + height: 20px; +} +/* +#ticket-icon div { + background-color: #e79b37; + display: inline; + width: 20px; + line-height: 30px; + vertical-align: middle; + padding: 0px; + margin: 0px; + +}*/ + +#linked-tickets .ticket #ticket-icon svg { + /*background-color: #e79b37;*/ + /*background-color: tomato;*/ + display: inline; + /*width: 20px;*/ + height: 22px; + padding: 1px; + border: none; + border-radius: 10px; + /*line-height: 20px;*/ + +} + + #ticket-additional-data { padding-right: 10px; font-size: 12pt; @@ -329,32 +363,32 @@ } -h3.incident-ticket { +.incident-ticket { background-color: #f7baba; } -h3.request-ticket { +.request-ticket { background-color: #f7e9ba; } -h3.change-ticket { +.change-ticket { background-color: #badff7; } -h3.problem-ticket { +.problem-ticket { background-color: #f7d0ba; } -h3.issue-ticket { +.issue-ticket { background-color: #baf7db; } -h3.project_task-ticket { +.project_task-ticket { background-color: #c5baf7; } From 5f3b12a4720df692b0665bf03b0bf9c09520472d Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 11:16:10 +0930 Subject: [PATCH 044/321] chore(core): clean up ticket css ref: #250 #252 #96 #93 #95 #90 #115 --- app/project-static/ticketing.css | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 89125016..444bda76 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -69,28 +69,13 @@ margin: 0px 5px 0px 0px; height: 20px; } -/* -#ticket-icon div { - background-color: #e79b37; - display: inline; - width: 20px; - line-height: 30px; - vertical-align: middle; - padding: 0px; - margin: 0px; - -}*/ #linked-tickets .ticket #ticket-icon svg { - /*background-color: #e79b37;*/ - /*background-color: tomato;*/ display: inline; - /*width: 20px;*/ height: 22px; padding: 1px; border: none; border-radius: 10px; - /*line-height: 20px;*/ } @@ -152,7 +137,6 @@ } #ticket-description div { - /*background-color: tomato;*/ padding: 10px; } From 0535674a96fa1aeba99f29fe4b60b541d1f87d34 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 11:22:13 +0930 Subject: [PATCH 045/321] docs(core): document get_dynamic_permissions function ref: #252 --- app/core/views/common.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/core/views/common.py b/app/core/views/common.py index 42f86c92..8046f6f3 100644 --- a/app/core/views/common.py +++ b/app/core/views/common.py @@ -14,6 +14,10 @@ from settings.models.user_settings import UserSettings class View(OrganizationPermission): """ Abstract class common to all views + ## Functions + + - `get_dynamic_permissions()` A function to build and return the permissions for the view + !!! Danger Don't directly use this class within your view as it's already assigned to the views that require it. """ From 31bc1e4e7609083e0dea687a5c90417773eebda7 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 11:25:08 +0930 Subject: [PATCH 046/321] fix(access): correct permission check to cater for is_global=None ref: #250 #252 #96 #93 #95 #90 #115 --- app/access/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/access/models.py b/app/access/models.py index 03c91a73..e614fba1 100644 --- a/app/access/models.py +++ b/app/access/models.py @@ -124,7 +124,7 @@ 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: if self.model.is_global: From 638ea466f0a229e7cabe51aa25fffea493f20aab Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 11:47:02 +0930 Subject: [PATCH 047/321] docs(core): initial docs pages for v1.2 ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/templates/core/ticket.html.j2 | 2 +- .../templates/core/ticket/comment/comment.html.j2 | 2 +- app/core/templatetags/tickets.py | 13 ------------- .../centurion_erp/administration/core/ticketing.md | 8 ++++++++ docs/projects/centurion_erp/development/forms.md | 2 +- .../projects/centurion_erp/development/templates.md | 4 +--- docs/projects/centurion_erp/development/testing.md | 2 +- docs/projects/centurion_erp/user/core/tickets.md | 8 ++++++++ .../user/project_management/index.md | 0 .../user/project_management/project.md | 0 mkdocs.yml | 10 ++++++++++ 11 files changed, 31 insertions(+), 20 deletions(-) delete mode 100644 app/core/templatetags/tickets.py create mode 100644 docs/projects/centurion_erp/administration/core/ticketing.md create mode 100644 docs/projects/centurion_erp/user/core/tickets.md rename docs/projects/{django-template => centurion_erp}/user/project_management/index.md (100%) rename docs/projects/{django-template => centurion_erp}/user/project_management/project.md (100%) diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index 19006109..e287ce2c 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -5,7 +5,7 @@ {% endblock additional-stylesheet %} -{% load tickets %} +{% load markdown %} {% block article %} diff --git a/app/core/templates/core/ticket/comment/comment.html.j2 b/app/core/templates/core/ticket/comment/comment.html.j2 index 06ccb139..da3b052e 100644 --- a/app/core/templates/core/ticket/comment/comment.html.j2 +++ b/app/core/templates/core/ticket/comment/comment.html.j2 @@ -1,4 +1,4 @@ -{% load tickets %} +{% load markdown %} {% if comment %} diff --git a/app/core/templatetags/tickets.py b/app/core/templatetags/tickets.py deleted file mode 100644 index 49e3e7ca..00000000 --- a/app/core/templatetags/tickets.py +++ /dev/null @@ -1,13 +0,0 @@ -from django import template -from django.template.defaultfilters import stringfilter - -import markdown as md - -register = template.Library() - - -@register.filter() -@stringfilter -def markdown(value): - - return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) diff --git a/docs/projects/centurion_erp/administration/core/ticketing.md b/docs/projects/centurion_erp/administration/core/ticketing.md new file mode 100644 index 00000000..44ebc817 --- /dev/null +++ b/docs/projects/centurion_erp/administration/core/ticketing.md @@ -0,0 +1,8 @@ +--- +title: Tickets +description: Tickets administration documentation for Centurion ERP by No Fuss Computing +date: 2024-08-27 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + diff --git a/docs/projects/centurion_erp/development/forms.md b/docs/projects/centurion_erp/development/forms.md index ab2795c7..35a62ec0 100644 --- a/docs/projects/centurion_erp/development/forms.md +++ b/docs/projects/centurion_erp/development/forms.md @@ -51,6 +51,7 @@ All forms must meet the following requirements: ``` + ## Details Form A details form is for the display of a models data. This form should inherit from a base form and contain any additional fields as is required for the display of the models data. Additional requirements are as follows: @@ -63,7 +64,6 @@ A details form is for the display of a models data. This form should inherit fro Ensure that there is a call to the super-class `__init__` method so that the form is correctly initialised. i.e. `super().__init__(*args, **kwargs)` - ## Abstract Classes The following abstract classes exist for a forms inheritance: diff --git a/docs/projects/centurion_erp/development/templates.md b/docs/projects/centurion_erp/development/templates.md index d4e86dcd..de835b8b 100644 --- a/docs/projects/centurion_erp/development/templates.md +++ b/docs/projects/centurion_erp/development/templates.md @@ -110,9 +110,7 @@ Base definition for defining a detail page is as follows: For the view to render the page, you must define the data as part of the form class. - - - The variable name to use is `tabs` The layout/schema is as follows: +The variable name to use is `tabs` The layout/schema is as follows: ##### Full Example diff --git a/docs/projects/centurion_erp/development/testing.md b/docs/projects/centurion_erp/development/testing.md index 4df71704..fed58be3 100644 --- a/docs/projects/centurion_erp/development/testing.md +++ b/docs/projects/centurion_erp/development/testing.md @@ -55,7 +55,7 @@ class DeviceHistory(TestCase, HistoryEntry, HistoryEntryParentItem): Each module is to contain a tests directory of the model being tested with a single file for grouping of what is being tested. for items that depend upon a parent model, the test file is to be within the child-models test directory named with format `test___` -_example file system structure showing the layout of the tests directory for a module_ +example file system structure showing the layout of the tests directory for a module. ``` text . diff --git a/docs/projects/centurion_erp/user/core/tickets.md b/docs/projects/centurion_erp/user/core/tickets.md new file mode 100644 index 00000000..2db63445 --- /dev/null +++ b/docs/projects/centurion_erp/user/core/tickets.md @@ -0,0 +1,8 @@ +--- +title: Tickets +description: Ticket system Documentation as part of the Core Module for Centurion ERP by No Fuss Computing +date: 2024-08-23 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + diff --git a/docs/projects/django-template/user/project_management/index.md b/docs/projects/centurion_erp/user/project_management/index.md similarity index 100% rename from docs/projects/django-template/user/project_management/index.md rename to docs/projects/centurion_erp/user/project_management/index.md diff --git a/docs/projects/django-template/user/project_management/project.md b/docs/projects/centurion_erp/user/project_management/project.md similarity index 100% rename from docs/projects/django-template/user/project_management/project.md rename to docs/projects/centurion_erp/user/project_management/project.md diff --git a/mkdocs.yml b/mkdocs.yml index 17787f85..23b6182a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,8 @@ nav: - projects/centurion_erp/administration/backup.md + - projects/centurion_erp/administration/core/ticketing.md + - projects/centurion_erp/administration/installation.md - Development: @@ -188,6 +190,8 @@ nav: - projects/centurion_erp/user/core/index.md + - projects/centurion_erp/user/core/tickets.md + - ITAM: - projects/centurion_erp/user/itam/index.md @@ -210,6 +214,12 @@ nav: - projects/centurion_erp/user/itim/service.md + - Project Management: + + - projects/centurion_erp/user/project_management/index.md + + - projects/centurion_erp/user/project_management/project.md + - Settings: - projects/centurion_erp/user/settings/index.md From b709839c38556308030a966ae18a7f0e7153f8c1 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 12:25:09 +0930 Subject: [PATCH 048/321] refactor(access): Add definable parameters to organization mixin ref: #252 --- app/access/mixin.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/access/mixin.py b/app/access/mixin.py index bc95e021..ac603a4a 100644 --- a/app/access/mixin.py +++ b/app/access/mixin.py @@ -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,7 +39,7 @@ 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: @@ -43,7 +56,7 @@ class OrganizationMixin(): self.get_queryset() - if hasattr(self, 'parent_model'): + if self.parent_model: obj = self.get_parent_obj() id = obj.get_organization().id From 28fe89e0480aae1f4c05a1de900153ac95c4d6d0 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 13:16:02 +0930 Subject: [PATCH 049/321] feat(core): Ticket comment orgaanization set to ticket organization ref: #250 #252 #96 #93 #95 #90 #115 --- ...005_ticket_relatedtickets_ticketcomment.py | 4 +- app/core/models/ticket/ticket_comment.py | 6 + .../administration/core/ticketing.md | 114 ++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py index d546057e..2a9fcafc 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-08-27 07:46 +# Generated by Django 5.0.8 on 2024-08-31 03:00 import access.fields import access.models @@ -27,7 +27,7 @@ class Migration(migrations.Migration): ('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.AutoCreatedField(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')], default=2, help_text='Status of ticket', verbose_name='Status')), + ('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=50, unique=True, verbose_name='Title')), ('description', models.TextField(default=None, 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')), diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 7c82d5cf..67b40544 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -409,6 +409,12 @@ class TicketComment( return self.ticket + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): + + self.organization = self.ticket.organization + + super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) + @property def threads(self): diff --git a/docs/projects/centurion_erp/administration/core/ticketing.md b/docs/projects/centurion_erp/administration/core/ticketing.md index 44ebc817..f308f70d 100644 --- a/docs/projects/centurion_erp/administration/core/ticketing.md +++ b/docs/projects/centurion_erp/administration/core/ticketing.md @@ -6,3 +6,117 @@ template: project.html about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp --- +The ticketing system within Centurion ERP is common to all ticket types. Available ticket types are as follows: + +- Change + +- Incident + +- Problem + +- Request + +In addition the following items within Centurion ERP use the ticketing system: + +- Git Issue + +- Git Merge/Pull Request + +- Project Task + + +## Permissions + +Centurion's Tickets have the following permissions: + +- add + +- change + +- comment + +- delete + +- import + +- purge + +- triage + +- view + +Each permission above is constructed into a permission value of `_ticket_`. For instance an add permission for a request ticket would be constructed as `add_ticket_request`. + +Some fields within a ticket are permission based. This means that if a user is missing that permission, they will not be able to change the field in question. The user should not be presented any field they do not have permission to adjust. However if they do manage to change the field, they will be presented with a `HTTP/403` error. + +Permissions are exclusive. If a user has not been assigned a permission, they can not perform that action. for instance, if you were to grant a user the "triage" permission, they will not be able to "add" or "view" a ticket. This is by design. + +!!! info + A super-user has permissions to work on any ticket. This also includes any fields. + + +### Add + +The add permissions is designated to allow users to create the ticket. This permission would typically be assigned so that a user could create a ticket A user with this permission can add/edit the following fields: + +- `title` + +- `description` + +- `urgency` + + +### Change + + +### Comment + +The comment permission is designated to allow users to comment on a ticket. THis permission would typically be assigned to a user so they can comment on a ticket. + + +### Delete + +The add permissions is designated to allow users to delete a ticket. This permission would typically be assigned so that a user could delete a ticket. A user with this permission can add/edit the following fields: + +- `is_deleted` + +!!! info + When a ticket is deleted, it still exists within the database. To completely remove a ticket from the database, the `purge` permission is required. + + +### Import + +The import permissions is designated to allow users to import tickets. This permission would typically be assigned so that a user could import tickets from a different ticketing system into Centurion. A user with this permission can add/edit the following fields: + +- `external_ref`, +- `external_system` +- `created` +- `date_closed` + + +### Purge + +The purge permissions is designated to allow users to remove tickets from the database to permanently delete them. + + +### Triage + +The triage permissions is designated to allow users to triage tickets. This permission would typically be assigned so that a user can work towards solving the ticket. A user with this permission can add/edit the following fields: + +- `impact` +- `priority` +- `project` +- `opened_by` +- `subscribed_users` +- `subscribed_teams` +- `assigned_users` +- `assigned_teams` +- `planned_start_date` +- `planned_finish_date` +- `real_start_date` +- `real_finish_date` + + +### View + +The view permissions is designated to allow users to view **ALL** tickets. This is caveated in the fact that a user will always be able to see their own tickets. The permission would typically be assigned to those whom work with tickets. From 8242d9f26904ec37ff469ba8fa277e0a0e1a388e Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 13:17:04 +0930 Subject: [PATCH 050/321] fix(core): Correct ticket comment permissions ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/views/ticket_comment.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/app/core/views/ticket_comment.py b/app/core/views/ticket_comment.py index 1f486ffd..c86ec16e 100644 --- a/app/core/views/ticket_comment.py +++ b/app/core/views/ticket_comment.py @@ -8,7 +8,7 @@ from django_celery_results.models import TaskResult from access.mixin import OrganizationPermission from core.forms.ticket_comment import CommentForm, DetailForm -from core.models.ticket.ticket_comment import TicketComment +from core.models.ticket.ticket_comment import TicketComment, Ticket from core.views.common import AddView, ChangeView, DeleteView, IndexView from settings.models.user_settings import UserSettings @@ -20,12 +20,22 @@ class Add(AddView): form_class = CommentForm model = TicketComment - permission_required = [ - 'itam.add_device', - ] + + parent_model = Ticket + + parent_model_pk_kwarg = 'ticket_id' + template_name = 'form.html.j2' + def get_dynamic_permissions(self): + + return [ + str('core.add_ticketcomment'), + ] + + + def get_initial(self): initial_values: dict = { @@ -72,9 +82,13 @@ class Change(ChangeView): model = TicketComment - permission_required = [ - 'itim.change_cluster', - ] + + + def get_dynamic_permissions(self): + + return [ + str('core.change_ticketcomment'), + ] def get_context_data(self, **kwargs): From 6532d0e0d78ccafe122930fe0be432564a00ffe6 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 13:17:32 +0930 Subject: [PATCH 051/321] fix(core): dont remove hidden fields on ticket comment form ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/forms/ticket_comment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 7243abf4..27864658 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -108,7 +108,7 @@ class CommentForm(CommonModelForm): for field in original_fields: - if field not in comment_fields: + if field not in comment_fields and not self.fields[field].widget.is_hidden: del self.fields[field] From 7f138d4b6862e46369f72543f67932b01225e8be Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 13:19:49 +0930 Subject: [PATCH 052/321] feat(core): Ensure for tenancy objects that the organization is set ref: #252 --- app/access/models.py | 9 +++++++++ .../tests/unit/tenancy_object/test_tenancy_object.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/access/models.py b/app/access/models.py index e614fba1..f0acca2a 100644 --- a/app/access/models.py +++ b/app/access/models.py @@ -196,6 +196,15 @@ class TenancyObject(SaveHistory): def get_organization(self) -> Organization: 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): diff --git a/app/access/tests/unit/tenancy_object/test_tenancy_object.py b/app/access/tests/unit/tenancy_object/test_tenancy_object.py index c2107ccf..06ba22d5 100644 --- a/app/access/tests/unit/tenancy_object/test_tenancy_object.py +++ b/app/access/tests/unit/tenancy_object/test_tenancy_object.py @@ -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 From 097b3fe8b6b156b35b61425046348622e3a2d6fd Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 14:34:20 +0930 Subject: [PATCH 053/321] feat(core): Add api validation for ticket ref: #250 #252 #96 #93 #95 #90 #115 --- app/api/serializers/itim/ticket.py | 25 ++++- app/api/views/core/tickets.py | 9 ++ app/core/forms/validate_ticket.py | 94 ++++++++++++++++--- .../0004_alter_service_config_key_variable.py | 19 ++++ 4 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 app/itim/migrations/0004_alter_service_config_key_variable.py diff --git a/app/api/serializers/itim/ticket.py b/app/api/serializers/itim/ticket.py index 13ff0275..213cbf6b 100644 --- a/app/api/serializers/itim/ticket.py +++ b/app/api/serializers/itim/ticket.py @@ -4,11 +4,15 @@ from rest_framework import serializers from api.serializers.itim.ticket_comment import TicketCommentSerializer +from core.forms.validate_ticket import TicketValidation from core.models.ticket.ticket import Ticket -class TicketSerializer(serializers.ModelSerializer): +class TicketSerializer( + serializers.ModelSerializer, + TicketValidation, +): url = serializers.HyperlinkedIdentityField( view_name="API:_api_core_tickets-detail", format="html" @@ -59,3 +63,22 @@ class TicketSerializer(serializers.ModelSerializer): 'id', 'url', ] + + + def is_valid(self, *, raise_exception=True) -> bool: + + self.request = self._context['request'] + + is_valid = super().is_valid(raise_exception=raise_exception) + + ticket_type_choice_id = int(self.instance.ticket_type - 1) + + self._ticket_type = str(self.fields['ticket_type'].choices[self.instance.ticket_type]).lower().replace(' ', '_') + + if self.instance.pk: + + self.original_object = self.Meta.model.objects.get(pk=self.instance.pk) + + is_valid = self.validate_ticket() + + return is_valid diff --git a/app/api/views/core/tickets.py b/app/api/views/core/tickets.py index e3891dbb..4cc07325 100644 --- a/app/api/views/core/tickets.py +++ b/app/api/views/core/tickets.py @@ -19,6 +19,15 @@ class View(OrganizationMixin, viewsets.ModelViewSet): OrganizationPermissionAPI ] + def get_permission_required(self): + + self.permission_required = [ + 'core.view_ticket_request', + ] + + return super().get_permission_required() + + queryset = Ticket.objects.all() serializer_class = TicketSerializer diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 748dd0fb..233730cc 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -1,12 +1,25 @@ from django.core.exceptions import PermissionDenied from django.forms import 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 + + 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 @@ -162,50 +175,68 @@ class TicketValidation( self._ticket_type = self.initial['type_ticket'] - - + + if hasattr(self, 'cleaned_data'): + + field = self.cleaned_data['status'] + + else: + + field = self.validated_data['status'] + + if self._ticket_type == 'request': - if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Request._value2member_map_: + if field in self.Meta.model.TicketStatus.Request._value2member_map_: is_valid = True elif self._ticket_type == 'incident': - if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Incident._value2member_map_: + if field in self.Meta.model.TicketStatus.Incident._value2member_map_: is_valid = True elif self._ticket_type == 'problem': - if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Problem._value2member_map_: + if field in self.Meta.model.TicketStatus.Problem._value2member_map_: is_valid = True elif self._ticket_type == 'change': - if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Change._value2member_map_: + if field in self.Meta.model.TicketStatus.Change._value2member_map_: is_valid = True elif self._ticket_type == 'issue': - if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Issue._value2member_map_: + if field in self.Meta.model.TicketStatus.Issue._value2member_map_: is_valid = True elif self._ticket_type == 'merge': - if self.cleaned_data['status'] in self.Meta.model.TicketStatus.Merge._value2member_map_: + if field in self.Meta.model.TicketStatus.Merge._value2member_map_: is_valid = True elif self._ticket_type == 'project_task': - if self.cleaned_data['status'] in self.Meta.model.TicketStatus.ProjectTask._value2member_map_: + 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: + + raise ValidationError('Incorrect Status set') return is_valid @@ -214,9 +245,50 @@ class TicketValidation( def validate_ticket(self): """Validations common to all ticket types.""" - self.validate_field_permission() + is_valid = False - self.validate_field_status() + if hasattr(self, 'validated_data'): + + changed_data: list = [] + + changed_data_exempt = [ + 'ticket_comments', + 'url', + ] + + for field in self.validated_data: + + if field in changed_data_exempt: + continue + + if ( + self.validated_data[field] != getattr(self.original_object, field) + and ( + type(self.validated_data[field]) in [str, int, bool] + ) + ) : + + changed_data = changed_data + [ field ] + + if len(changed_data) > 0: + + self.changed_data = changed_data + + validate_field_permission = False + if self.validate_field_permission(): + + validate_field_permission = True + + + validate_field_status = False + if self.validate_field_status(): + + validate_field_status = True + + if validate_field_permission and validate_field_status: + is_valid = True + + return is_valid diff --git a/app/itim/migrations/0004_alter_service_config_key_variable.py b/app/itim/migrations/0004_alter_service_config_key_variable.py new file mode 100644 index 00000000..626826a0 --- /dev/null +++ b/app/itim/migrations/0004_alter_service_config_key_variable.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.7 on 2024-08-23 13:51 + +import itim.models.services +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('itim', '0003_clustertype_config'), + ] + + operations = [ + migrations.AlterField( + model_name='service', + name='config_key_variable', + field=models.CharField(blank=True, help_text='Key name to use when merging with cluster/device config.', max_length=50, null=True, validators=[itim.models.services.Service.validate_config_key_variable], verbose_name='Configuration Key'), + ), + ] From 8662feb1c7260d16f7469f9a4caaa2646e940b40 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 15:27:08 +0930 Subject: [PATCH 054/321] feat(api): Ensure device can add/edit organization ref: #252 --- app/api/serializers/itam/device.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/api/serializers/itam/device.py b/app/api/serializers/itam/device.py index 3517c867..1001c487 100644 --- a/app/api/serializers/itam/device.py +++ b/app/api/serializers/itam/device.py @@ -53,7 +53,6 @@ class DeviceSerializer(serializers.ModelSerializer): class Meta: model = Device - depth = 1 fields = [ 'id', 'is_global', From 011a6c156ea855616332651a6692bcc504f65175 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 15:28:42 +0930 Subject: [PATCH 055/321] test: Ensure tests add organization to tenancy objects on creation ref: #252 --- app/api/tests/unit/inventory/test_api_inventory.py | 8 +++++--- .../test_device_operating_system.py | 1 + .../tests/unit/device_software/test_device_software.py | 3 ++- .../test_operating_system_version.py | 1 + app/itam/tests/unit/software/test_software_api.py | 6 ++++-- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/api/tests/unit/inventory/test_api_inventory.py b/app/api/tests/unit/inventory/test_api_inventory.py index 0ddf27e3..c9edf985 100644 --- a/app/api/tests/unit/inventory/test_api_inventory.py +++ b/app/api/tests/unit/inventory/test_api_inventory.py @@ -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( diff --git a/app/itam/tests/unit/device_operating_system/test_device_operating_system.py b/app/itam/tests/unit/device_operating_system/test_device_operating_system.py index 4e5cd664..a9646b02 100644 --- a/app/itam/tests/unit/device_operating_system/test_device_operating_system.py +++ b/app/itam/tests/unit/device_operating_system/test_device_operating_system.py @@ -45,6 +45,7 @@ class DeviceOperatingSystemModel( os_version = OperatingSystemVersion.objects.create( name = "12", operating_system = os, + organization=organization, ) diff --git a/app/itam/tests/unit/device_software/test_device_software.py b/app/itam/tests/unit/device_software/test_device_software.py index 776f9c73..942952af 100644 --- a/app/itam/tests/unit/device_software/test_device_software.py +++ b/app/itam/tests/unit/device_software/test_device_software.py @@ -43,7 +43,8 @@ class DeviceSoftwareModel( self.item = self.model.objects.create( software = self.software_item, - device = self.parent_item + device = self.parent_item, + organization=organization, ) diff --git a/app/itam/tests/unit/operating_system_version/test_operating_system_version.py b/app/itam/tests/unit/operating_system_version/test_operating_system_version.py index a510e3be..2b1c0392 100644 --- a/app/itam/tests/unit/operating_system_version/test_operating_system_version.py +++ b/app/itam/tests/unit/operating_system_version/test_operating_system_version.py @@ -39,6 +39,7 @@ class OperatingSystemVersionModel( self.item = self.model.objects.create( name = "12", operating_system = self.parent_item, + organization=organization, ) diff --git a/app/itam/tests/unit/software/test_software_api.py b/app/itam/tests/unit/software/test_software_api.py index 03130466..ad01f72a 100644 --- a/app/itam/tests/unit/software/test_software_api.py +++ b/app/itam/tests/unit/software/test_software_api.py @@ -44,11 +44,13 @@ class SoftwareAPI(TestCase): different_organization = Organization.objects.create(name='test_different_organization') category = SoftwareCategory.objects.create( - name='a category' + name='a category', + organization = organization, ) publisher = Manufacturer.objects.create( - name='a manufacturer' + name='a manufacturer', + organization = organization, ) From fe353904d8fb3f87ed1a9847755dab01c331bf24 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 15:29:40 +0930 Subject: [PATCH 056/321] test(itam): Refactor Device tests organization field to be editable. ref: #252 --- app/itam/tests/unit/device/test_device_api.py | 40 +------------------ 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/app/itam/tests/unit/device/test_device_api.py b/app/itam/tests/unit/device/test_device_api.py index 2b4aa259..3340a152 100644 --- a/app/itam/tests/unit/device/test_device_api.py +++ b/app/itam/tests/unit/device/test_device_api.py @@ -407,7 +407,7 @@ class DeviceAPI(TestCase): organization field must be dict """ - assert type(self.api_data['organization']) is dict + assert type(self.api_data['organization']) is int def test_api_field_exists_url(self): @@ -430,44 +430,6 @@ class DeviceAPI(TestCase): - def test_api_field_exists_organization_id(self): - """ Test for existance of API Field - - organization.id field must exist - """ - - assert 'id' in self.api_data['organization'] - - - def test_api_field_type_organization_id(self): - """ Test for type for API Field - - organization.id field must be int - """ - - assert type(self.api_data['organization']['id']) is int - - - def test_api_field_exists_organization_name(self): - """ Test for existance of API Field - - organization.name field must exist - """ - - assert 'name' in self.api_data['organization'] - - - def test_api_field_type_organization_name(self): - """ Test for type for API Field - - organization.name field must be str - """ - - assert type(self.api_data['organization']['name']) is str - - - - def test_api_field_exists_groups_id(self): """ Test for existance of API Field From d1b9283a9abfb43840e6a9a8c4770ed0252e4124 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 16:02:58 +0930 Subject: [PATCH 057/321] test: Add view must have function `get_initial` organization is set here for tenancy objects ref: #252 --- app/app/tests/abstract/views.py | 31 +++++++++++++++++++ .../test_config_management_views.py | 10 +++--- .../test_config_groups_software_views.py | 6 ++-- app/config_management/urls.py | 25 ++++++++------- app/config_management/views/groups/groups.py | 18 ++++++----- .../views/groups/software.py | 6 ++-- 6 files changed, 66 insertions(+), 30 deletions(-) diff --git a/app/app/tests/abstract/views.py b/app/app/tests/abstract/views.py index 62e3ed58..88b2ef4c 100644 --- a/app/app/tests/abstract/views.py +++ b/app/app/tests/abstract/views.py @@ -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, diff --git a/app/config_management/tests/unit/config_groups/test_config_management_views.py b/app/config_management/tests/unit/config_groups/test_config_management_views.py index a5d91f17..9e76fd87 100644 --- a/app/config_management/tests/unit/config_groups/test_config_management_views.py +++ b/app/config_management/tests/unit/config_groups/test_config_management_views.py @@ -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' diff --git a/app/config_management/tests/unit/config_groups_software/test_config_groups_software_views.py b/app/config_management/tests/unit/config_groups_software/test_config_groups_software_views.py index f37874e0..f3d40699 100644 --- a/app/config_management/tests/unit/config_groups_software/test_config_groups_software_views.py +++ b/app/config_management/tests/unit/config_groups_software/test_config_groups_software_views.py @@ -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' diff --git a/app/config_management/urls.py b/app/config_management/urls.py index 981ab628..287720d2 100644 --- a/app/config_management/urls.py +++ b/app/config_management/urls.py @@ -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/', GroupView.as_view(), name='_group_view'), - path('group//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/', groups.View.as_view(), name='_group_view'), + path('group//edit', groups.Change.as_view(), name='_group_change'), - path('group//child', GroupAdd.as_view(), name='_group_add_child'), - path('group//delete', GroupDelete.as_view(), name='_group_delete'), + path('group//child', groups.Add.as_view(), name='_group_add_child'), + path('group//delete', groups.Delete.as_view(), name='_group_delete'), - path("group//software/add", GroupSoftwareAdd.as_view(), name="_group_software_add"), - path("group//software/", GroupSoftwareChange.as_view(), name="_group_software_change"), - path("group//software//delete", GroupSoftwareDelete.as_view(), name="_group_software_delete"), + path("group//software/add", software.Add.as_view(), name="_group_software_add"), + path("group//software/", software.Change.as_view(), name="_group_software_change"), + path("group//software//delete", software.Delete.as_view(), name="_group_software_delete"), path('group//host', GroupHostAdd.as_view(), name='_group_add_host'), path('group//host//delete', GroupHostDelete.as_view(), name='_group_delete_host'), diff --git a/app/config_management/views/groups/groups.py b/app/config_management/views/groups/groups.py index be7be109..f1440033 100644 --- a/app/config_management/views/groups/groups.py +++ b/app/config_management/views/groups/groups.py @@ -18,7 +18,7 @@ from config_management.models.groups import ConfigGroups, ConfigGroupHosts, Conf -class GroupIndexView(IndexView): +class Index(IndexView): context_object_name = "groups" @@ -50,7 +50,7 @@ class GroupIndexView(IndexView): -class GroupAdd(AddView): +class Add(AddView): organization_field = 'organization' @@ -67,9 +67,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 +104,7 @@ class GroupAdd(AddView): -class GroupChange(ChangeView): +class Change(ChangeView): context_object_name = "group" @@ -132,7 +134,7 @@ class GroupChange(ChangeView): -class GroupView(ChangeView): +class View(ChangeView): context_object_name = "group" @@ -215,7 +217,7 @@ class GroupView(ChangeView): -class GroupDelete(DeleteView): +class Delete(DeleteView): model = ConfigGroups diff --git a/app/config_management/views/groups/software.py b/app/config_management/views/groups/software.py index deae6925..19ffcf4d 100644 --- a/app/config_management/views/groups/software.py +++ b/app/config_management/views/groups/software.py @@ -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 From f0b604b5dcbe4b3309dc9bd4a00093972e9b9a0b Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 16:58:56 +0930 Subject: [PATCH 058/321] feat(core): Use common function for markdown rendering for ticket objects ref: #250 #252 #96 #93 #95 #90 #115 --- app/app/settings.py | 4 ---- app/core/models/ticket/markdown.py | 10 +++------- app/core/models/ticket/ticket_comment.py | 4 ++-- app/core/templates/core/ticket.html.j2 | 3 +-- app/core/templates/core/ticket/comment/comment.html.j2 | 6 ++---- 5 files changed, 8 insertions(+), 19 deletions(-) diff --git a/app/app/settings.py b/app/app/settings.py index deac2587..10fa55c1 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -358,10 +358,6 @@ if DEBUG: "127.0.0.1", ] - # Apps Under Development - INSTALLED_APPS += [ - ] - if SSO_ENABLED: diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index e845a680..72cd6dfd 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -1,4 +1,4 @@ - +import markdown as md class TicketMarkdown: @@ -8,12 +8,8 @@ class TicketMarkdown: """ - def render_markdown(self, markdown): + def render_markdown(self, markdown_text): - # Requires context of ticket for ticket markdown - # Requires context of ticket for comment - # requires context of project for project task comment - - pass + return md.markdown(markdown_text, extensions=['markdown.extensions.fenced_code', 'codehilite']) diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 67b40544..d8783442 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -388,9 +388,9 @@ class TicketComment( @property - def markdown_description(self) -> str: + def markdown_body(self) -> str: - return self.render_markdown(self.description) + return self.render_markdown(self.body) @property def comment_template_queryset(self): diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index e287ce2c..8a7d99ce 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -5,7 +5,6 @@ {% endblock additional-stylesheet %} -{% load markdown %} {% block article %} @@ -18,7 +17,7 @@
-
{{ ticket.description | markdown | safe }}
+
{{ ticket.markdown_description | safe }}
diff --git a/app/core/templates/core/ticket/comment/comment.html.j2 b/app/core/templates/core/ticket/comment/comment.html.j2 index da3b052e..c1dce26c 100644 --- a/app/core/templates/core/ticket/comment/comment.html.j2 +++ b/app/core/templates/core/ticket/comment/comment.html.j2 @@ -1,10 +1,8 @@ -{% load markdown %} - {% if comment %} {% if comment.get_comment_type_display == 'Action' %} - {{ comment.action_comment | markdown | safe }} + {{ comment.markdown_body | safe }} {% 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' %}
@@ -78,7 +76,7 @@
- {{ comment.body | markdown | safe }} + {{ comment.markdown_body | safe }}

From 9132608aafb5acedf32dead3cfe7a5638d0dea86 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 31 Aug 2024 17:51:25 +0930 Subject: [PATCH 059/321] feat(core): render ticket number `#\d+` links within markdown ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/models/ticket/markdown.py | 33 ++++++++++++++++++- .../core/ticket/renderers/ticket_link.html.j2 | 15 +++++++++ app/project-static/ticketing.css | 27 +++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 app/core/templates/core/ticket/renderers/ticket_link.html.j2 diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index 72cd6dfd..2c1ee541 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -1,5 +1,7 @@ import markdown as md +import re +from django.template.loader import render_to_string class TicketMarkdown: """Ticket and Comment markdown functions @@ -10,6 +12,35 @@ class TicketMarkdown: def render_markdown(self, markdown_text): - + markdown_text = self.ticket_reference(markdown_text) return md.markdown(markdown_text, extensions=['markdown.extensions.fenced_code', 'codehilite']) + + + def build_ticket_html(self, match): + + ticket_id = match.group(1) + + if hasattr(self, 'ticket'): + + ticket = self.ticket.__class__.objects.get(pk=ticket_id) + + else: + + ticket = self.__class__.objects.get(pk=ticket_id) + + context: dict = { + 'id': ticket.id, + 'name': ticket, + 'ticket_type': str(ticket.get_ticket_type_display()).lower() + } + + html_link = render_to_string('core/ticket/renderers/ticket_link.html.j2', context) + + return str(html_link) + + + + def ticket_reference(self, text): + + return re.sub('#(\d+)', self.build_ticket_html, text) \ No newline at end of file diff --git a/app/core/templates/core/ticket/renderers/ticket_link.html.j2 b/app/core/templates/core/ticket/renderers/ticket_link.html.j2 new file mode 100644 index 00000000..386fc016 --- /dev/null +++ b/app/core/templates/core/ticket/renderers/ticket_link.html.j2 @@ -0,0 +1,15 @@ + + + {% include 'icons/ticket/ticket.svg' %} + + #{{ id }} {{ ticket_type }} {{ name }} + + \ No newline at end of file diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 444bda76..f95ba230 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -229,6 +229,33 @@ } +#rendered-ticket-link { + display: flexbox; + line-height: 30px; +} + +#rendered-ticket-link #icon { + display: inline-block; + line-height: 30px; + margin: 0px; + vertical-align: middle; +} + +#rendered-ticket-link #icon svg { + height: 20px; + margin: 0px; + margin: 0px; + line-height: inherit; +} + +#rendered-ticket-link #text{ + display: inline-block; + height: 20px; + line-height: inherit; + font-size: inherit; +} + + #ticket-comments #comment { border: 1px solid #177ee6; } From 6f2d431ae1a06682a1d32381c6579c8f8fb68404 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 12:11:06 +0930 Subject: [PATCH 060/321] fix(core): Ensure that the organization field is available ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/forms/ticket.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index 4722733b..2c1a8c64 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -51,6 +51,7 @@ class TicketForm( 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'].widget = self.fields['organization'].hidden_widget() original_fields = self.fields.copy() From 1829395a8a61a136ebf2a3fa616a394ef4046409 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 12:11:52 +0930 Subject: [PATCH 061/321] fix(core): Add `ticket_type` field to import_permissions ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/forms/validate_ticket.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 233730cc..0b15f200 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -53,6 +53,7 @@ class TicketValidation( 'real_finish_date', 'subscribed_users', 'subscribed_teams', + 'ticket_type', ] triage_fields: list = [ From b04b6fe645029f12aaee6ec5f0296b60c7def55c Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 12:12:22 +0930 Subject: [PATCH 062/321] fix(core): Ensure new ticket can be created ref: #250 #252 #96 #93 #95 #90 #115 --- app/api/serializers/itim/ticket.py | 15 ++++++---- app/core/forms/validate_ticket.py | 44 ++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/app/api/serializers/itim/ticket.py b/app/api/serializers/itim/ticket.py index 213cbf6b..1355a0ea 100644 --- a/app/api/serializers/itim/ticket.py +++ b/app/api/serializers/itim/ticket.py @@ -71,14 +71,19 @@ class TicketSerializer( is_valid = super().is_valid(raise_exception=raise_exception) - ticket_type_choice_id = int(self.instance.ticket_type - 1) + if self.instance: - self._ticket_type = str(self.fields['ticket_type'].choices[self.instance.ticket_type]).lower().replace(' ', '_') - - if self.instance.pk: - + ticket_type_choice_id = int(self.instance.ticket_type) self.original_object = self.Meta.model.objects.get(pk=self.instance.pk) + else: + + ticket_type_choice_id = int(self.initial_data['ticket_type']) + self.original_object = None + + self._ticket_type = str(self.fields['ticket_type'].choices[ticket_type_choice_id]).lower().replace(' ', '_') + + is_valid = self.validate_ticket() return is_valid diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 0b15f200..8f33496d 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -82,9 +82,22 @@ class TicketValidation( 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 self.has_organization_permission( - organization=self.instance.organization.id, + organization=ticket_organization.id, permissions_required = [ 'core.add_ticket_'+ self._ticket_type ], ) and not self.request.user.is_superuser: @@ -92,7 +105,7 @@ class TicketValidation( if self.has_organization_permission( - organization=self.instance.organization.id, + organization=ticket_organization.id, permissions_required = [ 'core.change_ticket_'+ self._ticket_type ], ) and not self.request.user.is_superuser: @@ -105,21 +118,21 @@ class TicketValidation( fields_allowed = fields_allowed + self.change_fields if self.has_organization_permission( - organization=self.instance.organization.id, + 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=self.instance.organization.id, + organization=ticket_organization.id, permissions_required = [ 'core.import_ticket_'+ self._ticket_type ], ) and not self.request.user.is_superuser: fields_allowed = fields_allowed + self.import_fields if self.has_organization_permission( - organization=self.instance.organization.id, + organization=ticket_organization.id, permissions_required = [ 'core.triage_ticket_'+ self._ticket_type ], ) and not self.request.user.is_superuser: @@ -262,14 +275,21 @@ class TicketValidation( if field in changed_data_exempt: continue - if ( - self.validated_data[field] != getattr(self.original_object, field) - and ( - type(self.validated_data[field]) in [str, int, bool] - ) - ) : + if self.original_object is not None: + if ( + self.validated_data[field] != getattr(self.original_object, field) + and ( + type(self.validated_data[field]) in [str, int, bool] + ) + ) : - changed_data = changed_data + [ field ] + changed_data = changed_data + [ field ] + else: + + + if type(self.validated_data[field]) in [str, int, bool]: + + changed_data = changed_data + [ field ] if len(changed_data) > 0: From 3ba89a926b78cf361abb11d8b04c7d86628f9a18 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 12:12:50 +0930 Subject: [PATCH 063/321] fix(core): Correct modified field to correct type ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/models/ticket/ticket.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 92cd8c51..b1f4165e 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -3,7 +3,7 @@ from django.db import models from django.db.models import Q from django.forms import ValidationError -from access.fields import AutoCreatedField +from access.fields import AutoCreatedField, AutoLastModifiedField from access.models import TenancyObject, Team from core.middleware.get_request import get_request @@ -109,9 +109,11 @@ class TicketCommonFields(models.Model): verbose_name = 'Number', ) - created = AutoCreatedField() + created = AutoCreatedField( + editable = True, + ) - modified = AutoCreatedField() + modified = AutoLastModifiedField() From 967b9251e23e70056589893c3f5fb6ac73b83375 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 13:03:09 +0930 Subject: [PATCH 064/321] feat(api): Set default values for ticket comment form to match ticket ref: #250 #252 #96 #93 #95 #90 #115 --- app/api/serializers/itim/ticket_comment.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/api/serializers/itim/ticket_comment.py b/app/api/serializers/itim/ticket_comment.py index ede46ca6..7442bbcc 100644 --- a/app/api/serializers/itim/ticket_comment.py +++ b/app/api/serializers/itim/ticket_comment.py @@ -1,8 +1,9 @@ from django.urls import reverse from rest_framework import serializers +from rest_framework.fields import empty -from core.models.ticket.ticket_comment import TicketComment +from core.models.ticket.ticket_comment import Ticket, TicketComment @@ -21,3 +22,17 @@ class TicketCommentSerializer(serializers.ModelSerializer): model = TicketComment fields = '__all__' + + + def __init__(self, instance=None, data=empty, **kwargs): + + if 'view' in self._kwargs['context']: + + ticket = Ticket.objects.get(pk=int(self._kwargs['context']['view'].kwargs['ticket_id'])) + self.fields.fields['organization'].initial = ticket.organization.id + + self.fields.fields['comment_type'].initial = TicketComment.CommentType.COMMENT + + self.fields.fields['ticket'].initial = int(self._kwargs['context']['view'].kwargs['ticket_id']) + + super().__init__(instance=instance, data=data, **kwargs) From 97326565561e90c06ef3f2a7b6efe3ad802fb6c9 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 13:03:45 +0930 Subject: [PATCH 065/321] fix(api): Filter ticket comments to match ticket ref: #250 #252 #96 #93 #95 #90 #115 --- app/api/views/core/ticket_comments.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/api/views/core/ticket_comments.py b/app/api/views/core/ticket_comments.py index 17e8a631..663c043a 100644 --- a/app/api/views/core/ticket_comments.py +++ b/app/api/views/core/ticket_comments.py @@ -38,6 +38,10 @@ class View(OrganizationMixin, viewsets.ModelViewSet): def get_queryset(self): + if 'ticket_id' in self.kwargs: + + self.queryset = self.queryset.filter(ticket=self.kwargs['ticket_id']) + if 'pk' in self.kwargs: self.queryset = self.queryset.filter(pk = self.kwargs['pk']) From c8d7b52fbf755b518f33bd493ed6d4bfdb8aa721 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 13:04:17 +0930 Subject: [PATCH 066/321] fix(core): Correct modified field to correct type for ticket comment ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/models/ticket/ticket_comment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index d8783442..b8c6c975 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from django.db import models from django.forms import ValidationError -from access.fields import AutoCreatedField +from access.fields import AutoCreatedField, AutoLastModifiedField from access.models import TenancyObject, Team from .markdown import TicketMarkdown @@ -165,7 +165,7 @@ class TicketComment( created = AutoCreatedField() - modified = AutoCreatedField() + modified = AutoLastModifiedField() private = models.BooleanField( blank = False, From 058e057088ea308ef251f56bdf95416e67773c06 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 13:05:22 +0930 Subject: [PATCH 067/321] feat(core): Enable ticket comment created date can be set when an import user ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/models/ticket/ticket_comment.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index b8c6c975..b7bec2a4 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -163,7 +163,9 @@ class TicketComment( verbose_name = 'Comment', ) - created = AutoCreatedField() + created = AutoCreatedField( + editable = True, + ) modified = AutoLastModifiedField() From 7829f4b7d8eb433bf8e79ca98f1bf91e0a83beb1 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 13:49:36 +0930 Subject: [PATCH 068/321] feat(core): Add ticket status icon ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/models/ticket/markdown.py | 3 +- .../templates/core/ticket/icon_status.html.j2 | 13 ++++ .../core/ticket/renderers/ticket_link.html.j2 | 2 +- .../icons/ticket/status_assigned.svg | 1 + .../templates/icons/ticket/status_pending.svg | 1 + .../templates/icons/ticket/status_solved.svg | 1 + app/project-static/ticketing.css | 72 +++++++++++++++---- 7 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 app/core/templates/core/ticket/icon_status.html.j2 create mode 100644 app/core/templates/icons/ticket/status_assigned.svg create mode 100644 app/core/templates/icons/ticket/status_pending.svg create mode 100644 app/core/templates/icons/ticket/status_solved.svg diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index 2c1ee541..29d27401 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -32,7 +32,8 @@ class TicketMarkdown: context: dict = { 'id': ticket.id, 'name': ticket, - 'ticket_type': str(ticket.get_ticket_type_display()).lower() + 'ticket_type': str(ticket.get_ticket_type_display()).lower(), + 'ticket_status': str(ticket.get_status_display()).lower(), } html_link = render_to_string('core/ticket/renderers/ticket_link.html.j2', context) diff --git a/app/core/templates/core/ticket/icon_status.html.j2 b/app/core/templates/core/ticket/icon_status.html.j2 new file mode 100644 index 00000000..5aac7ace --- /dev/null +++ b/app/core/templates/core/ticket/icon_status.html.j2 @@ -0,0 +1,13 @@ +{% if ticket_status == 'new' %} + {% include 'icons/ticket/add.svg' %} + {% elif ticket_status == 'assigned' %} + {% include 'icons/ticket/status_assigned.svg' %} + {% elif ticket_status == 'closed' %} + {% include 'icons/ticket/status_solved.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' %} +{% endif %} \ No newline at end of file diff --git a/app/core/templates/core/ticket/renderers/ticket_link.html.j2 b/app/core/templates/core/ticket/renderers/ticket_link.html.j2 index 386fc016..cb7a738d 100644 --- a/app/core/templates/core/ticket/renderers/ticket_link.html.j2 +++ b/app/core/templates/core/ticket/renderers/ticket_link.html.j2 @@ -1,6 +1,6 @@ - {% include 'icons/ticket/ticket.svg' %} + {% include 'core/ticket/icon_status.html.j2' %} \ No newline at end of file diff --git a/app/core/templates/icons/ticket/status_pending.svg b/app/core/templates/icons/ticket/status_pending.svg new file mode 100644 index 00000000..771a1c65 --- /dev/null +++ b/app/core/templates/icons/ticket/status_pending.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/status_solved.svg b/app/core/templates/icons/ticket/status_solved.svg new file mode 100644 index 00000000..aed4155f --- /dev/null +++ b/app/core/templates/icons/ticket/status_solved.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index f95ba230..7c05027e 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -234,20 +234,6 @@ line-height: 30px; } -#rendered-ticket-link #icon { - display: inline-block; - line-height: 30px; - margin: 0px; - vertical-align: middle; -} - -#rendered-ticket-link #icon svg { - height: 20px; - margin: 0px; - margin: 0px; - line-height: inherit; -} - #rendered-ticket-link #text{ display: inline-block; height: 20px; @@ -255,6 +241,64 @@ font-size: inherit; } +#icon.ticket-status { + display: inline-block; + width: 20px; + height: 20px; + line-height: 30px; + margin: 0px; + vertical-align: middle; +} + +#rendered-ticket-link #icon.ticket-status svg { + width: 20px; + height: 20px; + margin: 0px; + line-height: inherit; +} + +#icon.ticket-status-assigned svg { + background-color: #e1ffb2; + border: none; + border-radius: 10px; + fill: #2e9200; +} + +#icon.ticket-status-draft svg { + background-color: #cacaca; + border: none; + border-radius: 10px; + fill: #4d4d4d; +} + +#icon.ticket-status-new svg { + background-color: #b2dcff; + border: none; + border-radius: 10px; + fill: #004492; +} + +#icon.ticket-status-pending svg { + background-color: #ffceb2; + border: none; + border-radius: 10px; + fill: #d86100; +} + +#icon.ticket-status-solved svg { + background-color: #c9b2ff; + border: none; + border-radius: 10px; + fill: #640092; +} + +#icon.ticket-status-closed svg { + background-color: #c9b2ff; + border: none; + border-radius: 10px; + fill: #640092; +} + #ticket-comments #comment { border: 1px solid #177ee6; From ee17095a14f5c16c9fd4edd208f284ccb1e5ebba Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 14:40:09 +0930 Subject: [PATCH 069/321] chore(core): squash ticket migrations ref: #250 #252 #96 #93 #95 #90 #115 --- .../migrations/0005_ticket_relatedtickets_ticketcomment.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py index 2a9fcafc..64bcdee6 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-08-31 03:00 +# Generated by Django 5.0.8 on 2024-09-01 05:09 import access.fields import access.models @@ -26,7 +26,7 @@ class Migration(migrations.Migration): 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.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=50, unique=True, verbose_name='Title')), ('description', models.TextField(default=None, help_text='Ticket Description', verbose_name='Description')), @@ -80,7 +80,7 @@ class Migration(migrations.Migration): ('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(default=None, help_text='Comment contents', verbose_name='Comment')), ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), - ('modified', 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')), From 523341cf4a7e2b7d37d13d45e73d72071a52dd5f Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 16:44:52 +0930 Subject: [PATCH 070/321] docs(core): Add some ticketing docs ref: #250 #252 #96 #93 #95 #90 #115 --- app/api/serializers/itim/ticket_comment.py | 14 ++- app/api/views/core/ticket_comments.py | 48 +++++++- app/api/views/core/tickets.py | 49 +++++++- app/core/models/ticket/ticket.py | 18 ++- .../administration/core/ticketing.md | 106 ++++++++++++++++++ .../development/api/models/index.md | 4 + .../development/api/models/ticket.md | 76 +++++++++++++ .../centurion_erp/user/core/tickets.md | 13 +++ 8 files changed, 313 insertions(+), 15 deletions(-) create mode 100644 docs/projects/centurion_erp/development/api/models/ticket.md diff --git a/app/api/serializers/itim/ticket_comment.py b/app/api/serializers/itim/ticket_comment.py index 7442bbcc..0798412c 100644 --- a/app/api/serializers/itim/ticket_comment.py +++ b/app/api/serializers/itim/ticket_comment.py @@ -26,13 +26,17 @@ class TicketCommentSerializer(serializers.ModelSerializer): def __init__(self, instance=None, data=empty, **kwargs): - if 'view' in self._kwargs['context']: + if 'context' in self._kwargs: - ticket = Ticket.objects.get(pk=int(self._kwargs['context']['view'].kwargs['ticket_id'])) - self.fields.fields['organization'].initial = ticket.organization.id + if 'view' in self._kwargs['context']: - self.fields.fields['comment_type'].initial = TicketComment.CommentType.COMMENT + if 'ticket_id' in self._kwargs['context']['view'].kwargs: - self.fields.fields['ticket'].initial = int(self._kwargs['context']['view'].kwargs['ticket_id']) + 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 super().__init__(instance=instance, data=data, **kwargs) diff --git a/app/api/views/core/ticket_comments.py b/app/api/views/core/ticket_comments.py index 663c043a..04688219 100644 --- a/app/api/views/core/ticket_comments.py +++ b/app/api/views/core/ticket_comments.py @@ -1,6 +1,6 @@ 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 @@ -24,18 +24,60 @@ class View(OrganizationMixin, viewsets.ModelViewSet): serializer_class = TicketCommentSerializer - @extend_schema( description='Fetch all tickets', methods=["GET"]) + @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): + + 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, ticket_id): return super().list(request) - @extend_schema( description='Fetch the selected ticket', methods=["GET"]) + @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"], + ) + def update(self, request, *args, **kwargs): + + super().update(request, *args, **kwargs) + + def get_queryset(self): if 'ticket_id' in self.kwargs: diff --git a/app/api/views/core/tickets.py b/app/api/views/core/tickets.py index 4cc07325..67bc7b82 100644 --- a/app/api/views/core/tickets.py +++ b/app/api/views/core/tickets.py @@ -1,6 +1,6 @@ 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 @@ -33,18 +33,55 @@ class View(OrganizationMixin, viewsets.ModelViewSet): serializer_class = TicketSerializer - def get_object(self, queryset=None, **kwargs): - item = self.kwargs.get('pk') - return get_object_or_404(Ticket, pk=item) + # def get_object(self, queryset=None, **kwargs): + # item = self.kwargs.get('pk') + # return get_object_or_404(Ticket, pk=item) - @extend_schema( description='Fetch all tickets', methods=["GET"]) + @extend_schema( + summary='Create a 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. + """, + request = TicketSerializer, + responses = { + 201: OpenApiResponse(description='Ticket created', response=TicketSerializer), + 403: OpenApiResponse(description='User tried to edit field they dont have access to'), + } + ) + def create(self, request, *args, **kwargs): + + 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=TicketSerializer), + } + ) def list(self, request): return super().list(request) - @extend_schema( description='Fetch the selected ticket', methods=["GET"]) + @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"] + ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index b1f4165e..11fc11c3 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -342,7 +342,23 @@ class Ticket( class TicketType(models.IntegerChoices): - """Type of the ticket""" + """Centurion ERP has the following ticket types available: + + - Request + + - Incident + + - Change + + - Problem + + As we use a common model for **ALL** ticket types. Effort has been made to limit fields showing for a ticket type that it does not belong. + If you find a field displayed that does not belong to a ticket, please create an [issue](https://github.com/nofusscomputing/centurion_erp). + + !!! danger + The API does not filter ticket fields. It's important not to edit a field that does not belong to the ticket type selected, + as this will cause the ticket validation to fail. + """ REQUEST = '1', 'Request' INCIDENT = '2', 'Incident' diff --git a/docs/projects/centurion_erp/administration/core/ticketing.md b/docs/projects/centurion_erp/administration/core/ticketing.md index f308f70d..b4c69e92 100644 --- a/docs/projects/centurion_erp/administration/core/ticketing.md +++ b/docs/projects/centurion_erp/administration/core/ticketing.md @@ -25,6 +25,112 @@ In addition the following items within Centurion ERP use the ticketing system: - Project Task +## Ticket Fields + +Fields available for all ticket types: + +- `status` + +- `title` + +- `description` + +- `urgency` + +- `impact` + +- `priority` + +- `external_ref` + +- `external_system` + +- `ticket_type` + +- `project` + +- `opened_by` + +- `subscribed_users` + +- `subscribed_teams` + +- `assigned_users` + +- `assigned_teams` + +- `is_deleted` + +- `date_closed` + +- `planned_start_date` + +- `planned_finish_date` + +- `real_start_date` + +- `real_finish_date` + + +### Ticket Status + +::: app.core.models.ticket.ticket.Ticket.TicketStatus + options: + inherited_members: false + members: [] + show_bases: false + show_submodules: false + summary: true + + +### Ticket Urgency + +::: app.core.models.ticket.ticket.Ticket.TicketUrgency + options: + heading_level: 4 + inherited_members: false + show_bases: false + show_submodules: false + summary: true + show_category_heading: false + + +### Ticket Impact + +::: app.core.models.ticket.ticket.Ticket.TicketImpact + options: + heading_level: 4 + inherited_members: false + show_bases: false + show_submodules: false + summary: true + show_category_heading: false + + +### Ticket Priority + +::: app.core.models.ticket.ticket.Ticket.TicketPriority + options: + heading_level: 4 + inherited_members: false + show_bases: false + show_submodules: false + summary: true + show_category_heading: false + + +### Ticket External System + +::: app.core.models.ticket.ticket.Ticket.Ticket_ExternalSystem + options: + heading_level: 4 + inherited_members: false + show_bases: false + show_submodules: false + summary: true + show_category_heading: false + + ## Permissions Centurion's Tickets have the following permissions: diff --git a/docs/projects/centurion_erp/development/api/models/index.md b/docs/projects/centurion_erp/development/api/models/index.md index d744dc32..7cfef88f 100644 --- a/docs/projects/centurion_erp/development/api/models/index.md +++ b/docs/projects/centurion_erp/development/api/models/index.md @@ -8,6 +8,10 @@ about: https://gitlab.com/nofusscomputing/infrastructure/configuration-managemen Models within Centurion ERP: +- core + + - [Ticket](./ticket.md) + - itam - [Device](./itam_device.md) diff --git a/docs/projects/centurion_erp/development/api/models/ticket.md b/docs/projects/centurion_erp/development/api/models/ticket.md new file mode 100644 index 00000000..f5acae1a --- /dev/null +++ b/docs/projects/centurion_erp/development/api/models/ticket.md @@ -0,0 +1,76 @@ +--- +title: Ticket Object +description: No Fuss Computings Centurion ERP Ticket object API Documentation +date: 2024-09-01 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +This page contains the Ticket API Documentation. + + +## Ticket Status + +::: app.core.models.ticket.ticket.Ticket.TicketStatus + options: + members: [] + inherited_members: false + heading_level: 3 + show_submodules: false + summary: true + + +## Ticket External System + +::: app.core.models.ticket.ticket.Ticket.Ticket_ExternalSystem + options: + inherited_members: false + heading_level: 3 + + +## Ticket Type + +::: app.core.models.ticket.ticket.Ticket.TicketType + options: + inherited_members: false + heading_level: 3 + + +## Ticket Urgency + +::: app.core.models.ticket.ticket.Ticket.TicketUrgency + options: + inherited_members: false + heading_level: 3 + + +## Ticket Impact + +::: app.core.models.ticket.ticket.Ticket.TicketImpact + options: + inherited_members: false + heading_level: 3 + + +## Ticket Priority + +::: app.core.models.ticket.ticket.Ticket.TicketPriority + options: + inherited_members: false + heading_level: 3 + + +## Ticket Object Model Abstract class + +::: app.core.models.ticket.ticket.Ticket + options: + docstring_section_style: table + inherited_members: false + heading_level: 3 + filters: + - "!TicketStatus" + - "!Ticket_ExternalSystem" + - "!TicketType" + - "!TicketUrgency" + - "!TicketImpact" + - "!TicketPriority" diff --git a/docs/projects/centurion_erp/user/core/tickets.md b/docs/projects/centurion_erp/user/core/tickets.md index 2db63445..332adcd4 100644 --- a/docs/projects/centurion_erp/user/core/tickets.md +++ b/docs/projects/centurion_erp/user/core/tickets.md @@ -6,3 +6,16 @@ template: project.html about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp --- +The ticketing system within Centurion ERP is common to all ticket types. The differences are primarily fields and the value of fields. + + +## Ticket Types + +::: app.core.models.ticket.ticket.Ticket.TicketType + options: + inherited_members: false + members: [] + show_bases: false + show_submodules: false + summary: true + From 0b86ded4f5ee582c401c64385449316bd8d4dbbc Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 16:58:10 +0930 Subject: [PATCH 071/321] chore(core): Add Ticket Comment validation class ref: #250 #252 #96 #93 #95 #90 #115 #257 --- app/core/forms/ticket_comment.py | 13 +++++++++- app/core/forms/validate_ticket_comment.py | 29 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 app/core/forms/validate_ticket_comment.py diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 27864658..1963ed20 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -4,11 +4,16 @@ 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): + +class CommentForm( + CommonModelForm, + TicketCommentValidation +): prefix = 'ticket' @@ -122,6 +127,12 @@ class CommentForm(CommonModelForm): 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 diff --git a/app/core/forms/validate_ticket_comment.py b/app/core/forms/validate_ticket_comment.py new file mode 100644 index 00000000..819584bf --- /dev/null +++ b/app/core/forms/validate_ticket_comment.py @@ -0,0 +1,29 @@ +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 + + @property + def fields_allowed(self): + + pass + + + def validate_field_permission(self): + pass + + + + def validate_ticket_comment(self): + + pass From ba8b618b7dbb9e8488019c83fabaee27e594bb26 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 1 Sep 2024 17:01:29 +0930 Subject: [PATCH 072/321] chore(core): update validate field permission docstring ref: #250 #252 #96 #93 #95 #90 #115 --- app/core/forms/validate_ticket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 8f33496d..c169dc1d 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -159,7 +159,7 @@ class TicketValidation( Raises: PermissionDenied: Access Denied when user has no ticket permissions assigned - PermissionDenied: _description_ + PermissionDenied: User tried to edit a field they dont have permission to edit. """ fields_allowed = self.fields_allowed From d7c3e051ded8551ea99cbd5531ba1fea45f4d551 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 12:33:54 +0930 Subject: [PATCH 073/321] refactor(core): Move allowed fields logic to own function ref: #250 #96 #93 #95 #90 #257 --- CONTRIBUTING.md | 2 +- app/core/forms/ticket_comment.py | 75 ++++++++--------------- app/core/forms/validate_ticket_comment.py | 59 +++++++++++++++++- 3 files changed, 84 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8dfe0f99..bb203108 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ from the root of the project to start a test server use: ``` bash # activate python venv -/tmp/centurion_erp/bin/activate +source /tmp/centurion_erp/bin/activate # enter app dir cd app diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 1963ed20..47e7025a 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -52,71 +52,46 @@ class CommentForm( self.fields['parent'].widget = self.fields['parent'].hidden_widget() self.fields['comment_type'].widget = self.fields['comment_type'].hidden_widget() + self._ticket_type = kwargs['initial']['type_ticket'] + if 'qs_comment_type' in kwargs['initial']: - comment_type = kwargs['initial']['qs_comment_type'] + self._comment_type = kwargs['initial']['qs_comment_type'] else: - comment_type = str(self.instance.get_comment_type_display()).lower() + self._comment_type = str(self.instance.get_comment_type_display()).lower() + if self._comment_type == 'task': + + self.fields['comment_type'].initial = self.Meta.model.CommentType.TASK + + elif self._comment_type == 'comment': + + self.fields['comment_type'].initial = self.Meta.model.CommentType.COMMENT + + elif self._comment_type == 'solution': + + self.fields['comment_type'].initial = self.Meta.model.CommentType.SOLUTION + + elif self._comment_type == 'notification': + + self.fields['comment_type'].initial = self.Meta.model.CommentType.NOTIFICATION + + + allowed_fields = self.fields_allowed + original_fields = self.fields.copy() - comment_fields = [] - - - if ( - kwargs['initial']['type_ticket'] == 'request' - or - kwargs['initial']['type_ticket'] == 'incident' - or - kwargs['initial']['type_ticket'] == 'problem' - or - kwargs['initial']['type_ticket'] == 'change' - or - kwargs['initial']['type_ticket'] == 'project_task' - ): - - if comment_type == 'task': - - comment_fields = self.Meta.model.fields_itsm_task - - self.fields['comment_type'].initial = self.Meta.model.CommentType.TASK - - elif comment_type == 'comment': - - comment_fields = self.Meta.model.common_itsm_fields - - self.fields['comment_type'].initial = self.Meta.model.CommentType.COMMENT - - - elif comment_type == 'solution': - - comment_fields = self.Meta.model.common_itsm_fields - - self.fields['comment_type'].initial = self.Meta.model.CommentType.SOLUTION - - elif comment_type == 'notification': - - comment_fields = self.Meta.model.fields_itsm_notification - - self.fields['comment_type'].initial = self.Meta.model.CommentType.NOTIFICATION - - elif kwargs['initial']['type_ticket'] == 'issue': - - comment_fields = self.Meta.model.fields_git_issue - - elif kwargs['initial']['type_ticket'] == 'merge': - - comment_fields = self.Meta.model.fields_git_merge for field in original_fields: - if field not in comment_fields and not self.fields[field].widget.is_hidden: + 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() diff --git a/app/core/forms/validate_ticket_comment.py b/app/core/forms/validate_ticket_comment.py index 819584bf..68106208 100644 --- a/app/core/forms/validate_ticket_comment.py +++ b/app/core/forms/validate_ticket_comment.py @@ -13,10 +13,67 @@ class TicketCommentValidation( original_object = None + _comment_type:str = None + """Human readable comment type. i.e. `request` in lowercase""" + + _ticket_type: str = None + """Human readable type of ticket. i.e. `request` in lowercase""" + + @property def fields_allowed(self): - pass + 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 + + return comment_fields + + def validate_field_permission(self): From 6df22314c906cab17281c36321b7d58ae1afda0f Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 13:29:29 +0930 Subject: [PATCH 074/321] feat(itam): Accept device UUID in any case. when saving, normalise to lowercase ref: #260 closes #261 --- app/itam/models/device.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/itam/models/device.py b/app/itam/models/device.py index 7cb1a012..1c46831e 100644 --- a/app/itam/models/device.py +++ b/app/itam/models/device.py @@ -70,11 +70,11 @@ class Device(DeviceCommonFieldsName, SaveHistory): def validate_uuid_format(self): - pattern = r'[0-9|a-f]{8}\-[0-9|a-f]{4}\-[0-9|a-f]{4}\-[0-9|a-f]{4}\-[0-9|a-f]{12}' + pattern = r'[0-9|a-f|A-F]{8}\-[0-9|a-f|A-F]{4}\-[0-9|a-f|A-F]{4}\-[0-9|a-f|A-F]{4}\-[0-9|a-f|A-F]{12}' if not re.match(pattern, str(self)): - raise ValidationError(f'UUID Must be in {str(pattern)}') + raise ValidationError(f'UUID must be formated to match regex {str(pattern)}') def validate_hostname_format(self): @@ -170,6 +170,11 @@ class Device(DeviceCommonFieldsName, SaveHistory): of the same organization as the device. """ + if self.uuid is not None: + + self.uuid = str(self.uuid).lower() + + super().save( force_insert=False, force_update=False, using=None, update_fields=None ) From 5793295e1a5f47bdd308c3c6b706d602ac879493 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 13:36:58 +0930 Subject: [PATCH 075/321] feat(core): pass request to ticket comment form ref: #250 #96 #93 #95 #90 #257 --- app/access/functions/permissions.py | 1 + app/core/forms/ticket_comment.py | 4 +++- app/core/views/ticket_comment.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/access/functions/permissions.py b/app/access/functions/permissions.py index 4753b680..dbecedf0 100644 --- a/app/access/functions/permissions.py +++ b/app/access/functions/permissions.py @@ -21,6 +21,7 @@ def permission_queryset(): exclude_models = [ 'appsettings', 'chordcounter', + 'comment', 'groupresult', 'organization' 'settings', diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 47e7025a..92e8f3c6 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -22,7 +22,9 @@ class CommentForm( fields = '__all__' - def __init__(self, *args, **kwargs): + def __init__(self, request, *args, **kwargs): + + self.request = request super().__init__(*args, **kwargs) diff --git a/app/core/views/ticket_comment.py b/app/core/views/ticket_comment.py index c86ec16e..045ea61d 100644 --- a/app/core/views/ticket_comment.py +++ b/app/core/views/ticket_comment.py @@ -35,6 +35,11 @@ class Add(AddView): ] + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + def get_initial(self): @@ -100,6 +105,12 @@ class Change(ChangeView): return context + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def get_initial(self): return { 'type_ticket': self.kwargs['ticket_type'], From f76f81a3127932f19222461650530818fff8adc7 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 13:37:57 +0930 Subject: [PATCH 076/321] feat(core): When fetching allowed ticket comment fields, check against permissions ref: #250 #96 #93 #95 #90 #257 --- app/core/forms/ticket_comment.py | 2 + app/core/forms/validate_ticket_comment.py | 127 +++++++++++++++++++++- 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 92e8f3c6..2a4a2138 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -54,6 +54,8 @@ class CommentForm( self.fields['parent'].widget = self.fields['parent'].hidden_widget() self.fields['comment_type'].widget = self.fields['comment_type'].hidden_widget() + 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']: diff --git a/app/core/forms/validate_ticket_comment.py b/app/core/forms/validate_ticket_comment.py index 68106208..f6a5c60c 100644 --- a/app/core/forms/validate_ticket_comment.py +++ b/app/core/forms/validate_ticket_comment.py @@ -16,12 +16,130 @@ class TicketCommentValidation( _comment_type:str = None """Human readable comment type. i.e. `request` in lowercase""" + _ticket_organization = None + """Ticket Organization as a organization object""" + _ticket_type: str = None """Human readable type of ticket. i.e. `request` in lowercase""" + 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', + '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 = [ + '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): + 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 + """ + + fields_allowed: list = [] + + + 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: + + fields_allowed = self.add_fields + + + if self.has_organization_permission( + organization=self._ticket_organization.id, + permissions_required = [ 'core.change_ticketcomment' ], + ) 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=self._ticket_organization.id, + permissions_required = [ 'core.delete_ticketcomment' ], + ) and not self.request.user.is_superuser: + + fields_allowed = fields_allowed + self.delete_fields + + if self.has_organization_permission( + organization=self._ticket_organization.id, + permissions_required = [ 'core.import_ticketcomment' ], + ) and not self.request.user.is_superuser: + + fields_allowed = fields_allowed + self.import_fields + + 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: + + 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 = [] @@ -71,6 +189,13 @@ class TicketCommentValidation( 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 From 978bcf3b45aeda8c68fbbd67de1f1f1ce159fa01 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 14:38:26 +0930 Subject: [PATCH 077/321] refactor(core): cache permission check for ticket types ref: #250 #96 #93 #95 #90 #257 --- app/core/forms/ticket_comment.py | 27 +++--- app/core/forms/validate_ticket_comment.py | 102 +++++++++++++++++----- 2 files changed, 97 insertions(+), 32 deletions(-) diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 2a4a2138..22f670e9 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -28,6 +28,21 @@ class CommentForm( 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" @@ -54,18 +69,6 @@ class CommentForm( self.fields['parent'].widget = self.fields['parent'].hidden_widget() self.fields['comment_type'].widget = self.fields['comment_type'].hidden_widget() - 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() - if self._comment_type == 'task': diff --git a/app/core/forms/validate_ticket_comment.py b/app/core/forms/validate_ticket_comment.py index f6a5c60c..e43cffc5 100644 --- a/app/core/forms/validate_ticket_comment.py +++ b/app/core/forms/validate_ticket_comment.py @@ -16,12 +16,24 @@ class TicketCommentValidation( _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' @@ -86,21 +98,20 @@ class TicketCommentValidation( 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_organization_permission( - organization=self._ticket_organization.id, - permissions_required = [ 'core.add_ticket_'+ self._ticket_type ], - ) and not self.request.user.is_superuser: + if self._has_add_permission and not self.request.user.is_superuser: fields_allowed = self.add_fields - if self.has_organization_permission( - organization=self._ticket_organization.id, - permissions_required = [ 'core.change_ticketcomment' ], - ) and not self.request.user.is_superuser: + if self._has_change_permission: if len(fields_allowed) == 0: @@ -110,24 +121,15 @@ class TicketCommentValidation( fields_allowed = fields_allowed + self.change_fields - if self.has_organization_permission( - organization=self._ticket_organization.id, - permissions_required = [ 'core.delete_ticketcomment' ], - ) and not self.request.user.is_superuser: + if self._has_delete_permission and not self.request.user.is_superuser: fields_allowed = fields_allowed + self.delete_fields - if self.has_organization_permission( - organization=self._ticket_organization.id, - permissions_required = [ 'core.import_ticketcomment' ], - ) and not self.request.user.is_superuser: + if self._has_import_permission and not self.request.user.is_superuser: fields_allowed = fields_allowed + self.import_fields - 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: + if self._has_triage_permission and not self.request.user.is_superuser: fields_allowed = fields_allowed + self.triage_fields @@ -199,6 +201,66 @@ class TicketCommentValidation( 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_field_permission(self): From b8253ae9babbc98ba3b944646af354c6805be917 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 14:39:01 +0930 Subject: [PATCH 078/321] feat(core): Ticket Comment source hidden for non-triage users ref: #250 #96 #93 #95 #90 #257 --- app/core/forms/ticket_comment.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 22f670e9..91c6df23 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -69,6 +69,12 @@ class CommentForm( 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 not self._has_triage_permission: + self.fields['source'].initial = TicketComment.CommentSource.HELPDESK + self.fields['source'].widget = self.fields['source'].hidden_widget() + + + if self._comment_type == 'task': From cf577bbb4f5780233fe980b441f7fda52328ac07 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 14:39:38 +0930 Subject: [PATCH 079/321] feat(core): Ticket Comment can be edited by owner ref: #250 #96 #93 #95 #90 #257 --- app/core/forms/validate_ticket_comment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/forms/validate_ticket_comment.py b/app/core/forms/validate_ticket_comment.py index e43cffc5..9bdae2d7 100644 --- a/app/core/forms/validate_ticket_comment.py +++ b/app/core/forms/validate_ticket_comment.py @@ -111,7 +111,7 @@ class TicketCommentValidation( fields_allowed = self.add_fields - if self._has_change_permission: + if self._has_change_permission and not self.request.user.is_superuser: if len(fields_allowed) == 0: From 5f6c36e823815652b5c0d77f1a2e8cfb811f5696 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 14:54:33 +0930 Subject: [PATCH 080/321] feat(core): Ticket Comment form submission validation ref: #250 #96 #93 #95 #90 #257 --- app/api/views/core/ticket_comments.py | 4 ++++ app/core/forms/validate_ticket_comment.py | 15 +++++++++++---- app/core/models/ticket/ticket_comment.py | 3 +-- docs/projects/centurion_erp/user/core/tickets.md | 12 ++++++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/api/views/core/ticket_comments.py b/app/api/views/core/ticket_comments.py index 04688219..87fc83e1 100644 --- a/app/api/views/core/ticket_comments.py +++ b/app/api/views/core/ticket_comments.py @@ -72,6 +72,10 @@ class View(OrganizationMixin, viewsets.ModelViewSet): 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): diff --git a/app/core/forms/validate_ticket_comment.py b/app/core/forms/validate_ticket_comment.py index 9bdae2d7..5f2807da 100644 --- a/app/core/forms/validate_ticket_comment.py +++ b/app/core/forms/validate_ticket_comment.py @@ -263,11 +263,18 @@ class TicketCommentValidation( raise PermissionDenied("You dont have permission for comment types: notification, task and solution") - def validate_field_permission(self): - pass + def validate_ticket_comment(self) -> bool: + is_valid: bool = True + self.ticket_comment_permissions - def validate_ticket_comment(self): + fields_allowed = self.fields_allowed - pass + 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 diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index b7bec2a4..2ef0e818 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -46,8 +46,7 @@ class TicketComment( class CommentType(models.IntegerChoices): - """Type of Comment - + """ Comment types are as follows: - Action diff --git a/docs/projects/centurion_erp/user/core/tickets.md b/docs/projects/centurion_erp/user/core/tickets.md index 332adcd4..d1d794c0 100644 --- a/docs/projects/centurion_erp/user/core/tickets.md +++ b/docs/projects/centurion_erp/user/core/tickets.md @@ -19,3 +19,15 @@ The ticketing system within Centurion ERP is common to all ticket types. The dif show_submodules: false summary: true + +## Ticket Comments + +Within Centurion ERP the ticket comment model is common to all comment types. As with tickets the differences are the available fields, which depend upon comment type and permissions. + +::: app.core.models.ticket.ticket_comment.TicketComment.CommentType + options: + inherited_members: false + members: [] + show_bases: false + show_submodules: false + summary: true From 910a00220142016eecde61d84b6d7b79181dd06c Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 15:16:23 +0930 Subject: [PATCH 081/321] feat(core): Allow OP to edit own Ticket Comment ref: #250 #96 #93 #95 #90 closes #257 --- app/core/forms/validate_ticket_comment.py | 8 +------- app/core/views/ticket_comment.py | 8 +++++++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/core/forms/validate_ticket_comment.py b/app/core/forms/validate_ticket_comment.py index 5f2807da..853b8a79 100644 --- a/app/core/forms/validate_ticket_comment.py +++ b/app/core/forms/validate_ticket_comment.py @@ -113,13 +113,7 @@ class TicketCommentValidation( if self._has_change_permission 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 + fields_allowed = self.change_fields if self._has_delete_permission and not self.request.user.is_superuser: diff --git a/app/core/views/ticket_comment.py b/app/core/views/ticket_comment.py index 045ea61d..71e0fd3c 100644 --- a/app/core/views/ticket_comment.py +++ b/app/core/views/ticket_comment.py @@ -88,9 +88,15 @@ class Change(ChangeView): model = TicketComment - def get_dynamic_permissions(self): + if ( + self.request.user.is_authenticated and + self.get_object().user.id == self.request.user.id + ): + + return [] + return [ str('core.change_ticketcomment'), ] From 342fe7da9ef5a438d5a1f2de9de33dd9a02c0c9c Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 15:37:39 +0930 Subject: [PATCH 082/321] fix(core): Ensure status field remains as part of ticket ref: #250 #96 #93 #95 #90 --- app/core/forms/ticket.py | 1 + app/core/forms/validate_ticket.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index 2c1a8c64..ace36c03 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -113,6 +113,7 @@ class TicketForm( self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROJECT_TASK + self.fields['status'].widget = self.fields['status'].hidden_widget() if kwargs['user'].is_superuser: diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index c169dc1d..39d356c8 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -170,7 +170,7 @@ class TicketValidation( for field in self.changed_data: - if field not in fields_allowed: + if field not in fields_allowed and not self.fields['status'].widget.is_hidden: raise PermissionDenied(f'cant edit field: {field}') From d7dd2d6d8b466dfb94258ec03ae89a3d42360a60 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 15:38:49 +0930 Subject: [PATCH 083/321] feat(core): permit user to add comment to own ticket ref: #250 #96 #93 #95 #90 #257 --- app/core/forms/validate_ticket_comment.py | 2 +- app/core/views/ticket_comment.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/core/forms/validate_ticket_comment.py b/app/core/forms/validate_ticket_comment.py index 853b8a79..3c0e1a8e 100644 --- a/app/core/forms/validate_ticket_comment.py +++ b/app/core/forms/validate_ticket_comment.py @@ -220,7 +220,7 @@ class TicketCommentValidation( organization=self._ticket_organization.id, permissions_required = [ 'core.change_ticketcomment' ], ) or - self.request.user.id == self.instance.user.id + self.request.user.id == self.instance.user_id ) and not self.request.user.is_superuser: self._has_change_permission = True diff --git a/app/core/views/ticket_comment.py b/app/core/views/ticket_comment.py index 71e0fd3c..711b0f7a 100644 --- a/app/core/views/ticket_comment.py +++ b/app/core/views/ticket_comment.py @@ -30,6 +30,14 @@ class Add(AddView): def get_dynamic_permissions(self): + if self.request.user.is_authenticated: + + ticket = Ticket.objects.get(pk=int(self.kwargs['ticket_id'])) + + if ticket.opened_by.id == self.request.user.id: + + return [] + return [ str('core.add_ticketcomment'), ] From c339f17c5cfa000539459b8c7992af6d21692868 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 15:39:18 +0930 Subject: [PATCH 084/321] feat(core): Add opened by column to ticket indexes ref: #250 #96 #93 #95 #90 --- app/core/templates/core/ticket/index.html.j2 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/core/templates/core/ticket/index.html.j2 b/app/core/templates/core/ticket/index.html.j2 index 443b3bb2..77a09e3b 100644 --- a/app/core/templates/core/ticket/index.html.j2 +++ b/app/core/templates/core/ticket/index.html.j2 @@ -23,6 +23,7 @@ ID Title Status + Opened By Created {% for ticket in tickets %} @@ -47,6 +48,7 @@ {{ ticket.get_status_display }} + {{ ticket.opened_by }} {{ ticket.created }} {% endfor %} From eb9472927783fbe2229838acb4879bde6f354f6f Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 16:06:43 +0930 Subject: [PATCH 085/321] feat(core): When solution comment posted to ticket update status to solved ref: #250 #96 #93 #95 #90 #257 #260 --- app/core/models/ticket/ticket_comment.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 2ef0e818..d5d6b960 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -416,6 +416,13 @@ class TicketComment( 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): From c3d64a031d2a033049d2a1d6de2920524ced3047 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 2 Sep 2024 17:54:07 +0930 Subject: [PATCH 086/321] feat(api): when attempting to create a device and it's found within DB, dont recreate, return it. DB matches: name and uuid then name and serial number. first found is returned. ref: #260 closes #262 --- app/api/tests/abstract/api_permissions.py | 2 +- app/api/views/itam/device.py | 43 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/api/tests/abstract/api_permissions.py b/app/api/tests/abstract/api_permissions.py index ebddbf92..9808a156 100644 --- a/app/api/tests/abstract/api_permissions.py +++ b/app/api/tests/abstract/api_permissions.py @@ -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() diff --git a/app/api/views/itam/device.py b/app/api/views/itam/device.py index 582d47ed..a5010262 100644 --- a/app/api/views/itam/device.py +++ b/app/api/views/itam/device.py @@ -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): From c670f017a071007dd976a42fcce6d7172bd491f9 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 13:51:12 +0930 Subject: [PATCH 087/321] fix(api): Ensure if device found it is returned ref: #262 --- app/api/views/itam/device.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api/views/itam/device.py b/app/api/views/itam/device.py index a5010262..c1939044 100644 --- a/app/api/views/itam/device.py +++ b/app/api/views/itam/device.py @@ -57,11 +57,11 @@ class DeviceViewSet(OrganizationMixin, viewsets.ModelViewSet): serial_number = str(self.request.POST['serial_number']) ) - if len(current_device) == 1: + if len(current_device) == 1: - instance = current_device.get() - serializer = self.get_serializer(instance) - return Response(serializer.data) + instance = current_device.get() + serializer = self.get_serializer(instance) + return Response(serializer.data) return super().create(request, *args, **kwargs) From a3bfa921e85f2d1afa1d1d769e926fdcfca6389a Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 14:53:23 +0930 Subject: [PATCH 088/321] test: correct typo in test description for `test_model_add_has_permission` ref: #263 --- app/app/tests/abstract/model_permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/app/tests/abstract/model_permissions.py b/app/app/tests/abstract/model_permissions.py index dce45215..d293e6c0 100644 --- a/app/app/tests/abstract/model_permissions.py +++ b/app/app/tests/abstract/model_permissions.py @@ -195,7 +195,7 @@ 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() From cfc690f1c2c82fb794aeaf1b5dbd357b7418c307 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 14:54:58 +0930 Subject: [PATCH 089/321] feat(core): Add delete view for ticket types: request, incident, change and problem ref: #250 #96 #93 #95 #90 #263 --- app/assistance/urls.py | 1 + app/core/views/ticket.py | 41 ++++++++++++++++++++++++++++++++++++++++ app/itim/urls.py | 3 +++ 3 files changed, 45 insertions(+) diff --git a/app/assistance/urls.py b/app/assistance/urls.py index 4eb7bdec..e75793f7 100644 --- a/app/assistance/urls.py +++ b/app/assistance/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path('ticket/request', ticket.Index.as_view(), kwargs={'ticket_type': 'request'}, name="Requests"), path('ticket//add', ticket.Add.as_view(), name="_ticket_request_add"), path('ticket///edit', ticket.Change.as_view(), name="_ticket_request_change"), + path('ticket///delete', ticket.Delete.as_view(), name="_ticket_request_delete"), path('ticket//', ticket.View.as_view(), name="_ticket_request_view"), path('ticket///comment/add', ticket_comment.Add.as_view(), name="_ticket_comment_request_add"), diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index c2e20751..0abc91fa 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -112,6 +112,47 @@ class Change(ChangeView): return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'], self.kwargs['pk'],)) +class Delete(DeleteView): + + model = Ticket + + + def get_dynamic_permissions(self): + + return [ + str('core.delete_ticket_' + self.kwargs['ticket_type']), + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + str(self.object) + + return context + + + def get_success_url(self, **kwargs): + + if self.kwargs['ticket_type'] == 'request': + + return reverse('Assistance:Requests') + + else: + + if self.kwargs['ticket_type'] == 'change': + path = 'Changes' + + elif self.kwargs['ticket_type'] == 'incident': + path = 'Incidents' + + elif self.kwargs['ticket_type'] == 'problem': + path = 'Problems' + + return reverse('ITIM:' + path) + + class Index(OrganizationPermission, generic.ListView): diff --git a/app/itim/urls.py b/app/itim/urls.py index a7ff9aa4..4f87d9a2 100644 --- a/app/itim/urls.py +++ b/app/itim/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path('ticket/change', ticket.Index.as_view(), kwargs={'ticket_type': 'change'}, name="Changes"), path('ticket//add', ticket.Add.as_view(), name="_ticket_change_add"), path('ticket///edit', ticket.Change.as_view(), name="_ticket_change_change"), + path('ticket///delete', ticket.Delete.as_view(), name="_ticket_change_delete"), path('ticket//', ticket.View.as_view(), name="_ticket_change_view"), path('ticket///comment/add', ticket_comment.Add.as_view(), name="_ticket_comment_change_add"), path('ticket///comment//edit', ticket_comment.Change.as_view(), name="_ticket_comment_change_change"), @@ -24,6 +25,7 @@ urlpatterns = [ path('ticket/incident', ticket.Index.as_view(), kwargs={'ticket_type': 'incident'}, name="Incidents"), path('ticket//add', ticket.Add.as_view(), name="_ticket_incident_add"), path('ticket///edit', ticket.Change.as_view(), name="_ticket_incident_change"), + path('ticket///delete', ticket.Delete.as_view(), name="_ticket_incident_delete"), path('ticket//', ticket.View.as_view(), name="_ticket_incident_view"), path('ticket///comment/add', ticket_comment.Add.as_view(), name="_ticket_comment_incident_add"), path('ticket///comment//edit', ticket_comment.Change.as_view(), name="_ticket_comment_incident_change"), @@ -32,6 +34,7 @@ urlpatterns = [ path('ticket/problem', ticket.Index.as_view(), kwargs={'ticket_type': 'problem'}, name="Problems"), path('ticket//add', ticket.Add.as_view(), name="_ticket_problem_add"), path('ticket///edit', ticket.Change.as_view(), name="_ticket_problem_change"), + path('ticket///delete', ticket.Delete.as_view(), name="_ticket_problem_delete"), path('ticket//', ticket.View.as_view(), name="_ticket_problem_view"), path('ticket///comment/add', ticket_comment.Add.as_view(), name="_ticket_comment_problem_add"), path('ticket///comment//edit', ticket_comment.Change.as_view(), name="_ticket_comment_problem_change"), From 55a40fcf4d22582798a9ec587685ffcdfa25c6de Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 14:56:01 +0930 Subject: [PATCH 090/321] refactor(core): Ticket form ticket_type to use class var ref: #250 #96 #93 #95 #90 #263 --- app/core/forms/ticket.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index ace36c03..9d459a89 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -147,50 +147,46 @@ class TicketForm( is_valid = super().is_valid() - ticket_type_choice_id = int(self.cleaned_data['ticket_type'] - 1) - - ticket_type = str(self.fields['ticket_type'].choices.choices.pop(ticket_type_choice_id)[1]).lower().replace(' ', '_') - if self.instance.pk: self.original_object = self.Meta.model.objects.get(pk=self.instance.pk) self.validate_ticket() - if ticket_type == 'change': + if self._ticket_type == 'change': self.validate_change_ticket() - elif ticket_type == 'incident': + elif self._ticket_type == 'incident': self.validate_incident_ticket() - elif ticket_type == 'issue': + elif self._ticket_type == 'issue': # self.validate_issue_ticket() raise ValidationError( 'This Ticket type is not yet available' ) - elif ticket_type == 'merge_request': + elif self._ticket_type == 'merge_request': # self.validate_merge_request_ticket() raise ValidationError( 'This Ticket type is not yet available' ) - elif ticket_type == 'problem': + elif self._ticket_type == 'problem': self.validate_problem_ticket() - elif ticket_type == 'project_task': + elif self._ticket_type == 'project_task': # self.validate_project_task_ticket() raise ValidationError( 'This Ticket type is not yet available' ) - elif ticket_type == 'request': + elif self._ticket_type == 'request': self.validate_request_ticket() From 381d59c18fd6b5e221b4825f5e209b52d9be9582 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 14:56:36 +0930 Subject: [PATCH 091/321] refactor(core): During form validation for a ticket, use defaults if not defined for mandatory fields ref: #250 #96 #93 #95 #90 #263 --- app/core/forms/validate_ticket.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 39d356c8..afdc48a7 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -95,6 +95,12 @@ class TicketValidation( 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, @@ -189,14 +195,19 @@ class TicketValidation( self._ticket_type = self.initial['type_ticket'] + try: - if hasattr(self, 'cleaned_data'): + if hasattr(self, 'cleaned_data'): - field = self.cleaned_data['status'] + field = self.cleaned_data['status'] - else: + else: - field = self.validated_data['status'] + field = self.validated_data['status'] + + except KeyError: + + field = self.fields['status'].initial.value if self._ticket_type == 'request': From 8161d67a1f3a10c625e53587438f474b89ffaeea Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 15:22:35 +0930 Subject: [PATCH 092/321] test(itam): Ensure if an attempt to add an existing device via API, it's not recreated and is returned. ref: #262 #263 --- app/itam/tests/unit/device/test_device_api.py | 133 +++++++++++++++--- 1 file changed, 115 insertions(+), 18 deletions(-) diff --git a/app/itam/tests/unit/device/test_device_api.py b/app/itam/tests/unit/device/test_device_api.py index 3340a152..a340f202 100644 --- a/app/itam/tests/unit/device/test_device_api.py +++ b/app/itam/tests/unit/device/test_device_api.py @@ -44,7 +44,7 @@ class DeviceAPI(TestCase): self.item = self.model.objects.create( organization=organization, name = 'deviceone', - uuid = 'val', + uuid = '2981571b-9737-4aef-b937-1540c14ad9b9', serial_number = 'another val' ) @@ -99,20 +99,20 @@ class DeviceAPI(TestCase): - # add_permissions = Permission.objects.get( - # codename = 'add_' + self.model._meta.model_name, - # content_type = ContentType.objects.get( - # app_label = self.model._meta.app_label, - # model = self.model._meta.model_name, - # ) - # ) + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) - # add_team = Team.objects.create( - # team_name = 'add_team', - # organization = organization, - # ) + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) - # add_team.permissions.set([add_permissions]) + add_team.permissions.set([add_permissions]) @@ -158,11 +158,11 @@ class DeviceAPI(TestCase): user = self.view_user ) - # self.add_user = User.objects.create_user(username="test_user_add", password="password") - # teamuser = TeamUsers.objects.create( - # team = add_team, - # user = self.add_user - # ) + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) # self.change_user = User.objects.create_user(username="test_user_change", password="password") # teamuser = TeamUsers.objects.create( @@ -482,3 +482,100 @@ class DeviceAPI(TestCase): """ assert type(self.api_data['groups'][0]['url']) is Hyperlink + + + + def test_api_create_device_existing_uuid_matches_status_200(self): + """Creation of existing device + + Matching of device is by name and UUID. + + When a device is created and it existss within the DB, don't recreate it. + return the object with status HTTP/200 + """ + + client = Client() + url = reverse('API:device-list') + + + client.force_login(self.add_user) + response = client.post(url, data={ + 'name': self.item.name, + 'uuid': self.item.uuid, + 'organization': self.item.organization.id, + + }) + + assert response.status_code == 200 + + + def test_api_create_device_existing_uuid_matches_correct_item(self): + """Creation of existing device + + Matching of device is by name and UUID. + + When a device is created and it existss within the DB, don't recreate it. + Ensure correct device is returned + """ + + client = Client() + url = reverse('API:device-list') + + + client.force_login(self.add_user) + response = client.post(url, data={ + 'name': self.item.name, + 'uuid': self.item.uuid, + 'organization': self.item.organization.id, + + }) + + assert int(response.data['id']) == int(self.item.id) + + + def test_api_create_device_existing_serial_number_matches_status_200(self): + """Creation of existing device + + Matching of device is by name and Serial Number. + + When a device is created and it existss within the DB, don't recreate it. + return the object with status HTTP/200 + """ + + client = Client() + url = reverse('API:device-list') + + + client.force_login(self.add_user) + response = client.post(url, data={ + 'name': self.item.name, + 'serial_number': self.item.serial_number, + 'organization': self.item.organization.id, + + }) + + assert response.status_code == 200 + + + def test_api_create_device_existing_serial_number_matches_correct_item(self): + """Creation of existing device + + Matching of device is by name and Serial_number. + + When a device is created and it existss within the DB, don't recreate it. + Ensure correct device is returned + """ + + client = Client() + url = reverse('API:device-list') + + + client.force_login(self.add_user) + response = client.post(url, data={ + 'name': self.item.name, + 'serial_number': self.item.serial_number, + 'organization': self.item.organization.id, + + }) + + assert int(response.data['id']) == int(self.item.id) From da8d97a274f00ea5d1aff600cc15158a2d19d837 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 15:23:03 +0930 Subject: [PATCH 093/321] test(core): interim ticket unit tests ref: #250 #96 #93 #95 #90 #263 --- app/core/tests/unit/ticket/test_ticket.py | 17 ++ .../test_ticket_access_tenancy_object.py | 18 ++ .../unit/ticket/test_ticket_permission_api.py | 189 ++++++++++++ .../tests/unit/ticket/test_ticket_views.py | 29 ++ .../types/test_ticket_request_permission.py | 279 ++++++++++++++++++ 5 files changed, 532 insertions(+) create mode 100644 app/core/tests/unit/ticket/test_ticket.py create mode 100644 app/core/tests/unit/ticket/test_ticket_access_tenancy_object.py create mode 100644 app/core/tests/unit/ticket/test_ticket_permission_api.py create mode 100644 app/core/tests/unit/ticket/test_ticket_views.py create mode 100644 app/core/tests/unit/ticket/types/test_ticket_request_permission.py diff --git a/app/core/tests/unit/ticket/test_ticket.py b/app/core/tests/unit/ticket/test_ticket.py new file mode 100644 index 00000000..13514749 --- /dev/null +++ b/app/core/tests/unit/ticket/test_ticket.py @@ -0,0 +1,17 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import TenancyModel + +from core.models.ticket.ticket import Ticket + + +class TicketModel( + TestCase, + TenancyModel +): + + model = Ticket diff --git a/app/core/tests/unit/ticket/test_ticket_access_tenancy_object.py b/app/core/tests/unit/ticket/test_ticket_access_tenancy_object.py new file mode 100644 index 00000000..3ff5fc76 --- /dev/null +++ b/app/core/tests/unit/ticket/test_ticket_access_tenancy_object.py @@ -0,0 +1,18 @@ +import pytest +import unittest +import requests + +from django.test import TestCase, Client + +from access.tests.abstract.tenancy_object import TenancyObject + +from core.models.ticket.ticket import Ticket + + + +class TicketTenancyObject( + TestCase, + TenancyObject +): + + model = Ticket diff --git a/app/core/tests/unit/ticket/test_ticket_permission_api.py b/app/core/tests/unit/ticket/test_ticket_permission_api.py new file mode 100644 index 00000000..70bd226e --- /dev/null +++ b/app/core/tests/unit/ticket/test_ticket_permission_api.py @@ -0,0 +1,189 @@ +import pytest +import unittest +import requests + +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from access.models import Organization, Team, TeamUsers, Permission + +from api.tests.abstract.api_permissions import APIPermissions + +from core.models.ticket.ticket import Ticket + + +class TicketPermissionsAPI(TestCase, APIPermissions): + + + model = Ticket + + ticket_type: str = 'request' + + ticket_type_enum: int = int(Ticket.TicketType.REQUEST.value) + + app_namespace = 'API' + + url_name = '_api_core_tickets-detail' + + url_list = '_api_core_tickets-list' + + change_data = {'title': 'ticket change'} + + delete_data = {'title': 'ticket delete'} + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a software + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_permissions]) + + + self.item = self.model.objects.create( + organization=organization, + title = 'A ' + self.ticket_type + ' ticket', + description = 'the ticket body', + ticket_type = self.ticket_type_enum, + opened_by = self.add_user, + status = int(Ticket.TicketStatus.All.NEW.value) + ) + + + # self.url_kwargs = {'pk': self.item.id} + + self.url_view_kwargs = {'pk': self.item.id} + + self.add_data = { + 'title': 'an add ticket', + 'organization': self.organization.id, + 'opened_by': self.add_user.id, + 'status': int(Ticket.TicketStatus.All.NEW.value) + } + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) diff --git a/app/core/tests/unit/ticket/test_ticket_views.py b/app/core/tests/unit/ticket/test_ticket_views.py new file mode 100644 index 00000000..9464ee94 --- /dev/null +++ b/app/core/tests/unit/ticket/test_ticket_views.py @@ -0,0 +1,29 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import PrimaryModel + + + +class TicketViews( + TestCase, + PrimaryModel +): + + add_module = 'core.views.ticket' + add_view = 'Add' + + change_module = add_module + change_view = 'Change' + + delete_module = add_module + delete_view = 'Delete' + + display_module = add_module + display_view = 'View' + + index_module = add_module + index_view = 'Index' diff --git a/app/core/tests/unit/ticket/types/test_ticket_request_permission.py b/app/core/tests/unit/ticket/types/test_ticket_request_permission.py new file mode 100644 index 00000000..df9b4190 --- /dev/null +++ b/app/core/tests/unit/ticket/types/test_ticket_request_permission.py @@ -0,0 +1,279 @@ +# from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import reverse +from django.test import TestCase, Client + +import pytest +import unittest +import requests + +from access.models import Organization, Team, TeamUsers, Permission + +from app.tests.abstract.model_permissions import ModelPermissions + +from core.models.ticket.ticket import Ticket + + +class TicketPermissions(ModelPermissions): + + ticket_type:str = None + + ticket_type_enum: int = None + + model = Ticket + + # app_namespace = 'Assistance' + + # url_name_view = '_ticket_request_view' + + # url_name_add = '_ticket_request_add' + + # url_name_change = '_ticket_request_change' + + # url_name_delete = '_ticket_request_delete' + + # url_delete_response = reverse('Assistance:Requests') + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a manufacturer + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_permissions]) + + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + + self.item = self.model.objects.create( + organization=organization, + title = 'A ' + self.ticket_type + ' ticket', + description = 'the ticket body', + ticket_type = int(Ticket.TicketType.REQUEST.value), + opened_by = self.add_user, + status = int(Ticket.TicketStatus.All.NEW.value) + ) + + + self.url_view_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + + self.url_add_kwargs = {'ticket_type': self.ticket_type} + + self.add_data = { + 'title': 'an add ticket', + 'organization': self.organization.id, + 'opened_by': self.add_user.id, + 'status': int(Ticket.TicketStatus.All.NEW.value) + } + + self.url_change_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + + self.change_data = {'title': 'an change to ticket', 'organization': self.organization.id} + + self.url_delete_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + + self.delete_data = {'title': 'a delete to ticket', 'organization': self.organization.id} + + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) + + + +class ChangeTicketPermissions(TicketPermissions, TestCase): + + ticket_type = 'change' + + ticket_type_enum: int = int(Ticket.TicketType.CHANGE.value) + + app_namespace = 'ITIM' + + url_name_view = '_ticket_change_view' + + url_name_add = '_ticket_change_add' + + url_name_change = '_ticket_change_change' + + url_name_delete = '_ticket_change_delete' + + url_delete_response = reverse('ITIM:Changes') + + + +class IncidentTicketPermissions(TicketPermissions, TestCase): + + ticket_type = 'incident' + + ticket_type_enum: int = int(Ticket.TicketType.INCIDENT.value) + + app_namespace = 'ITIM' + + url_name_view = '_ticket_incident_view' + + url_name_add = '_ticket_incident_add' + + url_name_change = '_ticket_incident_change' + + url_name_delete = '_ticket_incident_delete' + + url_delete_response = reverse('ITIM:Incidents') + + + +class ProblemTicketPermissions(TicketPermissions, TestCase): + + ticket_type = 'problem' + + ticket_type_enum: int = int(Ticket.TicketType.PROBLEM.value) + + app_namespace = 'ITIM' + + url_name_view = '_ticket_problem_view' + + url_name_add = '_ticket_problem_add' + + url_name_change = '_ticket_problem_change' + + url_name_delete = '_ticket_problem_delete' + + url_delete_response = reverse('ITIM:Problems') + + + +class RequestTicketPermissions(TicketPermissions, TestCase): + + ticket_type = 'request' + + ticket_type_enum: int = int(Ticket.TicketType.REQUEST.value) + + app_namespace = 'Assistance' + + url_name_view = '_ticket_request_view' + + url_name_add = '_ticket_request_add' + + url_name_change = '_ticket_request_change' + + url_name_delete = '_ticket_request_delete' + + url_delete_response = reverse('Assistance:Requests') From 53489ec43b2f6cab02d6d40e31de7bafd09417a8 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 17:00:22 +0930 Subject: [PATCH 094/321] feat(access): add ability to fetch dynamic permissions ref: #250 #96 #93 #95 #90 #263 --- app/api/views/core/tickets.py | 2 +- app/api/views/mixin.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/api/views/core/tickets.py b/app/api/views/core/tickets.py index 67bc7b82..d8505772 100644 --- a/app/api/views/core/tickets.py +++ b/app/api/views/core/tickets.py @@ -19,7 +19,7 @@ class View(OrganizationMixin, viewsets.ModelViewSet): OrganizationPermissionAPI ] - def get_permission_required(self): + def get_dynamic_permissions(self): self.permission_required = [ 'core.view_ticket_request', diff --git a/app/api/views/mixin.py b/app/api/views/mixin.py index e2d77bc8..d965f9b6 100644 --- a/app/api/views/mixin.py +++ b/app/api/views/mixin.py @@ -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: From f6dd5a3156f294e3f635012887e96606dbdb5bb7 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 17:00:46 +0930 Subject: [PATCH 095/321] chore: remove empty settings model ref: #263 --- app/settings/models/settings.py | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 app/settings/models/settings.py diff --git a/app/settings/models/settings.py b/app/settings/models/settings.py deleted file mode 100644 index fee99043..00000000 --- a/app/settings/models/settings.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.db import models - -class Settings(models.Model): - - class Meta: - - managed = False From 754c3115804a3644f2f594507c800b87ca7f0806 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 3 Sep 2024 17:50:41 +0930 Subject: [PATCH 096/321] feat(core): add ticket status badge ref: #250 #96 #93 #95 #90 #263 --- app/core/templates/core/ticket.html.j2 | 2 +- .../core/ticket/badge_ticket_status.html.j2 | 6 ++++ app/core/templates/core/ticket/index.html.j2 | 10 +++++- .../templates/icons/ticket/status_solved.svg | 2 +- app/core/templatetags/markdown.py | 5 +++ app/project-static/ticketing.css | 32 +++++++++++++++++++ 6 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 app/core/templates/core/ticket/badge_ticket_status.html.j2 diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index 8a7d99ce..d924ad30 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -114,7 +114,7 @@
- {{ticket.get_status_display }} + {% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.get_status_display ticket_status=ticket.get_status_display|lower %}
diff --git a/app/core/templates/core/ticket/badge_ticket_status.html.j2 b/app/core/templates/core/ticket/badge_ticket_status.html.j2 new file mode 100644 index 00000000..a0138008 --- /dev/null +++ b/app/core/templates/core/ticket/badge_ticket_status.html.j2 @@ -0,0 +1,6 @@ + + + {% include 'core/ticket/icon_status.html.j2' %} + + {{ ticket_status_text }} + \ No newline at end of file diff --git a/app/core/templates/core/ticket/index.html.j2 b/app/core/templates/core/ticket/index.html.j2 index 77a09e3b..d95a6d03 100644 --- a/app/core/templates/core/ticket/index.html.j2 +++ b/app/core/templates/core/ticket/index.html.j2 @@ -1,5 +1,13 @@ {% extends 'base.html.j2' %} +{% block additional-stylesheet %} + {% load static %} + +{% endblock additional-stylesheet %} + + +{% load markdown %} + {% block content %} @@ -47,7 +55,7 @@ {{ ticket.title }} - {{ ticket.get_status_display }} + {% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.get_status_display ticket_status=ticket.get_status_display|lower %} {{ ticket.opened_by }} {{ ticket.created }} diff --git a/app/core/templates/icons/ticket/status_solved.svg b/app/core/templates/icons/ticket/status_solved.svg index aed4155f..deff8321 100644 --- a/app/core/templates/icons/ticket/status_solved.svg +++ b/app/core/templates/icons/ticket/status_solved.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index 35e00bcc..58e4a94c 100644 --- a/app/core/templatetags/markdown.py +++ b/app/core/templatetags/markdown.py @@ -10,3 +10,8 @@ register = template.Library() @stringfilter def markdown(value): return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) + +@register.filter() +@stringfilter +def lower(value): + return str(value).lower() diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 7c05027e..739f5de8 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -1,4 +1,36 @@ +#badge { + display: table; + padding: 0px 5px 0px 0px; + margin: 0px; + border: 1px solid #ccc; + border-radius: 15px; +} + +#badge #text { + display: inline-block; + vertical-align: middle; + margin: 0px; + padding: 0px; + padding-left: 5px; +} + +#badge #icon { + display: inline-block; + width: 20px; + vertical-align: middle; + padding: 0px; + margin: 0px; +} + +#badge .icon svg{ + display: inline; + width: 20px; + height: 30px; + +} + + #linked-tickets { display: table; padding: 0px; From 0f4b9fef9e3694b3f946a23a8e4180d84961ee90 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 4 Sep 2024 11:55:46 +0930 Subject: [PATCH 097/321] refactor(api): Move core tickets to own ticket endpoints require so that permissions can be dynamic ref: #250 #96 #93 #95 #90 #265 closes #264 --- app/api/serializers/core/ticket.py | 154 ++++++++++++++++++ .../{itim => core}/ticket_comment.py | 33 +++- app/api/serializers/itim/ticket.py | 89 ---------- app/api/urls.py | 23 ++- app/api/views/assistance/__init__.py | 1 + app/api/views/{core => assistance}/index.py | 8 +- app/api/views/core/ticket_comments.py | 6 +- app/api/views/core/tickets.py | 54 +++++- app/api/views/index.py | 3 +- app/api/views/itim/__init__.py | 1 + app/api/views/itim/index.py | 36 ++++ 11 files changed, 302 insertions(+), 106 deletions(-) create mode 100644 app/api/serializers/core/ticket.py rename app/api/serializers/{itim => core}/ticket_comment.py (54%) delete mode 100644 app/api/serializers/itim/ticket.py create mode 100644 app/api/views/assistance/__init__.py rename app/api/views/{core => assistance}/index.py (73%) create mode 100644 app/api/views/itim/__init__.py create mode 100644 app/api/views/itim/index.py diff --git a/app/api/serializers/core/ticket.py b/app/api/serializers/core/ticket.py new file mode 100644 index 00000000..d1a2a68c --- /dev/null +++ b/app/api/serializers/core/ticket.py @@ -0,0 +1,154 @@ +from django.urls import reverse + +from rest_framework import serializers + +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') + + 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' + + else: + + raise ValueError('Serializer unable to obtain ticket type') + + + return request.build_absolute_uri( + reverse( + 'API:' + view_name + '-detail', + kwargs={ + 'ticket_type': self._kwargs['context']['view'].kwargs['ticket_type'], + 'pk': item.id + } + ) + ) + + + ticket_comments = serializers.SerializerMethodField('get_url_ticket_comments') + + + def get_url_ticket_comments(self, item): + + request = self.context.get('request') + + 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' + + else: + + raise ValueError('Serializer unable to obtain ticket type') + + + return request.build_absolute_uri( + reverse( + 'API:' + view_name + '-list', + kwargs={ + 'ticket_type': self._kwargs['context']['view'].kwargs['ticket_type'], + 'ticket_id': item.id + } + ) + ) + + + class Meta: + model = Ticket + fields = [ + 'id', + 'assigned_teams', + 'assigned_users', + '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 is_valid(self, *, raise_exception=True) -> bool: + + self.request = self._context['request'] + + is_valid = super().is_valid(raise_exception=raise_exception) + + if self.instance: + + ticket_type_choice_id = int(self.instance.ticket_type) + self.original_object = self.Meta.model.objects.get(pk=self.instance.pk) + + else: + + ticket_type_choice_id = int(self.initial_data['ticket_type']) + self.original_object = None + + self._ticket_type = str(self.fields['ticket_type'].choices[ticket_type_choice_id]).lower().replace(' ', '_') + + + is_valid = self.validate_ticket() + + return is_valid diff --git a/app/api/serializers/itim/ticket_comment.py b/app/api/serializers/core/ticket_comment.py similarity index 54% rename from app/api/serializers/itim/ticket_comment.py rename to app/api/serializers/core/ticket_comment.py index 0798412c..c21246ac 100644 --- a/app/api/serializers/itim/ticket_comment.py +++ b/app/api/serializers/core/ticket_comment.py @@ -15,7 +15,38 @@ class TicketCommentSerializer(serializers.ModelSerializer): def get_url_ticket_comment(self, item): request = self.context.get('request') - return request.build_absolute_uri(reverse('API:_api_core_ticket_comments-detail', args=[item.ticket_id, item.pk])) + + 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_type': self._kwargs['context']['view'].kwargs['ticket_type'], + 'ticket_id': item.ticket.id, + 'pk': item.id + } + ) + ) + class Meta: diff --git a/app/api/serializers/itim/ticket.py b/app/api/serializers/itim/ticket.py deleted file mode 100644 index 1355a0ea..00000000 --- a/app/api/serializers/itim/ticket.py +++ /dev/null @@ -1,89 +0,0 @@ -from django.urls import reverse - -from rest_framework import serializers - -from api.serializers.itim.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.HyperlinkedIdentityField( - view_name="API:_api_core_tickets-detail", format="html" - ) - - ticket_comments = serializers.SerializerMethodField('get_url_ticket_comments') - - - def get_url_ticket_comments(self, item): - - request = self.context.get('request') - return request.build_absolute_uri(reverse('API:_api_core_ticket_comments-list', args=[item.id])) - - - class Meta: - model = Ticket - fields = [ - 'id', - 'assigned_teams', - 'assigned_users', - '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 is_valid(self, *, raise_exception=True) -> bool: - - self.request = self._context['request'] - - is_valid = super().is_valid(raise_exception=raise_exception) - - if self.instance: - - ticket_type_choice_id = int(self.instance.ticket_type) - self.original_object = self.Meta.model.objects.get(pk=self.instance.pk) - - else: - - ticket_type_choice_id = int(self.initial_data['ticket_type']) - self.original_object = None - - self._ticket_type = str(self.fields['ticket_type'].choices[ticket_type_choice_id]).lower().replace(' ', '_') - - - is_valid = self.validate_ticket() - - return is_valid diff --git a/app/api/urls.py b/app/api/urls.py index f060e79e..04827427 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -7,7 +7,8 @@ from .views import access, config, index from api.views.settings import permissions from api.views.settings import index as settings -from api.views.core import index as core + +from api.views import assistance, itim from api.views.core import tickets as core_tickets from api.views.core import ticket_comments as core_ticket_comments @@ -22,16 +23,28 @@ app_name = "API" router = DefaultRouter(trailing_slash=False) router.register('', index.Index, basename='_api_home') + +router.register('assistance/(?Prequest)', core_tickets.View, basename='_api_assistance_request') +router.register('assistance/(?Prequest)/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_assistance_request_ticket_comments') + router.register('device', DeviceViewSet, basename='device') + +router.register('itim/(?Pchange)', core_tickets.View, basename='_api_itim_change') +router.register('itim/(?Pchange)/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_change_ticket_comments') + +router.register('itim/(?Pincident)', core_tickets.View, basename='_api_itim_incident') +router.register('itim/(?Pincident)/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_incident_ticket_comments') + +router.register('itim/(?Pproblem)', core_tickets.View, basename='_api_itim_problem') +router.register('itim/(?Pproblem)/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_problem_ticket_comments') + router.register('software', software.SoftwareViewSet, basename='software') -router.register('core/tickets', core_tickets.View, basename='_api_core_tickets') -router.register('core/tickets/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_core_ticket_comments') urlpatterns = [ - path("core", core.Index.as_view(), name="_api_core"), + path("assistance", assistance.index.Index.as_view(), name="_api_assistance"), # # Sof Old Paths to be refactored @@ -44,6 +57,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//", access.OrganizationDetail.as_view(), name='_api_organization'), path("organization//team", access.TeamList.as_view(), name='_api_organization_teams'), diff --git a/app/api/views/assistance/__init__.py b/app/api/views/assistance/__init__.py new file mode 100644 index 00000000..623697bc --- /dev/null +++ b/app/api/views/assistance/__init__.py @@ -0,0 +1 @@ +from .index import * \ No newline at end of file diff --git a/app/api/views/core/index.py b/app/api/views/assistance/index.py similarity index 73% rename from app/api/views/core/index.py rename to app/api/views/assistance/index.py index a5f79aca..7be852f3 100644 --- a/app/api/views/core/index.py +++ b/app/api/views/assistance/index.py @@ -1,7 +1,7 @@ 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.decorators import api_view from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.reverse import reverse @@ -16,10 +16,10 @@ class Index(views.APIView): def get_view_name(self): - return "Core" + return "Assistance" def get_view_description(self, html=False) -> str: - text = "Core Module" + text = "Assistance Module" if html: return mark_safe(f"

{text}

") else: @@ -29,7 +29,7 @@ class Index(views.APIView): def get(self, request, *args, **kwargs): body: dict = { - 'tickets': reverse('API:_api_core_tickets-list', request=request) + 'requests': reverse('API:_api_assistance_request-list', request=request, kwargs={'ticket_type': 'request'}) } return Response(body) diff --git a/app/api/views/core/ticket_comments.py b/app/api/views/core/ticket_comments.py index 87fc83e1..9e2e22ca 100644 --- a/app/api/views/core/ticket_comments.py +++ b/app/api/views/core/ticket_comments.py @@ -6,7 +6,7 @@ from rest_framework import generics, viewsets from access.mixin import OrganizationMixin -from api.serializers.itim.ticket_comment import TicketCommentSerializer +from api.serializers.core.ticket_comment import TicketCommentSerializer from api.views.mixin import OrganizationPermissionAPI from core.models.ticket.ticket_comment import TicketComment @@ -48,9 +48,9 @@ class View(OrganizationMixin, viewsets.ModelViewSet): 200: OpenApiResponse(description='Success', response=TicketCommentSerializer), } ) - def list(self, request, ticket_id): + def list(self, request, *args, **kwargs): - return super().list(request) + return super().list(request, *args, **kwargs) @extend_schema( diff --git a/app/api/views/core/tickets.py b/app/api/views/core/tickets.py index d8505772..c160c8be 100644 --- a/app/api/views/core/tickets.py +++ b/app/api/views/core/tickets.py @@ -6,7 +6,7 @@ from rest_framework import generics, viewsets from access.mixin import OrganizationMixin -from api.serializers.itim.ticket import TicketSerializer +from api.serializers.core.ticket import TicketSerializer from api.views.mixin import OrganizationPermissionAPI from core.models.ticket.ticket import Ticket @@ -68,9 +68,9 @@ class View(OrganizationMixin, viewsets.ModelViewSet): 200: OpenApiResponse(description='Success', response=TicketSerializer), } ) - def list(self, request): + def list(self, request, *args, **kwargs): - return super().list(request) + return super().list(request, *args, **kwargs) @extend_schema( @@ -89,10 +89,56 @@ class View(OrganizationMixin, viewsets.ModelViewSet): def get_queryset(self): - return self.queryset + if self.kwargs['ticket_type'] == 'change': + + ticket_type = self.queryset.model.TicketType.CHANGE.value + + elif self.kwargs['ticket_type'] == 'incident': + + ticket_type = self.queryset.model.TicketType.INCIDENT.value + + elif self.kwargs['ticket_type'] == 'problem': + + ticket_type = self.queryset.model.TicketType.PROBLEM.value + + elif self.kwargs['ticket_type'] == 'request': + + ticket_type = self.queryset.model.TicketType.REQUEST.value + + else: + + raise ValueError('Unknown ticket type. kwarg `ticket_type` must be set') + + return self.queryset.filter( + ticket_type = ticket_type + ) def get_view_name(self): + + + # if self.kwargs['ticket_type'] == 'change': + + # ticket_type = 'Request' + + # elif self.kwargs['ticket_type'] == 'incident': + + # ticket_type = 'Incident' + + # elif self.kwargs['ticket_type'] == 'problem': + + # ticket_type = 'Problem' + + # elif self.kwargs['ticket_type'] == 'request': + + # ticket_type = 'Request' + + + # if self.detail: + # return ticket_type + " Ticket" + + # return ticket_type + ' Tickets' + if self.detail: return "Ticket" diff --git a/app/api/views/index.py b/app/api/views/index.py index c8215b6b..8fa706aa 100644 --- a/app/api/views/index.py +++ b/app/api/views/index.py @@ -30,9 +30,10 @@ class Index(viewsets.ViewSet): return Response( { # "teams": reverse("_api_teams", request=request), - 'core': reverse("API:_api_core", 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), "settings": reverse('API:_settings', request=request), "software": reverse("API:software-list", request=request), diff --git a/app/api/views/itim/__init__.py b/app/api/views/itim/__init__.py new file mode 100644 index 00000000..623697bc --- /dev/null +++ b/app/api/views/itim/__init__.py @@ -0,0 +1 @@ +from .index import * \ No newline at end of file diff --git a/app/api/views/itim/index.py b/app/api/views/itim/index.py new file mode 100644 index 00000000..f02e522a --- /dev/null +++ b/app/api/views/itim/index.py @@ -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"

{text}

") + else: + return text + + + def get(self, request, *args, **kwargs): + + body: dict = { + 'changes': reverse('API:_api_itim_change-list', request=request, kwargs={'ticket_type': 'change'}), + 'incidents': reverse('API:_api_itim_incident-list', request=request, kwargs={'ticket_type': 'incident'}), + 'problems': reverse('API:_api_itim_problem-list', request=request, kwargs={'ticket_type': 'problem'}), + } + + return Response(body) From d70f04c63d0bcf87c7f47cc047551888f4928632 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 6 Sep 2024 10:25:28 +0930 Subject: [PATCH 098/321] refactor(api): make ticket status field mandatory ref: #250 #96 #93 #95 #90 #264 #265 --- app/api/serializers/core/ticket.py | 6 ++++++ app/core/forms/validate_ticket.py | 25 +++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/api/serializers/core/ticket.py b/app/api/serializers/core/ticket.py index d1a2a68c..a7fe38f8 100644 --- a/app/api/serializers/core/ticket.py +++ b/app/api/serializers/core/ticket.py @@ -93,7 +93,13 @@ class TicketSerializer( class Meta: + model = Ticket + + extra_kwargs = { + 'status': {'required': True} + } + fields = [ 'id', 'assigned_teams', diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index afdc48a7..caa4161f 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -176,7 +176,21 @@ class TicketValidation( for field in self.changed_data: - if field not in fields_allowed and not self.fields['status'].widget.is_hidden: + allowed: bool = False + + if hasattr(self.fields[field], 'widget'): + + if field in fields_allowed or self.fields[field].widget.is_hidden: + + allowed = True + + else: + + if field in fields_allowed or self.fields[field].required: + + allowed = True + + if not allowed: raise PermissionDenied(f'cant edit field: {field}') @@ -207,7 +221,8 @@ class TicketValidation( except KeyError: - field = self.fields['status'].initial.value + # field = self.fields['status'].default.value + field = getattr(self.Meta.model, 'status').field.default.value if self._ticket_type == 'request': @@ -286,6 +301,12 @@ class TicketValidation( if field in changed_data_exempt: continue + if field == 'is_deleted': + + if not self.validated_data['is_deleted']: + + continue + if self.original_object is not None: if ( self.validated_data[field] != getattr(self.original_object, field) From d8361bf7410d7a06b5c7639f0837c69dc9a853fd Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 6 Sep 2024 10:26:16 +0930 Subject: [PATCH 099/321] feat(api): Ticket endpoint dynamic permissions ref: #250 #96 #93 #95 #90 #264 #265 --- app/api/views/core/tickets.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/app/api/views/core/tickets.py b/app/api/views/core/tickets.py index c160c8be..9fea9876 100644 --- a/app/api/views/core/tickets.py +++ b/app/api/views/core/tickets.py @@ -21,8 +21,28 @@ class View(OrganizationMixin, viewsets.ModelViewSet): def get_dynamic_permissions(self): + if self.action == 'create': + + action_keyword = 'add' + + elif self.action == 'destroy': + + action_keyword = 'delete' + + elif self.action == 'partial_update': + + action_keyword = 'change' + + elif self.action == 'retrieve': + + action_keyword = 'view' + + else: + + raise ValueError('unable to determin the action_keyword') + self.permission_required = [ - 'core.view_ticket_request', + 'core.' + action_keyword + '_ticket_' + self.kwargs['ticket_type'], ] return super().get_permission_required() @@ -53,7 +73,7 @@ class View(OrganizationMixin, viewsets.ModelViewSet): ) def create(self, request, *args, **kwargs): - super().create(request, *args, **kwargs) + return super().create(request, *args, **kwargs) @extend_schema( From 53ae19eda8995e0df1d933b5762f03c9bab483bd Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 6 Sep 2024 10:26:55 +0930 Subject: [PATCH 100/321] test(api): Ticket (change, incident, problem and request) api permission checks ref: #250 #96 #93 #95 #90 #264 #265 --- .../unit/ticket/test_ticket_permission_api.py | 86 +++++++++++++++---- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission_api.py b/app/core/tests/unit/ticket/test_ticket_permission_api.py index 70bd226e..9f6ddb53 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission_api.py +++ b/app/core/tests/unit/ticket/test_ticket_permission_api.py @@ -13,21 +13,9 @@ from api.tests.abstract.api_permissions import APIPermissions from core.models.ticket.ticket import Ticket -class TicketPermissionsAPI(TestCase, APIPermissions): +class TicketPermissionsAPI(APIPermissions): - model = Ticket - - ticket_type: str = 'request' - - ticket_type_enum: int = int(Ticket.TicketType.REQUEST.value) - - app_namespace = 'API' - - url_name = '_api_core_tickets-detail' - - url_list = '_api_core_tickets-list' - change_data = {'title': 'ticket change'} delete_data = {'title': 'ticket delete'} @@ -78,15 +66,17 @@ class TicketPermissionsAPI(TestCase, APIPermissions): ) - # self.url_kwargs = {'pk': self.item.id} + self.url_kwargs = {'ticket_type': self.ticket_type,} - self.url_view_kwargs = {'pk': self.item.id} + self.url_view_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} self.add_data = { 'title': 'an add ticket', 'organization': self.organization.id, 'opened_by': self.add_user.id, - 'status': int(Ticket.TicketStatus.All.NEW.value) + 'status': int(Ticket.TicketStatus.All.NEW.value), + 'ticket_type': self.ticket_type_enum, + 'description': 'the description' } view_permissions = Permission.objects.get( @@ -187,3 +177,67 @@ class TicketPermissionsAPI(TestCase, APIPermissions): team = different_organization_team, user = self.different_organization_user ) + + + +class ChangeTicketPermissionsAPI(TicketPermissionsAPI, TestCase): + + model = Ticket + + ticket_type: str = 'change' + + ticket_type_enum: int = int(Ticket.TicketType.CHANGE.value) + + app_namespace = 'API' + + url_name = '_api_itim_change-detail' + + url_list = '_api_itim_change-list' + + + +class IncidentTicketPermissionsAPI(TicketPermissionsAPI, TestCase): + + model = Ticket + + ticket_type: str = 'incident' + + ticket_type_enum: int = int(Ticket.TicketType.INCIDENT.value) + + app_namespace = 'API' + + url_name = '_api_itim_incident-detail' + + url_list = '_api_itim_incident-list' + + + +class ProblemTicketPermissionsAPI(TicketPermissionsAPI, TestCase): + + model = Ticket + + ticket_type: str = 'problem' + + ticket_type_enum: int = int(Ticket.TicketType.PROBLEM.value) + + app_namespace = 'API' + + url_name = '_api_itim_problem-detail' + + url_list = '_api_itim_problem-list' + + + +class RequestTicketPermissionsAPI(TicketPermissionsAPI, TestCase): + + model = Ticket + + ticket_type: str = 'request' + + ticket_type_enum: int = int(Ticket.TicketType.REQUEST.value) + + app_namespace = 'API' + + url_name = '_api_assistance_request-detail' + + url_list = '_api_assistance_request-list' From f49cc9c28669f0c6d957b109567f561ae84a6b02 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 6 Sep 2024 16:45:26 +0930 Subject: [PATCH 101/321] refactor(api): Ticket (change, incident, problem and request) to static api endpoints ref: #250 #96 #93 #95 #90 #264 #265 --- app/api/serializers/assistance/request.py | 53 ++++++++ app/api/serializers/core/ticket.py | 16 ++- app/api/serializers/core/ticket_comment.py | 1 - app/api/serializers/itim/change.py | 53 ++++++++ app/api/serializers/itim/incident.py | 53 ++++++++ app/api/serializers/itim/problem.py | 53 ++++++++ app/api/urls.py | 19 +-- app/api/views/assistance/request_ticket.py | 77 +++++++++++ app/api/views/core/tickets.py | 127 ++++++------------ app/api/views/itim/change_ticket.py | 82 +++++++++++ app/api/views/itim/incident_ticket.py | 81 +++++++++++ app/api/views/itim/problem_ticket.py | 81 +++++++++++ app/core/forms/validate_ticket.py | 6 + .../unit/ticket/test_ticket_permission_api.py | 5 +- 14 files changed, 603 insertions(+), 104 deletions(-) create mode 100644 app/api/serializers/assistance/request.py create mode 100644 app/api/serializers/itim/change.py create mode 100644 app/api/serializers/itim/incident.py create mode 100644 app/api/serializers/itim/problem.py create mode 100644 app/api/views/assistance/request_ticket.py create mode 100644 app/api/views/itim/change_ticket.py create mode 100644 app/api/views/itim/incident_ticket.py create mode 100644 app/api/views/itim/problem_ticket.py diff --git a/app/api/serializers/assistance/request.py b/app/api/serializers/assistance/request.py new file mode 100644 index 00000000..19f5a4f4 --- /dev/null +++ b/app/api/serializers/assistance/request.py @@ -0,0 +1,53 @@ +from api.serializers.core.ticket import TicketSerializer + +from core.models.ticket.ticket import Ticket + + + +class RequestTicketSerializer( + TicketSerializer, +): + + class Meta: + + model = Ticket + + extra_kwargs = { + 'status': {'required': True} + } + + fields = [ + 'id', + 'assigned_teams', + 'assigned_users', + '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', + 'ticket_type', + 'url', + ] diff --git a/app/api/serializers/core/ticket.py b/app/api/serializers/core/ticket.py index a7fe38f8..ca197aef 100644 --- a/app/api/serializers/core/ticket.py +++ b/app/api/serializers/core/ticket.py @@ -1,6 +1,7 @@ from django.urls import reverse from rest_framework import serializers +from rest_framework.fields import empty from api.serializers.core.ticket_comment import TicketCommentSerializer @@ -46,7 +47,6 @@ class TicketSerializer( reverse( 'API:' + view_name + '-detail', kwargs={ - 'ticket_type': self._kwargs['context']['view'].kwargs['ticket_type'], 'pk': item.id } ) @@ -85,7 +85,6 @@ class TicketSerializer( reverse( 'API:' + view_name + '-list', kwargs={ - 'ticket_type': self._kwargs['context']['view'].kwargs['ticket_type'], 'ticket_id': item.id } ) @@ -135,6 +134,13 @@ class TicketSerializer( 'url', ] + + def __init__(self, instance=None, data=empty, **kwargs): + + self.fields.fields['status'].initial = Ticket.TicketStatus.All.NEW + + super().__init__(instance=instance, data=data, **kwargs) + def is_valid(self, *, raise_exception=True) -> bool: @@ -142,17 +148,17 @@ class TicketSerializer( is_valid = super().is_valid(raise_exception=raise_exception) + self.validated_data['ticket_type'] = self._context['view']._ticket_type_value + if self.instance: - ticket_type_choice_id = int(self.instance.ticket_type) self.original_object = self.Meta.model.objects.get(pk=self.instance.pk) else: - ticket_type_choice_id = int(self.initial_data['ticket_type']) self.original_object = None - self._ticket_type = str(self.fields['ticket_type'].choices[ticket_type_choice_id]).lower().replace(' ', '_') + self._ticket_type = str(self.fields['ticket_type'].choices[self._context['view']._ticket_type_value]).lower().replace(' ', '_') is_valid = self.validate_ticket() diff --git a/app/api/serializers/core/ticket_comment.py b/app/api/serializers/core/ticket_comment.py index c21246ac..8f58f6a1 100644 --- a/app/api/serializers/core/ticket_comment.py +++ b/app/api/serializers/core/ticket_comment.py @@ -40,7 +40,6 @@ class TicketCommentSerializer(serializers.ModelSerializer): return request.build_absolute_uri( reverse('API:' + view_name + '-detail', kwargs={ - 'ticket_type': self._kwargs['context']['view'].kwargs['ticket_type'], 'ticket_id': item.ticket.id, 'pk': item.id } diff --git a/app/api/serializers/itim/change.py b/app/api/serializers/itim/change.py new file mode 100644 index 00000000..797c0195 --- /dev/null +++ b/app/api/serializers/itim/change.py @@ -0,0 +1,53 @@ +from api.serializers.core.ticket import TicketSerializer + +from core.models.ticket.ticket import Ticket + + + +class ChangeTicketSerializer( + TicketSerializer, +): + + class Meta: + + model = Ticket + + extra_kwargs = { + 'status': {'required': True} + } + + fields = [ + 'id', + 'assigned_teams', + 'assigned_users', + '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', + 'ticket_type', + 'url', + ] diff --git a/app/api/serializers/itim/incident.py b/app/api/serializers/itim/incident.py new file mode 100644 index 00000000..aeccefce --- /dev/null +++ b/app/api/serializers/itim/incident.py @@ -0,0 +1,53 @@ +from api.serializers.core.ticket import TicketSerializer + +from core.models.ticket.ticket import Ticket + + + +class IncidentTicketSerializer( + TicketSerializer, +): + + class Meta: + + model = Ticket + + extra_kwargs = { + 'status': {'required': True} + } + + fields = [ + 'id', + 'assigned_teams', + 'assigned_users', + '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', + 'ticket_type', + 'url', + ] diff --git a/app/api/serializers/itim/problem.py b/app/api/serializers/itim/problem.py new file mode 100644 index 00000000..daefb1de --- /dev/null +++ b/app/api/serializers/itim/problem.py @@ -0,0 +1,53 @@ +from api.serializers.core.ticket import TicketSerializer + +from core.models.ticket.ticket import Ticket + + + +class ProblemTicketSerializer( + TicketSerializer, +): + + class Meta: + + model = Ticket + + extra_kwargs = { + 'status': {'required': True} + } + + fields = [ + 'id', + 'assigned_teams', + 'assigned_users', + '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', + 'ticket_type', + 'url', + ] diff --git a/app/api/urls.py b/app/api/urls.py index 04827427..2dc9fb72 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -9,8 +9,9 @@ from api.views.settings import permissions from api.views.settings import index as settings from api.views import assistance, itim -from api.views.core import tickets as core_tickets +from api.views.assistance import request_ticket from api.views.core import ticket_comments as core_ticket_comments +from api.views.itim import change_ticket, incident_ticket, problem_ticket from .views.itam import software, config as itam_config from .views.itam.device import DeviceViewSet @@ -24,19 +25,19 @@ router = DefaultRouter(trailing_slash=False) router.register('', index.Index, basename='_api_home') -router.register('assistance/(?Prequest)', core_tickets.View, basename='_api_assistance_request') -router.register('assistance/(?Prequest)/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_assistance_request_ticket_comments') +router.register('assistance/request', request_ticket.View, basename='_api_assistance_request') +router.register('assistance/request/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_assistance_request_ticket_comments') router.register('device', DeviceViewSet, basename='device') -router.register('itim/(?Pchange)', core_tickets.View, basename='_api_itim_change') -router.register('itim/(?Pchange)/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_change_ticket_comments') +router.register('itim/change', change_ticket.View, basename='_api_itim_change') +router.register('itim/change/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_change_ticket_comments') -router.register('itim/(?Pincident)', core_tickets.View, basename='_api_itim_incident') -router.register('itim/(?Pincident)/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_incident_ticket_comments') +router.register('itim/incident', incident_ticket.View, basename='_api_itim_incident') +router.register('itim/incident/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_incident_ticket_comments') -router.register('itim/(?Pproblem)', core_tickets.View, basename='_api_itim_problem') -router.register('itim/(?Pproblem)/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_problem_ticket_comments') +router.register('itim/problem', problem_ticket.View, basename='_api_itim_problem') +router.register('itim/problem/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_itim_problem_ticket_comments') router.register('software', software.SoftwareViewSet, basename='software') diff --git a/app/api/views/assistance/request_ticket.py b/app/api/views/assistance/request_ticket.py new file mode 100644 index 00000000..ca83fe59 --- /dev/null +++ b/app/api/views/assistance/request_ticket.py @@ -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' diff --git a/app/api/views/core/tickets.py b/app/api/views/core/tickets.py index 9fea9876..8b1ebe9d 100644 --- a/app/api/views/core/tickets.py +++ b/app/api/views/core/tickets.py @@ -1,12 +1,12 @@ -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 import TicketSerializer +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.views.mixin import OrganizationPermissionAPI from core.models.ticket.ticket import Ticket @@ -29,6 +29,10 @@ class View(OrganizationMixin, viewsets.ModelViewSet): action_keyword = 'delete' + elif self.action == 'list': + + action_keyword = 'view' + elif self.action == 'partial_update': action_keyword = 'change' @@ -37,12 +41,20 @@ class View(OrganizationMixin, viewsets.ModelViewSet): 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.kwargs['ticket_type'], + 'core.' + action_keyword + '_ticket_' + self._ticket_type, ] return super().get_permission_required() @@ -50,78 +62,52 @@ class View(OrganizationMixin, viewsets.ModelViewSet): queryset = Ticket.objects.all() - serializer_class = TicketSerializer + def get_serializer(self, *args, **kwargs): - # def get_object(self, queryset=None, **kwargs): - # item = self.kwargs.get('pk') - # return get_object_or_404(Ticket, pk=item) + if self._ticket_type == 'change': + + self.serializer_class = ChangeTicketSerializer + self._ticket_type_value = Ticket.TicketType.CHANGE.value - @extend_schema( - summary='Create a 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. - """, - request = TicketSerializer, - responses = { - 201: OpenApiResponse(description='Ticket created', response=TicketSerializer), - 403: OpenApiResponse(description='User tried to edit field they dont have access to'), - } - ) - def create(self, request, *args, **kwargs): + elif self._ticket_type == 'incident': + + self.serializer_class = IncidentTicketSerializer + self._ticket_type_value = Ticket.TicketType.INCIDENT.value - return super().create(request, *args, **kwargs) + 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 - @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=TicketSerializer), - } - ) - def list(self, request, *args, **kwargs): + else: - return super().list(request, *args, **kwargs) + raise ValueError('unable to determin the serializer_class') - - @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"] - ) - def retrieve(self, request, *args, **kwargs): - - return super().retrieve(request, *args, **kwargs) + return super().get_serializer(*args, **kwargs) def get_queryset(self): - if self.kwargs['ticket_type'] == 'change': + if self._ticket_type == 'change': ticket_type = self.queryset.model.TicketType.CHANGE.value - elif self.kwargs['ticket_type'] == 'incident': + elif self._ticket_type == 'incident': ticket_type = self.queryset.model.TicketType.INCIDENT.value - elif self.kwargs['ticket_type'] == 'problem': + elif self._ticket_type == 'problem': ticket_type = self.queryset.model.TicketType.PROBLEM.value - elif self.kwargs['ticket_type'] == 'request': + elif self._ticket_type == 'request': ticket_type = self.queryset.model.TicketType.REQUEST.value @@ -132,34 +118,3 @@ class View(OrganizationMixin, viewsets.ModelViewSet): return self.queryset.filter( ticket_type = ticket_type ) - - - def get_view_name(self): - - - # if self.kwargs['ticket_type'] == 'change': - - # ticket_type = 'Request' - - # elif self.kwargs['ticket_type'] == 'incident': - - # ticket_type = 'Incident' - - # elif self.kwargs['ticket_type'] == 'problem': - - # ticket_type = 'Problem' - - # elif self.kwargs['ticket_type'] == 'request': - - # ticket_type = 'Request' - - - # if self.detail: - # return ticket_type + " Ticket" - - # return ticket_type + ' Tickets' - - if self.detail: - return "Ticket" - - return 'Tickets' diff --git a/app/api/views/itim/change_ticket.py b/app/api/views/itim/change_ticket.py new file mode 100644 index 00000000..4375956b --- /dev/null +++ b/app/api/views/itim/change_ticket.py @@ -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' diff --git a/app/api/views/itim/incident_ticket.py b/app/api/views/itim/incident_ticket.py new file mode 100644 index 00000000..3472a802 --- /dev/null +++ b/app/api/views/itim/incident_ticket.py @@ -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' diff --git a/app/api/views/itim/problem_ticket.py b/app/api/views/itim/problem_ticket.py new file mode 100644 index 00000000..2e428eea --- /dev/null +++ b/app/api/views/itim/problem_ticket.py @@ -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' diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index caa4161f..aa0a59c5 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -307,6 +307,12 @@ class TicketValidation( continue + if field == 'ticket_type': + + if self.validated_data['ticket_type'] == self._context['view']._ticket_type_value: + + continue + if self.original_object is not None: if ( self.validated_data[field] != getattr(self.original_object, field) diff --git a/app/core/tests/unit/ticket/test_ticket_permission_api.py b/app/core/tests/unit/ticket/test_ticket_permission_api.py index 9f6ddb53..8d730fa2 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission_api.py +++ b/app/core/tests/unit/ticket/test_ticket_permission_api.py @@ -66,16 +66,15 @@ class TicketPermissionsAPI(APIPermissions): ) - self.url_kwargs = {'ticket_type': self.ticket_type,} + # self.url_kwargs = {} - self.url_view_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + self.url_view_kwargs = {'pk': self.item.id} self.add_data = { 'title': 'an add ticket', 'organization': self.organization.id, 'opened_by': self.add_user.id, 'status': int(Ticket.TicketStatus.All.NEW.value), - 'ticket_type': self.ticket_type_enum, 'description': 'the description' } From ecaa24192ff2f227a6db23ad13f802922ef146e3 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 6 Sep 2024 17:10:06 +0930 Subject: [PATCH 102/321] fix(core): Correct display of ticket status within ticket interface ref: #250 #96 #93 #95 #90 #264 #265 --- app/core/templates/core/ticket.html.j2 | 12 ++++++------ app/project-static/ticketing.css | 15 +++++++-------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index d924ad30..17f3b02c 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -99,7 +99,7 @@
- + {% if ticket.assigned_users %} {% for user in ticket.assigned_users.all %} {{ user }} @@ -118,11 +118,11 @@
- val + val
- + {% if ticket.project %} {{ ticket.project }} {% else %} @@ -132,15 +132,15 @@
- U{{ ticket.get_urgency_display }} / I{{ ticket.get_impact_display }} / P{{ ticket.get_priority_display }} + U{{ ticket.get_urgency_display }} / I{{ ticket.get_impact_display }} / P{{ ticket.get_priority_display }}
- val + val
- val + val
diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 739f5de8..149d8a5d 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -1,10 +1,11 @@ #badge { display: table; - padding: 0px 5px 0px 0px; + padding: 1px 5px 0px 2px; margin: 0px; border: 1px solid #ccc; border-radius: 15px; + /* height:fit-content; */ } #badge #text { @@ -24,9 +25,8 @@ } #badge .icon svg{ - display: inline; width: 20px; - height: 30px; + height: 20px; } @@ -428,7 +428,6 @@ #ticket-meta fieldset { display: block; margin: 10px; - line-height: 30px; border: none; } @@ -436,15 +435,15 @@ #ticket-meta fieldset label { width: 100%; display: block; - line-height: inherit; + line-height: 30px; font-weight: bold; } -#ticket-meta fieldset span { - width: 100%; +#ticket-meta fieldset span.text { + /*width: 100%;*/ display: block; - line-height: inherit; + line-height: 30px; border: none; border-bottom: 1px solid #ccc; } From 7a2f7fdf3d6a31f7cf27bc15a3119aa1b04e643a Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 6 Sep 2024 17:20:19 +0930 Subject: [PATCH 103/321] refactor(core): move id to end for rendered ticket link. ref: #250 #96 #93 #95 #90 #264 #265 --- app/core/templates/core/ticket/renderers/ticket_link.html.j2 | 2 +- app/project-static/ticketing.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/core/templates/core/ticket/renderers/ticket_link.html.j2 b/app/core/templates/core/ticket/renderers/ticket_link.html.j2 index cb7a738d..1bc36055 100644 --- a/app/core/templates/core/ticket/renderers/ticket_link.html.j2 +++ b/app/core/templates/core/ticket/renderers/ticket_link.html.j2 @@ -10,6 +10,6 @@ {% url 'ITIM:_ticket_problem_view' ticket_type='problem' pk=id %} {% elif ticket_type == 'request' %} {% url 'Assistance:_ticket_request_view' ticket_type='request' pk=id %} - {% endif %}">#{{ id }} {{ ticket_type }} {{ name }} + {% endif %}">{{ ticket_type }} {{ name }}, #{{ id }} \ No newline at end of file diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 149d8a5d..1934cd30 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -114,7 +114,6 @@ #ticket-additional-data { padding-right: 10px; - font-size: 12pt; } @@ -130,6 +129,7 @@ margin: 20px; padding: 0px; width: auto; + font-size: 12pt; } From 685b8266e4e9d3206d1ad47afe3105dba751f0c4 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 6 Sep 2024 17:58:14 +0930 Subject: [PATCH 104/321] feat(core): adding of more ticket status icons ref: #250 #96 #93 #95 #90 #264 #265 --- app/core/forms/ticket.py | 2 +- app/core/templates/core/ticket.html.j2 | 3 +- .../templates/core/ticket/icon_status.html.j2 | 14 ++++++- app/core/templates/core/ticket/index.html.j2 | 2 +- .../icons/ticket/status_accepted.svg | 1 + .../icons/ticket/status_approvals.svg | 1 + .../icons/ticket/status_assigned_planning.svg | 1 + .../templates/icons/ticket/status_closed.svg | 1 + .../icons/ticket/status_evaluation.svg | 1 + .../templates/icons/ticket/status_invalid.svg | 1 + .../templates/icons/ticket/status_testing.svg | 1 + app/core/templatetags/markdown.py | 6 +++ app/project-static/ticketing.css | 42 +++++++++++++++++++ 13 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 app/core/templates/icons/ticket/status_accepted.svg create mode 100644 app/core/templates/icons/ticket/status_approvals.svg create mode 100644 app/core/templates/icons/ticket/status_assigned_planning.svg create mode 100644 app/core/templates/icons/ticket/status_closed.svg create mode 100644 app/core/templates/icons/ticket/status_evaluation.svg create mode 100644 app/core/templates/icons/ticket/status_invalid.svg create mode 100644 app/core/templates/icons/ticket/status_testing.svg diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index 9d459a89..143e659f 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -113,7 +113,7 @@ class TicketForm( self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROJECT_TASK - self.fields['status'].widget = self.fields['status'].hidden_widget() + # self.fields['status'].widget = self.fields['status'].hidden_widget() if kwargs['user'].is_superuser: diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index 17f3b02c..f6e1ca51 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -5,6 +5,7 @@ {% endblock additional-stylesheet %} +{% load markdown %} {% block article %} @@ -114,7 +115,7 @@
- {% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.get_status_display ticket_status=ticket.get_status_display|lower %} + {% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.get_status_display ticket_status=ticket.get_status_display|ticket_status %}
diff --git a/app/core/templates/core/ticket/icon_status.html.j2 b/app/core/templates/core/ticket/icon_status.html.j2 index 5aac7ace..c4bb390d 100644 --- a/app/core/templates/core/ticket/icon_status.html.j2 +++ b/app/core/templates/core/ticket/icon_status.html.j2 @@ -2,12 +2,24 @@ {% 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_solved.svg' %} + {% 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 %} \ No newline at end of file diff --git a/app/core/templates/core/ticket/index.html.j2 b/app/core/templates/core/ticket/index.html.j2 index d95a6d03..757f6ed5 100644 --- a/app/core/templates/core/ticket/index.html.j2 +++ b/app/core/templates/core/ticket/index.html.j2 @@ -55,7 +55,7 @@ {{ ticket.title }} - {% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.get_status_display ticket_status=ticket.get_status_display|lower %} + {% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.get_status_display ticket_status=ticket.get_status_display|ticket_status %} {{ ticket.opened_by }} {{ ticket.created }} diff --git a/app/core/templates/icons/ticket/status_accepted.svg b/app/core/templates/icons/ticket/status_accepted.svg new file mode 100644 index 00000000..4b6a0467 --- /dev/null +++ b/app/core/templates/icons/ticket/status_accepted.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/status_approvals.svg b/app/core/templates/icons/ticket/status_approvals.svg new file mode 100644 index 00000000..aebe7654 --- /dev/null +++ b/app/core/templates/icons/ticket/status_approvals.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/status_assigned_planning.svg b/app/core/templates/icons/ticket/status_assigned_planning.svg new file mode 100644 index 00000000..78d058b9 --- /dev/null +++ b/app/core/templates/icons/ticket/status_assigned_planning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/status_closed.svg b/app/core/templates/icons/ticket/status_closed.svg new file mode 100644 index 00000000..0c1c1a75 --- /dev/null +++ b/app/core/templates/icons/ticket/status_closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/status_evaluation.svg b/app/core/templates/icons/ticket/status_evaluation.svg new file mode 100644 index 00000000..7a896080 --- /dev/null +++ b/app/core/templates/icons/ticket/status_evaluation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/status_invalid.svg b/app/core/templates/icons/ticket/status_invalid.svg new file mode 100644 index 00000000..f015fd4e --- /dev/null +++ b/app/core/templates/icons/ticket/status_invalid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templates/icons/ticket/status_testing.svg b/app/core/templates/icons/ticket/status_testing.svg new file mode 100644 index 00000000..6c808c38 --- /dev/null +++ b/app/core/templates/icons/ticket/status_testing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index 58e4a94c..49ff81f4 100644 --- a/app/core/templatetags/markdown.py +++ b/app/core/templatetags/markdown.py @@ -15,3 +15,9 @@ def markdown(value): @stringfilter def lower(value): return str(value).lower() + +@register.filter() +@stringfilter +def ticket_status(value): + + return str(value).lower().replace('(', '').replace(')', '').replace(' ', '_') diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 1934cd30..f7448ffa 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -296,6 +296,41 @@ fill: #2e9200; } +#icon.ticket-status-assigned_planning svg { + background-color: #e1ffb2; + border: none; + border-radius: 10px; + fill: #2e9200; +} + +#icon.ticket-status-approvals svg { + background-color: #ffceb2; + border: none; + border-radius: 10px; + fill: #d86100; +} + +#icon.ticket-status-accepted svg { + background-color: #e1ffb2; + border: none; + border-radius: 10px; + fill: #2e9200; +} + +#icon.ticket-status-evaluation svg { + background-color: #b2d6ff; + border: none; + border-radius: 10px; + fill: #007592; +} + +#icon.ticket-status-testing svg { + background-color: #b2d3ff; + border: none; + border-radius: 10px; + fill: #8c00ff; +} + #icon.ticket-status-draft svg { background-color: #cacaca; border: none; @@ -331,6 +366,13 @@ fill: #640092; } +#icon.ticket-status-invalid svg { + background-color: #ffb2b6; + border: none; + border-radius: 10px; + fill: #920000; +} + #ticket-comments #comment { border: 1px solid #177ee6; From 09247246bbb747094883837e7f6b47289756a2ec Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 6 Sep 2024 18:01:22 +0930 Subject: [PATCH 105/321] fix(api): correct ticket view links ref: #250 #96 #93 #95 #90 #264 #265 --- app/api/views/assistance/index.py | 2 +- app/api/views/itim/index.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api/views/assistance/index.py b/app/api/views/assistance/index.py index 7be852f3..7099652b 100644 --- a/app/api/views/assistance/index.py +++ b/app/api/views/assistance/index.py @@ -29,7 +29,7 @@ class Index(views.APIView): def get(self, request, *args, **kwargs): body: dict = { - 'requests': reverse('API:_api_assistance_request-list', request=request, kwargs={'ticket_type': 'request'}) + 'requests': reverse('API:_api_assistance_request-list', request=request) } return Response(body) diff --git a/app/api/views/itim/index.py b/app/api/views/itim/index.py index f02e522a..a82b1688 100644 --- a/app/api/views/itim/index.py +++ b/app/api/views/itim/index.py @@ -28,9 +28,9 @@ class Index(views.APIView): def get(self, request, *args, **kwargs): body: dict = { - 'changes': reverse('API:_api_itim_change-list', request=request, kwargs={'ticket_type': 'change'}), - 'incidents': reverse('API:_api_itim_incident-list', request=request, kwargs={'ticket_type': 'incident'}), - 'problems': reverse('API:_api_itim_problem-list', request=request, kwargs={'ticket_type': 'problem'}), + '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) From a08d74cd3ce640057630d23cd5d67b9f7132d75f Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 7 Sep 2024 14:30:22 +0930 Subject: [PATCH 106/321] feat(core): Create action comment for assigned users/teams ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/models/ticket/ticket.py | 104 ++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 11fc11c3..2aef1bc5 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -1,6 +1,6 @@ from django.contrib.auth.models import User from django.db import models -from django.db.models import Q +from django.db.models import Q, signals from django.forms import ValidationError from access.fields import AutoCreatedField, AutoLastModifiedField @@ -805,6 +805,108 @@ class Ticket( comment.save() + signals.m2m_changed.connect(self.action_comment_ticket_users, Ticket.assigned_users.through) + signals.m2m_changed.connect(self.action_comment_ticket_teams, Ticket.assigned_teams.through) + + + + def action_comment_ticket_users(self, sender, instance, action, reverse, model, pk_set, **kwargs): + """ Ticket *_users many2many field + + - Create the action comment + - Update ticket status to New/Assigned + """ + + pk: int = 0 + + user: list(User) = None + comment_field_value: str = None + + if pk_set: + + pk = next(iter(pk_set)) + + request = get_request() + + if pk: + + user = User.objects.get(pk = pk) + + if sender.__name__ == 'Ticket_assigned_users': + + if action == 'post_remove': + + comment_field_value = f"Unassigned @" + str(user.username) + + elif action == 'post_add': + + comment_field_value = f"Assigned @" + str(user.username) + + + if comment_field_value: + + from core.models.ticket.ticket_comment import TicketComment + + comment = TicketComment.objects.create( + ticket = instance, + comment_type = TicketComment.CommentType.ACTION, + body = comment_field_value, + source = TicketComment.CommentSource.DIRECT, + user = request.user, + ) + + comment.save() + + + + def action_comment_ticket_teams(self, sender, instance, action, reverse, model, pk_set, **kwargs): + """Ticket *_teams many2many field + + - Create the action comment + - Update ticket status to New/Assigned + """ + + pk: int = 0 + + team: list(Team) = None + comment_field_value: str = None + + if pk_set: + + pk = next(iter(pk_set)) + + request = get_request() + + if pk: + + team = Team.objects.get(pk = pk) + + if sender.__name__ == 'Ticket_assigned_teams': + + if action == 'post_remove': + + comment_field_value = f"Unassigned team @" + str(team.team_name) + + elif action == 'post_add': + + comment_field_value = f"Assigned team @" + str(team.team_name) + + + if comment_field_value: + + from core.models.ticket.ticket_comment import TicketComment + + comment = TicketComment.objects.create( + ticket = instance, + comment_type = TicketComment.CommentType.ACTION, + body = comment_field_value, + source = TicketComment.CommentSource.DIRECT, + user = request.user, + ) + + comment.save() + + class RelatedTickets(TenancyObject): From f4d96c78e77465ca0f5875bfe01d8e2091695101 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 7 Sep 2024 14:32:24 +0930 Subject: [PATCH 107/321] feat(core): Create action comment for subscribed users/teams ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/models/ticket/ticket.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 2aef1bc5..245679a5 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -808,6 +808,8 @@ class Ticket( signals.m2m_changed.connect(self.action_comment_ticket_users, Ticket.assigned_users.through) signals.m2m_changed.connect(self.action_comment_ticket_teams, Ticket.assigned_teams.through) + signals.m2m_changed.connect(self.action_comment_ticket_users, Ticket.subscribed_users.through) + signals.m2m_changed.connect(self.action_comment_ticket_teams, Ticket.subscribed_teams.through) def action_comment_ticket_users(self, sender, instance, action, reverse, model, pk_set, **kwargs): @@ -843,6 +845,17 @@ class Ticket( comment_field_value = f"Assigned @" + str(user.username) + elif sender.__name__ == 'Ticket_subscribed_users': + + if action == 'post_remove': + + comment_field_value = f"Removed @{str(user.username)} as watching" + + elif action == 'post_add': + + comment_field_value = f"Added @{str(user.username)} as watching" + + if comment_field_value: from core.models.ticket.ticket_comment import TicketComment @@ -892,6 +905,17 @@ class Ticket( comment_field_value = f"Assigned team @" + str(team.team_name) + elif sender.__name__ == 'Ticket_subscribed_teams': + + if action == 'post_remove': + + comment_field_value = f"Removed team @{str(team.team_name)} as watching" + + elif action == 'post_add': + + comment_field_value = f"Added team @{str(team.team_name)} as watching" + + if comment_field_value: from core.models.ticket.ticket_comment import TicketComment From 8277e0520535d0da5fe85c474347d484daa071ec Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 7 Sep 2024 14:33:01 +0930 Subject: [PATCH 108/321] feat(core): Update ticket status when assigned/unassigned users/teams ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/models/ticket/ticket.py | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 245679a5..4a75beb2 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -812,6 +812,39 @@ class Ticket( signals.m2m_changed.connect(self.action_comment_ticket_teams, Ticket.subscribed_teams.through) + def ticketassigned(self, instance) -> bool: + """ Check if the ticket has any assigned user(s)/team(s)""" + + users = len(instance.assigned_users.all()) + teams = len(instance.assigned_teams.all()) + + if users < 1 and teams < 1: + + return False + + return True + + + def assigned_status_update(self, instance) -> None: + """Update Ticket status based off of assigned + + - If the ticket has any assigned team(s)/user(s), update the status to assigned. + - If the ticket does not have any assigned team(s)/user(s), update the status to new. + + This method only updates the status if the existing status is New or Assigned. + """ + + assigned = self.ticketassigned(instance) + + if not assigned and instance.status == Ticket.TicketStatus.All.ASSIGNED: + instance.status = Ticket.TicketStatus.All.NEW + instance.save() + + elif assigned and instance.status == Ticket.TicketStatus.All.NEW: + instance.status = Ticket.TicketStatus.All.ASSIGNED + instance.save() + + def action_comment_ticket_users(self, sender, instance, action, reverse, model, pk_set, **kwargs): """ Ticket *_users many2many field @@ -845,6 +878,9 @@ class Ticket( comment_field_value = f"Assigned @" + str(user.username) + self.assigned_status_update(instance) + + elif sender.__name__ == 'Ticket_subscribed_users': if action == 'post_remove': @@ -905,6 +941,9 @@ class Ticket( comment_field_value = f"Assigned team @" + str(team.team_name) + self.assigned_status_update() + + elif sender.__name__ == 'Ticket_subscribed_teams': if action == 'post_remove': From 819dc0145193ed8d8c9137250f32888f45b38c09 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 8 Sep 2024 13:01:53 +0930 Subject: [PATCH 109/321] refactor(core): dont require specifying ticket status ref: #250 #96 #93 #95 #90 #264 #266 --- app/api/serializers/assistance/request.py | 4 - app/api/serializers/core/ticket.py | 4 +- app/api/serializers/itim/change.py | 4 - app/api/serializers/itim/incident.py | 4 - app/api/serializers/itim/problem.py | 4 - app/core/forms/ticket.py | 1 - app/core/forms/validate_ticket.py | 100 +++++++++++------- ...ermission.py => test_ticket_permission.py} | 5 +- .../unit/ticket/test_ticket_permission_api.py | 1 - 9 files changed, 65 insertions(+), 62 deletions(-) rename app/core/tests/unit/ticket/{types/test_ticket_request_permission.py => test_ticket_permission.py} (98%) diff --git a/app/api/serializers/assistance/request.py b/app/api/serializers/assistance/request.py index 19f5a4f4..d4df3d56 100644 --- a/app/api/serializers/assistance/request.py +++ b/app/api/serializers/assistance/request.py @@ -12,10 +12,6 @@ class RequestTicketSerializer( model = Ticket - extra_kwargs = { - 'status': {'required': True} - } - fields = [ 'id', 'assigned_teams', diff --git a/app/api/serializers/core/ticket.py b/app/api/serializers/core/ticket.py index ca197aef..2ced65cf 100644 --- a/app/api/serializers/core/ticket.py +++ b/app/api/serializers/core/ticket.py @@ -95,9 +95,6 @@ class TicketSerializer( model = Ticket - extra_kwargs = { - 'status': {'required': True} - } fields = [ 'id', @@ -138,6 +135,7 @@ class TicketSerializer( 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 super().__init__(instance=instance, data=data, **kwargs) diff --git a/app/api/serializers/itim/change.py b/app/api/serializers/itim/change.py index 797c0195..e7a920c7 100644 --- a/app/api/serializers/itim/change.py +++ b/app/api/serializers/itim/change.py @@ -12,10 +12,6 @@ class ChangeTicketSerializer( model = Ticket - extra_kwargs = { - 'status': {'required': True} - } - fields = [ 'id', 'assigned_teams', diff --git a/app/api/serializers/itim/incident.py b/app/api/serializers/itim/incident.py index aeccefce..ceff1ef0 100644 --- a/app/api/serializers/itim/incident.py +++ b/app/api/serializers/itim/incident.py @@ -12,10 +12,6 @@ class IncidentTicketSerializer( model = Ticket - extra_kwargs = { - 'status': {'required': True} - } - fields = [ 'id', 'assigned_teams', diff --git a/app/api/serializers/itim/problem.py b/app/api/serializers/itim/problem.py index daefb1de..2c037864 100644 --- a/app/api/serializers/itim/problem.py +++ b/app/api/serializers/itim/problem.py @@ -12,10 +12,6 @@ class ProblemTicketSerializer( model = Ticket - extra_kwargs = { - 'status': {'required': True} - } - fields = [ 'id', 'assigned_teams', diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index 143e659f..cdab9e15 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -16,7 +16,6 @@ class TicketForm( TicketValidation, ): - prefix = 'ticket' class Meta: model = Ticket diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index aa0a59c5..44358f7d 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -1,5 +1,4 @@ -from django.core.exceptions import PermissionDenied -from django.forms import ValidationError +from django.core.exceptions import PermissionDenied, ValidationError from rest_framework import serializers @@ -172,27 +171,37 @@ class TicketValidation( if len(fields_allowed) == 0: - raise PermissionDenied('Access Denied') + raise ValidationError('Access Denied to all fields', code='access_denied_all_fields') for field in self.changed_data: allowed: bool = False - if hasattr(self.fields[field], 'widget'): + if field in self.fields: - if field in fields_allowed or self.fields[field].widget.is_hidden: + if hasattr(self.fields[field], 'widget'): - allowed = True + if field in fields_allowed or self.fields[field].widget.is_hidden: - else: + allowed = True - if field in fields_allowed or self.fields[field].required: + else: - allowed = True + if field in fields_allowed or self.fields[field].required: + + allowed = True if not allowed: - raise PermissionDenied(f'cant edit field: {field}') + raise ValidationError( + f'cant edit field: {field}', + code=f'cant_edit_field_{field}', + ) + + return False + + + return True @@ -287,51 +296,64 @@ class TicketValidation( is_valid = False + fields: list = [] + if hasattr(self, 'validated_data'): - changed_data: list = [] + fields = self.validated_data - changed_data_exempt = [ - 'ticket_comments', - 'url', - ] + else: - for field in self.validated_data: + fields = self.data + + changed_data: list = [] + + changed_data_exempt = [ + 'csrfmiddlewaretoken', + 'ticket_comments', + 'url', + ] + + for field in fields: + + if str(field).startswith('ticket-'): + + field = str(field).replace('ticket-','') + + if field in changed_data_exempt: + continue + + if field == 'is_deleted': + + if self.fields['is_deleted']: - if field in changed_data_exempt: continue - if field == 'is_deleted': + if field == 'ticket_type': - if not self.validated_data['is_deleted']: + if self.fields['ticket_type']: - continue + continue - if field == 'ticket_type': + if self.original_object is not None: + if ( + fields[field] != getattr(self.original_object, field) + and ( + type(fields[field]) in [str, int, bool] + ) + ) : - if self.validated_data['ticket_type'] == self._context['view']._ticket_type_value: - - continue - - if self.original_object is not None: - if ( - self.validated_data[field] != getattr(self.original_object, field) - and ( - type(self.validated_data[field]) in [str, int, bool] - ) - ) : - - changed_data = changed_data + [ field ] - else: + changed_data = changed_data + [ field ] + else: - if type(self.validated_data[field]) in [str, int, bool]: + if type(fields[field]) in [str, int, bool]: - changed_data = changed_data + [ field ] + changed_data = changed_data + [ field ] - if len(changed_data) > 0: + if len(changed_data) > 0: - self.changed_data = changed_data + self.changed_data = changed_data validate_field_permission = False if self.validate_field_permission(): diff --git a/app/core/tests/unit/ticket/types/test_ticket_request_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py similarity index 98% rename from app/core/tests/unit/ticket/types/test_ticket_request_permission.py rename to app/core/tests/unit/ticket/test_ticket_permission.py index df9b4190..1f477b95 100644 --- a/app/core/tests/unit/ticket/types/test_ticket_request_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -16,7 +16,9 @@ from app.tests.abstract.model_permissions import ModelPermissions from core.models.ticket.ticket import Ticket -class TicketPermissions(ModelPermissions): +class TicketPermissions( + ModelPermissions, +): ticket_type:str = None @@ -95,7 +97,6 @@ class TicketPermissions(ModelPermissions): 'title': 'an add ticket', 'organization': self.organization.id, 'opened_by': self.add_user.id, - 'status': int(Ticket.TicketStatus.All.NEW.value) } self.url_change_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} diff --git a/app/core/tests/unit/ticket/test_ticket_permission_api.py b/app/core/tests/unit/ticket/test_ticket_permission_api.py index 8d730fa2..24134c60 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission_api.py +++ b/app/core/tests/unit/ticket/test_ticket_permission_api.py @@ -74,7 +74,6 @@ class TicketPermissionsAPI(APIPermissions): 'title': 'an add ticket', 'organization': self.organization.id, 'opened_by': self.add_user.id, - 'status': int(Ticket.TicketStatus.All.NEW.value), 'description': 'the description' } From 27958f5e7a721654676894d5c5dde6e6a587daf2 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 8 Sep 2024 15:14:33 +0930 Subject: [PATCH 110/321] refactor(core): cache fields allowed during ticket validation ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/forms/ticket.py | 10 ++++++++-- app/core/forms/validate_ticket.py | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index cdab9e15..9d9e9cec 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -125,12 +125,18 @@ class TicketForm( if field not in ticket_type: - fields_allowed.remove(field) + self._fields_allowed.remove(field) for field in original_fields: # Remove fields user cant edit unless field is hidden - if field not in fields_allowed and not self.fields[field].widget.is_hidden: + if ( + ( + field not in self._fields_allowed and not self.fields[field].widget.is_hidden + ) + or + field not in ticket_type + ): del self.fields[field] diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 44358f7d..81ab105c 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -74,6 +74,10 @@ class TicketValidation( @property def fields_allowed(self): + if hasattr(self, '_fields_allowed'): + + return self._fields_allowed + if not hasattr(self, '_ticket_type'): self._ticket_type = self.initial['type_ticket'] @@ -153,6 +157,8 @@ class TicketValidation( fields_allowed = fields_allowed + all_fields + self._fields_allowed = fields_allowed + return fields_allowed From 8b4068ac7e8e5581ab4f145bd7bcffb3caac9cd6 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 8 Sep 2024 16:41:43 +0930 Subject: [PATCH 111/321] fix(core): prevent import user from having permssions within UI only allow import user to have API permissions. ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/forms/validate_ticket.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 81ab105c..fbc2d469 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -138,7 +138,9 @@ class TicketValidation( permissions_required = [ 'core.import_ticket_'+ self._ticket_type ], ) and not self.request.user.is_superuser: - fields_allowed = fields_allowed + self.import_fields + if hasattr(self, 'serializer_choice_field'): + + fields_allowed = fields_allowed + self.import_fields if self.has_organization_permission( organization=ticket_organization.id, From 3261342c4f5419eeecde938bd7744e30504ef778 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 8 Sep 2024 16:46:17 +0930 Subject: [PATCH 112/321] test(core): field based permission tests for add, change, import and triage user ref: #250 #96 #93 #95 #90 #264 #266 --- .../unit/ticket/test_ticket_permission.py | 185 +- .../field_based_permissions.py | 2630 +++++++++++++++++ 2 files changed, 2813 insertions(+), 2 deletions(-) create mode 100644 app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index 1f477b95..83681ebd 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser, User from django.contrib.contenttypes.models import ContentType +# from django.core.exceptions import ValidationError from django.shortcuts import reverse from django.test import TestCase, Client @@ -15,9 +16,14 @@ from app.tests.abstract.model_permissions import ModelPermissions from core.models.ticket.ticket import Ticket +from core.tests.unit.ticket.ticket_permission.field_based_permissions import TicketFieldBasedPermissions + + + class TicketPermissions( ModelPermissions, + TicketFieldBasedPermissions ): ticket_type:str = None @@ -101,11 +107,11 @@ class TicketPermissions( self.url_change_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} - self.change_data = {'title': 'an change to ticket', 'organization': self.organization.id} + self.change_data = {'title': 'an change to ticket'} self.url_delete_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} - self.delete_data = {'title': 'a delete to ticket', 'organization': self.organization.id} + self.delete_data = {'title': 'a delete to ticket'} view_permissions = Permission.objects.get( @@ -199,6 +205,181 @@ class TicketPermissions( user = self.different_organization_user ) + # Import user/permissions + + import_permissions = Permission.objects.get( + codename = 'import_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + import_team = Team.objects.create( + team_name = 'import_team', + organization = organization, + ) + + import_team.permissions.set([change_permissions, import_permissions]) + + + self.import_user = User.objects.create_user(username="test_user_import", password="password") + teamuser = TeamUsers.objects.create( + team = import_team, + user = self.import_user + ) + + # Triage user/permissions + + triage_permissions = Permission.objects.get( + codename = 'triage_' + self.model._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + triage_team = Team.objects.create( + team_name = 'triage_team', + organization = organization, + ) + + triage_team.permissions.set([change_permissions, triage_permissions]) + + + self.triage_user = User.objects.create_user(username="test_user_triage", password="password") + teamuser = TeamUsers.objects.create( + team = triage_team, + user = self.triage_user + ) + + + + + @pytest.mark.skip(reason="To be written") + def test_permission_triage(self): + + pass + + + @pytest.mark.skip(reason="To be written") + def test_permission_purge(self): + + pass + + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_assign_user_added(self): + """Action Comment test + Confirm an action comment is created when a user is added as assigned + """ + + pass + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_assign_user_removed(self): + """Action Comment test + Confirm an action comment is created when a user is removed as assigned + """ + + pass + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_assign_team_added(self): + """Action Comment test + Confirm an action comment is created when a team is added as assigned + """ + + pass + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_assign_team_removed(self): + """Action Comment test + Confirm an action comment is created when a team is removed as assigned + """ + + pass + + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_subscribe_user_added(self): + """Action Comment test + Confirm an action comment is created when a user is added as subscribed + """ + + pass + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_subscribe_user_removed(self): + """Action Comment test + Confirm an action comment is created when a user is removed as subscribed + """ + + pass + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_subscribe_team_added(self): + """Action Comment test + Confirm an action comment is created when a team is added as subscribed + """ + + pass + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_subscribe_team_removed(self): + """Action Comment test + Confirm an action comment is created when a team is removed as subscribed + """ + + pass + + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_status_change(self): + """Action Comment test + Confirm an action comment is created when the ticket status changes + """ + + pass + + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_related_ticket_added(self): + """Action Comment test + Confirm an action comment is created when a related ticket is added + """ + + pass + + + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_related_ticket_removed(self): + """Action Comment test + Confirm an action comment is created when a related ticket is removed + """ + + pass + + + @pytest.mark.skip(reason='to be written') + def test_ticket_creation_field_edit_denied(self): + """Action Comment test + Confirm an action comment is created when a user is added as assigned + """ + + pass + + class ChangeTicketPermissions(TicketPermissions, TestCase): diff --git a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py new file mode 100644 index 00000000..c1681c4c --- /dev/null +++ b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py @@ -0,0 +1,2630 @@ +import pytest + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.shortcuts import reverse +from django.test import Client + +from core.models.ticket.ticket import Ticket + + +class TicketFieldPermissionsAddUser: + + + def test_field_permission_status_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field status. + """ + + field_name: str = 'status' + field_value = int(Ticket.TicketStatus.All.ASSIGNED.value) + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_priority_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field priority. + """ + + field_name: str = 'priority' + field_value = int(Ticket.TicketStatus.All.ASSIGNED.value) + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + + def test_field_permission_assigned_users_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field assigned_users. + """ + + field_name: str = 'assigned_users' + field_value = [1] + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_assigned_teams_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field assigned_teams. + """ + + field_name: str = 'assigned_teams' + field_value = [1] + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_created_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field created. + """ + + field_name: str = 'created' + field_value = '2024-09-08T13:19:00' + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_date_closed_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field date_closed. + """ + + field_name: str = 'date_closed' + field_value = '2024-09-08T13:19:00' + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_external_ref_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field external_ref. + """ + + field_name: str = 'external_ref' + field_value = 1 + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_external_system_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field external_system. + """ + + field_name: str = 'external_system' + field_value = 9999 + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_opened_by_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field opened_by. + """ + + field_name: str = 'opened_by' + field_value = 1 + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_planned_start_date_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_start_date. + """ + + field_name: str = 'planned_start_date' + field_value = '2024-09-08T13:19:00' + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_planned_finish_date_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_finish_date. + """ + + field_name: str = 'planned_finish_date' + field_value = '2024-09-08T13:19:00' + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + @pytest.mark.skip(reason='Add project to setuptest') + def test_field_permission_project_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field project. + """ + + field_name: str = 'project' + field_value = '2024-09-08T13:19:00' + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_real_start_date_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_start_date. + """ + + field_name: str = 'real_start_date' + field_value = '2024-09-08T13:19:00' + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_real_finish_date_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_finish_date. + """ + + field_name: str = 'real_finish_date' + field_value = '2024-09-08T13:19:00' + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_subscribed_users_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field subscribed_users. + """ + + field_name: str = 'subscribed_users' + field_value = [1] + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_subscribed_teams_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field subscribed_teams. + """ + + field_name: str = 'subscribed_teams' + field_value = [1] + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + def test_field_permission_ticket_type_add_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field ticket_type. + """ + + field_name: str = 'ticket_type' + field_value = int(Ticket.TicketType.REQUEST) + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client.force_login(self.add_user) + + data = self.add_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + except Exception as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + + +class TicketFieldPermissionsChangeUser: + + + def test_field_permission_status_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field status. + """ + + field_name: str = 'status' + field_value = int(Ticket.TicketStatus.All.ASSIGNED.value) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_priority_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field priority. + """ + + field_name: str = 'priority' + field_value = int(Ticket.TicketStatus.All.ASSIGNED.value) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + + def test_field_permission_assigned_users_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field assigned_users. + """ + + field_name: str = 'assigned_users' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_assigned_teams_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field assigned_teams. + """ + + field_name: str = 'assigned_teams' + field_value = [1] + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_created_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field created. + """ + + field_name: str = 'created' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_date_closed_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field date_closed. + """ + + field_name: str = 'date_closed' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_external_ref_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field external_ref. + """ + + field_name: str = 'external_ref' + field_value = 1 + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_external_system_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field external_system. + """ + + field_name: str = 'external_system' + field_value = 9999 + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_opened_by_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field opened_by. + """ + + field_name: str = 'opened_by' + field_value = 1 + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_planned_start_date_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_start_date. + """ + + field_name: str = 'planned_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_planned_finish_date_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_finish_date. + """ + + field_name: str = 'planned_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + @pytest.mark.skip(reason='Add project to setuptest') + def test_field_permission_project_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field project. + """ + + field_name: str = 'project' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_real_start_date_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_start_date. + """ + + field_name: str = 'real_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_real_finish_date_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_finish_date. + """ + + field_name: str = 'real_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_subscribed_users_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field subscribed_users. + """ + + field_name: str = 'subscribed_users' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_subscribed_teams_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field subscribed_teams. + """ + + field_name: str = 'subscribed_teams' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_ticket_type_change_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field ticket_type. + """ + + field_name: str = 'ticket_type' + field_value = int(Ticket.TicketType.REQUEST) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.change_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + +class TicketFieldPermissionsImportUser: + """Although the import use has access to edit all fields + the import user should not allow access via the UI. + + These tests are to ensure this. + """ + + + def test_field_permission_status_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field status. + """ + + field_name: str = 'status' + field_value = int(Ticket.TicketStatus.All.ASSIGNED.value) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_priority_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field priority. + """ + + field_name: str = 'priority' + field_value = int(Ticket.TicketStatus.All.ASSIGNED.value) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + + def test_field_permission_assigned_users_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field assigned_users. + """ + + field_name: str = 'assigned_users' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_assigned_teams_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field assigned_teams. + """ + + field_name: str = 'assigned_teams' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_created_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field created. + """ + + field_name: str = 'created' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_date_closed_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field date_closed. + """ + + field_name: str = 'date_closed' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_external_ref_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field external_ref. + """ + + field_name: str = 'external_ref' + field_value = 1 + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_external_system_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field external_system. + """ + + field_name: str = 'external_system' + field_value = 9999 + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_opened_by_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field opened_by. + """ + + field_name: str = 'opened_by' + field_value = 1 + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_planned_start_date_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_start_date. + """ + + field_name: str = 'planned_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_planned_finish_date_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_finish_date. + """ + + field_name: str = 'planned_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + @pytest.mark.skip(reason='Add project to setuptest') + def test_field_permission_project_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field project. + """ + + field_name: str = 'project' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_real_start_date_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_start_date. + """ + + field_name: str = 'real_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_real_finish_date_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_finish_date. + """ + + field_name: str = 'real_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_subscribed_users_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field subscribed_users. + """ + + field_name: str = 'subscribed_users' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_subscribed_teams_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field subscribed_teams. + """ + + field_name: str = 'subscribed_teams' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_ticket_type_import_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field ticket_type. + """ + + field_name: str = 'ticket_type' + field_value = int(Ticket.TicketType.REQUEST) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.import_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + +class TicketFieldPermissionsTriageUser: + + + def test_field_permission_status_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field status. + """ + + field_name: str = 'status' + field_value = int(Ticket.TicketStatus.All.ASSIGNED.value) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + def test_field_permission_priority_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field priority. + """ + + field_name: str = 'priority' + field_value = int(Ticket.TicketStatus.All.ASSIGNED.value) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + + def test_field_permission_assigned_users_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field assigned_users. + """ + + field_name: str = 'assigned_users' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + def test_field_permission_assigned_teams_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field assigned_teams. + """ + + field_name: str = 'assigned_teams' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + def test_field_permission_created_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field created. + """ + + field_name: str = 'created' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_date_closed_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field date_closed. + """ + + field_name: str = 'date_closed' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_external_ref_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field external_ref. + """ + + field_name: str = 'external_ref' + field_value = 1 + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_external_system_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field external_system. + """ + + field_name: str = 'external_system' + field_value = 9999 + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_opened_by_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field opened_by. + """ + + field_name: str = 'opened_by' + field_value = 1 + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + def test_field_permission_planned_start_date_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_start_date. + """ + + field_name: str = 'planned_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_planned_finish_date_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_finish_date. + """ + + field_name: str = 'planned_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + + @pytest.mark.skip(reason='Add project to setuptest') + def test_field_permission_project_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field project. + """ + + field_name: str = 'project' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_real_start_date_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_start_date. + """ + + field_name: str = 'real_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_real_finish_date_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_finish_date. + """ + + field_name: str = 'real_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_subscribed_users_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field subscribed_users. + """ + + field_name: str = 'subscribed_users' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + def test_field_permission_subscribed_teams_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field subscribed_teams. + """ + + field_name: str = 'subscribed_teams' + field_value = [1] + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + def test_field_permission_ticket_type_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field ticket_type. + """ + + field_name: str = 'ticket_type' + field_value = int(Ticket.TicketType.REQUEST) + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + +class TicketFieldBasedPermissions( + TicketFieldPermissionsAddUser, + TicketFieldPermissionsChangeUser, + TicketFieldPermissionsImportUser, + TicketFieldPermissionsTriageUser, +): + + pass + + + +# @pytest.mark.django_db +# @pytest.mark.parametrize("field,value,status_code", [ +# ('status', int(Ticket.TicketStatus.All.ASSIGNED.value), 'cant_edit_field_status'), +# ('priority', int(Ticket.TicketPriority.LOW), 'cant_edit_field_priority'), +# ]) +# class TestFieldEditDenied: + + +# ticket_type = 'change' + +# ticket_type_enum: int = int(Ticket.TicketType.CHANGE.value) + +# app_namespace = 'ITIM' + +# url_name_view = '_ticket_change_view' + +# url_name_add = '_ticket_change_add' + +# url_name_change = '_ticket_change_change' + +# url_name_delete = '_ticket_change_delete' + +# url_delete_response = reverse('ITIM:Changes') + +# # @pytest.mark.django_db +# # @classmethod +# # def setUpTestData(self): +# @pytest.mark.django_db +# # @pytest.fixture(scope="class") +# # def setup_class(self, db): +# @classmethod +# # def setUpClass(self, db): +# def setUpTestData(self): +# """Setup Test + +# 1. Create an organization for user and item +# . create an organization that is different to item +# 2. Create a manufacturer +# 3. create teams with each permission: view, add, change, delete +# 4. create a user per team +# """ + +# organization = Organization.objects.create(name='test_org') + +# self.organization = organization + +# different_organization = Organization.objects.create(name='test_different_organization') + + +# add_permissions = Permission.objects.get( +# codename = 'add_' + self.model._meta.model_name + '_' + self.ticket_type, +# content_type = ContentType.objects.get( +# app_label = self.model._meta.app_label, +# model = self.model._meta.model_name, +# ) +# ) + +# add_team = Team.objects.create( +# team_name = 'add_team', +# organization = organization, +# ) + +# add_team.permissions.set([add_permissions]) + + +# self.add_user = User.objects.create_user(username="test_user_add", password="password") +# teamuser = TeamUsers.objects.create( +# team = add_team, +# user = self.add_user +# ) + + +# self.item = self.model.objects.create( +# organization=organization, +# title = 'A ' + self.ticket_type + ' ticket', +# description = 'the ticket body', +# ticket_type = int(Ticket.TicketType.REQUEST.value), +# opened_by = self.add_user, +# status = int(Ticket.TicketStatus.All.NEW.value) +# ) + + +# self.url_view_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + +# self.url_add_kwargs = {'ticket_type': self.ticket_type} + +# # self.add_data = { +# # 'title': 'an add ticket', +# # 'organization': self.organization.id, +# # 'opened_by': self.add_user.id, +# # 'status': int(Ticket.TicketStatus.All.NEW.value) +# # } + +# self.add_data = { +# 'title': 'an add ticket', +# 'organization': self.organization.id, +# 'opened_by': self.add_user.id, +# } + +# self.url_change_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + +# self.change_data = {'title': 'an change to ticket', 'organization': self.organization.id} + +# self.url_delete_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + +# self.delete_data = {'title': 'a delete to ticket', 'organization': self.organization.id} + + +# view_permissions = Permission.objects.get( +# codename = 'view_' + self.model._meta.model_name + '_' + self.ticket_type, +# content_type = ContentType.objects.get( +# app_label = self.model._meta.app_label, +# model = self.model._meta.model_name, +# ) +# ) + +# view_team = Team.objects.create( +# team_name = 'view_team', +# organization = organization, +# ) + +# view_team.permissions.set([view_permissions]) + + +# change_permissions = Permission.objects.get( +# codename = 'change_' + self.model._meta.model_name + '_' + self.ticket_type, +# content_type = ContentType.objects.get( +# app_label = self.model._meta.app_label, +# model = self.model._meta.model_name, +# ) +# ) + +# change_team = Team.objects.create( +# team_name = 'change_team', +# organization = organization, +# ) + +# change_team.permissions.set([change_permissions]) + + + +# delete_permissions = Permission.objects.get( +# codename = 'delete_' + self.model._meta.model_name + '_' + self.ticket_type, +# content_type = ContentType.objects.get( +# app_label = self.model._meta.app_label, +# model = self.model._meta.model_name, +# ) +# ) + +# delete_team = Team.objects.create( +# team_name = 'delete_team', +# organization = organization, +# ) + +# delete_team.permissions.set([delete_permissions]) + + +# self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + +# self.view_user = User.objects.create_user(username="test_user_view", password="password") +# teamuser = TeamUsers.objects.create( +# team = view_team, +# user = self.view_user +# ) + +# self.change_user = User.objects.create_user(username="test_user_change", password="password") +# teamuser = TeamUsers.objects.create( +# team = change_team, +# user = self.change_user +# ) + +# self.delete_user = User.objects.create_user(username="test_user_delete", password="password") +# teamuser = TeamUsers.objects.create( +# team = delete_team, +# user = self.delete_user +# ) + + +# self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + +# different_organization_team = Team.objects.create( +# team_name = 'different_organization_team', +# organization = different_organization, +# ) + +# different_organization_team.permissions.set([ +# view_permissions, +# add_permissions, +# change_permissions, +# delete_permissions, +# ]) + +# TeamUsers.objects.create( +# team = different_organization_team, +# user = self.different_organization_user +# ) + + + + +# def test_model_add_has_permission_field_denied(self, field,value,status_code, db): +# """ Check correct permission for add + +# set status to value and attempt to create a ticket. +# A standard user should not be able to edit field status. +# """ + +# client = Client(raise_request_exception=True) +# url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) + + +# client.force_login(self.add_user) + +# data = self.add_data.copy() + +# data[field] = value + +# try: + +# response = client.post( +# url, +# data=data +# ) + +# except Exception as exception: + +# assert exception.code == status_code + From 41158e495fc820fdcd908a78cfcb3117480c009f Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 8 Sep 2024 18:05:17 +0930 Subject: [PATCH 113/321] fix(core): Correctly set the ticket type initial value ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/forms/ticket.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index 9d9e9cec..5f422c40 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -70,7 +70,7 @@ class TicketForm( self.fields['status'].choices = self.Meta.model.TicketStatus.Incident - self.fields['ticket_type'].initial = self.Meta.model.TicketType.INCIDENT + self.fields['ticket_type'].initial = self.Meta.model.TicketType.INCIDENT.value elif kwargs['initial']['type_ticket'] == 'problem': @@ -78,7 +78,7 @@ class TicketForm( self.fields['status'].choices = self.Meta.model.TicketStatus.Problem - self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROBLEM + self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROBLEM.value elif kwargs['initial']['type_ticket'] == 'change': @@ -86,7 +86,7 @@ class TicketForm( self.fields['status'].choices = self.Meta.model.TicketStatus.Change - self.fields['ticket_type'].initial = self.Meta.model.TicketType.CHANGE + self.fields['ticket_type'].initial = self.Meta.model.TicketType.CHANGE.value elif kwargs['initial']['type_ticket'] == 'issue': @@ -94,7 +94,7 @@ class TicketForm( self.fields['status'].choices = self.Meta.model.TicketStatus.Git - self.fields['ticket_type'].initial = self.Meta.model.TicketType.ISSUE + self.fields['ticket_type'].initial = self.Meta.model.TicketType.ISSUE.value elif kwargs['initial']['type_ticket'] == 'merge': @@ -102,7 +102,7 @@ class TicketForm( self.fields['status'].choices = self.Meta.model.TicketStatus.Git - self.fields['ticket_type'].initial = self.Meta.model.TicketType.MERGE_REQUEST + self.fields['ticket_type'].initial = self.Meta.model.TicketType.MERGE_REQUEST.value elif kwargs['initial']['type_ticket'] == 'project_task': @@ -110,7 +110,7 @@ class TicketForm( self.fields['status'].choices = self.Meta.model.TicketStatus.ProjectTask - self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROJECT_TASK + self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROJECT_TASK.value # self.fields['status'].widget = self.fields['status'].hidden_widget() From c2eaf120b662b860478832cf815406cb5faccfde Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 8 Sep 2024 18:09:35 +0930 Subject: [PATCH 114/321] fix(core): During ticket form validation confirm if value specified/different then default ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/forms/validate_ticket.py | 52 ++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index fbc2d469..2923db6d 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -189,7 +189,27 @@ class TicketValidation( if hasattr(self.fields[field], 'widget'): - if field in fields_allowed or self.fields[field].widget.is_hidden: + if self.fields[field].widget.is_hidden: + + changed_value = None + + if type(self.fields[field].initial) is bool: + + changed_value: bool = bool(self.data[field]) + + elif type(self.fields[field].initial) is int: + + changed_value: int = int(self.data[field]) + + elif type(self.fields[field].initial) is str: + + changed_value: str = str(self.data[field]) + + if changed_value == self.fields[field].initial or field in fields_allowed: + + allowed = True + + if field in fields_allowed: allowed = True @@ -337,19 +357,27 @@ class TicketValidation( continue - if field == 'ticket_type': - - if self.fields['ticket_type']: - - continue - if self.original_object is not None: + + field_value: str = str(fields[field]) + + if type(getattr(self.original_object, field)) is bool: + + field_value: bool = bool(fields[field]) + + elif type(getattr(self.original_object, field)) is int: + + field_value: int = int(fields[field]) + if ( - fields[field] != getattr(self.original_object, field) - and ( - type(fields[field]) in [str, int, bool] - ) - ) : + ( + field_value != getattr(self.original_object, field) + and ( + type(field_value) in [str, int, bool] + ) + ) or + field in self.data + ): changed_data = changed_data + [ field ] else: From b1277c98ab81d95f625d3faeda07786cfe248f39 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 8 Sep 2024 18:10:31 +0930 Subject: [PATCH 115/321] fix(core): Add ticket fields to ticket types ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/models/ticket/ticket.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 4a75beb2..727239b9 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -618,11 +618,18 @@ class Ticket( 'title', 'description', 'opened_by', - 'ticket_type' + 'ticket_type', + 'assigned_users', + 'assigned_teams', ] common_itsm_fields: list(str()) = common_fields + [ + 'status', 'urgency', + 'priority', + 'impact', + 'subscribed_teams', + 'subscribed_users', ] From b93d3d217592c5ab0a9141f097014c1fc0c10142 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 8 Sep 2024 18:10:54 +0930 Subject: [PATCH 116/321] chore: add test to makefile ref: #266 --- makefile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/makefile b/makefile index 39260936..5738b211 100644 --- a/makefile +++ b/makefile @@ -4,7 +4,7 @@ PATH_VENV := /tmp/centurion_erp ACTIVATE_VENV :=. ${PATH_VENV}/bin/activate -.PHONY: clean prepare docs ansible-lint lint +.PHONY: clean prepare docs ansible-lint lint test prepare: @@ -38,11 +38,16 @@ docs: docs-lint lint: markdown-mkdocs-lint +test: + pytest --cov --cov-report term --cov-report xml:../artifacts/coverage.xml --cov-report html:../artifacts/coverage/ --junit-xml=../artifacts/unit.JUnit.xml **/tests/unit + clean: rm -rf ${PATH_VENV} + rm -rf artifacts rm -rf pages rm -rf build rm -rf node_modules rm -f package-lock.json - rm -f package.json \ No newline at end of file + rm -f package.json + rm -rf .pytest_cache \ No newline at end of file From bc39b1b8b5f7156f676faa778977dc90edc04108 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 8 Sep 2024 18:17:24 +0930 Subject: [PATCH 117/321] test(core): ensure ticket_type tests dont have change value that matches ticket type ref: #250 #96 #93 #95 #90 #264 #266 --- .../field_based_permissions.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py index c1681c4c..2f36f04c 100644 --- a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py +++ b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py @@ -518,7 +518,10 @@ class TicketFieldPermissionsAddUser: """ field_name: str = 'ticket_type' - field_value = int(Ticket.TicketType.REQUEST) + field_value = int(Ticket.TicketType.REQUEST.value) + + if self.ticket_type_enum == int(Ticket.TicketType.REQUEST.value): + field_value = int(Ticket.TicketType.INCIDENT.value) client = Client(raise_request_exception=True) url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) @@ -1150,7 +1153,10 @@ class TicketFieldPermissionsChangeUser: """ field_name: str = 'ticket_type' - field_value = int(Ticket.TicketType.REQUEST) + field_value = int(Ticket.TicketType.REQUEST.value) + + if self.ticket_type_enum == int(Ticket.TicketType.REQUEST.value): + field_value = int(Ticket.TicketType.INCIDENT.value) client = Client(raise_request_exception=True) @@ -1790,7 +1796,10 @@ class TicketFieldPermissionsImportUser: """ field_name: str = 'ticket_type' - field_value = int(Ticket.TicketType.REQUEST) + field_value = int(Ticket.TicketType.REQUEST.value) + + if self.ticket_type_enum == int(Ticket.TicketType.REQUEST.value): + field_value = int(Ticket.TicketType.INCIDENT.value) client = Client(raise_request_exception=True) @@ -2356,7 +2365,10 @@ class TicketFieldPermissionsTriageUser: """ field_name: str = 'ticket_type' - field_value = int(Ticket.TicketType.REQUEST) + field_value = int(Ticket.TicketType.REQUEST.value) + + if self.ticket_type_enum == int(Ticket.TicketType.REQUEST.value): + field_value = int(Ticket.TicketType.INCIDENT.value) client = Client(raise_request_exception=True) From 8998292a0f1213a423853d71cc159217e1404dbd Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 12:02:36 +0930 Subject: [PATCH 118/321] fix(api): ensure ticket_type is set from view var ref: #250 #96 #93 #95 #90 #264 #266 --- app/api/serializers/core/ticket.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/api/serializers/core/ticket.py b/app/api/serializers/core/ticket.py index 2ced65cf..666a67b1 100644 --- a/app/api/serializers/core/ticket.py +++ b/app/api/serializers/core/ticket.py @@ -136,7 +136,7 @@ class TicketSerializer( self.fields.fields['status'].initial = Ticket.TicketStatus.All.NEW self.fields.fields['status'].default = Ticket.TicketStatus.All.NEW - + super().__init__(instance=instance, data=data, **kwargs) @@ -146,8 +146,6 @@ class TicketSerializer( is_valid = super().is_valid(raise_exception=raise_exception) - self.validated_data['ticket_type'] = self._context['view']._ticket_type_value - if self.instance: self.original_object = self.Meta.model.objects.get(pk=self.instance.pk) @@ -161,4 +159,6 @@ class TicketSerializer( is_valid = self.validate_ticket() + self.validated_data['ticket_type'] = int(self._context['view']._ticket_type_value) + return is_valid From b80ca93ced2a07ab4ba9cfaab4beb64a6c97b512 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 12:13:05 +0930 Subject: [PATCH 119/321] test(core): Add ticket project field permission check ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/tests/unit/ticket/test_ticket_permission.py | 7 +++++++ .../ticket_permission/field_based_permissions.py | 11 ++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index 83681ebd..785c0261 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -14,6 +14,8 @@ from access.models import Organization, Team, TeamUsers, Permission from app.tests.abstract.model_permissions import ModelPermissions +from project_management.models.projects import Project + from core.models.ticket.ticket import Ticket from core.tests.unit.ticket.ticket_permission.field_based_permissions import TicketFieldBasedPermissions @@ -94,6 +96,11 @@ class TicketPermissions( status = int(Ticket.TicketStatus.All.NEW.value) ) + self.project = Project.objects.create( + name = 'ticket permissions project name', + organization = organization + ) + self.url_view_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} diff --git a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py index 2f36f04c..1aac5032 100644 --- a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py +++ b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py @@ -355,7 +355,6 @@ class TicketFieldPermissionsAddUser: assert exception.code == 'cant_edit_field_' + field_name - @pytest.mark.skip(reason='Add project to setuptest') def test_field_permission_project_add_user_denied(self): """ Check correct permission for add @@ -363,7 +362,7 @@ class TicketFieldPermissionsAddUser: """ field_name: str = 'project' - field_value = '2024-09-08T13:19:00' + field_value = self.project.id client = Client(raise_request_exception=True) url = reverse(self.app_namespace + ':' + self.url_name_add, kwargs=self.url_add_kwargs) @@ -960,7 +959,6 @@ class TicketFieldPermissionsChangeUser: assert False, f"reason: {exception}" - @pytest.mark.skip(reason='Add project to setuptest') def test_field_permission_project_change_user_denied(self): """ Check correct permission for add @@ -968,7 +966,7 @@ class TicketFieldPermissionsChangeUser: """ field_name: str = 'project' - field_value = '2024-09-08T13:19:00' + field_value = self.project.id client = Client(raise_request_exception=True) @@ -1603,7 +1601,6 @@ class TicketFieldPermissionsImportUser: assert False, f"reason: {exception}" - @pytest.mark.skip(reason='Add project to setuptest') def test_field_permission_project_import_user_denied(self): """ Check correct permission for add @@ -1611,7 +1608,7 @@ class TicketFieldPermissionsImportUser: """ field_name: str = 'project' - field_value = '2024-09-08T13:19:00' + field_value = self.project.id client = Client(raise_request_exception=True) @@ -2200,7 +2197,7 @@ class TicketFieldPermissionsTriageUser: """ field_name: str = 'project' - field_value = '2024-09-08T13:19:00' + field_value = self.project.id client = Client(raise_request_exception=True) From a99c1bb418ff8ee870347f048e010e00eca9529f Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 13:44:04 +0930 Subject: [PATCH 120/321] refactor(core): REmove constraint on setting user for ticket comment required so that tests can run. ToDo: add tests to ensure that user is set. ref: #250 #96 #93 #95 #90 #264 #266 --- ...005_ticket_relatedtickets_ticketcomment.py | 4 +- app/core/models/ticket/ticket.py | 68 +++++++++++++++++-- app/core/models/ticket/ticket_comment.py | 4 +- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py index 64bcdee6..6350e824 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-01 05:09 +# Generated by Django 5.0.8 on 2024-09-09 04:11 import access.fields import access.models @@ -97,7 +97,7 @@ class Migration(migrations.Migration): ('responsible_user', 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')), ('template', models.ForeignKey(blank=True, default=None, help_text='Comment Template to use', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_template', to='core.ticketcomment', verbose_name='Template')), ('ticket', 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')), - ('user', models.ForeignKey(help_text='Who made the comment', on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_user', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ('user', 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')), ], options={ 'verbose_name': 'Comment', diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 727239b9..9e897dc7 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -802,12 +802,26 @@ class Ticket( if comment_field_value: + if request: + + if request.user.pk: + + comment_user = request.user + + else: + + comment_user = None + + else: + + comment_user = None + comment = TicketComment.objects.create( ticket = self, comment_type = TicketComment.CommentType.ACTION, body = comment_field_value, source = TicketComment.CommentSource.DIRECT, - user = request.user, + user = comment_user, ) comment.save() @@ -903,12 +917,26 @@ class Ticket( from core.models.ticket.ticket_comment import TicketComment + if request: + + if request.user.pk: + + comment_user = request.user + + else: + + comment_user = None + + else: + + comment_user = None + comment = TicketComment.objects.create( ticket = instance, comment_type = TicketComment.CommentType.ACTION, body = comment_field_value, source = TicketComment.CommentSource.DIRECT, - user = request.user, + user = comment_user, ) comment.save() @@ -966,12 +994,26 @@ class Ticket( from core.models.ticket.ticket_comment import TicketComment + if request: + + if request.user.pk: + + comment_user = request.user + + else: + + comment_user = None + + else: + + comment_user = None + comment = TicketComment.objects.create( ticket = instance, comment_type = TicketComment.CommentType.ACTION, body = comment_field_value, source = TicketComment.CommentSource.DIRECT, - user = request.user, + user = comment_user, ) comment.save() @@ -1062,6 +1104,22 @@ class RelatedTickets(TenancyObject): 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 if comment_field_value_from: @@ -1071,7 +1129,7 @@ class RelatedTickets(TenancyObject): comment_type = TicketComment.CommentType.ACTION, body = comment_field_value_from, source = TicketComment.CommentSource.DIRECT, - user = request.user, + user = comment_user, ) comment.save() @@ -1084,7 +1142,7 @@ class RelatedTickets(TenancyObject): comment_type = TicketComment.CommentType.ACTION, body = comment_field_value_to, source = TicketComment.CommentSource.DIRECT, - user = request.user, + user = comment_user, ) comment.save() diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index d5d6b960..63458ea8 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -254,9 +254,9 @@ class TicketComment( user = models.ForeignKey( User, - blank= False, + blank= True, help_text = 'Who made the comment', - null = False, + null = True, on_delete = models.DO_NOTHING, related_name = 'comment_user', verbose_name = 'User', From 69124cff085e8aab155acd6f7569865d124278ad Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 13:44:59 +0930 Subject: [PATCH 121/321] chore(core): Remove field '_django_version' from history save must have been introduced in django 5.0.8 ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/mixin/history_save.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/mixin/history_save.py b/app/core/mixin/history_save.py index 569f323c..c173d9ba 100644 --- a/app/core/mixin/history_save.py +++ b/app/core/mixin/history_save.py @@ -25,6 +25,7 @@ class SaveHistory(models.Model): """ remove_keys = [ + '_django_version', '_state', 'created', 'modified' From 47aeac846bd8d5fd659282b17e4a6e6f5265a020 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 13:47:58 +0930 Subject: [PATCH 122/321] test(core): Ticket Action comment checks for assigning user ref: #250 #96 #93 #95 #90 #264 #266 --- .../unit/ticket/test_ticket_permission.py | 62 +++++++++++++++++-- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index 785c0261..26796851 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -1,8 +1,8 @@ -# from django.conf import settings +import re + from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser, User from django.contrib.contenttypes.models import ContentType -# from django.core.exceptions import ValidationError from django.shortcuts import reverse from django.test import TestCase, Client @@ -17,6 +17,7 @@ from app.tests.abstract.model_permissions import ModelPermissions from project_management.models.projects import Project from core.models.ticket.ticket import Ticket +from core.models.ticket.ticket_comment import TicketComment from core.tests.unit.ticket.ticket_permission.field_based_permissions import TicketFieldBasedPermissions @@ -275,9 +276,62 @@ class TicketPermissions( pass + def test_ticket_action_comment_assign_user_added_status_change(self): + """Action Comment test + Confirm a 'status changed' action comment is created when a user is added as assigned + """ + + self.item.assigned_users.add(self.add_user.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"changed status to assigned", str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_assign_user_added_user_assigned(self): + """Action Comment test + Confirm a 'user assigned' action comment is created when a user is added as assigned + """ + + self.item.assigned_users.add(self.add_user.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"assigned @" + self.add_user.username , str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_assign_user_added_status_update(self): + """Action Comment test + When a user is assigned and the status is 'new', the ticket status must update + to 'assigned' + """ + + self.item.assigned_users.add(self.add_user.id) + + assert self.item.status == Ticket.TicketStatus.All.ASSIGNED - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_assign_user_added(self): """Action Comment test Confirm an action comment is created when a user is added as assigned """ From 3ea84f008bccfc453ff508913e29db39545c9dcd Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 13:48:09 +0930 Subject: [PATCH 123/321] test(core): Ticket Action comment checks for un-assigning user ref: #250 #96 #93 #95 #90 #264 #266 --- .../unit/ticket/test_ticket_permission.py | 62 +++++++++++++++++-- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index 26796851..30df5c43 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -332,20 +332,70 @@ class TicketPermissions( assert self.item.status == Ticket.TicketStatus.All.ASSIGNED + + def test_ticket_action_comment_assign_user_removed_status_change(self): """Action Comment test - Confirm an action comment is created when a user is added as assigned + Confirm a 'status changed' action comment is created when a user is removed as assigned """ - pass + self.item.assigned_users.add(self.add_user.id) + + self.item.assigned_users.remove(self.add_user.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"changed status to new", str(comment.body).lower()): + + action_comment = True + + assert action_comment - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_assign_user_removed(self): + # @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_assign_user_removed_user_unassigned(self): """Action Comment test - Confirm an action comment is created when a user is removed as assigned + Confirm a 'user unassigned' action comment is created when a user is removed as assigned """ - pass + self.item.assigned_users.add(self.add_user.id) + + self.item.assigned_users.remove(self.add_user.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"unassigned @" + self.add_user.username, str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_assign_user_remove_status_update(self): + """Action Comment test + When a user is unassigned and the status is 'assigned', the ticket status must update + to 'new' + """ + + self.item.assigned_users.add(self.add_user.id) + + self.item.assigned_users.remove(self.add_user.id) + + assert self.item.status == Ticket.TicketStatus.All.NEW + @pytest.mark.skip(reason='to be written') From a68a9e7ef3575e080a2b6048d7cf59b989858129 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 14:05:39 +0930 Subject: [PATCH 124/321] fix(core): Team assigned to ticket status update ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/models/ticket/ticket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 9e897dc7..9a886f6c 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -976,7 +976,7 @@ class Ticket( comment_field_value = f"Assigned team @" + str(team.team_name) - self.assigned_status_update() + self.assigned_status_update(instance) elif sender.__name__ == 'Ticket_subscribed_teams': From e7015570d59d48de6ed204e395035ad779adafe3 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 14:06:17 +0930 Subject: [PATCH 125/321] test(core): Ticket Action comment checks for assigning team ref: #250 #96 #93 #95 #90 #264 #266 --- .../unit/ticket/test_ticket_permission.py | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index 30df5c43..f96683b7 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -153,6 +153,8 @@ class TicketPermissions( change_team.permissions.set([change_permissions]) + self.change_team = change_team + delete_permissions = Permission.objects.get( @@ -397,9 +399,62 @@ class TicketPermissions( assert self.item.status == Ticket.TicketStatus.All.NEW + def test_ticket_action_comment_assign_team_added_status_change(self): + """Action Comment test + Confirm a 'status changed' action comment is created when a user is added as assigned + """ + + self.item.assigned_teams.add(self.change_team.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"changed status to assigned", str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_assign_team_added_team_assigned(self): + """Action Comment test + Confirm a 'team assigned' action comment is created when a team is added as assigned + """ + + self.item.assigned_teams.add(self.change_team.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"assigned team @" + self.change_team.team_name , str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_assign_team_added_status_update(self): + """Action Comment test + When a team is assigned and the status is 'new', the ticket status must update + to 'assigned' + """ + + self.item.assigned_teams.add(self.change_team.id) + + assert self.item.status == Ticket.TicketStatus.All.ASSIGNED - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_assign_team_added(self): """Action Comment test Confirm an action comment is created when a team is added as assigned """ From c59dc7d2bfe6c4b4daa0e8352345f24e89f9afb6 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 14:06:45 +0930 Subject: [PATCH 126/321] test(core): Ticket Action comment checks for unassigning team ref: #250 #96 #93 #95 #90 #264 #266 --- .../unit/ticket/test_ticket_permission.py | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index f96683b7..b1fa181e 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -455,20 +455,68 @@ class TicketPermissions( assert self.item.status == Ticket.TicketStatus.All.ASSIGNED + + def test_ticket_action_comment_assign_team_remove_status_change(self): """Action Comment test - Confirm an action comment is created when a team is added as assigned + Confirm a 'status changed' action comment is created when a user is removed as assigned """ - pass + self.item.assigned_teams.add(self.change_team.id) + + self.item.assigned_teams.remove(self.change_team.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"changed status to new", str(comment.body).lower()): + + action_comment = True + + assert action_comment - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_assign_team_removed(self): + def test_ticket_action_comment_assign_team_remove_team_assigned(self): """Action Comment test - Confirm an action comment is created when a team is removed as assigned + Confirm a 'team assigned' action comment is created when a team is removed as assigned """ - pass + self.item.assigned_teams.add(self.change_team.id) + + self.item.assigned_teams.remove(self.change_team.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"unassigned team @" + self.change_team.team_name , str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_assign_team_remove_status_update(self): + """Action Comment test + When a team is unassigned and the status is 'assigned', the ticket status must update + to 'new' + """ + + self.item.assigned_teams.add(self.change_team.id) + + self.item.assigned_teams.remove(self.change_team.id) + + assert self.item.status == Ticket.TicketStatus.All.NEW From afceaca736f7f94b6ede85356a126921ad1e2d20 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 14:18:22 +0930 Subject: [PATCH 127/321] test(core): Ticket Action comment checks for subscribing user ref: #250 #96 #93 #95 #90 #264 #266 --- .../unit/ticket/test_ticket_permission.py | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index b1fa181e..8220bc79 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -519,27 +519,51 @@ class TicketPermissions( assert self.item.status == Ticket.TicketStatus.All.NEW - - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_subscribe_user_added(self): + def test_ticket_action_comment_subscribed_users_added_user_subscribed(self): """Action Comment test - Confirm an action comment is created when a user is added as subscribed + Confirm a 'user subscribed' action comment is created when a user is added as subscribed """ - pass + self.item.subscribed_users.add(self.add_user.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"added @" + self.add_user.username + " as watching" , str(comment.body).lower()): + + action_comment = True + + assert action_comment - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_subscribe_user_removed(self): + def test_ticket_action_comment_subscribed_users_removed_user_unsubscribed(self): """Action Comment test - Confirm an action comment is created when a user is removed as subscribed + Confirm a 'user unsubscribed' action comment is created when a user is removed as subscribed """ - pass + self.item.subscribed_users.add(self.add_user.id) + self.item.subscribed_users.remove(self.add_user.id) + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_subscribe_team_added(self): + action_comment: bool = False + + for comment in comments: + + if re.match(r"removed @" + self.add_user.username + " as watching" , str(comment.body).lower()): + + action_comment = True + + assert action_comment """Action Comment test Confirm an action comment is created when a team is added as subscribed """ From b0a4d2ca8458d572d4ce582efb9c08d47561a35e Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 14:18:48 +0930 Subject: [PATCH 128/321] test(core): Ticket Action comment checks for subscribing team ref: #250 #96 #93 #95 #90 #264 #266 --- .../unit/ticket/test_ticket_permission.py | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index 8220bc79..22e1e504 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -564,20 +564,53 @@ class TicketPermissions( action_comment = True assert action_comment + + + def test_ticket_action_comment_subscribed_teams_added_team_subscribed(self): """Action Comment test - Confirm an action comment is created when a team is added as subscribed + Confirm a 'team subscribed' action comment is created when a team is added as subscribed """ - pass + self.item.subscribed_teams.add(self.change_team.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"added team @" + self.change_team.team_name + " as watching" , str(comment.body).lower()): + + action_comment = True + + assert action_comment - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_subscribe_team_removed(self): + def test_ticket_action_comment_subscribed_teams_removed_team_unsubscribed(self): """Action Comment test - Confirm an action comment is created when a team is removed as subscribed + Confirm a 'team unsubscribed' action comment is created when a team is removed as subscribed """ - pass + self.item.subscribed_teams.add(self.change_team.id) + self.item.subscribed_teams.remove(self.change_team.id) + + comments = TicketComment.objects.filter( + ticket=self.item.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + for comment in comments: + + if re.match(r"removed team @" + self.change_team.team_name + " as watching" , str(comment.body).lower()): + + action_comment = True + + assert action_comment From a47e1977f0db6364f92c1319a46bda72348fe316 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 14:52:39 +0930 Subject: [PATCH 129/321] fix(core): Ensure related tricket action comment is trimmed ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/models/ticket/ticket.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 9a886f6c..a67e90d9 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -1088,18 +1088,18 @@ class RelatedTickets(TenancyObject): if self.how_related == self.Related.BLOCKED_BY: - comment_field_value_from = f" added #{self.from_ticket_id.id} as blocked by #{self.to_ticket_id.id}" - comment_field_value_to = f" added #{self.to_ticket_id.id} as blocking #{self.from_ticket_id.id}" + comment_field_value_from = f"added #{self.from_ticket_id.id} as blocked by #{self.to_ticket_id.id}" + comment_field_value_to = f"added #{self.to_ticket_id.id} as blocking #{self.from_ticket_id.id}" elif self.how_related == self.Related.BLOCKS: - comment_field_value_from = f" added #{self.from_ticket_id.id} as blocking #{self.to_ticket_id.id}" - comment_field_value_to = f" added #{self.to_ticket_id.id} as blocked by #{self.from_ticket_id.id}" + comment_field_value_from = f"added #{self.from_ticket_id.id} as blocking #{self.to_ticket_id.id}" + comment_field_value_to = f"added #{self.to_ticket_id.id} as blocked by #{self.from_ticket_id.id}" elif self.how_related == self.Related.RELATED: - comment_field_value_from = f" added #{self.from_ticket_id.id} as related to #{self.to_ticket_id.id}" - comment_field_value_to = f" added #{self.to_ticket_id.id} as related to #{self.from_ticket_id.id}" + comment_field_value_from = f"added #{self.from_ticket_id.id} as related to #{self.to_ticket_id.id}" + comment_field_value_to = f"added #{self.to_ticket_id.id} as related to #{self.from_ticket_id.id}" request = get_request() From 082a351c173c62e546b0e91a509822792c92582d Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 14:54:30 +0930 Subject: [PATCH 130/321] test(core): Ticket Action comment checks for related tickets ref: #250 #96 #93 #95 #90 #264 #266 --- .../unit/ticket/test_ticket_permission.py | 226 ++++++++++++++++-- 1 file changed, 206 insertions(+), 20 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index 22e1e504..0a83f09d 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -16,7 +16,7 @@ from app.tests.abstract.model_permissions import ModelPermissions from project_management.models.projects import Project -from core.models.ticket.ticket import Ticket +from core.models.ticket.ticket import Ticket, RelatedTickets from core.models.ticket.ticket_comment import TicketComment from core.tests.unit.ticket.ticket_permission.field_based_permissions import TicketFieldBasedPermissions @@ -97,6 +97,15 @@ class TicketPermissions( status = int(Ticket.TicketStatus.All.NEW.value) ) + self.second_item = self.model.objects.create( + organization=organization, + title = 'A second ' + self.ticket_type + ' ticket', + description = 'the ticket body of item two', + ticket_type = int(Ticket.TicketType.REQUEST.value), + opened_by = self.add_user, + status = int(Ticket.TicketStatus.All.NEW.value) + ) + self.project = Project.objects.create( name = 'ticket permissions project name', organization = organization @@ -613,24 +622,211 @@ class TicketPermissions( assert action_comment - - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_status_change(self): + def test_ticket_action_comment_related_ticket_added_type_related_source(self): """Action Comment test - Confirm an action comment is created when the ticket status changes + Confirm an 'related' action comment is created for the source ticket + when a ticket is added with type 'related'. """ - pass + from_ticket = self.item + to_ticket = self.second_item + related_ticket = RelatedTickets.objects.create( + from_ticket_id = from_ticket, + to_ticket_id = to_ticket, + how_related = RelatedTickets.Related.RELATED, + organization=self.organization, + ) - @pytest.mark.skip(reason='to be written') - def test_ticket_action_comment_related_ticket_added(self): + comments = TicketComment.objects.filter( + ticket=from_ticket.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + comment_body: str = f'added #{from_ticket.id} as related to #{to_ticket.id}' + + for comment in comments: + + if re.match(comment_body, str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_related_ticket_added_type_related_destination(self): """Action Comment test - Confirm an action comment is created when a related ticket is added + Confirm an 'related' action comment is created for the destination ticket + when a ticket is added with type 'related'. """ - pass + from_ticket = self.item + to_ticket = self.second_item + + related_ticket = RelatedTickets.objects.create( + from_ticket_id = from_ticket, + to_ticket_id = to_ticket, + how_related = RelatedTickets.Related.RELATED, + organization=self.organization, + ) + + comments = TicketComment.objects.filter( + ticket=to_ticket.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + comment_body: str = f'added #{to_ticket.id} as related to #{from_ticket.id}' + + for comment in comments: + + if re.match(comment_body, str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_related_ticket_added_type_blocks_source(self): + """Action Comment test + Confirm a 'related' action comment is created for the source ticket + when a ticket is added with type 'blocks'. + """ + + from_ticket = self.item + to_ticket = self.second_item + + + related_ticket = RelatedTickets.objects.create( + from_ticket_id = from_ticket, + to_ticket_id = to_ticket, + how_related = RelatedTickets.Related.BLOCKS, + organization=self.organization, + ) + + comments = TicketComment.objects.filter( + ticket=from_ticket.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + comment_body: str = f'added #{from_ticket.id} as blocking #{to_ticket.id}' + + for comment in comments: + + if re.match(comment_body, str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_related_ticket_added_type_blocks_destination(self): + """Action Comment test + Confirm an 'related' action comment is created for the destination ticket + when a ticket is added with type 'blocks'. + """ + + from_ticket = self.item + to_ticket = self.second_item + + related_ticket = RelatedTickets.objects.create( + from_ticket_id = from_ticket, + to_ticket_id = to_ticket, + how_related = RelatedTickets.Related.BLOCKS, + organization=self.organization, + ) + + comments = TicketComment.objects.filter( + ticket=to_ticket.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + comment_body: str = f'added #{to_ticket.id} as blocked by #{from_ticket.id}' + + for comment in comments: + + if re.match(comment_body, str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_related_ticket_added_type_blocked_by_source(self): + """Action Comment test + Confirm a 'related' action comment is created for the source ticket + when a ticket is added with type 'blocked_by'. + """ + + from_ticket = self.item + to_ticket = self.second_item + + + related_ticket = RelatedTickets.objects.create( + from_ticket_id = from_ticket, + to_ticket_id = to_ticket, + how_related = RelatedTickets.Related.BLOCKED_BY, + organization=self.organization, + ) + + comments = TicketComment.objects.filter( + ticket=from_ticket.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + comment_body: str = f'added #{from_ticket.id} as blocked by #{to_ticket.id}' + + for comment in comments: + + if re.match(comment_body, str(comment.body).lower()): + + action_comment = True + + assert action_comment + + + def test_ticket_action_comment_related_ticket_added_type_blocked_by_destination(self): + """Action Comment test + Confirm an 'related' action comment is created for the destination ticket + when a ticket is added with type 'blocked_by'. + """ + + from_ticket = self.item + to_ticket = self.second_item + + related_ticket = RelatedTickets.objects.create( + from_ticket_id = from_ticket, + to_ticket_id = to_ticket, + how_related = RelatedTickets.Related.BLOCKED_BY, + organization=self.organization, + ) + + comments = TicketComment.objects.filter( + ticket=to_ticket.pk, + comment_type = TicketComment.CommentType.ACTION + ) + + action_comment: bool = False + + comment_body: str = f'added #{to_ticket.id} as blocking #{from_ticket.id}' + + for comment in comments: + + if re.match(comment_body, str(comment.body).lower()): + + action_comment = True + + assert action_comment @pytest.mark.skip(reason='to be written') @@ -642,16 +838,6 @@ class TicketPermissions( pass - @pytest.mark.skip(reason='to be written') - def test_ticket_creation_field_edit_denied(self): - """Action Comment test - Confirm an action comment is created when a user is added as assigned - """ - - pass - - - class ChangeTicketPermissions(TicketPermissions, TestCase): From a57e9771311d02f6ef863f435b65e36c58afb6a7 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 15:38:41 +0930 Subject: [PATCH 131/321] feat(core): Add project field to tickets allowed fields ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/models/ticket/ticket.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index a67e90d9..4e234708 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -618,6 +618,7 @@ class Ticket( 'title', 'description', 'opened_by', + 'project', 'ticket_type', 'assigned_users', 'assigned_teams', From 7e0bd630b5c7dee3ea1747b527a31d2803515fd2 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 15:39:27 +0930 Subject: [PATCH 132/321] test(core): project field permission check for triage user ref: #250 #96 #93 #95 #90 #264 #266 --- .../unit/ticket/test_ticket_permission.py | 8 ------- .../field_based_permissions.py | 23 +++++-------------- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index 0a83f09d..a11b57c5 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -273,14 +273,6 @@ class TicketPermissions( ) - - - @pytest.mark.skip(reason="To be written") - def test_permission_triage(self): - - pass - - @pytest.mark.skip(reason="To be written") def test_permission_purge(self): diff --git a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py index 1aac5032..d2adeb13 100644 --- a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py +++ b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py @@ -2189,8 +2189,7 @@ class TicketFieldPermissionsTriageUser: - @pytest.mark.skip(reason='Add project to setuptest') - def test_field_permission_project_triage_user_denied(self): + def test_field_permission_project_triage_user_allowed(self): """ Check correct permission for add A standard user should not be able to edit field project. @@ -2209,22 +2208,12 @@ class TicketFieldPermissionsTriageUser: data[field_name] = field_value - try: + response = client.post( + url, + data=data + ) - response = client.post( - url, - data=data - ) - - assert False, 'a ValidationError exception should have been thrown' - - except ValidationError as exception: - - assert exception.code == 'cant_edit_field_' + field_name - - except Exception as exception: - - assert False, f"reason: {exception}" + assert response.status_code == 200 def test_field_permission_real_start_date_triage_user_denied(self): From 2cb21ae4a7409488b5554079ecb6054c6305c606 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 15:40:09 +0930 Subject: [PATCH 133/321] test(core): correct triage user test names for allowed field permissions ref: #250 #96 #93 #95 #90 #264 #266 --- .../ticket_permission/field_based_permissions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py index d2adeb13..221a4826 100644 --- a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py +++ b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py @@ -1857,7 +1857,7 @@ class TicketFieldPermissionsTriageUser: assert response.status_code == 200 - def test_field_permission_priority_triage_user_denied(self): + def test_field_permission_priority_triage_user_allowed(self): """ Check correct permission for add A standard user should not be able to edit field priority. @@ -1885,7 +1885,7 @@ class TicketFieldPermissionsTriageUser: - def test_field_permission_assigned_users_triage_user_denied(self): + def test_field_permission_assigned_users_triage_user_allowed(self): """ Check correct permission for add A standard user should not be able to edit field assigned_users. @@ -1912,7 +1912,7 @@ class TicketFieldPermissionsTriageUser: assert response.status_code == 200 - def test_field_permission_assigned_teams_triage_user_denied(self): + def test_field_permission_assigned_teams_triage_user_allowed(self): """ Check correct permission for add A standard user should not be able to edit field assigned_teams. @@ -2087,7 +2087,7 @@ class TicketFieldPermissionsTriageUser: assert False, f"reason: {exception}" - def test_field_permission_opened_by_triage_user_denied(self): + def test_field_permission_opened_by_triage_user_allowed(self): """ Check correct permission for add A standard user should not be able to edit field opened_by. @@ -2290,7 +2290,7 @@ class TicketFieldPermissionsTriageUser: assert False, f"reason: {exception}" - def test_field_permission_subscribed_users_triage_user_denied(self): + def test_field_permission_subscribed_users_triage_user_allowed(self): """ Check correct permission for add A standard user should not be able to edit field subscribed_users. @@ -2317,7 +2317,7 @@ class TicketFieldPermissionsTriageUser: assert response.status_code == 200 - def test_field_permission_subscribed_teams_triage_user_denied(self): + def test_field_permission_subscribed_teams_triage_user_allowed(self): """ Check correct permission for add A standard user should not be able to edit field subscribed_teams. From 118d41a53b4fee02aa102543e714f2ce546097ed Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 16:08:16 +0930 Subject: [PATCH 134/321] test(core): remove duplicated tenancy object tests for ticket model ref: #250 #96 #93 #95 #90 #264 #266 --- .../test_ticket_access_tenancy_object.py | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 app/core/tests/unit/ticket/test_ticket_access_tenancy_object.py diff --git a/app/core/tests/unit/ticket/test_ticket_access_tenancy_object.py b/app/core/tests/unit/ticket/test_ticket_access_tenancy_object.py deleted file mode 100644 index 3ff5fc76..00000000 --- a/app/core/tests/unit/ticket/test_ticket_access_tenancy_object.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest -import unittest -import requests - -from django.test import TestCase, Client - -from access.tests.abstract.tenancy_object import TenancyObject - -from core.models.ticket.ticket import Ticket - - - -class TicketTenancyObject( - TestCase, - TenancyObject -): - - model = Ticket From 857b8781cbe5a825904f2cfd9f0cc002d4783d87 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 16:10:55 +0930 Subject: [PATCH 135/321] feat(core): add option to allow the prevention of history saving for tenancy models ref: #266 #250 --- app/core/mixin/history_save.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/core/mixin/history_save.py b/app/core/mixin/history_save.py index c173d9ba..e0574d7d 100644 --- a/app/core/mixin/history_save.py +++ b/app/core/mixin/history_save.py @@ -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 @@ -177,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): From f3b249d18fb8ba92421a04fe2be48b7e1500959a Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 16:11:40 +0930 Subject: [PATCH 136/321] test: Ensure tenancy models save model history ref: #266 #250 --- app/access/tests/abstract/tenancy_object.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/access/tests/abstract/tenancy_object.py b/app/access/tests/abstract/tenancy_object.py index e1ad9b7e..a4bfd915 100644 --- a/app/access/tests/abstract/tenancy_object.py +++ b/app/access/tests/abstract/tenancy_object.py @@ -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 From c67e1430bd112a5d703c95a82c086432b935456b Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 16:13:22 +0930 Subject: [PATCH 137/321] feat(core): Don't save model history for ticket models ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/models/ticket/ticket.py | 2 ++ app/core/models/ticket/ticket_comment.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 4e234708..c2c7ceda 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -123,6 +123,8 @@ class Ticket( TicketMarkdown, ): + save_model_history: bool = False + class Meta: diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 63458ea8..1730721e 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -16,6 +16,8 @@ class TicketComment( ): + save_model_history: bool = False + class Meta: ordering = [ From 0794e5b58ff25d98b64aece35946e04313cdb410 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 16:13:43 +0930 Subject: [PATCH 138/321] test(core): ensure history for ticket models is not saved ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/tests/unit/ticket/test_ticket.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/core/tests/unit/ticket/test_ticket.py b/app/core/tests/unit/ticket/test_ticket.py index 13514749..6a8f61e4 100644 --- a/app/core/tests/unit/ticket/test_ticket.py +++ b/app/core/tests/unit/ticket/test_ticket.py @@ -15,3 +15,10 @@ class TicketModel( ): model = Ticket + + should_model_history_be_saved: bool = False + """Tickets should not save model history. + + Saving of model history is not required as a ticket stores it's + history as an 'action comment' + """ From 63077dfa26af848d1fcfd1c7c87fa34d5364880f Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 16:39:11 +0930 Subject: [PATCH 139/321] feat(core): Add edit details to ticket and comments ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/templates/core/ticket.html.j2 | 7 ++++++- .../templates/core/ticket/comment/comment.html.j2 | 9 +++++++-- app/core/templatetags/markdown.py | 6 ++++++ app/project-static/ticketing.css | 11 +++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index f6e1ca51..f6b47fe3 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -14,7 +14,12 @@
-

opened by {{ ticket.opened_by }} on {{ ticket.created }}

+

+ opened by {{ ticket.opened_by }} on {{ ticket.created }} + {% if ticket.created|date_time_seconds != ticket.modified|date_time_seconds %} + Updated {{ ticket.modified }} + {% endif %} +

diff --git a/app/core/templates/core/ticket/comment/comment.html.j2 b/app/core/templates/core/ticket/comment/comment.html.j2 index c1dce26c..1cb2acd1 100644 --- a/app/core/templates/core/ticket/comment/comment.html.j2 +++ b/app/core/templates/core/ticket/comment/comment.html.j2 @@ -1,5 +1,7 @@ {% if comment %} +{% load markdown %} + {% if comment.get_comment_type_display == 'Action' %} {{ comment.markdown_body | safe }} @@ -10,14 +12,17 @@

{{ comment.user }} - {% if comment.get_comment_type_display == 'Task' %} + {% if comment.get_comment_type_display == 'Task' %} created a task {% elif comment.get_comment_type_display == 'Solution' %} solved {% else %} wrote {% endif %} - on {{ comment.created }} + on {{ comment.created }} + {% if comment.created|date_time_seconds != comment.modified|date_time_seconds %} + Updated {{ comment.modified }} + {% endif %}
{%if not comment.parent_id %} diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index 49ff81f4..12771d05 100644 --- a/app/core/templatetags/markdown.py +++ b/app/core/templatetags/markdown.py @@ -21,3 +21,9 @@ def lower(value): def ticket_status(value): return str(value).lower().replace('(', '').replace(')', '').replace(' ', '_') + +@register.filter() +@stringfilter +def date_time_seconds(value): + + return str(value).split('.')[0] diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index f7448ffa..6b6bb6bd 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -168,6 +168,13 @@ text-align: left; } + +#ticket-description h3 span.sub-script { + color: #777; + font-size:smaller; +} + + #ticket-description div { padding: 10px; } @@ -399,6 +406,10 @@ width: 100%; } +#comment h4 #text span.sub-script{ + font-size: smaller; +} + #comment h4 #icons { border: none; From 44604d98ab02e2ee816b6578d830501f8379258a Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 17:34:03 +0930 Subject: [PATCH 140/321] feat(core): Caclulate ticket duration for ticket meta and comments ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/models/ticket/ticket.py | 23 +++++++++++++++++-- app/core/templates/core/ticket.html.j2 | 4 ++++ .../core/ticket/comment/comment.html.j2 | 2 +- app/core/templatetags/markdown.py | 22 ++++++++++++++++++ app/core/tests/unit/ticket/test_ticket.py | 10 ++++++++ .../test_ticket_comment_common.py | 2 +- 6 files changed, 59 insertions(+), 4 deletions(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index c2c7ceda..8ebdf4dd 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -1,6 +1,6 @@ from django.contrib.auth.models import User from django.db import models -from django.db.models import Q, signals +from django.db.models import Q, signals, Sum from django.forms import ValidationError from access.fields import AutoCreatedField, AutoLastModifiedField @@ -696,13 +696,32 @@ class Ticket( @property def comments(self): + if hasattr(self, '_ticket_comments'): + + return self._ticket_comments + from core.models.ticket.ticket_comment import TicketComment - return TicketComment.objects.filter( + self._ticket_comments = TicketComment.objects.filter( ticket = self.id, parent = None, ) + return self._ticket_comments + + + @property + def duration_ticket(self) -> str: + + comments = self.comments + + duration = comments.aggregate(Sum('duration'))['duration__sum'] + + if not duration: + + duration = 0 + + return str(duration) @property def markdown_description(self) -> str: diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index f6b47fe3..0ce8cf9d 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -140,6 +140,10 @@ U{{ ticket.get_urgency_display }} / I{{ ticket.get_impact_display }} / P{{ ticket.get_priority_display }}

+
+ + {{ ticket.duration_ticket|to_duration }} +
val diff --git a/app/core/templates/core/ticket/comment/comment.html.j2 b/app/core/templates/core/ticket/comment/comment.html.j2 index 1cb2acd1..1d8ab156 100644 --- a/app/core/templates/core/ticket/comment/comment.html.j2 +++ b/app/core/templates/core/ticket/comment/comment.html.j2 @@ -109,7 +109,7 @@ {% endif %}
- {{ comment.duration }} + {{ comment.duration|to_duration }}
diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index 12771d05..4bdb8c95 100644 --- a/app/core/templatetags/markdown.py +++ b/app/core/templatetags/markdown.py @@ -22,8 +22,30 @@ def ticket_status(value): return str(value).lower().replace('(', '').replace(')', '').replace(' ', '_') + @register.filter() @stringfilter def date_time_seconds(value): return str(value).split('.')[0] + + +@register.filter() +@stringfilter +def to_duration(value): + """Convert seconds to duration value + + Args: + value (str): Time in seconds + + Returns: + str: Duration value in format 00h 00m 00s + """ + + hours = int(int(value)//3600) + + minutes = int((int(value)%3600)//60) + + seconds = int((int(value)%3600)%60) + + return str("{:02d}h {:02d}m {:02d}s".format(hours, minutes, seconds)) diff --git a/app/core/tests/unit/ticket/test_ticket.py b/app/core/tests/unit/ticket/test_ticket.py index 6a8f61e4..d5843e83 100644 --- a/app/core/tests/unit/ticket/test_ticket.py +++ b/app/core/tests/unit/ticket/test_ticket.py @@ -22,3 +22,13 @@ class TicketModel( Saving of model history is not required as a ticket stores it's history as an 'action comment' """ + + + def test_attribute_duration_ticket_value(self): + """Attribute value test + + This aattribute calculates the ticket duration from + it's comments. must return total time in seconds + """ + + pass \ No newline at end of file diff --git a/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py b/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py index cb41b7fa..3568fa67 100644 --- a/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py +++ b/app/core/tests/unit/ticket_comment/test_ticket_comment_common.py @@ -17,7 +17,7 @@ class TicketCommentCommon( model = TicketComment - def text_ticket_field_type_opened_by(self): + def test_ticket_field_type_opened_by(self): """Replies to comments only to occur on primary comment If a comment has a 'parent_id' set, ensure the comment can't be replied to From aa6baf94a6cbbcb04967061ace3fe97e4290cb34 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 9 Sep 2024 17:59:22 +0930 Subject: [PATCH 141/321] feat(core): support negative numbers when Calculating ticket duration for ticket meta and comments enables time to be subtracted when negative value added to duration field. ref: #250 #96 #93 #95 #90 #264 #266 --- app/core/templatetags/markdown.py | 13 ++++++++++--- .../tests/unit/ticket/test_ticket_permission.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index 4bdb8c95..54c419e2 100644 --- a/app/core/templatetags/markdown.py +++ b/app/core/templatetags/markdown.py @@ -42,10 +42,17 @@ def to_duration(value): str: Duration value in format 00h 00m 00s """ - hours = int(int(value)//3600) + hour = int(3600) + minute = int(60) - minutes = int((int(value)%3600)//60) + if '-' in value: + hour = int(-3600) + minute = int(-60) - seconds = int((int(value)%3600)%60) + hours = int(int(value)//hour) + + minutes = int((int(value)%hour)//minute) + + seconds = int((int(value)%hour)%minute) return str("{:02d}h {:02d}m {:02d}s".format(hours, minutes, seconds)) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index a11b57c5..dfad433f 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -821,6 +821,16 @@ class TicketPermissions( assert action_comment + @pytest.mark.skip(reason='to be written') + def test_ticket_action_comment_project_added(self): + """Action Comment test + Confirm a 'project added' action comment is created for the ticket + when a project is added + """ + + pass + + @pytest.mark.skip(reason='to be written') def test_ticket_action_comment_related_ticket_removed(self): """Action Comment test From 94dd555e9ba28e6ff97925fd14738d2477df9951 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 10:53:31 +0930 Subject: [PATCH 142/321] test(core): Tenancy model tests for ticket comment ref: #250 #96 #93 #95 #90 #264 #267 --- .../ticket_comment/test_ticket_comment.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment/test_ticket_comment.py diff --git a/app/core/tests/unit/ticket_comment/test_ticket_comment.py b/app/core/tests/unit/ticket_comment/test_ticket_comment.py new file mode 100644 index 00000000..80f54835 --- /dev/null +++ b/app/core/tests/unit/ticket_comment/test_ticket_comment.py @@ -0,0 +1,34 @@ +import pytest +# import unittest +# import requests + +from django.test import TestCase + +from app.tests.abstract.models import TenancyModel + +from core.models.ticket.ticket_comment import TicketComment + + +class TicketCommentModel( + TestCase, + TenancyModel +): + + model = TicketComment + + should_model_history_be_saved: bool = False + """Tickets should not save model history. + + Saving of model history is not required as a ticket stores it's + history as an 'action comment' + """ + + + def test_attribute_duration_ticket_value(self): + """Attribute value test + + This aattribute calculates the ticket duration from + it's comments. must return total time in seconds + """ + + pass \ No newline at end of file From 0c3e38c543994ead485b4a88811e7a2f799a65b6 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 10:58:54 +0930 Subject: [PATCH 143/321] test(core): Ticket comment Views ref: #250 #96 #93 #95 #90 #264 #267 --- .../test_ticket_comment_views.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment/test_ticket_comment_views.py diff --git a/app/core/tests/unit/ticket_comment/test_ticket_comment_views.py b/app/core/tests/unit/ticket_comment/test_ticket_comment_views.py new file mode 100644 index 00000000..13a313fe --- /dev/null +++ b/app/core/tests/unit/ticket_comment/test_ticket_comment_views.py @@ -0,0 +1,34 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import PrimaryModel, ModelAdd, ModelChange, ModelDelete + + + +# class TicketCommentViews( +# TestCase, +# PrimaryModel +# ): +class TicketCommentViews( + TestCase, + ModelAdd, + ModelChange, +): + + add_module = 'core.views.ticket' + add_view = 'Add' + + change_module = add_module + change_view = 'Change' + + delete_module = add_module + delete_view = 'Delete' + + # display_module = add_module + # display_view = 'View' + + # index_module = add_module + # index_view = 'Index' From 4177f719729524e1d2036ffbc25b96438b4666c1 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 11:42:10 +0930 Subject: [PATCH 144/321] test(core): Ticket comment permission checks ref: #250 #96 #93 #95 #90 #264 #267 --- .../test_ticket_comment_permission.py | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment/test_ticket_comment_permission.py diff --git a/app/core/tests/unit/ticket_comment/test_ticket_comment_permission.py b/app/core/tests/unit/ticket_comment/test_ticket_comment_permission.py new file mode 100644 index 00000000..c7d4d193 --- /dev/null +++ b/app/core/tests/unit/ticket_comment/test_ticket_comment_permission.py @@ -0,0 +1,335 @@ +import re + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import reverse +from django.test import TestCase, Client + +import pytest +import unittest +import requests + +from access.models import Organization, Team, TeamUsers, Permission + +from app.tests.abstract.model_permissions import ModelPermissions, ModelPermissionsAdd, ModelPermissionsChange + +from project_management.models.projects import Project + +from core.models.ticket.ticket import Ticket, RelatedTickets +from core.models.ticket.ticket_comment import TicketComment + + + + +class TicketCommentPermissions( + # ModelPermissions, + ModelPermissionsAdd, + ModelPermissionsChange, +): + + ticket_type:str = None + + ticket_type_enum: int = None + + model = TicketComment + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a manufacturer + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_ticket_permissions = Permission.objects.get( + codename = 'add_' + Ticket._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = Ticket._meta.app_label, + model = Ticket._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_ticket_permissions, add_permissions]) + + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + + self.ticket = Ticket.objects.create( + organization=organization, + title = 'A second ' + self.ticket_type + ' ticket', + description = 'the ticket body of item two', + ticket_type = int(Ticket.TicketType.REQUEST.value), + opened_by = self.add_user, + status = int(Ticket.TicketStatus.All.NEW.value) + ) + + self.item_add_user = self.model.objects.create( + organization=organization, + body = 'A ' + self.ticket_type + ' ticket comment', + ticket = self.ticket, + comment_type = int(TicketComment.CommentType.COMMENT), + user = self.add_user, + # status = int(Ticket.TicketStatus.All.NEW.value) + ) + + self.project = Project.objects.create( + name = 'ticket permissions project name', + organization = organization + ) + + + self.url_add_kwargs = {'ticket_type': self.ticket_type, 'ticket_id': self.ticket.id} + + self.add_data = { + 'body': 'an add ticket', + 'comment_type': int(TicketComment.CommentType.COMMENT.value), + 'ticket': self.ticket.id, + 'organization': self.organization.id, + 'user': self.add_user.id, + } + + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + self.change_team = change_team + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + + self.item = self.model.objects.create( + organization=organization, + body = 'A ' + self.ticket_type + ' ticket comment', + ticket = self.ticket, + comment_type = int(TicketComment.CommentType.COMMENT), + user = self.change_user, + # status = int(Ticket.TicketStatus.All.NEW.value) + ) + + + self.url_view_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + + self.url_change_kwargs = {'ticket_type': self.ticket_type, 'ticket_id': self.ticket.id, 'pk': self.item.id} + + self.change_data = {'body': 'a change to ticket commennt'} + + self.url_delete_kwargs = {'ticket_type': self.ticket_type, 'ticket_id': self.ticket.id, 'pk': self.item.id} + + self.delete_data = {'body': 'a delete to ticket'} + + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) + + + def test_model_change_own_comment_has_permission(self): + """ Check correct permission for change + + Make change with user who has add permission on own comment + """ + + client = Client() + + kwargs = self.url_change_kwargs.copy() + + kwargs['pk'] = self.item_add_user.id + + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=kwargs) + + + client.force_login(self.add_user) + response = client.post(url, data=self.change_data) + + assert response.status_code == 200 + + + +class ChangeTicketCommentPermissions(TicketCommentPermissions, TestCase): + + ticket_type = 'change' + + ticket_type_enum: int = int(Ticket.TicketType.CHANGE.value) + + app_namespace = 'ITIM' + + url_name_view = '_ticket_comment_change_view' + + url_name_add = '_ticket_comment_change_add' + + url_name_change = '_ticket_comment_change_change' + + url_name_delete = '_ticket_comment_change_delete' + + url_delete_response = reverse('ITIM:Changes') + + + +class IncidentTicketCommentPermissions(TicketCommentPermissions, TestCase): + + ticket_type = 'incident' + + ticket_type_enum: int = int(Ticket.TicketType.INCIDENT.value) + + app_namespace = 'ITIM' + + url_name_view = '_ticket_comment_incident_view' + + url_name_add = '_ticket_comment_incident_add' + + url_name_change = '_ticket_comment_incident_change' + + url_name_delete = '_ticket_comment_incident_delete' + + url_delete_response = reverse('ITIM:Incidents') + + + +class ProblemTicketCommentPermissions(TicketCommentPermissions, TestCase): + + ticket_type = 'problem' + + ticket_type_enum: int = int(Ticket.TicketType.PROBLEM.value) + + app_namespace = 'ITIM' + + url_name_view = '_ticket_comment_problem_view' + + url_name_add = '_ticket_comment_problem_add' + + url_name_change = '_ticket_comment_problem_change' + + url_name_delete = '_ticket_comment_problem_delete' + + url_delete_response = reverse('ITIM:Problems') + + + +class RequestTicketCommentPermissions(TicketCommentPermissions, TestCase): + + ticket_type = 'request' + + ticket_type_enum: int = int(Ticket.TicketType.REQUEST.value) + + app_namespace = 'Assistance' + + url_name_view = '_ticket_comment_request_view' + + url_name_add = '_ticket_comment_request_add' + + url_name_change = '_ticket_comment_request_change' + + url_name_delete = '_ticket_comment_request_delete' + + url_delete_response = reverse('Assistance:Requests') From f09e7b77dbc5bca3b178149efae03413b4f682e8 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 12:16:03 +0930 Subject: [PATCH 145/321] fix(core): Ensure on ticket comment create and update a response is returned ref: #250 #96 #93 #95 #90 #264 #267 --- app/api/views/core/ticket_comments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/views/core/ticket_comments.py b/app/api/views/core/ticket_comments.py index 9e2e22ca..eed547de 100644 --- a/app/api/views/core/ticket_comments.py +++ b/app/api/views/core/ticket_comments.py @@ -38,7 +38,7 @@ class View(OrganizationMixin, viewsets.ModelViewSet): ) def create(self, request, *args, **kwargs): - super().create(request, *args, **kwargs) + return super().create(request, *args, **kwargs) @extend_schema( @@ -79,7 +79,7 @@ class View(OrganizationMixin, viewsets.ModelViewSet): ) def update(self, request, *args, **kwargs): - super().update(request, *args, **kwargs) + return super().update(request, *args, **kwargs) def get_queryset(self): From 55e512efb87002332272c715bcb47dbbd397b642 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 12:16:16 +0930 Subject: [PATCH 146/321] test(core): Ticket comment API permission checks ref: #250 #96 #93 #95 #90 #264 #267 --- .../test_ticket_comment_permission_api.py | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment/test_ticket_comment_permission_api.py diff --git a/app/core/tests/unit/ticket_comment/test_ticket_comment_permission_api.py b/app/core/tests/unit/ticket_comment/test_ticket_comment_permission_api.py new file mode 100644 index 00000000..caa4bc34 --- /dev/null +++ b/app/core/tests/unit/ticket_comment/test_ticket_comment_permission_api.py @@ -0,0 +1,306 @@ +import pytest +import unittest +import requests + +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import reverse +from django.test import Client, TestCase + +from access.models import Organization, Team, TeamUsers, Permission + +from api.tests.abstract.api_permissions import APIPermissions, APIPermissionAdd, APIPermissionChange + +from core.models.ticket.ticket import Ticket +from core.models.ticket.ticket_comment import TicketComment + + +class TicketCommentPermissionsAPI( + # APIPermissions + APIPermissionAdd, + APIPermissionChange, +): + + model = TicketComment + + change_data = {'body': 'ticket comment change'} + + delete_data = {'body': 'ticket commentn delete'} + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a software + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_ticket_permissions = Permission.objects.get( + codename = 'add_' + Ticket._meta.model_name + '_' + self.ticket_type, + content_type = ContentType.objects.get( + app_label = Ticket._meta.app_label, + model = Ticket._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_ticket_permissions, add_permissions]) + + + self.ticket = Ticket.objects.create( + organization=organization, + title = 'A second ' + self.ticket_type + ' ticket', + description = 'the ticket body of item two', + ticket_type = int(Ticket.TicketType.REQUEST.value), + opened_by = self.add_user, + status = int(Ticket.TicketStatus.All.NEW.value) + ) + + self.item_add_user = self.model.objects.create( + organization=organization, + body = 'A ' + self.ticket_type + ' ticket comment', + ticket = self.ticket, + comment_type = int(TicketComment.CommentType.COMMENT), + user = self.add_user, + # status = int(Ticket.TicketStatus.All.NEW.value) + ) + + self.add_data = { + 'body': 'an add ticket', + 'comment_type': int(TicketComment.CommentType.COMMENT.value), + 'ticket': self.ticket.id, + 'organization': self.organization.id, + 'user': self.add_user.id, + } + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.item = self.model.objects.create( + organization=organization, + body = 'A ' + self.ticket_type + ' ticket comment', + ticket = self.ticket, + comment_type = int(TicketComment.CommentType.COMMENT), + user = self.change_user, + # status = int(Ticket.TicketStatus.All.NEW.value) + ) + + + self.url_kwargs = {'ticket_id': self.ticket.id} + + self.url_view_kwargs = {'ticket_id': self.ticket.id, 'pk': self.item.id} + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) + + + + # def test_change_own_comment_has_permission(self): + # """ Check correct permission for change + + # Make change with user who has add permission on own comment + # """ + + # client = Client() + + # kwargs = self.url_view_kwargs.copy() + + # kwargs['pk'] = self.item_add_user.id + + # url = reverse(self.app_namespace + ':' + self.url_name, kwargs=kwargs) + + + # client.force_login(self.add_user) + # response = client.patch(url, data=self.change_data, content_type='application/json') + + # assert response.status_code == 200 + + # client = Client() + # + + + # client.force_login(self.change_user) + # response = client.patch(url, data=self.change_data, content_type='application/json') + + # assert response.status_code == 200 + + + +class ChangeCommentTicketPermissionsAPI(TicketCommentPermissionsAPI, TestCase): + + # model = TicketComment + + ticket_type: str = 'change' + + ticket_type_enum: int = int(Ticket.TicketType.CHANGE.value) + + app_namespace = 'API' + + url_name = '_api_itim_change_ticket_comments-detail' + + url_list = '_api_itim_change_ticket_comments-list' + + + +class IncidentTicketCommentPermissionsAPI(TicketCommentPermissionsAPI, TestCase): + + # model = Ticket + + ticket_type: str = 'incident' + + ticket_type_enum: int = int(Ticket.TicketType.INCIDENT.value) + + app_namespace = 'API' + + url_name = '_api_itim_incident_ticket_comments-detail' + + url_list = '_api_itim_incident_ticket_comments-list' + + + +class ProblemTicketCommentPermissionsAPI(TicketCommentPermissionsAPI, TestCase): + + # model = Ticket + + ticket_type: str = 'problem' + + ticket_type_enum: int = int(Ticket.TicketType.PROBLEM.value) + + app_namespace = 'API' + + url_name = '_api_itim_problem_ticket_comments-detail' + + url_list = '_api_itim_problem_ticket_comments-list' + + + +class RequestTicketCommentPermissionsAPI(TicketCommentPermissionsAPI, TestCase): + + # model = Ticket + + ticket_type: str = 'request' + + ticket_type_enum: int = int(Ticket.TicketType.REQUEST.value) + + app_namespace = 'API' + + url_name = '_api_assistance_request_ticket_comments-detail' + + url_list = '_api_assistance_request_ticket_comments-list' From 8a747d1d1fa88e8fe45557556bb23dd96a59ffa6 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 13:33:21 +0930 Subject: [PATCH 147/321] feat(api): Add project management endpoint ref: #14 #267 --- app/api/urls.py | 4 ++- app/api/views/index.py | 1 + app/api/views/project_management/index.py | 33 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 app/api/views/project_management/index.py diff --git a/app/api/urls.py b/app/api/urls.py index 2dc9fb72..29c27c1a 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -8,7 +8,7 @@ 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 +from api.views import assistance, itim, project_management from api.views.assistance import request_ticket from api.views.core import ticket_comments as core_ticket_comments from api.views.itim import change_ticket, incident_ticket, problem_ticket @@ -67,6 +67,8 @@ urlpatterns = [ path("organization//team//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'), diff --git a/app/api/views/index.py b/app/api/views/index.py index 8fa706aa..ce32c0d1 100644 --- a/app/api/views/index.py +++ b/app/api/views/index.py @@ -35,6 +35,7 @@ class Index(viewsets.ViewSet): "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), } diff --git a/app/api/views/project_management/index.py b/app/api/views/project_management/index.py new file mode 100644 index 00000000..5c563bc1 --- /dev/null +++ b/app/api/views/project_management/index.py @@ -0,0 +1,33 @@ +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"

{text}

") + else: + return text + + + def get(self, request, *args, **kwargs): + + body: dict = { + } + + return Response(body) From ae72d4ab6ae142ff430142adf538db331f0a5e03 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 13:37:13 +0930 Subject: [PATCH 148/321] feat(api): Add projects endpoint ref: #14 #267 --- .../project_management/projects.py | 48 ++++++++++++++++ app/api/urls.py | 3 + app/api/views/project_management/__init__.py | 1 + app/api/views/project_management/index.py | 1 + app/api/views/project_management/projects.py | 57 +++++++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 app/api/serializers/project_management/projects.py create mode 100644 app/api/views/project_management/__init__.py create mode 100644 app/api/views/project_management/projects.py diff --git a/app/api/serializers/project_management/projects.py b/app/api/serializers/project_management/projects.py new file mode 100644 index 00000000..3a57f8da --- /dev/null +++ b/app/api/serializers/project_management/projects.py @@ -0,0 +1,48 @@ +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, +): + + 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])) + + + class Meta: + + model = Project + + fields = [ + 'id', + 'name', + 'description', + 'code', + 'planned_start_date', + 'planned_finish_date', + 'real_start_date', + 'real_finish_date', + 'manager_user', + 'manager_team', + 'team_members', + 'created', + 'modified', + 'url', + ] + + read_only_fields = [ + 'id', + 'url', + ] diff --git a/app/api/urls.py b/app/api/urls.py index 29c27c1a..ac0459f3 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -12,6 +12,7 @@ from api.views import assistance, itim, project_management from api.views.assistance import request_ticket from api.views.core import ticket_comments as core_ticket_comments from api.views.itim import change_ticket, incident_ticket, problem_ticket +from api.views.project_management import projects from .views.itam import software, config as itam_config from .views.itam.device import DeviceViewSet @@ -39,6 +40,8 @@ router.register('itim/incident/(?P[0-9]+)/comments', core_ticket_comm router.register('itim/problem', problem_ticket.View, basename='_api_itim_problem') router.register('itim/problem/(?P[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('software', software.SoftwareViewSet, basename='software') diff --git a/app/api/views/project_management/__init__.py b/app/api/views/project_management/__init__.py new file mode 100644 index 00000000..623697bc --- /dev/null +++ b/app/api/views/project_management/__init__.py @@ -0,0 +1 @@ +from .index import * \ No newline at end of file diff --git a/app/api/views/project_management/index.py b/app/api/views/project_management/index.py index 5c563bc1..4286fdeb 100644 --- a/app/api/views/project_management/index.py +++ b/app/api/views/project_management/index.py @@ -28,6 +28,7 @@ class Index(views.APIView): def get(self, request, *args, **kwargs): body: dict = { + 'projects': reverse('API:_api_projects-list', request=request) } return Response(body) diff --git a/app/api/views/project_management/projects.py b/app/api/views/project_management/projects.py new file mode 100644 index 00000000..2f0a7571 --- /dev/null +++ b/app/api/views/project_management/projects.py @@ -0,0 +1,57 @@ +from django.db.models import Q +from django.shortcuts import get_object_or_404 + +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 + +from api.serializers.project_management.projects import ProjectSerializer +from api.views.mixin import OrganizationPermissionAPI + +from project_management.models.projects import Project + + + +class View(OrganizationMixin, viewsets.ModelViewSet): + + permission_classes = [ + OrganizationPermissionAPI + ] + + queryset = Project.objects.all() + + serializer_class = ProjectSerializer + + @extend_schema( + summary = 'Create a project', + methods=["POST"], + responses = { + 201: OpenApiResponse(description='project created', response=ProjectSerializer), + 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', methods=["GET"]) + def list(self, request): + + return super().list(request) + + + @extend_schema( summary='Fetch the selected project', methods=["GET"]) + def retrieve(self, request, *args, **kwargs): + + return super().retrieve(request, *args, **kwargs) + + + def get_view_name(self): + if self.detail: + return "Project" + + return 'Projects' From 63d33c287c7dd4115219600c17850220f6bef4e0 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 13:42:03 +0930 Subject: [PATCH 149/321] feat(api): Add project tasks endpoint ref: #14 #267 --- app/api/serializers/core/ticket.py | 27 ++++++-- .../project_management/project_task.py | 49 ++++++++++++++ .../project_management/projects.py | 17 +++++ app/api/urls.py | 4 +- app/api/views/core/tickets.py | 15 +++++ .../views/project_management/project_task.py | 64 +++++++++++++++++++ 6 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 app/api/serializers/project_management/project_task.py create mode 100644 app/api/views/project_management/project_task.py diff --git a/app/api/serializers/core/ticket.py b/app/api/serializers/core/ticket.py index 666a67b1..ceb90c54 100644 --- a/app/api/serializers/core/ticket.py +++ b/app/api/serializers/core/ticket.py @@ -22,6 +22,10 @@ class TicketSerializer( 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' @@ -38,6 +42,11 @@ class TicketSerializer( 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') @@ -46,9 +55,7 @@ class TicketSerializer( return request.build_absolute_uri( reverse( 'API:' + view_name + '-detail', - kwargs={ - 'pk': item.id - } + kwargs = kwargs ) ) @@ -60,6 +67,10 @@ class TicketSerializer( 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' @@ -76,6 +87,12 @@ class TicketSerializer( 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') @@ -84,9 +101,7 @@ class TicketSerializer( return request.build_absolute_uri( reverse( 'API:' + view_name + '-list', - kwargs={ - 'ticket_id': item.id - } + kwargs = kwargs ) ) diff --git a/app/api/serializers/project_management/project_task.py b/app/api/serializers/project_management/project_task.py new file mode 100644 index 00000000..b876d95b --- /dev/null +++ b/app/api/serializers/project_management/project_task.py @@ -0,0 +1,49 @@ +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', + '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', + 'ticket_type', + 'url', + ] diff --git a/app/api/serializers/project_management/projects.py b/app/api/serializers/project_management/projects.py index 3a57f8da..7ae2d9e6 100644 --- a/app/api/serializers/project_management/projects.py +++ b/app/api/serializers/project_management/projects.py @@ -21,6 +21,22 @@ class ProjectSerializer( 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 + } + ) + ) + class Meta: model = Project @@ -37,6 +53,7 @@ class ProjectSerializer( 'manager_user', 'manager_team', 'team_members', + 'project_tasks_url', 'created', 'modified', 'url', diff --git a/app/api/urls.py b/app/api/urls.py index ac0459f3..14a333c4 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -12,7 +12,7 @@ from api.views import assistance, itim, project_management from api.views.assistance import request_ticket from api.views.core import ticket_comments as core_ticket_comments from api.views.itim import change_ticket, incident_ticket, problem_ticket -from api.views.project_management import projects +from api.views.project_management import projects, project_task from .views.itam import software, config as itam_config from .views.itam.device import DeviceViewSet @@ -41,6 +41,8 @@ router.register('itim/problem', problem_ticket.View, basename='_api_itim_problem router.register('itim/problem/(?P[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[0-9]+)/tasks', project_task.View, basename='_api_project_tasks') +router.register('project_management/projects/(?P[0-9]+)/tasks/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_project_tasks_comments') router.register('software', software.SoftwareViewSet, basename='software') diff --git a/app/api/views/core/tickets.py b/app/api/views/core/tickets.py index 8b1ebe9d..1fd75d60 100644 --- a/app/api/views/core/tickets.py +++ b/app/api/views/core/tickets.py @@ -1,3 +1,4 @@ +from django.db.models import Q from rest_framework import generics, viewsets @@ -7,6 +8,7 @@ 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 @@ -86,6 +88,11 @@ class View(OrganizationMixin, viewsets.ModelViewSet): 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') @@ -111,6 +118,14 @@ class View(OrganizationMixin, viewsets.ModelViewSet): 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') diff --git a/app/api/views/project_management/project_task.py b/app/api/views/project_management/project_task.py new file mode 100644 index 00000000..0f6bfd7a --- /dev/null +++ b/app/api/views/project_management/project_task.py @@ -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' From 4d1600e39610feb9c2fa47eeb3cfc2601bc1163a Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 15:27:05 +0930 Subject: [PATCH 150/321] refactor(project_management): migrate projects to new style for views ref: #14 #267 --- app/project_management/forms/project.py | 90 +++++++ .../project_management/project.html.j2 | 249 ++++-------------- app/project_management/urls.py | 10 +- app/project_management/views/project.py | 191 +++++++------- 4 files changed, 247 insertions(+), 293 deletions(-) diff --git a/app/project_management/forms/project.py b/app/project_management/forms/project.py index 913b36b2..d96494fd 100644 --- a/app/project_management/forms/project.py +++ b/app/project_management/forms/project.py @@ -1,4 +1,5 @@ from django import forms +from django.urls import reverse from django.db.models import Q from app import settings @@ -41,3 +42,92 @@ class ProjectForm(CommonModelForm): self.fields['description'].widget.attrs = {'style': "height: 800px; width: 1000px"} + +class DetailForm(ProjectForm): + + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'code', + 'name', + 'type', + 'state', + 'completed', + 'organization' + ], + "right": [ + 'planned_start_date', + 'planned_finish_date', + 'real_finish_date', + ] + }, + { + "layout": "double", + "name": "Manager", + "left": [ + 'manager_user', + ], + "right": [ + 'manager_team', + ] + }, + { + "layout": "single", + "name": "Description", + "fields": [ + 'description', + ], + "markdown": [ + 'description', + ], + }, + ] + }, + "tasks": { + "name": "Tasks", + "slug": "tasks", + "sections": [] + }, + "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.fields['resources'] = forms.CharField( + label = 'Available Resources', + disabled = True, + initial = 'xx/yy CPU, xx/yy RAM, xx/yy Storage', + ) + + self.tabs['details'].update({ + "edit_url": reverse('Project Management:_project_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Project Management:Projects') diff --git a/app/project_management/templates/project_management/project.html.j2 b/app/project_management/templates/project_management/project.html.j2 index 3923c5b5..5c8175d1 100644 --- a/app/project_management/templates/project_management/project.html.j2 +++ b/app/project_management/templates/project_management/project.html.j2 @@ -1,212 +1,71 @@ -{% extends 'base.html.j2' %} +{% extends 'detail.html.j2' %} +{% block additional-stylesheet %} + {% load static %} + +{% endblock additional-stylesheet %} + +{% load json %} {% load markdown %} -{% block content %} +{% block tabs %} - - -
- - - -
- - - {% csrf_token %} -
-

- Details -

-
+
-
+ {% include 'content/section.html.j2' with tab=form.tabs.tasks %} -
- - {{ form.code.value }} -
- -
- - {{ form.name.value }} -
- -
- - project type -
- -
- - project state -
- -
- - {{ project.percent_completed }} -
- -
- - {{ form.organization }} -
- -
+ + + + + + + + + {% for project_task in project_tasks %} + + + + + + + + {% endfor %} +
idtitlestatustypecreated
{{ project_task.id }} + {% if project_task.get_ticket_type_display|lower == 'change' %} + + {% elif project_task.get_ticket_type_display|lower == 'incident' %} + + {% elif project_task.get_ticket_type_display|lower == 'problem' %} + + {% elif project_task.get_ticket_type_display|lower == 'request' %} + + {% elif project_task.get_ticket_type_display|lower == 'project task' %} + + {% else %} + + {% endif %} + {{ project_task.title }} + + + {% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=project_task.get_status_display ticket_status=project_task.get_status_display|ticket_status %} + {{ project_task.get_ticket_type_display }}{{ project_task.created }}
-
+
-
- - {{ form.planned_start_date.value }} -
+
-
- - {{ form.planned_finish_date.value }} -
+ {% include 'content/section.html.j2' with tab=form.tabs.notes %} -
- - {{ form.real_start_date.value }} -
- -
- - {{ form.real_finish_date.value }} -
- -
- -
- - -
- -
- -
-

Manager

-
- -
- - {{ form.manager_user.value }} -
- -
- -
- -
- - {{ form.team_members.value }} -
- -
-
-
-
-
- -
-

Description

-
- {{ form.description.value | markdown | safe }} -
-
-
-
- - - - - - -
- - -
-

Tasks

- -
- - -
-

- Notes -

{{ notes_form }}
@@ -217,7 +76,7 @@ {% endif %}
-
- -{% endblock %} \ No newline at end of file +
+ +{% endblock %} diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 71fc20f7..67a461b7 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -5,12 +5,12 @@ from .views.project_task import ProjectTaskAdd, ProjectTaskChange, ProjectTaskDe app_name = "Project Management" urlpatterns = [ - path('', ProjectIndex.as_view(), name='Projects'), + path('', project.Index.as_view(), name='Projects'), - path("project/add", ProjectAdd.as_view(), name="_project_add"), - path("project/", ProjectView.as_view(), name="_project_view"), - path("project//edit", ProjectChange.as_view(), name="_project_change"), - path("project//delete", ProjectDelete.as_view(), name="_project_delete"), + path("project/add", project.Add.as_view(), name="_project_add"), + path("project/", project.View.as_view(), name="_project_view"), + path("project//edit", project.Change.as_view(), name="_project_change"), + path("project//delete", project.Delete.as_view(), name="_project_delete"), path("project//task/add", ProjectTaskAdd.as_view(), name="_project_task_add"), path("project//task//edit", ProjectTaskChange.as_view(), name="_project_task_change"), diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index 8ec57f6e..d9361fa0 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -11,105 +11,17 @@ from access.mixin import OrganizationPermission from core.forms.comment import AddNoteForm from core.models.notes import Notes +from core.models.ticket.ticket import Ticket from core.views.common import AddView, ChangeView, DeleteView, DisplayView, IndexView -from project_management.forms.project import ProjectForm +from project_management.forms.project import ProjectForm, DetailForm from project_management.models.projects import Project from settings.models.user_settings import UserSettings -class ProjectIndex(IndexView): - - model = Project - - permission_required = 'project_management.view_project' - - template_name = 'project_management/project_index.html.j2' - - context_object_name = "projects" - - paginate_by = 10 - - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['content_title'] = 'Projects' - - return context - - - def get_queryset(self): - - if self.request.user.is_superuser: - - return self.model.objects.filter().order_by('name') - - else: - - return self.model.objects.filter(Q(organization__in=self.user_organizations()) | Q(is_global = True)).order_by('name') - - - -class ProjectView(ChangeView): - - model = Project - - permission_required = [ - 'itam.view_device', - 'itam.change_device' - ] - - template_name = 'project_management/project.html.j2' - - form_class = ProjectForm - - context_object_name = "project" - - - def get_context_data(self, **kwargs): - - context = super().get_context_data(**kwargs) - - # context['notes_form'] = AddNoteForm(prefix='note') - # context['notes'] = Notes.objects.filter(project=self.kwargs['pk']) - - - context['model_docs_path'] = self.model._meta.app_label + '/' + self.model._meta.model_name + '/' - - context['model_pk'] = self.kwargs['pk'] - context['model_name'] = self.model._meta.verbose_name.replace(' ', '') - - context['model_delete_url'] = reverse('Project Management:_project_delete', args=(self.kwargs['pk'],)) - - context['content_title'] = context['project'].name - - return context - - - # def post(self, request, *args, **kwargs): - - # project = self.model.objects.get(pk=self.kwargs['pk']) - - # notes = AddNoteForm(request.POST, prefix='note') - - # if notes.is_bound and notes.is_valid() and notes.instance.note != '': - - # if request.user.has_perm('core.add_notes'): - - # notes.instance.organization = device.organization - # notes.instance.project = project - # notes.instance.usercreated = request.user - - # notes.save() - - # return super().post(request, *args, **kwargs) - - - -class ProjectAdd(AddView): +class Add(AddView): form_class = ProjectForm @@ -146,7 +58,7 @@ class ProjectAdd(AddView): -class ProjectChange(ChangeView): +class Change(ChangeView): form_class = ProjectForm @@ -178,7 +90,7 @@ class ProjectChange(ChangeView): -class ProjectDelete(DeleteView): +class Delete(DeleteView): model = Project permission_required = [ @@ -200,3 +112,96 @@ class ProjectDelete(DeleteView): return context + + +class Index(IndexView): + + model = Project + + permission_required = 'project_management.view_project' + + template_name = 'project_management/project_index.html.j2' + + context_object_name = "projects" + + paginate_by = 10 + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Projects' + + return context + + + def get_queryset(self): + + if self.request.user.is_superuser: + + return self.model.objects.filter().order_by('name') + + else: + + return self.model.objects.filter(Q(organization__in=self.user_organizations()) | Q(is_global = True)).order_by('name') + + + +class View(ChangeView): + + model = Project + + permission_required = [ + 'itam.view_device', + 'itam.change_device' + ] + + template_name = 'project_management/project.html.j2' + + form_class = DetailForm + + context_object_name = "project" + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + # context['notes_form'] = AddNoteForm(prefix='note') + # context['notes'] = Notes.objects.filter(service=self.kwargs['pk']) + + context['project_tasks'] = Ticket.objects.filter( + project = self.object, + ) + + + context['model_docs_path'] = self.model._meta.app_label + '/' + self.model._meta.model_name + '/' + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.verbose_name.replace(' ', '') + + context['model_delete_url'] = reverse('Project Management:_project_delete', args=(self.kwargs['pk'],)) + + context['content_title'] = context['project'].name + + return context + + + # def post(self, request, *args, **kwargs): + + # project = self.model.objects.get(pk=self.kwargs['pk']) + + # notes = AddNoteForm(request.POST, prefix='note') + + # if notes.is_bound and notes.is_valid() and notes.instance.note != '': + + # if request.user.has_perm('core.add_notes'): + + # notes.instance.organization = device.organization + # notes.instance.project = project + # notes.instance.usercreated = request.user + + # notes.save() + + # return super().post(request, *args, **kwargs) + From daa872d2e7ea07399c47af5ab41b07aa29b06e92 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 15:38:30 +0930 Subject: [PATCH 151/321] feat(project_management): Add project tasks ref: #14 #250 #267 --- app/core/forms/ticket.py | 5 +---- app/core/forms/validate_ticket.py | 9 +++++++++ app/core/models/ticket/markdown.py | 7 +++++++ .../core/ticket/renderers/ticket_link.html.j2 | 4 +++- app/core/views/related_ticket.py | 6 +++++- app/core/views/ticket.py | 6 +++++- app/core/views/ticket_comment.py | 5 +++++ .../project_management/project.html.j2 | 2 ++ app/project_management/urls.py | 17 ++++++++++++----- 9 files changed, 49 insertions(+), 12 deletions(-) diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index 5f422c40..d3cbac47 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -186,10 +186,7 @@ class TicketForm( elif self._ticket_type == 'project_task': - # self.validate_project_task_ticket() - raise ValidationError( - 'This Ticket type is not yet available' - ) + self.validate_project_task_ticket() elif self._ticket_type == 'request': diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 2923db6d..cd48686a 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -444,3 +444,12 @@ class TicketValidation( # raise ValidationError('Test to see what it looks like') pass + + def validate_project_task_ticket(self): + + # check status + + # check type + + # raise ValidationError('Test to see what it looks like') + pass diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index 29d27401..a5b30129 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -29,11 +29,18 @@ class TicketMarkdown: 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) diff --git a/app/core/templates/core/ticket/renderers/ticket_link.html.j2 b/app/core/templates/core/ticket/renderers/ticket_link.html.j2 index 1bc36055..03e63344 100644 --- a/app/core/templates/core/ticket/renderers/ticket_link.html.j2 +++ b/app/core/templates/core/ticket/renderers/ticket_link.html.j2 @@ -10,6 +10,8 @@ {% url 'ITIM:_ticket_problem_view' ticket_type='problem' pk=id %} {% elif ticket_type == 'request' %} {% url 'Assistance:_ticket_request_view' ticket_type='request' pk=id %} - {% endif %}">{{ ticket_type }} {{ name }}, #{{ id }} + {% elif ticket_type == 'project task' %} + {% url 'Project Management:_project_task_view' project_id=project_id ticket_type='project_task' pk=id %} + {% endif %}">#{{ id }} {{ name }}, {{ ticket_type }} \ No newline at end of file diff --git a/app/core/views/related_ticket.py b/app/core/views/related_ticket.py index c215bfc3..36bd74d4 100644 --- a/app/core/views/related_ticket.py +++ b/app/core/views/related_ticket.py @@ -43,7 +43,11 @@ class Add(AddView): if self.kwargs['ticket_type'] == 'request': return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) - + + elif self.kwargs['ticket_type'] == 'project_task': + + return reverse('Project Management:_project_task_view', args=(self.object.from_ticket_id.project.id, self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) + else: return reverse('ITIM:_ticket_' + str(self.kwargs['ticket_type']).lower() + '_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index 0abc91fa..7b9d75db 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -57,7 +57,11 @@ class Add(AddView): if self.kwargs['ticket_type'] == 'request': return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.object.id,)) - + + elif self.kwargs['ticket_type'] == 'project_task': + + return reverse('Project Management:_project_task_view', args=(self.object.project.id, self.kwargs['ticket_type'],self.object.id,)) + else: return reverse('ITIM:_ticket_' + str(self.kwargs['ticket_type']).lower() + '_view', args=(self.kwargs['ticket_type'],self.object.id,)) diff --git a/app/core/views/ticket_comment.py b/app/core/views/ticket_comment.py index 711b0f7a..5a767377 100644 --- a/app/core/views/ticket_comment.py +++ b/app/core/views/ticket_comment.py @@ -75,8 +75,13 @@ class Add(AddView): def get_success_url(self, **kwargs): if self.kwargs['ticket_type'] == 'request': + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'])) + elif self.kwargs['ticket_type'] == 'project_task': + + return reverse('Project Management:_project_task_view', args=(self.object.ticket.project.id, self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) + return f"/ticket/" diff --git a/app/project_management/templates/project_management/project.html.j2 b/app/project_management/templates/project_management/project.html.j2 index 5c8175d1..c380aba9 100644 --- a/app/project_management/templates/project_management/project.html.j2 +++ b/app/project_management/templates/project_management/project.html.j2 @@ -22,6 +22,8 @@ {% include 'content/section.html.j2' with tab=form.tabs.tasks %} + + diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 67a461b7..93653878 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,8 +1,11 @@ from django.urls import path -from .views.project import ProjectIndex, ProjectAdd, ProjectDelete, ProjectChange, ProjectView +from .views import project from .views.project_task import ProjectTaskAdd, ProjectTaskChange, ProjectTaskDelete, ProjectTaskView +from core.views import ticket, ticket_comment + + app_name = "Project Management" urlpatterns = [ path('', project.Index.as_view(), name='Projects'), @@ -12,10 +15,14 @@ urlpatterns = [ path("project//edit", project.Change.as_view(), name="_project_change"), path("project//delete", project.Delete.as_view(), name="_project_delete"), - path("project//task/add", ProjectTaskAdd.as_view(), name="_project_task_add"), - path("project//task//edit", ProjectTaskChange.as_view(), name="_project_task_change"), - path("project//task//delete", ProjectTaskDelete.as_view(), name="_project_task_delete"), - path("project//task/", ProjectTaskView.as_view(), name="_project_task_view"), + path('project///add', ticket.Add.as_view(), name="_project_task_add"), + path('project////edit', ticket.Change.as_view(), name="_project_task_change"), + path('project////delete', ticket.Delete.as_view(), name="_project_task_delete"), + path('project///', ticket.View.as_view(), name="_project_task_view"), + + path('project////comment/add', ticket_comment.Add.as_view(), name="_project_task_comment_add"), + path('project////comment//edit', ticket_comment.Change.as_view(), name="_project_task_comment_change"), + path('project////comment//add', ticket_comment.Add.as_view(), name="_project_task_comment_add"), ] From 6371fa03a199d269bd75d328845576604a0ee778 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 15:52:34 +0930 Subject: [PATCH 152/321] chore(project_management): remove non-ticket based project tasks project tasks scope was moved to a type of ticket. ref: #14 #250 #267 --- .../migrations/0001_initial.py | 30 +-- .../models/project_tasks.py | 121 ------------ app/project_management/urls.py | 1 - app/project_management/views/project_task.py | 176 ------------------ 4 files changed, 1 insertion(+), 327 deletions(-) delete mode 100644 app/project_management/models/project_tasks.py delete mode 100644 app/project_management/views/project_task.py diff --git a/app/project_management/migrations/0001_initial.py b/app/project_management/migrations/0001_initial.py index 2da0a8e1..42e9a326 100644 --- a/app/project_management/migrations/0001_initial.py +++ b/app/project_management/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-16 05:57 +# Generated by Django 5.0.8 on 2024-09-10 06:20 import access.fields import access.models @@ -44,32 +44,4 @@ class Migration(migrations.Migration): 'ordering': ['code', 'name'], }, ), - migrations.CreateModel( - name='ProjectTask', - fields=[ - ('is_global', models.BooleanField(default=False)), - ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), - ('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(max_length=50, unique=True)), - ('slug', access.fields.AutoSlugField()), - ('description', models.TextField(blank=True, default=None, null=True)), - ('code', models.CharField(help_text='Project Code', max_length=25, unique=True)), - ('planned_start_date', models.DateTimeField(blank=True, help_text='When the task is planned to have been started by.', null=True, verbose_name='Planned Start Date')), - ('planned_finish_date', models.DateTimeField(blank=True, help_text='When the task is planned to be finished by.', null=True, verbose_name='Planned Finish Date')), - ('real_start_date', models.DateTimeField(blank=True, help_text='When work commenced on the task.', null=True, verbose_name='Real Start Date')), - ('real_finish_date', models.DateTimeField(blank=True, help_text='When work was completed for the task', null=True, verbose_name='Real Finish Date')), - ('milestone', models.BooleanField(default=False, help_text='Is this task a milestone?')), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('parent_task', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='project_management.projecttask')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project_management.project')), - ('task_members', models.ManyToManyField(help_text='User whom is responsible for completing the task.', related_name='task_members', to=settings.AUTH_USER_MODEL, verbose_name='Team Members')), - ('task_owner', models.ForeignKey(blank=True, help_text='User whom is considered the task owner.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Task Owner')), - ], - options={ - 'verbose_name': 'Project Task', - 'verbose_name_plural': 'Project Tasks', - 'ordering': ['code', 'name'], - }, - ), ] diff --git a/app/project_management/models/project_tasks.py b/app/project_management/models/project_tasks.py deleted file mode 100644 index 11848af8..00000000 --- a/app/project_management/models/project_tasks.py +++ /dev/null @@ -1,121 +0,0 @@ -from django.contrib.auth.models import User -from django.db import models - -from .projects import Project -from .project_common import ProjectCommonFieldsName - -from access.models import Team - -from core.mixin.history_save import SaveHistory - - - -class ProjectTask(ProjectCommonFieldsName): - - - class Meta: - - ordering = [ - 'code', - 'name', - ] - - verbose_name = 'Project Task' - - verbose_name_plural = 'Project Tasks' - - - - # class ProjectTaskStates(enum): - # OPEN = 1 - # CLOSED = 1 - - - project = models.ForeignKey( - Project, - on_delete=models.CASCADE, - null = False, - blank= False - ) - - parent_task = models.ForeignKey( - 'self', - blank= True, - default = None, - on_delete=models.CASCADE, - null = True, - ) - - description = models.TextField( - blank = True, - default = None, - null= True, - ) - - # priority - - # state - - # percent_done - - # task_type - - code = models.CharField( - blank = False, - help_text = 'Project Code', - max_length = 25, - unique = True, - ) - - planned_start_date = models.DateTimeField( - blank = True, - help_text = 'When the task is planned to have been started by.', - null = True, - verbose_name = 'Planned Start Date', - ) - - planned_finish_date = models.DateTimeField( - blank = True, - help_text = 'When the task is planned to be finished by.', - null = True, - verbose_name = 'Planned Finish Date', - ) - - real_start_date = models.DateTimeField( - blank = True, - help_text = 'When work commenced on the task.', - null = True, - verbose_name = 'Real Start Date', - ) - - real_finish_date = models.DateTimeField( - blank = True, - help_text = 'When work was completed for the task', - null = True, - verbose_name = 'Real Finish Date', - ) - - milestone = models.BooleanField( - blank = False, - help_text = 'Is this task a milestone?', - default = False, - ) - - model_notes = None - - task_owner = models.ForeignKey( - User, - blank= True, - help_text = 'User whom is considered the task owner.', - on_delete=models.SET_NULL, - null = True, - verbose_name = 'Task Owner', - ) - - task_members = models.ManyToManyField( - to = User, - blank = False, - help_text = 'User whom is responsible for completing the task.', - related_name = 'task_members', - verbose_name = 'Team Members', - ) \ No newline at end of file diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 93653878..69e6fdcd 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -1,7 +1,6 @@ from django.urls import path from .views import project -from .views.project_task import ProjectTaskAdd, ProjectTaskChange, ProjectTaskDelete, ProjectTaskView from core.views import ticket, ticket_comment diff --git a/app/project_management/views/project_task.py b/app/project_management/views/project_task.py deleted file mode 100644 index fa129c97..00000000 --- a/app/project_management/views/project_task.py +++ /dev/null @@ -1,176 +0,0 @@ -import json -import markdown - -from django.contrib.auth.mixins import PermissionRequiredMixin -from django.core.paginator import Paginator -from django.db.models import Q -from django.urls import reverse -from django.views import generic - -from access.mixin import OrganizationPermission - -from core.forms.comment import AddNoteForm -from core.models.notes import Notes -from core.views.common import AddView, ChangeView, DeleteView, DisplayView, IndexView - -from project_management.models.project_tasks import ProjectTask - -from settings.models.user_settings import UserSettings - - - -class ProjectTaskAdd(AddView): - - # form_class = form_class = ProjectTaskForm - - model = ProjectTask - - permission_required = [ - 'project_management.add_project', - ] - - template_name = 'form.html.j2' - - - def get_initial(self): - return { - 'organization': UserSettings.objects.get(user = self.request.user).default_organization - } - - def form_valid(self, form): - form.instance.is_global = False - return super().form_valid(form) - - - def get_success_url(self, **kwargs): - - return reverse('Project Management:Projects') - - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['content_title'] = 'Create a Project' - - return context - - - -class ProjectTaskChange(ChangeView): - - # form_class = ProjectTaskForm - - model = ProjectTask - - permission_required = [ - 'project_management.change_projecttask', - ] - - template_name = 'form.html.j2' - - - def form_valid(self, form): - form.instance.is_global = False - return super().form_valid(form) - - - def get_success_url(self, **kwargs): - - return reverse('Project Management:_project_task_view', kwargs={'pk': self.kwargs['pk'], 'project_id': self.kwargs['project_id']}) - - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['content_title'] = self.object.name - - return context - - - -class ProjectTaskView(ChangeView): - - model = ProjectTask - - permission_required = [ - 'project_management.change_projecttask' - ] - - template_name = 'project_management/project.html.j2' - - # form_class = ProjectTaskForm - - context_object_name = "project_task" - - - def form_valid(self, form): - form.instance.is_global = False - return super().form_valid(form) - - - def get_context_data(self, **kwargs): - - context = super().get_context_data(**kwargs) - - # context['notes_form'] = AddNoteForm(prefix='note') - # context['notes'] = Notes.objects.filter(project=self.kwargs['pk']) - - - context['model_docs_path'] = self.model._meta.app_label + '/' + self.model._meta.model_name + '/' - - context['model_pk'] = self.kwargs['pk'] - context['model_name'] = self.model._meta.verbose_name.replace(' ', '') - - context['model_delete_url'] = reverse('Project Management:_project_task_delete', args=(self.kwargs['project_id'],self.kwargs['pk'])) - - context['content_title'] = context['project_task'].name - - return context - - - def get_success_url(self, **kwargs): - - return reverse('Project Management:_project_task_view', kwargs={'pk': self.kwargs['pk'], 'project_id': self.kwargs['project_id']}) - - - # def post(self, request, *args, **kwargs): - - # project = self.model.objects.get(pk=self.kwargs['pk']) - - # notes = AddNoteForm(request.POST, prefix='note') - - # if notes.is_bound and notes.is_valid() and notes.instance.note != '': - - # if request.user.has_perm('core.add_notes'): - - # notes.instance.organization = device.organization - # notes.instance.project = project - # notes.instance.usercreated = request.user - - # notes.save() - - # return super().post(request, *args, **kwargs) - - - -class ProjectTaskDelete(DeleteView): - model = ProjectTask - - permission_required = [ - 'project_management.delete_projecttask', - ] - - template_name = 'form.html.j2' - - - def get_success_url(self, **kwargs): - - return reverse('Project Management:_project_view', kwargs={'pk': self.kwargs['project_id']}) - - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['content_title'] = 'Delete ' + self.object.name - - return context From 7d8b54a98008ac30a3a6d830c1c1a9517775ef00 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 16:09:44 +0930 Subject: [PATCH 153/321] fix(core): Ensure for both ticket and comment, external details are unique. ref: #250 #96 #93 #95 #90 #264 #267 fixes #268 --- .../migrations/0005_ticket_relatedtickets_ticketcomment.py | 4 +++- app/core/models/ticket/ticket.py | 2 ++ app/core/models/ticket/ticket_comment.py | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py index 6350e824..62eb2b00 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-09 04:11 +# Generated by Django 5.0.8 on 2024-09-10 06:38 import access.fields import access.models @@ -55,6 +55,7 @@ class Migration(migrations.Migration): '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')], + 'unique_together': {('external_system', 'external_ref')}, }, bases=(models.Model, core.models.ticket.markdown.TicketMarkdown), ), @@ -103,6 +104,7 @@ class Migration(migrations.Migration): 'verbose_name': 'Comment', 'verbose_name_plural': 'Comments', 'ordering': ['ticket', 'parent_id'], + 'unique_together': {('external_system', 'external_ref')}, }, bases=(models.Model, core.models.ticket.markdown.TicketMarkdown), ), diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 8ebdf4dd..961a9534 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -166,6 +166,8 @@ class Ticket( ('view_ticket_change', 'Can view all change ticket'), ] + unique_together = ('external_system', 'external_ref',) + verbose_name = "Ticket" verbose_name_plural = "Tickets" diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 1730721e..7f8d381e 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -25,6 +25,8 @@ class TicketComment( 'parent_id' ] + unique_together = ('external_system', 'external_ref',) + verbose_name = "Comment" verbose_name_plural = "Comments" From 2613a132a61b62b50ee7093768c54277f837b875 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 16:26:04 +0930 Subject: [PATCH 154/321] fix(core): order ticket comments by creation date oldest first.. ref: #250 #96 #93 #95 #90 #264 #267 fixes #269 --- app/api/views/core/ticket_comments.py | 2 +- app/core/models/ticket/ticket.py | 2 +- makefile | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/views/core/ticket_comments.py b/app/api/views/core/ticket_comments.py index eed547de..24ade486 100644 --- a/app/api/views/core/ticket_comments.py +++ b/app/api/views/core/ticket_comments.py @@ -86,7 +86,7 @@ class View(OrganizationMixin, viewsets.ModelViewSet): if 'ticket_id' in self.kwargs: - self.queryset = self.queryset.filter(ticket=self.kwargs['ticket_id']) + self.queryset = self.queryset.filter(ticket=self.kwargs['ticket_id']).order_by('created') if 'pk' in self.kwargs: diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 961a9534..958f4936 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -707,7 +707,7 @@ class Ticket( self._ticket_comments = TicketComment.objects.filter( ticket = self.id, parent = None, - ) + ).order_by('created') return self._ticket_comments diff --git a/makefile b/makefile index 5738b211..78f22277 100644 --- a/makefile +++ b/makefile @@ -39,6 +39,7 @@ docs: docs-lint lint: markdown-mkdocs-lint test: + cd app pytest --cov --cov-report term --cov-report xml:../artifacts/coverage.xml --cov-report html:../artifacts/coverage/ --junit-xml=../artifacts/unit.JUnit.xml **/tests/unit From fa9cff390a64fab624d15cb198142c93270f6cfe Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 17:01:24 +0930 Subject: [PATCH 155/321] feat(core): Add project task permissions ref: #250 #96 #93 #95 #90 #264 #267 fixes #269 --- .../0005_ticket_relatedtickets_ticketcomment.py | 4 ++-- app/core/models/ticket/ticket.py | 15 +++++++++------ app/core/views/ticket.py | 5 +++++ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py index 62eb2b00..bb8df3e5 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-10 06:38 +# Generated by Django 5.0.8 on 2024-09-10 07:30 import access.fields import access.models @@ -54,7 +54,7 @@ class Migration(migrations.Migration): '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')], + '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')], 'unique_together': {('external_system', 'external_ref')}, }, bases=(models.Model, core.models.ticket.markdown.TicketMarkdown), diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 958f4936..11ad5120 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -164,6 +164,14 @@ class 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'), ] unique_together = ('external_system', 'external_ref',) @@ -667,12 +675,7 @@ class Ticket( ] - fields_project_task: list(str()) = common_fields + [ - 'category', - 'urgency', - 'status', - 'impact', - 'priority', + fields_project_task: list(str()) = common_itsm_fields + [ 'planned_start_date', 'planned_finish_date', 'real_start_date', diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index 7b9d75db..8667f9ea 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -143,6 +143,10 @@ class Delete(DeleteView): return reverse('Assistance:Requests') + elif self.kwargs['ticket_type'] == 'project_task': + + return reverse('Project Management:_project_view', kwargs={'pk': self.object.id}) + else: if self.kwargs['ticket_type'] == 'change': @@ -154,6 +158,7 @@ class Delete(DeleteView): elif self.kwargs['ticket_type'] == 'problem': path = 'Problems' + return reverse('ITIM:' + path) From 78607a0bf9c5c425da9c2fc8458fd6dab2b3adaa Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 10 Sep 2024 17:01:57 +0930 Subject: [PATCH 156/321] test(core): Project task permission checks ref: #250 #96 #93 #95 #90 #264 #267 --- .../unit/ticket/test_ticket_permission.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index dfad433f..ef212b08 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -901,6 +901,50 @@ class ProblemTicketPermissions(TicketPermissions, TestCase): +class ProjectTaskPermissions(TicketPermissions, TestCase): + + ticket_type = 'project_task' + + ticket_type_enum: int = int(Ticket.TicketType.PROJECT_TASK.value) + + app_namespace = 'Project Management' + + url_name_view = '_project_task_view' + + url_name_add = '_project_task_add' + + url_name_change = '_project_task_change' + + url_name_delete = '_project_task_delete' + + + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a manufacturer + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + super().setUpTestData() + + self.url_add_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type} + + self.url_change_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type, 'pk': self.item.id} + + self.url_delete_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type, 'pk': self.project.id} + + # self.url_delete_kwargs = {'pk': self.project.id} + + self.url_view_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type, 'pk': self.item.id} + + self.url_delete_response = reverse('Project Management:_project_view', kwargs={'pk': self.project.id}) + class RequestTicketPermissions(TicketPermissions, TestCase): ticket_type = 'request' From dd68bfbea817ba89bed785ce0d8b1e000639c541 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 12:18:24 +0930 Subject: [PATCH 157/321] refactor(core): Adjust test layout for itsm and project field based permissions ref: #250 #96 #93 #95 #90 #264 #268 --- .../unit/ticket/test_ticket_permission.py | 33 +- .../field_based_permissions.py | 436 ++++++++++++------ 2 files changed, 311 insertions(+), 158 deletions(-) diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index ef212b08..8963f915 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -19,14 +19,13 @@ from project_management.models.projects import Project from core.models.ticket.ticket import Ticket, RelatedTickets from core.models.ticket.ticket_comment import TicketComment -from core.tests.unit.ticket.ticket_permission.field_based_permissions import TicketFieldBasedPermissions +from core.tests.unit.ticket.ticket_permission.field_based_permissions import ITSMTicketFieldBasedPermissions, ProjectTicketFieldBasedPermissions class TicketPermissions( ModelPermissions, - TicketFieldBasedPermissions ): ticket_type:str = None @@ -841,7 +840,25 @@ class TicketPermissions( -class ChangeTicketPermissions(TicketPermissions, TestCase): +class ITSMTicketPermissions( + TicketPermissions, + ITSMTicketFieldBasedPermissions, +): + + pass + + + +class ProjectTicketPermissions( + TicketPermissions, + ProjectTicketFieldBasedPermissions, +): + + pass + + + +class ChangeTicketPermissions(ITSMTicketPermissions, TestCase): ticket_type = 'change' @@ -861,7 +878,7 @@ class ChangeTicketPermissions(TicketPermissions, TestCase): -class IncidentTicketPermissions(TicketPermissions, TestCase): +class IncidentTicketPermissions(ITSMTicketPermissions, TestCase): ticket_type = 'incident' @@ -881,7 +898,7 @@ class IncidentTicketPermissions(TicketPermissions, TestCase): -class ProblemTicketPermissions(TicketPermissions, TestCase): +class ProblemTicketPermissions(ITSMTicketPermissions, TestCase): ticket_type = 'problem' @@ -901,7 +918,7 @@ class ProblemTicketPermissions(TicketPermissions, TestCase): -class ProjectTaskPermissions(TicketPermissions, TestCase): +class ProjectTaskPermissions(ProjectTicketPermissions, TestCase): ticket_type = 'project_task' @@ -945,7 +962,9 @@ class ProjectTaskPermissions(TicketPermissions, TestCase): self.url_delete_response = reverse('Project Management:_project_view', kwargs={'pk': self.project.id}) -class RequestTicketPermissions(TicketPermissions, TestCase): + + +class RequestTicketPermissions(ITSMTicketPermissions, TestCase): ticket_type = 'request' diff --git a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py index 221a4826..57f8f7e6 100644 --- a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py +++ b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py @@ -2114,81 +2114,6 @@ class TicketFieldPermissionsTriageUser: assert response.status_code == 200 - def test_field_permission_planned_start_date_triage_user_denied(self): - """ Check correct permission for add - - A standard user should not be able to edit field planned_start_date. - """ - - field_name: str = 'planned_start_date' - field_value = '2024-09-08T13:19:00' - - - client = Client(raise_request_exception=True) - url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) - - client.force_login(self.triage_user) - - data = self.change_data.copy() - - data[field_name] = field_value - - try: - - response = client.post( - url, - data=data - ) - - assert False, 'a ValidationError exception should have been thrown' - - except ValidationError as exception: - - assert exception.code == 'cant_edit_field_' + field_name - - except Exception as exception: - - assert False, f"reason: {exception}" - - - def test_field_permission_planned_finish_date_triage_user_denied(self): - """ Check correct permission for add - - A standard user should not be able to edit field planned_finish_date. - """ - - field_name: str = 'planned_finish_date' - field_value = '2024-09-08T13:19:00' - - - client = Client(raise_request_exception=True) - url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) - - client.force_login(self.triage_user) - - data = self.change_data.copy() - - data[field_name] = field_value - - try: - - response = client.post( - url, - data=data - ) - - assert False, 'a ValidationError exception should have been thrown' - - except ValidationError as exception: - - assert exception.code == 'cant_edit_field_' + field_name - - except Exception as exception: - - assert False, f"reason: {exception}" - - - def test_field_permission_project_triage_user_allowed(self): """ Check correct permission for add @@ -2216,80 +2141,6 @@ class TicketFieldPermissionsTriageUser: assert response.status_code == 200 - def test_field_permission_real_start_date_triage_user_denied(self): - """ Check correct permission for add - - A standard user should not be able to edit field real_start_date. - """ - - field_name: str = 'real_start_date' - field_value = '2024-09-08T13:19:00' - - - client = Client(raise_request_exception=True) - url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) - - client.force_login(self.triage_user) - - data = self.change_data.copy() - - data[field_name] = field_value - - try: - - response = client.post( - url, - data=data - ) - - assert False, 'a ValidationError exception should have been thrown' - - except ValidationError as exception: - - assert exception.code == 'cant_edit_field_' + field_name - - except Exception as exception: - - assert False, f"reason: {exception}" - - - def test_field_permission_real_finish_date_triage_user_denied(self): - """ Check correct permission for add - - A standard user should not be able to edit field real_finish_date. - """ - - field_name: str = 'real_finish_date' - field_value = '2024-09-08T13:19:00' - - - client = Client(raise_request_exception=True) - url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) - - client.force_login(self.triage_user) - - data = self.change_data.copy() - - data[field_name] = field_value - - try: - - response = client.post( - url, - data=data - ) - - assert False, 'a ValidationError exception should have been thrown' - - except ValidationError as exception: - - assert exception.code == 'cant_edit_field_' + field_name - - except Exception as exception: - - assert False, f"reason: {exception}" - - def test_field_permission_subscribed_users_triage_user_allowed(self): """ Check correct permission for add @@ -2385,17 +2236,300 @@ class TicketFieldPermissionsTriageUser: -class TicketFieldBasedPermissions( +class ITSMTicketFieldPermissionsTriageUser( + TicketFieldPermissionsTriageUser +): + + + def test_field_permission_planned_start_date_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_start_date. + """ + + field_name: str = 'planned_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_planned_finish_date_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field planned_finish_date. + """ + + field_name: str = 'planned_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_real_start_date_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_start_date. + """ + + field_name: str = 'real_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + def test_field_permission_real_finish_date_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field real_finish_date. + """ + + field_name: str = 'real_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + + +class ProjectTicketFieldPermissionsTriageUser( + TicketFieldPermissionsTriageUser +): + + + def test_field_permission_planned_start_date_triage_user_allowed(self): + """ Check correct permission for add + + A standard user should be able to edit field planned_start_date. + """ + + field_name: str = 'planned_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + def test_field_permission_planned_finish_date_triage_user_allowed(self): + """ Check correct permission for add + + A standard user should be able to edit field planned_finish_date. + """ + + field_name: str = 'planned_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + def test_field_permission_real_start_date_triage_user_allowed(self): + """ Check correct permission for add + + A standard user should be able to edit field real_start_date. + """ + + field_name: str = 'real_start_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + def test_field_permission_real_finish_date_triage_user_allowed(self): + """ Check correct permission for add + + A standard user should be able to edit field real_finish_date. + """ + + field_name: str = 'real_finish_date' + field_value = '2024-09-08T13:19:00' + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + + + +class ITSMTicketFieldBasedPermissions( TicketFieldPermissionsAddUser, TicketFieldPermissionsChangeUser, TicketFieldPermissionsImportUser, - TicketFieldPermissionsTriageUser, + ITSMTicketFieldPermissionsTriageUser, ): pass +class ProjectTicketFieldBasedPermissions( + TicketFieldPermissionsAddUser, + TicketFieldPermissionsChangeUser, + TicketFieldPermissionsImportUser, + ProjectTicketFieldPermissionsTriageUser, +): + + pass + + # @pytest.mark.django_db # @pytest.mark.parametrize("field,value,status_code", [ # ('status', int(Ticket.TicketStatus.All.ASSIGNED.value), 'cant_edit_field_status'), From 1cc196fd06a36d66207271f194ef7d2201116c72 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 12:36:23 +0930 Subject: [PATCH 158/321] feat(core): Add external ref to tickets if populated ref: #250 #96 #93 #95 #90 #270 --- app/core/templates/core/ticket.html.j2 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index 0ce8cf9d..37273471 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -101,7 +101,12 @@
-

{{ ticket_type }}

+

+ {{ ticket_type }} #{{ ticket.id }} + {% if ticket.external_ref %} + (#{{ ticket.external_ref }}) + {% endif %} +

From b07872c8c22157958f7dc604baa91d3744c037ef Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 12:50:31 +0930 Subject: [PATCH 159/321] feat(project_management): Add project duration field ref: #14 #270 --- app/project_management/forms/project.py | 9 +++++++++ app/project_management/models/projects.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/project_management/forms/project.py b/app/project_management/forms/project.py index d96494fd..61a9b5c1 100644 --- a/app/project_management/forms/project.py +++ b/app/project_management/forms/project.py @@ -5,6 +5,7 @@ from django.db.models import Q from app import settings from core.forms.common import CommonModelForm +from core.templatetags.markdown import to_duration from project_management.models.projects import Project @@ -64,7 +65,9 @@ class DetailForm(ProjectForm): "right": [ 'planned_start_date', 'planned_finish_date', + 'real_start_date', 'real_finish_date', + 'duration' ] }, { @@ -120,6 +123,12 @@ class DetailForm(ProjectForm): initial = self.instance.modified, ) + self.fields['duration'] = forms.IntegerField( + label = 'Duration', + disabled = True, + initial = to_duration(self.instance.duration_project), + ) + self.fields['resources'] = forms.CharField( label = 'Available Resources', disabled = True, diff --git a/app/project_management/models/projects.py b/app/project_management/models/projects.py index ece5b785..0dfe0e53 100644 --- a/app/project_management/models/projects.py +++ b/app/project_management/models/projects.py @@ -105,6 +105,25 @@ class Project(ProjectCommonFieldsName): return self.name + @property + def duration_project(self) -> int: + + duration_project: int = 0 + + from core.models.ticket.ticket import Ticket + + tickets = Ticket.objects.filter( + project = self.id + ) + + for ticket in tickets: + + duration_project = duration_project + int(ticket.duration_ticket) + + + return int(duration_project) + + @property def percent_completed(self) -> str: # Auto-Calculate """ How much of the project is completed. From c83ffe542ef7295a7c5f41fb095ef9e5512da9c2 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 13:09:24 +0930 Subject: [PATCH 160/321] fix(core): correct linked tickets hyperlink address ref: #250 #96 #93 #95 #90 #270 --- app/core/models/ticket/ticket.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 11ad5120..e7274d41 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -753,6 +753,9 @@ class Ticket( if related_ticket.to_ticket_id_id == self.id: + id = related_ticket.from_ticket_id.id + + if str(related_ticket.get_how_related_display()).lower() == 'blocks': how_related = 'blocked by' @@ -762,10 +765,13 @@ class Ticket( how_related = 'blocks' + elif related_ticket.from_ticket_id_id == self.id: + + id = related_ticket.to_ticket_id.id related_tickets += [ { - 'id': related_ticket.id, + 'id': id, 'type': related_ticket.to_ticket_id.get_ticket_type_display().lower(), 'title': ticket_title, 'how_related': how_related.replace(' ', '_'), From 56c3b9d7de4a67a0b1c0ef07ef2b46eb445217d6 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 13:09:54 +0930 Subject: [PATCH 161/321] feat(core): Add project task link for related project task ref: #250 #96 #93 #95 #90 #270 --- app/core/models/ticket/ticket.py | 14 ++++++++++++-- app/core/templates/core/ticket.html.j2 | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index e7274d41..2cc32e90 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -750,11 +750,15 @@ class Ticket( how_related:str = str(related_ticket.get_how_related_display()).lower() ticket_title: str = related_ticket.to_ticket_id.title + project: int = 0 if related_ticket.to_ticket_id_id == self.id: id = related_ticket.from_ticket_id.id + if related_ticket.from_ticket_id.project: + + project = related_ticket.from_ticket_id.project if str(related_ticket.get_how_related_display()).lower() == 'blocks': @@ -769,13 +773,19 @@ class Ticket( id = related_ticket.to_ticket_id.id + if related_ticket.to_ticket_id.project: + + project = related_ticket.to_ticket_id.project + + related_tickets += [ { 'id': id, - 'type': related_ticket.to_ticket_id.get_ticket_type_display().lower(), + 'type': related_ticket.to_ticket_id.get_ticket_type_display().lower().replace(' ', '_'), 'title': ticket_title, 'how_related': how_related.replace(' ', '_'), - 'icon_filename': str('icons/ticket/ticket_' + how_related.replace(' ', '_') + '.svg') + 'icon_filename': str('icons/ticket/ticket_' + how_related.replace(' ', '_') + '.svg'), + 'project': project, } ] diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index 37273471..fb87fbd1 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -56,6 +56,8 @@ {% url 'ITIM:_ticket_incident_view' ticket_type=related_ticket.type pk=related_ticket.id %} {% elif related_ticket.type == 'problem' %} {% url 'ITIM:_ticket_problem_view' ticket_type=related_ticket.type pk=related_ticket.id %} + {% elif related_ticket.type == 'project_task' %} + {% url 'Project Management:_project_task_view' project_id=related_ticket.project.id ticket_type=related_ticket.type pk=related_ticket.id %} {% endif %} ">{{ related_ticket.title }}
From 34f2d4c4d470723db6e806573476c21dc3e51730 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 13:18:27 +0930 Subject: [PATCH 162/321] chore(core): remove model history link not required as the history is saved as action comments ref: #250 #96 #93 #95 #90 #270 --- app/core/views/ticket.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index 8667f9ea..69544532 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -251,9 +251,6 @@ class View(ChangeView): context['ticket_type'] = self.kwargs['ticket_type'] - context['model_pk'] = self.kwargs['pk'] - context['model_name'] = self.model._meta.verbose_name.replace(' ', '') - # context['model_delete_url'] = reverse('ITAM:_device_delete', args=(self.kwargs['pk'],)) context['edit_url'] = reverse('Assistance:_ticket_request_change', args=(self.kwargs['ticket_type'], self.kwargs['pk'])) #/assistance/ticket/{{ ticket_type }}/{{ ticket.id }} From 58e2b9f7f51f9199962df079aa6ca108d9139150 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 13:50:17 +0930 Subject: [PATCH 163/321] feat(core): Render linked tickets the same as the rendered markdown link ref: #250 #96 #93 #95 #90 #270 --- app/core/models/ticket/ticket.py | 12 ++++++++++++ app/core/templates/core/ticket.html.j2 | 13 +------------ app/core/tests/unit/ticket/test_ticket_common.py | 16 +++++++++++----- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 2cc32e90..a33f848e 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -756,10 +756,16 @@ class Ticket( id = related_ticket.from_ticket_id.id + if related_ticket.from_ticket_id.project: project = related_ticket.from_ticket_id.project + + if related_ticket.from_ticket_id.status: + + status:str = related_ticket.from_ticket_id.get_status_display() + if str(related_ticket.get_how_related_display()).lower() == 'blocks': how_related = 'blocked by' @@ -769,6 +775,7 @@ class Ticket( how_related = 'blocks' + elif related_ticket.from_ticket_id_id == self.id: id = related_ticket.to_ticket_id.id @@ -777,6 +784,10 @@ class Ticket( project = related_ticket.to_ticket_id.project + if related_ticket.to_ticket_id.status: + + status:str = related_ticket.to_ticket_id.get_status_display() + related_tickets += [ { @@ -786,6 +797,7 @@ class Ticket( 'how_related': how_related.replace(' ', '_'), 'icon_filename': str('icons/ticket/ticket_' + how_related.replace(' ', '_') + '.svg'), 'project': project, + 'status': str(status).lower(), } ] diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index fb87fbd1..cf76d445 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -48,18 +48,7 @@ {% elif related_ticket.how_related == 'related' %} Related to {% endif %} - {{ related_ticket.title }} + {% include 'core/ticket/renderers/ticket_link.html.j2' with id=related_ticket.id name=related_ticket.title ticket_type=related_ticket.type ticket_status=related_ticket.status project_id=related_ticket.project %} {% endfor %} diff --git a/app/core/tests/unit/ticket/test_ticket_common.py b/app/core/tests/unit/ticket/test_ticket_common.py index 3a336da5..9c4138e6 100644 --- a/app/core/tests/unit/ticket/test_ticket_common.py +++ b/app/core/tests/unit/ticket/test_ticket_common.py @@ -12,14 +12,17 @@ class TicketCommon( TestCase ): - def text_ticket_field_type_opened_by(self): + + @pytest.mark.skip(reason='to write') + def test_ticket_field_type_opened_by(self): """Ensure field is of a certain type opened_by_field must be of type int """ pass - def text_ticket_field_value_not_null_opened_by(self): + @pytest.mark.skip(reason='to write') + def test_ticket_field_value_not_null_opened_by(self): """Ensure field is not null opened_by_field must be set and not null @@ -27,7 +30,8 @@ class TicketCommon( pass - def text_ticket_field_value_auto_set_opened_by(self): + @pytest.mark.skip(reason='to write') + def test_ticket_field_value_auto_set_opened_by(self): """Ensure field is auto set within code opened_by_field must be set by code with non-tech user not being able to change @@ -35,7 +39,8 @@ class TicketCommon( pass - def text_ticket_field_value_tech_set_opened_by(self): + @pytest.mark.skip(reason='to write') + def test_ticket_field_value_tech_set_opened_by(self): """Ensure field can be set by a technician opened_by_field can be set by a technician @@ -44,7 +49,8 @@ class TicketCommon( - def text_ticket_type_fields(self): + @pytest.mark.skip(reason='to write') + def test_ticket_type_fields(self): """Placeholder test following tests to be written: From 26c985e683bc2cd8a0da6b226d80c8690d6dc058 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 14:07:59 +0930 Subject: [PATCH 164/321] feat(core): Allow super-user to edit ticket comment source ref: #250 #96 #93 #95 #90 #270 --- app/core/forms/ticket_comment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 91c6df23..f620b5db 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -69,7 +69,7 @@ class CommentForm( 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 not self._has_triage_permission: + if (not self._has_import_permission or not self._has_triage_permission) and not request.user.is_superuser: self.fields['source'].initial = TicketComment.CommentSource.HELPDESK self.fields['source'].widget = self.fields['source'].hidden_widget() From b78e2adb09678782d8112ba0d92307837fbb65ec Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 15:19:03 +0930 Subject: [PATCH 165/321] fix(core): Redirect to correct url for itim tickets after adding comment ref: #250 #96 #93 #95 #90 #270 --- app/core/views/ticket_comment.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/core/views/ticket_comment.py b/app/core/views/ticket_comment.py index 5a767377..72e6dbb1 100644 --- a/app/core/views/ticket_comment.py +++ b/app/core/views/ticket_comment.py @@ -82,7 +82,10 @@ class Add(AddView): return reverse('Project Management:_project_task_view', args=(self.object.ticket.project.id, self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) - return f"/ticket/" + return reverse( + 'ITIM:_ticket_' + self.kwargs['ticket_type'] + '_view', + kwargs={'ticket_type': self.kwargs['ticket_type'], 'pk': self.kwargs['ticket_id']}, + ) def get_context_data(self, **kwargs): From 68785ef6c065f355919292c7c4ec30f6cf39e84c Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 15:20:21 +0930 Subject: [PATCH 166/321] fix(core): Generate the correct comment urls for tickets ref: #250 #96 #93 #95 #90 #270 --- .../templates/core/ticket/comment.html.j2 | 38 ++++++++++++++++--- .../core/ticket/comment/comment.html.j2 | 6 +-- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/core/templates/core/ticket/comment.html.j2 b/app/core/templates/core/ticket/comment.html.j2 index 5194eb4f..01dcc4ca 100644 --- a/app/core/templates/core/ticket/comment.html.j2 +++ b/app/core/templates/core/ticket/comment.html.j2 @@ -1,5 +1,18 @@ +{% 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 %} +
    @@ -28,8 +41,8 @@ {% endif %}
    - - + +
    @@ -41,11 +54,24 @@
+ + {% 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:_ticket_comment_request_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 %} - - - - + + + + +
diff --git a/app/core/templates/core/ticket/comment/comment.html.j2 b/app/core/templates/core/ticket/comment/comment.html.j2 index 1d8ab156..3beec0c7 100644 --- a/app/core/templates/core/ticket/comment/comment.html.j2 +++ b/app/core/templates/core/ticket/comment/comment.html.j2 @@ -26,13 +26,13 @@
{%if not comment.parent_id %} - + {% include 'icons/ticket/reply.svg' %} - + {% include 'icons/ticket/task.svg' %} - + {% include 'icons/ticket/notification.svg' %} {% endif %} From e87bbe9ed832c4b2a0cc6803b01eb97dee690de1 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 15:20:54 +0930 Subject: [PATCH 167/321] fix(core): Generate the correct edit url for tickets ref: #250 #96 #93 #95 #90 #270 --- app/core/views/ticket.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index 69544532..9b13e24d 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -253,7 +253,39 @@ class View(ChangeView): # context['model_delete_url'] = reverse('ITAM:_device_delete', args=(self.kwargs['pk'],)) - context['edit_url'] = reverse('Assistance:_ticket_request_change', args=(self.kwargs['ticket_type'], self.kwargs['pk'])) #/assistance/ticket/{{ ticket_type }}/{{ ticket.id }} + url_kwargs = { 'ticket_type': self.kwargs['ticket_type'], 'pk': self.kwargs['pk']} + + if self.kwargs['ticket_type'] == 'request': + + path = 'Assistance:_ticket_request_change' + + elif self.kwargs['ticket_type'] == 'project_task': + + path = 'Project Management:_project_task_view' + + url_kwargs = { 'project_id': self.object.project.id,'ticket_type': self.kwargs['ticket_type'], 'pk': self.kwargs['pk']} + + else: + + comment_path = 'ITIM:_ticket_comment_' + self.kwargs['ticket_type'] + + if self.kwargs['ticket_type'] == 'change': + + path = 'ITIM:_ticket_change_change' + + elif self.kwargs['ticket_type'] == 'incident': + + path = 'ITIM:_ticket_incident_change' + + elif self.kwargs['ticket_type'] == 'problem': + + path = 'ITIM:_ticket_problem_change' + + context['edit_url'] = reverse( + path, + kwargs = url_kwargs, + ) # /assistance/ticket/{{ ticket_type }}/{{ ticket.id }} + context['content_title'] = self.object.title From bc2f30ac9be77109bbabba1c3c4beeca2d96a081 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 15:21:19 +0930 Subject: [PATCH 168/321] feat(core): Add organization column to ticket pages ref: #250 #96 #93 #95 #90 #270 --- app/core/templates/core/ticket/index.html.j2 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/core/templates/core/ticket/index.html.j2 b/app/core/templates/core/ticket/index.html.j2 index 757f6ed5..a1a2823c 100644 --- a/app/core/templates/core/ticket/index.html.j2 +++ b/app/core/templates/core/ticket/index.html.j2 @@ -32,6 +32,7 @@
+ {% for ticket in tickets %} @@ -57,6 +58,7 @@ + {% endfor %} From eb4a58ed01f788929277a4dd99ec18493923e833 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 15:21:55 +0930 Subject: [PATCH 169/321] fix(project_management): correct comment reply url name ref: #14 #270 --- app/project_management/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/project_management/urls.py b/app/project_management/urls.py index 69e6fdcd..3fb94dcf 100644 --- a/app/project_management/urls.py +++ b/app/project_management/urls.py @@ -21,7 +21,7 @@ urlpatterns = [ path('project////comment/add', ticket_comment.Add.as_view(), name="_project_task_comment_add"), path('project////comment//edit', ticket_comment.Change.as_view(), name="_project_task_comment_change"), - path('project////comment//add', ticket_comment.Add.as_view(), name="_project_task_comment_add"), + path('project////comment//add', ticket_comment.Add.as_view(), name="_project_task_comment_reply_add"), ] From b69d210759db359fb5051b32504cfd4a4d2c52ac Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 15:26:06 +0930 Subject: [PATCH 170/321] chore(core): remove superuser clause ref: #250 #96 #93 #95 #90 #270 --- app/core/forms/ticket_comment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index f620b5db..91c6df23 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -69,7 +69,7 @@ class CommentForm( 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 not self._has_triage_permission) and not request.user.is_superuser: + if not self._has_import_permission or not self._has_triage_permission: self.fields['source'].initial = TicketComment.CommentSource.HELPDESK self.fields['source'].widget = self.fields['source'].hidden_widget() From 200c9d8d8dc6730edc820becb69d8b53af2e3011 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 19:12:05 +0930 Subject: [PATCH 171/321] feat(core): move markdown parser py-markdown -> markdown-it py-markdown was missing a lot of the common/gfm items. ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/models/ticket/markdown.py | 45 ++++++++++++++++++- app/core/templatetags/markdown.py | 10 ++++- docs/projects/centurion_erp/index.md | 2 + .../centurion_erp/user/core/markdown.md | 26 +++++++++++ mkdocs.yml | 2 + requirements.txt | 7 ++- 6 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 docs/projects/centurion_erp/user/core/markdown.md diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index a5b30129..a55463af 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -1,8 +1,17 @@ -import markdown as md import re +from markdown_it import MarkdownIt + +from mdit_py_plugins import admon, 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 + + class TicketMarkdown: """Ticket and Comment markdown functions @@ -10,11 +19,43 @@ class TicketMarkdown: """ + 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 + """ markdown_text = self.ticket_reference(markdown_text) - return md.markdown(markdown_text, extensions=['markdown.extensions.fenced_code', 'codehilite']) + md = ( + MarkdownIt( + config = "commonmark", + options_update={ + 'highlight': self.highlight_func + } + ) + + return md.render(markdown_text) def build_ticket_html(self, match): diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index 54c419e2..5ddf677a 100644 --- a/app/core/templatetags/markdown.py +++ b/app/core/templatetags/markdown.py @@ -1,7 +1,7 @@ from django import template from django.template.defaultfilters import stringfilter -import markdown as md +from core.models.ticket.markdown import TicketMarkdown register = template.Library() @@ -9,7 +9,13 @@ register = template.Library() @register.filter() @stringfilter def markdown(value): - return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) + + if not value: + value = None + + markdown = TicketMarkdown() + + return markdown.render_markdown(value) @register.filter() @stringfilter diff --git a/docs/projects/centurion_erp/index.md b/docs/projects/centurion_erp/index.md index 30e4feb4..d17d2381 100644 --- a/docs/projects/centurion_erp/index.md +++ b/docs/projects/centurion_erp/index.md @@ -45,6 +45,8 @@ Centurion ERP contains the following modules: - History + - [Markdown](./user/core/markdown.md) + - [Multi-Tenant](./development/api/models/access_organization_permission_checking.md#permission-checking) - [Single Sign-On {SSO}](./user/configuration.md#single-sign-on) diff --git a/docs/projects/centurion_erp/user/core/markdown.md b/docs/projects/centurion_erp/user/core/markdown.md new file mode 100644 index 00000000..438393ad --- /dev/null +++ b/docs/projects/centurion_erp/user/core/markdown.md @@ -0,0 +1,26 @@ +--- +title: Markdown +description: Markdown Documentation as part of the Core Module for Centurion ERP by No Fuss Computing +date: 2024-06-07 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +All Text fields, that is those that are multi-lined support markdown text. + + +## Features + +- CommonMark Markdown + +- Tables + +- Strikethrough + +- Code highlighting + +- Admonitions + +- Linkify + +- Task Lists \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 23b6182a..189ede16 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -190,6 +190,8 @@ nav: - projects/centurion_erp/user/core/index.md + - projects/centurion_erp/user/core/markdown.md + - projects/centurion_erp/user/core/tickets.md - ITAM: diff --git a/requirements.txt b/requirements.txt index 7099ca45..48290ebe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,8 +18,11 @@ drf-spectacular[sidecar]==0.27.2 django_split_settings==1.3.1 -markdown==3.6 -Pygments +markdown-it-py[plugins]==3.0.0 +markdown-it-py[linkify]==3.0.0 +Pygments==2.18.0 + + celery==5.4.0 django-celery-results==2.5.1 From 00ec5179f9c65e4f1c988a0073d44d890186f39c Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 19:13:02 +0930 Subject: [PATCH 172/321] feat(core): Add linkify extension to markdowm ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/models/ticket/markdown.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index a55463af..ad4af39c 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -51,10 +51,16 @@ class TicketMarkdown: MarkdownIt( config = "commonmark", options_update={ + 'linkify': True, 'highlight': self.highlight_func } ) + .enable([ + 'linkify', + ]) + ) + return md.render(markdown_text) From b86b1fd1ad0d89695c1c051141ca126eafbf7a6f Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 19:13:25 +0930 Subject: [PATCH 173/321] feat(core): Add strikethrough extension to markdowm ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/models/ticket/markdown.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index ad4af39c..6e4ec4ea 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -58,6 +58,7 @@ class TicketMarkdown: .enable([ 'linkify', + 'strikethrough', ]) ) From 14bdc67a4a57d054d9223b4477093ef43ceb8024 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 19:13:35 +0930 Subject: [PATCH 174/321] feat(core): Add table extension to markdowm ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/models/ticket/markdown.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index 6e4ec4ea..29ffc24a 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -59,6 +59,7 @@ class TicketMarkdown: .enable([ 'linkify', 'strikethrough', + 'table', ]) ) From 91aa87d122a64e5d89dae051614b74122414b9b7 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 19:13:58 +0930 Subject: [PATCH 175/321] feat(core): Add admonition plugin to markdowm ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/models/ticket/markdown.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index 29ffc24a..87e08536 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -61,6 +61,8 @@ class TicketMarkdown: 'strikethrough', 'table', ]) + + .use(admon.admon_plugin) ) return md.render(markdown_text) From 411cd5d4a39f216b017f603e795cd281976fad74 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 19:14:07 +0930 Subject: [PATCH 176/321] feat(core): Add footnote plugin to markdowm ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/models/ticket/markdown.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index 87e08536..b3e35232 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -63,6 +63,7 @@ class TicketMarkdown: ]) .use(admon.admon_plugin) + .use(footnote.footnote_plugin) ) return md.render(markdown_text) From bfb7176db357d2fee482630ac048d706198aecc8 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 19:14:22 +0930 Subject: [PATCH 177/321] feat(core): Add task listts plugin to markdowm ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/models/ticket/markdown.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index b3e35232..bbc7860c 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -64,6 +64,7 @@ class TicketMarkdown: .use(admon.admon_plugin) .use(footnote.footnote_plugin) + .use(tasklists.tasklists_plugin) ) return md.render(markdown_text) From 91af43adba98c00a58f50d03649fbbde85ac655a Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 19:14:44 +0930 Subject: [PATCH 178/321] feat(core): Add action comment on title change ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/models/ticket/ticket.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index a33f848e..5106677b 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -850,6 +850,10 @@ class Ticket( comment_field_value = f"changed {field} to {self.get_status_display()}" + if field == 'title': + + comment_field_value = f"Title changed ~~{before[field]}~~ to **{after[field]}**" + if field == 'project_id': comment_field_value = f"changed {field.replace('_id','')} to {self.project}" From cfda7e5e1e53aeff7f2bd1bc8bd0278311dc5acf Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 20:15:37 +0930 Subject: [PATCH 179/321] feat(core): set project ID to match url kwarg ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/forms/ticket.py | 2 ++ app/core/forms/validate_ticket.py | 9 +++------ app/core/views/ticket.py | 23 ++++++++++++++++++++--- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index d3cbac47..d85307ae 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -110,6 +110,8 @@ class TicketForm( self.fields['status'].choices = self.Meta.model.TicketStatus.ProjectTask + self._project: int = kwargs['initial']['project'] + self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROJECT_TASK.value # self.fields['status'].widget = self.fields['status'].hidden_widget() diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index cd48686a..b59c5f30 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -447,9 +447,6 @@ class TicketValidation( def validate_project_task_ticket(self): - # check status - - # check type - - # raise ValidationError('Test to see what it looks like') - pass + self.cleaned_data.update({ + 'project': self._project + }) diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index 9b13e24d..0de0942b 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -38,6 +38,12 @@ class Add(AddView): 'type_ticket': self.kwargs['ticket_type'], }) + if self.kwargs['ticket_type'] == 'project_task': + + initial.update({ + 'project': int(self.kwargs['project_id']) + }) + return initial @@ -106,9 +112,20 @@ class Change(ChangeView): def get_initial(self): - return { + + initial = super().get_initial() + + initial.update({ 'type_ticket': self.kwargs['ticket_type'], - } + }) + + if self.kwargs['ticket_type'] == 'project_task': + + initial.update({ + 'project': int(self.kwargs['project_id']) + }) + + return initial def get_success_url(self, **kwargs): @@ -261,7 +278,7 @@ class View(ChangeView): elif self.kwargs['ticket_type'] == 'project_task': - path = 'Project Management:_project_task_view' + path = 'Project Management:_project_task_change' url_kwargs = { 'project_id': self.object.project.id,'ticket_type': self.kwargs['ticket_type'], 'pk': self.kwargs['pk']} From c0ac09b928dd889405bb0ee75ec4d9105933a369 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 20:16:05 +0930 Subject: [PATCH 180/321] fix(core): correct project task comment buttons ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/templates/core/ticket/comment.html.j2 | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/core/templates/core/ticket/comment.html.j2 b/app/core/templates/core/ticket/comment.html.j2 index 01dcc4ca..dc356049 100644 --- a/app/core/templates/core/ticket/comment.html.j2 +++ b/app/core/templates/core/ticket/comment.html.j2 @@ -41,8 +41,14 @@ {% endif %}
+ + {% if ticket_type == 'project_task'%} + + + {% else %} + {% endif %}
@@ -60,19 +66,24 @@ {% elif ticket_type == 'incident'%} {% translate 'ITIM:_ticket_comment_incident_add' as comment_url %} {% elif ticket_type == 'project_task'%} - {% translate 'Project Management:_ticket_comment_request_add' as comment_url %} + {% 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'%} + + + + + {% else %} - - + {% endif %} From 122216dbe4785d6e8558f9cbbf535e7840dc7ff9 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 21:15:21 +0930 Subject: [PATCH 181/321] chore(core): remove unused markdown import ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/views/celery_log.py | 2 -- app/core/views/history.py | 2 -- app/core/views/related_ticket.py | 2 -- app/core/views/ticket.py | 2 -- app/core/views/ticket_comment.py | 2 -- app/itam/views/device.py | 1 - app/project_management/views/project.py | 1 - 7 files changed, 12 deletions(-) diff --git a/app/core/views/celery_log.py b/app/core/views/celery_log.py index 76fb276e..9206ab2e 100644 --- a/app/core/views/celery_log.py +++ b/app/core/views/celery_log.py @@ -1,5 +1,3 @@ -import markdown - from django.views import generic from access.mixin import OrganizationPermission diff --git a/app/core/views/history.py b/app/core/views/history.py index d8f68be4..f58ffb6a 100644 --- a/app/core/views/history.py +++ b/app/core/views/history.py @@ -1,5 +1,3 @@ -import markdown - from django.db.models import Q from django.http import HttpResponseRedirect from django.shortcuts import redirect, render diff --git a/app/core/views/related_ticket.py b/app/core/views/related_ticket.py index 36bd74d4..f14d4b13 100644 --- a/app/core/views/related_ticket.py +++ b/app/core/views/related_ticket.py @@ -1,5 +1,3 @@ -import markdown - from django.urls import reverse from django.views import generic diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index 0de0942b..f3b0f746 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -1,5 +1,3 @@ -import markdown - from django.http import Http404 from django.urls import reverse from django.views import generic diff --git a/app/core/views/ticket_comment.py b/app/core/views/ticket_comment.py index 72e6dbb1..0e55d1ba 100644 --- a/app/core/views/ticket_comment.py +++ b/app/core/views/ticket_comment.py @@ -1,5 +1,3 @@ -import markdown - from django.urls import reverse from django.views import generic diff --git a/app/itam/views/device.py b/app/itam/views/device.py index f5b2e9f0..1003560d 100644 --- a/app/itam/views/device.py +++ b/app/itam/views/device.py @@ -1,5 +1,4 @@ import json -import markdown from django.contrib.auth import decorators as auth_decorator from django.core.paginator import Paginator diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index d9361fa0..cdde66b5 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -1,5 +1,4 @@ import json -import markdown from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.paginator import Paginator From 008f8c1554542af9fde4d5a36e4d9829e146062b Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 21:20:59 +0930 Subject: [PATCH 182/321] feat(project_management): Validate project task has project set ref: #14 #96 #93 #95 #90 #250 #270 --- app/core/forms/ticket.py | 3 +++ app/core/forms/validate_ticket.py | 11 ++++++++--- app/core/tests/unit/ticket/test_ticket_permission.py | 10 ++++++++++ app/core/views/ticket.py | 4 ++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index d85307ae..d9022a33 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -112,6 +112,9 @@ class TicketForm( 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['status'].widget = self.fields['status'].hidden_widget() diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index b59c5f30..b517d69e 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -447,6 +447,11 @@ class TicketValidation( def validate_project_task_ticket(self): - self.cleaned_data.update({ - 'project': self._project - }) + if hasattr(self,'_project'): + self.cleaned_data.update({ + 'project': self._project + }) + + if self.cleaned_data['project'] is None: + + raise ValidationError('A project task requires a project') diff --git a/app/core/tests/unit/ticket/test_ticket_permission.py b/app/core/tests/unit/ticket/test_ticket_permission.py index 8963f915..d5d40032 100644 --- a/app/core/tests/unit/ticket/test_ticket_permission.py +++ b/app/core/tests/unit/ticket/test_ticket_permission.py @@ -950,6 +950,16 @@ class ProjectTaskPermissions(ProjectTicketPermissions, TestCase): super().setUpTestData() + self.item = self.model.objects.create( + organization = self.organization, + title = 'Amended ' + self.ticket_type + ' ticket', + description = 'the ticket body', + ticket_type = int(Ticket.TicketType.REQUEST.value), + opened_by = self.add_user, + status = int(Ticket.TicketStatus.All.NEW.value), + project = self.project + ) + self.url_add_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type} self.url_change_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type, 'pk': self.item.id} diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index f3b0f746..718ed06d 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -64,7 +64,7 @@ class Add(AddView): elif self.kwargs['ticket_type'] == 'project_task': - return reverse('Project Management:_project_task_view', args=(self.object.project.id, self.kwargs['ticket_type'],self.object.id,)) + return reverse('Project Management:_project_task_view', args=(self.kwargs['project_id'], self.kwargs['ticket_type'],self.object.id,)) else: @@ -278,7 +278,7 @@ class View(ChangeView): path = 'Project Management:_project_task_change' - url_kwargs = { 'project_id': self.object.project.id,'ticket_type': self.kwargs['ticket_type'], 'pk': self.kwargs['pk']} + url_kwargs = { 'project_id': self.kwargs['project_id'],'ticket_type': self.kwargs['ticket_type'], 'pk': self.kwargs['pk']} else: From c7f69ad7c114210a712a4047e235ddd1a26e2d5f Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 22:19:23 +0930 Subject: [PATCH 183/321] feat(core): Add admonition style ref: #14 #96 #93 #95 #90 #250 #270 closes #272 --- app/project-static/ticketing.css | 85 +++++++++++++++++- .../centurion_erp/user/core/markdown.md | 29 +++++- .../user/images/admonition-example.png | Bin 0 -> 22186 bytes 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 docs/projects/centurion_erp/user/images/admonition-example.png diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 6b6bb6bd..04cf84ce 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -653,12 +653,93 @@ } -#ticket-content #markdown p { + +#ticket-content #markdown p:not(div.admonition p){ background-color: inherit; - font-size: 14px; + font-size: inherit; line-height: 25px; padding: 0px; margin: 0px; text-align: left; } + +#markdown div.admonition { + border-width: 1px; + border-style: solid; + border-radius: 10px; + font-size: inherit; + margin: 10px; + padding: 0px; +} + +#markdown div.admonition p.admonition-title { + border-top-left-radius: 10px; + border-top-right-radius: 10px; + font-size: inherit; + margin: 0px; + padding: 2px 10px 2px 10px; + width: 100%; +} + +#markdown div.admonition p:not(p.admonition-title) { + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + font-size: inherit; + padding: 10px; + margin: 0px; + width: 100%; +} + +#markdown div.admonition.note { + background-color: #b6d3f5; + border-color:#278cff; +} + +#markdown div.admonition.note p.admonition-title { + background-color: #1e82f570; + color: #fff; +} + +#markdown div.admonition.tip { + background-color: #bbf5b6; + border-color:#39ff27; +} + +#markdown div.admonition.tip p.admonition-title { + background-color: #37f51e70; + color: #fff; +} + +#markdown div.admonition.warning { + background-color: #f5d8b6; + border-color:#ffa127; +} + +#markdown div.admonition.warning p.admonition-title { + background-color: #f5911e70; + color: #fff; +} + +#markdown div.admonition.danger { + background-color: #f5b6b6; + border-color:#ff2727; +} + +#markdown div.admonition.danger p.admonition-title { + background-color: #f51e1e70; + color: #fff; +} + +#markdown div.admonition.quote { + background-color: #d3d3d3; + border-color:#868686; +} + +#markdown div.admonition.quote p.admonition-title { + background-color: #81818170; + color: #fff; +} +#markdown div.admonition.quote p:not(p.admonition-title) { + font-style: italic; +} diff --git a/docs/projects/centurion_erp/user/core/markdown.md b/docs/projects/centurion_erp/user/core/markdown.md index 438393ad..0f3ab3cf 100644 --- a/docs/projects/centurion_erp/user/core/markdown.md +++ b/docs/projects/centurion_erp/user/core/markdown.md @@ -23,4 +23,31 @@ All Text fields, that is those that are multi-lined support markdown text. - Linkify -- Task Lists \ No newline at end of file +- Task Lists + + +## Admonitions + + +![admonition example](../images/admonition-example.png) + +declare with: + +``` md + +!!! "" + text goes here + +``` + +Available admonition types are: + +- note + +- tip + +- warning + +- danger + +- quote diff --git a/docs/projects/centurion_erp/user/images/admonition-example.png b/docs/projects/centurion_erp/user/images/admonition-example.png new file mode 100644 index 0000000000000000000000000000000000000000..5301e1694831328aa74ce37abeeaa56c46151a6c GIT binary patch literal 22186 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 literal 0 HcmV?d00001 From 63146aa41c6f5ddea2d7e08f9c9476c9a5a63f9a Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 22:31:55 +0930 Subject: [PATCH 184/321] feat(core): remove project field from being editable when creating project task ref: #14 #270 --- app/core/models/ticket/ticket.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 5106677b..de4afbf5 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -630,7 +630,6 @@ class Ticket( 'title', 'description', 'opened_by', - 'project', 'ticket_type', 'assigned_users', 'assigned_teams', @@ -639,6 +638,7 @@ class Ticket( common_itsm_fields: list(str()) = common_fields + [ 'status', 'urgency', + 'project', 'priority', 'impact', 'subscribed_teams', @@ -675,7 +675,13 @@ class Ticket( ] - fields_project_task: list(str()) = common_itsm_fields + [ + fields_project_task: list(str()) = common_fields + [ + 'status', + 'urgency', + 'priority', + 'impact', + 'subscribed_teams', + 'subscribed_users', 'planned_start_date', 'planned_finish_date', 'real_start_date', From 948713d13dcd29ddebecb2bc164f8f4941375c79 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 22:45:58 +0930 Subject: [PATCH 185/321] test(core): correct project tests for triage user ref: #14 #96 #93 #95 #90 #250 #270 --- .../field_based_permissions.py | 118 ++++++++++-------- 1 file changed, 64 insertions(+), 54 deletions(-) diff --git a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py index 57f8f7e6..65fa7c5f 100644 --- a/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py +++ b/app/core/tests/unit/ticket/ticket_permission/field_based_permissions.py @@ -2114,60 +2114,6 @@ class TicketFieldPermissionsTriageUser: assert response.status_code == 200 - def test_field_permission_project_triage_user_allowed(self): - """ Check correct permission for add - - A standard user should not be able to edit field project. - """ - - field_name: str = 'project' - field_value = self.project.id - - - client = Client(raise_request_exception=True) - url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) - - client.force_login(self.triage_user) - - data = self.change_data.copy() - - data[field_name] = field_value - - response = client.post( - url, - data=data - ) - - assert response.status_code == 200 - - - def test_field_permission_subscribed_users_triage_user_allowed(self): - """ Check correct permission for add - - A standard user should not be able to edit field subscribed_users. - """ - - field_name: str = 'subscribed_users' - field_value = [1] - - - client = Client(raise_request_exception=True) - url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) - - client.force_login(self.triage_user) - - data = self.change_data.copy() - - data[field_name] = field_value - - response = client.post( - url, - data=data - ) - - assert response.status_code == 200 - - def test_field_permission_subscribed_teams_triage_user_allowed(self): """ Check correct permission for add @@ -2241,6 +2187,33 @@ class ITSMTicketFieldPermissionsTriageUser( ): + def test_field_permission_project_triage_user_allowed(self): + """ Check correct permission for add + + A standard user should not be able to edit field project. + """ + + field_name: str = 'project' + field_value = self.project.id + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + response = client.post( + url, + data=data + ) + + assert response.status_code == 200 + + def test_field_permission_planned_start_date_triage_user_denied(self): """ Check correct permission for add @@ -2395,6 +2368,43 @@ class ProjectTicketFieldPermissionsTriageUser( ): + def test_field_permission_project_triage_user_denied(self): + """ Check correct permission for add + + A standard user should not be able to edit field project. + """ + + field_name: str = 'project' + field_value = self.project.id + + + client = Client(raise_request_exception=True) + url = reverse(self.app_namespace + ':' + self.url_name_change, kwargs=self.url_change_kwargs) + + client.force_login(self.triage_user) + + data = self.change_data.copy() + + data[field_name] = field_value + + try: + + response = client.post( + url, + data=data + ) + + assert False, 'a ValidationError exception should have been thrown' + + except ValidationError as exception: + + assert exception.code == 'cant_edit_field_' + field_name + + except Exception as exception: + + assert False, f"reason: {exception}" + + def test_field_permission_planned_start_date_triage_user_allowed(self): """ Check correct permission for add From 97874b73f671a3ecc2397bd8a24a649abf9524d8 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Sep 2024 22:51:58 +0930 Subject: [PATCH 186/321] docs: correct date ref: #270 --- docs/projects/centurion_erp/user/core/markdown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/projects/centurion_erp/user/core/markdown.md b/docs/projects/centurion_erp/user/core/markdown.md index 0f3ab3cf..0898448e 100644 --- a/docs/projects/centurion_erp/user/core/markdown.md +++ b/docs/projects/centurion_erp/user/core/markdown.md @@ -1,7 +1,7 @@ --- title: Markdown description: Markdown Documentation as part of the Core Module for Centurion ERP by No Fuss Computing -date: 2024-06-07 +date: 2024-09-11 template: project.html about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp --- From a8b21d7c74e995504788402e2ece1658c923e632 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 12 Sep 2024 00:04:51 +0930 Subject: [PATCH 187/321] feat(core): correct markdown formatting for KB articles ref: #270 --- app/project-static/content.css | 4 +- app/project-static/ticketing.css | 35 ++++++++++++++---- app/templates/content/field.html.j2 | 9 ++++- .../centurion_erp/user/core/markdown.md | 2 + .../user/images/admonition-example.png | Bin 22186 -> 26961 bytes 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/app/project-static/content.css b/app/project-static/content.css index e1827120..36921655 100644 --- a/app/project-static/content.css +++ b/app/project-static/content.css @@ -43,7 +43,7 @@ refactored } -#content-body form#dynamic-form div { +#content-body form#dynamic-form div:not(#markdown) { align-items: center; display: block; line-height: inherit; @@ -199,7 +199,7 @@ main section a:visited { } -article div { +article div:not(.codehilite) { /* background-color: #ff0000; */ justify-content: center; width: 100%; diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 04cf84ce..3a2d3bc5 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -581,8 +581,20 @@ margin: 0px 5px 0px 5px; } +div#markdown { + justify-content: center; + display: block; + text-align: left; + padding: 10px; +} +#markdown .codehilite { + display: block; + width: 100%; + word-wrap: break-word; + white-space: pre-wrap; +} -#ticket-content #markdown h1 { +#markdown h1 { background-color: inherit; color: inherit; font-size: 24px; @@ -594,7 +606,7 @@ } -#ticket-content #markdown h2 { +#markdown h2 { background-color: inherit; color: inherit; font-size: 20px; @@ -606,7 +618,7 @@ } -#ticket-content #markdown h3 { +#markdown h3 { background-color: inherit; color: inherit; font-size: 18px; @@ -618,7 +630,7 @@ } -#ticket-content #markdown h4 { +#markdown h4 { background-color: inherit; color: #000; font-size: 16px; @@ -630,7 +642,7 @@ } -#ticket-content #markdown h5 { +#markdown h5 { background-color: inherit; color: #000; font-size: 14px; @@ -642,7 +654,7 @@ } -#ticket-content #markdown li { +#markdown li { background-color: inherit; font-size: 14px; line-height: 25px; @@ -654,7 +666,7 @@ -#ticket-content #markdown p:not(div.admonition p){ +#markdown p:not(div.admonition p){ background-color: inherit; font-size: inherit; line-height: 25px; @@ -691,6 +703,15 @@ width: 100%; } +#markdown div.admonition.info { + background-color: #b6f5dd; + border-color:#27ffbe; +} + +#markdown div.admonition.info p.admonition-title { + background-color: #1ef59170; +} + #markdown div.admonition.note { background-color: #b6d3f5; border-color:#278cff; diff --git a/app/templates/content/field.html.j2 b/app/templates/content/field.html.j2 index b374f364..81832ae3 100644 --- a/app/templates/content/field.html.j2 +++ b/app/templates/content/field.html.j2 @@ -2,6 +2,11 @@ {% load markdown %} {% load choice_ids %} +{% block additional-stylesheet %} + {% load static %} + +{% endblock additional-stylesheet %} + {% if field.widget_type == 'textarea' or field.label == 'Notes' %} {% if field.name in section.json and field.value %} @@ -17,7 +22,7 @@ {{ field.label }} -
+
{% if field.value %} {{ field.value | markdown | safe }} {% else %} @@ -30,7 +35,7 @@ {% if field.value %} -
{{ field.value | markdown | safe }}
+
{{ field.value | markdown | safe }}
{% else %} diff --git a/docs/projects/centurion_erp/user/core/markdown.md b/docs/projects/centurion_erp/user/core/markdown.md index 0898448e..d71653e0 100644 --- a/docs/projects/centurion_erp/user/core/markdown.md +++ b/docs/projects/centurion_erp/user/core/markdown.md @@ -44,6 +44,8 @@ Available admonition types are: - note +- info + - tip - warning diff --git a/docs/projects/centurion_erp/user/images/admonition-example.png b/docs/projects/centurion_erp/user/images/admonition-example.png index 5301e1694831328aa74ce37abeeaa56c46151a6c..d947bf8d5894403b5a2c59672eef926c1a79204e 100644 GIT binary patch literal 26961 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 A;{X5v literal 22186 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 From c9d05152c97bb9f03674a20f81e7a70d91add4d7 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 12 Sep 2024 00:49:26 +0930 Subject: [PATCH 188/321] feat(core): Add heading anchor plugin to markdown ref: #270 --- app/core/models/ticket/markdown.py | 5 +++-- docs/projects/centurion_erp/user/core/markdown.md | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index bbc7860c..33e3415b 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -2,7 +2,7 @@ import re from markdown_it import MarkdownIt -from mdit_py_plugins import admon, footnote, tasklists +from mdit_py_plugins import admon, anchors, footnote, tasklists from pygments import highlight from pygments.formatters.html import HtmlFormatter @@ -52,7 +52,7 @@ class TicketMarkdown: config = "commonmark", options_update={ 'linkify': True, - 'highlight': self.highlight_func + 'highlight': self.highlight_func, } ) @@ -63,6 +63,7 @@ class TicketMarkdown: ]) .use(admon.admon_plugin) + .use(anchors.anchors_plugin, permalink=True) .use(footnote.footnote_plugin) .use(tasklists.tasklists_plugin) ) diff --git a/docs/projects/centurion_erp/user/core/markdown.md b/docs/projects/centurion_erp/user/core/markdown.md index d71653e0..aaf68c5a 100644 --- a/docs/projects/centurion_erp/user/core/markdown.md +++ b/docs/projects/centurion_erp/user/core/markdown.md @@ -25,6 +25,8 @@ All Text fields, that is those that are multi-lined support markdown text. - Task Lists +- Heading Anchors + ## Admonitions From 0adfd95ced5258cff6e1728f38b11aa0350e9b0a Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 12 Sep 2024 12:10:47 +0930 Subject: [PATCH 189/321] fea(core): Add opened_by user as subscribed to ticket when creating ref: #14 #96 #93 #95 #90 #250 #270 --- app/api/serializers/core/ticket.py | 4 ++++ app/core/tests/unit/ticket/test_ticket.py | 23 ++++++++++++++++++ app/core/views/ticket.py | 29 ++++++++++++++++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/app/api/serializers/core/ticket.py b/app/api/serializers/core/ticket.py index ceb90c54..371610df 100644 --- a/app/api/serializers/core/ticket.py +++ b/app/api/serializers/core/ticket.py @@ -176,4 +176,8 @@ class TicketSerializer( self.validated_data['ticket_type'] = int(self._context['view']._ticket_type_value) + if self.instance is None: + + self.validated_data['subscribed_users'] = self.validated_data['subscribed_users'] + [ self.validated_data['opened_by'] ] + return is_valid diff --git a/app/core/tests/unit/ticket/test_ticket.py b/app/core/tests/unit/ticket/test_ticket.py index d5843e83..468875e0 100644 --- a/app/core/tests/unit/ticket/test_ticket.py +++ b/app/core/tests/unit/ticket/test_ticket.py @@ -24,6 +24,7 @@ class TicketModel( """ + @pytest.mark.skip(reason='test to be written') def test_attribute_duration_ticket_value(self): """Attribute value test @@ -31,4 +32,26 @@ class TicketModel( it's comments. must return total time in seconds """ + pass + + + @pytest.mark.skip(reason='test to be written') + def test_ticket_create_add_opened_by_as_watcher_ui(self): + """New ticket action from UI + + When a new ticket is created, the 'opened_by' user must be added + as a subscribed user. + """ + + pass + + + @pytest.mark.skip(reason='test to be written') + def test_ticket_create_add_opened_by_as_watcher_api(self): + """New ticket action from API + + When a new ticket is created, the 'opened_by' user must be added + as a subscribed user. + """ + pass \ No newline at end of file diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index 718ed06d..e6b1678a 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -46,8 +46,21 @@ class Add(AddView): def form_valid(self, form): - form.instance.is_global = False - return super().form_valid(form) + + created: bool = False + + if form.instance.id is None: + + created = True + + val = super().form_valid(form) + + if created: + + form.instance.subscribed_users.add(form.instance.opened_by) + + return val + def get_form_kwargs(self): @@ -128,7 +141,17 @@ class Change(ChangeView): def get_success_url(self, **kwargs): - return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'], self.kwargs['pk'],)) + if self.kwargs['ticket_type'] == 'request': + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.object.id,)) + + elif self.kwargs['ticket_type'] == 'project_task': + + return reverse('Project Management:_project_task_view', args=(self.kwargs['project_id'], self.kwargs['ticket_type'],self.object.id,)) + + else: + + return reverse('ITIM:_ticket_' + str(self.kwargs['ticket_type']).lower() + '_view', args=(self.kwargs['ticket_type'],self.object.id,)) class Delete(DeleteView): From 212e864db1653ac27b52e6a21667595849473f21 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 12 Sep 2024 12:40:46 +0930 Subject: [PATCH 190/321] feat(core): during markdown render, if ticket ID not found return the tag ref: #270 --- app/core/models/ticket/markdown.py | 36 +++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/app/core/models/ticket/markdown.py b/app/core/models/ticket/markdown.py index 33e3415b..7f518a3a 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/models/ticket/markdown.py @@ -75,31 +75,35 @@ class TicketMarkdown: ticket_id = match.group(1) - if hasattr(self, 'ticket'): + try: + if hasattr(self, 'ticket'): - ticket = self.ticket.__class__.objects.get(pk=ticket_id) + ticket = self.ticket.__class__.objects.get(pk=ticket_id) - else: + else: - ticket = self.__class__.objects.get(pk=ticket_id) + ticket = self.__class__.objects.get(pk=ticket_id) - project_id = str('0') + project_id = str('0') - if ticket.project: + if ticket.project: - project_id = str(ticket.project.id).lower() + 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, - } + 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) + html_link = render_to_string('core/ticket/renderers/ticket_link.html.j2', context) - return str(html_link) + return str(html_link) + except: + + return str('#' + ticket_id) From 51f28a6cf83dd7fdda3393722d3a76239b90c8ce Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 12 Sep 2024 16:16:29 +0930 Subject: [PATCH 191/321] refactor(core): move markdown functions out of ticket model ref: #270 ##271 --- app/core/{models/ticket => lib}/markdown.py | 2 +- .../0005_ticket_relatedtickets_ticketcomment.py | 5 +---- app/core/models/ticket/ticket.py | 8 +------- app/core/models/ticket/ticket_comment.py | 7 ------- app/core/templates/core/ticket.html.j2 | 6 +++--- app/core/templates/core/ticket/comment/comment.html.j2 | 4 ++-- app/core/templatetags/markdown.py | 5 +++-- 7 files changed, 11 insertions(+), 26 deletions(-) rename app/core/{models/ticket => lib}/markdown.py (99%) diff --git a/app/core/models/ticket/markdown.py b/app/core/lib/markdown.py similarity index 99% rename from app/core/models/ticket/markdown.py rename to app/core/lib/markdown.py index 7f518a3a..0785f257 100644 --- a/app/core/models/ticket/markdown.py +++ b/app/core/lib/markdown.py @@ -12,7 +12,7 @@ from django.template.loader import render_to_string -class TicketMarkdown: +class Markdown: """Ticket and Comment markdown functions Intended to be used for all areas of a tickets, projects and comments. diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py index bb8df3e5..ef4a2711 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py @@ -1,8 +1,7 @@ -# Generated by Django 5.0.8 on 2024-09-10 07:30 +# Generated by Django 5.0.8 on 2024-09-12 06:18 import access.fields import access.models -import core.models.ticket.markdown import core.models.ticket.ticket import core.models.ticket.ticket_comment import django.db.models.deletion @@ -57,7 +56,6 @@ class Migration(migrations.Migration): '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')], 'unique_together': {('external_system', 'external_ref')}, }, - bases=(models.Model, core.models.ticket.markdown.TicketMarkdown), ), migrations.CreateModel( name='RelatedTickets', @@ -106,6 +104,5 @@ class Migration(migrations.Migration): 'ordering': ['ticket', 'parent_id'], 'unique_together': {('external_system', 'external_ref')}, }, - bases=(models.Model, core.models.ticket.markdown.TicketMarkdown), ), ] diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index de4afbf5..4709018d 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -8,8 +8,6 @@ from access.models import TenancyObject, Team from core.middleware.get_request import get_request -from .markdown import TicketMarkdown - from project_management.models.projects import Project @@ -120,7 +118,6 @@ class TicketCommonFields(models.Model): class Ticket( TenancyObject, TicketCommonFields, - TicketMarkdown, ): save_model_history: bool = False @@ -734,10 +731,6 @@ class Ticket( return str(duration) - @property - def markdown_description(self) -> str: - - return self.render_markdown(self.description) @property def related_tickets(self) -> list(dict()): @@ -804,6 +797,7 @@ class Ticket( 'icon_filename': str('icons/ticket/ticket_' + how_related.replace(' ', '_') + '.svg'), 'project': project, 'status': str(status).lower(), + 'markdown': str('#' + str(id)) } ] diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 7f8d381e..288f4836 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -5,14 +5,12 @@ from django.forms import ValidationError from access.fields import AutoCreatedField, AutoLastModifiedField from access.models import TenancyObject, Team -from .markdown import TicketMarkdown from .ticket import Ticket class TicketComment( TenancyObject, - TicketMarkdown, ): @@ -392,11 +390,6 @@ class TicketComment( return self.user.username + ' ' + self.body + ' on ' + str(self.created) - @property - def markdown_body(self) -> str: - - return self.render_markdown(self.body) - @property def comment_template_queryset(self): diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index cf76d445..ab07c33f 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -23,7 +23,7 @@
-
{{ ticket.markdown_description | safe }}
+
{{ ticket.description | markdown | safe }}
@@ -40,7 +40,7 @@ {% for related_ticket in ticket.related_tickets %}
idTitle Status Opened Byorganization Created {% include 'core/ticket/badge_ticket_status.html.j2' with ticket_status_text=ticket.get_status_display ticket_status=ticket.get_status_display|ticket_status %} {{ ticket.opened_by }}{{ ticket.organization.name }} {{ ticket.created }}
+ + + + + + + + {% if items %} + {% for category in items %} + + + + + + + + {% endfor %} + {% else %} + + {% endif%} +
NameOrganizationcreatedmodified 
{{ category.name }}{{ category.organization }}{{ category.created }}{{ category.modified }} 
Nothing Found
+ +{% endblock %} diff --git a/app/core/templates/core/ticket_category.html.j2 b/app/core/templates/core/ticket_category.html.j2 new file mode 100644 index 00000000..d2fc524f --- /dev/null +++ b/app/core/templates/core/ticket_category.html.j2 @@ -0,0 +1,12 @@ +{% extends 'detail.html.j2' %} + + +{% block tabs %} + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.details %} + +
+ +{% endblock %} diff --git a/app/core/views/ticket_categories.py b/app/core/views/ticket_categories.py new file mode 100644 index 00000000..f0a3ac5c --- /dev/null +++ b/app/core/views/ticket_categories.py @@ -0,0 +1,168 @@ +from django.urls import reverse + +from core.forms.comment import AddNoteForm +from core.forms.ticket_categories import DetailForm, TicketCategory, TicketCategoryForm + +from core.models.notes import Notes +from core.views.common import AddView, ChangeView, DeleteView, IndexView + + + +class Add(AddView): + + form_class = TicketCategoryForm + + model = TicketCategory + + permission_required = [ + 'core.add_ticketcategory', + ] + + + def get_initial(self): + + initial = super().get_initial() + + if 'pk' in self.kwargs: + + if self.kwargs['pk']: + + initial.update({'parent': self.kwargs['pk']}) + + self.model.parent.field.hidden = True + + return initial + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_ticket_categories') + + + +class Change(ChangeView): + + form_class = TicketCategoryForm + + model = TicketCategory + + permission_required = [ + 'core.change_ticketcategory', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = str(self.object) + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_ticket_category_view', args=(self.kwargs['pk'],)) + + + +class Delete(DeleteView): + + model = TicketCategory + + permission_required = [ + 'itim.delete_ticketcategory', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + str(self.object) + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_ticket_categories') + + + +class Index(IndexView): + + context_object_name = "items" + + model = TicketCategory + + paginate_by = 10 + + permission_required = [ + 'core.view_ticketcategory' + ] + + template_name = 'core/index_ticket_categories.html.j2' + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['model_docs_path'] = self.model._meta.app_label + '/' + self.model._meta.model_name + + return context + + + +class View(ChangeView): + + context_object_name = "ticket_categories" + + form_class = DetailForm + + model = TicketCategory + + permission_required = [ + 'core.view_ticketcategory', + ] + + template_name = 'core/ticket_category.html.j2' + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['notes_form'] = AddNoteForm(prefix='note') + context['notes'] = Notes.objects.filter(service=self.kwargs['pk']) + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.model_name + + context['model_delete_url'] = reverse('Settings:_ticket_category_delete', kwargs={'pk': self.kwargs['pk']}) + + + context['content_title'] = self.object.name + + return context + + + # def post(self, request, *args, **kwargs): + + # item = Cluster.objects.get(pk=self.kwargs['pk']) + + # notes = AddNoteForm(request.POST, prefix='note') + + # if notes.is_bound and notes.is_valid() and notes.instance.note != '': + + # notes.instance.service = item + + # notes.instance.organization = item.organization + + # notes.save() + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_ticket_category_view', kwargs={'pk': self.kwargs['pk']}) diff --git a/app/settings/templates/settings/home.html.j2 b/app/settings/templates/settings/home.html.j2 index fda2e5af..b6ec69cf 100644 --- a/app/settings/templates/settings/home.html.j2 +++ b/app/settings/templates/settings/home.html.j2 @@ -50,6 +50,13 @@ div#content article h3 { +
+

ITAM

    diff --git a/app/settings/urls.py b/app/settings/urls.py index 7f266131..540e496e 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -2,7 +2,7 @@ from django.urls import path from assistance.views import knowledge_base_category -from core.views import celery_log +from core.views import celery_log, ticket_categories from settings.views import app_settings, home, device_models, device_types, external_link, manufacturer, software_categories @@ -65,6 +65,12 @@ urlpatterns = [ path("software_category//edit", software_category.Change.as_view(), name="_software_category_change"), path("software_category//delete", software_category.Delete.as_view(), name="_software_category_delete"), + path("ticket_categories", ticket_categories.Index.as_view(), name="_ticket_categories"), + path("ticket_categories/", ticket_categories.View.as_view(), name="_ticket_category_view"), + path("ticket_categories/add", ticket_categories.Add.as_view(), name="_ticket_category_add"), + path("ticket_categories//edit", ticket_categories.Change.as_view(), name="_ticket_category_change"), + path("ticket_categories//delete", ticket_categories.Delete.as_view(), name="_ticket_category_delete"), + path("task_results", celery_log.Index.as_view(), name="_task_results"), path("task_result/", celery_log.View.as_view(), name="_task_result_view"), diff --git a/docs/projects/centurion_erp/user/core/ticketcategory.md b/docs/projects/centurion_erp/user/core/ticketcategory.md new file mode 100644 index 00000000..2c24d350 --- /dev/null +++ b/docs/projects/centurion_erp/user/core/ticketcategory.md @@ -0,0 +1,44 @@ +--- +title: Ticket Categories +description: Ticket Categories Documentation as part of the Core Module for Centurion ERP by No Fuss Computing +date: 2024-09-13 +template: project.html +about: https://github.com/nofusscomputing/centurion_erp +--- + +Ticket categories enables you to sort tickets to aid in reporting. + + +## Fields + +- parent + + The parent category. This field enables nesting of categories + +- name + + The name for this category + +- [runbook](../assistance/knowledge_base.md) + + The runbook for this category + +- change + + Should this category be available for change tickets + +- incident + + Should this category be available for incident tickets + +- problem + + Should this category be available for problem tickets + +- project task + + Should this category be available for project task + +- request + + Should this category be available for request tickets diff --git a/mkdocs.yml b/mkdocs.yml index 189ede16..2c193125 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -194,6 +194,8 @@ nav: - projects/centurion_erp/user/core/tickets.md + - projects/centurion_erp/user/core/ticketcategory.md + - ITAM: - projects/centurion_erp/user/itam/index.md From 09bb2d8e277a5f3b81c9f0f3afc95e03a97e6ea9 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 10:43:04 +0930 Subject: [PATCH 197/321] feat(core): Addpage titles to view abstract classes ref: #283 --- app/core/views/common.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/core/views/common.py b/app/core/views/common.py index 8046f6f3..a3d6829b 100644 --- a/app/core/views/common.py +++ b/app/core/views/common.py @@ -52,6 +52,15 @@ class AddView(View, generic.CreateView): 'organization': UserSettings.objects.get(user = self.request.user).default_organization } + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = 'New ' + self.model._meta.verbose_name + + return context + class ChangeView(View, generic.UpdateView): @@ -209,3 +218,12 @@ class IndexView(View, generic.ListView): raise MissingAttribute('Model is required for view') super().__init__(**kwargs) + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = self.model._meta.verbose_name_plural + + return context From 5f7d0e474eb89f3beda63d349291f7c897a3613b Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 11:03:40 +0930 Subject: [PATCH 198/321] feat(core): Ability to assign categories to tickets ref: #14 #96 #93 #95 #90 #283 #284 --- app/core/forms/ticket.py | 18 +++++++- app/core/forms/validate_ticket.py | 1 + ...icketcategory_ticket_category_and_more.py} | 37 +++++++++++++++- app/core/migrations/0006_ticketcategory.py | 43 ------------------- app/core/models/ticket/ticket.py | 17 +++++--- app/core/templates/core/ticket.html.j2 | 8 ++++ 6 files changed, 71 insertions(+), 53 deletions(-) rename app/core/migrations/{0005_ticket_relatedtickets_ticketcomment.py => 0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py} (83%) delete mode 100644 app/core/migrations/0006_ticketcategory.py diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index d9022a33..f2a5a71f 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -64,6 +64,10 @@ class TicketForm( 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 @@ -72,6 +76,10 @@ class TicketForm( 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 @@ -80,6 +88,9 @@ class TicketForm( 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 @@ -88,6 +99,9 @@ class TicketForm( 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 @@ -117,7 +131,9 @@ class TicketForm( self.fields['ticket_type'].initial = self.Meta.model.TicketType.PROJECT_TASK.value - # self.fields['status'].widget = self.fields['status'].hidden_widget() + self.fields['category'].queryset = self.fields['category'].queryset.filter( + project_task=True + ) if kwargs['user'].is_superuser: diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index b517d69e..777d367b 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -56,6 +56,7 @@ class TicketValidation( ] triage_fields: list = [ + 'category', 'assigned_users', 'assigned_teams', 'status', diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py similarity index 83% rename from app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py rename to app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py index ef4a2711..22947e5b 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcomment.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-12 06:18 +# Generated by Django 5.0.8 on 2024-09-13 01:31 import access.fields import access.models @@ -14,6 +14,7 @@ class Migration(migrations.Migration): dependencies = [ ('access', '0001_initial'), + ('assistance', '0001_initial'), ('core', '0004_notes_service'), ('project_management', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), @@ -54,7 +55,6 @@ class Migration(migrations.Migration): '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')], - 'unique_together': {('external_system', 'external_ref')}, }, ), migrations.CreateModel( @@ -70,6 +70,39 @@ class Migration(migrations.Migration): 'ordering': ['id'], }, ), + 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')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('parent', 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')), + ('runbook', 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')), + ], + options={ + 'verbose_name': 'Ticket Category', + 'verbose_name_plural': 'Ticket Categories', + 'ordering': ['name'], + }, + ), + 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.AlterUniqueTogether( + name='ticket', + unique_together={('external_system', 'external_ref')}, + ), migrations.CreateModel( name='TicketComment', fields=[ diff --git a/app/core/migrations/0006_ticketcategory.py b/app/core/migrations/0006_ticketcategory.py deleted file mode 100644 index 3dfe26ac..00000000 --- a/app/core/migrations/0006_ticketcategory.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-13 01:10 - -import access.fields -import access.models -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('access', '0001_initial'), - ('assistance', '0001_initial'), - ('core', '0005_ticket_relatedtickets_ticketcomment'), - ] - - 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')), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('parent', 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')), - ('runbook', 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')), - ], - options={ - 'verbose_name': 'Ticket Category', - 'verbose_name_plural': 'Ticket Categories', - 'ordering': ['name'], - }, - ), - ] diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 4709018d..e7d461be 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -7,6 +7,7 @@ from access.fields import AutoCreatedField, AutoLastModifiedField from access.models import TenancyObject, Team from core.middleware.get_request import get_request +from core.models.ticket.ticket_category import TicketCategory from project_management.models.projects import Project @@ -433,13 +434,14 @@ class Ticket( verbose_name = 'Status', ) - # category = models.CharField( - # blank = False, - # help_text = "Category of the Ticket", - # max_length = 50, - # unique = True, - # verbose_name = 'Category', - # ) + category = models.ForeignKey( + TicketCategory, + blank= True, + help_text = 'Category for this ticket', + null = True, + on_delete = models.SET_NULL, + verbose_name = 'Category', + ) title = models.CharField( blank = False, @@ -634,6 +636,7 @@ class Ticket( common_itsm_fields: list(str()) = common_fields + [ 'status', + 'category' 'urgency', 'project', 'priority', diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index ab07c33f..74e90b77 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -122,6 +122,14 @@ val +
    + + + + {{ ticket.category }} + + +
    From 6402897329c68226df3d9d0be9fc577a2ce08073 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 11:25:14 +0930 Subject: [PATCH 199/321] fix(core): Correct the delete permission ref: #14 #96 #93 #95 #90 #283 #284 --- app/core/views/ticket_categories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/views/ticket_categories.py b/app/core/views/ticket_categories.py index f0a3ac5c..b0664bdd 100644 --- a/app/core/views/ticket_categories.py +++ b/app/core/views/ticket_categories.py @@ -71,7 +71,7 @@ class Delete(DeleteView): model = TicketCategory permission_required = [ - 'itim.delete_ticketcategory', + 'core.delete_ticketcategory', ] From 6cc992f6d60c6e0e8ceff791c3d747bf361f4b09 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 11:25:37 +0930 Subject: [PATCH 200/321] fix(core): Dont attempt to render ticket category if none ref: #14 #96 #93 #95 #90 #283 #284 --- app/core/templates/core/ticket.html.j2 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/core/templates/core/ticket.html.j2 b/app/core/templates/core/ticket.html.j2 index 74e90b77..0e02ca13 100644 --- a/app/core/templates/core/ticket.html.j2 +++ b/app/core/templates/core/ticket.html.j2 @@ -122,6 +122,7 @@ val
    + {% if ticket.category %}
    @@ -130,6 +131,7 @@
    + {% endif %}
    From 297e318243a4acc7a66997228dedb1c11d81fe0b Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 11:26:14 +0930 Subject: [PATCH 201/321] test(core): ui permissions ref: #14 #96 #93 #95 #90 #283 #284 --- .../test_ticket_category_permission.py | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 app/core/tests/unit/ticket_category/test_ticket_category_permission.py diff --git a/app/core/tests/unit/ticket_category/test_ticket_category_permission.py b/app/core/tests/unit/ticket_category/test_ticket_category_permission.py new file mode 100644 index 00000000..b19374bb --- /dev/null +++ b/app/core/tests/unit/ticket_category/test_ticket_category_permission.py @@ -0,0 +1,187 @@ +# from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import reverse +from django.test import TestCase, Client + +import pytest +import unittest +import requests + +from access.models import Organization, Team, TeamUsers, Permission + +from app.tests.abstract.model_permissions import ModelPermissions + +from core.models.ticket.ticket_category import TicketCategory + + +class TicketCategoryPermissions(TestCase, ModelPermissions): + + model = TicketCategory + + app_namespace = 'Settings' + + url_name_view = '_ticket_category_view' + + url_name_add = '_ticket_category_add' + + url_name_change = '_ticket_category_change' + + url_name_delete = '_ticket_category_delete' + + url_delete_response = reverse('Settings:_ticket_categories') + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a category + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + + self.item = self.model.objects.create( + organization=organization, + name = 'manufacturerone' + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + # self.url_add_kwargs = {'pk': self.item.id} + + self.add_data = {'name': 'manufacturer', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'name': 'manufacturer', 'organization': self.organization.id} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'name': 'manufacturer'} + + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_permissions]) + + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) From 7d80857d8dbda51633c6abaac91245683f086832 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 11:26:27 +0930 Subject: [PATCH 202/321] test(core): view checks ref: #14 #96 #93 #95 #90 #283 #284 --- .../test_ticket_category_views.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/core/tests/unit/ticket_category/test_ticket_category_views.py diff --git a/app/core/tests/unit/ticket_category/test_ticket_category_views.py b/app/core/tests/unit/ticket_category/test_ticket_category_views.py new file mode 100644 index 00000000..4269318d --- /dev/null +++ b/app/core/tests/unit/ticket_category/test_ticket_category_views.py @@ -0,0 +1,34 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import PrimaryModel, ModelAdd, ModelChange, ModelDelete + + + +# class TicketCommentViews( +# TestCase, +# PrimaryModel +# ): +class TicketCategoryViews( + TestCase, + ModelAdd, + ModelChange, +): + + add_module = 'core.views.ticket_categories' + add_view = 'Add' + + change_module = add_module + change_view = 'Change' + + delete_module = add_module + delete_view = 'Delete' + + display_module = add_module + display_view = 'View' + + index_module = add_module + index_view = 'Index' From ded6a72072f47f23edd19cf2d8caf2704acfaf57 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 11:26:50 +0930 Subject: [PATCH 203/321] test(core): ticket category model checks ref: #14 #96 #93 #95 #90 #283 #284 --- .../ticket_category/test_ticket_category.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/core/tests/unit/ticket_category/test_ticket_category.py diff --git a/app/core/tests/unit/ticket_category/test_ticket_category.py b/app/core/tests/unit/ticket_category/test_ticket_category.py new file mode 100644 index 00000000..951c8392 --- /dev/null +++ b/app/core/tests/unit/ticket_category/test_ticket_category.py @@ -0,0 +1,27 @@ +import pytest +# import unittest +# import requests + +from django.test import TestCase + +from app.tests.abstract.models import TenancyModel + +from core.models.ticket.ticket_category import TicketCategory + + +class TicketCategoryModel( + TestCase, + TenancyModel +): + + model = TicketCategory + + + # def test_attribute_duration_ticket_value(self): + # """Attribute value test + + # This aattribute calculates the ticket duration from + # it's comments. must return total time in seconds + # """ + + # pass \ No newline at end of file From 2a31815267fd525ba0d051c8b1b1235bf78c24b2 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 12:24:36 +0930 Subject: [PATCH 204/321] feat(core): Add ticket category API endpoint ref: #14 #96 #93 #95 #90 #283 #284 --- app/api/serializers/core/ticket.py | 1 + app/api/serializers/core/ticket_category.py | 42 +++++++++++ app/api/urls.py | 7 +- app/api/views/core/ticket_categories.py | 79 +++++++++++++++++++++ app/api/views/settings/index.py | 3 +- app/core/models/ticket/ticket.py | 1 + 6 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 app/api/serializers/core/ticket_category.py create mode 100644 app/api/views/core/ticket_categories.py diff --git a/app/api/serializers/core/ticket.py b/app/api/serializers/core/ticket.py index 371610df..26e6b349 100644 --- a/app/api/serializers/core/ticket.py +++ b/app/api/serializers/core/ticket.py @@ -115,6 +115,7 @@ class TicketSerializer( 'id', 'assigned_teams', 'assigned_users', + 'category', 'created', 'modified', 'status', diff --git a/app/api/serializers/core/ticket_category.py b/app/api/serializers/core/ticket_category.py new file mode 100644 index 00000000..9688d7bb --- /dev/null +++ b/app/api/serializers/core/ticket_category.py @@ -0,0 +1,42 @@ +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: + + self.fields.fields['parent'].queryset = self.fields.fields['parent'].queryset.exclude( + id=instance.id + ) + + super().__init__(instance=instance, data=data, **kwargs) diff --git a/app/api/urls.py b/app/api/urls.py index 14a333c4..ff28539b 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -10,7 +10,10 @@ 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_comments as core_ticket_comments +from api.views.core import ( + ticket_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_task @@ -44,6 +47,8 @@ router.register('project_management/projects', projects.View, basename='_api_pro router.register('project_management/projects/(?P[0-9]+)/tasks', project_task.View, basename='_api_project_tasks') router.register('project_management/projects/(?P[0-9]+)/tasks/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_project_tasks_comments') +router.register('settings/ticket_caategories', ticket_categories.View, basename='_api_ticket_category') + router.register('software', software.SoftwareViewSet, basename='software') diff --git a/app/api/views/core/ticket_categories.py b/app/api/views/core/ticket_categories.py new file mode 100644 index 00000000..d0c5d372 --- /dev/null +++ b/app/api/views/core/ticket_categories.py @@ -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' diff --git a/app/api/views/settings/index.py b/app/api/views/settings/index.py index 6870c9f7..cea0d7fa 100644 --- a/app/api/views/settings/index.py +++ b/app/api/views/settings/index.py @@ -37,7 +37,8 @@ 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), + "ticket_categories": reverse('API:_api_ticket_category-list', request=request) } return Response(data=response_data,status=status) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index e7d461be..0248358c 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -676,6 +676,7 @@ class Ticket( ] fields_project_task: list(str()) = common_fields + [ + 'category', 'status', 'urgency', 'priority', From 5d116c7224c8fb7f03b5f6912216d46875f0d8b1 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 12:25:30 +0930 Subject: [PATCH 205/321] feat(core): Extend all ticket endpoints to contain ticket categories ref: #14 #96 #93 #95 #90 #283 #284 --- app/api/serializers/assistance/request.py | 12 ++++++++++++ app/api/serializers/itim/change.py | 12 ++++++++++++ app/api/serializers/itim/incident.py | 12 ++++++++++++ app/api/serializers/itim/problem.py | 12 ++++++++++++ .../serializers/project_management/project_task.py | 12 ++++++++++++ app/core/forms/validate_ticket.py | 1 + 6 files changed, 61 insertions(+) diff --git a/app/api/serializers/assistance/request.py b/app/api/serializers/assistance/request.py index d4df3d56..fa6ce01d 100644 --- a/app/api/serializers/assistance/request.py +++ b/app/api/serializers/assistance/request.py @@ -1,3 +1,5 @@ +from rest_framework.fields import empty + from api.serializers.core.ticket import TicketSerializer from core.models.ticket.ticket import Ticket @@ -16,6 +18,7 @@ class RequestTicketSerializer( 'id', 'assigned_teams', 'assigned_users', + 'category', 'created', 'modified', 'status', @@ -47,3 +50,12 @@ class RequestTicketSerializer( '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 + ) diff --git a/app/api/serializers/itim/change.py b/app/api/serializers/itim/change.py index e7a920c7..03d3dbac 100644 --- a/app/api/serializers/itim/change.py +++ b/app/api/serializers/itim/change.py @@ -1,3 +1,5 @@ +from rest_framework.fields import empty + from api.serializers.core.ticket import TicketSerializer from core.models.ticket.ticket import Ticket @@ -16,6 +18,7 @@ class ChangeTicketSerializer( 'id', 'assigned_teams', 'assigned_users', + 'category', 'created', 'modified', 'status', @@ -47,3 +50,12 @@ class ChangeTicketSerializer( '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 + ) diff --git a/app/api/serializers/itim/incident.py b/app/api/serializers/itim/incident.py index ceff1ef0..a5a61c05 100644 --- a/app/api/serializers/itim/incident.py +++ b/app/api/serializers/itim/incident.py @@ -1,3 +1,5 @@ +from rest_framework.fields import empty + from api.serializers.core.ticket import TicketSerializer from core.models.ticket.ticket import Ticket @@ -16,6 +18,7 @@ class IncidentTicketSerializer( 'id', 'assigned_teams', 'assigned_users', + 'category', 'created', 'modified', 'status', @@ -47,3 +50,12 @@ class IncidentTicketSerializer( '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 + ) diff --git a/app/api/serializers/itim/problem.py b/app/api/serializers/itim/problem.py index 2c037864..6ed59e48 100644 --- a/app/api/serializers/itim/problem.py +++ b/app/api/serializers/itim/problem.py @@ -1,3 +1,5 @@ +from rest_framework.fields import empty + from api.serializers.core.ticket import TicketSerializer from core.models.ticket.ticket import Ticket @@ -16,6 +18,7 @@ class ProblemTicketSerializer( 'id', 'assigned_teams', 'assigned_users', + 'category', 'created', 'modified', 'status', @@ -47,3 +50,12 @@ class ProblemTicketSerializer( '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 + ) diff --git a/app/api/serializers/project_management/project_task.py b/app/api/serializers/project_management/project_task.py index b876d95b..efc9dfd1 100644 --- a/app/api/serializers/project_management/project_task.py +++ b/app/api/serializers/project_management/project_task.py @@ -1,3 +1,5 @@ +from rest_framework.fields import empty + from api.serializers.core.ticket import TicketSerializer from core.models.ticket.ticket import Ticket @@ -16,6 +18,7 @@ class ProjectTaskSerializer( 'id', 'assigned_teams', 'assigned_users', + 'category', 'created', 'modified', 'status', @@ -47,3 +50,12 @@ class ProjectTaskSerializer( '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 + ) diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 777d367b..a50247e5 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -37,6 +37,7 @@ class TicketValidation( import_fields: list = [ 'assigned_users', 'assigned_teams', + 'category', 'created', 'date_closed', 'external_ref', From f2898037b005587ea722dd94ef1f6e1187a77a0f Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 12:37:48 +0930 Subject: [PATCH 206/321] test(core): ticket category tenancy model checks ref: #283 #284 --- ...st_ticket_category_access_tenancy_object.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 app/core/tests/unit/ticket_category/test_ticket_category_access_tenancy_object.py diff --git a/app/core/tests/unit/ticket_category/test_ticket_category_access_tenancy_object.py b/app/core/tests/unit/ticket_category/test_ticket_category_access_tenancy_object.py new file mode 100644 index 00000000..cca769c0 --- /dev/null +++ b/app/core/tests/unit/ticket_category/test_ticket_category_access_tenancy_object.py @@ -0,0 +1,18 @@ +import pytest +import unittest +import requests + +from django.test import TestCase, Client + +from access.tests.abstract.tenancy_object import TenancyObject + +from core.models.ticket.ticket_category import TicketCategory + + + +class TicketCategoryTenancyObject( + TestCase, + TenancyObject +): + + model = TicketCategory From e68dbdfb4ca357baa3550e3502e4d32344649e2b Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 12:38:14 +0930 Subject: [PATCH 207/321] test(core): ticket category history checks ref: #283 #284 --- .../test_ticket_category_core_history.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 app/core/tests/unit/ticket_category/test_ticket_category_core_history.py diff --git a/app/core/tests/unit/ticket_category/test_ticket_category_core_history.py b/app/core/tests/unit/ticket_category/test_ticket_category_core_history.py new file mode 100644 index 00000000..ee1d322e --- /dev/null +++ b/app/core/tests/unit/ticket_category/test_ticket_category_core_history.py @@ -0,0 +1,75 @@ +import pytest +import unittest +import requests + +from django.test import TestCase, Client + + +from access.models import Organization + +from core.models.history import History +from core.models.ticket.ticket_category import TicketCategory +from core.tests.abstract.history_entry import HistoryEntry +from core.tests.abstract.history_entry_parent_model import HistoryEntryParentItem + +# from itam.models.device import Device + + + +class TicketCategoryHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + + model = TicketCategory + + + @classmethod + def setUpTestData(self): + """ Setup Test """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + self.item_create = self.model.objects.create( + name = 'test_item_' + self.model._meta.model_name, + organization = self.organization + ) + + + self.history_create = History.objects.get( + action = History.Actions.ADD[0], + item_pk = self.item_create.pk, + item_class = self.model._meta.model_name, + ) + + + self.item_change = self.item_create + self.item_change.name = 'test_item_' + self.model._meta.model_name + '_changed' + self.item_change.save() + + self.field_after_expected_value = '{"name": "test_item_' + self.model._meta.model_name + '_changed"}' + + self.history_change = History.objects.get( + action = History.Actions.UPDATE[0], + item_pk = self.item_change.pk, + item_class = self.model._meta.model_name, + ) + + self.item_delete = self.model.objects.create( + name = 'test_item_delete_' + self.model._meta.model_name, + organization = self.organization + ) + + self.deleted_pk = self.item_delete.pk + + self.item_delete.delete() + + self.history_delete = History.objects.filter( + item_pk = self.deleted_pk, + item_class = self.model._meta.model_name, + ) + + self.history_delete_children = History.objects.filter( + item_parent_pk = self.deleted_pk, + item_parent_class = self.model._meta.model_name, + ) From 4fdabc16ba25e3ba03a9345cfb6aa57b4a6364d6 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 12:38:26 +0930 Subject: [PATCH 208/321] test(core): ticket category API permission checks ref: #283 #284 --- .../test_ticket_category_permission_api.py | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 app/core/tests/unit/ticket_category/test_ticket_category_permission_api.py diff --git a/app/core/tests/unit/ticket_category/test_ticket_category_permission_api.py b/app/core/tests/unit/ticket_category/test_ticket_category_permission_api.py new file mode 100644 index 00000000..ce5f333e --- /dev/null +++ b/app/core/tests/unit/ticket_category/test_ticket_category_permission_api.py @@ -0,0 +1,176 @@ +import pytest +import unittest +import requests + +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from access.models import Organization, Team, TeamUsers, Permission + +from api.tests.abstract.api_permissions import APIPermissions + +from core.models.ticket.ticket_category import TicketCategory + + +class TicketCategoryPermissionsAPI(TestCase, APIPermissions): + + + model = TicketCategory + + app_namespace = 'API' + + url_name = '_api_ticket_category-detail' + + url_list = '_api_ticket_category-list' + + change_data = {'name': 'category'} + + delete_data = {'name': 'software'} + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a software + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + + self.item = self.model.objects.create( + organization=organization, + name = 'softwareone' + ) + + + # self.url_kwargs = {'pk': self.item.id} + + self.url_view_kwargs = {'pk': self.item.id} + + self.add_data = {'name': 'software', 'organization': self.organization.id} + + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_permissions]) + + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) From 40f564b32a48ed9605fbedc2858f0b95bb86f5fa Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 12:45:13 +0930 Subject: [PATCH 209/321] fix(core): dont attempt to modify field for ticket category API list ref: #283 #284 --- app/api/serializers/core/ticket_category.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/api/serializers/core/ticket_category.py b/app/api/serializers/core/ticket_category.py index 9688d7bb..fc151766 100644 --- a/app/api/serializers/core/ticket_category.py +++ b/app/api/serializers/core/ticket_category.py @@ -35,8 +35,10 @@ class TicketCategorySerializer( if instance is not None: - self.fields.fields['parent'].queryset = self.fields.fields['parent'].queryset.exclude( - id=instance.id - ) + 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) From 1161bf79aabaa3638aea54f0c0b9e87362128628 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 12:45:33 +0930 Subject: [PATCH 210/321] fix(core): correct url typo for ticket category API endpoint ref: #283 #284 --- app/api/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/urls.py b/app/api/urls.py index ff28539b..6c46fd1b 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -47,7 +47,7 @@ router.register('project_management/projects', projects.View, basename='_api_pro router.register('project_management/projects/(?P[0-9]+)/tasks', project_task.View, basename='_api_project_tasks') router.register('project_management/projects/(?P[0-9]+)/tasks/(?P[0-9]+)/comments', core_ticket_comments.View, basename='_api_project_tasks_comments') -router.register('settings/ticket_caategories', ticket_categories.View, basename='_api_ticket_category') +router.register('settings/ticket_categories', ticket_categories.View, basename='_api_ticket_category') router.register('software', software.SoftwareViewSet, basename='software') From 11948c95004c6fcc31e3b7ab5497fa2d9bc5bb55 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:14:25 +0930 Subject: [PATCH 211/321] feat(core): Add ticket comment categories ref: #14 #96 #93 #95 #90 #283 #284 --- app/core/forms/ticket_categories.py | 2 +- app/core/forms/ticket_comment_category.py | 119 +++++++++++++ ...ticketcategory_ticket_category_and_more.py | 25 ++- .../models/ticket/ticket_comment_category.py | 103 +++++++++++ .../core/index_ticket_categories.html.j2 | 1 + .../index_ticket_comment_categories.html.j2 | 30 ++++ .../core/ticket_comment_category.html.j2 | 12 ++ app/core/views/ticket_comment_category.py | 168 ++++++++++++++++++ app/settings/templates/settings/home.html.j2 | 1 + app/settings/urls.py | 8 +- .../user/core/ticket_comment_category.md | 40 +++++ mkdocs.yml | 2 + 12 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 app/core/forms/ticket_comment_category.py create mode 100644 app/core/models/ticket/ticket_comment_category.py create mode 100644 app/core/templates/core/index_ticket_comment_categories.html.j2 create mode 100644 app/core/templates/core/ticket_comment_category.html.j2 create mode 100644 app/core/views/ticket_comment_category.py create mode 100644 docs/projects/centurion_erp/user/core/ticket_comment_category.md diff --git a/app/core/forms/ticket_categories.py b/app/core/forms/ticket_categories.py index 613bcd11..61db679f 100644 --- a/app/core/forms/ticket_categories.py +++ b/app/core/forms/ticket_categories.py @@ -74,7 +74,7 @@ class DetailForm(TicketCategoryForm): "name": "Ticket Types", "left": [ 'change', - 'problem' + 'problem', 'request' ], "right": [ diff --git a/app/core/forms/ticket_comment_category.py b/app/core/forms/ticket_comment_category.py new file mode 100644 index 00000000..9912cff7 --- /dev/null +++ b/app/core/forms/ticket_comment_category.py @@ -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') + diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py index 22947e5b..49bca34a 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-13 01:31 +# Generated by Django 5.0.8 on 2024-09-13 03:43 import access.fields import access.models @@ -99,6 +99,29 @@ class Migration(migrations.Migration): 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.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='Change Comment')), + ('notification', models.BooleanField(default=True, help_text='Use category for notification comment', verbose_name='Incident Comment')), + ('solution', models.BooleanField(default=True, help_text='Use category for solution comment', verbose_name='Problem Comment')), + ('task', models.BooleanField(default=True, help_text='Use category for task comment', verbose_name='Project Comment')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('parent', 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')), + ('runbook', 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')), + ], + options={ + 'verbose_name': 'Ticket Comment Category', + 'verbose_name_plural': 'Ticket Comment Categories', + 'ordering': ['name'], + }, + ), migrations.AlterUniqueTogether( name='ticket', unique_together={('external_system', 'external_ref')}, diff --git a/app/core/models/ticket/ticket_comment_category.py b/app/core/models/ticket/ticket_comment_category.py new file mode 100644 index 00000000..2d4d8230 --- /dev/null +++ b/app/core/models/ticket/ticket_comment_category.py @@ -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 = 'Change Comment', + ) + + notification = models.BooleanField( + blank = False, + default = True, + help_text = 'Use category for notification comment', + null = False, + verbose_name = 'Incident Comment', + ) + + solution = models.BooleanField( + blank = False, + default = True, + help_text = 'Use category for solution comment', + null = False, + verbose_name = 'Problem Comment', + ) + + task = models.BooleanField( + blank = False, + default = True, + help_text = 'Use category for task comment', + null = False, + verbose_name = 'Project Comment', + ) + + + def __str__(self): + + return self.name diff --git a/app/core/templates/core/index_ticket_categories.html.j2 b/app/core/templates/core/index_ticket_categories.html.j2 index 47099704..3ad06470 100644 --- a/app/core/templates/core/index_ticket_categories.html.j2 +++ b/app/core/templates/core/index_ticket_categories.html.j2 @@ -1,6 +1,7 @@ {% extends 'base.html.j2' %} {% block content %} + diff --git a/app/core/templates/core/index_ticket_comment_categories.html.j2 b/app/core/templates/core/index_ticket_comment_categories.html.j2 new file mode 100644 index 00000000..f648f758 --- /dev/null +++ b/app/core/templates/core/index_ticket_comment_categories.html.j2 @@ -0,0 +1,30 @@ +{% extends 'base.html.j2' %} + +{% block content %} + + + +
    + + + + + + + + {% if items %} + {% for category in items %} + + + + + + + + {% endfor %} + {% else %} + + {% endif%} +
    NameOrganizationcreatedmodified 
    {{ category.name }}{{ category.organization }}{{ category.created }}{{ category.modified }} 
    Nothing Found
    + +{% endblock %} diff --git a/app/core/templates/core/ticket_comment_category.html.j2 b/app/core/templates/core/ticket_comment_category.html.j2 new file mode 100644 index 00000000..d2fc524f --- /dev/null +++ b/app/core/templates/core/ticket_comment_category.html.j2 @@ -0,0 +1,12 @@ +{% extends 'detail.html.j2' %} + + +{% block tabs %} + +
    + + {% include 'content/section.html.j2' with tab=form.tabs.details %} + +
    + +{% endblock %} diff --git a/app/core/views/ticket_comment_category.py b/app/core/views/ticket_comment_category.py new file mode 100644 index 00000000..4b9fd781 --- /dev/null +++ b/app/core/views/ticket_comment_category.py @@ -0,0 +1,168 @@ +from django.urls import reverse + +from core.forms.comment import AddNoteForm +from core.forms.ticket_comment_category import DetailForm, TicketCommentCategory, TicketCommentCategoryForm + +from core.models.notes import Notes +from core.views.common import AddView, ChangeView, DeleteView, IndexView + + + +class Add(AddView): + + form_class = TicketCommentCategoryForm + + model = TicketCommentCategory + + permission_required = [ + 'core.add_ticketcategory', + ] + + + def get_initial(self): + + initial = super().get_initial() + + if 'pk' in self.kwargs: + + if self.kwargs['pk']: + + initial.update({'parent': self.kwargs['pk']}) + + self.model.parent.field.hidden = True + + return initial + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_ticket_comment_categories') + + + +class Change(ChangeView): + + form_class = TicketCommentCategoryForm + + model = TicketCommentCategory + + permission_required = [ + 'core.change_ticketcategory', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = str(self.object) + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_ticket_comment_category_view', args=(self.kwargs['pk'],)) + + + +class Delete(DeleteView): + + model = TicketCommentCategory + + permission_required = [ + 'core.delete_ticketcategory', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + str(self.object) + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_ticket_comment_categories') + + + +class Index(IndexView): + + context_object_name = "items" + + model = TicketCommentCategory + + paginate_by = 10 + + permission_required = [ + 'core.view_ticketcategory' + ] + + template_name = 'core/index_ticket_comment_categories.html.j2' + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['model_docs_path'] = self.model._meta.app_label + '/ticket_comment_category' + + return context + + + +class View(ChangeView): + + context_object_name = "ticket_categories" + + form_class = DetailForm + + model = TicketCommentCategory + + permission_required = [ + 'core.view_ticketcategory', + ] + + template_name = 'core/ticket_comment_category.html.j2' + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['notes_form'] = AddNoteForm(prefix='note') + context['notes'] = Notes.objects.filter(service=self.kwargs['pk']) + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.model_name + + context['model_delete_url'] = reverse('Settings:_ticket_comment_category_delete', kwargs={'pk': self.kwargs['pk']}) + + + context['content_title'] = self.object.name + + return context + + + # def post(self, request, *args, **kwargs): + + # item = Cluster.objects.get(pk=self.kwargs['pk']) + + # notes = AddNoteForm(request.POST, prefix='note') + + # if notes.is_bound and notes.is_valid() and notes.instance.note != '': + + # notes.instance.service = item + + # notes.instance.organization = item.organization + + # notes.save() + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_ticket_comment_category_view', kwargs={'pk': self.kwargs['pk']}) diff --git a/app/settings/templates/settings/home.html.j2 b/app/settings/templates/settings/home.html.j2 index b6ec69cf..caa40d18 100644 --- a/app/settings/templates/settings/home.html.j2 +++ b/app/settings/templates/settings/home.html.j2 @@ -54,6 +54,7 @@ div#content article h3 {

    Core

diff --git a/app/settings/urls.py b/app/settings/urls.py index 540e496e..e78bceaa 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -2,7 +2,7 @@ from django.urls import path from assistance.views import knowledge_base_category -from core.views import celery_log, ticket_categories +from core.views import celery_log, ticket_categories, ticket_comment_category from settings.views import app_settings, home, device_models, device_types, external_link, manufacturer, software_categories @@ -71,6 +71,12 @@ urlpatterns = [ path("ticket_categories//edit", ticket_categories.Change.as_view(), name="_ticket_category_change"), path("ticket_categories//delete", ticket_categories.Delete.as_view(), name="_ticket_category_delete"), + path("ticket_comment_categories", ticket_comment_category.Index.as_view(), name="_ticket_comment_categories"), + path("ticket_comment_categories/", ticket_comment_category.View.as_view(), name="_ticket_comment_category_view"), + path("ticket_comment_categories/add", ticket_comment_category.Add.as_view(), name="_ticket_comment_category_add"), + path("ticket_comment_categories//edit", ticket_comment_category.Change.as_view(), name="_ticket_comment_category_change"), + path("ticket_comment_categories//delete", ticket_comment_category.Delete.as_view(), name="_ticket_comment_category_delete"), + path("task_results", celery_log.Index.as_view(), name="_task_results"), path("task_result/", celery_log.View.as_view(), name="_task_result_view"), diff --git a/docs/projects/centurion_erp/user/core/ticket_comment_category.md b/docs/projects/centurion_erp/user/core/ticket_comment_category.md new file mode 100644 index 00000000..f6d7f935 --- /dev/null +++ b/docs/projects/centurion_erp/user/core/ticket_comment_category.md @@ -0,0 +1,40 @@ +--- +title: Ticket Comment Categories +description: Ticket Comment Categories Documentation as part of the Core Module for Centurion ERP by No Fuss Computing +date: 2024-09-13 +template: project.html +about: https://github.com/nofusscomputing/centurion_erp +--- + +Ticket comment categories enables you to sort ticket comments to aid in reporting. + + +## Fields + +- parent + + The parent category. This field enables nesting of categories + +- name + + The name for this category + +- [runbook](../assistance/knowledge_base.md) + + The runbook for this category + +- comment + + Should this category be available for standard comments + +- notification + + Should this category be available for notification comments + +- solution + + Should this category be available for solution comments + +- task + + Should this category be available for task comments diff --git a/mkdocs.yml b/mkdocs.yml index 2c193125..85a34536 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -196,6 +196,8 @@ nav: - projects/centurion_erp/user/core/ticketcategory.md + - projects/centurion_erp/user/core/ticket_comment_category.md + - ITAM: - projects/centurion_erp/user/itam/index.md From 56b715797e32e78db4ccdc77f7402bfcf6d10ec7 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:30:36 +0930 Subject: [PATCH 212/321] feat(core): Ability to assign categories to ticket comments ref: #14 #96 #93 #95 #90 #283 #283 #284 --- app/core/forms/ticket_comment.py | 16 +++++++++++++++ app/core/forms/validate_ticket_comment.py | 2 ++ ...ticketcategory_ticket_category_and_more.py | 13 ++++++------ app/core/models/ticket/ticket_comment.py | 20 ++++++++++--------- .../models/ticket/ticket_comment_category.py | 8 ++++---- 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 91c6df23..23c2aa19 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -80,18 +80,34 @@ class CommentForm( 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 diff --git a/app/core/forms/validate_ticket_comment.py b/app/core/forms/validate_ticket_comment.py index 3c0e1a8e..589e5d7d 100644 --- a/app/core/forms/validate_ticket_comment.py +++ b/app/core/forms/validate_ticket_comment.py @@ -55,6 +55,7 @@ class TicketCommentValidation( 'external_system', 'comment_type', 'body', + 'category', 'created', 'modified', 'private', @@ -74,6 +75,7 @@ class TicketCommentValidation( ] triage_fields: list = [ + 'category', 'body', 'private', 'duration', diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py b/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py index 49bca34a..726619de 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py +++ b/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-13 03:43 +# Generated by Django 5.0.8 on 2024-09-13 03:59 import access.fields import access.models @@ -108,10 +108,10 @@ class Migration(migrations.Migration): ('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='Change Comment')), - ('notification', models.BooleanField(default=True, help_text='Use category for notification comment', verbose_name='Incident Comment')), - ('solution', models.BooleanField(default=True, help_text='Use category for solution comment', verbose_name='Problem Comment')), - ('task', models.BooleanField(default=True, help_text='Use category for task comment', verbose_name='Project Comment')), + ('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')), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), ('parent', 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')), ('runbook', 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')), @@ -150,9 +150,10 @@ class Migration(migrations.Migration): ('parent', 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')), ('responsible_team', 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')), ('responsible_user', 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')), - ('template', models.ForeignKey(blank=True, default=None, help_text='Comment Template to use', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='comment_template', to='core.ticketcomment', verbose_name='Template')), + ('template', 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')), ('ticket', 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')), ('user', 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')), + ('category', 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')), ], options={ 'verbose_name': 'Comment', diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index 288f4836..2a07a555 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -6,6 +6,7 @@ from access.fields import AutoCreatedField, AutoLastModifiedField from access.models import TenancyObject, Team from .ticket import Ticket +from .ticket_comment_category import TicketCommentCategory @@ -186,14 +187,15 @@ class TicketComment( verbose_name = 'Duration', ) - - # category = models.CharField( - # blank = False, - # help_text = "Category of the Ticket", - # max_length = 50, - # unique = True, - # verbose_name = 'Category', - # ) + 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', @@ -201,7 +203,7 @@ class TicketComment( default = None, help_text = 'Comment Template to use', null = True, - on_delete = models.DO_NOTHING, + on_delete = models.SET_NULL, related_name = 'comment_template', verbose_name = 'Template', ) diff --git a/app/core/models/ticket/ticket_comment_category.py b/app/core/models/ticket/ticket_comment_category.py index 2d4d8230..78e52ec9 100644 --- a/app/core/models/ticket/ticket_comment_category.py +++ b/app/core/models/ticket/ticket_comment_category.py @@ -70,7 +70,7 @@ class TicketCommentCategory(TicketCommentCategoryCommonFields): default = True, help_text = 'Use category for standard comment', null = False, - verbose_name = 'Change Comment', + verbose_name = 'Comment', ) notification = models.BooleanField( @@ -78,7 +78,7 @@ class TicketCommentCategory(TicketCommentCategoryCommonFields): default = True, help_text = 'Use category for notification comment', null = False, - verbose_name = 'Incident Comment', + verbose_name = 'Notification Comment', ) solution = models.BooleanField( @@ -86,7 +86,7 @@ class TicketCommentCategory(TicketCommentCategoryCommonFields): default = True, help_text = 'Use category for solution comment', null = False, - verbose_name = 'Problem Comment', + verbose_name = 'Solution Comment', ) task = models.BooleanField( @@ -94,7 +94,7 @@ class TicketCommentCategory(TicketCommentCategoryCommonFields): default = True, help_text = 'Use category for task comment', null = False, - verbose_name = 'Project Comment', + verbose_name = 'Task Comment', ) From f3dccd3b8437f01b338aa04adcc1daba345fe2fd Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:37:49 +0930 Subject: [PATCH 213/321] test(core): ticket comment category tenancy model checks ref: #283 #284 --- ...t_comment_category_access_tenancy_object.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_access_tenancy_object.py diff --git a/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_access_tenancy_object.py b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_access_tenancy_object.py new file mode 100644 index 00000000..0b53faae --- /dev/null +++ b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_access_tenancy_object.py @@ -0,0 +1,18 @@ +import pytest +import unittest +import requests + +from django.test import TestCase, Client + +from access.tests.abstract.tenancy_object import TenancyObject + +from core.models.ticket.ticket_comment_category import TicketCommentCategory + + + +class TicketCommentCategoryTenancyObject( + TestCase, + TenancyObject +): + + model = TicketCommentCategory From a0b0d79777e748ebdd74dcf9cf6cc41dba5245cb Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:38:03 +0930 Subject: [PATCH 214/321] test(core): ticket comment category history checks ref: #283 #284 --- ...st_ticket_comment_category_core_history.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_core_history.py diff --git a/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_core_history.py b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_core_history.py new file mode 100644 index 00000000..2b17b090 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_core_history.py @@ -0,0 +1,75 @@ +import pytest +import unittest +import requests + +from django.test import TestCase, Client + + +from access.models import Organization + +from core.models.history import History +from core.models.ticket.ticket_comment_category import TicketCommentCategory +from core.tests.abstract.history_entry import HistoryEntry +from core.tests.abstract.history_entry_parent_model import HistoryEntryParentItem + +# from itam.models.device import Device + + + +class TicketCommentCategoryHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + + model = TicketCommentCategory + + + @classmethod + def setUpTestData(self): + """ Setup Test """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + self.item_create = self.model.objects.create( + name = 'test_item_' + self.model._meta.model_name, + organization = self.organization + ) + + + self.history_create = History.objects.get( + action = History.Actions.ADD[0], + item_pk = self.item_create.pk, + item_class = self.model._meta.model_name, + ) + + + self.item_change = self.item_create + self.item_change.name = 'test_item_' + self.model._meta.model_name + '_changed' + self.item_change.save() + + self.field_after_expected_value = '{"name": "test_item_' + self.model._meta.model_name + '_changed"}' + + self.history_change = History.objects.get( + action = History.Actions.UPDATE[0], + item_pk = self.item_change.pk, + item_class = self.model._meta.model_name, + ) + + self.item_delete = self.model.objects.create( + name = 'test_item_delete_' + self.model._meta.model_name, + organization = self.organization + ) + + self.deleted_pk = self.item_delete.pk + + self.item_delete.delete() + + self.history_delete = History.objects.filter( + item_pk = self.deleted_pk, + item_class = self.model._meta.model_name, + ) + + self.history_delete_children = History.objects.filter( + item_parent_pk = self.deleted_pk, + item_parent_class = self.model._meta.model_name, + ) From 9fbb88fa5fe520989bc48275e8e420a1b1f0d72e Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:38:15 +0930 Subject: [PATCH 215/321] test(core): ticket comment category ui permission checks ref: #283 #284 --- ...test_ticket_comment_category_permission.py | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_permission.py diff --git a/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_permission.py b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_permission.py new file mode 100644 index 00000000..a7b00f27 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_permission.py @@ -0,0 +1,187 @@ +# from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import reverse +from django.test import TestCase, Client + +import pytest +import unittest +import requests + +from access.models import Organization, Team, TeamUsers, Permission + +from app.tests.abstract.model_permissions import ModelPermissions + +from core.models.ticket.ticket_comment_category import TicketCommentCategory + + +class TicketCommentCategoryPermissions(TestCase, ModelPermissions): + + model = TicketCommentCategory + + app_namespace = 'Settings' + + url_name_view = '_ticket_comment_category_view' + + url_name_add = '_ticket_comment_category_add' + + url_name_change = '_ticket_comment_category_change' + + url_name_delete = '_ticket_comment_category_delete' + + url_delete_response = reverse('Settings:_ticket_comment_categories') + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a category + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + + self.item = self.model.objects.create( + organization=organization, + name = 'manufacturerone' + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + # self.url_add_kwargs = {'pk': self.item.id} + + self.add_data = {'name': 'manufacturer', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'name': 'manufacturer', 'organization': self.organization.id} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'name': 'manufacturer'} + + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_permissions]) + + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) From 01c57b37ade7bce9d79922f771525b3eeafeb728 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:40:03 +0930 Subject: [PATCH 216/321] test(core): ticket comment category view checks ref: #283 #284 --- .../test_ticket_comment_category_views.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_views.py diff --git a/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_views.py b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_views.py new file mode 100644 index 00000000..e8ff79d0 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_views.py @@ -0,0 +1,33 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import PrimaryModel, ModelAdd, ModelChange, ModelDelete + + + +# class TicketCommentViews( +# TestCase, +# PrimaryModel +# ): +class TicketCommentCategoryViews( + TestCase, + PrimaryModel +): + + add_module = 'core.views.ticket_comment_category' + add_view = 'Add' + + change_module = add_module + change_view = 'Change' + + delete_module = add_module + delete_view = 'Delete' + + display_module = add_module + display_view = 'View' + + index_module = add_module + index_view = 'Index' From 88d6a734548c13ab7830b1e8831c551e72e9563f Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:40:26 +0930 Subject: [PATCH 217/321] test(core): ticket comment category tenancy model checks ref: #283 #284 --- .../test_ticket_comment_category.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_category/test_ticket_comment_category.py diff --git a/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category.py b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category.py new file mode 100644 index 00000000..5cad5ff9 --- /dev/null +++ b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category.py @@ -0,0 +1,27 @@ +import pytest +# import unittest +# import requests + +from django.test import TestCase + +from app.tests.abstract.models import TenancyModel + +from core.models.ticket.ticket_comment_category import TicketCommentCategory + + +class TicketCommentCategoryModel( + TestCase, + TenancyModel +): + + model = TicketCommentCategory + + + # def test_attribute_duration_ticket_value(self): + # """Attribute value test + + # This aattribute calculates the ticket duration from + # it's comments. must return total time in seconds + # """ + + # pass \ No newline at end of file From 1be23148d7b9daeedf94d14afe4413d7ed625bc7 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:40:57 +0930 Subject: [PATCH 218/321] test(core): add missing ticket category view checks ref: #283 #284 --- .../tests/unit/ticket_category/test_ticket_category_views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/core/tests/unit/ticket_category/test_ticket_category_views.py b/app/core/tests/unit/ticket_category/test_ticket_category_views.py index 4269318d..2c001e84 100644 --- a/app/core/tests/unit/ticket_category/test_ticket_category_views.py +++ b/app/core/tests/unit/ticket_category/test_ticket_category_views.py @@ -14,8 +14,7 @@ from app.tests.abstract.models import PrimaryModel, ModelAdd, ModelChange, Model # ): class TicketCategoryViews( TestCase, - ModelAdd, - ModelChange, + PrimaryModel ): add_module = 'core.views.ticket_categories' From 902aaf31dd94aa2fbe17e5ddf42c14bc54dd3b8a Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:41:20 +0930 Subject: [PATCH 219/321] fix(core): Correct view permissions for ticket comment category ref: #283 #284 --- app/core/views/ticket_comment_category.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/core/views/ticket_comment_category.py b/app/core/views/ticket_comment_category.py index 4b9fd781..f7ae63bf 100644 --- a/app/core/views/ticket_comment_category.py +++ b/app/core/views/ticket_comment_category.py @@ -15,7 +15,7 @@ class Add(AddView): model = TicketCommentCategory permission_required = [ - 'core.add_ticketcategory', + 'core.add_ticketcommentcategory', ] @@ -47,7 +47,7 @@ class Change(ChangeView): model = TicketCommentCategory permission_required = [ - 'core.change_ticketcategory', + 'core.change_ticketcommentcategory', ] @@ -71,7 +71,7 @@ class Delete(DeleteView): model = TicketCommentCategory permission_required = [ - 'core.delete_ticketcategory', + 'core.delete_ticketcommentcategory', ] @@ -99,7 +99,7 @@ class Index(IndexView): paginate_by = 10 permission_required = [ - 'core.view_ticketcategory' + 'core.view_ticketcommentcategory' ] template_name = 'core/index_ticket_comment_categories.html.j2' @@ -124,7 +124,7 @@ class View(ChangeView): model = TicketCommentCategory permission_required = [ - 'core.view_ticketcategory', + 'core.view_ticketcommentcategory', ] template_name = 'core/ticket_comment_category.html.j2' From f2a4223d25be75bb4049f75e9337759255c009b7 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 13:54:42 +0930 Subject: [PATCH 220/321] feat(core): Add ticket comment category API endpoint ref: #283 #284 --- .../core/ticket_comment_category.py | 42 ++++++++++ app/api/urls.py | 3 + .../views/core/ticket_comment_categories.py | 79 +++++++++++++++++++ app/api/views/settings/index.py | 3 +- 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 app/api/serializers/core/ticket_comment_category.py create mode 100644 app/api/views/core/ticket_comment_categories.py diff --git a/app/api/serializers/core/ticket_comment_category.py b/app/api/serializers/core/ticket_comment_category.py new file mode 100644 index 00000000..dbb03d6d --- /dev/null +++ b/app/api/serializers/core/ticket_comment_category.py @@ -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) diff --git a/app/api/urls.py b/app/api/urls.py index 6c46fd1b..9abc1f9b 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -12,6 +12,7 @@ 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 @@ -49,6 +50,8 @@ router.register('project_management/projects/(?P[0-9]+)/tasks/(?P Date: Fri, 13 Sep 2024 13:55:09 +0930 Subject: [PATCH 221/321] test(core): Ticket comment category API permission checks ref: #284 closes #283 --- ..._ticket_comment_category_permission_api.py | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_permission_api.py diff --git a/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_permission_api.py b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_permission_api.py new file mode 100644 index 00000000..fe0f35eb --- /dev/null +++ b/app/core/tests/unit/ticket_comment_category/test_ticket_comment_category_permission_api.py @@ -0,0 +1,176 @@ +import pytest +import unittest +import requests + +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from access.models import Organization, Team, TeamUsers, Permission + +from api.tests.abstract.api_permissions import APIPermissions + +from core.models.ticket.ticket_comment_category import TicketCommentCategory + + +class TicketCommentCategoryPermissionsAPI(TestCase, APIPermissions): + + + model = TicketCommentCategory + + app_namespace = 'API' + + url_name = '_api_ticket_comment_category-detail' + + url_list = '_api_ticket_comment_category-list' + + change_data = {'name': 'category'} + + delete_data = {'name': 'software'} + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a software + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + + self.item = self.model.objects.create( + organization=organization, + name = 'softwareone' + ) + + + # self.url_kwargs = {'pk': self.item.id} + + self.url_view_kwargs = {'pk': self.item.id} + + self.add_data = {'name': 'software', 'organization': self.organization.id} + + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_permissions]) + + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) From 34a1a190894666a8d4d6fce02d04a332c7b72593 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 14:34:19 +0930 Subject: [PATCH 222/321] test(core): Project Tenancy object checks ref: #14 #284 --- .../test_project_access_tenancy_object.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 app/project_management/tests/unit/project/test_project_access_tenancy_object.py diff --git a/app/project_management/tests/unit/project/test_project_access_tenancy_object.py b/app/project_management/tests/unit/project/test_project_access_tenancy_object.py new file mode 100644 index 00000000..43218f1d --- /dev/null +++ b/app/project_management/tests/unit/project/test_project_access_tenancy_object.py @@ -0,0 +1,18 @@ +import pytest +import unittest +import requests + +from django.test import TestCase, Client + +from access.tests.abstract.tenancy_object import TenancyObject + +from project_management.models.projects import Project + + + +class ProjectTenancyObject( + TestCase, + TenancyObject +): + + model = Project From 6e566b8840b4772d0b82cc328fadd0ed545d3a14 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 14:34:28 +0930 Subject: [PATCH 223/321] test(core): Project history checks ref: #14 #284 --- .../unit/project/test_project_core_history.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 app/project_management/tests/unit/project/test_project_core_history.py diff --git a/app/project_management/tests/unit/project/test_project_core_history.py b/app/project_management/tests/unit/project/test_project_core_history.py new file mode 100644 index 00000000..799874fd --- /dev/null +++ b/app/project_management/tests/unit/project/test_project_core_history.py @@ -0,0 +1,74 @@ +import pytest +import unittest +import requests + +from django.test import TestCase, Client + + +from access.models import Organization + +from core.models.history import History +from core.tests.abstract.history_entry import HistoryEntry +from core.tests.abstract.history_entry_parent_model import HistoryEntryParentItem + +from project_management.models.projects import Project + + + +class ProjectHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + + model = Project + + + @classmethod + def setUpTestData(self): + """ Setup Test """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + self.item_create = self.model.objects.create( + name = 'test_item_' + self.model._meta.model_name, + organization = self.organization + ) + + + self.history_create = History.objects.get( + action = History.Actions.ADD[0], + item_pk = self.item_create.pk, + item_class = self.model._meta.model_name, + ) + + + self.item_change = self.item_create + self.item_change.name = 'test_item_' + self.model._meta.model_name + '_changed' + self.item_change.save() + + self.field_after_expected_value = '{"name": "test_item_' + self.model._meta.model_name + '_changed"}' + + self.history_change = History.objects.get( + action = History.Actions.UPDATE[0], + item_pk = self.item_change.pk, + item_class = self.model._meta.model_name, + ) + + self.item_delete = self.model.objects.create( + name = 'test_item_delete_' + self.model._meta.model_name, + organization = self.organization + ) + + self.deleted_pk = self.item_delete.pk + + self.item_delete.delete() + + self.history_delete = History.objects.filter( + item_pk = self.deleted_pk, + item_class = self.model._meta.model_name, + ) + + self.history_delete_children = History.objects.filter( + item_parent_pk = self.deleted_pk, + item_parent_class = self.model._meta.model_name, + ) From b56f3236fd9f6f451549057ea54e71495a416343 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 14:34:38 +0930 Subject: [PATCH 224/321] test(core): Project API permission checks ref: #14 #284 --- .../project/test_project_permission_api.py | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 app/project_management/tests/unit/project/test_project_permission_api.py diff --git a/app/project_management/tests/unit/project/test_project_permission_api.py b/app/project_management/tests/unit/project/test_project_permission_api.py new file mode 100644 index 00000000..fe0f35eb --- /dev/null +++ b/app/project_management/tests/unit/project/test_project_permission_api.py @@ -0,0 +1,176 @@ +import pytest +import unittest +import requests + +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from access.models import Organization, Team, TeamUsers, Permission + +from api.tests.abstract.api_permissions import APIPermissions + +from core.models.ticket.ticket_comment_category import TicketCommentCategory + + +class TicketCommentCategoryPermissionsAPI(TestCase, APIPermissions): + + + model = TicketCommentCategory + + app_namespace = 'API' + + url_name = '_api_ticket_comment_category-detail' + + url_list = '_api_ticket_comment_category-list' + + change_data = {'name': 'category'} + + delete_data = {'name': 'software'} + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a software + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + + self.item = self.model.objects.create( + organization=organization, + name = 'softwareone' + ) + + + # self.url_kwargs = {'pk': self.item.id} + + self.url_view_kwargs = {'pk': self.item.id} + + self.add_data = {'name': 'software', 'organization': self.organization.id} + + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_permissions]) + + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) From 9d564ffbb2b1a28f0151349883f7acbf1522af47 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 14:34:47 +0930 Subject: [PATCH 225/321] test(core): Project UI permission checks ref: #14 #284 --- .../unit/project/test_project_permission.py | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 app/project_management/tests/unit/project/test_project_permission.py diff --git a/app/project_management/tests/unit/project/test_project_permission.py b/app/project_management/tests/unit/project/test_project_permission.py new file mode 100644 index 00000000..77b1548a --- /dev/null +++ b/app/project_management/tests/unit/project/test_project_permission.py @@ -0,0 +1,187 @@ +# from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import reverse +from django.test import TestCase, Client + +import pytest +import unittest +import requests + +from access.models import Organization, Team, TeamUsers, Permission + +from app.tests.abstract.model_permissions import ModelPermissions + +from project_management.models.projects import Project + + +class ProjectPermissions(TestCase, ModelPermissions): + + model = Project + + app_namespace = 'Project Management' + + url_name_view = '_project_view' + + url_name_add = '_project_add' + + url_name_change = '_project_change' + + url_name_delete = '_project_delete' + + url_delete_response = reverse('Project Management:Projects') + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a category + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + + self.item = self.model.objects.create( + organization=organization, + name = 'manufacturerone' + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + # self.url_add_kwargs = {'pk': self.item.id} + + self.add_data = {'name': 'manufacturer', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'name': 'manufacturer', 'organization': self.organization.id} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'name': 'manufacturer'} + + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_permissions]) + + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) From 1576605acb3e191d7858987c9b9365931d967881 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 14:34:57 +0930 Subject: [PATCH 226/321] test(core): Project view checks ref: #14 #284 --- .../tests/unit/project/test_project_views.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 app/project_management/tests/unit/project/test_project_views.py diff --git a/app/project_management/tests/unit/project/test_project_views.py b/app/project_management/tests/unit/project/test_project_views.py new file mode 100644 index 00000000..70388800 --- /dev/null +++ b/app/project_management/tests/unit/project/test_project_views.py @@ -0,0 +1,33 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import PrimaryModel, ModelAdd, ModelChange, ModelDelete + + + +# class TicketCommentViews( +# TestCase, +# PrimaryModel +# ): +class ProjectViews( + TestCase, + PrimaryModel +): + + add_module = 'project_management.views.project' + add_view = 'Add' + + change_module = add_module + change_view = 'Change' + + delete_module = add_module + delete_view = 'Delete' + + display_module = add_module + display_view = 'View' + + index_module = add_module + index_view = 'Index' From 574357b60aca934e7ac331708206bf4760c22d1e Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 14:35:09 +0930 Subject: [PATCH 227/321] test(core): Project tenancy model checks ref: #14 #284 --- .../tests/unit/project/test_project.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/project_management/tests/unit/project/test_project.py diff --git a/app/project_management/tests/unit/project/test_project.py b/app/project_management/tests/unit/project/test_project.py new file mode 100644 index 00000000..75c10dea --- /dev/null +++ b/app/project_management/tests/unit/project/test_project.py @@ -0,0 +1,27 @@ +import pytest +# import unittest +# import requests + +from django.test import TestCase + +from app.tests.abstract.models import TenancyModel + +from project_management.models.projects import Project + + +class ProjectModel( + TestCase, + TenancyModel +): + + model = Project + + + # def test_attribute_duration_ticket_value(self): + # """Attribute value test + + # This aattribute calculates the ticket duration from + # it's comments. must return total time in seconds + # """ + + # pass \ No newline at end of file From 2e15e61059ca3db5e8d43157e22b72aaa7748d05 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 14:35:37 +0930 Subject: [PATCH 228/321] fix(project_management): correct project view permissions ref: #14 #284 --- app/project_management/views/project.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/project_management/views/project.py b/app/project_management/views/project.py index cdde66b5..7b6f5f22 100644 --- a/app/project_management/views/project.py +++ b/app/project_management/views/project.py @@ -117,7 +117,9 @@ class Index(IndexView): model = Project - permission_required = 'project_management.view_project' + permission_required = [ + 'project_management.view_project', + ] template_name = 'project_management/project_index.html.j2' @@ -151,8 +153,7 @@ class View(ChangeView): model = Project permission_required = [ - 'itam.view_device', - 'itam.change_device' + 'project_management.view_project' ] template_name = 'project_management/project.html.j2' From 5f3c7296b71b81f3c9059869ee58e5bbf643fb3a Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 14:39:50 +0930 Subject: [PATCH 229/321] feat(project_management): remove requirement for code field to be populated ref: #14 #284 --- ...comment_ticketcommentcategory_and_more.py} | 191 ++++++++---------- ...roject_ticket_subscribed_teams_and_more.py | 129 ++++++++++++ .../migrations/0001_initial.py | 4 +- app/project_management/models/projects.py | 4 +- 4 files changed, 214 insertions(+), 114 deletions(-) rename app/core/migrations/{0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py => 0005_ticketcategory_ticketcomment_ticketcommentcategory_and_more.py} (74%) create mode 100644 app/core/migrations/0006_ticket_project_ticket_subscribed_teams_and_more.py diff --git a/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py b/app/core/migrations/0005_ticketcategory_ticketcomment_ticketcommentcategory_and_more.py similarity index 74% rename from app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py rename to app/core/migrations/0005_ticketcategory_ticketcomment_ticketcommentcategory_and_more.py index 726619de..9ad5d5d0 100644 --- a/app/core/migrations/0005_ticket_relatedtickets_ticketcategory_ticket_category_and_more.py +++ b/app/core/migrations/0005_ticketcategory_ticketcomment_ticketcommentcategory_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-13 03:59 +# Generated by Django 5.0.8 on 2024-09-13 05:06 import access.fields import access.models @@ -14,13 +14,90 @@ class Migration(migrations.Migration): dependencies = [ ('access', '0001_initial'), - ('assistance', '0001_initial'), ('core', '0004_notes_service'), - ('project_management', '0001_initial'), 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': ['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(default=None, 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'], + }, + ), + 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='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=[ @@ -46,9 +123,6 @@ class Migration(migrations.Migration): ('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)')), ('opened_by', 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')), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('project', models.ForeignKey(blank=True, help_text='Assign to a project', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='project_management.project', verbose_name='Project')), - ('subscribed_teams', 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)')), - ('subscribed_users', 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)')), ], options={ 'verbose_name': 'Ticket', @@ -57,109 +131,4 @@ class Migration(migrations.Migration): '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')], }, ), - 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])), - ('from_ticket_id', models.ForeignKey(help_text='This Ticket', on_delete=django.db.models.deletion.CASCADE, related_name='from_ticket_id', to='core.ticket', verbose_name='Ticket')), - ('to_ticket_id', 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')), - ], - options={ - 'ordering': ['id'], - }, - ), - 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')), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('parent', 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')), - ('runbook', 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')), - ], - options={ - 'verbose_name': 'Ticket Category', - 'verbose_name_plural': 'Ticket Categories', - 'ordering': ['name'], - }, - ), - 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.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')), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('parent', 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')), - ('runbook', 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')), - ], - options={ - 'verbose_name': 'Ticket Comment Category', - 'verbose_name_plural': 'Ticket Comment Categories', - 'ordering': ['name'], - }, - ), - migrations.AlterUniqueTogether( - name='ticket', - unique_together={('external_system', 'external_ref')}, - ), - 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(default=None, 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')), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('parent', 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')), - ('responsible_team', 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')), - ('responsible_user', 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')), - ('template', 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')), - ('ticket', 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')), - ('user', 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')), - ('category', 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')), - ], - options={ - 'verbose_name': 'Comment', - 'verbose_name_plural': 'Comments', - 'ordering': ['ticket', 'parent_id'], - 'unique_together': {('external_system', 'external_ref')}, - }, - ), ] diff --git a/app/core/migrations/0006_ticket_project_ticket_subscribed_teams_and_more.py b/app/core/migrations/0006_ticket_project_ticket_subscribed_teams_and_more.py new file mode 100644 index 00000000..834f0003 --- /dev/null +++ b/app/core/migrations/0006_ticket_project_ticket_subscribed_teams_and_more.py @@ -0,0 +1,129 @@ +# Generated by Django 5.0.8 on 2024-09-13 05:06 + +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='project', + field=models.ForeignKey(blank=True, help_text='Assign to a project', null=True, on_delete=django.db.models.deletion.DO_NOTHING, 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.AlterUniqueTogether( + name='ticket', + unique_together={('external_system', 'external_ref')}, + ), + migrations.AlterUniqueTogether( + name='ticketcomment', + unique_together={('external_system', 'external_ref')}, + ), + ] diff --git a/app/project_management/migrations/0001_initial.py b/app/project_management/migrations/0001_initial.py index 42e9a326..5eedf100 100644 --- a/app/project_management/migrations/0001_initial.py +++ b/app/project_management/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-10 06:20 +# Generated by Django 5.0.8 on 2024-09-13 05:06 import access.fields import access.models @@ -28,7 +28,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=50, unique=True)), ('slug', access.fields.AutoSlugField()), ('description', models.TextField(blank=True, default=None, null=True)), - ('code', models.CharField(help_text='Project Code', max_length=25, unique=True)), + ('code', models.CharField(blank=True, help_text='Project Code', max_length=25, null=True, unique=True, verbose_name='Project Code')), ('planned_start_date', models.DateTimeField(blank=True, help_text='When the project is planned to have been started by.', null=True, verbose_name='Planned Start Date')), ('planned_finish_date', models.DateTimeField(blank=True, help_text='When the project is planned to be finished by.', null=True, verbose_name='Planned Finish Date')), ('real_start_date', models.DateTimeField(blank=True, help_text='When work commenced on the project.', null=True, verbose_name='Real Start Date')), diff --git a/app/project_management/models/projects.py b/app/project_management/models/projects.py index 0dfe0e53..2fffd511 100644 --- a/app/project_management/models/projects.py +++ b/app/project_management/models/projects.py @@ -41,10 +41,12 @@ class Project(ProjectCommonFieldsName): # project_type code = models.CharField( - blank = False, + blank = True, help_text = 'Project Code', max_length = 25, + null = True, unique = True, + verbose_name = 'Project Code', ) planned_start_date = models.DateTimeField( From 6e7e6587c249db652768280873f802201a61781a Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 15:01:41 +0930 Subject: [PATCH 230/321] docs: update roadmap ref: #284 --- docs/projects/centurion_erp/index.md | 44 +++++++++++++--------------- mkdocs.yml | 3 ++ 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/docs/projects/centurion_erp/index.md b/docs/projects/centurion_erp/index.md index d17d2381..756fa9f9 100644 --- a/docs/projects/centurion_erp/index.md +++ b/docs/projects/centurion_erp/index.md @@ -28,15 +28,14 @@ Whilst there are many Enterprise Rescource Planning (ERP) applications, Centurio Centurion ERP contains the following modules: +- Change Management + +- [Cluster Management](./user/itim/cluster.md) + - [Companion Ansible Collection](../ansible/collections/centurion/index.md) - [Configuration Management](./user/config_management/index.md) -- [IT Asset Management (ITAM)](./user/itam/index.md) - -- [Knowledge Base](./user/assistance/knowledge_base.md) - - - **Core Features:** - [API](./user/api.md) @@ -51,6 +50,21 @@ Centurion ERP contains the following modules: - [Single Sign-On {SSO}](./user/configuration.md#single-sign-on) +- Incident Management + +- [IT Asset Management (ITAM)](./user/itam/index.md) + +- **Knowledge Management:** + + - [Knowledge Base](./user/assistance/knowledge_base.md) + +- Problem Management + +- [Project Management](./user/project_management/index.md) + +- Request Management + +- [Service Management](./user/itim/service.md) ## Documentation @@ -67,7 +81,7 @@ Specific features for a module can be found on the module's documentation un the ## Development -It's important to us that Centurion ERP remaining stable. To assist with this we do test Centurion during it's development cycle. Testing reports are available and can be viewed from [Gitlab](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests) on each Merge Request. You will find a link to the last report conducted as part of that merge request just below the Merge Request's description. +It's important to us that Centurion ERP remaining stable. To assist with this we do test Centurion during it's development cycle. Testing reports are available and can be viewed from [Github](https://github.com/nofusscomputing/centurion_erp/actions/workflows/ci.yaml). !!! info If you find any test that is less than sufficient, or does not exist; please let us know. If you know a better way of doing the test, even better. We welcome your contribution/feedback. @@ -75,7 +89,7 @@ It's important to us that Centurion ERP remaining stable. To assist with this we ## Roadmap / Planned Features -Below is a list of modules/features we intend to add to Centurion. To find out what we are working on now please view the [Milestones](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/milestones) on Gitlab. +Below is a list of modules/features we intend to add to Centurion. To find out what we are working on now please view the [Milestones](https://github.com/nofusscomputing/centurion_erp/milestones) on Github. - **Planned Modules:** @@ -85,10 +99,6 @@ Below is a list of modules/features we intend to add to Centurion. To find out w - Asset Management _[see #89](https://github.com/nofusscomputing/centurion_erp/issues/88)_ - - Change Management _[see #90](https://github.com/nofusscomputing/centurion_erp/issues/90)_ - - - Config Management - - Core - Location Management (Regions, Sites and Locations) _[see #62](https://github.com/nofusscomputing/centurion_erp/issues/62)_ @@ -103,20 +113,14 @@ Below is a list of modules/features we intend to add to Centurion. To find out w - Human Resource Management _[see #92](https://github.com/nofusscomputing/centurion_erp/issues/92)_ - - Incident Management _[see #93](https://github.com/nofusscomputing/centurion_erp/issues/93)_ - - IT Asset Management (ITAM) - Licence Management _[see #4](https://github.com/nofusscomputing/centurion_erp/issues/4)_ - IT Infrastructure Management (ITIM) _[see #61](https://github.com/nofusscomputing/centurion_erp/issues/61)_ - - Cluster Management _[see #71](https://github.com/nofusscomputing/centurion_erp/issues/71)_ - - Database Management _[see #72](https://github.com/nofusscomputing/centurion_erp/issues/72)_ - - Service Management _[see #19](https://github.com/nofusscomputing/centurion_erp/issues/19)_ - - Software Package Management _[see #96](https://github.com/nofusscomputing/centurion_erp/issues/96)_ - Role Management _[see #70](https://github.com/nofusscomputing/centurion_erp/issues/70)_ @@ -131,12 +135,6 @@ Below is a list of modules/features we intend to add to Centurion. To find out w - Supplier Management _[see #123](https://github.com/nofusscomputing/centurion_erp/issues/123)_ - - Project Management _[see #14](https://github.com/nofusscomputing/centurion_erp/issues/14)_ - - - Problem Management _[see #95](https://github.com/nofusscomputing/centurion_erp/issues/95)_ - - - Request Management _[see #96](https://github.com/nofusscomputing/centurion_erp/issues/96)_ - - **Planned Integrations:** diff --git a/mkdocs.yml b/mkdocs.yml index 85a34536..fa8915c9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,9 @@ docs_dir: 'docs' repo_name: Centurion ERP repo_url: https://github.com/nofusscomputing/centurion_erp edit_uri: '/edit/development/docs/' +theme: + icon: + repo: fontawesome/brands/github plugins: mkdocstrings: From 9cb3afeb30b1a8dccbfdd3fea979619d77698dee Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 16:19:42 +0930 Subject: [PATCH 231/321] feat(core): Disable HTML tag rendering for markdown ref: #284 closes #271 --- app/core/lib/markdown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/lib/markdown.py b/app/core/lib/markdown.py index 9e68ab39..1118bc97 100644 --- a/app/core/lib/markdown.py +++ b/app/core/lib/markdown.py @@ -49,7 +49,7 @@ class Markdown: md = ( MarkdownIt( - config = "commonmark", + config = "js-default", options_update={ 'linkify': True, 'highlight': self.highlight_func, From c45aae7048afd27eb994186534f78a921af598c1 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 16:22:43 +0930 Subject: [PATCH 232/321] chore: docs linting errors ref: #284 --- docs/projects/centurion_erp/index.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/projects/centurion_erp/index.md b/docs/projects/centurion_erp/index.md index 756fa9f9..14eb0dfb 100644 --- a/docs/projects/centurion_erp/index.md +++ b/docs/projects/centurion_erp/index.md @@ -66,6 +66,7 @@ Centurion ERP contains the following modules: - [Service Management](./user/itim/service.md) + ## Documentation Documentation is broken down into three areas, they are: @@ -81,7 +82,7 @@ Specific features for a module can be found on the module's documentation un the ## Development -It's important to us that Centurion ERP remaining stable. To assist with this we do test Centurion during it's development cycle. Testing reports are available and can be viewed from [Github](https://github.com/nofusscomputing/centurion_erp/actions/workflows/ci.yaml). +It's important to us that Centurion ERP remaining stable. To assist with this we do test Centurion during it's development cycle. Testing reports are available and can be viewed from [Github](https://github.com/nofusscomputing/centurion_erp/actions/workflows/ci.yaml). !!! info If you find any test that is less than sufficient, or does not exist; please let us know. If you know a better way of doing the test, even better. We welcome your contribution/feedback. From a7e99eb5b4bf82da46910b680061f667ffe9351e Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 21:07:35 +0930 Subject: [PATCH 233/321] refactor: reduce action comment spacing ref: #24 #284 --- app/project-static/ticketing.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index ed767a17..19f00bbe 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -221,13 +221,21 @@ } -#ticket-comments li { +#ticket-comments li:not(#Action) { line-height: 30px; margin: 0px; margin-bottom: 30px; padding-left: 10px; } +#ticket-comments li#Action { + line-height: 30px; + margin: 0px; + margin-bottom: -10px; + margin-top: -20px; + padding-left: 10px; +} + #data-block h3 { background-color: #177ee6; From c3307152e8ec696a9a656c8a1471b27bd461488e Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 13 Sep 2024 21:58:19 +0930 Subject: [PATCH 234/321] feat(core): Add slash command `/spend` for ticket and ticket comments ref: #284 closes #286 --- app/core/forms/ticket_comment.py | 10 ++- app/core/lib/slash_commands/__init__.py | 37 ++++++++++ app/core/lib/slash_commands/duration.py | 91 ++++++++++++++++++++++++ app/core/models/ticket/ticket.py | 11 +++ app/core/models/ticket/ticket_comment.py | 5 ++ 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 app/core/lib/slash_commands/__init__.py create mode 100644 app/core/lib/slash_commands/duration.py diff --git a/app/core/forms/ticket_comment.py b/app/core/forms/ticket_comment.py index 23c2aa19..0a93265e 100644 --- a/app/core/forms/ticket_comment.py +++ b/app/core/forms/ticket_comment.py @@ -61,6 +61,8 @@ class CommentForm( 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() @@ -69,10 +71,16 @@ class CommentForm( 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 not self._has_triage_permission: + 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 + diff --git a/app/core/lib/slash_commands/__init__.py b/app/core/lib/slash_commands/__init__.py new file mode 100644 index 00000000..ec5666c2 --- /dev/null +++ b/app/core/lib/slash_commands/__init__.py @@ -0,0 +1,37 @@ +import re + +from core.lib.slash_commands.duration import Duration + + +class SlashCommands( + Duration +): + """Slash Commands Base Class + + This class in intended to be included in the following models: + + - Ticket + + - TicketComment + """ + + + 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) + + return markdown diff --git a/app/core/lib/slash_commands/duration.py b/app/core/lib/slash_commands/duration.py new file mode 100644 index 00000000..1d9315b9 --- /dev/null +++ b/app/core/lib/slash_commands/duration.py @@ -0,0 +1,91 @@ +import re + + +class Duration: + """Duration Slash Command + + The command keyword is `spend` and you can also use `spent`. The formatting for the time + after the command, is 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 `` (`\n`) or a `` char immediatly before the slash `/` + + - There is a `` char after the command keyword, i.e. `/spend1h` + + - _Optional_ `` char between the time blocks. + """ + + + time_spent: str = r'[\s|\n]\/(?P[spend|spent]+)\s(?P
diff --git a/app/core/views/ticket_linked_item.py b/app/core/views/ticket_linked_item.py new file mode 100644 index 00000000..ba60e64d --- /dev/null +++ b/app/core/views/ticket_linked_item.py @@ -0,0 +1,92 @@ +from django.urls import reverse +from django.views import generic + +from django_celery_results.models import TaskResult + +from access.mixin import OrganizationPermission + +from core.forms.ticket_linked_item import TicketLinkedItem, TicketLinkedItemForm +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = TicketLinkedItemForm + + model = TicketLinkedItem + + permission_required = [ + 'itam.add_device', + ] + + template_name = 'form.html.j2' + + + def get_initial(self): + + initial_values: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization, + 'ticket': self.kwargs['ticket_id'], + } + + return initial_values + + + def get_success_url(self, **kwargs): + + if self.kwargs['ticket_type'] == 'request': + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) + + elif self.kwargs['ticket_type'] == 'project_task': + + return reverse('Project Management:_project_task_view', args=(self.object.from_ticket_id.project.id, self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) + + else: + + return reverse('ITIM:_ticket_' + str(self.kwargs['ticket_type']).lower() + '_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Ticket Linked Item' + + return context + + + +class Delete(DeleteView): + + model = TicketLinkedItem + + permission_required = [ + 'itim.delete_cluster', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + str(self.object) + + return context + + + def get_success_url(self, **kwargs): + + if self.kwargs['ticket_type'] == 'request': + + return reverse('Assistance:_ticket_request_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) + + elif self.kwargs['ticket_type'] == 'project_task': + + return reverse('Project Management:_project_task_view', args=(self.object.from_ticket_id.project.id, self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) + + else: + + return reverse('ITIM:_ticket_' + str(self.kwargs['ticket_type']).lower() + '_view', args=(self.kwargs['ticket_type'],self.kwargs['ticket_id'],)) diff --git a/app/project-static/ticketing.css b/app/project-static/ticketing.css index 19f00bbe..a756136d 100644 --- a/app/project-static/ticketing.css +++ b/app/project-static/ticketing.css @@ -208,6 +208,11 @@ width: 33%; } +#data-block.linked-item div#item p{ + margin: 0px; + padding: 0px; +} + #ticket-comments { padding: 10px; diff --git a/docs/projects/centurion_erp/development/models.md b/docs/projects/centurion_erp/development/models.md index 9b08df64..85f0a915 100644 --- a/docs/projects/centurion_erp/development/models.md +++ b/docs/projects/centurion_erp/development/models.md @@ -45,6 +45,12 @@ All models must meet the following requirements: - If creating a new model, function `access.functions.permissions.permission_queryset()` has been updated to display the models permission(s) +## Checklist + +This section details the additional items that may need to be done when adding a new model: + +- If the model is a primary model, add to model reference rendering in `app/core/lib/markdown_plugins/model_reference.py` function `tag_html` + ## History Currently the adding of history to a model is a manual process. edit the file located at `core.views.history` and within `View.get_object` add the model to the `switch` statement. From c022551427bc065562d5b59d19bc461edcc76c44 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 20 Sep 2024 15:26:38 +0930 Subject: [PATCH 297/321] feat(core): Add to markdown rendering model references ref: #296 #308 --- app/core/lib/markdown.py | 3 +- .../lib/markdown_plugins/model_reference.py | 142 ++++++++++++++++++ .../centurion_erp/development/models.md | 1 + .../centurion_erp/user/core/markdown.md | 19 +++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 app/core/lib/markdown_plugins/model_reference.py diff --git a/app/core/lib/markdown.py b/app/core/lib/markdown.py index 1118bc97..d7439ac4 100644 --- a/app/core/lib/markdown.py +++ b/app/core/lib/markdown.py @@ -10,7 +10,7 @@ from pygments.lexers import get_lexer_by_name from django.template.loader import render_to_string -from .markdown_plugins import ticket_number +from .markdown_plugins import ticket_number, model_reference @@ -67,6 +67,7 @@ class Markdown: .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) diff --git a/app/core/lib/markdown_plugins/model_reference.py b/app/core/lib/markdown_plugins/model_reference.py new file mode 100644 index 00000000..18321454 --- /dev/null +++ b/app/core/lib/markdown_plugins/model_reference.py @@ -0,0 +1,142 @@ + +import re + +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 `$-` 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) + ) + + return f'{ item.name }, { item_type }' + + 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[a-z_]+)-(?P\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) diff --git a/docs/projects/centurion_erp/development/models.md b/docs/projects/centurion_erp/development/models.md index 85f0a915..ab101d86 100644 --- a/docs/projects/centurion_erp/development/models.md +++ b/docs/projects/centurion_erp/development/models.md @@ -51,6 +51,7 @@ This section details the additional items that may need to be done when adding a - If the model is a primary model, add to model reference rendering in `app/core/lib/markdown_plugins/model_reference.py` function `tag_html` + ## History Currently the adding of history to a model is a manual process. edit the file located at `core.views.history` and within `View.get_object` add the model to the `switch` statement. diff --git a/docs/projects/centurion_erp/user/core/markdown.md b/docs/projects/centurion_erp/user/core/markdown.md index c03bf313..cb6e48b1 100644 --- a/docs/projects/centurion_erp/user/core/markdown.md +++ b/docs/projects/centurion_erp/user/core/markdown.md @@ -62,3 +62,22 @@ Available admonition types are: ## Ticket References Declare a ticket reference in format `#`, and it will be rendered as a link to the ticket. i.e. `#2` + + +## Model + +A Model link is a reference to an item within the database. Supported model link items are: + +- cluster + +- config_group + +- device + +- service + +- software + +- operating system + +To declare a model link use syntax `$-`. i.e. for device 1, it would be `$device-1` From dfdc5bac9d501b30c126c3fdf247db73ff243af5 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 20 Sep 2024 16:57:34 +0930 Subject: [PATCH 298/321] feat(core): Add slash command `link` for linking items to tickets ref: #296 #308 --- app/core/lib/slash_commands/__init__.py | 6 +- app/core/lib/slash_commands/linked_model.py | 148 ++++++++++++++++++ app/core/models/ticket/__init__.py | 0 .../centurion_erp/development/models.md | 4 +- .../centurion_erp/user/core/tickets.md | 19 +++ mkdocs.yml | 8 + 6 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 app/core/lib/slash_commands/linked_model.py create mode 100644 app/core/models/ticket/__init__.py diff --git a/app/core/lib/slash_commands/__init__.py b/app/core/lib/slash_commands/__init__.py index a4928ba1..ef680d6a 100644 --- a/app/core/lib/slash_commands/__init__.py +++ b/app/core/lib/slash_commands/__init__.py @@ -2,11 +2,13 @@ import re from .duration import Duration from .related_ticket import CommandRelatedTicket +from .linked_model import CommandLinkedModel class SlashCommands( Duration, - CommandRelatedTicket + CommandRelatedTicket, + CommandLinkedModel, ): """Slash Commands Base Class @@ -38,6 +40,8 @@ class SlashCommands( 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 diff --git a/app/core/lib/slash_commands/linked_model.py b/app/core/lib/slash_commands/linked_model.py new file mode 100644 index 00000000..dbd40c49 --- /dev/null +++ b/app/core/lib/slash_commands/linked_model.py @@ -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. `$-`. + +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 `` (`\\n`) or a `` char immediatly before the slash `/` + +- There is a `` char after the command keyword, i.e. `/link$device-101` +""" + + + linked_item: str = r'[\s|\n]\/(?P[link]+)\s\$(?P[a-z_]+)-(?P\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 diff --git a/app/core/models/ticket/__init__.py b/app/core/models/ticket/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/projects/centurion_erp/development/models.md b/docs/projects/centurion_erp/development/models.md index ab101d86..a142cde1 100644 --- a/docs/projects/centurion_erp/development/models.md +++ b/docs/projects/centurion_erp/development/models.md @@ -49,7 +49,9 @@ All models must meet the following requirements: This section details the additional items that may need to be done when adding a new model: -- If the model is a primary model, add to model reference rendering in `app/core/lib/markdown_plugins/model_reference.py` function `tag_html` +- If the model is a primary model, add it to model reference rendering in `app/core/lib/markdown_plugins/model_reference.py` function `tag_html` + +- If the model is a primary model, add it to the model link slash command in `app/core/lib/slash_commands/linked_model.py` function `command_linked_model` ## History diff --git a/docs/projects/centurion_erp/user/core/tickets.md b/docs/projects/centurion_erp/user/core/tickets.md index b6c40e04..b3da5eb4 100644 --- a/docs/projects/centurion_erp/user/core/tickets.md +++ b/docs/projects/centurion_erp/user/core/tickets.md @@ -13,6 +13,12 @@ The ticketing system within Centurion ERP is common to all ticket types. The dif - Commenting +- Linked Items to ticket + +- Milestone + +- Project + - Related Tickets - Slash commands @@ -47,6 +53,8 @@ Comment types are: Slash commands are a quick action that is specified after a slash command. As the name implies, the command starts with a slash `/`. The following slash commands are available: +- Linked Item `link` + - Related `/blocked_by`, `/blocks` and `/relate` - Time Spent `/spend`, `/spent` @@ -63,6 +71,17 @@ Slash commands are a quick action that is specified after a slash command. As th summary: true +### Linked Items + +::: app.core.lib.slash_commands.CommandLinkedModel + options: + inherited_members: false + members: [] + show_bases: false + show_submodules: false + summary: true + + ### Related Tickets ::: app.core.lib.slash_commands.CommandRelatedTicket diff --git a/mkdocs.yml b/mkdocs.yml index fa8915c9..49c686c1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,8 @@ nav: - projects/centurion_erp/development/api/models/access_organization_permission_checking.md + - projects/centurion_erp/development/api/models/ticket.md + - projects/centurion_erp/development/api/models/tenancy_object.md - projects/centurion_erp/development/api/common_views.md @@ -229,6 +231,12 @@ nav: - projects/centurion_erp/user/project_management/project.md + - projects/centurion_erp/user/project_management/project_state.md + + - projects/centurion_erp/user/project_management/project_task.md + + - projects/centurion_erp/user/project_management/project_type.md + - Settings: - projects/centurion_erp/user/settings/index.md From fff3a968893ea73522d70e2cb022cde57d5aa353 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 20 Sep 2024 16:57:53 +0930 Subject: [PATCH 299/321] fix(core): remove org field when editing a ticket ref: #308 --- app/core/forms/ticket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/forms/ticket.py b/app/core/forms/ticket.py index 47119f4a..bf8c7165 100644 --- a/app/core/forms/ticket.py +++ b/app/core/forms/ticket.py @@ -55,7 +55,7 @@ class TicketForm( if self.instance.pk is not None: - self.fields['organization'].widget = self.fields['organization'].hidden_widget() + del self.fields['organization'] if self.instance.project is not None: From 582ee4031d90d590c7eee8da62432a217000dffe Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 10:57:24 +0930 Subject: [PATCH 300/321] docs(core): correct typos ref: #309 --- docs/projects/centurion_erp/user/core/markdown.md | 2 +- docs/projects/centurion_erp/user/core/tickets.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/projects/centurion_erp/user/core/markdown.md b/docs/projects/centurion_erp/user/core/markdown.md index cb6e48b1..22de1661 100644 --- a/docs/projects/centurion_erp/user/core/markdown.md +++ b/docs/projects/centurion_erp/user/core/markdown.md @@ -64,7 +64,7 @@ Available admonition types are: Declare a ticket reference in format `#`, and it will be rendered as a link to the ticket. i.e. `#2` -## Model +## Model Reference A Model link is a reference to an item within the database. Supported model link items are: diff --git a/docs/projects/centurion_erp/user/core/tickets.md b/docs/projects/centurion_erp/user/core/tickets.md index b3da5eb4..21d68226 100644 --- a/docs/projects/centurion_erp/user/core/tickets.md +++ b/docs/projects/centurion_erp/user/core/tickets.md @@ -53,7 +53,7 @@ Comment types are: Slash commands are a quick action that is specified after a slash command. As the name implies, the command starts with a slash `/`. The following slash commands are available: -- Linked Item `link` +- Linked Item `/link` - Related `/blocked_by`, `/blocks` and `/relate` From 1c1f4ecdfa48dbc443e591f4a6e5e44a20ed7e1e Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 11:44:57 +0930 Subject: [PATCH 301/321] test(core): Ticket Linked item permission checks ref: #296 #309 --- app/app/tests/abstract/model_permissions.py | 45 +- .../test_ticket_linked_item_permission.py | 440 ++++++++++++++++++ app/core/views/ticket_linked_item.py | 2 +- 3 files changed, 481 insertions(+), 6 deletions(-) create mode 100644 app/core/tests/unit/ticket_linked_item/test_ticket_linked_item_permission.py diff --git a/app/app/tests/abstract/model_permissions.py b/app/app/tests/abstract/model_permissions.py index d293e6c0..3a98176b 100644 --- a/app/app/tests/abstract/model_permissions.py +++ b/app/app/tests/abstract/model_permissions.py @@ -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) @@ -199,7 +227,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.add_user) diff --git a/app/core/tests/unit/ticket_linked_item/test_ticket_linked_item_permission.py b/app/core/tests/unit/ticket_linked_item/test_ticket_linked_item_permission.py new file mode 100644 index 00000000..bb3f8648 --- /dev/null +++ b/app/core/tests/unit/ticket_linked_item/test_ticket_linked_item_permission.py @@ -0,0 +1,440 @@ +import re + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import reverse +from django.test import TestCase, Client + +import pytest +import unittest +import requests + +from access.models import Organization, Team, TeamUsers, Permission + +from app.tests.abstract.model_permissions import ModelPermissions, ModelPermissionsAdd + +from project_management.models.projects import Project + +from core.models.ticket.ticket import Ticket +from core.models.ticket.ticket_linked_items import TicketLinkedItem + +# from core.tests.unit.ticket.ticket_permission.field_based_permissions import ITSMTicketFieldBasedPermissions, ProjectTicketFieldBasedPermissions + +from itam.models.device import Device + +from settings.models.user_settings import UserSettings + + +class TicketLinkedItemPermissions( + ModelPermissionsAdd, +): + + ticket_type:str = None + + ticket_type_enum: int = None + + model = TicketLinkedItem + + app_namespace = '' + + # url_name_view = '_ticket_request_view' + + # url_name_add = '_ticket_request_add' + + # url_name_change = '_ticket_request_change' + + # url_name_delete = '_ticket_request_delete' + + # url_delete_response = reverse('Assistance:Requests') + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + . create an organization that is different to item + 2. Create a manufacturer + 3. create teams with each permission: view, add, change, delete + 4. create a user per team + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + + add_permissions = Permission.objects.get( + codename = 'add_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + add_team = Team.objects.create( + team_name = 'add_team', + organization = organization, + ) + + add_team.permissions.set([add_permissions]) + + + self.add_user = User.objects.create_user(username="test_user_add", password="password") + teamuser = TeamUsers.objects.create( + team = add_team, + user = self.add_user + ) + + user_settings = UserSettings.objects.get(user = self.add_user) + user_settings.default_organization = self.organization + user_settings.save() + + + self.ticket = Ticket.objects.create( + organization=organization, + title = 'A ' + self.ticket_type + ' ticket', + description = 'the ticket body', + ticket_type = self.ticket_type_enum, + opened_by = self.add_user, + status = int(Ticket.TicketStatus.All.NEW.value) + ) + + self.second_ticket = Ticket.objects.create( + organization=organization, + title = 'A second ' + self.ticket_type + ' ticket', + description = 'the ticket body of item two', + ticket_type = self.ticket_type_enum, + opened_by = self.add_user, + status = int(Ticket.TicketStatus.All.NEW.value) + ) + + self.device = Device.objects.create( + name = 'device-' + self.ticket_type, + organization=organization, + ) + + # self.project = Project.objects.create( + # name = 'ticket permissions project name', + # organization = organization + # ) + + # self.project_two = Project.objects.create( + # name = 'ticket permissions project name two', + # organization = organization + # ) + + + # self.url_view_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + + self.url_add_kwargs = {'ticket_type': self.ticket_type, 'ticket_id': self.ticket.id} + + self.add_data = { + 'ticket': self.ticket.id, + 'item': self.device.id, + 'organization': self.organization.id, + } + + # self.url_change_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + + # self.change_data = {'title': 'an change to ticket'} + + # self.url_delete_kwargs = {'ticket_type': self.ticket_type, 'pk': self.item.id} + + # self.delete_data = {'title': 'a delete to ticket'} + + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + view_team = Team.objects.create( + team_name = 'view_team', + organization = organization, + ) + + view_team.permissions.set([view_permissions]) + + + change_permissions = Permission.objects.get( + codename = 'change_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + change_team = Team.objects.create( + team_name = 'change_team', + organization = organization, + ) + + change_team.permissions.set([change_permissions]) + + self.change_team = change_team + + + + delete_permissions = Permission.objects.get( + codename = 'delete_' + self.model._meta.model_name, + content_type = ContentType.objects.get( + app_label = self.model._meta.app_label, + model = self.model._meta.model_name, + ) + ) + + delete_team = Team.objects.create( + team_name = 'delete_team', + organization = organization, + ) + + delete_team.permissions.set([delete_permissions]) + + + self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password") + + + self.view_user = User.objects.create_user(username="test_user_view", password="password") + teamuser = TeamUsers.objects.create( + team = view_team, + user = self.view_user + ) + + self.change_user = User.objects.create_user(username="test_user_change", password="password") + teamuser = TeamUsers.objects.create( + team = change_team, + user = self.change_user + ) + + self.delete_user = User.objects.create_user(username="test_user_delete", password="password") + teamuser = TeamUsers.objects.create( + team = delete_team, + user = self.delete_user + ) + + + self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password") + + + different_organization_team = Team.objects.create( + team_name = 'different_organization_team', + organization = different_organization, + ) + + different_organization_team.permissions.set([ + view_permissions, + add_permissions, + change_permissions, + delete_permissions, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) + + # Import user/permissions + + # import_permissions = Permission.objects.get( + # codename = 'import_' + self.model._meta.model_name, + # content_type = ContentType.objects.get( + # app_label = self.model._meta.app_label, + # model = self.model._meta.model_name, + # ) + # ) + + # import_team = Team.objects.create( + # team_name = 'import_team', + # organization = organization, + # ) + + # import_team.permissions.set([change_permissions, import_permissions]) + + + # self.import_user = User.objects.create_user(username="test_user_import", password="password") + # teamuser = TeamUsers.objects.create( + # team = import_team, + # user = self.import_user + # ) + + # Triage user/permissions + + # triage_permissions = Permission.objects.get( + # codename = 'triage_' + self.model._meta.model_name, + # content_type = ContentType.objects.get( + # app_label = self.model._meta.app_label, + # model = self.model._meta.model_name, + # ) + # ) + + # triage_team = Team.objects.create( + # team_name = 'triage_team', + # organization = organization, + # ) + + # triage_team.permissions.set([change_permissions, triage_permissions]) + + + # self.triage_user = User.objects.create_user(username="test_user_triage", password="password") + # teamuser = TeamUsers.objects.create( + # team = triage_team, + # user = self.triage_user + # ) + + + +class ITSMTicketLinkedItemPermissions( + TicketLinkedItemPermissions, +): + + pass + + + +class ProjectTicketLinkedItemPermissions( + TicketLinkedItemPermissions, +): + + pass + + + +class ChangeTicketLinkedItemPermissions(ITSMTicketLinkedItemPermissions, TestCase): + + ticket_type = 'change' + + ticket_type_enum: int = int(Ticket.TicketType.CHANGE.value) + + app_namespace = '' + + # url_name_view = '_ticket_change_view' + + url_name_add = '_ticket_linked_item_add' + + # url_name_change = '_ticket_change_change' + + # url_name_delete = '_ticket_change_delete' + + # url_delete_response = reverse('ITIM:Changes') + + + +class IncidentTicketLinkedItemPermissions(ITSMTicketLinkedItemPermissions, TestCase): + + ticket_type = 'incident' + + ticket_type_enum: int = int(Ticket.TicketType.INCIDENT.value) + + # app_namespace = 'ITIM' + + # url_name_view = '_ticket_incident_view' + + url_name_add = '_ticket_linked_item_add' + + # url_name_change = '_ticket_incident_change' + + # url_name_delete = '_ticket_incident_delete' + + # url_delete_response = reverse('ITIM:Incidents') + + + +class ProblemTicketLinkedItemPermissions(ITSMTicketLinkedItemPermissions, TestCase): + + ticket_type = 'problem' + + ticket_type_enum: int = int(Ticket.TicketType.PROBLEM.value) + + # app_namespace = 'ITIM' + + # url_name_view = '_ticket_problem_view' + + url_name_add = '_ticket_linked_item_add' + + # url_name_change = '_ticket_problem_change' + + # url_name_delete = '_ticket_problem_delete' + + # url_delete_response = reverse('ITIM:Problems') + + + +class ProjectTaskTicketLinkedItemPermissions(ProjectTicketLinkedItemPermissions, TestCase): + + ticket_type = 'project_task' + + ticket_type_enum: int = int(Ticket.TicketType.PROJECT_TASK.value) + + # app_namespace = 'Project Management' + + # url_name_view = '_project_task_view' + + url_name_add = '_ticket_linked_item_add' + + # url_name_change = '_project_task_change' + + # url_name_delete = '_project_task_delete' + + + + + # @classmethod + # def setUpTestData(self): + # """Setup Test + + # 1. Create an organization for user and item + # . create an organization that is different to item + # 2. Create a manufacturer + # 3. create teams with each permission: view, add, change, delete + # 4. create a user per team + # """ + + # super().setUpTestData() + + # self.item = self.model.objects.create( + # organization = self.organization, + # title = 'Amended ' + self.ticket_type + ' ticket', + # description = 'the ticket body', + # ticket_type = int(Ticket.TicketType.REQUEST.value), + # opened_by = self.add_user, + # status = int(Ticket.TicketStatus.All.NEW.value), + # project = self.project + # ) + + # self.url_add_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type} + + # self.url_change_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type, 'pk': self.item.id} + + # self.url_delete_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type, 'pk': self.project.id} + + # # self.url_delete_kwargs = {'pk': self.project.id} + + # self.url_view_kwargs = {'project_id': self.project.id, 'ticket_type': self.ticket_type, 'pk': self.item.id} + + # self.url_delete_response = reverse('Project Management:_project_view', kwargs={'pk': self.project.id}) + + + +class RequestTicketLinkedItemPermissions(ITSMTicketLinkedItemPermissions, TestCase): + + ticket_type = 'request' + + ticket_type_enum: int = int(Ticket.TicketType.REQUEST.value) + + # app_namespace = '' + + # url_name_view = '_ticket_request_view' + + url_name_add = '_ticket_linked_item_add' + + # url_name_change = '_ticket_request_change' + + # url_name_delete = '_ticket_request_delete' + + # url_delete_response = reverse('Assistance:Requests') diff --git a/app/core/views/ticket_linked_item.py b/app/core/views/ticket_linked_item.py index ba60e64d..746b0aae 100644 --- a/app/core/views/ticket_linked_item.py +++ b/app/core/views/ticket_linked_item.py @@ -19,7 +19,7 @@ class Add(AddView): model = TicketLinkedItem permission_required = [ - 'itam.add_device', + 'core.add_ticketlinkeditem', ] template_name = 'form.html.j2' From 104575780a56681aca2e90a38d566c44ef0b0b77 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 11:45:24 +0930 Subject: [PATCH 302/321] test(core): Ticket Linked item view checks ref: #296 #309 --- .../test_ticket_linked_item_views.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 app/core/tests/unit/ticket_linked_item/test_ticket_linked_item_views.py diff --git a/app/core/tests/unit/ticket_linked_item/test_ticket_linked_item_views.py b/app/core/tests/unit/ticket_linked_item/test_ticket_linked_item_views.py new file mode 100644 index 00000000..782e5f48 --- /dev/null +++ b/app/core/tests/unit/ticket_linked_item/test_ticket_linked_item_views.py @@ -0,0 +1,29 @@ +import pytest +import unittest +import requests + +from django.test import TestCase + +from app.tests.abstract.models import ModelAdd, PrimaryModel + + + +class TicketLinkedItemViews( + TestCase, + ModelAdd +): + + add_module = 'core.views.ticket_linked_item' + add_view = 'Add' + + change_module = add_module + change_view = 'Change' + + delete_module = add_module + delete_view = 'Delete' + + display_module = add_module + display_view = 'View' + + index_module = add_module + index_view = 'Index' From b5ec42fc563da788e879a3dd0345ca0f7f3c39e7 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 12:01:58 +0930 Subject: [PATCH 303/321] fix(api): Ensure user is set to current user for ticket comment ref: #296 #309 --- app/api/serializers/core/ticket_comment.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/api/serializers/core/ticket_comment.py b/app/api/serializers/core/ticket_comment.py index 8f58f6a1..4a21ea39 100644 --- a/app/api/serializers/core/ticket_comment.py +++ b/app/api/serializers/core/ticket_comment.py @@ -68,5 +68,7 @@ class TicketCommentSerializer(serializers.ModelSerializer): 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) From d26868ecedb7891347b545b358c9030eb024c8a3 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 12:28:00 +0930 Subject: [PATCH 304/321] refactor(core): Ticket Linked ref render as template ref: #296 #309 --- app/core/lib/markdown_plugins/model_reference.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/core/lib/markdown_plugins/model_reference.py b/app/core/lib/markdown_plugins/model_reference.py index 18321454..bb3c65b2 100644 --- a/app/core/lib/markdown_plugins/model_reference.py +++ b/app/core/lib/markdown_plugins/model_reference.py @@ -1,6 +1,7 @@ import re +from django.template import Context, Template from django.template.loader import render_to_string from django.urls import reverse @@ -112,7 +113,19 @@ def plugin( pk = int(id) ) - return f'{ item.name }, { item_type }' + html_template = Template(''' + + {{ name }}, {{ item_type }} + + ''') + context = Context({ + 'url': url, + 'item_type': item_type, + 'name': item.name + }) + html = html_template.render(context) + + return html except Exception as e: From 064f74736fa9a94f981177f67874c03e166fde9a Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 13:10:36 +0930 Subject: [PATCH 305/321] feat(config_management): Add ticket tab to conf groups ref: #296 #309 --- app/config_management/forms/group/group.py | 5 ++++ .../templates/config_management/group.html.j2 | 28 +++++++++++++++++++ app/config_management/views/groups/groups.py | 6 ++++ 3 files changed, 39 insertions(+) diff --git a/app/config_management/forms/group/group.py b/app/config_management/forms/group/group.py index 1b31e0fd..022f083d 100644 --- a/app/config_management/forms/group/group.py +++ b/app/config_management/forms/group/group.py @@ -87,6 +87,11 @@ class DetailForm(ConfigGroupForm): "slug": "configuration", "sections": [] }, + "tickets": { + "name": "Tickets", + "slug": "tickets", + "sections": [] + }, "notes": { "name": "Notes", "slug": "notes", diff --git a/app/config_management/templates/config_management/group.html.j2 b/app/config_management/templates/config_management/group.html.j2 index 1b401c30..6c900098 100644 --- a/app/config_management/templates/config_management/group.html.j2 +++ b/app/config_management/templates/config_management/group.html.j2 @@ -130,6 +130,34 @@
+
+ + {% include 'content/section.html.j2' with tab=form.tabs.tickets %} + + + + + + + + {% if tickets %} + {% for ticket in tickets %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
NameStatus 
{% concat_strings "#" ticket.ticket.id as ticket_ref %}{{ ticket_ref | markdown | safe}}{% 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 %} 
No related tickets exist
+ +
+ +
{% include 'content/section.html.j2' with tab=form.tabs.notes %} diff --git a/app/config_management/views/groups/groups.py b/app/config_management/views/groups/groups.py index f1440033..7d22c638 100644 --- a/app/config_management/views/groups/groups.py +++ b/app/config_management/views/groups/groups.py @@ -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 @@ -159,6 +160,11 @@ class View(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']) From 280abb884111d0bb232228f91adc95ae0e02c549 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 13:11:24 +0930 Subject: [PATCH 306/321] feat(itam): Add ticket tab to devices shows related tickets ref: #296 #309 --- app/itam/forms/device/device.py | 5 +++++ app/itam/templates/itam/device.html.j2 | 25 +++++++++++++++++++++++++ app/itam/views/device.py | 5 +++++ 3 files changed, 35 insertions(+) diff --git a/app/itam/forms/device/device.py b/app/itam/forms/device/device.py index d459398c..d6b78ed5 100644 --- a/app/itam/forms/device/device.py +++ b/app/itam/forms/device/device.py @@ -61,6 +61,11 @@ class DetailForm(DeviceForm): "slug": "software", "sections": [] }, + "tickets": { + "name": "Tickets", + "slug": "tickets", + "sections": [] + }, "notes": { "name": "Notes", "slug": "notes", diff --git a/app/itam/templates/itam/device.html.j2 b/app/itam/templates/itam/device.html.j2 index e73c4285..6cc499e9 100644 --- a/app/itam/templates/itam/device.html.j2 +++ b/app/itam/templates/itam/device.html.j2 @@ -136,7 +136,32 @@
+
+ {% include 'content/section.html.j2' with tab=form.tabs.tickets %} + + + + + + + + {% if tickets %} + {% for ticket in tickets %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
NameStatus 
{% concat_strings "#" ticket.ticket.id as ticket_ref %}{{ ticket_ref | markdown | safe}}{% 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 %} 
No related tickets exist
+ +
diff --git a/app/itam/views/device.py b/app/itam/views/device.py index 1003560d..db64cf89 100644 --- a/app/itam/views/device.py +++ b/app/itam/views/device.py @@ -16,6 +16,7 @@ from ..models.software import Software 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.forms.device_softwareadd import SoftwareAdd @@ -106,6 +107,10 @@ class View(ChangeView): context['services'] = Service.objects.filter(device=self.kwargs['pk']) + context['tickets'] = TicketLinkedItem.objects.filter( + item = int(self.kwargs['pk']), + item_type = TicketLinkedItem.Modules.DEVICE + ) softwares = DeviceSoftware.objects.filter(device=self.kwargs['pk']) softwares = Paginator(softwares, 10) From facdd0111b2b4d6d97d37ee1e0e14028da59974a Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 13:11:48 +0930 Subject: [PATCH 307/321] feat(itam): Add ticket tab to operating systems shows related tickets ref: #296 #309 --- app/itam/forms/operating_system/update.py | 5 ++++ .../templates/itam/operating_system.html.j2 | 26 +++++++++++++++++++ app/itam/views/operating_system.py | 6 +++++ 3 files changed, 37 insertions(+) diff --git a/app/itam/forms/operating_system/update.py b/app/itam/forms/operating_system/update.py index d398c65e..80473a24 100644 --- a/app/itam/forms/operating_system/update.py +++ b/app/itam/forms/operating_system/update.py @@ -91,6 +91,11 @@ class DetailForm(OperatingSystemForm): "slug": "installations", "sections": [] }, + "tickets": { + "name": "Tickets", + "slug": "tickets", + "sections": [] + }, "notes": { "name": "Notes", "slug": "notes", diff --git a/app/itam/templates/itam/operating_system.html.j2 b/app/itam/templates/itam/operating_system.html.j2 index ff4dc0c4..0a9d8f02 100644 --- a/app/itam/templates/itam/operating_system.html.j2 +++ b/app/itam/templates/itam/operating_system.html.j2 @@ -106,6 +106,32 @@
+
+ + {% include 'content/section.html.j2' with tab=form.tabs.tickets %} + + + + + + + + {% if tickets %} + {% for ticket in tickets %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
NameStatus 
{% concat_strings "#" ticket.ticket.id as ticket_ref %}{{ ticket_ref | markdown | safe}}{% 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 %} 
No related tickets exist
+ +
diff --git a/app/itam/views/operating_system.py b/app/itam/views/operating_system.py index d0a93493..24c78f32 100644 --- a/app/itam/views/operating_system.py +++ b/app/itam/views/operating_system.py @@ -5,6 +5,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 DeviceOperatingSystem @@ -187,6 +188,11 @@ class View(ChangeView): context['operating_system_versions'] = operating_system_versions + context['tickets'] = TicketLinkedItem.objects.filter( + item = int(self.kwargs['pk']), + item_type = TicketLinkedItem.Modules.OPERATING_SYSTEM + ) + installs = DeviceOperatingSystem.objects.filter(operating_system_version__operating_system_id=self.kwargs['pk']) context['installs'] = installs From e762713416b2e101bfe4fe21ca9066c05d6f8dc7 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 13:12:01 +0930 Subject: [PATCH 308/321] feat(itam): Add ticket tab to software shows related tickets ref: #296 #309 --- app/itam/forms/software/update.py | 5 +++++ app/itam/templates/itam/software.html.j2 | 27 ++++++++++++++++++++++++ app/itam/views/software.py | 6 ++++++ 3 files changed, 38 insertions(+) diff --git a/app/itam/forms/software/update.py b/app/itam/forms/software/update.py index 77679d2e..e9fd346f 100644 --- a/app/itam/forms/software/update.py +++ b/app/itam/forms/software/update.py @@ -78,6 +78,11 @@ class DetailForm(SoftwareForm): "slug": "licences", "sections": [] }, + "tickets": { + "name": "Tickets", + "slug": "tickets", + "sections": [] + }, "notes": { "name": "Notes", "slug": "notes", diff --git a/app/itam/templates/itam/software.html.j2 b/app/itam/templates/itam/software.html.j2 index 7ff75bc9..1810268c 100644 --- a/app/itam/templates/itam/software.html.j2 +++ b/app/itam/templates/itam/software.html.j2 @@ -76,6 +76,33 @@
+
+ + {% include 'content/section.html.j2' with tab=form.tabs.tickets %} + + + + + + + + {% if tickets %} + {% for ticket in tickets %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
NameStatus 
{% concat_strings "#" ticket.ticket.id as ticket_ref %}{{ ticket_ref | markdown | safe}}{% 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 %} 
No related tickets exist
+ +
+
{% include 'content/section.html.j2' with tab=form.tabs.notes %} diff --git a/app/itam/views/software.py b/app/itam/views/software.py index 5e6b1683..d784245d 100644 --- a/app/itam/views/software.py +++ b/app/itam/views/software.py @@ -5,6 +5,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 DeviceSoftware @@ -109,6 +110,11 @@ class View(ChangeView): context['software_versions'] = software_versions + context['tickets'] = TicketLinkedItem.objects.filter( + item = int(self.kwargs['pk']), + item_type = TicketLinkedItem.Modules.SOFTWARE + ) + context['notes_form'] = AddNoteForm(prefix='note') context['notes'] = Notes.objects.filter(software=self.kwargs['pk']) From c74b89e0d6196373d4977d2681fd5c73ff27eb52 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 13:12:16 +0930 Subject: [PATCH 309/321] feat(itim): Add ticket tab to clusters shows related tickets ref: #296 #309 --- app/itim/forms/clusters.py | 5 +++++ app/itim/templates/itim/cluster.html.j2 | 27 +++++++++++++++++++++++++ app/itim/views/clusters.py | 6 ++++++ 3 files changed, 38 insertions(+) diff --git a/app/itim/forms/clusters.py b/app/itim/forms/clusters.py index 923f28c2..3f8ae3f4 100644 --- a/app/itim/forms/clusters.py +++ b/app/itim/forms/clusters.py @@ -92,6 +92,11 @@ class DetailForm(ClusterForm): } ] }, + "tickets": { + "name": "Tickets", + "slug": "tickets", + "sections": [] + }, "notes": { "name": "Notes", "slug": "notes", diff --git a/app/itim/templates/itim/cluster.html.j2 b/app/itim/templates/itim/cluster.html.j2 index de59180b..d7feb9c4 100644 --- a/app/itim/templates/itim/cluster.html.j2 +++ b/app/itim/templates/itim/cluster.html.j2 @@ -105,4 +105,31 @@
+
+ + {% include 'content/section.html.j2' with tab=form.tabs.tickets %} + + + + + + + + {% if tickets %} + {% for ticket in tickets %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
NameStatus 
{% concat_strings "#" ticket.ticket.id as ticket_ref %}{{ ticket_ref | markdown | safe}}{% 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 %} 
No related tickets exist
+ +
+ {% endblock %} \ No newline at end of file diff --git a/app/itim/views/clusters.py b/app/itim/views/clusters.py index 6de866e1..7c593471 100644 --- a/app/itim/views/clusters.py +++ b/app/itim/views/clusters.py @@ -4,6 +4,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 itim.forms.clusters import ClusterForm, DetailForm @@ -152,6 +153,11 @@ class View(ChangeView): context = super().get_context_data(**kwargs) + context['tickets'] = TicketLinkedItem.objects.filter( + item = int(self.kwargs['pk']), + item_type = TicketLinkedItem.Modules.CLUSTER + ) + context['notes_form'] = AddNoteForm(prefix='note') context['notes'] = Notes.objects.filter(service=self.kwargs['pk']) From 0e987088a34662ba8782cbe506148f140946700c Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 13:12:28 +0930 Subject: [PATCH 310/321] feat(itim): Add ticket tab to services shows related tickets ref: #296 #309 --- app/itim/forms/services.py | 7 ++++++- app/itim/templates/itim/service.html.j2 | 27 +++++++++++++++++++++++++ app/itim/views/services.py | 6 ++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/itim/forms/services.py b/app/itim/forms/services.py index 293c53ec..c88c32bf 100644 --- a/app/itim/forms/services.py +++ b/app/itim/forms/services.py @@ -128,7 +128,12 @@ class DetailForm(ServiceForm): ] } ] - } + }, + "tickets": { + "name": "Tickets", + "slug": "tickets", + "sections": [] + }, } diff --git a/app/itim/templates/itim/service.html.j2 b/app/itim/templates/itim/service.html.j2 index c6b48466..c681181d 100644 --- a/app/itim/templates/itim/service.html.j2 +++ b/app/itim/templates/itim/service.html.j2 @@ -72,4 +72,31 @@
+
+ + {% include 'content/section.html.j2' with tab=form.tabs.tickets %} + + + + + + + + {% if tickets %} + {% for ticket in tickets %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
NameStatus 
{% concat_strings "#" ticket.ticket.id as ticket_ref %}{{ ticket_ref | markdown | safe}}{% 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 %} 
No related tickets exist
+ +
+ {% endblock %} \ No newline at end of file diff --git a/app/itim/views/services.py b/app/itim/views/services.py index 0068416c..c7e78741 100644 --- a/app/itim/views/services.py +++ b/app/itim/views/services.py @@ -4,6 +4,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 itim.forms.services import ServiceForm, DetailForm @@ -151,6 +152,11 @@ class View(ChangeView): context = super().get_context_data(**kwargs) + context['tickets'] = TicketLinkedItem.objects.filter( + item = int(self.kwargs['pk']), + item_type = TicketLinkedItem.Modules.SERVICE + ) + context['notes_form'] = AddNoteForm(prefix='note') context['notes'] = Notes.objects.filter(service=self.kwargs['pk']) From 19ad26261704ef85c4fc3b2756255c141761f5c1 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 13:13:07 +0930 Subject: [PATCH 311/321] feat(core): [Templating Engine] Add template tag concat_strings ref: #296 #309 --- app/core/templatetags/markdown.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index 33c08932..1101235a 100644 --- a/app/core/templatetags/markdown.py +++ b/app/core/templatetags/markdown.py @@ -63,3 +63,8 @@ def to_duration(value): seconds = int((int(value)%hour)%minute) return str("{:02d}h {:02d}m {:02d}s".format(hours, minutes, seconds)) + +@register.simple_tag +def concat_strings(*args): + """concatenate all args""" + return ''.join(map(str, args)) \ No newline at end of file From 4ac0da6ba234b25f9a4fd7e86fbe420385875061 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 14:03:47 +0930 Subject: [PATCH 312/321] fix: ensure model mandatory fields don't specify a default value ref: #309 fixes #306 --- app/core/models/ticket/ticket.py | 1 - app/core/models/ticket/ticket_comment.py | 1 - app/itam/models/device.py | 4 ---- 3 files changed, 6 deletions(-) diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 3c1528f5..9dd682be 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -376,7 +376,6 @@ class Ticket( description = models.TextField( blank = False, - default = None, help_text = 'Ticket Description', null = False, verbose_name = 'Description', diff --git a/app/core/models/ticket/ticket_comment.py b/app/core/models/ticket/ticket_comment.py index afa60279..6c517b54 100644 --- a/app/core/models/ticket/ticket_comment.py +++ b/app/core/models/ticket/ticket_comment.py @@ -162,7 +162,6 @@ class TicketComment( body = models.TextField( blank = False, - default = None, help_text = 'Comment contents', null = False, verbose_name = 'Comment', diff --git a/app/itam/models/device.py b/app/itam/models/device.py index 1c46831e..3740efdf 100644 --- a/app/itam/models/device.py +++ b/app/itam/models/device.py @@ -344,7 +344,6 @@ class DeviceSoftware(DeviceCommonFields, SaveHistory): device = models.ForeignKey( Device, on_delete=models.CASCADE, - default = None, null = False, blank= False ) @@ -352,7 +351,6 @@ class DeviceSoftware(DeviceCommonFields, SaveHistory): software = models.ForeignKey( Software, on_delete=models.CASCADE, - default = None, null = False, blank= False ) @@ -419,7 +417,6 @@ class DeviceOperatingSystem(DeviceCommonFields, SaveHistory): device = models.ForeignKey( Device, on_delete = models.CASCADE, - default = None, null = False, blank = False, @@ -429,7 +426,6 @@ class DeviceOperatingSystem(DeviceCommonFields, SaveHistory): OperatingSystemVersion, verbose_name = 'Operating System/Version', on_delete = models.CASCADE, - default = None, null = False, blank = False From c7701bb2dfccc3d31ea76b789e9480e1583ebdd2 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 14:19:15 +0930 Subject: [PATCH 313/321] feat(core): Add ability to delete a ticket ref: #296 #309 --- app/core/views/ticket.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/core/views/ticket.py b/app/core/views/ticket.py index e6b1678a..12d0b58a 100644 --- a/app/core/views/ticket.py +++ b/app/core/views/ticket.py @@ -289,20 +289,22 @@ class View(ChangeView): context['ticket_type'] = self.kwargs['ticket_type'] - # context['model_delete_url'] = reverse('ITAM:_device_delete', args=(self.kwargs['pk'],)) - url_kwargs = { 'ticket_type': self.kwargs['ticket_type'], 'pk': self.kwargs['pk']} if self.kwargs['ticket_type'] == 'request': path = 'Assistance:_ticket_request_change' + context['model_delete_url'] = reverse('Assistance:_ticket_' + self.kwargs['ticket_type'] + '_delete', kwargs=url_kwargs) + elif self.kwargs['ticket_type'] == 'project_task': path = 'Project Management:_project_task_change' url_kwargs = { 'project_id': self.kwargs['project_id'],'ticket_type': self.kwargs['ticket_type'], 'pk': self.kwargs['pk']} + context['model_delete_url'] = reverse('Project Management:_project_task_delete', kwargs=url_kwargs) + else: comment_path = 'ITIM:_ticket_comment_' + self.kwargs['ticket_type'] @@ -319,6 +321,9 @@ class View(ChangeView): path = 'ITIM:_ticket_problem_change' + + context['model_delete_url'] = reverse('ITIM:_ticket_' + self.kwargs['ticket_type'] + '_delete', kwargs=url_kwargs) + context['edit_url'] = reverse( path, kwargs = url_kwargs, From 59a930f934080009bab32da3359b5c414ad4c63b Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 21 Sep 2024 15:43:27 +0930 Subject: [PATCH 314/321] feat(core): Add ability track ticket estimation time for completion ref: #296 #309 #312 --- app/api/serializers/assistance/request.py | 1 + app/api/serializers/itim/change.py | 1 + app/api/serializers/itim/incident.py | 1 + app/api/serializers/itim/problem.py | 1 + .../project_management/project_task.py | 1 + app/core/forms/validate_ticket.py | 2 ++ ...imate_alter_ticket_description_and_more.py | 28 +++++++++++++++ app/core/models/ticket/ticket.py | 9 +++++ ...r_deviceoperatingsystem_device_and_more.py | 34 +++++++++++++++++++ 9 files changed, 78 insertions(+) create mode 100644 app/core/migrations/0012_ticket_estimate_alter_ticket_description_and_more.py create mode 100644 app/itam/migrations/0004_alter_deviceoperatingsystem_device_and_more.py diff --git a/app/api/serializers/assistance/request.py b/app/api/serializers/assistance/request.py index 020ee51f..5210d867 100644 --- a/app/api/serializers/assistance/request.py +++ b/app/api/serializers/assistance/request.py @@ -24,6 +24,7 @@ class RequestTicketSerializer( 'status', 'title', 'description', + 'estimate', 'urgency', 'impact', 'priority', diff --git a/app/api/serializers/itim/change.py b/app/api/serializers/itim/change.py index e6814927..a0124783 100644 --- a/app/api/serializers/itim/change.py +++ b/app/api/serializers/itim/change.py @@ -24,6 +24,7 @@ class ChangeTicketSerializer( 'status', 'title', 'description', + 'estimate', 'urgency', 'impact', 'priority', diff --git a/app/api/serializers/itim/incident.py b/app/api/serializers/itim/incident.py index 35c7616e..ef9fd186 100644 --- a/app/api/serializers/itim/incident.py +++ b/app/api/serializers/itim/incident.py @@ -24,6 +24,7 @@ class IncidentTicketSerializer( 'status', 'title', 'description', + 'estimate', 'urgency', 'impact', 'priority', diff --git a/app/api/serializers/itim/problem.py b/app/api/serializers/itim/problem.py index 9f6007b9..80475e01 100644 --- a/app/api/serializers/itim/problem.py +++ b/app/api/serializers/itim/problem.py @@ -24,6 +24,7 @@ class ProblemTicketSerializer( 'status', 'title', 'description', + 'estimate', 'urgency', 'impact', 'priority', diff --git a/app/api/serializers/project_management/project_task.py b/app/api/serializers/project_management/project_task.py index 1e722d13..bf514b8c 100644 --- a/app/api/serializers/project_management/project_task.py +++ b/app/api/serializers/project_management/project_task.py @@ -24,6 +24,7 @@ class ProjectTaskSerializer( 'status', 'title', 'description', + 'estimate', 'urgency', 'impact', 'priority', diff --git a/app/core/forms/validate_ticket.py b/app/core/forms/validate_ticket.py index 172b0470..640dff49 100644 --- a/app/core/forms/validate_ticket.py +++ b/app/core/forms/validate_ticket.py @@ -47,6 +47,7 @@ class TicketValidation( 'category', 'created', 'date_closed', + 'estimate', 'external_ref', 'external_system', 'status', @@ -68,6 +69,7 @@ class TicketValidation( 'category', 'assigned_users', 'assigned_teams', + 'estimate', 'status', 'impact', 'opened_by', diff --git a/app/core/migrations/0012_ticket_estimate_alter_ticket_description_and_more.py b/app/core/migrations/0012_ticket_estimate_alter_ticket_description_and_more.py new file mode 100644 index 00000000..fe9cdd79 --- /dev/null +++ b/app/core/migrations/0012_ticket_estimate_alter_ticket_description_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.8 on 2024-09-21 06:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_ticketlinkeditem'), + ] + + operations = [ + migrations.AddField( + model_name='ticket', + name='estimate', + field=models.IntegerField(default=0, help_text='Time Eastimated to complete this ticket in seconds', verbose_name='Estimation'), + ), + migrations.AlterField( + model_name='ticket', + name='description', + field=models.TextField(help_text='Ticket Description', verbose_name='Description'), + ), + migrations.AlterField( + model_name='ticketcomment', + name='body', + field=models.TextField(help_text='Comment contents', verbose_name='Comment'), + ), + ] diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 9dd682be..1232fdf0 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -534,6 +534,14 @@ class Ticket( verbose_name = 'Planned Finish Date', ) + estimate = models.IntegerField( + blank = False, + default = 0, + help_text = 'Time Eastimated to complete this ticket in seconds', + null = False, + verbose_name = 'Estimation', + ) + real_start_date = models.DateTimeField( blank = True, help_text = 'Real start date', @@ -563,6 +571,7 @@ class Ticket( 'ticket_type', 'assigned_users', 'assigned_teams', + 'estimate', ] common_itsm_fields: list(str()) = common_fields + [ diff --git a/app/itam/migrations/0004_alter_deviceoperatingsystem_device_and_more.py b/app/itam/migrations/0004_alter_deviceoperatingsystem_device_and_more.py new file mode 100644 index 00000000..9d1dee0d --- /dev/null +++ b/app/itam/migrations/0004_alter_deviceoperatingsystem_device_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.8 on 2024-09-21 06:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('itam', '0003_alter_device_options_alter_devicemodel_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='deviceoperatingsystem', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itam.device'), + ), + migrations.AlterField( + model_name='deviceoperatingsystem', + name='operating_system_version', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itam.operatingsystemversion', verbose_name='Operating System/Version'), + ), + migrations.AlterField( + model_name='devicesoftware', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itam.device'), + ), + migrations.AlterField( + model_name='devicesoftware', + name='software', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itam.software'), + ), + ] From a44b2479e3bdb58198a209225332afd4b78d2556 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 30 Sep 2024 13:07:13 +0930 Subject: [PATCH 315/321] feat(core): increase ticket title field length 50 -> 100 chars ref: #339 closes #321 --- app/core/migrations/0013_alter_ticket_title.py | 18 ++++++++++++++++++ app/core/models/ticket/ticket.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 app/core/migrations/0013_alter_ticket_title.py diff --git a/app/core/migrations/0013_alter_ticket_title.py b/app/core/migrations/0013_alter_ticket_title.py new file mode 100644 index 00000000..5c8bc20f --- /dev/null +++ b/app/core/migrations/0013_alter_ticket_title.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2024-09-30 03:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_ticket_estimate_alter_ticket_description_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='ticket', + name='title', + field=models.CharField(help_text='Title of the Ticket', max_length=100, unique=True, verbose_name='Title'), + ), + ] diff --git a/app/core/models/ticket/ticket.py b/app/core/models/ticket/ticket.py index 1232fdf0..a3e53269 100644 --- a/app/core/models/ticket/ticket.py +++ b/app/core/models/ticket/ticket.py @@ -369,7 +369,7 @@ class Ticket( title = models.CharField( blank = False, help_text = "Title of the Ticket", - max_length = 50, + max_length = 100, unique = True, verbose_name = 'Title', ) From 1dda4a9fb5527fb94a9d87f32e3c6ab2139c1650 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 5 Oct 2024 11:33:23 +0930 Subject: [PATCH 316/321] feat(project_management): increase project field length 50 -> 100 chars ref: #341 closes #340 --- ...roject_name_alter_projectmilestone_name.py | 23 +++++++++++++++++++ .../models/project_common.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 app/project_management/migrations/0014_alter_project_name_alter_projectmilestone_name.py diff --git a/app/project_management/migrations/0014_alter_project_name_alter_projectmilestone_name.py b/app/project_management/migrations/0014_alter_project_name_alter_projectmilestone_name.py new file mode 100644 index 00000000..94a7c4b0 --- /dev/null +++ b/app/project_management/migrations/0014_alter_project_name_alter_projectmilestone_name.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.8 on 2024-10-05 02:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('project_management', '0013_alter_projectmilestone_description_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='projectmilestone', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + ] diff --git a/app/project_management/models/project_common.py b/app/project_management/models/project_common.py index 7e9b5d09..c885ccd5 100644 --- a/app/project_management/models/project_common.py +++ b/app/project_management/models/project_common.py @@ -30,7 +30,7 @@ class ProjectCommonFieldsName(ProjectCommonFields): name = models.CharField( blank = False, - max_length = 50, + max_length = 100, unique = True, ) From 105cb63d713685c6555143703207094857ac167a Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 5 Oct 2024 11:36:08 +0930 Subject: [PATCH 317/321] feat(core): Add API filter of fields external_system and external_ref to tickets ref: #341 --- app/api/views/core/tickets.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/api/views/core/tickets.py b/app/api/views/core/tickets.py index 1fd75d60..764cef6c 100644 --- a/app/api/views/core/tickets.py +++ b/app/api/views/core/tickets.py @@ -17,6 +17,16 @@ 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 ] From 551473feb7d1eb02398788ae4bb8dbcfd887cba3 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 5 Oct 2024 11:36:24 +0930 Subject: [PATCH 318/321] feat(core): Add API filter of fields external_system and external_ref for projects ref: #341 --- app/api/views/project_management/projects.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/api/views/project_management/projects.py b/app/api/views/project_management/projects.py index 102e717a..10065410 100644 --- a/app/api/views/project_management/projects.py +++ b/app/api/views/project_management/projects.py @@ -19,6 +19,16 @@ 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 ] From b1fc8e0f980bb1eb97dd301caa70eb3c7914fec1 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 5 Oct 2024 11:37:09 +0930 Subject: [PATCH 319/321] feat(settings): Add API filter and search ref: #341 --- app/app/settings.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/app/settings.py b/app/app/settings.py index 10fa55c1..d0ae5905 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -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', @@ -258,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', From 983921fc22371fa898164cb2c10152630f495fb5 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 11 Oct 2024 23:41:03 +0930 Subject: [PATCH 320/321] chore: squash migrations squash all to limit number of migrations required ref: #259 --- ...tcomment_ticketcommentcategory_and_more.py | 24 +++++-- ...ket_milestone_ticket_opened_by_and_more.py | 16 ++++- ...r_ticket_milestone_alter_ticket_project.py | 25 ------- ...nal_system_alter_ticket_impact_and_more.py | 49 -------------- ...nal_system_alter_ticket_impact_and_more.py | 49 -------------- .../0010_alter_ticketcategory_options.py | 17 ----- app/core/migrations/0011_ticketlinkeditem.py | 31 --------- ...imate_alter_ticket_description_and_more.py | 28 -------- .../migrations/0013_alter_ticket_title.py | 18 ----- ...r_deviceoperatingsystem_device_and_more.py | 2 +- .../migrations/0001_initial.py | 65 +++++++++++++++++-- ...ct_external_ref_project_external_system.py | 23 ------- ...t_external_system_projectstate_and_more.py | 48 -------------- .../migrations/0004_alter_project_state.py | 19 ------ .../migrations/0005_projecttype.py | 37 ----------- ...roject_project_type_alter_project_state.py | 24 ------- .../migrations/0007_project_priority.py | 18 ----- .../0008_alter_project_project_type.py | 19 ------ .../migrations/0009_alter_project_state.py | 19 ------ .../migrations/0010_project_is_deleted.py | 18 ----- .../0011_alter_project_is_deleted.py | 18 ----- .../migrations/0012_alter_project_options.py | 17 ----- ...r_projectmilestone_description_and_more.py | 24 ------- ...roject_name_alter_projectmilestone_name.py | 23 ------- 24 files changed, 92 insertions(+), 539 deletions(-) delete mode 100644 app/core/migrations/0007_alter_ticket_milestone_alter_ticket_project.py delete mode 100644 app/core/migrations/0008_alter_ticket_external_system_alter_ticket_impact_and_more.py delete mode 100644 app/core/migrations/0009_alter_ticket_external_system_alter_ticket_impact_and_more.py delete mode 100644 app/core/migrations/0010_alter_ticketcategory_options.py delete mode 100644 app/core/migrations/0011_ticketlinkeditem.py delete mode 100644 app/core/migrations/0012_ticket_estimate_alter_ticket_description_and_more.py delete mode 100644 app/core/migrations/0013_alter_ticket_title.py delete mode 100644 app/project_management/migrations/0002_project_external_ref_project_external_system.py delete mode 100644 app/project_management/migrations/0003_alter_project_external_system_projectstate_and_more.py delete mode 100644 app/project_management/migrations/0004_alter_project_state.py delete mode 100644 app/project_management/migrations/0005_projecttype.py delete mode 100644 app/project_management/migrations/0006_project_project_type_alter_project_state.py delete mode 100644 app/project_management/migrations/0007_project_priority.py delete mode 100644 app/project_management/migrations/0008_alter_project_project_type.py delete mode 100644 app/project_management/migrations/0009_alter_project_state.py delete mode 100644 app/project_management/migrations/0010_project_is_deleted.py delete mode 100644 app/project_management/migrations/0011_alter_project_is_deleted.py delete mode 100644 app/project_management/migrations/0012_alter_project_options.py delete mode 100644 app/project_management/migrations/0013_alter_projectmilestone_description_and_more.py delete mode 100644 app/project_management/migrations/0014_alter_project_name_alter_projectmilestone_name.py diff --git a/app/core/migrations/0005_ticketcategory_ticketcomment_ticketcommentcategory_and_more.py b/app/core/migrations/0005_ticketcategory_ticketcomment_ticketcommentcategory_and_more.py index ed57713c..bb9871db 100644 --- a/app/core/migrations/0005_ticketcategory_ticketcomment_ticketcommentcategory_and_more.py +++ b/app/core/migrations/0005_ticketcategory_ticketcomment_ticketcommentcategory_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-14 06:29 +# Generated by Django 5.0.8 on 2024-10-11 14:09 import access.fields import access.models @@ -38,7 +38,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'Ticket Category', 'verbose_name_plural': 'Ticket Categories', - 'ordering': ['name'], + 'ordering': ['parent__name', 'name'], }, ), migrations.CreateModel( @@ -48,7 +48,7 @@ class Migration(migrations.Migration): ('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(default=None, help_text='Comment contents', verbose_name='Comment')), + ('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')), @@ -89,6 +89,19 @@ class Migration(migrations.Migration): '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=[ @@ -107,8 +120,8 @@ class Migration(migrations.Migration): ('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=50, unique=True, verbose_name='Title')), - ('description', models.TextField(default=None, help_text='Ticket Description', verbose_name='Description')), + ('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')), @@ -119,6 +132,7 @@ class Migration(migrations.Migration): ('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)')), diff --git a/app/core/migrations/0006_ticket_milestone_ticket_opened_by_and_more.py b/app/core/migrations/0006_ticket_milestone_ticket_opened_by_and_more.py index 685b3f18..21fd17d6 100644 --- a/app/core/migrations/0006_ticket_milestone_ticket_opened_by_and_more.py +++ b/app/core/migrations/0006_ticket_milestone_ticket_opened_by_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-14 06:29 +# Generated by Django 5.0.8 on 2024-10-11 14:09 import access.models import core.models.ticket.ticket_comment @@ -21,7 +21,7 @@ class Migration(migrations.Migration): 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.DO_NOTHING, to='project_management.projectmilestone', verbose_name='Project 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', @@ -36,7 +36,7 @@ class Migration(migrations.Migration): 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.DO_NOTHING, to='project_management.project', verbose_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', @@ -133,6 +133,16 @@ class Migration(migrations.Migration): 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')}, diff --git a/app/core/migrations/0007_alter_ticket_milestone_alter_ticket_project.py b/app/core/migrations/0007_alter_ticket_milestone_alter_ticket_project.py deleted file mode 100644 index 6c3c9e81..00000000 --- a/app/core/migrations/0007_alter_ticket_milestone_alter_ticket_project.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-16 03:16 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0006_ticket_milestone_ticket_opened_by_and_more'), - ('project_management', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - 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.AlterField( - 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'), - ), - ] diff --git a/app/core/migrations/0008_alter_ticket_external_system_alter_ticket_impact_and_more.py b/app/core/migrations/0008_alter_ticket_external_system_alter_ticket_impact_and_more.py deleted file mode 100644 index 19ceaab1..00000000 --- a/app/core/migrations/0008_alter_ticket_external_system_alter_ticket_impact_and_more.py +++ /dev/null @@ -1,49 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-16 05:19 - -import core.models.ticket.ticket -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0007_alter_ticket_milestone_alter_ticket_project'), - ] - - operations = [ - migrations.AlterField( - model_name='ticket', - name='external_system', - field=models.IntegerField(blank=True, choices=[], default=None, help_text='External system this item derives', null=True, verbose_name='External System'), - ), - migrations.AlterField( - model_name='ticket', - name='impact', - field=models.IntegerField(blank=True, choices=[], default=('1', 'Very Low'), help_text='End user assessed impact', null=True, verbose_name='Impact'), - ), - migrations.AlterField( - model_name='ticket', - name='priority', - field=models.IntegerField(blank=True, choices=[], default=('1', 'Very Low'), help_text='What priority should this ticket for its completion', null=True, verbose_name='Priority'), - ), - migrations.AlterField( - model_name='ticket', - name='status', - field=models.IntegerField(choices=[], default=('2', 'New'), help_text='Status of ticket', verbose_name='Status'), - ), - migrations.AlterField( - model_name='ticket', - name='ticket_type', - field=models.IntegerField(choices=[], help_text='The type of ticket this is', validators=[core.models.ticket.ticket.Ticket.validation_ticket_type], verbose_name='Type'), - ), - migrations.AlterField( - model_name='ticket', - name='urgency', - field=models.IntegerField(blank=True, choices=[], default=('1', 'Very Low'), help_text='How urgent is this tickets resolution for the user?', null=True, verbose_name='Urgency'), - ), - migrations.AlterField( - model_name='ticketcomment', - name='external_system', - field=models.IntegerField(blank=True, choices=[], default=None, help_text='External system this item derives', null=True, verbose_name='External System'), - ), - ] diff --git a/app/core/migrations/0009_alter_ticket_external_system_alter_ticket_impact_and_more.py b/app/core/migrations/0009_alter_ticket_external_system_alter_ticket_impact_and_more.py deleted file mode 100644 index 7d40d3de..00000000 --- a/app/core/migrations/0009_alter_ticket_external_system_alter_ticket_impact_and_more.py +++ /dev/null @@ -1,49 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-17 03:14 - -import core.models.ticket.ticket -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0008_alter_ticket_external_system_alter_ticket_impact_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='ticket', - name='external_system', - field=models.IntegerField(blank=True, choices=[(1, 'Github'), (2, 'Gitlab'), (9999, 'Custom #1 (Imported)'), (9998, 'Custom #2 (Imported)'), (9997, 'Custom #3 (Imported)'), (9996, 'Custom #4 (Imported)'), (9995, 'Custom #5 (Imported)'), (9994, 'Custom #6 (Imported)'), (9993, 'Custom #7 (Imported)'), (9992, 'Custom #8 (Imported)'), (9991, 'Custom #9 (Imported)')], default=None, help_text='External system this item derives', null=True, verbose_name='External System'), - ), - migrations.AlterField( - model_name='ticket', - name='impact', - field=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'), - ), - migrations.AlterField( - model_name='ticket', - name='priority', - field=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'), - ), - migrations.AlterField( - model_name='ticket', - name='status', - field=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'), - ), - migrations.AlterField( - model_name='ticket', - name='ticket_type', - field=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'), - ), - migrations.AlterField( - model_name='ticket', - name='urgency', - field=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'), - ), - migrations.AlterField( - model_name='ticketcomment', - name='external_system', - field=models.IntegerField(blank=True, choices=[(1, 'Github'), (2, 'Gitlab'), (9999, 'Custom #1 (Imported)'), (9998, 'Custom #2 (Imported)'), (9997, 'Custom #3 (Imported)'), (9996, 'Custom #4 (Imported)'), (9995, 'Custom #5 (Imported)'), (9994, 'Custom #6 (Imported)'), (9993, 'Custom #7 (Imported)'), (9992, 'Custom #8 (Imported)'), (9991, 'Custom #9 (Imported)')], default=None, help_text='External system this item derives', null=True, verbose_name='External System'), - ), - ] diff --git a/app/core/migrations/0010_alter_ticketcategory_options.py b/app/core/migrations/0010_alter_ticketcategory_options.py deleted file mode 100644 index 83b158de..00000000 --- a/app/core/migrations/0010_alter_ticketcategory_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-18 02:49 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0009_alter_ticket_external_system_alter_ticket_impact_and_more'), - ] - - operations = [ - migrations.AlterModelOptions( - name='ticketcategory', - options={'ordering': ['parent__name', 'name'], 'verbose_name': 'Ticket Category', 'verbose_name_plural': 'Ticket Categories'}, - ), - ] diff --git a/app/core/migrations/0011_ticketlinkeditem.py b/app/core/migrations/0011_ticketlinkeditem.py deleted file mode 100644 index e88602d4..00000000 --- a/app/core/migrations/0011_ticketlinkeditem.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-20 05:50 - -import access.models -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('access', '0001_initial'), - ('core', '0010_alter_ticketcategory_options'), - ] - - operations = [ - 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')), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('ticket', models.ForeignKey(help_text='Ticket the item will be linked to', on_delete=django.db.models.deletion.CASCADE, to='core.ticket', verbose_name='Ticket')), - ], - options={ - 'verbose_name': 'Ticket Linked Item', - 'verbose_name_plural': 'Ticket linked Items', - 'ordering': ['id'], - }, - ), - ] diff --git a/app/core/migrations/0012_ticket_estimate_alter_ticket_description_and_more.py b/app/core/migrations/0012_ticket_estimate_alter_ticket_description_and_more.py deleted file mode 100644 index fe9cdd79..00000000 --- a/app/core/migrations/0012_ticket_estimate_alter_ticket_description_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-21 06:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0011_ticketlinkeditem'), - ] - - operations = [ - migrations.AddField( - model_name='ticket', - name='estimate', - field=models.IntegerField(default=0, help_text='Time Eastimated to complete this ticket in seconds', verbose_name='Estimation'), - ), - migrations.AlterField( - model_name='ticket', - name='description', - field=models.TextField(help_text='Ticket Description', verbose_name='Description'), - ), - migrations.AlterField( - model_name='ticketcomment', - name='body', - field=models.TextField(help_text='Comment contents', verbose_name='Comment'), - ), - ] diff --git a/app/core/migrations/0013_alter_ticket_title.py b/app/core/migrations/0013_alter_ticket_title.py deleted file mode 100644 index 5c8bc20f..00000000 --- a/app/core/migrations/0013_alter_ticket_title.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-30 03:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0012_ticket_estimate_alter_ticket_description_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='ticket', - name='title', - field=models.CharField(help_text='Title of the Ticket', max_length=100, unique=True, verbose_name='Title'), - ), - ] diff --git a/app/itam/migrations/0004_alter_deviceoperatingsystem_device_and_more.py b/app/itam/migrations/0004_alter_deviceoperatingsystem_device_and_more.py index 9d1dee0d..12ba53b2 100644 --- a/app/itam/migrations/0004_alter_deviceoperatingsystem_device_and_more.py +++ b/app/itam/migrations/0004_alter_deviceoperatingsystem_device_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-21 06:09 +# Generated by Django 5.0.8 on 2024-10-11 14:09 import django.db.models.deletion from django.db import migrations, models diff --git a/app/project_management/migrations/0001_initial.py b/app/project_management/migrations/0001_initial.py index 848f40e1..f7312428 100644 --- a/app/project_management/migrations/0001_initial.py +++ b/app/project_management/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.8 on 2024-09-14 06:29 +# Generated by Django 5.0.8 on 2024-10-11 14:09 import access.fields import access.models @@ -14,6 +14,7 @@ class Migration(migrations.Migration): dependencies = [ ('access', '0001_initial'), + ('assistance', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -25,14 +26,18 @@ class Migration(migrations.Migration): ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), ('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(max_length=50, unique=True)), + ('name', models.CharField(max_length=100, unique=True)), ('slug', access.fields.AutoSlugField()), + ('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')), ('description', models.TextField(blank=True, default=None, null=True)), + ('priority', models.IntegerField(choices=[(1, 'Very Low'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Very High'), (6, 'Major')], default=2, help_text='Priority of the project', null=True, verbose_name='Priority')), ('code', models.CharField(blank=True, help_text='Project Code', max_length=25, null=True, unique=True, verbose_name='Project Code')), ('planned_start_date', models.DateTimeField(blank=True, help_text='When the project is planned to have been started by.', null=True, verbose_name='Planned Start Date')), ('planned_finish_date', models.DateTimeField(blank=True, help_text='When the project is planned to be finished by.', null=True, verbose_name='Planned Finish Date')), ('real_start_date', models.DateTimeField(blank=True, help_text='When work commenced on the project.', null=True, verbose_name='Real Start Date')), ('real_finish_date', models.DateTimeField(blank=True, help_text='When work was completed for the project', null=True, verbose_name='Real Finish Date')), + ('is_deleted', models.BooleanField(default=False, help_text='Is this project considered deleted', verbose_name='Deleted')), ('manager_team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='access.team')), ('manager_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='manager_user', to=settings.AUTH_USER_MODEL)), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), @@ -42,6 +47,7 @@ class Migration(migrations.Migration): 'verbose_name': 'Project', 'verbose_name_plural': 'Projects', 'ordering': ['code', 'name'], + 'permissions': [('import_project', 'Can import a project')], }, ), migrations.CreateModel( @@ -49,15 +55,15 @@ class Migration(migrations.Migration): fields=[ ('is_global', models.BooleanField(default=False)), ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), - ('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(max_length=50, unique=True)), + ('name', models.CharField(max_length=100, unique=True)), ('slug', access.fields.AutoSlugField()), - ('description', models.TextField(blank=True, default=None, null=True)), + ('description', models.TextField(blank=True, default=None, help_text='Description of milestone. Markdown supported', null=True, verbose_name='Description')), ('start_date', models.DateTimeField(blank=True, help_text='When work commenced on the project.', null=True, verbose_name='Real Start Date')), ('finish_date', models.DateTimeField(blank=True, help_text='When work was completed for the project', null=True, verbose_name='Real Finish Date')), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project_management.project')), + ('project', models.ForeignKey(help_text='Project this milestone belongs.', on_delete=django.db.models.deletion.CASCADE, to='project_management.project')), ], options={ 'verbose_name': 'Project Milestone', @@ -65,4 +71,51 @@ class Migration(migrations.Migration): 'ordering': ['name'], }, ), + migrations.CreateModel( + name='ProjectState', + 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='State 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='Name of thee project state.', max_length=50, unique=True, verbose_name='Name')), + ('is_completed', models.BooleanField(default=False, help_text='Is this state considered complete', verbose_name='State Completed')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('runbook', models.ForeignKey(blank=True, help_text='The runbook for this project state', null=True, on_delete=django.db.models.deletion.SET_NULL, to='assistance.knowledgebase', verbose_name='Runbook')), + ], + options={ + 'verbose_name': 'Project State', + 'verbose_name_plural': 'Project States', + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='project', + name='state', + field=models.ForeignKey(blank=True, help_text='State of the project', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.projectstate', verbose_name='Project State'), + ), + migrations.CreateModel( + name='ProjectType', + 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='Type 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='Name of thee project type.', max_length=50, unique=True, verbose_name='Name')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('runbook', models.ForeignKey(blank=True, help_text='The runbook for this project type', null=True, on_delete=django.db.models.deletion.SET_NULL, to='assistance.knowledgebase', verbose_name='Runbook')), + ], + options={ + 'verbose_name': 'Project Type', + 'verbose_name_plural': 'Project Types', + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='project', + name='project_type', + field=models.ForeignKey(blank=True, help_text='Type of project', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.projecttype', verbose_name='Project Type'), + ), ] diff --git a/app/project_management/migrations/0002_project_external_ref_project_external_system.py b/app/project_management/migrations/0002_project_external_ref_project_external_system.py deleted file mode 100644 index 66250a97..00000000 --- a/app/project_management/migrations/0002_project_external_ref_project_external_system.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-16 05:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='external_ref', - field=models.IntegerField(blank=True, default=None, help_text='External System reference', null=True, verbose_name='Reference Number'), - ), - migrations.AddField( - model_name='project', - name='external_system', - field=models.IntegerField(blank=True, choices=[], default=None, help_text='External system this item derives', null=True, verbose_name='External System'), - ), - ] diff --git a/app/project_management/migrations/0003_alter_project_external_system_projectstate_and_more.py b/app/project_management/migrations/0003_alter_project_external_system_projectstate_and_more.py deleted file mode 100644 index d82f4337..00000000 --- a/app/project_management/migrations/0003_alter_project_external_system_projectstate_and_more.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-17 03:14 - -import access.fields -import access.models -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('access', '0001_initial'), - ('assistance', '0001_initial'), - ('project_management', '0002_project_external_ref_project_external_system'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='external_system', - field=models.IntegerField(blank=True, choices=[(1, 'Github'), (2, 'Gitlab'), (9999, 'Custom #1 (Imported)'), (9998, 'Custom #2 (Imported)'), (9997, 'Custom #3 (Imported)'), (9996, 'Custom #4 (Imported)'), (9995, 'Custom #5 (Imported)'), (9994, 'Custom #6 (Imported)'), (9993, 'Custom #7 (Imported)'), (9992, 'Custom #8 (Imported)'), (9991, 'Custom #9 (Imported)')], default=None, help_text='External system this item derives', null=True, verbose_name='External System'), - ), - migrations.CreateModel( - name='ProjectState', - 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='State 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='Name of thee project state.', max_length=50, unique=True, verbose_name='Name')), - ('is_completed', models.BooleanField(default=False, help_text='Is this state considered complete', verbose_name='State Completed')), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('runbook', models.ForeignKey(blank=True, help_text='The runbook for this project state', null=True, on_delete=django.db.models.deletion.SET_NULL, to='assistance.knowledgebase', verbose_name='Runbook')), - ], - options={ - 'verbose_name': 'Project State', - 'verbose_name_plural': 'Project States', - 'ordering': ['name'], - }, - ), - migrations.AddField( - model_name='project', - name='state', - field=models.ForeignKey(help_text='Staate of the project', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.projectstate', verbose_name='ProjectState'), - ), - ] diff --git a/app/project_management/migrations/0004_alter_project_state.py b/app/project_management/migrations/0004_alter_project_state.py deleted file mode 100644 index c09436a6..00000000 --- a/app/project_management/migrations/0004_alter_project_state.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-17 03:18 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0003_alter_project_external_system_projectstate_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='state', - field=models.ForeignKey(help_text='Staate of the project', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.projectstate', verbose_name='Project State'), - ), - ] diff --git a/app/project_management/migrations/0005_projecttype.py b/app/project_management/migrations/0005_projecttype.py deleted file mode 100644 index 3a1c9aba..00000000 --- a/app/project_management/migrations/0005_projecttype.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-17 03:32 - -import access.fields -import access.models -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('access', '0001_initial'), - ('assistance', '0001_initial'), - ('project_management', '0004_alter_project_state'), - ] - - operations = [ - migrations.CreateModel( - name='ProjectType', - 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='Type 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='Name of thee project type.', max_length=50, unique=True, verbose_name='Name')), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('runbook', models.ForeignKey(blank=True, help_text='The runbook for this project type', null=True, on_delete=django.db.models.deletion.SET_NULL, to='assistance.knowledgebase', verbose_name='Runbook')), - ], - options={ - 'verbose_name': 'Project Type', - 'verbose_name_plural': 'Project Types', - 'ordering': ['name'], - }, - ), - ] diff --git a/app/project_management/migrations/0006_project_project_type_alter_project_state.py b/app/project_management/migrations/0006_project_project_type_alter_project_state.py deleted file mode 100644 index 85962eb8..00000000 --- a/app/project_management/migrations/0006_project_project_type_alter_project_state.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-17 03:40 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0005_projecttype'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='project_type', - field=models.ForeignKey(help_text='Type of project', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.projecttype', verbose_name='Project Type'), - ), - migrations.AlterField( - model_name='project', - name='state', - field=models.ForeignKey(help_text='State of the project', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.projectstate', verbose_name='Project State'), - ), - ] diff --git a/app/project_management/migrations/0007_project_priority.py b/app/project_management/migrations/0007_project_priority.py deleted file mode 100644 index 5e3b0168..00000000 --- a/app/project_management/migrations/0007_project_priority.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-17 04:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0006_project_project_type_alter_project_state'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='priority', - field=models.IntegerField(choices=[(1, 'Very Low'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Very High'), (6, 'Major')], default=2, help_text='Priority of the project', null=True, verbose_name='Priority'), - ), - ] diff --git a/app/project_management/migrations/0008_alter_project_project_type.py b/app/project_management/migrations/0008_alter_project_project_type.py deleted file mode 100644 index 5134a980..00000000 --- a/app/project_management/migrations/0008_alter_project_project_type.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-17 05:43 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0007_project_priority'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='project_type', - field=models.ForeignKey(blank=True, help_text='Type of project', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.projecttype', verbose_name='Project Type'), - ), - ] diff --git a/app/project_management/migrations/0009_alter_project_state.py b/app/project_management/migrations/0009_alter_project_state.py deleted file mode 100644 index 2de4b8a6..00000000 --- a/app/project_management/migrations/0009_alter_project_state.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-17 05:45 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0008_alter_project_project_type'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='state', - field=models.ForeignKey(blank=True, help_text='State of the project', null=True, on_delete=django.db.models.deletion.SET_NULL, to='project_management.projectstate', verbose_name='Project State'), - ), - ] diff --git a/app/project_management/migrations/0010_project_is_deleted.py b/app/project_management/migrations/0010_project_is_deleted.py deleted file mode 100644 index f9bc45b1..00000000 --- a/app/project_management/migrations/0010_project_is_deleted.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-18 02:49 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0009_alter_project_state'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='is_deleted', - field=models.BooleanField(default=False, help_text='Is this project_considered deleted', verbose_name='Deleted'), - ), - ] diff --git a/app/project_management/migrations/0011_alter_project_is_deleted.py b/app/project_management/migrations/0011_alter_project_is_deleted.py deleted file mode 100644 index ab1fa05a..00000000 --- a/app/project_management/migrations/0011_alter_project_is_deleted.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-18 02:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0010_project_is_deleted'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='is_deleted', - field=models.BooleanField(default=False, help_text='Is this project considered deleted', verbose_name='Deleted'), - ), - ] diff --git a/app/project_management/migrations/0012_alter_project_options.py b/app/project_management/migrations/0012_alter_project_options.py deleted file mode 100644 index 48c1657f..00000000 --- a/app/project_management/migrations/0012_alter_project_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-18 03:22 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0011_alter_project_is_deleted'), - ] - - operations = [ - migrations.AlterModelOptions( - name='project', - options={'ordering': ['code', 'name'], 'permissions': [('import_project', 'Can import a project')], 'verbose_name': 'Project', 'verbose_name_plural': 'Projects'}, - ), - ] diff --git a/app/project_management/migrations/0013_alter_projectmilestone_description_and_more.py b/app/project_management/migrations/0013_alter_projectmilestone_description_and_more.py deleted file mode 100644 index aa6e0735..00000000 --- a/app/project_management/migrations/0013_alter_projectmilestone_description_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.0.8 on 2024-09-18 04:14 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0012_alter_project_options'), - ] - - operations = [ - migrations.AlterField( - model_name='projectmilestone', - name='description', - field=models.TextField(blank=True, default=None, help_text='Description of milestone. Markdown supported', null=True, verbose_name='Description'), - ), - migrations.AlterField( - model_name='projectmilestone', - name='project', - field=models.ForeignKey(help_text='Project this milestone belongs.', on_delete=django.db.models.deletion.CASCADE, to='project_management.project'), - ), - ] diff --git a/app/project_management/migrations/0014_alter_project_name_alter_projectmilestone_name.py b/app/project_management/migrations/0014_alter_project_name_alter_projectmilestone_name.py deleted file mode 100644 index 94a7c4b0..00000000 --- a/app/project_management/migrations/0014_alter_project_name_alter_projectmilestone_name.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.0.8 on 2024-10-05 02:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project_management', '0013_alter_projectmilestone_description_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='name', - field=models.CharField(max_length=100, unique=True), - ), - migrations.AlterField( - model_name='projectmilestone', - name='name', - field=models.CharField(max_length=100, unique=True), - ), - ] From c634695e4e9019a286ad71e6af288061b47d5ca3 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 11 Oct 2024 23:56:10 +0930 Subject: [PATCH 321/321] feat: update django 5.0.8 -> 5.1.2 ref: #259 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 48290ebe..94b105b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==5.0.8 +Django==5.1.2 django-debug-toolbar==4.3.0 social-auth-app-django==5.4.1