From f295f150349e437b7f59b9d862304d8040b32d4a Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 17 Jul 2024 14:32:41 +0930 Subject: [PATCH 01/82] docs: move settings pages into sub-directory !43 #6 --- docs/projects/centurion_erp/index.md | 2 +- .../projects/centurion_erp/user/itam/software.md | 2 +- .../{settings.md => settings/app_settings.md} | 2 +- .../centurion_erp/user/settings/index.md | 16 ++++++++++++++++ mkdocs.yml | 6 +++++- 5 files changed, 24 insertions(+), 4 deletions(-) rename docs/projects/centurion_erp/user/{settings.md => settings/app_settings.md} (95%) create mode 100644 docs/projects/centurion_erp/user/settings/index.md diff --git a/docs/projects/centurion_erp/index.md b/docs/projects/centurion_erp/index.md index 5b634401..58c0d78e 100644 --- a/docs/projects/centurion_erp/index.md +++ b/docs/projects/centurion_erp/index.md @@ -39,7 +39,7 @@ Centurion ERP contains the following modules: - [API](./user/api.md) - - [Application wide settings](./user/settings.md) + - [Application wide settings](./user/settings/app_settings.md) - History diff --git a/docs/projects/centurion_erp/user/itam/software.md b/docs/projects/centurion_erp/user/itam/software.md index 33d5687f..dfb7941c 100644 --- a/docs/projects/centurion_erp/user/itam/software.md +++ b/docs/projects/centurion_erp/user/itam/software.md @@ -37,7 +37,7 @@ This tab displays the details of the software, in particular: - global !!! info - If a super admin sets [application setting](../settings.md#global-software) `software is global`, when any software is created, regardless of what organization you set. The software will be created in the "global" organization. + If a super admin sets [application setting](../settings/app_settings.md#global-software) `software is global`, when any software is created, regardless of what organization you set. The software will be created in the "global" organization. ## Versions diff --git a/docs/projects/centurion_erp/user/settings.md b/docs/projects/centurion_erp/user/settings/app_settings.md similarity index 95% rename from docs/projects/centurion_erp/user/settings.md rename to docs/projects/centurion_erp/user/settings/app_settings.md index 036fa82f..1493ad8d 100644 --- a/docs/projects/centurion_erp/user/settings.md +++ b/docs/projects/centurion_erp/user/settings/app_settings.md @@ -1,6 +1,6 @@ --- title: Settings -description: Settings Module Documentation for Centurion ERP by No Fuss Computing +description: Application Settings user documentation for Centurion ERP by No Fuss Computing date: 2024-05-25 template: project.html about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp diff --git a/docs/projects/centurion_erp/user/settings/index.md b/docs/projects/centurion_erp/user/settings/index.md new file mode 100644 index 00000000..864b1547 --- /dev/null +++ b/docs/projects/centurion_erp/user/settings/index.md @@ -0,0 +1,16 @@ +--- +title: Settings +description: Settings module documentation for Centurion ERP by No Fuss Computing +date: 2024-07-17 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +This module contains settings that are common to all modules or a application wide. + + +## Areas + +- [Application Settings](./app_settings.md) + +- [External Links](./external_links.md) diff --git a/mkdocs.yml b/mkdocs.yml index ae45edc6..181907af 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -188,7 +188,11 @@ nav: - projects/centurion_erp/user/itam/software.md - - projects/centurion_erp/user/settings.md + - Settings: + + - projects/centurion_erp/user/settings/index.md + + - projects/centurion_erp/user/settings/app_settings.md - Operations: From 9b4dbc58f344fe744a492621e4c3b0de0e8e2dad Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 17 Jul 2024 14:48:15 +0930 Subject: [PATCH 02/82] feat(settings): New model to allow adding templated links to devices and software !43 #6 --- app/core/views/common.py | 112 ++++++++++ app/core/views/history.py | 6 + app/itam/templates/itam/device.html.j2 | 4 +- app/itam/templates/itam/software.html.j2 | 8 +- app/project-static/base.css | 11 + app/settings/forms/external_links.py | 21 ++ app/settings/migrations/0002_externallink.py | 37 ++++ app/settings/models/__init__.py | 0 app/settings/models/external_link.py | 66 ++++++ .../templates/icons/external_link.html.j2 | 18 ++ app/settings/templates/icons/link.svg | 1 + .../templates/settings/external_link.html.j2 | 194 ++++++++++++++++++ .../templates/settings/external_links.html.j2 | 42 ++++ app/settings/urls.py | 9 +- app/settings/views/external_link.py | 164 +++++++++++++++ .../development/api/models/external_links.md | 17 ++ .../centurion_erp/development/models.md | 5 + .../user/settings/external_links.md | 18 ++ mkdocs.yml | 4 + 19 files changed, 733 insertions(+), 4 deletions(-) create mode 100644 app/settings/forms/external_links.py create mode 100644 app/settings/migrations/0002_externallink.py create mode 100644 app/settings/models/__init__.py create mode 100644 app/settings/models/external_link.py create mode 100644 app/settings/templates/icons/external_link.html.j2 create mode 100644 app/settings/templates/icons/link.svg create mode 100644 app/settings/templates/settings/external_link.html.j2 create mode 100644 app/settings/templates/settings/external_links.html.j2 create mode 100644 app/settings/views/external_link.py create mode 100644 docs/projects/centurion_erp/development/api/models/external_links.md create mode 100644 docs/projects/centurion_erp/user/settings/external_links.md diff --git a/app/core/views/common.py b/app/core/views/common.py index fef95df0..f1baac20 100644 --- a/app/core/views/common.py +++ b/app/core/views/common.py @@ -1,9 +1,12 @@ +from django.template import Template, Context +from django.utils.html import escape from django.views import generic from access.mixin import OrganizationPermission from core.exceptions import MissingAttribute +from settings.models.external_link import ExternalLink from settings.models.user_settings import UserSettings @@ -50,6 +53,61 @@ class ChangeView(View, generic.UpdateView): template_name:str = 'form.html.j2' + # ToDo: on migrating all views to seperate display and change views, external_links will not be required in `ChangView` + def get_context_data(self, **kwargs): + """ Get template context + + For items that have the ability to have external links, this function + adds the external link details to the context. + + !!! Danger "Requirement" + This function may be overridden with the caveat that this function is still called. + by the overriding function. i.e. `super().get_context_data(skwargs)` + + !!! note + The adding of `external_links` within this view is scheduled to be removed. + + Returns: + (dict): Context for the template to use inclusive of 'external_links' + """ + + context = super().get_context_data(**kwargs) + + external_links_query = None + + + if self.model._meta.model_name == 'device': + + external_links_query = ExternalLink.objects.filter(devices=True) + + elif self.model._meta.model_name == 'software': + + external_links_query = ExternalLink.objects.filter(software=True) + + + if external_links_query: + + external_links: list = [] + + user_context = Context(context) + + for external_link in external_links_query: + + user_string = Template(external_link) + external_link_context: dict = { + 'name': escape(external_link.name), + 'link': escape(user_string.render(user_context)), + } + + if external_link.colour: + + external_link_context.update({'colour': external_link.colour }) + external_links += [ external_link_context ] + + context['external_links'] = external_links + + + return context class DeleteView(OrganizationPermission, generic.DeleteView): @@ -64,6 +122,60 @@ class DisplayView(OrganizationPermission, generic.DetailView): template_name:str = 'form.html.j2' + # ToDo: on migrating all views to seperate display and change views, external_links will not be required in `ChangView` + def get_context_data(self, **kwargs): + """ Get template context + + For items that have the ability to have external links, this function + adds the external link details to the context. + + !!! Danger "Requirement" + This function may be overridden with the caveat that this function is still called. + by the overriding function. i.e. `super().get_context_data(skwargs)` + + Returns: + (dict): Context for the template to use inclusive of 'external_links' + """ + + context = super().get_context_data(**kwargs) + + external_links_query = None + + + if self.model._meta.model_name == 'device': + + external_links_query = ExternalLink.objects.filter(devices=True) + + elif self.model._meta.model_name == 'software': + + external_links_query = ExternalLink.objects.filter(software=True) + + + if external_links_query: + + external_links: list = [] + + user_context = Context(context) + + for external_link in external_links_query: + + user_string = Template(external_link) + external_link_context: dict = { + 'name': escape(external_link.name), + 'link': escape(user_string.render(user_context)), + } + + if external_link.colour: + + external_link_context.update({'colour': external_link.colour }) + external_links += [ external_link_context ] + + context['external_links'] = external_links + + + return context + + class IndexView(View, generic.ListView): diff --git a/app/core/views/history.py b/app/core/views/history.py index be6d44bc..5b95b5ab 100644 --- a/app/core/views/history.py +++ b/app/core/views/history.py @@ -41,6 +41,8 @@ class View(OrganizationPermission, generic.View): from config_management.models.groups import ConfigGroups + from settings.models.external_link import ExternalLink + if not hasattr(self, 'model'): match self.kwargs['model_name']: @@ -61,6 +63,10 @@ class View(OrganizationPermission, generic.View): self.model = DeviceType + case 'externallink': + + self.model = ExternalLink + case 'manufacturer': self.model = Manufacturer diff --git a/app/itam/templates/itam/device.html.j2 b/app/itam/templates/itam/device.html.j2 index e145329f..15c9beb5 100644 --- a/app/itam/templates/itam/device.html.j2 +++ b/app/itam/templates/itam/device.html.j2 @@ -83,7 +83,9 @@

Details - {% include 'icons/issue_link.html.j2' with issue=6 %} + {% for external_link in external_links %} + {% include 'icons/external_link.html.j2' with external_link=external_link %} + {% endfor %}

diff --git a/app/itam/templates/itam/software.html.j2 b/app/itam/templates/itam/software.html.j2 index 64290aa4..1544c00d 100644 --- a/app/itam/templates/itam/software.html.j2 +++ b/app/itam/templates/itam/software.html.j2 @@ -43,8 +43,12 @@
-

Details

- +

+ Details + {% for external_link in external_links %} + {% include 'icons/external_link.html.j2' with external_link=external_link %} + {% endfor %} +

{% csrf_token %} {{ form }}
diff --git a/app/project-static/base.css b/app/project-static/base.css index 350d6d7e..28723032 100644 --- a/app/project-static/base.css +++ b/app/project-static/base.css @@ -52,6 +52,7 @@ span.icon-text { padding-right: 10px; height: 30px; display: inline-block; + margin-left: 5px; } span.icon-text a { @@ -142,6 +143,16 @@ span.icon-issue { display: inline-block; } +span.icon-external-link { + height: 30px; + line-height: 30px; + margin: 0px; + padding: 0px; + vertical-align: middle; + display: inline-block; + width: 25px; +} + /* .icon { display: block; content: none; diff --git a/app/settings/forms/external_links.py b/app/settings/forms/external_links.py new file mode 100644 index 00000000..51061def --- /dev/null +++ b/app/settings/forms/external_links.py @@ -0,0 +1,21 @@ +from django import forms +from django.db.models import Q + +from django.contrib.auth.models import User + +from access.models import Organization, TeamUsers + +from core.forms.common import CommonModelForm + +from settings.models.external_link import ExternalLink + + +class ExternalLinksForm(CommonModelForm): + + prefix = 'external_links' + + class Meta: + + fields = '__all__' + + model = ExternalLink diff --git a/app/settings/migrations/0002_externallink.py b/app/settings/migrations/0002_externallink.py new file mode 100644 index 00000000..0b488acf --- /dev/null +++ b/app/settings/migrations/0002_externallink.py @@ -0,0 +1,37 @@ +# Generated by Django 5.0.7 on 2024-07-17 05:02 + +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'), + ('settings', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ExternalLink', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(help_text='Name to display on link button', max_length=30, unique=True, verbose_name='Button Name')), + ('template', models.CharField(help_text='External Link template', max_length=180, verbose_name='Link Template')), + ('colour', models.CharField(blank=True, default=None, help_text='Colour to render the link button. Use HTML colour code', max_length=80, null=True, verbose_name='Button Colour')), + ('devices', models.BooleanField(default=False, help_text='Render link for devices', verbose_name='Devices')), + ('software', models.BooleanField(default=False, help_text='Render link for software', verbose_name='Software')), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoLastModifiedField(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])), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/app/settings/models/__init__.py b/app/settings/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/settings/models/external_link.py b/app/settings/models/external_link.py new file mode 100644 index 00000000..33fafcc1 --- /dev/null +++ b/app/settings/models/external_link.py @@ -0,0 +1,66 @@ +from django.template import Template + +from access.fields import * +from access.models import TenancyObject + + + +class ExternalLink(TenancyObject): + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + name = models.CharField( + blank = False, + max_length = 30, + unique = True, + help_text = 'Name to display on link button', + verbose_name = 'Button Name', + ) + + slug = None + + template = models.CharField( + blank = False, + max_length = 180, + unique = False, + help_text = 'External Link template', + verbose_name = 'Link Template', + ) + + colour = models.CharField( + blank = True, + null = True, + default = None, + max_length = 80, + unique = False, + help_text = 'Colour to render the link button. Use HTML colour code', + verbose_name = 'Button Colour', + ) + + devices = models.BooleanField( + default = False, + blank = False, + help_text = 'Render link for devices', + verbose_name = 'Devices', + ) + + software = models.BooleanField( + default = False, + blank = False, + help_text = 'Render link for software', + verbose_name = 'Software', + ) + + created = AutoCreatedField() + + modified = AutoLastModifiedField() + + + def __str__(self): + """ Return the Template to render """ + + return str(self.template) diff --git a/app/settings/templates/icons/external_link.html.j2 b/app/settings/templates/icons/external_link.html.j2 new file mode 100644 index 00000000..7982524d --- /dev/null +++ b/app/settings/templates/icons/external_link.html.j2 @@ -0,0 +1,18 @@ + + + + + {% include 'icons/link.svg' %} + + {{ external_link.name }} + \ No newline at end of file diff --git a/app/settings/templates/icons/link.svg b/app/settings/templates/icons/link.svg new file mode 100644 index 00000000..38ec0372 --- /dev/null +++ b/app/settings/templates/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/settings/templates/settings/external_link.html.j2 b/app/settings/templates/settings/external_link.html.j2 new file mode 100644 index 00000000..e16e75e1 --- /dev/null +++ b/app/settings/templates/settings/external_link.html.j2 @@ -0,0 +1,194 @@ +{% extends 'base.html.j2' %} + +{% load markdown %} + +{% block title %}{{ externallink.name }}{% endblock %} + +{% block content %} + + + +
+ + + +
+ + + {% csrf_token %} + + +
+

+ Details + {% for external_link in external_links %} + {% include 'icons/external_link.html.j2' with external_link=external_link %} + {% endfor %} +

+
+ +
+ +
+ + {{ externallink.organization }} +
+ +
+ + {{ form.name.value }} +
+ +
+ + {{ externallink.template }} +
+ +
+ + + {% if form.colour.value %} + {{ form.colour.value }} + {% else %} +   + {% endif %} + +
+ +
+ + {{ form.devices.value }} +
+ +
+ + {{ externallink.software }} +
+ +
+ + {{ externallink.created }} +
+ +
+ + {{ externallink.modified }} +
+ +
+ +
+
+ + +
+ {% if form.model_notes.value %} + {{ form.model_notes.value | markdown | safe }} + {% else %} +   + {% endif %} +
+
+
+
+ + + + + + + {% if not tab %} + + {% endif %} + +
+ + + + +
+

+ Notes +

+ {{ notes_form }} + +
+ {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ + {% if tab == 'notes' %} + + {% endif %} +
+ + + +{% endblock %} \ No newline at end of file diff --git a/app/settings/templates/settings/external_links.html.j2 b/app/settings/templates/settings/external_links.html.j2 new file mode 100644 index 00000000..28b2bb1c --- /dev/null +++ b/app/settings/templates/settings/external_links.html.j2 @@ -0,0 +1,42 @@ +{% extends 'base.html.j2' %} + + +{% block content_header_icon %}{% endblock %} + +{% block content %} + + + + + + + + + {% for item in list %} + + + + + + {% endfor %} + +
NameOrganization 
{{ item.name }}{% if item.is_global %}Global{% else %}{{ item.organization }}{% endif %} 
+ + +{% endblock %} \ No newline at end of file diff --git a/app/settings/urls.py b/app/settings/urls.py index 3b44b111..4fb0dcad 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -2,7 +2,7 @@ from django.urls import path from core.views import celery_log -from .views import app_settings, home, device_models, device_types, manufacturer, software_categories +from settings.views import app_settings, home, device_models, device_types, external_link, manufacturer, software_categories from itam.views import device_type, device_model, software_category @@ -11,6 +11,13 @@ urlpatterns = [ path("", home.View.as_view(), name="Settings"), + path("external_links", external_link.Index.as_view(), name="External Links"), + path("external_links/add", external_link.Add.as_view(), name="_external_link_add"), + path("external_links/", external_link.View.as_view(), name="_external_link_view"), + path("external_links//edit", external_link.Change.as_view(), name="_external_link_change"), + path("external_links//delete", external_link.Delete.as_view(), name="_external_link_delete"), + + path('application', app_settings.View.as_view(), name="_settings_application"), path("task_results", celery_log.Index.as_view(), name="_task_results"), diff --git a/app/settings/views/external_link.py b/app/settings/views/external_link.py new file mode 100644 index 00000000..6ebc2b1b --- /dev/null +++ b/app/settings/views/external_link.py @@ -0,0 +1,164 @@ +from django.contrib.auth import decorators as auth_decorator +from django.db.models import Q +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views import generic + + +from access.mixin import OrganizationPermission + +from core.views.common import AddView, ChangeView, DeleteView, DisplayView, IndexView + +from settings.forms.external_links import ExternalLinksForm +from settings.models.external_link import ExternalLink + + +class Index(IndexView): + + context_object_name = "list" + + model = ExternalLink + + paginate_by = 10 + + permission_required = [ + 'settings.view_externallink' + ] + + template_name = 'settings/external_links.html.j2' + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['model_docs_path'] = self.model._meta.app_label + '/external_links/' + + context['content_title'] = 'External Links' + + return context + + + + +class View(ChangeView): + + context_object_name = "externallink" + + form_class = ExternalLinksForm + + model = ExternalLink + + permission_required = [ + 'settings.view_externallink', + ] + + template_name = 'settings/external_link.html.j2' + + + 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['model_delete_url'] = reverse('Settings:_external_link_delete', args=(self.kwargs['pk'],)) + + context['content_title'] = self.object.name + + return context + + def get_success_url(self, **kwargs): + + return reverse('Settings:_external_link_view', args={self.kwargs['pk']}) + + + @method_decorator(auth_decorator.permission_required("settings.change_externallink", raise_exception=True)) + def post(self, request, *args, **kwargs): + + return super().post(request, *args, **kwargs) + + +class Change(ChangeView): + + context_object_name = "externallink" + + form_class = ExternalLinksForm + + model = ExternalLink + + permission_required = [ + 'settings.change_externallink', + ] + + template_name = 'form.html.j2' + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = self.object.name + + return context + + def get_success_url(self, **kwargs): + + return reverse('Settings:_external_link_view', args={self.kwargs['pk']}) + + + @method_decorator(auth_decorator.permission_required("settings.change_externallink", raise_exception=True)) + def post(self, request, *args, **kwargs): + + return super().post(request, *args, **kwargs) + + + +class Add(AddView): + + + form_class = ExternalLinksForm + + model = ExternalLink + + permission_required = [ + 'settings.add_externallink', + ] + + template_name = 'form.html.j2' + + + def get_success_url(self, **kwargs): + + return reverse(viewname = 'Settings:External Links') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Add External Link' + + return context + + +class Delete(DeleteView): + + model = ExternalLink + + permission_required = [ + 'settings.delete_externallink', + ] + + template_name = 'form.html.j2' + + def get_success_url(self, **kwargs): + + return reverse('Settings:External Links') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + self.object.name + + return context + diff --git a/docs/projects/centurion_erp/development/api/models/external_links.md b/docs/projects/centurion_erp/development/api/models/external_links.md new file mode 100644 index 00000000..ee1c10b4 --- /dev/null +++ b/docs/projects/centurion_erp/development/api/models/external_links.md @@ -0,0 +1,17 @@ +--- +title: External Links +description: No Fuss Computings Centurion ERP External Links model documentation. +date: 2024-07-15 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +This model enables the end user to define external links to be rendered alongside other models display pages. The values are added to the page context in the [Change View](../common_views.md#display-view). + + +## External Links + +::: app.settings.models.external_link.ExternalLink + options: + inherited_members: true + heading_level: 3 diff --git a/docs/projects/centurion_erp/development/models.md b/docs/projects/centurion_erp/development/models.md index a7d8158c..03a6131a 100644 --- a/docs/projects/centurion_erp/development/models.md +++ b/docs/projects/centurion_erp/development/models.md @@ -34,6 +34,11 @@ All models must meet the following requirements: - No `queryset` is to return data that the user has not got access to. _see [queryset()](./api/models/tenancy_object.md#tenancy-object-manager)_ +## 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. + + ## Tests The following Unit test cases exists for models: diff --git a/docs/projects/centurion_erp/user/settings/external_links.md b/docs/projects/centurion_erp/user/settings/external_links.md new file mode 100644 index 00000000..1f20e5c9 --- /dev/null +++ b/docs/projects/centurion_erp/user/settings/external_links.md @@ -0,0 +1,18 @@ +--- +title: External Links +description: External Links user documentation for Centurion ERP by No Fuss Computing +date: 2024-07-17 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +External Links allow an end user to specify by means of a jinja template a link that when displayed upon an items display page will add a button with a hyperlink to the url provided. External links can be assigned to: devices and software. This includes both at the same time. + + +## Create a link + +- Software context is under key `software` + +- Device context is under key `device` + +To add a templated link within the `Link Template` field enter your url, with the variable within jinja braces. for example to add a link that will expand with the devices id, specify `{{ device.id }}`. i.e. `https://domainname.tld/{{ device.id }}`. If the link is for software use key `software`. Available fields under context key all of those that are available at the time the page is rendered. diff --git a/mkdocs.yml b/mkdocs.yml index 181907af..4e06137a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,6 +82,8 @@ nav: - projects/centurion_erp/development/api/models/core_history_save.md + - projects/centurion_erp/development/api/models/external_links.md + - projects/centurion_erp/development/api/models/itam_device.md - projects/centurion_erp/development/api/models/access_organization_permission_checking.md @@ -194,6 +196,8 @@ nav: - projects/centurion_erp/user/settings/app_settings.md + - projects/centurion_erp/user/settings/external_links.md + - Operations: From 28ce99f46a40b0b344a5c46049f5fabdbd8b3445 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 17 Jul 2024 16:26:49 +0930 Subject: [PATCH 03/82] test(external_link): add tests !43 fixes #6 --- .../external_links/test_external_links.py | 18 ++ .../test_external_links_core_history.py | 72 +++++++ .../test_external_links_history_permission.py | 92 +++++++++ .../test_external_links_permission.py | 188 ++++++++++++++++++ .../test_external_links_views.py | 29 +++ 5 files changed, 399 insertions(+) create mode 100644 app/settings/tests/unit/external_links/test_external_links.py create mode 100644 app/settings/tests/unit/external_links/test_external_links_core_history.py create mode 100644 app/settings/tests/unit/external_links/test_external_links_history_permission.py create mode 100644 app/settings/tests/unit/external_links/test_external_links_permission.py create mode 100644 app/settings/tests/unit/external_links/test_external_links_views.py diff --git a/app/settings/tests/unit/external_links/test_external_links.py b/app/settings/tests/unit/external_links/test_external_links.py new file mode 100644 index 00000000..d9df925c --- /dev/null +++ b/app/settings/tests/unit/external_links/test_external_links.py @@ -0,0 +1,18 @@ +import pytest +import unittest +import requests + +from django.test import TestCase, Client + +from app.tests.abstract.models import TenancyModel + +from settings.models.external_link import ExternalLink + + + +class ExternalLinkTests( + TestCase, + TenancyModel, +): + + model = ExternalLink diff --git a/app/settings/tests/unit/external_links/test_external_links_core_history.py b/app/settings/tests/unit/external_links/test_external_links_core_history.py new file mode 100644 index 00000000..a2cc1317 --- /dev/null +++ b/app/settings/tests/unit/external_links/test_external_links_core_history.py @@ -0,0 +1,72 @@ +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 settings.models.external_link import ExternalLink + + + +class ExternalLinkHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + model = ExternalLink + + + @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, + ) diff --git a/app/settings/tests/unit/external_links/test_external_links_history_permission.py b/app/settings/tests/unit/external_links/test_external_links_history_permission.py new file mode 100644 index 00000000..34455cfc --- /dev/null +++ b/app/settings/tests/unit/external_links/test_external_links_history_permission.py @@ -0,0 +1,92 @@ +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 core.tests.abstract.history_permissions import HistoryPermissions + +from settings.models.external_link import ExternalLink + + + +class ExternalLinkHistoryPermissions(TestCase, HistoryPermissions): + + + item_model = ExternalLink + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. create an organization that is different to item + 3. Create a device + 4. Add history device history entry as item + 5. create a user + 6. create user in different organization (with the required permission) + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + self.item = self.item_model.objects.create( + organization=organization, + name = 'deviceone' + ) + + self.history = self.model.objects.get( + item_pk = self.item.id, + item_class = self.item._meta.model_name, + action = self.model.Actions.ADD, + ) + + 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]) + + + 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.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, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) diff --git a/app/settings/tests/unit/external_links/test_external_links_permission.py b/app/settings/tests/unit/external_links/test_external_links_permission.py new file mode 100644 index 00000000..7d2c636a --- /dev/null +++ b/app/settings/tests/unit/external_links/test_external_links_permission.py @@ -0,0 +1,188 @@ +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 TestCase + +from access.models import Organization, Team, TeamUsers, Permission + +from app.tests.abstract.model_permissions import ModelPermissions + +from settings.models.external_link import ExternalLink + + + +class ExternalLinkPermissions(TestCase, ModelPermissions): + + + model = ExternalLink + + app_label = 'settings' + + app_namespace = 'Settings' + + url_name_view = '_external_link_view' + + url_name_add = '_external_link_add' + + url_name_change = '_external_link_change' + + url_name_delete = '_external_link_delete' + + url_delete_response = reverse('Settings:External Links') + + + @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 device + 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 = 'deviceone' + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + self.add_data = {'device': 'device', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'device': 'device'} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'device': 'device'} + + + 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 + ) diff --git a/app/settings/tests/unit/external_links/test_external_links_views.py b/app/settings/tests/unit/external_links/test_external_links_views.py new file mode 100644 index 00000000..81142513 --- /dev/null +++ b/app/settings/tests/unit/external_links/test_external_links_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 ExternalLinkViews( + TestCase, + PrimaryModel +): + + add_module = 'settings.views.external_link' + 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' +6 \ No newline at end of file From 9668e811c5bf85d87d510c1aad707048eb971393 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 17 Jul 2024 16:58:50 +0930 Subject: [PATCH 04/82] feat(itam): Ability to add device configuration !43 fixes #44 --- app/itam/forms/device/device.py | 1 + app/itam/migrations/0002_device_config.py | 19 +++++++++++++ app/itam/models/device.py | 27 +++++++++++++++++++ app/itam/templates/itam/device.html.j2 | 7 +++++ .../centurion_erp/user/itam/device.md | 2 ++ 5 files changed, 56 insertions(+) create mode 100644 app/itam/migrations/0002_device_config.py diff --git a/app/itam/forms/device/device.py b/app/itam/forms/device/device.py index 8f6c1626..78d3fcf0 100644 --- a/app/itam/forms/device/device.py +++ b/app/itam/forms/device/device.py @@ -23,6 +23,7 @@ class DeviceForm(CommonModelForm): 'device_type', 'organization', 'model_notes', + 'config', ] diff --git a/app/itam/migrations/0002_device_config.py b/app/itam/migrations/0002_device_config.py new file mode 100644 index 00000000..4992b6fe --- /dev/null +++ b/app/itam/migrations/0002_device_config.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.7 on 2024-07-17 07:17 + +import itam.models.device +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('itam', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='config', + field=models.JSONField(blank=True, default=None, help_text='Configuration for this device', null=True, validators=[itam.models.device.Device.validate_config_keys_not_reserved], verbose_name='Host Configuration'), + ), + ] diff --git a/app/itam/models/device.py b/app/itam/models/device.py index 4bbc292b..bb660623 100644 --- a/app/itam/models/device.py +++ b/app/itam/models/device.py @@ -44,6 +44,20 @@ class DeviceType(DeviceCommonFieldsName, SaveHistory): class Device(DeviceCommonFieldsName, SaveHistory): + reserved_config_keys: list = [ + 'software' + ] + + def validate_config_keys_not_reserved(self): + + value: dict = self + + for invalid_key in Device.reserved_config_keys: + + if invalid_key in value.keys(): + raise ValidationError(f'json key "{invalid_key}" is a reserved configuration key') + + 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}' @@ -113,6 +127,15 @@ class Device(DeviceCommonFieldsName, SaveHistory): ) + config = models.JSONField( + blank = True, + default = None, + null = True, + validators=[ validate_config_keys_not_reserved ], + verbose_name = 'Host Configuration', + help_text = 'Configuration for this device' + ) + inventorydate = models.DateTimeField( verbose_name = 'Last Inventory Date', null = True, @@ -254,6 +277,10 @@ class Device(DeviceCommonFieldsName, SaveHistory): config['software'] = merge_software(group_software, host_software) + if self.config: + + config.update(self.config) + return config diff --git a/app/itam/templates/itam/device.html.j2 b/app/itam/templates/itam/device.html.j2 index 15c9beb5..67ecd1e6 100644 --- a/app/itam/templates/itam/device.html.j2 +++ b/app/itam/templates/itam/device.html.j2 @@ -184,6 +184,13 @@
+
+

Device Config

+
+ +
+ + {% if not tab %} + + +
+ + + + {% if perms.assistance.change_knowledgebase %} + + {% endif %} +
+ +
+
+ {% if perms.assistance.change_knowledgebase %} +

Details

+ + {% csrf_token %} + + +
+ +
+ +
+ + {{ form.title.value }} +
+ +
+ + + {% if kb.category %} + {{ kb.category }} + {% else %} +   + {% endif %} + +
+ +
+ + + {% if form.responsible_user.value %} + {{ kb.responsible_user }} + {% else %} +   + {% endif %} + +
+ +
+ + + {% if form.organization.value %} + {{ kb.organization }} + {% else %} +   + {% endif %} + +
+ + +
+ +
+ +
+ + + {% if form.release_date.value %} + {{ form.release_date.value }} + {% else %} +   + {% endif %} + +
+ +
+ + + {% if form.expiry_date.value %} + {{ form.expiry_date.value }} + {% else %} +   + {% endif %} + +
+ +
+ + + {% if form.target_user.value %} + {{ kb.target_user }} + {% else %} +   + {% endif %} + +
+ +
+ + + {% if form.target_team.value %} + {{ form.target_team.value }} {{ kb.target_team }} + {% else %} +   + {% endif %} + +
+ + +
+
+ + + + {% endif %} + + {% if form.summary.value %} +
+

Summary

+ {{ form.summary.value | safe }} +
+
+
+ {% endif %} + +
+

Content

+
+
+ {{ form.content.value | markdown | safe }} +
+
+ +
+ + + +
+ + {% if perms.assistance.change_knowledgebase %} +
+

+ Notes +

+ {{ notes_form }} + +
+ {% if notes %} + {% for note in notes %} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ {% endif %} + +
+ +{% endblock %} \ No newline at end of file diff --git a/app/assistance/templates/assistance/kb_category.html.j2 b/app/assistance/templates/assistance/kb_category.html.j2 new file mode 100644 index 00000000..24351ac3 --- /dev/null +++ b/app/assistance/templates/assistance/kb_category.html.j2 @@ -0,0 +1,213 @@ +{% extends 'base.html.j2' %} + +{% load markdown %} + +{% block content %} + + + + +
+ + + + + {% if perms.assistance.change_knowledgebase %} + + {% endif %} +
+ +
+
+ +

Details

+ + {% csrf_token %} + + +
+ +
+ +
+ + {{ form.name.value }} +
+ +
+ + + {% if item.parent_category %} + {{ item.parent_category }} + {% else %} +   + {% endif %} + +
+ +
+ + {{ item.created }} +
+ +
+ + {{ item.modified }} +
+ + +
+ +
+ +
+ + + {% if form.organization.value %} + {{ item.organization }} + {% else %} +   + {% endif %} + +
+ +
+ + + {% if form.target_user.value %} + {{ form.target_user.value }} + {% else %} +   + {% endif %} + +
+ +
+ + + {% if form.target_team.value %} + {{ form.target_team.value }} + {% else %} +   + {% endif %} + +
+ + +
+
+ + + + +
+ + + +
+ +
+

+ Articles +

+ + + + + + {% for article in articles %} + + + + + {% endfor %} +
TitleOrganization
{{ article.title }}{{ article.organization }}
+ +
+ + {% if perms.assistance.change_knowledgebase %} +
+

+ Notes +

+ {{ notes_form }} + +
+ {% if notes %} + {% for note in notes %} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ {% endif %} + +
+ +{% endblock %} \ No newline at end of file diff --git a/app/assistance/templates/assistance/kb_category_index.html.j2 b/app/assistance/templates/assistance/kb_category_index.html.j2 new file mode 100644 index 00000000..0b969fa2 --- /dev/null +++ b/app/assistance/templates/assistance/kb_category_index.html.j2 @@ -0,0 +1,47 @@ +{% extends 'base.html.j2' %} + +{% block content %} + + + + + + + + + + {% if items %} + {% for item in items %} + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
TitleParentOrganization 
{{ item.name }}{{ item.parent_category }}{{ item.organization }} 
Nothing Found
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/assistance/templates/assistance/kb_index.html.j2 b/app/assistance/templates/assistance/kb_index.html.j2 new file mode 100644 index 00000000..06634726 --- /dev/null +++ b/app/assistance/templates/assistance/kb_index.html.j2 @@ -0,0 +1,47 @@ +{% extends 'base.html.j2' %} + +{% block content %} + + + + + + + + + + {% if items %} + {% for item in items %} + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
TitleCategoryOrganization 
{{ item.title }}{{ item.category }}{{ item.organization }} 
Nothing Found
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/assistance/tests/unit/knowledge_base/test_knowledge_base.py b/app/assistance/tests/unit/knowledge_base/test_knowledge_base.py new file mode 100644 index 00000000..95c0f5a6 --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base/test_knowledge_base.py @@ -0,0 +1,44 @@ +import pytest +import unittest + +from django.test import TestCase + +from access.models import Organization + +from app.tests.abstract.models import TenancyModel + +from assistance.models.knowledge_base import KnowledgeBase + + + +@pytest.mark.django_db +class KnowledgeBaseModel( + TestCase, + TenancyModel +): + + model = KnowledgeBase + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. Create an item + + """ + + self.organization = Organization.objects.create(name='test_org') + + + self.item = self.model.objects.create( + organization = self.organization, + title = 'one', + content = 'dict({"key": "one", "existing": "dont_over_write"})' + ) + + self.second_item = self.model.objects.create( + organization = self.organization, + title = 'one_two', + content = 'dict({"key": "two"})', + ) diff --git a/app/assistance/tests/unit/knowledge_base/test_knowledge_base_core_history.py b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_core_history.py new file mode 100644 index 00000000..eaf858b9 --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_core_history.py @@ -0,0 +1,78 @@ + +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 assistance.models.knowledge_base import KnowledgeBase + + + +class KnowledgeBaseHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + + model = KnowledgeBase + + + @classmethod + def setUpTestData(self): + """ Setup Test """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + self.item_parent = self.model.objects.create( + title = 'test_item_parent_' + self.model._meta.model_name, + organization = self.organization + ) + + self.item_create = self.model.objects.create( + title = '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.title = 'test_item_' + self.model._meta.model_name + '_changed' + self.item_change.save() + + self.field_after_expected_value = '{"title": "' + self.item_change.title + '"}' + + 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( + title = '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.item_parent._meta.model_name, + ) diff --git a/app/assistance/tests/unit/knowledge_base/test_knowledge_base_history_permission.py b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_history_permission.py new file mode 100644 index 00000000..9b1b8c5e --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_history_permission.py @@ -0,0 +1,95 @@ +# 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 assistance.models.knowledge_base import KnowledgeBase + +from core.tests.abstract.history_permissions import HistoryPermissions + + + +class KnowledgeBaseHistoryPermissions(TestCase, HistoryPermissions): + + + item_model = KnowledgeBase + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. create an organization that is different to item + 3. Create a device + 4. Add history device history entry as item + 5. create a user + 6. create user in different organization (with the required permission) + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + self.item = self.item_model.objects.create( + organization=organization, + title = 'deviceone' + ) + + self.history = self.model.objects.get( + item_pk = self.item.id, + item_class = self.item._meta.model_name, + action = self.model.Actions.ADD, + ) + + 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]) + + + 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.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, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) diff --git a/app/assistance/tests/unit/knowledge_base/test_knowledge_base_permission.py b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_permission.py new file mode 100644 index 00000000..76368584 --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_permission.py @@ -0,0 +1,189 @@ +# 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 assistance.models.knowledge_base import KnowledgeBase + + +class KnowledgeBasePermissions(TestCase, ModelPermissions): + + + model = KnowledgeBase + + app_namespace = 'Assistance' + + url_name_view = '_knowledge_base_view' + + url_name_add = '_knowledge_base_add' + + url_name_change = '_knowledge_base_change' + + url_name_delete = '_knowledge_base_delete' + + url_delete_response = reverse('Assistance:Knowledge Base') + + + @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 device + 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, + title = 'deviceone' + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + # self.url_add_kwargs = {'pk': self.item.id} + + self.add_data = {'device': 'device', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'device': 'device', 'organization': self.organization.id} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'device': 'device', '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 + ) diff --git a/app/assistance/tests/unit/knowledge_base/test_knowledge_base_views.py b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_views.py new file mode 100644 index 00000000..6bffbcf1 --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_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 ConfigManagementViews( + TestCase, + PrimaryModel +): + + add_module = 'assistance.views.knowledge_base' + 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/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category.py b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category.py new file mode 100644 index 00000000..ac9456dd --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category.py @@ -0,0 +1,42 @@ +import pytest +import unittest + +from django.test import TestCase + +from access.models import Organization + +from app.tests.abstract.models import TenancyModel + +from assistance.models.knowledge_base import KnowledgeBaseCategory + + + +@pytest.mark.django_db +class KnowledgeBaseModel( + TestCase, + TenancyModel +): + + model = KnowledgeBaseCategory + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. Create an item + + """ + + self.organization = Organization.objects.create(name='test_org') + + + self.item = self.model.objects.create( + organization = self.organization, + name = 'one', + ) + + self.second_item = self.model.objects.create( + organization = self.organization, + name = 'one_two', + ) diff --git a/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_core_history.py b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_core_history.py new file mode 100644 index 00000000..3b381348 --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_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.tests.abstract.history_entry import HistoryEntry +from core.tests.abstract.history_entry_parent_model import HistoryEntryParentItem + +from assistance.models.knowledge_base import KnowledgeBaseCategory + + + +class KnowledgeBaseHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + + model = KnowledgeBaseCategory + + + @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": "' + self.item_change.name + '"}' + + 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, + ) + + + def test_history_entry_children_delete(self): + """ Model has no child items """ + pass + diff --git a/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_history_permission.py b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_history_permission.py new file mode 100644 index 00000000..f1b1c0cc --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_history_permission.py @@ -0,0 +1,95 @@ +# 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 assistance.models.knowledge_base import KnowledgeBaseCategory + +from core.tests.abstract.history_permissions import HistoryPermissions + + + +class KnowledgeBaseHistoryPermissions(TestCase, HistoryPermissions): + + + item_model = KnowledgeBaseCategory + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. create an organization that is different to item + 3. Create a device + 4. Add history device history entry as item + 5. create a user + 6. create user in different organization (with the required permission) + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + self.item = self.item_model.objects.create( + organization=organization, + name = 'deviceone' + ) + + self.history = self.model.objects.get( + item_pk = self.item.id, + item_class = self.item._meta.model_name, + action = self.model.Actions.ADD, + ) + + 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]) + + + 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.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, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) diff --git a/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_permission.py b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_permission.py new file mode 100644 index 00000000..d14d4e1e --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_permission.py @@ -0,0 +1,189 @@ +# 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 assistance.models.knowledge_base import KnowledgeBaseCategory + + +class KnowledgeBasePermissions(TestCase, ModelPermissions): + + + model = KnowledgeBaseCategory + + app_namespace = 'Settings' + + url_name_view = '_knowledge_base_category_view' + + url_name_add = '_knowledge_base_category_add' + + url_name_change = '_knowledge_base_category_change' + + url_name_delete = '_knowledge_base_category_delete' + + url_delete_response = reverse('Settings:KB 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 device + 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 = 'deviceone' + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + # self.url_add_kwargs = {'pk': self.item.id} + + self.add_data = {'device': 'device', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'device': 'device', 'organization': self.organization.id} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'device': 'device', '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 + ) diff --git a/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_views.py b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_views.py new file mode 100644 index 00000000..ffe62a21 --- /dev/null +++ b/app/assistance/tests/unit/knowledge_base_category/test_knowledge_base_category_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 ConfigManagementViews( + TestCase, + PrimaryModel +): + + add_module = 'assistance.views.knowledge_base_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' diff --git a/app/assistance/urls.py b/app/assistance/urls.py index 6328d0cd..9d0e17be 100644 --- a/app/assistance/urls.py +++ b/app/assistance/urls.py @@ -7,5 +7,9 @@ app_name = "Assistance" urlpatterns = [ path("information", knowledge_base.Index.as_view(), name="Knowledge Base"), + path("information/add", knowledge_base.Add.as_view(), name="_knowledge_base_add"), + path("information//edit", knowledge_base.Change.as_view(), name="_knowledge_base_change"), + path("information//delete", knowledge_base.Delete.as_view(), name="_knowledge_base_delete"), + path("information/", knowledge_base.View.as_view(), name="_knowledge_base_view"), ] diff --git a/app/assistance/views/knowledge_base.py b/app/assistance/views/knowledge_base.py index a0bffddf..ff395db7 100644 --- a/app/assistance/views/knowledge_base.py +++ b/app/assistance/views/knowledge_base.py @@ -1,31 +1,190 @@ -import json +from django.contrib.auth import decorators as auth_decorator +from django.urls import reverse +from django.utils.decorators import method_decorator -from django.db.models import Q -from django.shortcuts import render -from django.template import Template, Context -from django.views import generic +from assistance.forms.knowledge_base import KnowledgeBaseForm +from assistance.models.knowledge_base import KnowledgeBase -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 core.views.common import DisplayView - -class Index(DisplayView): - - # permission_required = [ - # 'itil.view_knowledge_base' - # ] - - template_name = 'form.html.j2' +from settings.models.user_settings import UserSettings - def get(self, request): - context = {} - user_string = Template("{% include 'icons/issue_link.html.j2' with issue=10 %}") - user_context = Context(context) - context['form'] = user_string.render(user_context) +class Index(IndexView): + + context_object_name = "items" + + model = KnowledgeBase + + paginate_by = 10 + + permission_required = [ + 'assistance.view_knowledgebase' + ] + + template_name = 'assistance/kb_index.html.j2' - context['content_title'] = 'Knowledge Base' + def get_context_data(self, **kwargs): - return render(request, self.template_name, context) + context = super().get_context_data(**kwargs) + + context['model_docs_path'] = self.model._meta.app_label + '/knowledge_base/' + + context['content_title'] = 'Knowledge Base Articles' + + return context + + + +class Add(AddView): + + form_class = KnowledgeBaseForm + + model = KnowledgeBase + + permission_required = [ + 'assistance.add_knowledgebase', + ] + + + def get_initial(self): + + initial: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization + } + + 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('Assistance:Knowledge Base') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'New Group' + + return context + + + +class Change(ChangeView): + + context_object_name = "group" + + form_class = KnowledgeBaseForm + + model = KnowledgeBase + + permission_required = [ + 'assistance.change_knowledgebase', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = self.object.title + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Assistance:_knowledge_base_view', args=(self.kwargs['pk'],)) + + + +class View(ChangeView): + + context_object_name = "kb" + + form_class = KnowledgeBaseForm + + model = KnowledgeBase + + permission_required = [ + 'assistance.view_knowledgebase', + ] + + template_name = 'assistance/kb_article.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(config_group=self.kwargs['pk']) + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.model_name + + context['model_delete_url'] = reverse('Assistance:_knowledge_base_delete', args=(self.kwargs['pk'],)) + + + context['content_title'] = self.object.title + + return context + + + @method_decorator(auth_decorator.permission_required("assistance.change_knowledgebase", raise_exception=True)) + def post(self, request, *args, **kwargs): + + item = KnowledgeBase.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.organization = item.organization + + notes.save() + + # dont allow saving any post data outside notes. + # todo: figure out what needs to be returned + # return super().post(request, *args, **kwargs) + + + def get_success_url(self, **kwargs): + + return reverse('Assistance:_knowledge_base_view', args=(self.kwargs['pk'],)) + + + +class Delete(DeleteView): + + model = KnowledgeBase + + permission_required = [ + 'assistance.delete_knowledgebase', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + self.object.title + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Assistance:Knowledge Base') diff --git a/app/assistance/views/knowledge_base_category.py b/app/assistance/views/knowledge_base_category.py new file mode 100644 index 00000000..e02eed6d --- /dev/null +++ b/app/assistance/views/knowledge_base_category.py @@ -0,0 +1,191 @@ +from django.contrib.auth import decorators as auth_decorator +from django.urls import reverse +from django.utils.decorators import method_decorator + +from assistance.forms.knowledge_base_category import KnowledgeBaseCategoryForm +from assistance.models.knowledge_base import KnowledgeBase, KnowledgeBaseCategory + +from core.forms.comment import AddNoteForm +from core.models.notes import Notes +from core.views.common import AddView, ChangeView, DeleteView, DisplayView, IndexView + +from settings.models.user_settings import UserSettings + + + +class Index(IndexView): + + context_object_name = "items" + + model = KnowledgeBaseCategory + + paginate_by = 10 + + permission_required = [ + 'assistance.view_knowledgebasecategory' + ] + + template_name = 'assistance/kb_category_index.html.j2' + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['model_docs_path'] = self.model._meta.app_label + '/knowledge_base/' + + context['content_title'] = 'Knowledge Base Categories' + + return context + + +class Add(AddView): + + form_class = KnowledgeBaseCategoryForm + + model = KnowledgeBaseCategory + + permission_required = [ + 'assistance.add_knowledgebasecategory', + ] + + + def get_initial(self): + + initial: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization + } + + 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:KB Categories') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'New Group' + + return context + + + +class Change(ChangeView): + + context_object_name = "group" + + form_class = KnowledgeBaseCategoryForm + + model = KnowledgeBaseCategory + + permission_required = [ + 'assistance.change_knowledgebasecategory', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = self.object.name + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_knowledge_base_category_view', args=(self.kwargs['pk'],)) + + + +class View(ChangeView): + + context_object_name = "item" + + form_class = KnowledgeBaseCategoryForm + + model = KnowledgeBaseCategory + + permission_required = [ + 'assistance.view_knowledgebasecategory', + ] + + template_name = 'assistance/kb_category.html.j2' + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['articles'] = KnowledgeBase.objects.filter(category=self.kwargs['pk']) + + context['notes_form'] = AddNoteForm(prefix='note') + context['notes'] = Notes.objects.filter(config_group=self.kwargs['pk']) + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.model_name + + context['model_delete_url'] = reverse('Settings:_knowledge_base_category_delete', args=(self.kwargs['pk'],)) + + + context['content_title'] = self.object.name + + return context + + + @method_decorator(auth_decorator.permission_required("assistance.change_knowledgebasecategory", raise_exception=True)) + def post(self, request, *args, **kwargs): + + item = KnowledgeBase.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.organization = item.organization + + notes.save() + + # dont allow saving any post data outside notes. + # todo: figure out what needs to be returned + # return super().post(request, *args, **kwargs) + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_knowledge_base_category_view', args=(self.kwargs['pk'],)) + + + +class Delete(DeleteView): + + model = KnowledgeBaseCategory + + permission_required = [ + 'assistance.delete_knowledgebasecategory', + ] + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Delete ' + self.object.name + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Settings:KB Categories') diff --git a/app/core/tests/abstract/history_permissions.py b/app/core/tests/abstract/history_permissions.py index f45e467a..ae43a7e6 100644 --- a/app/core/tests/abstract/history_permissions.py +++ b/app/core/tests/abstract/history_permissions.py @@ -12,7 +12,12 @@ from itam.models.device import Device class HistoryPermissions: - """Test cases for accessing History """ + """Test cases for accessing History + + For this test to function properly you must add the history items model to + `app.core.views.history.View.get_object()`. specifically an entry to the switch in the middle + of the function. + """ item: object diff --git a/app/core/views/history.py b/app/core/views/history.py index 5b95b5ab..56582519 100644 --- a/app/core/views/history.py +++ b/app/core/views/history.py @@ -67,6 +67,18 @@ class View(OrganizationPermission, generic.View): self.model = ExternalLink + case 'knowledgebase': + + from assistance.models.knowledge_base import KnowledgeBase + + self.model = KnowledgeBase + + case 'knowledgebasecategory': + + from assistance.models.knowledge_base import KnowledgeBaseCategory + + self.model = KnowledgeBaseCategory + case 'manufacturer': self.model = Manufacturer diff --git a/app/settings/urls.py b/app/settings/urls.py index 4fb0dcad..80504ce0 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -1,5 +1,7 @@ from django.urls import path +from assistance.views import knowledge_base_category + from core.views import celery_log from settings.views import app_settings, home, device_models, device_types, external_link, manufacturer, software_categories @@ -33,6 +35,12 @@ urlpatterns = [ path("device_type/add/", device_type.Add.as_view(), name="_device_type_add"), path("device_type//delete", device_type.Delete.as_view(), name="_device_type_delete"), + path("kb/category", knowledge_base_category.Index.as_view(), name="KB Categories"), + path("kb/category/add", knowledge_base_category.Add.as_view(), name="_knowledge_base_category_add"), + path("kb/category//edit", knowledge_base_category.Change.as_view(), name="_knowledge_base_category_change"), + path("kb/category//delete", knowledge_base_category.Delete.as_view(), name="_knowledge_base_category_delete"), + path("kb/category/", knowledge_base_category.View.as_view(), name="_knowledge_base_category_view"), + path("software_category", software_categories.Index.as_view(), name="_software_categories"), path("software_category/", software_category.View.as_view(), name="_software_category_view"), path("software_category/add/", software_category.Add.as_view(), name="_software_category_add"), diff --git a/docs/projects/centurion_erp/development/forms.md b/docs/projects/centurion_erp/development/forms.md index c5905f08..2a0c893f 100644 --- a/docs/projects/centurion_erp/development/forms.md +++ b/docs/projects/centurion_erp/development/forms.md @@ -27,6 +27,30 @@ All forms must meet the following requirements: - Any filtering of a fields `queryset` is to filter the existing `queryset` not redefine it. i.e. `field[].queryset = field[].queryset.filter()` +- validating fields where the validation requires access to multiple fields is done inside the form class using function `clean` + + ``` py + + def clean(self): + + cleaned_data = super().clean() + + # begin example + responsible_user = cleaned_data.get("responsible_user") + responsible_teams = cleaned_data.get("responsible_teams") example + + + if not responsible_user and not responsible_teams: + + raise ValidationError('A Responsible User or Team must be assigned.') + # end example + + # your validation after `super()` call + + return cleaned_data + + ``` + ## Abstract Classes diff --git a/docs/projects/centurion_erp/development/models.md b/docs/projects/centurion_erp/development/models.md index 03a6131a..72942c1b 100644 --- a/docs/projects/centurion_erp/development/models.md +++ b/docs/projects/centurion_erp/development/models.md @@ -33,6 +33,11 @@ All models must meet the following requirements: - No `queryset` is to return data that the user has not got access to. _see [queryset()](./api/models/tenancy_object.md#tenancy-object-manager)_ +- Single Field validation is conducted if required. + + !!! danger "Requirement" + Multi-field validation, or validation that requires access to multiple fields must be done within the [form class](./forms.md#requirements). + ## History From 32cdcc38b56435d46722364c5782436696ce343b Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 00:36:20 +0930 Subject: [PATCH 09/82] feat(base): add code highlighting to markdown !43 #10 --- CONTRIBUTING.md | 3 ++ app/core/templatetags/markdown.py | 2 +- app/project-static/base.css | 4 ++ app/project-static/code.css | 75 +++++++++++++++++++++++++++++++ app/templates/base.html.j2 | 1 + requirements.txt | 1 + 6 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 app/project-static/code.css diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 990bfbee..b451e128 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,9 @@ python3 manage.py createsuperuser # If model changes python3 manage.py makemigrations --noinput +# To update code highlight run +pygmentize -S default -f html -a .codehilite > project-static/code.css + ``` Updates to python modules will need to be captured with SCM. This can be done by running `pip freeze > requirements.txt` from the running virtual environment. diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py index d303dcc7..867b2f80 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']) \ No newline at end of file + return md.markdown(value, extensions=['markdown.extensions.fenced_code', 'codehilite']) \ No newline at end of file diff --git a/app/project-static/base.css b/app/project-static/base.css index 28723032..5d03b7cb 100644 --- a/app/project-static/base.css +++ b/app/project-static/base.css @@ -31,6 +31,10 @@ h2 { padding-left: 50px } +.codehilite { + display: inline; +} + span#content_header_icon { float: right; width: 30px; diff --git a/app/project-static/code.css b/app/project-static/code.css new file mode 100644 index 00000000..c4b2fd9c --- /dev/null +++ b/app/project-static/code.css @@ -0,0 +1,75 @@ +pre { line-height: 125%; } +td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 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 .c { color: #3D7B7B; font-style: italic } /* Comment */ +.codehilite .err { border: 1px solid #FF0000 } /* Error */ +.codehilite .k { color: #008000; font-weight: bold } /* Keyword */ +.codehilite .o { color: #666666 } /* Operator */ +.codehilite .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */ +.codehilite .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */ +.codehilite .cp { color: #9C6500 } /* Comment.Preproc */ +.codehilite .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */ +.codehilite .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */ +.codehilite .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */ +.codehilite .gd { color: #A00000 } /* Generic.Deleted */ +.codehilite .ge { font-style: italic } /* Generic.Emph */ +.codehilite .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +.codehilite .gr { color: #E40000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #008400 } /* Generic.Inserted */ +.codehilite .go { color: #717171 } /* Generic.Output */ +.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #0044DD } /* Generic.Traceback */ +.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.codehilite .kp { color: #008000 } /* Keyword.Pseudo */ +.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.codehilite .kt { color: #B00040 } /* Keyword.Type */ +.codehilite .m { color: #666666 } /* Literal.Number */ +.codehilite .s { color: #BA2121 } /* Literal.String */ +.codehilite .na { color: #687822 } /* Name.Attribute */ +.codehilite .nb { color: #008000 } /* Name.Builtin */ +.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.codehilite .no { color: #880000 } /* Name.Constant */ +.codehilite .nd { color: #AA22FF } /* Name.Decorator */ +.codehilite .ni { color: #717171; font-weight: bold } /* Name.Entity */ +.codehilite .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */ +.codehilite .nf { color: #0000FF } /* Name.Function */ +.codehilite .nl { color: #767600 } /* Name.Label */ +.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.codehilite .nv { color: #19177C } /* Name.Variable */ +.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.codehilite .w { color: #bbbbbb } /* Text.Whitespace */ +.codehilite .mb { color: #666666 } /* Literal.Number.Bin */ +.codehilite .mf { color: #666666 } /* Literal.Number.Float */ +.codehilite .mh { color: #666666 } /* Literal.Number.Hex */ +.codehilite .mi { color: #666666 } /* Literal.Number.Integer */ +.codehilite .mo { color: #666666 } /* Literal.Number.Oct */ +.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */ +.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */ +.codehilite .sc { color: #BA2121 } /* Literal.String.Char */ +.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */ +.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */ +.codehilite .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */ +.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.codehilite .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */ +.codehilite .sx { color: #008000 } /* Literal.String.Other */ +.codehilite .sr { color: #A45A77 } /* Literal.String.Regex */ +.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */ +.codehilite .ss { color: #19177C } /* Literal.String.Symbol */ +.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.codehilite .fm { color: #0000FF } /* Name.Function.Magic */ +.codehilite .vc { color: #19177C } /* Name.Variable.Class */ +.codehilite .vg { color: #19177C } /* Name.Variable.Global */ +.codehilite .vi { color: #19177C } /* Name.Variable.Instance */ +.codehilite .vm { color: #19177C } /* Name.Variable.Magic */ +.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/app/templates/base.html.j2 b/app/templates/base.html.j2 index b86f81bd..585c648c 100644 --- a/app/templates/base.html.j2 +++ b/app/templates/base.html.j2 @@ -12,6 +12,7 @@ {% else %} + {% endif %} diff --git a/requirements.txt b/requirements.txt index 89b35d00..7099ca45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ drf-spectacular[sidecar]==0.27.2 django_split_settings==1.3.1 markdown==3.6 +Pygments celery==5.4.0 django-celery-results==2.5.1 From cf2dce320c61d6b2e64b1c5b5865759c35b11dcb Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 01:03:49 +0930 Subject: [PATCH 10/82] docs(assistance): document kb for user !43 #10 --- .../centurion_erp/user/assistance/index.md | 6 +++ .../user/assistance/knowledge_base.md | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/docs/projects/centurion_erp/user/assistance/index.md b/docs/projects/centurion_erp/user/assistance/index.md index 20380820..e7ba080d 100644 --- a/docs/projects/centurion_erp/user/assistance/index.md +++ b/docs/projects/centurion_erp/user/assistance/index.md @@ -6,3 +6,9 @@ template: project.html about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp --- +The Assistance module within Centurion ERP is intended to cover those items that are end user facing. These items are generally for an organizations IT users to be able to obtain "Assistance." + + +## Features + +- [Knowledge Base](./knowledge_base.md) \ No newline at end of file diff --git a/docs/projects/centurion_erp/user/assistance/knowledge_base.md b/docs/projects/centurion_erp/user/assistance/knowledge_base.md index a418f169..b790f314 100644 --- a/docs/projects/centurion_erp/user/assistance/knowledge_base.md +++ b/docs/projects/centurion_erp/user/assistance/knowledge_base.md @@ -6,3 +6,48 @@ template: project.html about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp --- +A Knowledge Base forms part of the ITSM, specifically Information Management. Information Management is intended to capture all available data within an organization, not just the IT department. This information is then categorised and presented to the users that require it. + + +## Article + +Article content is intended to be written in markdown, which provides for a richer user experience. To create an article the following fields are are available: + +- Title _Article title_ + +- Summary _Short summary of the article_ + +- Content _article content_ + +- Category _article category_ + +- Release Date _date to publish the article_ + + !!! info + If no release date is set, the article will be published immediately. + +- Expiry Date _date the article expires_ + + !!! info + Not specifying an expiry date means that the article will remain published indefinitely. + +- Target Team(s) _team(s) to make the article available for_ + +- Target User _user to target the article for_ + +- Responsible User _the user who is considered the owner_ + +- Responsible Team(s) _the team or teams who is considered the owner_ + +- Public _if the article is to be made available to public user (users who are not logged in)_ _[See #144](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/144)_ + +!!! info + An article must either have a target user or target group. Not both. + +!!! info + An article can have a responsible user or responsible team(s). Not both. + + +### Notes + +Notes can be added to an article that is intended to be article owner notes. these notes are not made available as part of the article. From 215c5e464cc6b03e2c081201e6bfed67b4807c1c Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 01:27:17 +0930 Subject: [PATCH 11/82] feat(assistance): Dont display expired articles for "view" users !43 #10 --- app/assistance/views/knowledge_base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/assistance/views/knowledge_base.py b/app/assistance/views/knowledge_base.py index ff395db7..dd7b5ca4 100644 --- a/app/assistance/views/knowledge_base.py +++ b/app/assistance/views/knowledge_base.py @@ -1,3 +1,5 @@ +from datetime import datetime + from django.contrib.auth import decorators as auth_decorator from django.urls import reverse from django.utils.decorators import method_decorator @@ -32,6 +34,10 @@ class Index(IndexView): context = super().get_context_data(**kwargs) + if not self.request.user.has_perm('assistance.change_knowledgebase') and not self.request.user.is_superuser: + + context['items'] = self.get_queryset().filter(expiry_date__lte=datetime.now()) + context['model_docs_path'] = self.model._meta.app_label + '/knowledge_base/' context['content_title'] = 'Knowledge Base Articles' From b73807a140c4018629fbca30a9596f134c80ff99 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 01:27:44 +0930 Subject: [PATCH 12/82] feat(assistance): Add date picker to date fields for KB articles !43 #10 --- app/assistance/forms/knowledge_base.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/assistance/forms/knowledge_base.py b/app/assistance/forms/knowledge_base.py index 2bfcebfe..b50e4e47 100644 --- a/app/assistance/forms/knowledge_base.py +++ b/app/assistance/forms/knowledge_base.py @@ -1,5 +1,9 @@ + +from django import forms from django.forms import ValidationError +from app import settings + from assistance.models.knowledge_base import KnowledgeBase from core.forms.common import CommonModelForm @@ -19,6 +23,18 @@ class KnowledgeBaseForm(CommonModelForm): prefix = 'knowledgebase' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['expiry_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local', 'format': "%Y-%m-%dT%H:%M"}) + self.fields['expiry_date'].input_formats = settings.DATETIME_FORMAT + self.fields['expiry_date'].format="%Y-%m-%dT%H:%M" + + self.fields['release_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local', 'format': "%Y-%m-%dT%H:%M"}) + self.fields['release_date'].input_formats = settings.DATETIME_FORMAT + self.fields['release_date'].format="%Y-%m-%dT%H:%M" + + def clean(self): cleaned_data = super().clean() From 05484d9e02667c8ce6c675317d083b1913d0a4f8 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 01:59:15 +0930 Subject: [PATCH 13/82] feat(assistance): Filter KB articles to target user only intended to filter for users whom dont have change perm. !43 #10 --- app/assistance/views/knowledge_base.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/assistance/views/knowledge_base.py b/app/assistance/views/knowledge_base.py index dd7b5ca4..f7d9622f 100644 --- a/app/assistance/views/knowledge_base.py +++ b/app/assistance/views/knowledge_base.py @@ -1,9 +1,12 @@ from datetime import datetime from django.contrib.auth import decorators as auth_decorator +from django.db.models import Q from django.urls import reverse from django.utils.decorators import method_decorator +from access.models import TeamUsers + from assistance.forms.knowledge_base import KnowledgeBaseForm from assistance.models.knowledge_base import KnowledgeBase @@ -36,7 +39,23 @@ class Index(IndexView): if not self.request.user.has_perm('assistance.change_knowledgebase') and not self.request.user.is_superuser: - context['items'] = self.get_queryset().filter(expiry_date__lte=datetime.now()) + user_teams = [] + for team_user in TeamUsers.objects.filter(user=self.request.user): + + if team_user.team.id not in user_teams: + + user_teams += [ team_user.team.id ] + + + context['items'] = self.get_queryset().filter( + Q(expiry_date__lte=datetime.now()) + | + Q(expiry_date=None) + ).filter( + Q(target_team__in=user_teams) + | + Q(target_user=self.request.user.id) + ) context['model_docs_path'] = self.model._meta.app_label + '/knowledge_base/' From 3d06112860153865847dcf5d5713cdc7f4117e15 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 01:59:30 +0930 Subject: [PATCH 14/82] docs(assistance): document kb categories for user !43 closes #10 --- .../user/assistance/knowledge_base.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/projects/centurion_erp/user/assistance/knowledge_base.md b/docs/projects/centurion_erp/user/assistance/knowledge_base.md index b790f314..19bc1c8f 100644 --- a/docs/projects/centurion_erp/user/assistance/knowledge_base.md +++ b/docs/projects/centurion_erp/user/assistance/knowledge_base.md @@ -51,3 +51,19 @@ Article content is intended to be written in markdown, which provides for a rich ### Notes Notes can be added to an article that is intended to be article owner notes. these notes are not made available as part of the article. + + +## Categories + +Categories are available to offer an ability to filter/sort articles. Fields available as part of Knowledge Base Categories are: + +- Name _Category Name_ + +- Parent _Parent Category for nesting categories_ + +- Target Team _Team the categories articles should be made available for_ + +- Target User _User the categories articles should be made available for_ + +!!! info + A KB Category must either have a target user or target group. Not both. From 56196f721df90229b52a68877d3acbd2e6f38e4a Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 02:37:32 +0930 Subject: [PATCH 15/82] fix(assistance): Only return distinct values when limiting KB articles !43 #10 --- app/assistance/views/knowledge_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assistance/views/knowledge_base.py b/app/assistance/views/knowledge_base.py index f7d9622f..79eef8ba 100644 --- a/app/assistance/views/knowledge_base.py +++ b/app/assistance/views/knowledge_base.py @@ -55,7 +55,7 @@ class Index(IndexView): Q(target_team__in=user_teams) | Q(target_user=self.request.user.id) - ) + ).distinct() context['model_docs_path'] = self.model._meta.app_label + '/knowledge_base/' From a948ec7bd7d8dce4b57449315281a032f859029d Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 05:20:05 +0930 Subject: [PATCH 16/82] feat(itim): Service Management !43 #69 --- .../test_knowledge_base_views.py | 2 +- app/core/migrations/0003_notes_service.py | 20 ++ app/core/models/notes.py | 11 + app/core/views/history.py | 6 + app/itim/forms/ports.py | 24 ++ app/itim/forms/services.py | 40 ++++ app/itim/migrations/0001_initial.py | 98 +++++++++ app/itim/models/__init__.py | 0 app/itim/models/clusters.py | 123 +++++++++++ app/itim/models/services.py | 155 +++++++++++++ app/itim/templates/itim/port_index.html.j2 | 53 +++++ app/itim/templates/itim/service.html.j2 | 206 ++++++++++++++++++ app/itim/templates/itim/service_index.html.j2 | 53 +++++ app/itim/tests/unit/service/test_service.py | 42 ++++ .../unit/service/test_service_core_history.py | 78 +++++++ .../test_service_history_permission.py | 95 ++++++++ .../unit/service/test_service_permission.py | 189 ++++++++++++++++ .../tests/unit/service/test_service_views.py | 29 +++ app/itim/urls.py | 11 +- app/itim/views/ports.py | 81 +++++++ app/itim/views/services.py | 186 ++++++++++++++++ app/settings/templates/settings/home.html.j2 | 7 + app/settings/urls.py | 4 + .../projects/centurion_erp/user/itim/index.md | 14 ++ .../centurion_erp/user/itim/service.md | 12 + mkdocs.yml | 6 + 26 files changed, 1540 insertions(+), 5 deletions(-) create mode 100644 app/core/migrations/0003_notes_service.py create mode 100644 app/itim/forms/ports.py create mode 100644 app/itim/forms/services.py create mode 100644 app/itim/migrations/0001_initial.py create mode 100644 app/itim/models/__init__.py create mode 100644 app/itim/models/clusters.py create mode 100644 app/itim/models/services.py create mode 100644 app/itim/templates/itim/port_index.html.j2 create mode 100644 app/itim/templates/itim/service.html.j2 create mode 100644 app/itim/templates/itim/service_index.html.j2 create mode 100644 app/itim/tests/unit/service/test_service.py create mode 100644 app/itim/tests/unit/service/test_service_core_history.py create mode 100644 app/itim/tests/unit/service/test_service_history_permission.py create mode 100644 app/itim/tests/unit/service/test_service_permission.py create mode 100644 app/itim/tests/unit/service/test_service_views.py create mode 100644 app/itim/views/ports.py create mode 100644 app/itim/views/services.py create mode 100644 docs/projects/centurion_erp/user/itim/index.md create mode 100644 docs/projects/centurion_erp/user/itim/service.md diff --git a/app/assistance/tests/unit/knowledge_base/test_knowledge_base_views.py b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_views.py index 6bffbcf1..5c6f6b3c 100644 --- a/app/assistance/tests/unit/knowledge_base/test_knowledge_base_views.py +++ b/app/assistance/tests/unit/knowledge_base/test_knowledge_base_views.py @@ -8,7 +8,7 @@ from app.tests.abstract.models import PrimaryModel -class ConfigManagementViews( +class KnowledgeBaseViews( TestCase, PrimaryModel ): diff --git a/app/core/migrations/0003_notes_service.py b/app/core/migrations/0003_notes_service.py new file mode 100644 index 00000000..c3c88b5b --- /dev/null +++ b/app/core/migrations/0003_notes_service.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.7 on 2024-07-20 20:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_notes'), + ('itim', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='notes', + name='service', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.service'), + ), + ] diff --git a/app/core/models/notes.py b/app/core/models/notes.py index 22225f0d..fdb71794 100644 --- a/app/core/models/notes.py +++ b/app/core/models/notes.py @@ -10,6 +10,9 @@ from itam.models.device import Device from itam.models.software import Software from itam.models.operating_system import OperatingSystem +from itim.models.services import Service + + class NotesCommonFields(TenancyObject, models.Model): @@ -88,6 +91,14 @@ class Notes(NotesCommonFields): blank= True ) + service = models.ForeignKey( + Service, + on_delete=models.CASCADE, + default = None, + null = True, + blank= True + ) + software = models.ForeignKey( Software, on_delete=models.CASCADE, diff --git a/app/core/views/history.py b/app/core/views/history.py index 56582519..6b3698f6 100644 --- a/app/core/views/history.py +++ b/app/core/views/history.py @@ -103,6 +103,12 @@ class View(OrganizationPermission, generic.View): self.model = Team + case 'service': + + from itim.models.services import Service + + self.model = Service + case _: raise Exception('Unable to determine history items model') diff --git a/app/itim/forms/ports.py b/app/itim/forms/ports.py new file mode 100644 index 00000000..dc128df7 --- /dev/null +++ b/app/itim/forms/ports.py @@ -0,0 +1,24 @@ + +# from django import forms +# from django.forms import ValidationError + +# from app import settings + +from itim.models.services import Port + +from core.forms.common import CommonModelForm + +from settings.models.user_settings import UserSettings + + + +class PortForm(CommonModelForm): + + + class Meta: + + fields = '__all__' + + model = Port + + prefix = 'port' diff --git a/app/itim/forms/services.py b/app/itim/forms/services.py new file mode 100644 index 00000000..2b948875 --- /dev/null +++ b/app/itim/forms/services.py @@ -0,0 +1,40 @@ +from django import forms +from django.forms import ValidationError + +from itim.models.services import Service + +from core.forms.common import CommonModelForm + + + +class ServiceForm(CommonModelForm): + + + class Meta: + + fields = '__all__' + + model = Service + + prefix = 'service' + + + def clean(self): + + cleaned_data = super().clean() + + device = cleaned_data.get("device") + cluster = cleaned_data.get("cluster") + + + if not device and not cluster: + + raise ValidationError('A Service must be assigned to either a "Cluster" or a "Device".') + + + if device and cluster: + + raise ValidationError('A Service must only be assigned to either a "Cluster" or a "Device". Not both.') + + + return cleaned_data diff --git a/app/itim/migrations/0001_initial.py b/app/itim/migrations/0001_initial.py new file mode 100644 index 00000000..aa8a6eea --- /dev/null +++ b/app/itim/migrations/0001_initial.py @@ -0,0 +1,98 @@ +# Generated by Django 5.0.7 on 2024-07-20 20:40 + +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', '0001_initial'), + ('itam', '0002_device_config'), + ] + + operations = [ + migrations.CreateModel( + name='ClusterType', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(help_text='Name of the Cluster Type', max_length=50, verbose_name='Name')), + ('slug', access.fields.AutoSlugField()), + ('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': 'ClusterType', + 'verbose_name_plural': 'ClusterTypes', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Cluster', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(help_text='Name of the Cluster', max_length=50, verbose_name='Name')), + ('slug', access.fields.AutoSlugField()), + ('config', models.JSONField(blank=True, default=None, help_text='Cluster Configuration', null=True, verbose_name='Configuration')), + ('devices', models.ManyToManyField(blank=True, default=None, help_text='Devices that are deployed upon the cluster.', related_name='cluster_device', to='itam.device', verbose_name='Devices')), + ('node', models.ManyToManyField(blank=True, default=None, help_text='Hosts for resource consumption that the cluster is deployed upon', related_name='cluster_node', to='itam.device', verbose_name='Nodes')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('parent_cluster', models.ForeignKey(blank=True, default=None, help_text='Parent Cluster for this cluster', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.cluster', verbose_name='Parent Cluster')), + ('cluster_type', models.ForeignKey(blank=True, default=None, help_text='Parent Cluster for this cluster', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.clustertype', verbose_name='Parent Cluster')), + ], + options={ + 'verbose_name': 'Cluster', + 'verbose_name_plural': 'Clusters', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Port', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('number', models.IntegerField(help_text='The port number', verbose_name='Port Number')), + ('description', models.CharField(blank=True, default=None, help_text='Short description of port', max_length=80, null=True, verbose_name='Description')), + ('protocol', models.CharField(choices=[('TCP', 'TCP'), ('UDP', 'UDP')], default='TCP', help_text='Layer 4 Network Protocol', max_length=3, verbose_name='Protocol')), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoLastModifiedField(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])), + ], + options={ + 'verbose_name': 'Protocol', + 'verbose_name_plural': 'Protocols', + 'ordering': ['number', 'protocol'], + }, + ), + migrations.CreateModel( + name='Service', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(help_text='Name of the Service', max_length=50, verbose_name='Name')), + ('config', models.JSONField(blank=True, default=None, help_text='Cluster Configuration', null=True, verbose_name='Configuration')), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)), + ('cluster', models.ForeignKey(blank=True, default=None, help_text='Cluster the service is assigned to', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.cluster', verbose_name='Cluster')), + ('dependent_service', models.ManyToManyField(blank=True, default=None, help_text='Services that this service depends upon', to='itim.service', verbose_name='Dependent Services')), + ('device', models.ForeignKey(blank=True, default=None, help_text='Device the service is assigned to', null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.device', verbose_name='Device')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), + ('port', models.ManyToManyField(help_text='Port the service is available on', to='itim.port', verbose_name='Port')), + ], + options={ + 'verbose_name': 'Service', + 'verbose_name_plural': 'Services', + 'ordering': ['name'], + }, + ), + ] diff --git a/app/itim/models/__init__.py b/app/itim/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/itim/models/clusters.py b/app/itim/models/clusters.py new file mode 100644 index 00000000..88552b96 --- /dev/null +++ b/app/itim/models/clusters.py @@ -0,0 +1,123 @@ +from django.contrib.auth.models import User +from django.db import models +from django.forms import ValidationError + +from access.fields import * +from access.models import Team, TenancyObject + +from itam.models.device import Device + + + +class ClusterType(TenancyObject): + + + class Meta: + + ordering = [ + 'name', + ] + + verbose_name = "ClusterType" + + verbose_name_plural = "ClusterTypes" + + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + name = models.CharField( + blank = False, + help_text = 'Name of the Cluster Type', + max_length = 50, + unique = False, + verbose_name = 'Name', + ) + + slug = AutoSlugField() + + + +class Cluster(TenancyObject): + + + class Meta: + + ordering = [ + 'name', + ] + + verbose_name = "Cluster" + + verbose_name_plural = "Clusters" + + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + parent_cluster = models.ForeignKey( + 'self', + blank = True, + default = None, + help_text = 'Parent Cluster for this cluster', + null = True, + on_delete = models.CASCADE, + verbose_name = 'Parent Cluster', + ) + + cluster_type = models.ForeignKey( + ClusterType, + blank = True, + default = None, + help_text = 'Parent Cluster for this cluster', + null = True, + on_delete = models.CASCADE, + verbose_name = 'Parent Cluster', + ) + + name = models.CharField( + blank = False, + help_text = 'Name of the Cluster', + max_length = 50, + unique = False, + verbose_name = 'Name', + ) + + slug = AutoSlugField() + + config = models.JSONField( + blank = True, + default = None, + help_text = 'Cluster Configuration', + null = True, + verbose_name = 'Configuration', + ) + + node = models.ManyToManyField( + Device, + blank = True, + default = None, + help_text = 'Hosts for resource consumption that the cluster is deployed upon', + related_name = 'cluster_node', + verbose_name = 'Nodes', + ) + + devices = models.ManyToManyField( + Device, + blank = True, + default = None, + help_text = 'Devices that are deployed upon the cluster.', + related_name = 'cluster_device', + verbose_name = 'Devices', + ) + + + def __str__(self): + + return self.name diff --git a/app/itim/models/services.py b/app/itim/models/services.py new file mode 100644 index 00000000..c17b0705 --- /dev/null +++ b/app/itim/models/services.py @@ -0,0 +1,155 @@ +from django.contrib.auth.models import User +from django.db import models +from django.forms import ValidationError + +from access.fields import * +from access.models import Team, TenancyObject + +from itam.models.device import Device + +from itim.models.clusters import Cluster + + + +class Port(TenancyObject): + + + class Meta: + + ordering = [ + 'number', + 'protocol', + ] + + verbose_name = "Protocol" + + verbose_name_plural = "Protocols" + + + class Protocol(models.TextChoices): + TCP = 'TCP', 'TCP' + UDP = 'UDP', 'UDP' + + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + number = models.IntegerField( + blank = False, + help_text = 'The port number', + unique = False, + verbose_name = 'Port Number', + ) + + description = models.CharField( + blank = True, + default = None, + help_text = 'Short description of port', + max_length = 80, + null = True, + verbose_name = 'Description', + ) + + protocol = models.CharField( + blank = False, + choices=Protocol.choices, + default = Protocol.TCP, + help_text = 'Layer 4 Network Protocol', + max_length = 3, + verbose_name = 'Protocol', + ) + + created = AutoCreatedField() + + modified = AutoLastModifiedField() + + + def __str__(self): + + return str(self.protocol) + '/' + str(self.number) + + + +class Service(TenancyObject): + + + class Meta: + + ordering = [ + 'name', + ] + + verbose_name = "Service" + + verbose_name_plural = "Services" + + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + name = models.CharField( + blank = False, + help_text = 'Name of the Service', + max_length = 50, + unique = False, + verbose_name = 'Name', + ) + + device = models.ForeignKey( + Device, + blank = True, + default = None, + help_text = 'Device the service is assigned to', + null = True, + on_delete = models.CASCADE, + verbose_name = 'Device', + ) + + cluster = models.ForeignKey( + 'Cluster', + blank = True, + default = None, + help_text = 'Cluster the service is assigned to', + null = True, + on_delete = models.CASCADE, + unique = False, + verbose_name = 'Cluster', + ) + + config = models.JSONField( + blank = True, + default = None, + help_text = 'Cluster Configuration', + null = True, + verbose_name = 'Configuration', + ) + + port = models.ManyToManyField( + Port, + blank = False, + help_text = 'Port the service is available on', + verbose_name = 'Port', + ) + + dependent_service = models.ManyToManyField( + 'self', + blank = True, + default = None, + help_text = 'Services that this service depends upon', + verbose_name = 'Dependent Services', + ) + + created = AutoCreatedField() + + modified = AutoLastModifiedField() + + + def __str__(self): + + return self.name diff --git a/app/itim/templates/itim/port_index.html.j2 b/app/itim/templates/itim/port_index.html.j2 new file mode 100644 index 00000000..125c6959 --- /dev/null +++ b/app/itim/templates/itim/port_index.html.j2 @@ -0,0 +1,53 @@ +{% extends 'base.html.j2' %} + +{% block content %} + + + + + + + + + + {% if items %} + {% for item in items %} + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
TitleCluster / DeviceOrganization 
{{ item.protocol }}/{{ item.number }} + {% if item.device %} + {{ item.device }} + {% else %} + {{ item.cluster }} + {% endif %} + {{ item.organization }} 
Nothing Found
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/itim/templates/itim/service.html.j2 b/app/itim/templates/itim/service.html.j2 new file mode 100644 index 00000000..ead3ba83 --- /dev/null +++ b/app/itim/templates/itim/service.html.j2 @@ -0,0 +1,206 @@ +{% extends 'base.html.j2' %} + +{% load markdown %} + +{% block content %} + + + + +
+ + + + {% if perms.assistance.change_service %} + + {% endif %} +
+ +
+
+

Details

+ + {% csrf_token %} + +
+ +
+ +
+ + {{ form.name.value }} +
+ +
+ + + {% if form.organization.value %} + {{ item.organization }} + {% else %} +   + {% endif %} + +
+ +
+ + {{ item.created }} +
+ +
+ + + {% if item.cluster %} + {{ item.cluster }} + {% else %} + {{ item.device }} + {% endif %} + +
+ +
+ + {{ item.modified }} +
+ +
+ + +
+
+ + +
+ {% if form.model_notes.value %} + {{ form.model_notes.value | markdown | safe }} + {% else %} +   + {% endif %} +
+
+
+ +
+ + + +
+ +
+

Ports

+ + + + + + {% for port in item.port.all %} + + + + + {% endfor%} +
NameDescription
{{ port }}{{ port.description }}
+ + +
+ + +
+

Service Config

+
+ +
+ +
+ + + +
+ + {% if perms.assistance.change_knowledgebase %} +
+

+ Notes +

+ {{ notes_form }} + +
+ {% if notes %} + {% for note in notes %} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ {% endif %} + +
+ +{% endblock %} \ No newline at end of file diff --git a/app/itim/templates/itim/service_index.html.j2 b/app/itim/templates/itim/service_index.html.j2 new file mode 100644 index 00000000..fce45c14 --- /dev/null +++ b/app/itim/templates/itim/service_index.html.j2 @@ -0,0 +1,53 @@ +{% extends 'base.html.j2' %} + +{% block content %} + + + + + + + + + + {% if items %} + {% for item in items %} + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
TitleCluster / DeviceOrganization 
{{ item.name }} + {% if item.device %} + {{ item.device }} + {% else %} + {{ item.cluster }} + {% endif %} + {{ item.organization }} 
Nothing Found
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/itim/tests/unit/service/test_service.py b/app/itim/tests/unit/service/test_service.py new file mode 100644 index 00000000..c25f7bf5 --- /dev/null +++ b/app/itim/tests/unit/service/test_service.py @@ -0,0 +1,42 @@ +import pytest +import unittest + +from django.test import TestCase + +from access.models import Organization + +from app.tests.abstract.models import TenancyModel + +from itim.models.services import Service + + + +@pytest.mark.django_db +class ServiceModel( + TestCase, + TenancyModel +): + + model = Service + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. Create an item + + """ + + self.organization = Organization.objects.create(name='test_org') + + + self.item = self.model.objects.create( + organization = self.organization, + name = 'one', + ) + + self.second_item = self.model.objects.create( + organization = self.organization, + name = 'one_two', + ) diff --git a/app/itim/tests/unit/service/test_service_core_history.py b/app/itim/tests/unit/service/test_service_core_history.py new file mode 100644 index 00000000..8447aa70 --- /dev/null +++ b/app/itim/tests/unit/service/test_service_core_history.py @@ -0,0 +1,78 @@ + +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 itim.models.services import Service + + + +class ServiceHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + + model = Service + + + @classmethod + def setUpTestData(self): + """ Setup Test """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + self.item_parent = self.model.objects.create( + name = 'test_item_parent_' + self.model._meta.model_name, + organization = self.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": "' + self.item_change.name + '"}' + + 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.item_parent._meta.model_name, + ) diff --git a/app/itim/tests/unit/service/test_service_history_permission.py b/app/itim/tests/unit/service/test_service_history_permission.py new file mode 100644 index 00000000..bfec747a --- /dev/null +++ b/app/itim/tests/unit/service/test_service_history_permission.py @@ -0,0 +1,95 @@ +# 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 itim.models.services import Service + +from core.tests.abstract.history_permissions import HistoryPermissions + + + +class ServiceHistoryPermissions(TestCase, HistoryPermissions): + + + item_model = Service + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. create an organization that is different to item + 3. Create a device + 4. Add history device history entry as item + 5. create a user + 6. create user in different organization (with the required permission) + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + self.item = self.item_model.objects.create( + organization=organization, + name = 'deviceone' + ) + + self.history = self.model.objects.get( + item_pk = self.item.id, + item_class = self.item._meta.model_name, + action = self.model.Actions.ADD, + ) + + 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]) + + + 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.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, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) diff --git a/app/itim/tests/unit/service/test_service_permission.py b/app/itim/tests/unit/service/test_service_permission.py new file mode 100644 index 00000000..c3680b9d --- /dev/null +++ b/app/itim/tests/unit/service/test_service_permission.py @@ -0,0 +1,189 @@ +# 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 itim.models.services import Service + + +class ServicePermissions(TestCase, ModelPermissions): + + + model = Service + + app_namespace = 'ITIM' + + url_name_view = '_service_view' + + url_name_add = '_service_add' + + url_name_change = '_service_change' + + url_name_delete = '_service_delete' + + url_delete_response = reverse('ITIM:Services') + + + @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 device + 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 = 'deviceone' + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + # self.url_add_kwargs = {'pk': self.item.id} + + self.add_data = {'device': 'device', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'device': 'device', 'organization': self.organization.id} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'device': 'device', '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 + ) diff --git a/app/itim/tests/unit/service/test_service_views.py b/app/itim/tests/unit/service/test_service_views.py new file mode 100644 index 00000000..66b2c67d --- /dev/null +++ b/app/itim/tests/unit/service/test_service_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 ServiceViews( + TestCase, + PrimaryModel +): + + add_module = 'itim.views.services' + 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/itim/urls.py b/app/itim/urls.py index 97c19206..83e064d4 100644 --- a/app/itim/urls.py +++ b/app/itim/urls.py @@ -1,12 +1,15 @@ from django.urls import path -from itam import views -from itam.views import device, device_type, software, software_category, software_version, operating_system, operating_system_version + +from itim.views import services app_name = "ITIM" urlpatterns = [ - path("clusters", device.IndexView.as_view(), name="Clusters"), - path("services", device.IndexView.as_view(), name="Services"), + 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"), ] diff --git a/app/itim/views/ports.py b/app/itim/views/ports.py new file mode 100644 index 00000000..d3e3a84b --- /dev/null +++ b/app/itim/views/ports.py @@ -0,0 +1,81 @@ +from django.contrib.auth import decorators as auth_decorator +from django.urls import reverse +from django.utils.decorators import method_decorator + +from core.forms.comment import AddNoteForm +from core.models.notes import Notes +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from itim.forms.ports import PortForm +from itim.models.services import Port + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = PortForm + + model = Port + + permission_required = [ + 'itam.add_service', + ] + + + def get_initial(self): + + initial: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization + } + + 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:_ports') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'New Group' + + return context + + + +class Index(IndexView): + + context_object_name = "items" + + model = Port + + paginate_by = 10 + + permission_required = [ + 'assistance.view_service' + ] + + template_name = 'itim/port_index.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 + + context['content_title'] = 'Ports' + + return context diff --git a/app/itim/views/services.py b/app/itim/views/services.py new file mode 100644 index 00000000..42c97fb4 --- /dev/null +++ b/app/itim/views/services.py @@ -0,0 +1,186 @@ +from django.contrib.auth import decorators as auth_decorator +from django.urls import reverse +from django.utils.decorators import method_decorator + +from core.forms.comment import AddNoteForm +from core.models.notes import Notes +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from itim.forms.services import ServiceForm +from itim.models.services import Service + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = ServiceForm + + model = Service + + permission_required = [ + 'itim.add_service', + ] + + + def get_initial(self): + + initial: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization + } + + 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('ITIM:Services') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'New Service' + + return context + + + +class Change(ChangeView): + + form_class = ServiceForm + + model = Service + + permission_required = [ + 'itim.change_service', + ] + + + 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('ITIM:_service_view', args=(self.kwargs['pk'],)) + + + +class Delete(DeleteView): + + model = Service + + permission_required = [ + 'itim.delete_service', + ] + + + 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:Services') + + + +class Index(IndexView): + + context_object_name = "items" + + model = Service + + paginate_by = 10 + + permission_required = [ + 'itim.view_service' + ] + + template_name = 'itim/service_index.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 + + context['content_title'] = 'Services' + + return context + + + +class View(ChangeView): + + context_object_name = "item" + + form_class = ServiceForm + + model = Service + + permission_required = [ + 'itim.view_service', + ] + + template_name = 'itim/service.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('ITIM:_service_delete', args=(self.kwargs['pk'],)) + + + context['content_title'] = self.object.name + + return context + + + @method_decorator(auth_decorator.permission_required("itim.change_service", raise_exception=True)) + def post(self, request, *args, **kwargs): + + item = Service.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('ITIM:_service_view', args=(self.kwargs['pk'],)) diff --git a/app/settings/templates/settings/home.html.j2 b/app/settings/templates/settings/home.html.j2 index aa8e6d93..f7308962 100644 --- a/app/settings/templates/settings/home.html.j2 +++ b/app/settings/templates/settings/home.html.j2 @@ -59,6 +59,13 @@ div#content article h3 { + +
diff --git a/app/settings/urls.py b/app/settings/urls.py index 80504ce0..4e3c6db1 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -8,6 +8,8 @@ from settings.views import app_settings, home, device_models, device_types, exte from itam.views import device_type, device_model, software_category +from itim.views import ports + app_name = "Settings" urlpatterns = [ @@ -51,4 +53,6 @@ urlpatterns = [ path("manufacturer/add/", manufacturer.Add.as_view(), name="_manufacturer_add"), path("manufacturer//delete", manufacturer.Delete.as_view(), name="_manufacturer_delete"), + path("ports", ports.Index.as_view(), name="_ports"), + path("port/add", ports.Add.as_view(), name="_port_add"), ] diff --git a/docs/projects/centurion_erp/user/itim/index.md b/docs/projects/centurion_erp/user/itim/index.md new file mode 100644 index 00000000..d67a58c4 --- /dev/null +++ b/docs/projects/centurion_erp/user/itim/index.md @@ -0,0 +1,14 @@ +--- +title: IT Infrastructure Management +description: IT Infrastructure Management (ITIM) Module Documentation for Centurion ERP by No Fuss Computing +date: 2024-07-21 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +IT Infrastructure Management (ITIM) is a crucial area of IT Service Management (ITSM). ITIM covers all aspects of deployed services and it's supporting infrastructure. + + +## ITIM Components + +- [Services](./service.md) diff --git a/docs/projects/centurion_erp/user/itim/service.md b/docs/projects/centurion_erp/user/itim/service.md new file mode 100644 index 00000000..94b304fa --- /dev/null +++ b/docs/projects/centurion_erp/user/itim/service.md @@ -0,0 +1,12 @@ +--- +title: Service +description: Service Management Documentation for Centurion ERP by No Fuss Computing +date: 2024-07-21 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +This component within ITIM is intended to enable the management of services deployed throughout your IT infrastructure. A service is defined as anything that is deployed that end users would access via a client application. + + +## Services diff --git a/mkdocs.yml b/mkdocs.yml index b28e8c9d..d8799753 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -196,6 +196,12 @@ nav: - projects/centurion_erp/user/itam/software.md + - ITIM: + + - projects/centurion_erp/user/itim/index.md + + - projects/centurion_erp/user/itim/service.md + - Settings: - projects/centurion_erp/user/settings/index.md From 0b220424bbefb9c889a6751af92c253c266b7793 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 06:12:53 +0930 Subject: [PATCH 17/82] feat(itim): Ports for service management !43 #69 --- app/core/views/history.py | 6 + app/itim/templates/itim/port.html.j2 | 196 ++++++++++++++++++ app/itim/tests/unit/port/test_port.py | 42 ++++ .../tests/unit/port/test_port_core_history.py | 78 +++++++ .../unit/port/test_port_history_permission.py | 95 +++++++++ .../tests/unit/port/test_port_permission.py | 189 +++++++++++++++++ app/itim/tests/unit/port/test_port_views.py | 29 +++ app/itim/views/ports.py | 115 +++++++++- app/settings/urls.py | 4 + .../projects/centurion_erp/user/itim/index.md | 2 + docs/projects/centurion_erp/user/itim/port.md | 12 ++ mkdocs.yml | 2 + 12 files changed, 766 insertions(+), 4 deletions(-) create mode 100644 app/itim/templates/itim/port.html.j2 create mode 100644 app/itim/tests/unit/port/test_port.py create mode 100644 app/itim/tests/unit/port/test_port_core_history.py create mode 100644 app/itim/tests/unit/port/test_port_history_permission.py create mode 100644 app/itim/tests/unit/port/test_port_permission.py create mode 100644 app/itim/tests/unit/port/test_port_views.py create mode 100644 docs/projects/centurion_erp/user/itim/port.md diff --git a/app/core/views/history.py b/app/core/views/history.py index 6b3698f6..67a14dc4 100644 --- a/app/core/views/history.py +++ b/app/core/views/history.py @@ -99,6 +99,12 @@ class View(OrganizationPermission, generic.View): self.model = Organization + case 'port': + + from itim.models.services import Port + + self.model = Port + case 'team': self.model = Team diff --git a/app/itim/templates/itim/port.html.j2 b/app/itim/templates/itim/port.html.j2 new file mode 100644 index 00000000..02a0db7c --- /dev/null +++ b/app/itim/templates/itim/port.html.j2 @@ -0,0 +1,196 @@ +{% extends 'base.html.j2' %} + +{% load markdown %} + +{% block content %} + + + + +
+ + + + + {% if perms.assistance.change_service %} + + {% endif %} +
+ +
+
+

Details

+ + {% csrf_token %} + + +
+ +
+ +
+ + {{ form.number.value }} +
+ +
+ + + {% if form.description.value %} + {{ form.description.value }} + {% else %} +   + {% endif %} + +
+ +
+ + {{ form.protocol.value }} +
+ +
+ + {{ item.organization }} +
+ +
+ + {{ item.created }} +
+ +
+ + {{ item.modified }} +
+ + +
+ +
+
+ + +
+ {% if form.model_notes.value %} + {{ form.model_notes.value | markdown | safe }} + {% else %} +   + {% endif %} +
+
+
+ +
+ + + + +
+ + + +
+ +
+

+ Services +

+ + + + + + + {% for service in services %} + + + + + {% endfor%} +
NameOrganization
{{ service.name }}{{ service.organization }}
+
+ +
+ + {% if perms.assistance.change_knowledgebase %} +
+

+ Notes +

+ {{ notes_form }} + +
+ {% if notes %} + {% for note in notes %} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ {% endif %} + + + +{% endblock %} \ No newline at end of file diff --git a/app/itim/tests/unit/port/test_port.py b/app/itim/tests/unit/port/test_port.py new file mode 100644 index 00000000..b787dda0 --- /dev/null +++ b/app/itim/tests/unit/port/test_port.py @@ -0,0 +1,42 @@ +import pytest +import unittest + +from django.test import TestCase + +from access.models import Organization + +from app.tests.abstract.models import TenancyModel + +from itim.models.services import Port + + + +@pytest.mark.django_db +class PortModel( + TestCase, + TenancyModel +): + + model = Port + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. Create an item + + """ + + self.organization = Organization.objects.create(name='test_org') + + + self.item = self.model.objects.create( + organization = self.organization, + number = 1, + ) + + self.second_item = self.model.objects.create( + organization = self.organization, + number = 2, + ) diff --git a/app/itim/tests/unit/port/test_port_core_history.py b/app/itim/tests/unit/port/test_port_core_history.py new file mode 100644 index 00000000..29109f3c --- /dev/null +++ b/app/itim/tests/unit/port/test_port_core_history.py @@ -0,0 +1,78 @@ + +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 itim.models.services import Port + + + +class PortHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + + model = Port + + + @classmethod + def setUpTestData(self): + """ Setup Test """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + self.item_parent = self.model.objects.create( + number = 1, + organization = self.organization + ) + + self.item_create = self.model.objects.create( + number = 2, + 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.number = 3 + self.item_change.save() + + self.field_after_expected_value = '{"number": ' + str(self.item_change.number) + '}' + + 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( + number = 4, + 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.item_parent._meta.model_name, + ) diff --git a/app/itim/tests/unit/port/test_port_history_permission.py b/app/itim/tests/unit/port/test_port_history_permission.py new file mode 100644 index 00000000..4b786dc2 --- /dev/null +++ b/app/itim/tests/unit/port/test_port_history_permission.py @@ -0,0 +1,95 @@ +# 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 itim.models.services import Port + +from core.tests.abstract.history_permissions import HistoryPermissions + + + +class PortHistoryPermissions(TestCase, HistoryPermissions): + + + item_model = Port + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. create an organization that is different to item + 3. Create a device + 4. Add history device history entry as item + 5. create a user + 6. create user in different organization (with the required permission) + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + self.item = self.item_model.objects.create( + organization=organization, + number = 1 + ) + + self.history = self.model.objects.get( + item_pk = self.item.id, + item_class = self.item._meta.model_name, + action = self.model.Actions.ADD, + ) + + 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]) + + + 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.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, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) diff --git a/app/itim/tests/unit/port/test_port_permission.py b/app/itim/tests/unit/port/test_port_permission.py new file mode 100644 index 00000000..64b0bcd6 --- /dev/null +++ b/app/itim/tests/unit/port/test_port_permission.py @@ -0,0 +1,189 @@ +# 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 itim.models.services import Port + + +class PortPermissions(TestCase, ModelPermissions): + + + model = Port + + app_namespace = 'Settings' + + url_name_view = '_port_view' + + url_name_add = '_port_add' + + url_name_change = '_port_change' + + url_name_delete = '_port_delete' + + url_delete_response = reverse('Settings:_ports') + + + @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 device + 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, + number = 1 + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + # self.url_add_kwargs = {'pk': self.item.id} + + self.add_data = {'device': 'device', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'device': 'device', 'organization': self.organization.id} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'device': 'device', '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 + ) diff --git a/app/itim/tests/unit/port/test_port_views.py b/app/itim/tests/unit/port/test_port_views.py new file mode 100644 index 00000000..9776ecc0 --- /dev/null +++ b/app/itim/tests/unit/port/test_port_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 ServiceViews( + TestCase, + PrimaryModel +): + + add_module = 'itim.views.ports' + 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/itim/views/ports.py b/app/itim/views/ports.py index d3e3a84b..89a8cb86 100644 --- a/app/itim/views/ports.py +++ b/app/itim/views/ports.py @@ -7,7 +7,7 @@ from core.models.notes import Notes from core.views.common import AddView, ChangeView, DeleteView, IndexView from itim.forms.ports import PortForm -from itim.models.services import Port +from itim.models.services import Port, Service from settings.models.user_settings import UserSettings @@ -20,7 +20,7 @@ class Add(AddView): model = Port permission_required = [ - 'itam.add_service', + 'itim.add_port', ] @@ -49,12 +49,64 @@ class Add(AddView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['content_title'] = 'New Group' + context['content_title'] = 'New Port' return context +class Change(ChangeView): + + context_object_name = "item" + + form_class = PortForm + + model = Port + + permission_required = [ + 'itim.change_port', + ] + + + 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:_port_view', args=(self.kwargs['pk'],)) + + + +class Delete(DeleteView): + + model = Port + + permission_required = [ + 'itim.delete_port', + ] + + + 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:_ports') + + + class Index(IndexView): context_object_name = "items" @@ -64,7 +116,7 @@ class Index(IndexView): paginate_by = 10 permission_required = [ - 'assistance.view_service' + 'itim.view_port' ] template_name = 'itim/port_index.html.j2' @@ -79,3 +131,58 @@ class Index(IndexView): context['content_title'] = 'Ports' return context + + + +class View(ChangeView): + + context_object_name = "item" + + form_class = PortForm + + model = Port + + permission_required = [ + 'itim.view_port', + ] + + template_name = 'itim/port.html.j2' + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['services'] = Service.objects.filter(port=self.kwargs['pk']).order_by('name', 'organization') + + context['notes_form'] = AddNoteForm(prefix='note') + context['notes'] = Notes.objects.filter(config_group=self.kwargs['pk']) + + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.model_name + + context['model_delete_url'] = reverse('Settings:_port_delete', args=(self.kwargs['pk'],)) + + + context['content_title'] = self.object + + return context + + + @method_decorator(auth_decorator.permission_required("itim.change_service", raise_exception=True)) + def post(self, request, *args, **kwargs): + + item = Port.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.organization = item.organization + + notes.save() + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_port_view', args=(self.kwargs['pk'],)) diff --git a/app/settings/urls.py b/app/settings/urls.py index 4e3c6db1..6d6f0525 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -55,4 +55,8 @@ urlpatterns = [ path("ports", ports.Index.as_view(), name="_ports"), path("port/add", ports.Add.as_view(), name="_port_add"), + path("port//edit", ports.Change.as_view(), name="_port_change"), + path("port//delete", ports.Delete.as_view(), name="_port_delete"), + path("port/", ports.View.as_view(), name="_port_view"), + ] diff --git a/docs/projects/centurion_erp/user/itim/index.md b/docs/projects/centurion_erp/user/itim/index.md index d67a58c4..387bce3b 100644 --- a/docs/projects/centurion_erp/user/itim/index.md +++ b/docs/projects/centurion_erp/user/itim/index.md @@ -12,3 +12,5 @@ IT Infrastructure Management (ITIM) is a crucial area of IT Service Management ( ## ITIM Components - [Services](./service.md) + +- [Ports](./port.md) diff --git a/docs/projects/centurion_erp/user/itim/port.md b/docs/projects/centurion_erp/user/itim/port.md new file mode 100644 index 00000000..fcf3ed93 --- /dev/null +++ b/docs/projects/centurion_erp/user/itim/port.md @@ -0,0 +1,12 @@ +--- +title: Ports +description: Ports as part of Service Management Documentation for Centurion ERP by No Fuss Computing +date: 2024-07-21 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +This component within ITIM is an extension of Service Management, in particular the what in relation to the Layer 4 for the OSI layer for a [service](./service.md). + + +## Ports diff --git a/mkdocs.yml b/mkdocs.yml index d8799753..d8c44f1c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -200,6 +200,8 @@ nav: - projects/centurion_erp/user/itim/index.md + - projects/centurion_erp/user/itim/port.md + - projects/centurion_erp/user/itim/service.md - Settings: From eb320c4e957daafa63d06dada130638c0e1857f9 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 06:27:06 +0930 Subject: [PATCH 18/82] fix(itim): Dont show self within service dependencies !43 #69 --- app/itim/forms/services.py | 8 ++++++++ app/itim/templates/itim/service.html.j2 | 27 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/app/itim/forms/services.py b/app/itim/forms/services.py index 2b948875..64c1cbcd 100644 --- a/app/itim/forms/services.py +++ b/app/itim/forms/services.py @@ -18,6 +18,14 @@ class ServiceForm(CommonModelForm): prefix = 'service' + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.fields['dependent_service'].queryset = self.fields['dependent_service'].queryset.exclude( + id=self.instance.pk + ) + def clean(self): diff --git a/app/itim/templates/itim/service.html.j2 b/app/itim/templates/itim/service.html.j2 index ead3ba83..6b509f8d 100644 --- a/app/itim/templates/itim/service.html.j2 +++ b/app/itim/templates/itim/service.html.j2 @@ -157,15 +157,42 @@ Name Description + {% if item.port.all %} {% for port in item.port.all %} {{ port }} {{ port.description }} {% endfor%} + {% else %} + + Nothing Found + + {% endif %} + +
+

Dependent Services

+ + + + + + {% if item.dependent_service.all %} + {% for service in item.dependent_service.all %} + + + + + {% endfor%} + {% else %} + + + + {% endif %} +
NameOrganization
{{ service }}{{ service.organization }}
Nothing Found
From 2a3373a19b8d96ee24c9f7877aab2b5583cf4ec1 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 09:17:46 +0930 Subject: [PATCH 19/82] feat(itim): Add service template support !43 #69 --- app/core/migrations/0003_notes_service.py | 2 +- app/core/templatetags/json.py | 2 +- app/itim/forms/services.py | 24 +++++++++++--- app/itim/migrations/0001_initial.py | 6 ++-- app/itim/models/services.py | 40 ++++++++++++++++++++++- app/itim/templates/itim/service.html.j2 | 37 +++++++++++++++++++-- 6 files changed, 100 insertions(+), 11 deletions(-) diff --git a/app/core/migrations/0003_notes_service.py b/app/core/migrations/0003_notes_service.py index c3c88b5b..101998c9 100644 --- a/app/core/migrations/0003_notes_service.py +++ b/app/core/migrations/0003_notes_service.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-20 20:40 +# Generated by Django 5.0.7 on 2024-07-20 23:46 import django.db.models.deletion from django.db import migrations, models diff --git a/app/core/templatetags/json.py b/app/core/templatetags/json.py index 3b7b2a57..1bb62bd6 100644 --- a/app/core/templatetags/json.py +++ b/app/core/templatetags/json.py @@ -14,4 +14,4 @@ def json_pretty(value): return str('{}') - return json.dumps(json.loads(value), indent=4, sort_keys=True) + return json.dumps(json.loads(value.replace("'", '"')), indent=4, sort_keys=True) diff --git a/app/itim/forms/services.py b/app/itim/forms/services.py index 64c1cbcd..d15a610e 100644 --- a/app/itim/forms/services.py +++ b/app/itim/forms/services.py @@ -26,6 +26,10 @@ class ServiceForm(CommonModelForm): id=self.instance.pk ) + self.fields['template'].queryset = self.fields['template'].queryset.exclude( + id=self.instance.pk + ) + def clean(self): @@ -33,16 +37,28 @@ class ServiceForm(CommonModelForm): device = cleaned_data.get("device") cluster = cleaned_data.get("cluster") + is_template = cleaned_data.get("is_template") + template = cleaned_data.get("template") + port = cleaned_data.get("port") - if not device and not cluster: + if not is_template and not template: - raise ValidationError('A Service must be assigned to either a "Cluster" or a "Device".') + if not device and not cluster: + + raise ValidationError('A Service must be assigned to either a "Cluster" or a "Device".') - if device and cluster: + if device and cluster: - raise ValidationError('A Service must only be assigned to either a "Cluster" or a "Device". Not both.') + raise ValidationError('A Service must only be assigned to either a "Cluster" or a "Device". Not both.') + + + if not port: + + raise ValidationError('Port(s) must be assigned to a service.') + + return cleaned_data diff --git a/app/itim/migrations/0001_initial.py b/app/itim/migrations/0001_initial.py index aa8a6eea..e38ef233 100644 --- a/app/itim/migrations/0001_initial.py +++ b/app/itim/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-20 20:40 +# Generated by Django 5.0.7 on 2024-07-20 23:46 import access.fields import access.models @@ -79,6 +79,7 @@ class Migration(migrations.Migration): ('is_global', models.BooleanField(default=False)), ('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')), ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('is_template', models.BooleanField(default=False, help_text='Is this service to be used as a template', verbose_name='Template')), ('name', models.CharField(help_text='Name of the Service', max_length=50, verbose_name='Name')), ('config', models.JSONField(blank=True, default=None, help_text='Cluster Configuration', null=True, verbose_name='Configuration')), ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), @@ -87,7 +88,8 @@ class Migration(migrations.Migration): ('dependent_service', models.ManyToManyField(blank=True, default=None, help_text='Services that this service depends upon', to='itim.service', verbose_name='Dependent Services')), ('device', models.ForeignKey(blank=True, default=None, help_text='Device the service is assigned to', null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.device', verbose_name='Device')), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), - ('port', models.ManyToManyField(help_text='Port the service is available on', to='itim.port', verbose_name='Port')), + ('port', models.ManyToManyField(blank=True, help_text='Port the service is available on', to='itim.port', verbose_name='Port')), + ('template', models.ForeignKey(blank=True, default=None, help_text='Template this service uses', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.service', verbose_name='Template')), ], options={ 'verbose_name': 'Service', diff --git a/app/itim/models/services.py b/app/itim/models/services.py index c17b0705..a974c787 100644 --- a/app/itim/models/services.py +++ b/app/itim/models/services.py @@ -93,6 +93,23 @@ class Service(TenancyObject): blank=False ) + is_template = models.BooleanField( + blank = False, + default = False, + help_text = 'Is this service to be used as a template', + verbose_name = 'Template', + ) + + template = models.ForeignKey( + 'self', + blank = True, + default = None, + help_text = 'Template this service uses', + null = True, + on_delete = models.CASCADE, + verbose_name = 'Template', + ) + name = models.CharField( blank = False, help_text = 'Name of the Service', @@ -132,7 +149,7 @@ class Service(TenancyObject): port = models.ManyToManyField( Port, - blank = False, + blank = True, help_text = 'Port the service is available on', verbose_name = 'Port', ) @@ -149,6 +166,27 @@ class Service(TenancyObject): modified = AutoLastModifiedField() + @property + def config_variables(self): + + if self.is_template: + + return self.config + + if self.template: + + template_config: dict = Service.objects.get(id=self.template.id).config + + template_config.update(self.config) + + return template_config + + else: + + return self.config + + return None + def __str__(self): diff --git a/app/itim/templates/itim/service.html.j2 b/app/itim/templates/itim/service.html.j2 index 6b509f8d..ffe32dc8 100644 --- a/app/itim/templates/itim/service.html.j2 +++ b/app/itim/templates/itim/service.html.j2 @@ -1,5 +1,6 @@ {% extends 'base.html.j2' %} +{% load json %} {% load markdown %} {% block content %} @@ -68,6 +69,7 @@ Back to Services + {% if perms.assistance.change_service %} {% endif %} @@ -88,6 +90,19 @@ {{ form.name.value }} + {% if form.template.value or form.is_template.value %} +
+ + + {% if form.is_template.value %} + {{ form.is_template.value }} + {% else %} + {{ item.template }} + {% endif %} + +
+ {% endif %} +
@@ -157,12 +172,19 @@ Name Description - {% if item.port.all %} + {% if item.port.all and not item.template %} {% for port in item.port.all %} {{ port }} {{ port.description }} + {% endfor %} + {% elif not item.port.all and item.template %} + {% for port in item.template.port.all %} + + {{ port }} + {{ port.description }} + {% endfor%} {% else %} @@ -199,7 +221,7 @@

Service Config


- +

@@ -210,6 +232,17 @@
+
+

+ Rendered Config +

+ +
+ +
+ +
+ {% if perms.assistance.change_knowledgebase %}

From 7b8b8a6394a786f1790f78c231bb6252c320e905 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 09:21:29 +0930 Subject: [PATCH 20/82] feat(itim): Prevent Service template from being assigned as dependent service !43 #69 --- app/core/migrations/0003_notes_service.py | 2 +- app/itim/forms/services.py | 2 ++ app/itim/migrations/0001_initial.py | 4 ++-- app/itim/models/services.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/core/migrations/0003_notes_service.py b/app/core/migrations/0003_notes_service.py index 101998c9..140f8640 100644 --- a/app/core/migrations/0003_notes_service.py +++ b/app/core/migrations/0003_notes_service.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-20 23:46 +# Generated by Django 5.0.7 on 2024-07-20 23:50 import django.db.models.deletion from django.db import migrations, models diff --git a/app/itim/forms/services.py b/app/itim/forms/services.py index d15a610e..4d792224 100644 --- a/app/itim/forms/services.py +++ b/app/itim/forms/services.py @@ -24,6 +24,8 @@ class ServiceForm(CommonModelForm): self.fields['dependent_service'].queryset = self.fields['dependent_service'].queryset.exclude( id=self.instance.pk + ).exclude( + is_template=True ) self.fields['template'].queryset = self.fields['template'].queryset.exclude( diff --git a/app/itim/migrations/0001_initial.py b/app/itim/migrations/0001_initial.py index e38ef233..2a6b18d2 100644 --- a/app/itim/migrations/0001_initial.py +++ b/app/itim/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-20 23:46 +# Generated by Django 5.0.7 on 2024-07-20 23:50 import access.fields import access.models @@ -89,7 +89,7 @@ class Migration(migrations.Migration): ('device', models.ForeignKey(blank=True, default=None, help_text='Device the service is assigned to', null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.device', verbose_name='Device')), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), ('port', models.ManyToManyField(blank=True, help_text='Port the service is available on', to='itim.port', verbose_name='Port')), - ('template', models.ForeignKey(blank=True, default=None, help_text='Template this service uses', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.service', verbose_name='Template')), + ('template', models.ForeignKey(blank=True, default=None, help_text='Template this service uses', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.service', verbose_name='Template Name')), ], options={ 'verbose_name': 'Service', diff --git a/app/itim/models/services.py b/app/itim/models/services.py index a974c787..ec4044ee 100644 --- a/app/itim/models/services.py +++ b/app/itim/models/services.py @@ -107,7 +107,7 @@ class Service(TenancyObject): help_text = 'Template this service uses', null = True, on_delete = models.CASCADE, - verbose_name = 'Template', + verbose_name = 'Template Name', ) name = models.CharField( From 6d6f1c54013ffe3eb58a200c876ba8bab17cc177 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 09:35:57 +0930 Subject: [PATCH 21/82] feat(itim): Port number validation to check for valid port numbers !43 #69 --- app/core/migrations/0003_notes_service.py | 2 +- app/itim/migrations/0001_initial.py | 5 +++-- app/itim/models/services.py | 7 +++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/core/migrations/0003_notes_service.py b/app/core/migrations/0003_notes_service.py index 140f8640..77c0c4ef 100644 --- a/app/core/migrations/0003_notes_service.py +++ b/app/core/migrations/0003_notes_service.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-20 23:50 +# Generated by Django 5.0.7 on 2024-07-21 00:05 import django.db.models.deletion from django.db import migrations, models diff --git a/app/itim/migrations/0001_initial.py b/app/itim/migrations/0001_initial.py index 2a6b18d2..a734bc17 100644 --- a/app/itim/migrations/0001_initial.py +++ b/app/itim/migrations/0001_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 5.0.7 on 2024-07-20 23:50 +# Generated by Django 5.0.7 on 2024-07-21 00:05 import access.fields import access.models import django.db.models.deletion import django.utils.timezone +import itim.models.services from django.db import migrations, models @@ -60,7 +61,7 @@ class Migration(migrations.Migration): ('is_global', models.BooleanField(default=False)), ('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')), ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), - ('number', models.IntegerField(help_text='The port number', verbose_name='Port Number')), + ('number', models.IntegerField(help_text='The port number', validators=[itim.models.services.Port.validation_port_number], verbose_name='Port Number')), ('description', models.CharField(blank=True, default=None, help_text='Short description of port', max_length=80, null=True, verbose_name='Description')), ('protocol', models.CharField(choices=[('TCP', 'TCP'), ('UDP', 'UDP')], default='TCP', help_text='Layer 4 Network Protocol', max_length=3, verbose_name='Protocol')), ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), diff --git a/app/itim/models/services.py b/app/itim/models/services.py index ec4044ee..7f4b03db 100644 --- a/app/itim/models/services.py +++ b/app/itim/models/services.py @@ -30,6 +30,12 @@ class Port(TenancyObject): TCP = 'TCP', 'TCP' UDP = 'UDP', 'UDP' + def validation_port_number(number: int): + + if number < 1 or number > 65535: + + raise ValidationError('A Valid port number is between 1-65535') + id = models.AutoField( primary_key=True, @@ -41,6 +47,7 @@ class Port(TenancyObject): blank = False, help_text = 'The port number', unique = False, + validators = [ validation_port_number ], verbose_name = 'Port Number', ) From b5d2fe70ff732929d7c0a33bdbe57759952810a5 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 11:16:17 +0930 Subject: [PATCH 22/82] feat(itim): Prevent circular service dependencies !43 #69 --- app/core/migrations/0003_notes_service.py | 2 +- app/itim/forms/services.py | 15 +++++++++++++++ app/itim/migrations/0001_initial.py | 4 ++-- app/itim/models/services.py | 2 ++ app/itim/templates/itim/service.html.j2 | 2 +- 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/core/migrations/0003_notes_service.py b/app/core/migrations/0003_notes_service.py index 77c0c4ef..6735c474 100644 --- a/app/core/migrations/0003_notes_service.py +++ b/app/core/migrations/0003_notes_service.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-21 00:05 +# Generated by Django 5.0.7 on 2024-07-21 01:45 import django.db.models.deletion from django.db import migrations, models diff --git a/app/itim/forms/services.py b/app/itim/forms/services.py index 4d792224..a88dc277 100644 --- a/app/itim/forms/services.py +++ b/app/itim/forms/services.py @@ -37,6 +37,8 @@ class ServiceForm(CommonModelForm): cleaned_data = super().clean() + pk = self.instance.id + dependent_service = cleaned_data.get("dependent_service") device = cleaned_data.get("device") cluster = cleaned_data.get("cluster") is_template = cleaned_data.get("is_template") @@ -60,6 +62,19 @@ class ServiceForm(CommonModelForm): raise ValidationError('Port(s) must be assigned to a service.') + if dependent_service: + + for dependency in dependent_service: + + query = Service.objects.filter( + dependent_service = pk, + id = dependency.id, + ) + + if query.exists(): + + raise ValidationError('A dependent service already depends upon this service. Circular dependencies are not allowed.') + diff --git a/app/itim/migrations/0001_initial.py b/app/itim/migrations/0001_initial.py index a734bc17..f354822a 100644 --- a/app/itim/migrations/0001_initial.py +++ b/app/itim/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-21 00:05 +# Generated by Django 5.0.7 on 2024-07-21 01:45 import access.fields import access.models @@ -86,7 +86,7 @@ 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)), ('cluster', models.ForeignKey(blank=True, default=None, help_text='Cluster the service is assigned to', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.cluster', verbose_name='Cluster')), - ('dependent_service', models.ManyToManyField(blank=True, default=None, help_text='Services that this service depends upon', to='itim.service', verbose_name='Dependent Services')), + ('dependent_service', models.ManyToManyField(blank=True, default=None, help_text='Services that this service depends upon', related_name='dependentservice', to='itim.service', verbose_name='Dependent Services')), ('device', models.ForeignKey(blank=True, default=None, help_text='Device the service is assigned to', null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.device', verbose_name='Device')), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), ('port', models.ManyToManyField(blank=True, help_text='Port the service is available on', to='itim.port', verbose_name='Port')), diff --git a/app/itim/models/services.py b/app/itim/models/services.py index 7f4b03db..45b28caa 100644 --- a/app/itim/models/services.py +++ b/app/itim/models/services.py @@ -166,6 +166,8 @@ class Service(TenancyObject): blank = True, default = None, help_text = 'Services that this service depends upon', + related_name = 'dependentservice', + symmetrical = False, verbose_name = 'Dependent Services', ) diff --git a/app/itim/templates/itim/service.html.j2 b/app/itim/templates/itim/service.html.j2 index ffe32dc8..f6960d4b 100644 --- a/app/itim/templates/itim/service.html.j2 +++ b/app/itim/templates/itim/service.html.j2 @@ -205,7 +205,7 @@ {% if item.dependent_service.all %} {% for service in item.dependent_service.all %} - {{ service }} + {{ service }} {{ service.organization }} {% endfor%} From 0b04cdcfbf8619e7fd7d6d8f4a76043fb49e8c2a Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 12:07:04 +0930 Subject: [PATCH 23/82] feat(itam): Display deployed services for devices !43 #69 --- app/core/migrations/0003_notes_service.py | 2 +- app/itam/templates/itam/device.html.j2 | 22 +++++++++++++++++ app/itam/views/device.py | 5 +++- app/itim/migrations/0001_initial.py | 3 ++- app/itim/models/services.py | 30 +++++++++++++++++++++++ app/itim/templates/itim/service.html.j2 | 7 +++++- 6 files changed, 65 insertions(+), 4 deletions(-) diff --git a/app/core/migrations/0003_notes_service.py b/app/core/migrations/0003_notes_service.py index 6735c474..83abbb73 100644 --- a/app/core/migrations/0003_notes_service.py +++ b/app/core/migrations/0003_notes_service.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-21 01:45 +# Generated by Django 5.0.7 on 2024-07-21 02:35 import django.db.models.deletion from django.db import migrations, models diff --git a/app/itam/templates/itam/device.html.j2 b/app/itam/templates/itam/device.html.j2 index 67ecd1e6..d79890d3 100644 --- a/app/itam/templates/itam/device.html.j2 +++ b/app/itam/templates/itam/device.html.j2 @@ -184,6 +184,28 @@

+
+

Dependent Services

+ + + + + + {% if services %} + {% for service in services %} + + + + + {% endfor%} + {% else %} + + + + {% endif %} +
NamePorts
{{ service }}{% for port in service.port.all %}{{ port }} ({{ port.description}}), {% endfor %}
Nothing Found
+
+

Device Config


diff --git a/app/itam/views/device.py b/app/itam/views/device.py index 3b54a5a1..88d25b79 100644 --- a/app/itam/views/device.py +++ b/app/itam/views/device.py @@ -21,10 +21,11 @@ from core.views.common import AddView, ChangeView, DeleteView, IndexView from itam.forms.device_softwareadd import SoftwareAdd from itam.forms.device_softwareupdate import SoftwareUpdate - from itam.forms.device.device import DeviceForm from itam.forms.device.operating_system import Update as OperatingSystemForm +from itim.models.services import Service + from settings.models.user_settings import UserSettings @@ -104,6 +105,8 @@ class View(ChangeView): context['operating_system'] = OperatingSystemForm(prefix='operating_system') + context['services'] = Service.objects.filter(device=self.kwargs['pk']) + softwares = DeviceSoftware.objects.filter(device=self.kwargs['pk']) softwares = Paginator(softwares, 10) diff --git a/app/itim/migrations/0001_initial.py b/app/itim/migrations/0001_initial.py index f354822a..5ce0aac2 100644 --- a/app/itim/migrations/0001_initial.py +++ b/app/itim/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-21 01:45 +# Generated by Django 5.0.7 on 2024-07-21 02:35 import access.fields import access.models @@ -83,6 +83,7 @@ class Migration(migrations.Migration): ('is_template', models.BooleanField(default=False, help_text='Is this service to be used as a template', verbose_name='Template')), ('name', models.CharField(help_text='Name of the Service', max_length=50, verbose_name='Name')), ('config', models.JSONField(blank=True, default=None, help_text='Cluster Configuration', null=True, verbose_name='Configuration')), + ('config_key_variable', models.CharField(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')), ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), ('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)), ('cluster', models.ForeignKey(blank=True, default=None, help_text='Cluster the service is assigned to', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.cluster', verbose_name='Cluster')), diff --git a/app/itim/models/services.py b/app/itim/models/services.py index 45b28caa..10b9ad44 100644 --- a/app/itim/models/services.py +++ b/app/itim/models/services.py @@ -1,3 +1,5 @@ +import re + from django.contrib.auth.models import User from django.db import models from django.forms import ValidationError @@ -93,6 +95,18 @@ class Service(TenancyObject): verbose_name_plural = "Services" + def validate_config_key_variable(value): + + if not value: + + raise ValidationError('You must enter a config key.') + + valid_chars = search=re.compile(r'[^a-z_]').search + + if bool(valid_chars(value)): + + raise ValidationError('config key must only contain [a-z_].') + id = models.AutoField( primary_key=True, @@ -154,6 +168,16 @@ class Service(TenancyObject): verbose_name = 'Configuration', ) + config_key_variable = models.CharField( + blank = False, + help_text = 'Key name to use when merging with cluster/device config.', + max_length = 50, + null = True, + unique = False, + validators = [ validate_config_key_variable ], + verbose_name = 'Configuration Key', + ) + port = models.ManyToManyField( Port, blank = True, @@ -197,6 +221,12 @@ class Service(TenancyObject): return None + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): + + self.config_key_variable = self.config_key_variable.lower() + + super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) + def __str__(self): return self.name diff --git a/app/itim/templates/itim/service.html.j2 b/app/itim/templates/itim/service.html.j2 index f6960d4b..dfa8f9c7 100644 --- a/app/itim/templates/itim/service.html.j2 +++ b/app/itim/templates/itim/service.html.j2 @@ -90,6 +90,11 @@ {{ form.name.value }}
+
+ + {{ form.config_key_variable.value }} +
+ {% if form.template.value or form.is_template.value %}
@@ -131,7 +136,7 @@ {% if item.cluster %} {{ item.cluster }} {% else %} - {{ item.device }} + {{ item.device }} {% endif %}
From 53a720a80208f35c4c4c446cbe6ab56a83e5ccc9 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 12:08:53 +0930 Subject: [PATCH 24/82] feat(itam): Render Service Config with device config !43 #69 --- app/itam/models/device.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/itam/models/device.py b/app/itam/models/device.py index bb660623..30023d4d 100644 --- a/app/itam/models/device.py +++ b/app/itam/models/device.py @@ -281,6 +281,21 @@ class Device(DeviceCommonFieldsName, SaveHistory): config.update(self.config) + from itim.models.services import Service + services = Service.objects.filter( + device = self.pk + ) + + for service in services: + + if service.config: + + service_config:dict = { + service.config_key_variable: service.config + } + + config.update(service_config) + return config From d339fdb645ab5d14e7f173bca4156c14223acc2b Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 12:17:22 +0930 Subject: [PATCH 25/82] fix(itim): dont render link if no device !43 #69 --- app/itim/models/services.py | 5 ++++- app/itim/templates/itim/service.html.j2 | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/itim/models/services.py b/app/itim/models/services.py index 10b9ad44..f2c5afa9 100644 --- a/app/itim/models/services.py +++ b/app/itim/models/services.py @@ -223,10 +223,13 @@ class Service(TenancyObject): def save(self, force_insert=False, force_update=False, using=None, update_fields=None): - self.config_key_variable = self.config_key_variable.lower() + if self.config_key_variable: + + self.config_key_variable = self.config_key_variable.lower() super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) + def __str__(self): return self.name diff --git a/app/itim/templates/itim/service.html.j2 b/app/itim/templates/itim/service.html.j2 index dfa8f9c7..14bbe245 100644 --- a/app/itim/templates/itim/service.html.j2 +++ b/app/itim/templates/itim/service.html.j2 @@ -136,8 +136,10 @@ {% if item.cluster %} {{ item.cluster }} {% else %} + {% if item.device.id %} {{ item.device }} {% endif %} + {% endif %} From acc6879fb1cc0ece430d1102a16c597173fc83c7 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 14:01:05 +0930 Subject: [PATCH 26/82] docs: fluff the port and services !43 closes #69 --- docs/projects/centurion_erp/user/itim/port.md | 6 ++++ .../centurion_erp/user/itim/service.md | 31 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/projects/centurion_erp/user/itim/port.md b/docs/projects/centurion_erp/user/itim/port.md index fcf3ed93..47b7a6e3 100644 --- a/docs/projects/centurion_erp/user/itim/port.md +++ b/docs/projects/centurion_erp/user/itim/port.md @@ -10,3 +10,9 @@ This component within ITIM is an extension of Service Management, in particular ## Ports + +- number _A Valid port number [1-65535]_ + +- description _Short description of the port_ + +- protocol _OSI Layer 4 protocol [TCP or UDP]_ diff --git a/docs/projects/centurion_erp/user/itim/service.md b/docs/projects/centurion_erp/user/itim/service.md index 94b304fa..4f2cd817 100644 --- a/docs/projects/centurion_erp/user/itim/service.md +++ b/docs/projects/centurion_erp/user/itim/service.md @@ -6,7 +6,36 @@ template: project.html about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp --- -This component within ITIM is intended to enable the management of services deployed throughout your IT infrastructure. A service is defined as anything that is deployed that end users would access via a client application. +This component within ITIM is intended to enable the management of services deployed throughout your IT infrastructure. A service is defined as anything that is deployed that end users would access via a client application. The design of our services is intended to work with either ansible collections or roles. Either way as long as the resulting ansible tasks look for the variables under a single key for the service; any method you choose to deploy a service is up to you. ## Services + +Within the services the following fields are available: + +- is_template _Defines the service as a template_ + +- template _name if the template that this service inherits from_ + +- name _name of the service_ + +- device _Device service deployed to_ + +- cluster _Cluster the service is deployed to_ + +- config _Ansible configuration variables_ + +- config_key_variable _Ansible dictionary key name for config_ + +- [port](./port.md) _Ports assosiated with the service_ + +- dependent_service _A List of services this service depends upon_ + + +## Service Template + +A service can be setup as a template `is_template=True` for which then can be used as the base template for further service creations. Both config and Ports are inherited from the template with any conflict taking the current services values. + +## Deployed to + +A service can be deployed to a Cluster or a Device. When assosiated with an item, within it's details page the services deployed to it are available. From fd4da657fb031a48485432ce9cef1bb440dcc72a Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 14:10:19 +0930 Subject: [PATCH 27/82] fix(itim): ensure that the service template config is also rendered as part of device config !43 #69 --- app/itam/models/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/itam/models/device.py b/app/itam/models/device.py index 30023d4d..9fc4f885 100644 --- a/app/itam/models/device.py +++ b/app/itam/models/device.py @@ -288,10 +288,10 @@ class Device(DeviceCommonFieldsName, SaveHistory): for service in services: - if service.config: + if service.config_variables: service_config:dict = { - service.config_key_variable: service.config + service.config_key_variable: service.config_variables } config.update(service_config) From 485dd43b58a0d025b73df1187ccdc9d226c6f58c Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 21 Jul 2024 14:17:50 +0930 Subject: [PATCH 28/82] chore: add services navigation icon !43 #69 --- app/templates/icons/service.svg | 1 + app/templates/navigation.html.j2 | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 app/templates/icons/service.svg diff --git a/app/templates/icons/service.svg b/app/templates/icons/service.svg new file mode 100644 index 00000000..53fe48ca --- /dev/null +++ b/app/templates/icons/service.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 39ed82b6..a3c4188b 100644 --- a/app/templates/navigation.html.j2 +++ b/app/templates/navigation.html.j2 @@ -49,6 +49,8 @@ span.navigation_icon { {% include 'icons/devices.svg' %} {% elif group_urls.name == 'Knowledge Base' %} {% include 'icons/information.svg' %} + {% elif group_urls.name == 'Services' %} + {% include 'icons/service.svg' %} {% elif group_urls.name == 'Software' %} {% include 'icons/software.svg' %} {% elif group_urls.name == 'Groups' %} From eb919f2d5e90ecb538f51713f1d4b6a508961f79 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 13 Aug 2024 15:03:10 +0930 Subject: [PATCH 29/82] docs: initial adding of template page #22 --- docs/projects/centurion_erp/development/templates.md | 9 +++++++++ mkdocs.yml | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 docs/projects/centurion_erp/development/templates.md diff --git a/docs/projects/centurion_erp/development/templates.md b/docs/projects/centurion_erp/development/templates.md new file mode 100644 index 00000000..5758fc9b --- /dev/null +++ b/docs/projects/centurion_erp/development/templates.md @@ -0,0 +1,9 @@ +--- +title: Templates +description: Development documentation for template usage and layout for Centurion ERP by No Fuss Computing +date: 2024-08-13 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +This section of the documentation contains the details related to the templates used within Centurion ERP for rendering data for the end user to view. diff --git a/mkdocs.yml b/mkdocs.yml index d8c44f1c..bce51a41 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -155,7 +155,9 @@ nav: - projects/centurion_erp/development/testing.md - projects/centurion_erp/development/views.md - + + - projects/centurion_erp/development/templates.md + - User: - projects/centurion_erp/user/index.md From 4ecf5236c13fb23d6f66b2415463c37039389851 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 14 Aug 2024 00:02:25 +0930 Subject: [PATCH 30/82] feat(base): create detail view templates purpose is to aid in the development of a detail form #22 #24 #226 --- app/project-static/content.css | 168 ++++++++++++------ app/project-static/functions.js | 16 ++ app/templates/base.html.j2 | 1 + app/templates/content/field.html.j2 | 72 ++++++++ app/templates/content/section.html.j2 | 93 ++++++++++ app/templates/detail.html.j2 | 33 ++++ .../media/layout-template-view-base.png | Bin 0 -> 23492 bytes .../media/layout-template-view-detail.drawio | 46 +++++ .../media/layout-template-view-detail.png | Bin 0 -> 33554 bytes .../centurion_erp/development/templates.md | 48 +++++ 10 files changed, 422 insertions(+), 55 deletions(-) create mode 100644 app/project-static/functions.js create mode 100644 app/templates/content/field.html.j2 create mode 100644 app/templates/content/section.html.j2 create mode 100644 app/templates/detail.html.j2 create mode 100644 docs/projects/centurion_erp/development/media/layout-template-view-base.png create mode 100644 docs/projects/centurion_erp/development/media/layout-template-view-detail.drawio create mode 100644 docs/projects/centurion_erp/development/media/layout-template-view-detail.png diff --git a/app/project-static/content.css b/app/project-static/content.css index 80f5de1e..e1827120 100644 --- a/app/project-static/content.css +++ b/app/project-static/content.css @@ -65,6 +65,119 @@ input[type=submit] { height: 30px; margin: 10px; } + + + + +/* Style the navigation tabs at the top of a content page */ +.content-navigation-tabs { + display: block; + overflow: hidden; + border-bottom: 1px solid #ccc; + /* background-color: #f1f1f1; */ + width: 100%; + text-align: left; + padding: 0px; + margin: 0px +} + + +.content-navigation-tabs-link { + border: 0px; + margin: none; + padding: none; +} + +/* Style the buttons that are used to open the tab content */ +.content-navigation-tabs button { + display: inline; + background-color: inherit; + float: left; + border: none; + outline: none; + cursor: pointer; + margin: 0px; + padding: 0px; + padding: 14px 16px; + transition: 0.3s; + font-size: inherit; + color: #6a6e73; +} + + +/* Change background color of buttons on hover */ +.content-navigation-tabs button:hover { + /* background-color: #ddd; */ + border-bottom: 3px solid #ccc; +} + + +/* Create an active/current tablink class */ +.content-navigation-tabs button.active { + /* background-color: #ccc; */ + border-bottom: 3px solid #177ee6; +} + + + +/* Style content for each tab */ +.content-tab { + width: 100%; + display: none; + padding-bottom: 0px; + border: none; + border-top: none; +} + +.content-tab hr { + border: none; + border-top: 1px solid #ccc; +} + +.content-tab pre { + word-wrap: break-word; + white-space: pre-wrap; +} + +/* Style for section fields on details page */ +.detail-view-field { + display: unset; + height: 30px; + line-height: 30px; + padding: 0px 20px 40px 20px; + +} + +.detail-view-field label { + display: inline-block; + font-weight: bold; + width: 200px; + margin: 10px; + height: 30px; + line-height: 30px; + +} + +.detail-view-field span { + display: inline-block; + width: 340px; + margin: 10px; + border-bottom: 1px solid #ccc; + height: 30px; + line-height: 30px; + +} + + + + + + + + + + + /******************************************************************************* EoF refactored @@ -124,61 +237,6 @@ input[type=checkbox]:checked::after { -/* Style the tab */ -.tab { - display: block; - overflow: hidden; - border-bottom: 1px solid #ccc; - /* background-color: #f1f1f1; */ - width: 100%; - text-align: left; - padding: 0px; - margin: 0px -} - -.tablinks { - border: 0px; - margin: none; - padding: none; -} - -/* Style the buttons that are used to open the tab content */ -.tab button { - display: inline; - background-color: inherit; - float: left; - border: none; - outline: none; - cursor: pointer; - margin: 0px; - padding: 0px; - padding: 14px 16px; - transition: 0.3s; - font-size: inherit; - color: #6a6e73; -} - -/* Change background color of buttons on hover */ -.tab button:hover { - /* background-color: #ddd; */ - border-bottom: 3px solid #ccc; -} - -/* Create an active/current tablink class */ -.tab button.active { - /* background-color: #ccc; */ - border-bottom: 3px solid #177ee6; -} - -/* Style the tab content */ -.tabcontent { - width: 100%; - display: none; - /* padding: 6px 12px; */ - padding-bottom: 0px; - border: none; - border-top: none; -} table { width: 100%; diff --git a/app/project-static/functions.js b/app/project-static/functions.js new file mode 100644 index 00000000..cf2c74f1 --- /dev/null +++ b/app/project-static/functions.js @@ -0,0 +1,16 @@ +function openContentNavigationTab(evt, TabName) { + var i, tabcontent, tablinks; + + tabcontent = document.getElementsByClassName("content-tab"); + for (i = 0; i < tabcontent.length; i++) { + tabcontent[i].style.display = "none"; + } + + tablinks = document.getElementsByClassName("content-navigation-tabs-link"); + for (i = 0; i < tablinks.length; i++) { + tablinks[i].className = tablinks[i].className.replace(" active", ""); + } + + document.getElementById(TabName).style.display = "block"; + evt.currentTarget.className += " active"; +} diff --git a/app/templates/base.html.j2 b/app/templates/base.html.j2 index 585c648c..712f2e90 100644 --- a/app/templates/base.html.j2 +++ b/app/templates/base.html.j2 @@ -14,6 +14,7 @@ + {% endif %} diff --git a/app/templates/content/field.html.j2 b/app/templates/content/field.html.j2 new file mode 100644 index 00000000..76b77f85 --- /dev/null +++ b/app/templates/content/field.html.j2 @@ -0,0 +1,72 @@ +{% load json %} +{% load markdown %} + +{% if field.widget_type == 'textarea' or field.label == 'Notes' %} + + {% if field.name in section.json and field.value %} + +
{{ field.value.strip | json_pretty | safe }}
+ + {% elif field.name in section.markdown or field.label == 'Notes' %} + + {% if field.label == 'Notes' %} + +
+ + +
+ {% if field.value %} + {{ field.value | markdown | safe }} + {% else %} +   + {% endif %} +
+
+ + {% else %} + + {% if field.value %} + + {{ field.value | markdown | safe }} + + {% else %} + +   + + {% endif %} + + {% endif %} + + {% elif not field.value %} + +   + + {% endif %} + + +{% else %} + + +
+ + + {% if field.field.choices %} {# Display the selected choice text value #} + {% for id, value in field.field.choices %} + + {% if field.value == id %} + + {{ value }} + + {% endif %} + + {%endfor%} + + {% else %} + {{ field.value }} + {% endif %} + +
+ +{% endif %} diff --git a/app/templates/content/section.html.j2 b/app/templates/content/section.html.j2 new file mode 100644 index 00000000..c6c18bd1 --- /dev/null +++ b/app/templates/content/section.html.j2 @@ -0,0 +1,93 @@ +{% load json %} +{% load markdown %} + +{% for section in tab.sections %} + + + {% if forloop.first %} + +

{{ tab.name }}

+ + {% else %} + +
+

{{ section.name }}

+ + {% endif %} + +
+ + {% if section.layout == 'single' %} + + {% for section_field in section.fields %} + {% for field in form %} + + {% if field.name in section_field %} + + {% include 'content/field.html.j2' %} + + {% endif %} + + {% endfor %} + {% endfor %} + + {% elif section.layout == 'double' %} + + {% if section.left %} + +
+ + {% for section_field in section.left %} + {% for field in form %} + + {% if field.name in section_field %} + + {% include 'content/field.html.j2' %} + + {% endif %} + + {% endfor %} + {% endfor %} + +
+ + {% endif %} + + + {% if section.right %} + +
+ + {% for section_field in section.right %} + {% for field in form %} + + {% if field.name in section_field %} + + {% include 'content/field.html.j2' %} + + {% endif %} + + {% endfor %} + {% endfor %} + +
+ + {% endif %} + + {% endif %} + + {% if forloop.first %} + + {% if tab.edit_url %} + +
+ +
+ + {% endif %} + + {% endif %} + +
+ +{% endfor %} diff --git a/app/templates/detail.html.j2 b/app/templates/detail.html.j2 new file mode 100644 index 00000000..35d54ee2 --- /dev/null +++ b/app/templates/detail.html.j2 @@ -0,0 +1,33 @@ +{% extends 'base.html.j2' %} + +{% block content %} + +
+ + + + {% for key, tab in form.tabs.items %} + + {% if forloop.first %} + + + + {% else %} + + + + {% endif %} + + {% endfor %} + +
+ +{% block tabs %}{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/docs/projects/centurion_erp/development/media/layout-template-view-base.png b/docs/projects/centurion_erp/development/media/layout-template-view-base.png new file mode 100644 index 0000000000000000000000000000000000000000..f5668551a50d1cb33753341b6e5d9b07d5112c62 GIT binary patch literal 23492 zcmeHv2Ut_fwy*^&9zju1sTRaS5s)HXK+2(dPyrE;5+XG;2@sOVu^>{FBcha`qNoT6 zNRbv)iV6||A%v13sFVN!fg~g(c@sdvd(V5{d+)#h{m;EW;`i;nXYH9iv-+&HCXtrr zhQF-ev3}XIWxp63>HoHD*~-CX%T}ygyBf3*Hij94%kqHV40V^~G>QPoGM)fk;}dJa zzp%BgSC=i@xIgfiO`vzMo4XfenWUoL+?}Mtp>wc+KuJY?Nd*NbUtd{QcPFU7lXrlu z4`jZIIQc^nB1JZa_# zwK4nK(Y}xA9c<|DHavQAN zL%%O&cH-c*^X{&YfceqpdIrK^UV-kuKR3F-e0(4-^F*5;(aGN*2LJgrH<;J_>gM0^ z1q=UabZ&#y-=UP-YVGbCcy9h(dF3N>li>o4Anwp}^Pg5#m?w+3)AyI>R}yf}$rT2l z>;HFDnZx6EZ2H4sV7Ns@TMYjGl6SBk#EXmU?|7X54&a;rdEXz{@c+U2|0ly)$@`wx z^@SXVJNlZKnk&j1TX|c`0IJV2vXj^Od6dsJ1O$eBXJCKWc^_8@mqLJ1b>ZjS10hzv zPA=Tn;efwD+qpn*FYq)cgWTbKoff7%zX~8KfspSU$oM`A!WZ$vp+obH=N9pS5_eSY{G8?)5c*?mZtH)CNZ_4$8vobx=PI_pVTC`% zzvAEF-;&Gx3Wtm!PF#I4JKg^+F@TQs(t~-y=G2a&s}n@k%>~daAkZJ?30VYDc2R{m zyUnW5zcBff|8mru*P}rT(qrxvJT(_dt-_xP?cu*3*_HlIX!T$|fm|gG z{0)Bya7_Mz^mB8AD7*Zr)Viu1aRwOs=g?2_ZHA+(@2@9(s(&YZ^Taa%4%$E1V!EliK>l3zoK=+$ zEB(KcJ*DqdS)@DvT=xD67E=*yp$z^L=Xe(O(>7$W*E9?LzVVp@%y-~!4(A-iJ>!@I z%a%zjGuA(PBG_T1drepB#I*L`u)q>RrjvYtr8Q1P-b0%ioMRC^y{QD6k8gs_ML3_NbpIMPZn_^WsU~vJY z{*3zAy6$mE@FoeJ3(J9U}nZ9z+u5p-=;5~S!z z@2I*@M*RRFe@av-L1c( zfZV7t{itN-UckNnj}^{o(}LHe#L;`0$Eu67X$dbvYYt%T70Kl2<@j0)LAxg#cDJpZ zmDV>Wb&PN~JKeYQSbgCj~ zR8+A+HNfjdpIBaV^F(dPEsmz(nc$|9(Rbl_bsLjwMPD1#qKSyypzg2oV;wJTjZlH~ z5AEm4zw}W%a6@q|afle+!30xIM?2W4$=u&5=t?r#NDW2<|2VDm4WVrHmf+I zTenTUdLuoIcXTm`i#3)?md;@?SB^JDh<_Y?M*4#l_PM~K_7`i%&3YJybxKHc6QO&` z4{NhcE6_;fInLbHh*LB*w3GF)%KpB2 zXpuj<^mZIvU0a(B6VgtDhL`lU9UUJx6t9)DW_zauHoWwkj?q)Qns^6YTf#_O(|f00 z?#ZftXx~5rjzTABJ0gzo4v+$+Se?2IH*$^$)(-L726mrFIE@MW=2wKKjdr`dznK_K znn9)7I6wa4Ozh~RPN@&lw8ukdMn1dTW9wI;B1CRg^yfuH)UXj=$(JV$$Yv_64TRLm zGVzedR3pwLM7)eWtM03K1o)e!wq;-Ds`>QwD^f2{#^4#}pH_ZlZgCCb zewLWw<8+@wU(M0h252)mt{6{3e>zzVg){j&5Op1Wla$)k$v&x?4%+mWLiu$)WOx%N zzq?l6wc2z1)f?zNTr9d#44%7Yw6d^lfUkA*Azm-^{uBd)x*qRvd1bC4*YG9va7iFl zw0xEXw&?}&4xG(}ui1=S9YTd>R%Pm}V-46Ii{93bek=|207P{&w(v39E= zm`#|8x`~fkaVR&_BM@A>OWpf?TqYim0qb^!H;^&*j$wmeoT~^@c=i#=hfh{9k9jyQ zQweKF+8V*0JFub#x{T!}jOTdhv!p~uzofVLtPvmE8qTnvN{CZG97OE9kQ8=pf8X0p zan+~L4EwH$frE~S4AC94j3;3!vwUa8>4`QO?&F?4zP(MzzOUrm=uyp3SInkd^zqKk zCUO|d%fIfN^ANrX@;6Ti7K_jI{_ZuLlaRlj(M^1u5VH$%5n@%!-ekj=oo5ZHc6;V1Xv!iD(J4U0Vspz!s1E=}sMzPn)- z7e598Q(U<;MV|iw7W3m#V^enM+eVu2B;r%5O1p_BIeS+vauzKe!lg5E3qCh~8L@=& zoaP@;nCYQA-!!qad1<%U-TRQD6KB`P!NQv_PkyaBb5av9U8o{n{s$L@M*{Y>kPcxA z8t53wH9Xq#>kmFk9|Qj5C&NeK>tw(y=bXs1Mt!d5A#)ugxZv8rU4XW`7SVQI7%;Ea zuN}NBn)+&CE?Ynkxvn=`Qi?Ccrxlm57I^7|wT@Bl=!YyAze>e3`N^mNPu*N4}x z&@F8_nl7+>XZNOgx#E6JLJmlboI7*NDKN+3fi30>IPm4-z~Es|IT)AN6xmw`#( zRZ&>g4=C3!SKMpH`C~BmZA^# zd{NhdoFkO35^7|o%N1L<$m~-N^5!o-)g}ezzje+tE~^{Kt=QCJs?FlbdjE<$?f6*0 zWR21%Zaf0lfwO-c1G;H^3-DG%`LqtBSP@DETZu?=cKatPykiRU3-92E2Yf;B|}OgBU43SE8xlW zty~Xqs<2V5+%b9^Lnp;|s?d*o)4F=PPE6oFmcEOKWMge>b}0*9Uizm9qS>Qlum zK9T?)+I9X&NCQ`*=?Q&+k5fEs^$aY#*(8sw=2_z*?6pmplJDtN^y-`6$0`%TLD z=-3@wvB|VL`On?7z9uFmO0SrtEGzO&$COn;fpAb188l9pQI%jGEft}?Wew)MdEiK! zOrs{_(YER8KEL)5p|JjVEtaVwvM+T~#Q`ZvC508J!p6GgPs*g@(T|_?Um@3-?RIPs z>!fi7INTqw_?M*nvU}5ko4MRi#tgJYN&we1w}S2*NNL*LTQBGG?r~l?2&=O-oUBJx);mQ&PioWXSK9~quwb%9SvfcC z9kFHh=om3gwY|w+-?q`Ko)ul5Jjf1m+n{+PcDB8oF z+`64W4X!=unfNQ<`BqgW;1P$@AJ%o(<<0b`!kt-?LQyyIwkm@5KGR9iPsaeQE;^zW z8`Y@;!?9Dctk^fQxupFhzZeo^X9fEh4BPE{-zJMDNWf{a=zVnhYd)8b?QZAnjIhJe zEzT!K0?4dJ@eg@l$#U3~Re|tkQ7^!Je*m>7hm$_95DqNuIv!73&}OERg)lmt%JF#}OQ32zYA}*hO@BwO|`(E3@RG|4vuX|iw7JiL4%+4qmZQE6u?0utK z665u4zjJKJ*T_wUShZ}n?F1urD#~1A=Melk8LiZzQ7Gqy=)Jq9Z>TQqO{ME-HH#Kg zwz`|&-UD6ODKgO@ye2iIEg0$76la#Z`3q>wFLyG1_5$ZcYq_P2F=$({{v&pq54{@>b(FP6 z|Dsxnjt=f&s}A*=@V3X?;2JQAlfXQ*zI}9XQRqbAWIFq@KeCvpV4yXvA0&nNNx9jC#vrfoYPgUw9n(KVS|Mwy4K z+5!xL>S4)#U{(6b-YN@_lR(>NX&@_9fc;mg`MhfZ!qWkq%Hriso4NOp^7g=3n zU~S!Zco_c%uKv>37Fpe20p8KwzZ&^4DSff}#E8us|MW|M(CZ?eJfH&B8ku@NzonR+Va%#afteR|q*hGDj{>vz z!(9k3=P4BlW3@Tv0IxA*1S-Db^5%h1maaT$h)?i|Tg~Q$U4W%&c;*_JO}e4-(9EF} zplDLky%#g$+QE&>!_9dv1sX02B7*UOhmQ!5&i0h=aY^56brw#!$@@U9({5jdSb43%W@7>z3};T@ZL32Ou~O z&D8x*f$C{*+Mn`673h2dlKLvf^koPTv5=_FMcb3}KzR=}I`o?X(K6G05&fgjvhYLz zN?a#n83e?8SfFho4iO#*h6{ANdqW?*p}*$7^nxXk1Y&KXdP1fU?C@Z@nXTWg5|G$) z(qQK(%A*E$7NVOLdhmP$Mswmuk%bc&>{YD!rv>3&4~(XekL&tIJD`8MX8V3lB--$522YoCM96q}w4&j(9|V4lrI%w|`Wu^ZT4Mc$ix4gg!MwhgkNEB4(5 z7<+Zs<8cMRAVM>H@y^Z#>>LLrK1718&D8~~EfIeU_|>U+uKUJ4O&y{7-mA>t+V{g?B&Rpj&Sd`cv#T{#wK0$G>yK3E71 z1=opn4sVK*W+$`xB75BB2Qw0C zHwVaUzc^a4(=8>WcKkFhdCn(YwBhGwGt7+edJKn7im2jy1*{!HrKV zH}Sct#h*$wLnrAS zsgr(jWmxJr&piA_M4hCHy+{iZU0TAvCfxhH&pe7uDV3Fdw2D^0`i!78$?v?WG9e?6 zlY@?-;8c;>Y4&$4AB{@Rrpy8swkF&ysM*yx-obu*q=SYpZe<=hu?!MdAr61zCyFuV z47`r(&FLJCz2y+RKMhTtE*T#Z?jvT7Mmn@iUL(ntg^C0)n&b(e6Kbf!6n+(LUn`C2 z_5{(L$cmHfZDno0fAhzlmd*NluR!jalo(Q>EP;5gJ53XTaxubsRec$MTZhrdT%-;U zcoC)(qj+=0`QgM+>*z2ySCODscL}&5c$;I$;7!$@H(2Gnmfa?EyW@rZB{wOb%+K*K z%5`+$>wg`gDMG@0V&)C}7F8?GE-`po)*PDmj$$2IJmkPe(aD?hwDLIsOXoL;hx*poPK?8bLgZ)Mz5C0Om zBS-zbcWN$bFS09j(xkC$j}~Vps(qqLQ%#LY2&=E1woVnIZ4}!Ks&0;K)EK%D8_#al zRylh7EtvYRvGy$sQ{Q_H#K?G<=!nTC$LQD?6fHA&^l`s)F$*3wEp#WBdUe{tk9@J% zpxCE7zieO%caNmh+ct>t~@hhKrk2T_H}AF)Uv^mcQ8{FaazO9j+~?z8!P9r z95Hb|wC+p*ZmVYwC*lg(T%GkiZ;Vecl9?v<8}n%xE*1vQC2c1mB9Oh!$#QS0Vu7vc zs?>OwzBZ>~x41IiL9-1rSlLa@*s|O3MZ4@nC@$~&a73HQu*ceMm80*-HRgzsEmOD* zN<|-WysKq*fni5Ky-9>1^jx%q6K~@+Sd%XrG+1FWV)CW7dcROg3TrwG-b{A<+y*0| z#(P+NH77JPuMI+zwA^x#2{Xhs6jnC54IkE%cGu5uWTKzk>am?>{mkA0@tJk6L#VqI z8(`(*g-~2w6V>c{V&-N#@ zpDmaYcx1kAbgtxLU!o2LD#>#l-wP4-?)pteQ{c#;>R&``T^jYZbYC(x|-yC<;k_(W5c|1b+51v z+72PUmJOE7bL}N~PgwbO6_TH$SKcS_e5p>VDxF^PrIG0=ZbjZmZ00$pf@z)TJm(h*h3H`x-7D^y-KoXKa(-7hA() zv<~=NuQL26`Oti$yXq8`0dS`x>mTU$lgB3eB7~d2) zu49C`N-x^0bD;{prG$P5TX$UwBOW(%-_OX=DLdO4H=+_r$}QI|3S|}a9L&ZdXUKVW z`SI-Po_c4hEwmAJiZd}mvlnhb=2{yXiEH%^+W1v?yAV6zAEFTzH1-<5&usOYI5$jT zkW?6@qi+Zg(SAa*CAnshUa8bll-)6Ld~;e**9Xhu;Wxj?+YP^w?r#i zFf+0dH9N zxy8<}N2podBU(FoUrZTRp^Lxa3NYHIn8K$}FR04njaJ3byJ4T>J)IwJe^`%F9>q-+ z)*|;JZANm9yU#IMy*DF#hHbE7PcxH?tTm{g0}63ij#64B^W89XDqd?87c-hcVpY8! zk18yVwRK~lsKwPE9jD~Vv+&mF=M`ejj!4#f)#+zi!1OtuwdU+9XWVB_;p;GAJH*?x z6!5@1vGKa84T*jkYSmIRZ=KxaktFuDiy7?`bko=3~rC5X7w&$7`@od63GvYM>1 zD?5?36bs>QH~Ry;-?~eK>R(a(){F?EPa_Jq9T~c5;n#A&fJoQ%XZKHqJh zy5Pd;mqlxi;l*9FIhQI$FP5>=obujXQLpBuISQ@Jt(^7*srO*!13Yo_#as==P;;eY zURd0w@lgb&PC5B$!U(JRnebd4BiR!>J|J^Emoyt15RsToD0Uug#p97zlV1N$BgpZx|OViOSbWIZbuw>I?RB6q;+H@J8|j^b~Fimm^nf zKzVu}I{M-OlQbmIrZAPuX-noA3x!k2vIo zd(z5)w?^u2P+t_s-=@ti4DTw>(gkF{d?ouNP+;>WTL2VqVcx8KhGYN`L-DtrKb-yW zd=OB8+|JP23r5y@c}~W3A#YKI2K#_mo~2;*Qm}d{SpAnOC|C+s|5bz4O9ASo0QFLU zdMQBt-ShfU1Gc2HmsIvrNyyT%lmCy8o%{oGir4Ta)!-NCc5;6g;GbH;yQIFC)c2D5 z{%5Oi2ZA4mDtm5m0p;4TD4kmub4iT)^NC&CH!oj7;vP!Ec3E4(Rlhr3Anuhkw&EgF zbcZhE61+$+9Mog3;IkO~5f9^84~}~Xd7XXt-Vs!=lDc9SAWuLMs`dGA44>1Wg7wb3 z%L|aT&q36AZ5PpnI(?&KAoBv*JY)n;Em{N}UcXR4YN-b5M+0}aT9$!1 z1Vx=*sPJ7DZUBxz7W^9@fjl5RMxha-Y*7`znX0|rb6fY8>?TE1b^GDT%wJqGW0ddo zC}4*wj%^Sg4-O=^jd&s|LhwDYcPdRbaZ47SmW~AZjTlSSx^i+eDc?|dF1mI6K&qm* z;nxQO;P?y1Xsk#QwDGP0Ie1|>aZ}6W@a^o$H!ZkokHU1sr%e%c*XpIaUk#;V3&old zxi8$j@@pyT*pkSD265X3tYlsUmcu#xJ1f@7-Qs2k-hklf^~_A_8SsrWN#3A>JG^Lp zn3Yj3>=}cqUJXmMlMQ0G7&Os7w)7_Mq}60%(1I8gHADmt#dY8kV=T0o(m`4?SGRo| z+dW?K7*V@Io8ia(ZH$ywTDtTAC1f~n{G}ntgmrL+35W?B#DlGzOe#1qHdR#t=k)c7 z)4CnYT8K)5j%Q!0y5YKAhW2MhGsTh83=zDx7W;F;0H{e2dDdN&*MYkg=gklk>32lf zOTYN=yI;4lREFZ;8g%VwX6j}ubFE`DUmo4}jElp>YofGzO38gN zYlp4yRJ4yE5rZ6jP|`Do8XwQZ)V{Ox*~Wt~tUgk9FB*xr1EEAgIKkJUA{&so%xkrq+#&fFz#V_N%cKxCY2R0mq}Niq}O0T$i=jdx(Q$a1Rk*DQNAW zcHfKbp%v!~iYvd>3i~lv>h)c=c8u8>r?Htb)2}`L0dlJrr`(~sk?0;_GY!?Q&rzSc zVd6r!vZzV)%(Z8pp%)eEXc^YEQIKOtMT+Xp^N0tsW-N?glO@b+c3xZ21g``eu7l4()kus}Yltj&cDyIOG`kp^ov$O+_XEYEeNber;KNZ=BSnrizyazSb4a>dboVtAn#4rMD-eR zv`9n>Ds%&OCZ8TMGD>$W?%sRKHi$6%twpv%S}gS2+xD>c+SL!4qe3`Rpo`&cSUD@m zv@;8jA5Fre>E3Z=9RCSoQzkWO28L!&c%^Q_X$oa|6`E0Pr?F{){u^GL)2U;SDV5Nt z&#HU#-izbOYX|u>$Ms*UiG?r*+o?Tz`C8>%Wt^S_<}2U-a7YAbKao6d?%`kMb%BD- ze42d?!Cz~R%lBMG5$vg{9&sro)`ziS#V%Ikj9m~Mp(Aq`)R^?=4YhUFmIu7QjdVkX zE44-6hm&NKZM+ME**C473EJK_G-I_$DFLYLZ$OM#?4VN`bh-ecZ*&Tmil^m}u4{*2 zkuGUT$X!oMZpDj%|O$5)`P@r0Xd~=4vab( zLf?3qXvm2!#jxPXJ04Gx{7MI1+HSi=3s8v%ha4MDah!|!v>B>Qg%=f}wAd+YyD9W% ztV{;}BBxR+B}vmmJG5b-L#R%MpT(Svq)gpW8%5GT4PU3lX|eW{1`X~&coWpy>_fhI zvA{1|ynKD7rh8>$90Q8OK8_7Vj%F~mdANCOa418LI&%VZ5gdeAnJ)wA5FcOlrMT-i zrt^!p1$9ki$L6A!X-MY>ki-@&7Yjuk~klp=@7iLNPF@beW1^xcS5f6 zs-yG^eUXB?QNsN}hgsWrRu`nA1WvazE(5d3tF8OXe9j2~_eFzjRnqbOfzBY2Bw#|U zob521V0r?MkXXaMs=-Yqh;yMT!3zyA2{5;(^q6`gf*;r9;s>5ky6$m4&bvm$EXJdtomgiaGr rq8jJ@y_-)R7IJ+&>!rug%if4ljU=I;^1v@~FEc)7uAifO_V@n) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/projects/centurion_erp/development/media/layout-template-view-detail.png b/docs/projects/centurion_erp/development/media/layout-template-view-detail.png new file mode 100644 index 0000000000000000000000000000000000000000..ef245ff61c6274a704629ea45c7e86f9d54ff2ea GIT binary patch literal 33554 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

Z`B_BRDpdGo=Be4z5C?AQ$YK=HFgY0-ym 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 literal 0 HcmV?d00001 diff --git a/docs/projects/centurion_erp/development/templates.md b/docs/projects/centurion_erp/development/templates.md index 5758fc9b..36d5cf04 100644 --- a/docs/projects/centurion_erp/development/templates.md +++ b/docs/projects/centurion_erp/development/templates.md @@ -7,3 +7,51 @@ about: https://gitlab.com/nofusscomputing/infrastructure/configuration-managemen --- This section of the documentation contains the details related to the templates used within Centurion ERP for rendering data for the end user to view. +The base template is common to all templates and is responsible for the rendering of the common layout. Each subsequent template includes this template. This enables **ALL** pages within the site to share the same layout. + +![Base template layout](./media/layout-template-view-base.png) + +Point of note is that the orange area of the template is what each template is "filling out." + +This view contains the following areas: + +- Page Header +- Navigation +- Page Title +- Content Area +- Page footer + +!!! note + This template should not be included directly as it is incomplete and requires subsequent templates to populate the contents of the orange area. + + +## Detail + +This template is intended to be used to render the details of a single model. The layout of the detail view is as follows: + +![detail layout](./media/layout-template-view-detail.png) + +This view contains the following areas: + +- Section navigation tabs +- Section Content + +The page title represents the "what" to the contents of the page. i.e. for a device this would be the device name. A detail page contains navigation tabs to aid in displaying multiple facets of an item, with each "tabbed" page containing one or more sections. Point of note is that the tabs are only rendered within the top section of each "tabbed" page. + +Base definition for defining a detail page is as follows: + +``` jinja + +{% extends 'detail.html.j2' %} + +{% load json %} +{% load markdown %} + + +{% block tabs %} + + your tabs content here + +{% endblock %} + +``` From ea8c60ccc5e67710b25e0b0ffddf2651e1fe0c0e Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 14 Aug 2024 00:10:56 +0930 Subject: [PATCH 31/82] refactor(itim): services now use details template . #22 #226 --- app/core/views/common.py | 7 + app/itim/forms/services.py | 81 ++++++ app/itim/templates/itim/service.html.j2 | 312 +++++------------------- app/itim/views/services.py | 4 +- app/templates/detail.html.j2 | 16 +- 5 files changed, 162 insertions(+), 258 deletions(-) diff --git a/app/core/views/common.py b/app/core/views/common.py index f1baac20..a1350afa 100644 --- a/app/core/views/common.py +++ b/app/core/views/common.py @@ -75,6 +75,13 @@ class ChangeView(View, generic.UpdateView): external_links_query = None + if 'tab' in self.request.GET: + + context['open_tab'] = str(self.request.GET.get("tab")).lower() + + else: + context['open_tab'] = None + if self.model._meta.model_name == 'device': diff --git a/app/itim/forms/services.py b/app/itim/forms/services.py index a88dc277..e591afb4 100644 --- a/app/itim/forms/services.py +++ b/app/itim/forms/services.py @@ -1,8 +1,11 @@ from django import forms from django.forms import ValidationError +from django.urls import reverse from itim.models.services import Service +from app import settings + from core.forms.common import CommonModelForm @@ -79,3 +82,81 @@ class ServiceForm(CommonModelForm): return cleaned_data + + + +class DetailForm(ServiceForm): + + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'config_key_variable', + 'template', + 'organization', + 'c_created', + 'c_modified' + ], + "right": [ + 'model_notes', + ] + } + ] + }, + "rendered_config": { + "name": "Rendered Config", + "slug": "rendered_config", + "sections": [ + { + "layout": "single", + "fields": [ + 'config_variables', + ], + "json": [ + 'config_variables' + ] + } + ] + } + } + + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + + self.fields['config_variables'] = forms.fields.JSONField( + widget = forms.Textarea( + attrs = { + "cols": "80", + "rows": "100" + } + ), + label = 'Rendered Configuration', + initial = self.instance.config_variables, + ) + + 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('ITIM:_service_change', args=(self.instance.pk,)) + }) diff --git a/app/itim/templates/itim/service.html.j2 b/app/itim/templates/itim/service.html.j2 index 14bbe245..c6b48466 100644 --- a/app/itim/templates/itim/service.html.j2 +++ b/app/itim/templates/itim/service.html.j2 @@ -1,273 +1,75 @@ -{% extends 'base.html.j2' %} +{% extends 'detail.html.j2' %} {% load json %} {% load markdown %} -{% block content %} - +

+

Dependent Services

+ + + + + + {% if item.dependent_service.all %} + {% for service in item.dependent_service.all %} + + + + + {% endfor%} + {% else %} + + + + {% endif %} +
NameOrganization
{{ service }}{{ service.organization }}
Nothing Found
+
- -
- - - - - {% if perms.assistance.change_service %} - - {% endif %}
-
-
-

Details

- {% csrf_token %} +
-
+ {% include 'content/section.html.j2' with tab=form.tabs.rendered_config %} -
- -
- - {{ form.name.value }} -
- -
- - {{ form.config_key_variable.value }} -
- - {% if form.template.value or form.is_template.value %} -
- - - {% if form.is_template.value %} - {{ form.is_template.value }} - {% else %} - {{ item.template }} - {% endif %} - -
- {% endif %} - -
- - - {% if form.organization.value %} - {{ item.organization }} - {% else %} -   - {% endif %} - -
- -
- - {{ item.created }} -
- -
- - - {% if item.cluster %} - {{ item.cluster }} - {% else %} - {% if item.device.id %} - {{ item.device }} - {% endif %} - {% endif %} - -
- -
- - {{ item.modified }} -
- -
- - -
-
- - -
- {% if form.model_notes.value %} - {{ form.model_notes.value | markdown | safe }} - {% else %} -   - {% endif %} -
-
-
- -
- - - -
- -
-

Ports

- - - - - - {% if item.port.all and not item.template %} - {% for port in item.port.all %} - - - - - {% endfor %} - {% elif not item.port.all and item.template %} - {% for port in item.template.port.all %} - - - - - {% endfor%} - {% else %} - - - - {% endif %} -
NameDescription
{{ port }}{{ port.description }}
{{ port }}{{ port.description }}
Nothing Found
-
- - -
-

Dependent Services

- - - - - - {% if item.dependent_service.all %} - {% for service in item.dependent_service.all %} - - - - - {% endfor%} - {% else %} - - - - {% endif %} -
NameOrganization
{{ service }}{{ service.organization }}
Nothing Found
-
- - -
-

Service Config

-
- -
- -
- - - -
- -
-

- Rendered Config -

- -
- -
- -
- - {% if perms.assistance.change_knowledgebase %} -
-

- Notes -

- {{ notes_form }} - -
- {% if notes %} - {% for note in notes %} - {% include 'note.html.j2' %} - {% endfor %} - {% endif %} -
- -
- {% endif %} - - +
{% endblock %} \ No newline at end of file diff --git a/app/itim/views/services.py b/app/itim/views/services.py index 42c97fb4..0068416c 100644 --- a/app/itim/views/services.py +++ b/app/itim/views/services.py @@ -6,7 +6,7 @@ from core.forms.comment import AddNoteForm from core.models.notes import Notes from core.views.common import AddView, ChangeView, DeleteView, IndexView -from itim.forms.services import ServiceForm +from itim.forms.services import ServiceForm, DetailForm from itim.models.services import Service from settings.models.user_settings import UserSettings @@ -136,7 +136,7 @@ class View(ChangeView): context_object_name = "item" - form_class = ServiceForm + form_class = DetailForm model = Service diff --git a/app/templates/detail.html.j2 b/app/templates/detail.html.j2 index 35d54ee2..4b893626 100644 --- a/app/templates/detail.html.j2 +++ b/app/templates/detail.html.j2 @@ -20,7 +20,7 @@ {% else %} - + {% endif %} @@ -30,4 +30,18 @@ {% block tabs %}{% endblock %} +{% if open_tab %} + + + +{% else %} + + + +{% endif %} + {% endblock %} \ No newline at end of file From 2cd4d387a7a3ef299d1c0ae81c1d005a8faeb1b1 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 14 Aug 2024 01:23:52 +0930 Subject: [PATCH 32/82] docs(base): detail view template . #24 #226 closes #22 --- .../centurion_erp/development/forms.md | 12 ++ .../centurion_erp/development/index.md | 2 + .../centurion_erp/development/templates.md | 133 +++++++++++++++++- mkdocs.yml | 4 +- 4 files changed, 148 insertions(+), 3 deletions(-) diff --git a/docs/projects/centurion_erp/development/forms.md b/docs/projects/centurion_erp/development/forms.md index 2a0c893f..ab2795c7 100644 --- a/docs/projects/centurion_erp/development/forms.md +++ b/docs/projects/centurion_erp/development/forms.md @@ -51,6 +51,18 @@ 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: + +- `tab` is defined as a `dict` within the class. _See [Template](./templates.md#detail)._ + +- There is an `__init__` class defined that sets up the additional fields. + + !!! danger "Requirement" + 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 diff --git a/docs/projects/centurion_erp/development/index.md b/docs/projects/centurion_erp/development/index.md index 382ba600..ca040fa5 100644 --- a/docs/projects/centurion_erp/development/index.md +++ b/docs/projects/centurion_erp/development/index.md @@ -19,6 +19,8 @@ Centurion ERP is a Django Application. We have added a lot of little tid bits th - [Models](./models.md) +- [Templates](./templates.md) + - [Testing](./testing.md) - [Views](./views.md) diff --git a/docs/projects/centurion_erp/development/templates.md b/docs/projects/centurion_erp/development/templates.md index 36d5cf04..31eddf4c 100644 --- a/docs/projects/centurion_erp/development/templates.md +++ b/docs/projects/centurion_erp/development/templates.md @@ -7,6 +7,17 @@ about: https://gitlab.com/nofusscomputing/infrastructure/configuration-managemen --- This section of the documentation contains the details related to the templates used within Centurion ERP for rendering data for the end user to view. + + +## Templates + +- Base + +- Detail + + +## Base + The base template is common to all templates and is responsible for the rendering of the common layout. Each subsequent template includes this template. This enables **ALL** pages within the site to share the same layout. ![Base template layout](./media/layout-template-view-base.png) @@ -16,11 +27,25 @@ Point of note is that the orange area of the template is what each template is " This view contains the following areas: - Page Header + + _Site header._ + - Navigation + + _Site navigation._ + - Page Title + + _represents the "what" to the contents of the page. i.e. for a device this would be the device name._ + - Content Area + + _The views content_ + - Page footer + _Site footer_ + !!! note This template should not be included directly as it is incomplete and requires subsequent templates to populate the contents of the orange area. @@ -36,7 +61,7 @@ This view contains the following areas: - Section navigation tabs - Section Content -The page title represents the "what" to the contents of the page. i.e. for a device this would be the device name. A detail page contains navigation tabs to aid in displaying multiple facets of an item, with each "tabbed" page containing one or more sections. Point of note is that the tabs are only rendered within the top section of each "tabbed" page. +A detail page contains navigation tabs to aid in displaying multiple facets of an item, with each "tabbed" page containing one or more sections. Point of note is that the tabs are only rendered within the top section of each "tabbed" page. Base definition for defining a detail page is as follows: @@ -55,3 +80,109 @@ Base definition for defining a detail page is as follows: {% endblock %} ``` + +!!! tip + Need to navigate directly to a tab, add `tab=` to the url query string + + +### Providing data for the view + +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: + + +#### Full Example + +This example is a full example with two tabs: `details` and `rendered_config` + +``` python + +tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'config_key_variable', + 'template', + 'organization', + 'c_created', + 'c_modified' + ], + "right": [ + 'model_notes', + ] + } + ] + }, + "rendered_config": { + "name": "Rendered Config", + "slug": "rendered_config", + "sections": [ + { + "layout": "single", + "fields": [ + 'config_variables', + ], + "json": [ + 'config_variables' + ] + } + ] + } +} + +``` + +additional fields can be defined as part of the form `__init__` method. + +``` python + +def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.fields['config_variables'] = forms.fields.JSONField( + widget = forms.Textarea( + attrs = { + "cols": "80", + "rows": "100" + } + ), + label = 'Rendered Configuration', + initial = self.instance.config_variables, + ) + + 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, + ) + +``` + +You can add an edit button to any tab by defining the following as part of the `__init__` method: + +``` py + +self.tabs['details'].update({ + "edit_url": reverse('ITIM:_service_change', args=(self.instance.pk,)) +}) + +``` + +in this example, the details tab will display an `Edit` button. The `Edit` button will only display at the end of the first section of any tab it has been defined for. diff --git a/mkdocs.yml b/mkdocs.yml index bce51a41..8a9e82c6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -152,12 +152,12 @@ nav: - projects/centurion_erp/development/models.md + - projects/centurion_erp/development/templates.md + - projects/centurion_erp/development/testing.md - projects/centurion_erp/development/views.md - - projects/centurion_erp/development/templates.md - - User: - projects/centurion_erp/user/index.md From 01e47c889b292c80393063b487e61ca07226965c Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 14 Aug 2024 01:46:21 +0930 Subject: [PATCH 33/82] docs(roadmap): update completed features #226 --- docs/projects/centurion_erp/index.md | 60 ++++++++++++++-------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/docs/projects/centurion_erp/index.md b/docs/projects/centurion_erp/index.md index 58c0d78e..30e4feb4 100644 --- a/docs/projects/centurion_erp/index.md +++ b/docs/projects/centurion_erp/index.md @@ -34,6 +34,8 @@ Centurion ERP contains the following modules: - [IT Asset Management (ITAM)](./user/itam/index.md) +- [Knowledge Base](./user/assistance/knowledge_base.md) + - **Core Features:** @@ -75,75 +77,71 @@ Below is a list of modules/features we intend to add to Centurion. To find out w - **Planned Modules:** - - Accounting _[see #88](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/88)_ + - Accounting _[see #88](https://github.com/nofusscomputing/centurion_erp/issues/88)_ - General Ledger - _[see #116](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/116)_ + General Ledger - _[see #116](https://github.com/nofusscomputing/centurion_erp/issues/116)_ - - Asset Management _[see #89](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/88)_ + - Asset Management _[see #89](https://github.com/nofusscomputing/centurion_erp/issues/88)_ - - Change Management _[see #90](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/90)_ + - Change Management _[see #90](https://github.com/nofusscomputing/centurion_erp/issues/90)_ - Config Management - - Host Config _[see #44](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/44)_ - - Core - - Location Management (Regions, Sites and Locations) _[see #62](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/62)_ + - Location Management (Regions, Sites and Locations) _[see #62](https://github.com/nofusscomputing/centurion_erp/issues/62)_ - - Customer Relationship Management (CRM) _[see #91](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/91)_ + - Customer Relationship Management (CRM) _[see #91](https://github.com/nofusscomputing/centurion_erp/issues/91)_ - - Database Management _[see #72](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/72)_ + - Database Management _[see #72](https://github.com/nofusscomputing/centurion_erp/issues/72)_ - - Development Operations (DevOPS) _[see #68](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/58)_ + - Development Operations (DevOPS) _[see #68](https://github.com/nofusscomputing/centurion_erp/issues/58)_ - - Repository Management _[see #115](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/115)_ + - Repository Management _[see #115](https://github.com/nofusscomputing/centurion_erp/issues/115)_ - - Human Resource Management _[see #92](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/92)_ + - Human Resource Management _[see #92](https://github.com/nofusscomputing/centurion_erp/issues/92)_ - - Incident Management _[see #93](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/93)_ - - - Information Management _[see #10](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/10)_ + - Incident Management _[see #93](https://github.com/nofusscomputing/centurion_erp/issues/93)_ - IT Asset Management (ITAM) - - Licence Management _[see #4](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/4)_ + - Licence Management _[see #4](https://github.com/nofusscomputing/centurion_erp/issues/4)_ - - IT Infrastructure Management (ITIM) _[see #61](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/61)_ + - IT Infrastructure Management (ITIM) _[see #61](https://github.com/nofusscomputing/centurion_erp/issues/61)_ - - Cluster Management _[see #71](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/71)_ + - Cluster Management _[see #71](https://github.com/nofusscomputing/centurion_erp/issues/71)_ - - Database Management _[see #72](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/72)_ + - Database Management _[see #72](https://github.com/nofusscomputing/centurion_erp/issues/72)_ - - Service Management _[see #19](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/19)_ + - Service Management _[see #19](https://github.com/nofusscomputing/centurion_erp/issues/19)_ - - Software Package Management _[see #96](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/96)_ + - Software Package Management _[see #96](https://github.com/nofusscomputing/centurion_erp/issues/96)_ - - Role Management _[see #70](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/70)_ + - Role Management _[see #70](https://github.com/nofusscomputing/centurion_erp/issues/70)_ - - Virtual Machine Management _[see #73](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/73)_ + - Virtual Machine Management _[see #73](https://github.com/nofusscomputing/centurion_erp/issues/73)_ - Vulnerability Management - - Software _[see #3](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/3)_ + - Software _[see #3](https://github.com/nofusscomputing/centurion_erp/issues/3)_ - - Order Management _[see #94](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/94)_ + - Order Management _[see #94](https://github.com/nofusscomputing/centurion_erp/issues/94)_ - - Supplier Management _[see #123](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/123)_ + - Supplier Management _[see #123](https://github.com/nofusscomputing/centurion_erp/issues/123)_ - - Project Management _[see #14](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/14)_ + - Project Management _[see #14](https://github.com/nofusscomputing/centurion_erp/issues/14)_ - - Problem Management _[see #95](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/95)_ + - Problem Management _[see #95](https://github.com/nofusscomputing/centurion_erp/issues/95)_ - - Request Management _[see #96](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/96)_ + - Request Management _[see #96](https://github.com/nofusscomputing/centurion_erp/issues/96)_ - **Planned Integrations:** - - ArgoCD _[see #77](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/77)_ + - ArgoCD _[see #77](https://github.com/nofusscomputing/centurion_erp/issues/77)_ [ArgoCD](https://github.com/argoproj-labs) is a Continuous Deployment system for ensuring objects deployed to kubernetes remain in the desired state. - - AWX _[see #113](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/113)_ + - AWX _[see #113](https://github.com/nofusscomputing/centurion_erp/issues/113)_ [AWX](https://github.com/ansible/awx) is an Automation Orchestration system that uses Ansible for its configuration. From 955081f155867a2120431b540ed9dd8f345e30b5 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 14 Aug 2024 01:52:22 +0930 Subject: [PATCH 34/82] chore: add Merge/Pull request template #226 --- .github/pull_request_template.md | 39 ++++++++++++++++++++++ .gitlab/merge_request_templates/default.md | 2 ++ 2 files changed, 41 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..c6545449 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,39 @@ +### :books: Summary + + + + +### :link: Links / References + + + + +### :construction_worker: Tasks + + - [ ] Add your tasks here if required (delete) + + + +- [ ] :firecracker: Contains breaking-change Any Breaking change(s)? + + _Breaking Change must also be notated in the commit that introduces it and in [Conventional Commit Format](https://www.conventionalcommits.org/en/v1.0.0/)._ + + - [ ] :notebook: Release notes updated + +- [ ] :blue_book: Documentation written + + _All features to be documented within the correct section(s). Administration, Development and/or User_ + +- [ ] :checkered_flag: Milestone assigned + +- [ ] :test_tube: [Unit Test(s) Written](https://nofusscomputing.com/projects/centurion_erp/development/testing/) + + _ensure test coverage delta is not less than zero_ + +- [ ] :page_facing_up: Roadmap updated diff --git a/.gitlab/merge_request_templates/default.md b/.gitlab/merge_request_templates/default.md index 52ee7ff4..e48024d1 100644 --- a/.gitlab/merge_request_templates/default.md +++ b/.gitlab/merge_request_templates/default.md @@ -35,3 +35,5 @@ - [ ] [Unit Test(s) Written](https://nofusscomputing.com/projects/centurion_erp/development/testing/) _ensure test coverage delta is not less than zero_ + +- [ ] :page_facing_up: Roadmap updated From 4cca9d9904e9c287abc2c3e66b21b6bb6d301f98 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 15 Aug 2024 22:00:59 +0930 Subject: [PATCH 35/82] feat(development): render heading if section included #227 --- app/templates/content/section.html.j2 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/templates/content/section.html.j2 b/app/templates/content/section.html.j2 index c6c18bd1..4f229f6f 100644 --- a/app/templates/content/section.html.j2 +++ b/app/templates/content/section.html.j2 @@ -1,6 +1,12 @@ {% load json %} {% load markdown %} +{% if not tab.sections %} + +

{{ tab.name }}

+ +{% endif %} + {% for section in tab.sections %} From 750e3239471600ac9c590832f83eb9ea83c80944 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 15 Aug 2024 22:04:03 +0930 Subject: [PATCH 36/82] feat(development): add to form field `model_name_plural` #227 #239 --- app/core/forms/common.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/core/forms/common.py b/app/core/forms/common.py index d59714d1..b88c64c0 100644 --- a/app/core/forms/common.py +++ b/app/core/forms/common.py @@ -98,3 +98,7 @@ class CommonModelForm(forms.ModelForm): | Q(manager=user) ) + + if hasattr(self, 'instance'): + + self.model_name_plural = self.instance._meta.verbose_name_plural From ac6408c3bbbf043df8929b29dcba55ac872e8233 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 15 Aug 2024 22:05:30 +0930 Subject: [PATCH 37/82] feat(development): Render `model_name_plural` as part of back button #227 #239 --- app/templates/detail.html.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/detail.html.j2 b/app/templates/detail.html.j2 index 4b893626..bdb2477b 100644 --- a/app/templates/detail.html.j2 +++ b/app/templates/detail.html.j2 @@ -9,7 +9,7 @@ style="vertical-align: middle; margin: 0px; padding: 0px border: none; " fill="#6a6e73"> - Back to Services + Back to {{ form.model_name_plural }} {% for key, tab in form.tabs.items %} From 300fe283d6f44cb6f5a24a9d514dac8d6377c3c9 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 15 Aug 2024 22:07:20 +0930 Subject: [PATCH 38/82] refactor(itam): device now use details template #227 #240 --- app/itam/forms/device/device.py | 75 ++++++- app/itam/models/device.py | 6 + app/itam/templates/itam/device.html.j2 | 286 +++++++++++++++++-------- app/itam/views/device.py | 4 +- 4 files changed, 271 insertions(+), 100 deletions(-) diff --git a/app/itam/forms/device/device.py b/app/itam/forms/device/device.py index 78d3fcf0..72a14818 100644 --- a/app/itam/forms/device/device.py +++ b/app/itam/forms/device/device.py @@ -1,5 +1,6 @@ from django import forms from django.db.models import Q +from django.urls import reverse from app import settings @@ -27,11 +28,71 @@ class DeviceForm(CommonModelForm): ] + +class DetailForm(DeviceForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'device_model', + 'serial_number', + 'uuid', + 'device_type', + 'organization', + 'c_created', + 'c_modified', + 'lastinventory', + ], + "right": [ + 'model_notes', + ] + } + ] + }, + "software": { + "name": "Software", + "slug": "software", + "sections": [] + }, + "notes": { + "name": "Notes", + "slug": "notes", + "sections": [] + }, + "config_management": { + "name": "Config Management", + "slug": "config_management", + "sections": [] + } + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - if hasattr(kwargs['instance'], 'inventorydate'): - self.fields['lastinventory'] = forms.DateTimeField( + + 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['lastinventory'] = forms.DateTimeField( label="Last Inventory Date", input_formats=settings.DATETIME_FORMAT, initial=kwargs['instance'].inventorydate, @@ -39,5 +100,11 @@ class DeviceForm(CommonModelForm): required=False, ) - # for key in self.fields.keys(): - # self.fields[key].widget.attrs['disabled'] = True + self.tabs['details'].update({ + "edit_url": reverse('ITAM:_device_change', args=(self.instance.pk,)) + }) + + # self.model_name_plural = self.instance._meta.verbose_name_plural + + self.url_index_view = reverse('ITAM:Devices') + diff --git a/app/itam/models/device.py b/app/itam/models/device.py index 9fc4f885..5f8d842f 100644 --- a/app/itam/models/device.py +++ b/app/itam/models/device.py @@ -43,6 +43,12 @@ class DeviceType(DeviceCommonFieldsName, SaveHistory): class Device(DeviceCommonFieldsName, SaveHistory): + class Meta: + + abstract = False + + verbose_name_plural = 'Devices' + reserved_config_keys: list = [ 'software' diff --git a/app/itam/templates/itam/device.html.j2 b/app/itam/templates/itam/device.html.j2 index d79890d3..898e7828 100644 --- a/app/itam/templates/itam/device.html.j2 +++ b/app/itam/templates/itam/device.html.j2 @@ -1,81 +1,213 @@ -{% extends 'base.html.j2' %} +{% extends 'detail.html.j2' %} +{% load json %} {% load markdown %} -{% block title %}{{ device.name }}{% endblock %} -{% block content %} +{% block tabs %} +
+ {% csrf_token %} +
- -
- - - - - -
- {% csrf_token %} @@ -177,40 +309,6 @@ -
-

Operating System

-
- {{ operating_system.as_p }} - -
- -
-

Dependent Services

- - - - - - {% if services %} - {% for service in services %} - - - - - {% endfor%} - {% else %} - - - - {% endif %} -
NamePorts
{{ service }}{% for port in service.port.all %}{{ port }} ({{ port.description}}), {% endfor %}
Nothing Found
-
- -
-

Device Config

-
- -
{% if not tab %} diff --git a/app/itam/views/device.py b/app/itam/views/device.py index 88d25b79..f5b2e9f0 100644 --- a/app/itam/views/device.py +++ b/app/itam/views/device.py @@ -21,7 +21,7 @@ from core.views.common import AddView, ChangeView, DeleteView, IndexView from itam.forms.device_softwareadd import SoftwareAdd from itam.forms.device_softwareupdate import SoftwareUpdate -from itam.forms.device.device import DeviceForm +from itam.forms.device.device import DetailForm, DeviceForm from itam.forms.device.operating_system import Update as OperatingSystemForm from itim.models.services import Service @@ -79,7 +79,7 @@ class View(ChangeView): template_name = 'itam/device.html.j2' - form_class = DeviceForm + form_class = DetailForm context_object_name = "device" From 68c3b644243e3e58ffdbfb9e54a7ea300fcb4dbb Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 15 Aug 2024 22:08:05 +0930 Subject: [PATCH 39/82] fix(base): Use correct url for back button .#227 #240 --- app/itim/forms/services.py | 3 +++ app/templates/detail.html.j2 | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/itim/forms/services.py b/app/itim/forms/services.py index e591afb4..faaafee4 100644 --- a/app/itim/forms/services.py +++ b/app/itim/forms/services.py @@ -160,3 +160,6 @@ class DetailForm(ServiceForm): self.tabs['details'].update({ "edit_url": reverse('ITIM:_service_change', args=(self.instance.pk,)) }) + + self.url_index_view = reverse('ITAM:Services') + diff --git a/app/templates/detail.html.j2 b/app/templates/detail.html.j2 index bdb2477b..5fa6ee92 100644 --- a/app/templates/detail.html.j2 +++ b/app/templates/detail.html.j2 @@ -4,7 +4,7 @@
- - - - - - -
- - -
-

- Details - {% for external_link in external_links %} - {% include 'icons/external_link.html.j2' with external_link=external_link %} - {% endfor %} -

- {% csrf_token %} - {{ form }} -
- - - + {% include 'content/section.html.j2' with tab=form.tabs.details %}
-
-

Versions

- - - - - - - - - {% for version in software_versions %} - - - - - - - {% endfor %} -
VersionInstallationsVulnerable 
{{ version.name }}{{ version.installs }}{% include 'icons/issue_link.html.j2' with issue=3 %} 
-
-
-

Licences

- {% include 'icons/issue_link.html.j2' with issue=4 %} - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAvailable 
GPL-3Open Source1 / 5 
MITOpen SourceUnlimited 
Windows DeviceCAL11 / 15 
+
+ + {% include 'content/section.html.j2' with tab=form.tabs.versions %} + + + + + + + + + + + {% for version in software_versions %} + + + + + + + {% endfor %} +
VersionInstallationsVulnerable 
{{ version.name }}{{ version.installs }}{% include 'icons/issue_link.html.j2' with issue=3 %} 
+
-
-

- Notes -

+
+ + {% include 'content/section.html.j2' with tab=form.tabs.licences %} + + {% include 'icons/issue_link.html.j2' with issue=4 %} + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAvailable 
GPL-3Open Source1 / 5 
MITOpen SourceUnlimited 
Windows DeviceCAL11 / 15 
+ +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.notes %} + {{ notes_form }}
@@ -128,58 +90,61 @@ {% endif %}
-
- - -
-

Installations

- - - - - - - - - - {% if device_software %} - {% for device in device_software %} - - - - - - - - - {% endfor %} - {% else %} - - - - {% endif %} -
DeviceOrganizationActionInstalled VersionInstall Date 
{{ device.device }}{{ device.organization }} - {% if device.get_action_display == 'Install' %} - {% include 'icons/success_text.html.j2' with icon_text=device.get_action_display %} - {% elif device.get_action_display == 'Remove'%} - {% include 'icons/cross_text.html.j2' with icon_text=device.get_action_display %} - {% else %} - - - {% endif %} - - {% if device.installedversion %} - {{ device.installedversion }} - {% else %} - - - {% endif %} - - {% if device.installed %} - {{ device.installed }} - {% else %} - - - {% endif %} -  
Nothing Found
- -{% endblock %} \ No newline at end of file + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.installations %} + + + + + + + + + + + {% if device_software %} + {% for device in device_software %} + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
DeviceOrganizationActionInstalled VersionInstall Date 
{{ device.device }}{{ device.organization }} + {% if device.get_action_display == 'Install' %} + {% include 'icons/success_text.html.j2' with icon_text=device.get_action_display %} + {% elif device.get_action_display == 'Remove'%} + {% include 'icons/cross_text.html.j2' with icon_text=device.get_action_display %} + {% else %} + - + {% endif %} + + {% if device.installedversion %} + {{ device.installedversion }} + {% else %} + - + {% endif %} + + {% if device.installed %} + {{ device.installed }} + {% else %} + - + {% endif %} +  
Nothing Found
+ +
+ + +{% endblock %} diff --git a/app/itam/urls.py b/app/itam/urls.py index 7a910b4d..00e6190f 100644 --- a/app/itam/urls.py +++ b/app/itam/urls.py @@ -30,6 +30,7 @@ urlpatterns = [ path("software/", software.IndexView.as_view(), name="Software"), path("software//", software.View.as_view(), name="_software_view"), + path("software//change", software.Change.as_view(), name="_software_change"), path("software//delete", software.Delete.as_view(), name="_software_delete"), path("software//version/add", software_version.Add.as_view(), name="_software_version_add"), path("software//version/", software_version.View.as_view(), name="_software_version_view"), diff --git a/app/itam/views/software.py b/app/itam/views/software.py index 1ab04f40..e3fb2406 100644 --- a/app/itam/views/software.py +++ b/app/itam/views/software.py @@ -9,7 +9,7 @@ from core.views.common import AddView, ChangeView, DeleteView, IndexView from itam.models.device import DeviceSoftware from itam.models.software import Software, SoftwareVersion -from itam.forms.software.update import SoftwareForm, SoftwareFormUpdate +from itam.forms.software.update import DetailForm, SoftwareForm, SoftwareChange from settings.models.user_settings import UserSettings @@ -51,11 +51,39 @@ class IndexView(IndexView): + +class Change(ChangeView): + + model = Software + + permission_required = [ + 'itam.change_device', + ] + + template_name = 'form.html.j2' + + form_class = SoftwareChange + + + def get_success_url(self, **kwargs): + + return reverse('ITAM:_software_view', args=(self.kwargs['pk'],)) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Edit ' + self.object.name + + return context + + + class View(ChangeView): context_object_name = "software" - form_class = SoftwareFormUpdate + form_class = DetailForm model = Software From a8262e0a54e9a5d568674c3f6863aad1c01be3b5 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 00:45:12 +0930 Subject: [PATCH 44/82] test(itam): Correct software permissions test to use "change" view #240 #233 --- app/itam/forms/software/update.py | 5 ----- app/itam/tests/unit/software/test_software_permission.py | 2 +- app/itam/views/software.py | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/itam/forms/software/update.py b/app/itam/forms/software/update.py index 9ddece31..77679d2e 100644 --- a/app/itam/forms/software/update.py +++ b/app/itam/forms/software/update.py @@ -101,11 +101,6 @@ class DetailForm(SoftwareForm): initial = self.instance.organization ) - - if not self.instance.is_global: - - self.fields['is_global'].widget.attrs['disabled'] = True - self.fields['c_created'] = forms.DateTimeField( label = 'Created', input_formats=settings.DATETIME_FORMAT, diff --git a/app/itam/tests/unit/software/test_software_permission.py b/app/itam/tests/unit/software/test_software_permission.py index 3ba81080..56780472 100644 --- a/app/itam/tests/unit/software/test_software_permission.py +++ b/app/itam/tests/unit/software/test_software_permission.py @@ -26,7 +26,7 @@ class SoftwarePermissions(TestCase, ModelPermissions): url_name_add = '_software_add' - url_name_change = '_software_view' + url_name_change = '_software_change' url_name_delete = '_software_delete' diff --git a/app/itam/views/software.py b/app/itam/views/software.py index e3fb2406..5e6b1683 100644 --- a/app/itam/views/software.py +++ b/app/itam/views/software.py @@ -57,7 +57,7 @@ class Change(ChangeView): model = Software permission_required = [ - 'itam.change_device', + 'itam.change_software', ] template_name = 'form.html.j2' From 12abc741d218d05253563e44678a21fe04b7c9eb Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 14:14:41 +0930 Subject: [PATCH 45/82] refactor(itam): manufacturer now uses details template #242 closes #232 --- app/core/forms/manufacturer.py | 64 ++++++++++++++++++++ app/core/templates/core/manufacturer.html.j2 | 34 +++++++++++ app/settings/urls.py | 1 + app/settings/views/manufacturer.py | 45 +++++++++++--- 4 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 app/core/templates/core/manufacturer.html.j2 diff --git a/app/core/forms/manufacturer.py b/app/core/forms/manufacturer.py index ffd3c72f..9a3b4401 100644 --- a/app/core/forms/manufacturer.py +++ b/app/core/forms/manufacturer.py @@ -1,4 +1,7 @@ from django import forms +from django.urls import reverse + +from app import settings from core.forms.common import CommonModelForm from core.models.manufacturer import Manufacturer @@ -24,3 +27,64 @@ class ManufacturerForm( ] model = Manufacturer + + + +class DetailForm(ManufacturerForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'slug', + 'organization', + 'is_global', + 'c_created', + 'c_modified', + ], + "right": [ + 'model_notes', + ] + } + ] + }, + # "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:_manufacturer_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Settings:_manufacturers') + + diff --git a/app/core/templates/core/manufacturer.html.j2 b/app/core/templates/core/manufacturer.html.j2 new file mode 100644 index 00000000..983853ef --- /dev/null +++ b/app/core/templates/core/manufacturer.html.j2 @@ -0,0 +1,34 @@ +{% extends 'detail.html.j2' %} + +{% load json %} +{% load markdown %} + + +{% block tabs %} +
+ {% csrf_token %} +
+ + {% include 'content/section.html.j2' with tab=form.tabs.details %} + +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.notes %} + + {{ notes_form }} + +
+ {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ +
+{% endblock %} diff --git a/app/settings/urls.py b/app/settings/urls.py index 6d6f0525..38fe60af 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -51,6 +51,7 @@ urlpatterns = [ path("manufacturers", manufacturer.Index.as_view(), name="_manufacturers"), path("manufacturer/", manufacturer.View.as_view(), name="_manufacturer_view"), path("manufacturer/add/", manufacturer.Add.as_view(), name="_manufacturer_add"), + path("manufacturer//edit", manufacturer.Change.as_view(), name="_manufacturer_change"), path("manufacturer//delete", manufacturer.Delete.as_view(), name="_manufacturer_delete"), path("ports", ports.Index.as_view(), name="_ports"), diff --git a/app/settings/views/manufacturer.py b/app/settings/views/manufacturer.py index 90c5ab16..ee8d6a09 100644 --- a/app/settings/views/manufacturer.py +++ b/app/settings/views/manufacturer.py @@ -6,7 +6,7 @@ from django.views import generic from access.mixin import OrganizationPermission -from core.forms.manufacturer import ManufacturerForm +from core.forms.manufacturer import DetailForm, ManufacturerForm from core.models.manufacturer import Manufacturer from core.views.common import AddView, ChangeView, DeleteView, IndexView @@ -37,7 +37,7 @@ class Index(IndexView): -class View(ChangeView): +class Change(ChangeView): context_object_name = "manufacturer" @@ -46,13 +46,46 @@ class View(ChangeView): model = Manufacturer permission_required = [ - 'core.view_manufacturer', 'core.change_manufacturer', ] template_name = 'form.html.j2' + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = self.object.name + + return context + + def get_success_url(self, **kwargs): + + return f"/settings/manufacturer/{self.kwargs['pk']}" + + + @method_decorator(auth_decorator.permission_required("core.change_manufacturer", raise_exception=True)) + def post(self, request, *args, **kwargs): + + return super().post(request, *args, **kwargs) + + + +class View(ChangeView): + + context_object_name = "manufacturer" + + form_class = DetailForm + + model = Manufacturer + + permission_required = [ + 'core.view_manufacturer', + ] + + template_name = 'core/manufacturer.html.j2' + + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -70,12 +103,6 @@ class View(ChangeView): return f"/settings/manufacturer/{self.kwargs['pk']}" - @method_decorator(auth_decorator.permission_required("core.change_manufacturer", raise_exception=True)) - def post(self, request, *args, **kwargs): - - return super().post(request, *args, **kwargs) - - class Add(AddView): From d41cc312bb1e3f2581c46342721cd4875cb4c859 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 14:17:00 +0930 Subject: [PATCH 46/82] test(core): Correct manufacturer permissions test to use "change" view .#242 #232 --- .../tests/unit/manufacturer/test_manufacturer_permission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/tests/unit/manufacturer/test_manufacturer_permission.py b/app/core/tests/unit/manufacturer/test_manufacturer_permission.py index ed641ad9..6f3a7db9 100644 --- a/app/core/tests/unit/manufacturer/test_manufacturer_permission.py +++ b/app/core/tests/unit/manufacturer/test_manufacturer_permission.py @@ -26,7 +26,7 @@ class ManufacturerPermissions(TestCase, ModelPermissions): url_name_add = '_manufacturer_add' - url_name_change = '_manufacturer_view' + url_name_change = '_manufacturer_change' url_name_delete = '_manufacturer_delete' From 4a4c8e94e444b11aa38b1b36ebe6e4409eecfd3e Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 14:28:16 +0930 Subject: [PATCH 47/82] refactor(itam): Software Categories now uses details template #242 closes #236 --- app/itam/forms/software_category.py | 63 +++- app/itam/templates/itam/device.html.j2 | 279 ------------------ .../itam/software_categories.html.j2 | 150 ++++++++++ app/itam/views/software_category.py | 37 ++- app/settings/urls.py | 1 + 5 files changed, 247 insertions(+), 283 deletions(-) create mode 100644 app/itam/templates/itam/software_categories.html.j2 diff --git a/app/itam/forms/software_category.py b/app/itam/forms/software_category.py index d0218cd1..755cfbd9 100644 --- a/app/itam/forms/software_category.py +++ b/app/itam/forms/software_category.py @@ -1,4 +1,7 @@ -from django.db.models import Q +from django import forms +from django.urls import reverse + +from app import settings from core.forms.common import CommonModelForm @@ -25,3 +28,61 @@ class SoftwareCategoryForm( ] model = SoftwareCategory + + + +class DetailForm(SoftwareCategoryForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'slug', + 'organization', + 'is_global', + 'c_created', + 'c_modified', + ], + "right": [ + 'model_notes', + ] + } + ] + }, + # "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:_software_category_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Settings:_software_categories') diff --git a/app/itam/templates/itam/device.html.j2 b/app/itam/templates/itam/device.html.j2 index 898e7828..e73c4285 100644 --- a/app/itam/templates/itam/device.html.j2 +++ b/app/itam/templates/itam/device.html.j2 @@ -189,282 +189,3 @@
{% endblock %} - - - - - - - - - - - - - - - - - -{% block contents %} - -
- {% csrf_token %} - - -
-

- Details - {% for external_link in external_links %} - {% include 'icons/external_link.html.j2' with external_link=external_link %} - {% endfor %} -

-
- -
- -
- - {{ form.name.value }} -
- -
- - - {% if device.device_model %} - {{ device.device_model }} - {% else %} -   - {% endif %} - -
- -
- - - {% if form.serial_number.value %} - {{ form.serial_number.value }} - {% else %} -   - {% endif %} - -
- -
- - - {% if form.uuid.value %} - {{ form.uuid.value }} - {% else %} -   - {% endif %} - -
- -
- - - {% if device.device_type %} - {{ device.device_type }} - {% else %} -   - {% endif %} - -
- -
- - {{ device.organization }} -
- -
- - - {% if form.lastinventory.value %} - {{ form.lastinventory.value }} - {% else %} -   - {% endif %} - -
- -
- -
-
- - -
- {% if form.model_notes.value %} - {{ form.model_notes.value | markdown | safe }} - {% else %} -   - {% endif %} -
-
-
-
- - - - - - - - {% if not tab %} - - {% endif %} - -
- - -
-

Software

-
- Installed Software: {{ installed_software }} - - - - - - - - - - - - {% if softwares %} - {% for software in softwares %} - - - - - - - - - - {% endfor %} - {% else %} - - {% endif %} -
NameCategoryActionDesired VersionInstalled VersionInstalled 
{{ software.software }}{{ software.software.category }} - {% url 'ITAM:_device_software_view' device_id=device.id pk=software.id as icon_link %} - {% if software.get_action_display == 'Install' %} - {% include 'icons/success_text.html.j2' with icon_text=software.get_action_display icon_link=icon_link %} - {% elif software.get_action_display == 'Remove'%} - {% include 'icons/cross_text.html.j2' with icon_text=software.get_action_display icon_link=icon_link %} - {% else %} - {% include 'icons/add_link.html.j2' with icon_text='Add' icon_link=icon_link %} - {% endif %} - - {% if software.version %} - {{ software.version }} - {% else %} - - - {% endif %} - - {% if software.installedversion %} - {{ software.installedversion }} - {% else %} - - - {% endif %} - - {% if software.installed %} - {{ software.installed }} - {% else %} - - - {% endif %} -  
Nothing Found
- - - - {% if tab == 'software' %} - - {% endif %} -
- - -
-

- Notes -

- {{ notes_form }} - -
- {% if notes %} - {% for note in notes%} - {% include 'note.html.j2' %} - {% endfor %} - {% endif %} -
- - {% if tab == 'notes' %} - - {% endif %} -
- - -
-

Configuration Management

-
- -
-
-
- - - - - - - {% if config_groups %} - {% for group in config_groups %} - - - - - - {% endfor %} - {% else %} - - - - {% endif %} -
GroupAdded 
{{ group.group }}{{ group.created }}Delete
Nothing Found
- - {% if tab == 'configmanagement' %} - - {% endif %} -
- -
-{% endblock %} \ No newline at end of file diff --git a/app/itam/templates/itam/software_categories.html.j2 b/app/itam/templates/itam/software_categories.html.j2 new file mode 100644 index 00000000..7ff75bc9 --- /dev/null +++ b/app/itam/templates/itam/software_categories.html.j2 @@ -0,0 +1,150 @@ +{% extends 'detail.html.j2' %} + +{% load json %} +{% load markdown %} + + +{% block tabs %} +
+ {% csrf_token %} +
+ + {% include 'content/section.html.j2' with tab=form.tabs.details %} + +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.versions %} + + + + + + + + + + + {% for version in software_versions %} + + + + + + + {% endfor %} +
VersionInstallationsVulnerable 
{{ version.name }}{{ version.installs }}{% include 'icons/issue_link.html.j2' with issue=3 %} 
+ +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.licences %} + + {% include 'icons/issue_link.html.j2' with issue=4 %} + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAvailable 
GPL-3Open Source1 / 5 
MITOpen SourceUnlimited 
Windows DeviceCAL11 / 15 
+ +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.notes %} + + {{ notes_form }} + +
+ {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.installations %} + + + + + + + + + + + {% if device_software %} + {% for device in device_software %} + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
DeviceOrganizationActionInstalled VersionInstall Date 
{{ device.device }}{{ device.organization }} + {% if device.get_action_display == 'Install' %} + {% include 'icons/success_text.html.j2' with icon_text=device.get_action_display %} + {% elif device.get_action_display == 'Remove'%} + {% include 'icons/cross_text.html.j2' with icon_text=device.get_action_display %} + {% else %} + - + {% endif %} + + {% if device.installedversion %} + {{ device.installedversion }} + {% else %} + - + {% endif %} + + {% if device.installed %} + {{ device.installed }} + {% else %} + - + {% endif %} +  
Nothing Found
+ +
+ +
+{% endblock %} diff --git a/app/itam/views/software_category.py b/app/itam/views/software_category.py index d0811ef9..ab159fdc 100644 --- a/app/itam/views/software_category.py +++ b/app/itam/views/software_category.py @@ -4,13 +4,13 @@ from django.utils.decorators import method_decorator from core.views.common import AddView, ChangeView, DeleteView -from itam.forms.software_category import SoftwareCategoryForm +from itam.forms.software_category import DetailForm, SoftwareCategoryForm from itam.models.software import Software, SoftwareCategory from settings.models.user_settings import UserSettings -class View(ChangeView): +class Change(ChangeView): context_object_name = "software" @@ -19,7 +19,6 @@ class View(ChangeView): model = SoftwareCategory permission_required = [ - 'itam.view_softwarecategory', 'itam.change_softwarecategory', ] @@ -48,6 +47,38 @@ class View(ChangeView): +class View(ChangeView): + + context_object_name = "software" + + form_class = DetailForm + + model = SoftwareCategory + + permission_required = [ + 'itam.view_softwarecategory', + ] + + template_name = 'itam/software_categories.html.j2' + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['model_delete_url'] = reverse('Settings:_software_category_delete', args=(self.kwargs['pk'],)) + + context['content_title'] = self.object.name + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_software_category_view', args=(self.kwargs['pk'],)) + + + + class Add(AddView): form_class = SoftwareCategoryForm diff --git a/app/settings/urls.py b/app/settings/urls.py index 38fe60af..a9a5f7bd 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -46,6 +46,7 @@ urlpatterns = [ path("software_category", software_categories.Index.as_view(), name="_software_categories"), path("software_category/", software_category.View.as_view(), name="_software_category_view"), path("software_category/add/", software_category.Add.as_view(), name="_software_category_add"), + 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("manufacturers", manufacturer.Index.as_view(), name="_manufacturers"), From 4391aa3ea89c2025b0fd43edfa9e037fc871d555 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 14:29:32 +0930 Subject: [PATCH 48/82] test(itam): Correct Software Category permissions test to use "change" view #242 #236 --- .../unit/software_category/test_software_category_permission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/itam/tests/unit/software_category/test_software_category_permission.py b/app/itam/tests/unit/software_category/test_software_category_permission.py index b33e0c30..48d83d1f 100644 --- a/app/itam/tests/unit/software_category/test_software_category_permission.py +++ b/app/itam/tests/unit/software_category/test_software_category_permission.py @@ -29,7 +29,7 @@ class SoftwareCategoryPermissions(TestCase, ModelPermissions): url_name_add = '_software_category_add' - url_name_change = '_software_category_view' + url_name_change = '_software_category_change' url_name_delete = '_software_category_delete' From 28259b329e41c98b2214f66c2ce3f48b9db7d0eb Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 15:00:48 +0930 Subject: [PATCH 49/82] refactor(config_management): Config Groups now uses details template #242 closes #230 --- app/config_management/forms/group/group.py | 91 +++++- .../templates/config_management/group.html.j2 | 292 ++++++++++-------- app/config_management/urls.py | 3 +- app/config_management/views/groups/groups.py | 35 ++- 4 files changed, 294 insertions(+), 127 deletions(-) diff --git a/app/config_management/forms/group/group.py b/app/config_management/forms/group/group.py index 8199d4b3..1b31e0fd 100644 --- a/app/config_management/forms/group/group.py +++ b/app/config_management/forms/group/group.py @@ -1,4 +1,7 @@ -from django.db.models import Q +from django import forms +from django.urls import reverse + +from app import settings from config_management.models.groups import ConfigGroups @@ -32,3 +35,89 @@ class ConfigGroupForm(CommonModelForm): ).exclude( id=int(kwargs['instance'].id) ) + + + +class DetailForm(ConfigGroupForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'parent', + 'is_global', + 'organization', + 'c_created', + 'c_modified', + ], + "right": [ + 'model_notes', + ] + }, + { + "layout": "single", + "fields": [ + 'config', + ] + } + ] + }, + "child_groups": { + "name": "Child Groups", + "slug": "child_groups", + "sections": [] + }, + "hosts": { + "name": "Hosts", + "slug": "hosts", + "sections": [] + }, + "software": { + "name": "Software", + "slug": "software", + "sections": [] + }, + "configuration": { + "name": "Configuration", + "slug": "configuration", + "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.tabs['details'].update({ + "edit_url": reverse('Config Management:_group_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Config Management:Groups') + diff --git a/app/config_management/templates/config_management/group.html.j2 b/app/config_management/templates/config_management/group.html.j2 index de30b9d4..1b401c30 100644 --- a/app/config_management/templates/config_management/group.html.j2 +++ b/app/config_management/templates/config_management/group.html.j2 @@ -1,47 +1,180 @@ -{% extends 'base.html.j2' %} +{% extends 'detail.html.j2' %} -{% block content %} +{% load json %} +{% load markdown %} - - -
- - - - - - - - + {% include 'content/section.html.j2' with tab=form.tabs.details %}
+ +
+ + {% include 'content/section.html.j2' with tab=form.tabs.child_groups %} + + + + + + + + + + {% if child_groups %} + {% for group in child_groups %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
NameSub-Groups 
{{ group.name }}{{ group.count_children }} 
Nothing Found
+
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.hosts %} + + + + + + + + + + {% if config_group_hosts %} + {% for host in config_group_hosts %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
NameOrganization 
{{ host.host }}{{ host.host.organization }}Delete
Nothing Found
+
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.software %} + + + + + + + + + + + + {% if softwares %} + {% for software in softwares %} + + + + + + + + {% endfor %} + {% else %} + + {% endif %} +
NameCategoryActionDesired Version 
{{ software.software }}{{ software.software.category }} + {% url 'Config Management:_group_software_change' group_id=group.id pk=software.id as icon_link %} + {% if software.get_action_display == 'Install' %} + {% include 'icons/success_text.html.j2' with icon_text=software.get_action_display icon_link=icon_link %} + {% elif software.get_action_display == 'Remove'%} + {% include 'icons/cross_text.html.j2' with icon_text=software.get_action_display %} + {% else %} + {% include 'icons/add_link.html.j2' with icon_text='Add' %} + {% endif %} + + {% if software.version %} + {{ software.version }} + {% else %} + - + {% endif %} +  
Nothing Found
+ +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.configuration %} + +
+ +
+
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.notes %} + + {{ notes_form }} + +
+ {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ + + +{% endblock %} + + + + + + + + + + + + + + + + + + + + + + + +{% block contents %} + +

Details

@@ -60,28 +193,6 @@

Child Groups

- - - - - - - - - {% if child_groups %} - {% for group in child_groups %} - - - - - - {% endfor %} - {% else %} - - - - {% endif %} -
NameSub-Groups 
{{ group.name }}{{ group.count_children }} 
Nothing Found
@@ -90,28 +201,6 @@ Hosts - - - - - - - - - {% if config_group_hosts %} - {% for host in config_group_hosts %} - - - - - - {% endfor %} - {% else %} - - - - {% endif %} -
NameOrganization 
{{ host.host }}{{ host.host.organization }}Delete
Nothing Found
@@ -120,52 +209,11 @@ Software - - - - - - - - - - {% if softwares %} - {% for software in softwares %} - - - - - - - - {% endfor %} - {% else %} - - {% endif %} -
NameCategoryActionDesired Version 
{{ software.software }}{{ software.software.category }} - {% url 'Config Management:_group_software_change' group_id=group.id pk=software.id as icon_link %} - {% if software.get_action_display == 'Install' %} - {% include 'icons/success_text.html.j2' with icon_text=software.get_action_display icon_link=icon_link %} - {% elif software.get_action_display == 'Remove'%} - {% include 'icons/cross_text.html.j2' with icon_text=software.get_action_display %} - {% else %} - {% include 'icons/add_link.html.j2' with icon_text='Add' %} - {% endif %} - - {% if software.version %} - {{ software.version }} - {% else %} - - - {% endif %} -  
Nothing Found
-

Configuration

-
- -
+
diff --git a/app/config_management/urls.py b/app/config_management/urls.py index b04f1e90..981ab628 100644 --- a/app/config_management/urls.py +++ b/app/config_management/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from config_management.views.groups.groups import GroupIndexView, GroupAdd, GroupDelete, GroupView, GroupHostAdd, GroupHostDelete +from config_management.views.groups.groups import GroupIndexView, GroupAdd, GroupChange, GroupDelete, GroupView, GroupHostAdd, GroupHostDelete from config_management.views.groups.software import GroupSoftwareAdd, GroupSoftwareChange, GroupSoftwareDelete app_name = "Config Management" @@ -9,6 +9,7 @@ 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//child', GroupAdd.as_view(), name='_group_add_child'), path('group//delete', GroupDelete.as_view(), name='_group_delete'), diff --git a/app/config_management/views/groups/groups.py b/app/config_management/views/groups/groups.py index 5ff4e655..be7be109 100644 --- a/app/config_management/views/groups/groups.py +++ b/app/config_management/views/groups/groups.py @@ -13,7 +13,7 @@ from itam.models.device import Device from settings.models.user_settings import UserSettings from config_management.forms.group_hosts import ConfigGroupHostsForm -from config_management.forms.group.group import ConfigGroupForm +from config_management.forms.group.group import ConfigGroupForm, DetailForm from config_management.models.groups import ConfigGroups, ConfigGroupHosts, ConfigGroupSoftware @@ -102,7 +102,7 @@ class GroupAdd(AddView): -class GroupView(ChangeView): +class GroupChange(ChangeView): context_object_name = "group" @@ -111,10 +111,39 @@ class GroupView(ChangeView): model = ConfigGroups permission_required = [ - 'config_management.view_configgroups', 'config_management.change_configgroups', ] + template_name = 'form.html.j2' + + + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['content_title'] = self.object.name + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Config Management:_group_view', args=(self.kwargs['pk'],)) + + + +class GroupView(ChangeView): + + context_object_name = "group" + + form_class = DetailForm + + model = ConfigGroups + + permission_required = [ + 'config_management.view_configgroups', + ] + template_name = 'config_management/group.html.j2' From 47b2e61987d92e8420cda4e059711e61ae01b952 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 15:01:17 +0930 Subject: [PATCH 50/82] test(config_management): Correct Config Group permissions test to use "change" view #242 #230 --- .../tests/unit/config_groups/test_config_groups_permission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config_management/tests/unit/config_groups/test_config_groups_permission.py b/app/config_management/tests/unit/config_groups/test_config_groups_permission.py index f5d0be0c..b6ab681c 100644 --- a/app/config_management/tests/unit/config_groups/test_config_groups_permission.py +++ b/app/config_management/tests/unit/config_groups/test_config_groups_permission.py @@ -27,7 +27,7 @@ class ConfigGroupPermissions(TestCase, ModelPermissions): url_name_add = '_group_add' - url_name_change = '_group_view' + url_name_change = '_group_change' url_name_delete = '_group_delete' From 6a0b507c3ba8cf1cb228da6466d4bd26a68b4b49 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 16:24:50 +0930 Subject: [PATCH 51/82] refactor(itam): Device Model now uses details template #242 closes #235 --- app/itam/forms/device/device.py | 2 - app/itam/forms/device_model.py | 64 +++++++++++++++++++- app/itam/templates/itam/device_model.html.j2 | 34 +++++++++++ app/itam/views/device_model.py | 33 +++++++++- app/settings/urls.py | 1 + 5 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 app/itam/templates/itam/device_model.html.j2 diff --git a/app/itam/forms/device/device.py b/app/itam/forms/device/device.py index fb42633b..16e04940 100644 --- a/app/itam/forms/device/device.py +++ b/app/itam/forms/device/device.py @@ -1,5 +1,4 @@ from django import forms -from django.db.models import Q from django.urls import reverse from app import settings @@ -105,4 +104,3 @@ class DetailForm(DeviceForm): }) self.url_index_view = reverse('ITAM:Devices') - diff --git a/app/itam/forms/device_model.py b/app/itam/forms/device_model.py index c2841f77..1fd76d55 100644 --- a/app/itam/forms/device_model.py +++ b/app/itam/forms/device_model.py @@ -1,4 +1,7 @@ -from django.db.models import Q +from django import forms +from django.urls import reverse + +from app import settings from core.forms.common import CommonModelForm @@ -27,3 +30,62 @@ class DeviceModelForm( ] model = DeviceModel + + + +class DetailForm(DeviceModelForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'slug', + 'manufacturer', + 'organization', + 'is_global', + 'c_created', + 'c_modified', + ], + "right": [ + 'model_notes', + ] + } + ] + }, + # "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:_device_model_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Settings:_device_models') diff --git a/app/itam/templates/itam/device_model.html.j2 b/app/itam/templates/itam/device_model.html.j2 new file mode 100644 index 00000000..983853ef --- /dev/null +++ b/app/itam/templates/itam/device_model.html.j2 @@ -0,0 +1,34 @@ +{% extends 'detail.html.j2' %} + +{% load json %} +{% load markdown %} + + +{% block tabs %} + + {% csrf_token %} +
+ + {% include 'content/section.html.j2' with tab=form.tabs.details %} + +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.notes %} + + {{ notes_form }} + +
+ {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ + +{% endblock %} diff --git a/app/itam/views/device_model.py b/app/itam/views/device_model.py index d45d0a87..087d329d 100644 --- a/app/itam/views/device_model.py +++ b/app/itam/views/device_model.py @@ -2,7 +2,7 @@ from django.contrib.auth import decorators as auth_decorator from django.urls import reverse from django.utils.decorators import method_decorator -from itam.forms.device_model import DeviceModelForm +from itam.forms.device_model import DetailForm, DeviceModelForm from itam.models.device_models import DeviceModel from core.views.common import AddView, ChangeView, DeleteView @@ -11,7 +11,7 @@ from settings.models.user_settings import UserSettings -class View(ChangeView): +class Change(ChangeView): form_class = DeviceModelForm @@ -20,13 +20,40 @@ class View(ChangeView): model = DeviceModel permission_required = [ - 'itam.view_devicemodel', 'itam.change_devicemodel', ] template_name = 'form.html.j2' + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = self.object.name + + return context + + def get_success_url(self, **kwargs): + + return reverse('Settings:_device_model_view', args=(self.kwargs['pk'],)) + + + +class View(ChangeView): + + form_class = DetailForm + + context_object_name = "device_model" + + model = DeviceModel + + permission_required = [ + 'itam.view_devicemodel', + ] + + template_name = 'itam/device_model.html.j2' + + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/app/settings/urls.py b/app/settings/urls.py index a9a5f7bd..70083b2e 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -29,6 +29,7 @@ urlpatterns = [ path("device_models", device_models.Index.as_view(), name="_device_models"), path("device_model/", device_model.View.as_view(), name="_device_model_view"), + path("device_model//edit", device_model.Change.as_view(), name="_device_model_change"), path("device_model/add/", device_model.Add.as_view(), name="_device_model_add"), path("device_model//delete", device_model.Delete.as_view(), name="_device_model_delete"), From fed6eee951be213f1de770805b6cb1a358778004 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 16:25:07 +0930 Subject: [PATCH 52/82] test(config_management): Correct Device Model permissions test to use "change" view #242 #235 --- .../tests/unit/device_model/test_device_model_permission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/itam/tests/unit/device_model/test_device_model_permission.py b/app/itam/tests/unit/device_model/test_device_model_permission.py index 9becd507..27048a5a 100644 --- a/app/itam/tests/unit/device_model/test_device_model_permission.py +++ b/app/itam/tests/unit/device_model/test_device_model_permission.py @@ -30,7 +30,7 @@ class DeviceModelPermissions(TestCase, ModelPermissions): url_name_add = '_device_model_add' - url_name_change = '_device_model_view' + url_name_change = '_device_model_change' url_name_delete = '_device_model_delete' From eb4df77614c5fef566b6ee13ab5f950c6d44a676 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 16:39:07 +0930 Subject: [PATCH 53/82] refactor(itim): Service Port now uses details template #242 closes #238 --- app/itim/forms/ports.py | 70 ++++++++- app/itim/templates/itim/port.html.j2 | 215 +++++---------------------- app/itim/views/ports.py | 4 +- 3 files changed, 106 insertions(+), 183 deletions(-) diff --git a/app/itim/forms/ports.py b/app/itim/forms/ports.py index dc128df7..8bc6935c 100644 --- a/app/itim/forms/ports.py +++ b/app/itim/forms/ports.py @@ -1,8 +1,8 @@ -# from django import forms -# from django.forms import ValidationError +from django import forms +from django.urls import reverse -# from app import settings +from app import settings from itim.models.services import Port @@ -22,3 +22,67 @@ class PortForm(CommonModelForm): model = Port prefix = 'port' + + + +class DetailForm(PortForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'number', + 'description', + 'protocol', + 'organization', + 'c_created', + 'c_modified', + 'lastinventory', + ], + "right": [ + 'model_notes', + ] + } + ] + }, + "services": { + "name": "Services", + "slug": "services", + "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.tabs['details'].update({ + "edit_url": reverse('Settings:_port_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Settings:_ports') diff --git a/app/itim/templates/itim/port.html.j2 b/app/itim/templates/itim/port.html.j2 index 02a0db7c..eb18d2c1 100644 --- a/app/itim/templates/itim/port.html.j2 +++ b/app/itim/templates/itim/port.html.j2 @@ -1,196 +1,55 @@ -{% extends 'base.html.j2' %} +{% extends 'detail.html.j2' %} +{% load json %} {% load markdown %} -{% block content %} - - - -
- - - - - {% if perms.assistance.change_service %} - - {% endif %}
-
-
-

Details

- {% csrf_token %} +
+ + {% include 'content/section.html.j2' with tab=form.tabs.services %} + + + + + + + {% for service in services %} + + + + + {% endfor%} +
NameOrganization
{{ service.name }}{{ service.organization }}
+ +
-
-
+
-
- - {{ form.number.value }} -
- -
- - - {% if form.description.value %} - {{ form.description.value }} - {% else %} -   - {% endif %} - -
- -
- - {{ form.protocol.value }} -
- -
- - {{ item.organization }} -
- -
- - {{ item.created }} -
- -
- - {{ item.modified }} -
- - -
- -
-
- - -
- {% if form.model_notes.value %} - {{ form.model_notes.value | markdown | safe }} - {% else %} -   - {% endif %} -
-
-
- -
- - - - -
- - + {% include 'content/section.html.j2' with tab=form.tabs.notes %} + {{ notes_form }} + +
+ {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %}
-
-

- Services -

- - - - - - - {% for service in services %} - - - - - {% endfor%} -
NameOrganization
{{ service.name }}{{ service.organization }}
-
- -
- - {% if perms.assistance.change_knowledgebase %} -
-

- Notes -

- {{ notes_form }} - -
- {% if notes %} - {% for note in notes %} - {% include 'note.html.j2' %} - {% endfor %} - {% endif %} -
- -
- {% endif %} +
- -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/itim/views/ports.py b/app/itim/views/ports.py index 89a8cb86..99ef0184 100644 --- a/app/itim/views/ports.py +++ b/app/itim/views/ports.py @@ -6,7 +6,7 @@ from core.forms.comment import AddNoteForm from core.models.notes import Notes from core.views.common import AddView, ChangeView, DeleteView, IndexView -from itim.forms.ports import PortForm +from itim.forms.ports import DetailForm, PortForm from itim.models.services import Port, Service from settings.models.user_settings import UserSettings @@ -138,7 +138,7 @@ class View(ChangeView): context_object_name = "item" - form_class = PortForm + form_class = DetailForm model = Port From 8c1f033b1c1f34b62a2a3255b32bff06c9add126 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 17:00:47 +0930 Subject: [PATCH 54/82] refactor(itam): Operating System now uses details template #242 closes #229 --- app/itam/forms/operating_system/update.py | 100 ++++++-- .../templates/itam/operating_system.html.j2 | 234 ++++++++---------- app/itam/urls.py | 1 + app/itam/views/operating_system.py | 178 ++++++++----- 4 files changed, 301 insertions(+), 212 deletions(-) diff --git a/app/itam/forms/operating_system/update.py b/app/itam/forms/operating_system/update.py index a050abe8..d398c65e 100644 --- a/app/itam/forms/operating_system/update.py +++ b/app/itam/forms/operating_system/update.py @@ -1,5 +1,5 @@ from django import forms -from django.db.models import Q +from django.urls import reverse from app import settings @@ -9,7 +9,7 @@ from itam.models.operating_system import OperatingSystem -class OperatingSystemFormCommon(CommonModelForm): +class OperatingSystemForm(CommonModelForm): class Meta: @@ -27,27 +27,99 @@ class OperatingSystemFormCommon(CommonModelForm): -class Update(OperatingSystemFormCommon): +# class Update(OperatingSystemFormCommon): + + +# def __init__(self, *args, **kwargs): +# super().__init__(*args, **kwargs) + +# self.fields['_created'] = forms.DateTimeField( +# label="Created", +# input_formats=settings.DATETIME_FORMAT, +# initial=kwargs['instance'].created, +# disabled=True +# ) + +# self.fields['_modified'] = forms.DateTimeField( +# label="Modified", +# input_formats=settings.DATETIME_FORMAT, +# initial=kwargs['instance'].modified, +# disabled=True +# ) + + +# if kwargs['instance'].is_global: + +# self.fields['is_global'].widget.attrs['disabled'] = True + + +class DetailForm(OperatingSystemForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'publisher', + 'serial_number', + 'organization', + 'c_created', + 'c_modified', + ], + "right": [ + 'model_notes', + ] + } + ] + }, + "versions": { + "name": "Versions", + "slug": "versions", + "sections": [] + }, + "licences": { + "name": "Licences", + "slug": "licences", + "sections": [] + }, + "installations": { + "name": "Installations", + "slug": "installations", + "sections": [] + }, + "notes": { + "name": "Notes", + "slug": "notes", + "sections": [] + } + } def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - self.fields['_created'] = forms.DateTimeField( - label="Created", + + self.fields['c_created'] = forms.DateTimeField( + label = 'Created', input_formats=settings.DATETIME_FORMAT, - initial=kwargs['instance'].created, - disabled=True + disabled = True, + initial = self.instance.created, ) - self.fields['_modified'] = forms.DateTimeField( - label="Modified", + self.fields['c_modified'] = forms.DateTimeField( + label = 'Modified', input_formats=settings.DATETIME_FORMAT, - initial=kwargs['instance'].modified, - disabled=True + disabled = True, + initial = self.instance.modified, ) + self.tabs['details'].update({ + "edit_url": reverse('ITAM:_operating_system_change', args=(self.instance.pk,)) + }) - if kwargs['instance'].is_global: - - self.fields['is_global'].widget.attrs['disabled'] = True + self.url_index_view = reverse('ITAM:Operating Systems') diff --git a/app/itam/templates/itam/operating_system.html.j2 b/app/itam/templates/itam/operating_system.html.j2 index 46a376a5..ff4dc0c4 100644 --- a/app/itam/templates/itam/operating_system.html.j2 +++ b/app/itam/templates/itam/operating_system.html.j2 @@ -1,118 +1,116 @@ -{% extends 'base.html.j2' %} +{% extends 'detail.html.j2' %} -{% block title %}{{ operating_system.name }}{% endblock %} +{% load json %} +{% load markdown %} -{% block content %} - - -
- - - - - - -
- -
-
-

Details

- +{% block tabs %} + {% csrf_token %} - {{ form }} -
- - +
+ + {% include 'content/section.html.j2' with tab=form.tabs.details %}
-
-

Versions

- - - - - - - - - - {% for version in operating_system_versions %} - - - - - - - {% endfor %} -
VersionInstallationsVulnerable 
{{ version.name }}{% if version.installs == 0%}-{% else %}{{ version.installs }}{% endif %} DELETE
+ +
+ + {% include 'content/section.html.j2' with tab=form.tabs.versions %} + + + + + + + + + {% for version in operating_system_versions %} + + + + + + + {% endfor %} +
VersionInstallationsVulnerable 
{{ version.name }}{% if version.installs == 0%}-{% else %}{{ version.installs }}{% endif %} DELETE
-
-

Licences

- {% include 'icons/issue_link.html.j2' with issue=4 %} - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAvailable 
GPL-3Open Source1 / 5 
MITOpen SourceUnlimited 
Windows DeviceCAL11 / 15 
- + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.licences %} + + {% include 'icons/issue_link.html.j2' with issue=4 %} + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAvailable 
GPL-3Open Source1 / 5 
MITOpen SourceUnlimited 
Windows DeviceCAL11 / 15 
+
-
-

- Notes -

+
+ + {% include 'content/section.html.j2' with tab=form.tabs.installations %} + + + + + + + + + + {% for install in installs %} + + + + + + + + {% endfor %} +
DeviceOrganizationVersionInstalled 
{{ install.device }}{{ install.organization }}{{ install.version }} + {% if install.installdate %} + {{ install.installdate }} + {% else %} + - + {% endif %} +  
+ +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.notes %} + {{ notes_form }}
@@ -123,36 +121,8 @@ {% endif %}
-
- - -
-

Installations

- - - - - - - - - {% for install in installs %} - - - - - - - - {% endfor %} -
DeviceOrganizationVersionInstalled 
{{ install.device }}{{ install.organization }}{{ install.version }} - {% if install.installdate %} - {{ install.installdate }} - {% else %} - - - {% endif %} -  
- -{% endblock %} \ No newline at end of file + +{% endblock %} + diff --git a/app/itam/urls.py b/app/itam/urls.py index 00e6190f..e010dc8b 100644 --- a/app/itam/urls.py +++ b/app/itam/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ path("operating_system", operating_system.IndexView.as_view(), name="Operating Systems"), path("operating_system/", operating_system.View.as_view(), name="_operating_system_view"), + path("operating_system//edit", operating_system.Change.as_view(), name="_operating_system_change"), path("operating_system/add", operating_system.Add.as_view(), name="_operating_system_add"), path("operating_system/delete/", operating_system.Delete.as_view(), name="_operating_system_delete"), diff --git a/app/itam/views/operating_system.py b/app/itam/views/operating_system.py index 0053ac80..d0a93493 100644 --- a/app/itam/views/operating_system.py +++ b/app/itam/views/operating_system.py @@ -9,11 +9,120 @@ from core.views.common import AddView, ChangeView, DeleteView, IndexView from itam.models.device import DeviceOperatingSystem from itam.models.operating_system import OperatingSystem, OperatingSystemVersion -from itam.forms.operating_system.update import OperatingSystemFormCommon, Update +from itam.forms.operating_system.update import DetailForm, OperatingSystemForm from settings.models.user_settings import UserSettings + +class Add(AddView): + + form_class = OperatingSystemForm + + model = OperatingSystem + + permission_required = [ + 'itam.add_operatingsystem', + ] + + template_name = 'form.html.j2' + + + def get_initial(self): + + return { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization + } + + + def get_success_url(self, **kwargs): + + return reverse('ITAM:Operating Systems') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'Add Operating System' + + return context + + + +class Change(ChangeView): + + context_object_name = "operating_system" + + form_class = OperatingSystemForm + + model = OperatingSystem + + permission_required = [ + 'itam.change_operatingsystem', + ] + + template_name = 'form.html.j2' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = self.object.name + + return context + + + @method_decorator(auth_decorator.permission_required("itam.change_operatingsystem", raise_exception=True)) + def post(self, request, *args, **kwargs): + + operatingsystem = OperatingSystem.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.organization = operatingsystem.organization + notes.instance.operatingsystem = operatingsystem + notes.instance.usercreated = request.user + + notes.save() + + return super().post(request, *args, **kwargs) + + + def get_success_url(self, **kwargs): + + return reverse('ITAM:_operating_system_view', args=(self.kwargs['pk'],)) + + + +class Delete(DeleteView): + + model = OperatingSystem + + permission_required = [ + 'itam.delete_operatingsystem', + ] + + template_name = 'form.html.j2' + + + def get_success_url(self, **kwargs): + + return reverse('ITAM:Operating Systems') + + + 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'] = 'Delete ' + self.object.name + + return context + + + class IndexView(IndexView): model = OperatingSystem permission_required = [ @@ -48,13 +157,12 @@ class View(ChangeView): context_object_name = "operating_system" - form_class = Update + form_class = DetailForm model = OperatingSystem permission_required = [ 'itam.view_operatingsystem', - 'itam.change_operatingsystem', ] template_name = 'itam/operating_system.html.j2' @@ -96,7 +204,7 @@ class View(ChangeView): return context - @method_decorator(auth_decorator.permission_required("itam.change_operatingsystem", raise_exception=True)) + # @method_decorator(auth_decorator.permission_required("itam.change_operatingsystem", raise_exception=True)) def post(self, request, *args, **kwargs): operatingsystem = OperatingSystem.objects.get(pk=self.kwargs['pk']) @@ -117,65 +225,3 @@ class View(ChangeView): def get_success_url(self, **kwargs): return reverse('ITAM:_operating_system_view', args=(self.kwargs['pk'],)) - - - -class Add(AddView): - - form_class = OperatingSystemFormCommon - - model = OperatingSystem - - permission_required = [ - 'itam.add_operatingsystem', - ] - - template_name = 'form.html.j2' - - - def get_initial(self): - - return { - 'organization': UserSettings.objects.get(user = self.request.user).default_organization - } - - - def get_success_url(self, **kwargs): - - return reverse('ITAM:Operating Systems') - - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['content_title'] = 'Add Operating System' - - return context - - - -class Delete(DeleteView): - - model = OperatingSystem - - permission_required = [ - 'itam.delete_operatingsystem', - ] - - template_name = 'form.html.j2' - - - def get_success_url(self, **kwargs): - - return reverse('ITAM:Operating Systems') - - - 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'] = 'Delete ' + self.object.name - - return context From 8e71bb932e282ef32c2bea4e8059c5b86ace2ef2 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 17:01:10 +0930 Subject: [PATCH 55/82] test(itam): Correct Operating System Model permissions test to use "change" view #242 #229 --- .../unit/operating_system/test_operating_system_permission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/itam/tests/unit/operating_system/test_operating_system_permission.py b/app/itam/tests/unit/operating_system/test_operating_system_permission.py index c2747987..40684ad6 100644 --- a/app/itam/tests/unit/operating_system/test_operating_system_permission.py +++ b/app/itam/tests/unit/operating_system/test_operating_system_permission.py @@ -27,7 +27,7 @@ class OperatingSystemPermissions(TestCase, ModelPermissions): url_name_add = '_operating_system_add' - url_name_change = '_operating_system_view' + url_name_change = '_operating_system_change' url_name_delete = '_operating_system_delete' From a2af58ae0935e8b8880f3df93b979b70d41e636c Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 17:10:48 +0930 Subject: [PATCH 56/82] refactor(itam): Device Type now uses details template #242 closes #234 --- app/itam/forms/device_type.py | 63 +++++++++++- app/itam/templates/itam/device_type.html.j2 | 36 +++++++ app/itam/views/device_type.py | 105 +++++++++++++------- app/settings/urls.py | 1 + 4 files changed, 166 insertions(+), 39 deletions(-) create mode 100644 app/itam/templates/itam/device_type.html.j2 diff --git a/app/itam/forms/device_type.py b/app/itam/forms/device_type.py index 9d0adfc3..a4ac8221 100644 --- a/app/itam/forms/device_type.py +++ b/app/itam/forms/device_type.py @@ -1,4 +1,7 @@ -from django.db.models import Q +from django import forms +from django.urls import reverse + +from app import settings from core.forms.common import CommonModelForm @@ -26,3 +29,61 @@ class DeviceTypeForm( ] model = DeviceType + + + +class DetailForm(DeviceTypeForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'slug', + 'organization', + 'is_global', + 'c_created', + 'c_modified', + ], + "right": [ + 'model_notes', + ] + } + ] + }, + # "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:_device_type_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Settings:_device_types') diff --git a/app/itam/templates/itam/device_type.html.j2 b/app/itam/templates/itam/device_type.html.j2 new file mode 100644 index 00000000..df3b1a0c --- /dev/null +++ b/app/itam/templates/itam/device_type.html.j2 @@ -0,0 +1,36 @@ +{% extends 'detail.html.j2' %} + +{% load json %} +{% load markdown %} + + +{% block tabs %} +
+ + {% csrf_token %} + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.details %} + +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.notes %} + + {{ notes_form }} + +
+ {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %} +
+ +
+ +
+{% endblock %} diff --git a/app/itam/views/device_type.py b/app/itam/views/device_type.py index 77e92513..84e1b58d 100644 --- a/app/itam/views/device_type.py +++ b/app/itam/views/device_type.py @@ -5,48 +5,11 @@ from django.utils.decorators import method_decorator from core.views.common import AddView, ChangeView, DeleteView, IndexView from itam.models.device import DeviceType -from itam.forms.device_type import DeviceTypeForm +from itam.forms.device_type import DetailForm, DeviceTypeForm -class View(ChangeView): - - form_class = DeviceTypeForm - - model = DeviceType - - permission_required = [ - 'itam.view_devicetype', - 'itam.change_devicetype' - ] - - template_name = 'form.html.j2' - - context_object_name = "device_category" - - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['model_delete_url'] = reverse('Settings:_device_type_delete', args=(self.kwargs['pk'],)) - - context['content_title'] = self.object.name - - return context - - def get_success_url(self, **kwargs): - - return reverse('Settings:_device_type_view', args=(self.kwargs['pk'],)) - - - @method_decorator(auth_decorator.permission_required("itam.change_devicetype", raise_exception=True)) - def post(self, request, *args, **kwargs): - - return super().post(request, *args, **kwargs) - - - class Add(AddView): form_class = DeviceTypeForm @@ -73,6 +36,36 @@ class Add(AddView): return context +class Change(ChangeView): + + form_class = DeviceTypeForm + + model = DeviceType + + permission_required = [ + 'itam.change_devicetype' + ] + + template_name = 'form.html.j2' + + context_object_name = "device_category" + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['model_delete_url'] = reverse('Settings:_device_type_delete', args=(self.kwargs['pk'],)) + + context['content_title'] = self.object.name + + return context + + + def get_success_url(self, **kwargs): + + return reverse('Settings:_device_type_view', args=(self.kwargs['pk'],)) + + class Delete(DeleteView): model = DeviceType @@ -98,3 +91,39 @@ class Delete(DeleteView): context['content_title'] = 'Delete ' + self.object.name return context + + + +class View(ChangeView): + + form_class = DetailForm + + model = DeviceType + + permission_required = [ + 'itam.view_devicetype', + ] + + template_name = 'itam/device_type.html.j2' + + context_object_name = "device_category" + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['model_delete_url'] = reverse('Settings:_device_type_delete', args=(self.kwargs['pk'],)) + + context['content_title'] = self.object.name + + return context + + def get_success_url(self, **kwargs): + + return reverse('Settings:_device_type_view', args=(self.kwargs['pk'],)) + + + @method_decorator(auth_decorator.permission_required("itam.change_devicetype", raise_exception=True)) + def post(self, request, *args, **kwargs): + + return super().post(request, *args, **kwargs) diff --git a/app/settings/urls.py b/app/settings/urls.py index 70083b2e..38fc2c3e 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -37,6 +37,7 @@ urlpatterns = [ path("device_type/", device_type.View.as_view(), name="_device_type_view"), path("device_type/add/", device_type.Add.as_view(), name="_device_type_add"), path("device_type//delete", device_type.Delete.as_view(), name="_device_type_delete"), + path("device_type//edit", device_type.Change.as_view(), name="_device_type_change"), path("kb/category", knowledge_base_category.Index.as_view(), name="KB Categories"), path("kb/category/add", knowledge_base_category.Add.as_view(), name="_knowledge_base_category_add"), From 7ddc0abce6249d7043ee3aa61ea5305fe31b5336 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 17:11:11 +0930 Subject: [PATCH 57/82] test(itam): Correct Device Type Model permissions test to use "change" view #242 #234 --- app/itam/tests/unit/device_type/test_device_type_permission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/itam/tests/unit/device_type/test_device_type_permission.py b/app/itam/tests/unit/device_type/test_device_type_permission.py index 1ca59847..0a2b95f7 100644 --- a/app/itam/tests/unit/device_type/test_device_type_permission.py +++ b/app/itam/tests/unit/device_type/test_device_type_permission.py @@ -26,7 +26,7 @@ class DeviceTypePermissions(TestCase, ModelPermissions): url_name_add = '_device_type_add' - url_name_change = '_device_type_view' + url_name_change = '_device_type_change' url_name_delete = '_device_type_delete' From 1f76da8709ce21067a3a070d6cfffefdc7e4ddc0 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 16 Aug 2024 19:46:06 +0930 Subject: [PATCH 58/82] refactor(itam): Knowledge Base now uses details template #242 closes #231 --- app/assistance/forms/knowledge_base.py | 82 ++++++ .../templates/assistance/kb_article.html.j2 | 234 ++---------------- app/assistance/views/knowledge_base.py | 6 +- app/templates/content/field.html.j2 | 2 +- 4 files changed, 107 insertions(+), 217 deletions(-) diff --git a/app/assistance/forms/knowledge_base.py b/app/assistance/forms/knowledge_base.py index b50e4e47..1969be9f 100644 --- a/app/assistance/forms/knowledge_base.py +++ b/app/assistance/forms/knowledge_base.py @@ -1,5 +1,6 @@ from django import forms +from django.urls import reverse from django.forms import ValidationError from app import settings @@ -63,3 +64,84 @@ class KnowledgeBaseForm(CommonModelForm): return cleaned_data + + + +class DetailForm(KnowledgeBaseForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'title', + 'category', + 'responsible_user', + 'organization', + 'is_global', + 'c_created', + 'c_modified', + ], + "right": [ + 'release_date', + 'expiry_date', + 'target_user', + 'target_team', + ] + }, + { + "layout": "single", + "name": "Summary", + "fields": [ + 'summary', + ], + "markdown": [ + 'summary', + ] + }, + { + "layout": "single", + "name": "Content", + "fields": [ + 'content', + ], + "markdown": [ + 'content', + ] + } + ] + }, + "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('Assistance:_knowledge_base_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Assistance:Knowledge Base') diff --git a/app/assistance/templates/assistance/kb_article.html.j2 b/app/assistance/templates/assistance/kb_article.html.j2 index 6337267f..eab39a77 100644 --- a/app/assistance/templates/assistance/kb_article.html.j2 +++ b/app/assistance/templates/assistance/kb_article.html.j2 @@ -1,232 +1,40 @@ -{% extends 'base.html.j2' %} +{% extends 'detail.html.j2' %} +{% load json %} {% load markdown %} -{% block content %} - - - -
- - - - {% if perms.assistance.change_knowledgebase %} - - {% endif %}
-
-
- {% if perms.assistance.change_knowledgebase %} -

Details

- - {% csrf_token %} - - -
- -
- -
- - {{ form.title.value }} -
- -
- - - {% if kb.category %} - {{ kb.category }} - {% else %} -   - {% endif %} - -
- -
- - - {% if form.responsible_user.value %} - {{ kb.responsible_user }} - {% else %} -   - {% endif %} - -
- -
- - - {% if form.organization.value %} - {{ kb.organization }} - {% else %} -   - {% endif %} - -
-
+{% if perms.assistance.change_knowledgebase %} +
-
+ {% include 'content/section.html.j2' with tab=form.tabs.notes %} -
- - - {% if form.release_date.value %} - {{ form.release_date.value }} - {% else %} -   - {% endif %} - -
+ {{ notes_form }} -
- - - {% if form.expiry_date.value %} - {{ form.expiry_date.value }} - {% else %} -   - {% endif %} - -
+ -
- - - {% if form.target_user.value %} - {{ kb.target_user }} - {% else %} -   - {% endif %} - -
- -
- - - {% if form.target_team.value %} - {{ form.target_team.value }} {{ kb.target_team }} - {% else %} -   - {% endif %} - -
- - -
+
+ {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %}
- - - {% endif %} - - {% if form.summary.value %} -
-

Summary

- {{ form.summary.value | safe }} -
-
-
- {% endif %} - -
-

Content

-
-
- {{ form.content.value | markdown | safe }} -
-
- -
- - - -
- - {% if perms.assistance.change_knowledgebase %} -
-

- Notes -

- {{ notes_form }} - -
- {% if notes %} - {% for note in notes %} - {% include 'note.html.j2' %} - {% endfor %} - {% endif %} -
- -
- {% endif %} +
+{% endif %} +{% endblock %} -{% endblock %} \ No newline at end of file diff --git a/app/assistance/views/knowledge_base.py b/app/assistance/views/knowledge_base.py index 79eef8ba..6dc90508 100644 --- a/app/assistance/views/knowledge_base.py +++ b/app/assistance/views/knowledge_base.py @@ -7,7 +7,7 @@ from django.utils.decorators import method_decorator from access.models import TeamUsers -from assistance.forms.knowledge_base import KnowledgeBaseForm +from assistance.forms.knowledge_base import DetailForm, KnowledgeBaseForm from assistance.models.knowledge_base import KnowledgeBase from core.forms.comment import AddNoteForm @@ -139,7 +139,7 @@ class View(ChangeView): context_object_name = "kb" - form_class = KnowledgeBaseForm + form_class = DetailForm model = KnowledgeBase @@ -168,7 +168,7 @@ class View(ChangeView): return context - @method_decorator(auth_decorator.permission_required("assistance.change_knowledgebase", raise_exception=True)) + # @method_decorator(auth_decorator.permission_required("assistance.change_knowledgebase", raise_exception=True)) def post(self, request, *args, **kwargs): item = KnowledgeBase.objects.get(pk=self.kwargs['pk']) diff --git a/app/templates/content/field.html.j2 b/app/templates/content/field.html.j2 index 76b77f85..9385b5fe 100644 --- a/app/templates/content/field.html.j2 +++ b/app/templates/content/field.html.j2 @@ -29,7 +29,7 @@ {% if field.value %} - {{ field.value | markdown | safe }} +
{{ field.value | markdown | safe }}
{% else %} From d778cd0e8320005ab7cca10e2eb327f625b60bc7 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 17 Aug 2024 13:27:07 +0930 Subject: [PATCH 59/82] feat(development): Add new template tag `choice_ids` for string list casting Ref: #244 closes #243 --- app/core/templatetags/choice_ids.py | 64 +++++++++++++++++++ app/templates/content/field.html.j2 | 36 +++++++++-- .../centurion_erp/development/templates.md | 29 +++++++-- 3 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 app/core/templatetags/choice_ids.py diff --git a/app/core/templatetags/choice_ids.py b/app/core/templatetags/choice_ids.py new file mode 100644 index 00000000..b5553db9 --- /dev/null +++ b/app/core/templatetags/choice_ids.py @@ -0,0 +1,64 @@ +from django import template +from django.template.defaultfilters import stringfilter + + +register = template.Library() + + +@register.filter() +@stringfilter +def choice_ids(value): + """Convert choice field value to list + + Provide from `{{ field.field.choices }}` the `field.value` and have it converted to a loop + + Args: + value (string): for field that has `field.field.choices`, provide `field.value` + + Returns: + list: `field.value` casted to a useable list + """ + + if value == 'None': + + return '' + + alist: list = [] + + if '[' in value: + + value = str(value).replace('[', '').replace(']', '') + + if ',' in value: + + for item in value.split(','): + + try: + + alist += [ int(item) ] + + except: + + alist += [ str(item) ] + + else: + + try: + + alist += [ int(item) ] + + except: + + alist += [ str(item) ] + + else: + + try: + + alist += [ int(value) ] + + except: + + alist += [ str(value) ] + + return alist \ No newline at end of file diff --git a/app/templates/content/field.html.j2 b/app/templates/content/field.html.j2 index 9385b5fe..b374f364 100644 --- a/app/templates/content/field.html.j2 +++ b/app/templates/content/field.html.j2 @@ -1,5 +1,6 @@ {% load json %} {% load markdown %} +{% load choice_ids %} {% if field.widget_type == 'textarea' or field.label == 'Notes' %} @@ -53,18 +54,41 @@ {% if field.field.choices %} {# Display the selected choice text value #} - {% for id, value in field.field.choices %} - {% if field.value == id %} + {% if field.value %} + + {% for field_value in field.value|choice_ids %} - {{ value }} + {% for id, value in field.field.choices %} - {% endif %} + {% if field_value == id %} - {%endfor%} + {{ value }}, + + {% endif %} + + {% endfor %} + + {% endfor %} + + {% else %} + +   + + {% endif %} {% else %} - {{ field.value }} + + {% if field.value is not None %} + + {{ field.value }} + + {% else %} + +   + + {% endif %} + {% endif %}
diff --git a/docs/projects/centurion_erp/development/templates.md b/docs/projects/centurion_erp/development/templates.md index 31eddf4c..d4e86dcd 100644 --- a/docs/projects/centurion_erp/development/templates.md +++ b/docs/projects/centurion_erp/development/templates.md @@ -9,6 +9,27 @@ about: https://gitlab.com/nofusscomputing/infrastructure/configuration-managemen This section of the documentation contains the details related to the templates used within Centurion ERP for rendering data for the end user to view. +## Template Tags + +Within Centurion ERP, the following custom template tags exist: + +- `choice_ids` + + _if the field is a choice field, you can convert the string value `[ , ]` to a usable list within the template_ + +- `json` + + _Renders the value in json format_ + +- `markdown` + + _renders the value as markdown_ + +- `settings_value` + + _fetches a value from `app/settings.py`_ + + ## Templates - Base @@ -16,7 +37,7 @@ This section of the documentation contains the details related to the templates - Detail -## Base +### Base The base template is common to all templates and is responsible for the rendering of the common layout. Each subsequent template includes this template. This enables **ALL** pages within the site to share the same layout. @@ -50,7 +71,7 @@ This view contains the following areas: This template should not be included directly as it is incomplete and requires subsequent templates to populate the contents of the orange area. -## Detail +### Detail This template is intended to be used to render the details of a single model. The layout of the detail view is as follows: @@ -85,7 +106,7 @@ Base definition for defining a detail page is as follows: Need to navigate directly to a tab, add `tab=` to the url query string -### Providing data for the view +#### Providing data for the view For the view to render the page, you must define the data as part of the form class. @@ -94,7 +115,7 @@ For the view to render the page, you must define the data as part of the form cl The variable name to use is `tabs` The layout/schema is as follows: -#### Full Example +##### Full Example This example is a full example with two tabs: `details` and `rendered_config` From e472022c91bef5efd2462d6a301f698e96ebed15 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 17 Aug 2024 15:16:34 +0930 Subject: [PATCH 60/82] feat(development): Add function to filter permissions to those used by centurion ref: #244 #164 --- app/access/forms/team.py | 42 ++--------------- app/access/functions/permissions.py | 45 +++++++++++++++++++ .../centurion_erp/development/models.md | 2 + 3 files changed, 51 insertions(+), 38 deletions(-) create mode 100644 app/access/functions/permissions.py diff --git a/app/access/forms/team.py b/app/access/forms/team.py index 336688aa..9ba53d07 100644 --- a/app/access/forms/team.py +++ b/app/access/forms/team.py @@ -1,13 +1,13 @@ from django import forms -from django.contrib.auth.models import Permission from django.db.models import Q from django.forms import inlineformset_factory -from app import settings - from .team_users import TeamUsersForm, TeamUsers from access.models import Team +from access.functions import permissions + +from app import settings from core.forms.common import CommonModelForm @@ -66,38 +66,4 @@ class TeamForm(CommonModelForm): self.fields['permissions'].widget.attrs = {'style': "height: 200px;"} - apps = [ - 'access', - 'assistance', - 'config_management', - 'core', - 'django_celery_results', - 'itam', - 'settings', - ] - - exclude_models = [ - 'appsettings', - 'chordcounter', - 'groupresult', - 'organization' - 'settings', - 'usersettings', - ] - - exclude_permissions = [ - 'add_organization', - 'add_taskresult', - 'change_organization', - 'change_taskresult', - 'delete_organization', - 'delete_taskresult', - ] - - self.fields['permissions'].queryset = Permission.objects.filter( - content_type__app_label__in=apps, - ).exclude( - content_type__model__in=exclude_models - ).exclude( - codename__in = exclude_permissions - ) + self.fields['permissions'].queryset = permissions.permission_queryset() diff --git a/app/access/functions/permissions.py b/app/access/functions/permissions.py new file mode 100644 index 00000000..4753b680 --- /dev/null +++ b/app/access/functions/permissions.py @@ -0,0 +1,45 @@ +from django.contrib.auth.models import Permission + +def permission_queryset(): + """Filter Permissions to those used within the application + + Returns: + list: Filtered queryset that only contains the used permissions + """ + + apps = [ + 'access', + 'assistance', + 'config_management', + 'core', + 'django_celery_results', + 'itam', + 'itim', + 'settings', + ] + + exclude_models = [ + 'appsettings', + 'chordcounter', + 'groupresult', + 'organization' + 'settings', + 'usersettings', + ] + + exclude_permissions = [ + 'add_organization', + 'add_taskresult', + 'change_organization', + 'change_taskresult', + 'delete_organization', + 'delete_taskresult', + ] + + return Permission.objects.filter( + content_type__app_label__in=apps, + ).exclude( + content_type__model__in=exclude_models + ).exclude( + codename__in = exclude_permissions + ) \ No newline at end of file diff --git a/docs/projects/centurion_erp/development/models.md b/docs/projects/centurion_erp/development/models.md index 92963650..9b08df64 100644 --- a/docs/projects/centurion_erp/development/models.md +++ b/docs/projects/centurion_erp/development/models.md @@ -42,6 +42,8 @@ All models must meet the following requirements: - `verbose_name_plural` +- If creating a new model, function `access.functions.permissions.permission_queryset()` has been updated to display the models permission(s) + ## History From 04a9cde47e5f116c3677902b434bd78d513cbff6 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 17 Aug 2024 15:20:17 +0930 Subject: [PATCH 61/82] feat(api): Endpoint to fetch user permissions ref: #244 closes #164 --- app/api/urls.py | 6 +++ app/api/views/index.py | 10 +++- app/api/views/settings/index.py | 47 +++++++++++++++++++ app/api/views/settings/permissions.py | 67 +++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 app/api/views/settings/index.py create mode 100644 app/api/views/settings/permissions.py diff --git a/app/api/urls.py b/app/api/urls.py index d5d51ecc..5d2a8900 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -5,6 +5,9 @@ from rest_framework.urlpatterns import format_suffix_patterns from .views import access, config, index +from api.views.settings import permissions +from api.views.settings import index as settings + from .views.itam import software, config as itam_config from .views.itam.device import DeviceViewSet from .views.itam import inventory @@ -36,6 +39,9 @@ urlpatterns = [ path("organization//team//permissions", access.TeamPermissionDetail.as_view(), name='_api_team_permission'), path("organization/team/", access.TeamList.as_view(), name='_api_teams'), + path("settings", settings.View.as_view(), name='_settings'), + path("settings/permissions", permissions.View.as_view(), name='_settings_permissions'), + ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/app/api/views/index.py b/app/api/views/index.py index f15b79f6..0bbce58a 100644 --- a/app/api/views/index.py +++ b/app/api/views/index.py @@ -1,15 +1,20 @@ -# from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin from django.contrib.auth.models import User from django.utils.safestring import mark_safe from rest_framework import generics, permissions, routers, viewsets 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(viewsets.ViewSet): - # permission_required = 'access.view_organization' + permission_classes = [ + IsAuthenticated, + ] + def get_view_name(self): return "API Index" @@ -29,6 +34,7 @@ class Index(viewsets.ViewSet): "devices": reverse("API:device-list", request=request), "config_groups": reverse("API:_api_config_groups", 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/settings/index.py b/app/api/views/settings/index.py new file mode 100644 index 00000000..6870c9f7 --- /dev/null +++ b/app/api/views/settings/index.py @@ -0,0 +1,47 @@ +from django.contrib.auth.models import Permission + +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from rest_framework import views +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.reverse import reverse + +from core.http.common import Http + + + +class View(views.APIView): + + permission_classes = [ + IsAuthenticated, + ] + + + @extend_schema( + summary = "Settings Index Page", + description = """This endpoint provides the available settings as available via the API. + """, + + methods=["GET"], + parameters = None, + tags = ['settings',], + responses = { + 200: OpenApiResponse(description='Inventory upload successful'), + 401: OpenApiResponse(description='User Not logged in'), + 500: OpenApiResponse(description='Exception occured. View server logs for the Stack Trace'), + } + ) + def get(self, request, *args, **kwargs): + + status = Http.Status.OK + + response_data: dict = { + "permissions": reverse('API:_settings_permissions', request=request) + } + + return Response(data=response_data,status=status) + + + def get_view_name(self): + return "Settings" diff --git a/app/api/views/settings/permissions.py b/app/api/views/settings/permissions.py new file mode 100644 index 00000000..fca58e67 --- /dev/null +++ b/app/api/views/settings/permissions.py @@ -0,0 +1,67 @@ +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from rest_framework import views +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from access.functions import permissions + +from core.http.common import Http + + + +class View(views.APIView): + + permission_classes = [ + IsAuthenticated, + ] + + + @extend_schema( + summary = "Fetch available permissions", + description = """This endpoint provides a list of permissions that are available within +Centurion ERP. The format of each permission is `._`. + +This endpoint is available to **all** authenticated users. + """, + + methods=["GET"], + parameters = None, + tags = ['settings',], + responses = { + 200: OpenApiResponse(description='Inventory upload successful'), + 401: OpenApiResponse(description='User Not logged in'), + 500: OpenApiResponse(description='Exception occured. View server logs for the Stack Trace'), + } + ) + def get(self, request, *args, **kwargs): + + status = Http.Status.OK + + response_data: list = [] + + try: + + for permission in permissions.permission_queryset(): + + response_data += [ str(f"{permission.content_type.app_label}.{permission.codename}") ] + + except PermissionDenied as e: + + status = Http.Status.FORBIDDEN + response_data = '' + + + except Exception as e: + + print(f'An error occured{e}') + + status = Http.Status.SERVER_ERROR + response_data = 'Unknown Server Error occured' + + + return Response(data=response_data,status=status) + + + def get_view_name(self): + return "Permissions" From 0020550dde758b01d2855d41629d8390d4c2fef9 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 17 Aug 2024 17:40:31 +0930 Subject: [PATCH 62/82] feat(itam): Track if device is virtual ref: #244 closes #245 --- app/itam/forms/device/device.py | 2 + ...ions_alter_devicemodel_options_and_more.py | 69 +++++++++++++++++++ app/itam/models/device.py | 9 +++ 3 files changed, 80 insertions(+) create mode 100644 app/itam/migrations/0003_alter_device_options_alter_devicemodel_options_and_more.py diff --git a/app/itam/forms/device/device.py b/app/itam/forms/device/device.py index 16e04940..d459398c 100644 --- a/app/itam/forms/device/device.py +++ b/app/itam/forms/device/device.py @@ -22,6 +22,7 @@ class DeviceForm(CommonModelForm): 'uuid', 'device_type', 'organization', + 'is_virtual', 'model_notes', 'config', ] @@ -50,6 +51,7 @@ class DetailForm(DeviceForm): ], "right": [ 'model_notes', + 'is_virtual', ] } ] diff --git a/app/itam/migrations/0003_alter_device_options_alter_devicemodel_options_and_more.py b/app/itam/migrations/0003_alter_device_options_alter_devicemodel_options_and_more.py new file mode 100644 index 00000000..60d33c2f --- /dev/null +++ b/app/itam/migrations/0003_alter_device_options_alter_devicemodel_options_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.0.7 on 2024-08-17 08:05 + +import itam.models.device +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('itam', '0002_device_config'), + ] + + operations = [ + migrations.AlterModelOptions( + name='device', + options={'verbose_name_plural': 'Devices'}, + ), + migrations.AlterModelOptions( + name='devicemodel', + options={'ordering': ['manufacturer', 'name'], 'verbose_name_plural': 'Device Models'}, + ), + migrations.AlterModelOptions( + name='deviceoperatingsystem', + options={'verbose_name_plural': 'Device Operating Systems'}, + ), + migrations.AlterModelOptions( + name='devicesoftware', + options={'ordering': ['-action', 'software'], 'verbose_name_plural': 'Device Softwares'}, + ), + migrations.AlterModelOptions( + name='devicetype', + options={'verbose_name_plural': 'Device Types'}, + ), + migrations.AlterModelOptions( + name='operatingsystem', + options={'verbose_name_plural': 'Operating Systems'}, + ), + migrations.AlterModelOptions( + name='operatingsystemversion', + options={'verbose_name_plural': 'Operating System Versions'}, + ), + migrations.AlterModelOptions( + name='software', + options={'verbose_name_plural': 'Softwares'}, + ), + migrations.AlterModelOptions( + name='softwarecategory', + options={'verbose_name_plural': 'Software Categories'}, + ), + migrations.AlterModelOptions( + name='softwareversion', + options={'verbose_name_plural': 'Software Versions'}, + ), + migrations.AddField( + model_name='device', + name='is_virtual', + field=models.BooleanField(blank=True, default=False, help_text='Is this device a virtual machine', verbose_name='Is Virtual'), + ), + migrations.AlterField( + model_name='device', + name='name', + field=models.CharField(max_length=50, unique=True, validators=[itam.models.device.Device.validate_hostname_format]), + ), + migrations.AlterField( + model_name='device', + name='uuid', + field=models.CharField(blank=True, default=None, help_text='System GUID/UUID.', max_length=50, null=True, unique=True, validators=[itam.models.device.Device.validate_uuid_format], verbose_name='UUID'), + ), + ] diff --git a/app/itam/models/device.py b/app/itam/models/device.py index e2dbea48..7cb1a012 100644 --- a/app/itam/models/device.py +++ b/app/itam/models/device.py @@ -152,6 +152,15 @@ class Device(DeviceCommonFieldsName, SaveHistory): blank = True, ) + is_virtual = models.BooleanField( + blank = True, + default = False, + help_text = 'Is this device a virtual machine', + null = False, + verbose_name = 'Is Virtual', + ) + + def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): From efce9c0219a9b5324253ae8aeb1e8220f71db975 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 17 Aug 2024 17:42:50 +0930 Subject: [PATCH 63/82] chore: update migrations from previous days work ref: #244 #71 #245 --- ...003_alter_configgroups_options_and_more.py | 21 +++++++++++++++++++ ...anufacturer_options_alter_notes_options.py | 21 +++++++++++++++++++ ...notes_service.py => 0004_notes_service.py} | 4 ++-- app/itim/migrations/0001_initial.py | 10 +++++---- .../0003_alter_externallink_options.py | 17 +++++++++++++++ 5 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 app/config_management/migrations/0003_alter_configgroups_options_and_more.py create mode 100644 app/core/migrations/0003_alter_manufacturer_options_alter_notes_options.py rename app/core/migrations/{0003_notes_service.py => 0004_notes_service.py} (78%) create mode 100644 app/settings/migrations/0003_alter_externallink_options.py diff --git a/app/config_management/migrations/0003_alter_configgroups_options_and_more.py b/app/config_management/migrations/0003_alter_configgroups_options_and_more.py new file mode 100644 index 00000000..793c637e --- /dev/null +++ b/app/config_management/migrations/0003_alter_configgroups_options_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.7 on 2024-08-17 08:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('config_management', '0002_configgrouphosts_configgroupsoftware'), + ] + + operations = [ + migrations.AlterModelOptions( + name='configgroups', + options={'verbose_name_plural': 'Config Groups'}, + ), + migrations.AlterModelOptions( + name='configgroupsoftware', + options={'ordering': ['-action', 'software'], 'verbose_name_plural': 'Config Group Softwares'}, + ), + ] diff --git a/app/core/migrations/0003_alter_manufacturer_options_alter_notes_options.py b/app/core/migrations/0003_alter_manufacturer_options_alter_notes_options.py new file mode 100644 index 00000000..97f1c656 --- /dev/null +++ b/app/core/migrations/0003_alter_manufacturer_options_alter_notes_options.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.7 on 2024-08-17 08:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_notes'), + ] + + operations = [ + migrations.AlterModelOptions( + name='manufacturer', + options={'ordering': ['name'], 'verbose_name_plural': 'Manufacturers'}, + ), + migrations.AlterModelOptions( + name='notes', + options={'ordering': ['-created'], 'verbose_name_plural': 'Notes'}, + ), + ] diff --git a/app/core/migrations/0003_notes_service.py b/app/core/migrations/0004_notes_service.py similarity index 78% rename from app/core/migrations/0003_notes_service.py rename to app/core/migrations/0004_notes_service.py index 83abbb73..b512e992 100644 --- a/app/core/migrations/0003_notes_service.py +++ b/app/core/migrations/0004_notes_service.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-21 02:35 +# Generated by Django 5.0.7 on 2024-08-17 08:05 import django.db.models.deletion from django.db import migrations, models @@ -7,7 +7,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('core', '0002_notes'), + ('core', '0003_alter_manufacturer_options_alter_notes_options'), ('itim', '0001_initial'), ] diff --git a/app/itim/migrations/0001_initial.py b/app/itim/migrations/0001_initial.py index 5ce0aac2..d85e6bce 100644 --- a/app/itim/migrations/0001_initial.py +++ b/app/itim/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-21 02:35 +# Generated by Django 5.0.7 on 2024-08-17 08:05 import access.fields import access.models @@ -14,7 +14,7 @@ class Migration(migrations.Migration): dependencies = [ ('access', '0001_initial'), - ('itam', '0002_device_config'), + ('itam', '0003_alter_device_options_alter_devicemodel_options_and_more'), ] operations = [ @@ -43,11 +43,13 @@ class Migration(migrations.Migration): ('name', models.CharField(help_text='Name of the Cluster', max_length=50, verbose_name='Name')), ('slug', access.fields.AutoSlugField()), ('config', models.JSONField(blank=True, default=None, help_text='Cluster Configuration', null=True, verbose_name='Configuration')), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)), ('devices', models.ManyToManyField(blank=True, default=None, help_text='Devices that are deployed upon the cluster.', related_name='cluster_device', to='itam.device', verbose_name='Devices')), - ('node', models.ManyToManyField(blank=True, default=None, help_text='Hosts for resource consumption that the cluster is deployed upon', related_name='cluster_node', to='itam.device', verbose_name='Nodes')), + ('nodes', models.ManyToManyField(blank=True, default=None, help_text='Hosts for resource consumption that the cluster is deployed upon', related_name='cluster_node', to='itam.device', verbose_name='Nodes')), ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])), ('parent_cluster', models.ForeignKey(blank=True, default=None, help_text='Parent Cluster for this cluster', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.cluster', verbose_name='Parent Cluster')), - ('cluster_type', models.ForeignKey(blank=True, default=None, help_text='Parent Cluster for this cluster', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.clustertype', verbose_name='Parent Cluster')), + ('cluster_type', models.ForeignKey(blank=True, default=None, help_text='Type of Cluster', null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.clustertype', verbose_name='Cluster Type')), ], options={ 'verbose_name': 'Cluster', diff --git a/app/settings/migrations/0003_alter_externallink_options.py b/app/settings/migrations/0003_alter_externallink_options.py new file mode 100644 index 00000000..a4e0ff40 --- /dev/null +++ b/app/settings/migrations/0003_alter_externallink_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.7 on 2024-08-17 08:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('settings', '0002_externallink'), + ] + + operations = [ + migrations.AlterModelOptions( + name='externallink', + options={'verbose_name_plural': 'External Links'}, + ), + ] From 30bd8aa4832dd5ea3afdd14fa0dd9effcd4f8c03 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 17 Aug 2024 17:50:08 +0930 Subject: [PATCH 64/82] feat(itim): Ability to add and configure cluster ref: #244 #71 --- app/itim/forms/clusters.py | 142 ++++++++++++++ app/itim/models/clusters.py | 10 +- app/itim/templates/itim/cluster.html.j2 | 79 ++++++++ app/itim/templates/itim/cluster_index.html.j2 | 53 +++++ app/itim/urls.py | 8 +- app/itim/views/clusters.py | 185 ++++++++++++++++++ 6 files changed, 473 insertions(+), 4 deletions(-) create mode 100644 app/itim/forms/clusters.py create mode 100644 app/itim/templates/itim/cluster.html.j2 create mode 100644 app/itim/templates/itim/cluster_index.html.j2 create mode 100644 app/itim/views/clusters.py diff --git a/app/itim/forms/clusters.py b/app/itim/forms/clusters.py new file mode 100644 index 00000000..a559a396 --- /dev/null +++ b/app/itim/forms/clusters.py @@ -0,0 +1,142 @@ +from django import forms +from django.forms import ValidationError +from django.urls import reverse + +from itim.models.clusters import Cluster + +from app import settings + +from core.forms.common import CommonModelForm + + + +class ClusterForm(CommonModelForm): + + + class Meta: + + fields = '__all__' + + model = Cluster + + prefix = 'cluster' + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.fields['parent_cluster'].queryset = self.fields['parent_cluster'].queryset.exclude( + id=self.instance.pk + ) + + self.fields['devices'].queryset = self.fields['devices'].queryset.exclude( + is_virtual=False + ) + + + def clean(self): + + cleaned_data = super().clean() + + pk = self.instance.id + + parent_cluster = cleaned_data.get("parent_cluster") + + if parent_cluster == pk: + + raise ValidationError("Cluster can't have itself as its parent cluster") + + return cleaned_data + + + +class DetailForm(ClusterForm): + + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'parent_cluster', + 'cluster_type', + 'name', + 'organization', + 'c_created', + 'c_modified' + ], + "right": [ + 'model_notes', + 'resources', + ] + }, + ] + }, + "rendered_config": { + "name": "Rendered Config", + "slug": "rendered_config", + "sections": [ + { + "layout": "single", + "fields": [ + 'config_variables', + ], + "json": [ + 'config_variables' + ] + } + ] + }, + "notes": { + "name": "Notes", + "slug": "notes", + "sections": [] + } + } + + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + + # self.fields['config_variables'] = forms.fields.JSONField( + # widget = forms.Textarea( + # attrs = { + # "cols": "80", + # "rows": "100" + # } + # ), + # label = 'Rendered Configuration', + # initial = self.instance.config_variables, + # ) + + 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('ITIM:_cluster_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('ITIM:Clusters') + diff --git a/app/itim/models/clusters.py b/app/itim/models/clusters.py index 88552b96..31010e7d 100644 --- a/app/itim/models/clusters.py +++ b/app/itim/models/clusters.py @@ -75,10 +75,10 @@ class Cluster(TenancyObject): ClusterType, blank = True, default = None, - help_text = 'Parent Cluster for this cluster', + help_text = 'Type of Cluster', null = True, on_delete = models.CASCADE, - verbose_name = 'Parent Cluster', + verbose_name = 'Cluster Type', ) name = models.CharField( @@ -99,7 +99,7 @@ class Cluster(TenancyObject): verbose_name = 'Configuration', ) - node = models.ManyToManyField( + nodes = models.ManyToManyField( Device, blank = True, default = None, @@ -117,6 +117,10 @@ class Cluster(TenancyObject): verbose_name = 'Devices', ) + created = AutoCreatedField() + + modified = AutoLastModifiedField() + def __str__(self): diff --git a/app/itim/templates/itim/cluster.html.j2 b/app/itim/templates/itim/cluster.html.j2 new file mode 100644 index 00000000..57d9e7d4 --- /dev/null +++ b/app/itim/templates/itim/cluster.html.j2 @@ -0,0 +1,79 @@ +{% extends 'detail.html.j2' %} + +{% load json %} +{% load markdown %} + + +{% block tabs %} + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.details %} + +
+ +
+ +

Nodes

+ + + + + + + {% if item.nodes.all %} + {% for node in item.nodes.all %} + + + + + {% endfor%} + {% else %} + + + + {% endif %} +
NameOrganization
{{ node }}{{ node.organization }}
Nothing Found
+ +
+ +
+ +

Devices

+ + + + + + + {% if item.devices.all %} + {% for device in item.devices.all %} + + + + + {% endfor%} + {% else %} + + + + {% endif %} +
NameOrganization
{{ device }}{{ device.organization }}
Nothing Found
+ +
+ +
+

Config

+
{{ item.config | json_pretty }}
+
+ +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.rendered_config %} + +
+ +{% endblock %} \ No newline at end of file diff --git a/app/itim/templates/itim/cluster_index.html.j2 b/app/itim/templates/itim/cluster_index.html.j2 new file mode 100644 index 00000000..7f1dd0ec --- /dev/null +++ b/app/itim/templates/itim/cluster_index.html.j2 @@ -0,0 +1,53 @@ +{% extends 'base.html.j2' %} + +{% block content %} + + + + + + + + + + {% if items %} + {% for item in items %} + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
TitleCluster / DeviceOrganization 
{{ item.name }} + {% if item.device %} + {{ item.device }} + {% else %} + {{ item.cluster }} + {% endif %} + {{ item.organization }} 
Nothing Found
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/itim/urls.py b/app/itim/urls.py index 83e064d4..440d4960 100644 --- a/app/itim/urls.py +++ b/app/itim/urls.py @@ -1,7 +1,7 @@ from django.urls import path -from itim.views import services +from itim.views import clusters, services app_name = "ITIM" urlpatterns = [ @@ -12,4 +12,10 @@ urlpatterns = [ path("service//delete", services.Delete.as_view(), name="_service_delete"), path("service/", services.View.as_view(), name="_service_view"), + path("clusters", clusters.Index.as_view(), name="Clusters"), + path("clusters/add", clusters.Add.as_view(), name="_cluster_add"), + path("clusters//edit", clusters.Change.as_view(), name="_cluster_change"), + path("clusters//delete", clusters.Delete.as_view(), name="_cluster_delete"), + path("clusters/", clusters.View.as_view(), name="_cluster_view"), + ] diff --git a/app/itim/views/clusters.py b/app/itim/views/clusters.py new file mode 100644 index 00000000..a62f4a99 --- /dev/null +++ b/app/itim/views/clusters.py @@ -0,0 +1,185 @@ +from django.contrib.auth import decorators as auth_decorator +from django.urls import reverse +from django.utils.decorators import method_decorator + +from core.forms.comment import AddNoteForm +from core.models.notes import Notes +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from itim.forms.clusters import ClusterForm, DetailForm +from itim.models.clusters import Cluster + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = ClusterForm + + model = Cluster + + permission_required = [ + 'itim.add_cluster', + ] + + + def get_initial(self): + + initial: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization + } + + 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('ITIM:Clusters') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'New Cluster' + + return context + + + +class Change(ChangeView): + + form_class = ClusterForm + + model = Cluster + + 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_success_url(self, **kwargs): + + return reverse('ITIM:_cluster_view', args=(self.kwargs['pk'],)) + + + +class Delete(DeleteView): + + model = Cluster + + 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') + + + +class Index(IndexView): + + context_object_name = "items" + + model = Cluster + + paginate_by = 10 + + permission_required = [ + 'itim.view_cluster' + ] + + template_name = 'itim/cluster_index.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 + + context['content_title'] = 'Clusters' + + return context + + + +class View(ChangeView): + + context_object_name = "item" + + form_class = DetailForm + + model = Cluster + + permission_required = [ + 'itim.view_cluster', + ] + + template_name = 'itim/cluster.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('ITIM:_cluster_delete', args=(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('ITIM:_cluster_view', args=(self.kwargs['pk'],)) From 24967ae3a67e1ff286cef9c42ba376d02149ee37 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 17 Aug 2024 17:51:32 +0930 Subject: [PATCH 65/82] fix(itim): Fix name typo in Add Service button ref: #244 --- app/itim/templates/itim/service_index.html.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/itim/templates/itim/service_index.html.j2 b/app/itim/templates/itim/service_index.html.j2 index fce45c14..669ce858 100644 --- a/app/itim/templates/itim/service_index.html.j2 +++ b/app/itim/templates/itim/service_index.html.j2 @@ -2,7 +2,7 @@ {% block content %} - + From 17df9d1fa3f4a38bf6f0d9ee44b45a24504d48e8 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 12:56:37 +0930 Subject: [PATCH 66/82] fix(core): Ensure when saving history json is correctly formatted ref: #244 --- app/core/mixin/history_save.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/core/mixin/history_save.py b/app/core/mixin/history_save.py index e9efd6eb..569f323c 100644 --- a/app/core/mixin/history_save.py +++ b/app/core/mixin/history_save.py @@ -41,6 +41,18 @@ class SaveHistory(models.Model): value = bool(before[entry]) + elif ( + "{" in str(after[entry]) + and + "}" in str(after[entry]) + ) or ( + "[" in str(after[entry]) + and + "]" in str(after[entry]) + ): + + value = str(after[entry]).replace("'", '\"') + else: value = str(before[entry]) @@ -62,6 +74,18 @@ class SaveHistory(models.Model): value = bool(after[entry]) + elif ( + "{" in str(after[entry]) + and + "}" in str(after[entry]) + ) or ( + "[" in str(after[entry]) + and + "]" in str(after[entry]) + ): + + value = str(after[entry]).replace("'", '\"') + else: value = str(after[entry]) From 8ec1ea2a4c6d5d06e5338f0dcb719b8efbf41902 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 12:57:30 +0930 Subject: [PATCH 67/82] feat(itim): prevent cluster from setting itself as parent ref: #244 #71 --- app/itim/forms/clusters.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/itim/forms/clusters.py b/app/itim/forms/clusters.py index a559a396..d9abb08a 100644 --- a/app/itim/forms/clusters.py +++ b/app/itim/forms/clusters.py @@ -42,9 +42,11 @@ class ClusterForm(CommonModelForm): parent_cluster = cleaned_data.get("parent_cluster") - if parent_cluster == pk: + if pk: - raise ValidationError("Cluster can't have itself as its parent cluster") + if parent_cluster == pk: + + raise ValidationError("Cluster can't have itself as its parent cluster") return cleaned_data From f9dee4465b5287ba6f60904357144b0d4b1ffcf7 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 12:57:50 +0930 Subject: [PATCH 68/82] feat(itim): Add cluster to history save ref: #244 #71 --- app/core/views/history.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/core/views/history.py b/app/core/views/history.py index 67a14dc4..b983a703 100644 --- a/app/core/views/history.py +++ b/app/core/views/history.py @@ -47,6 +47,12 @@ class View(OrganizationPermission, generic.View): match self.kwargs['model_name']: + case 'cluster': + + from itim.models.clusters import Cluster + + self.model = Cluster + case 'configgroups': self.model = ConfigGroups From 45ef81481fd553112ceec02f90be9549bd615a43 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 12:59:26 +0930 Subject: [PATCH 69/82] test(itim): Cluster unit tests ref: #244 #71 --- app/itim/tests/unit/cluster/test_cluster.py | 42 ++++ .../unit/cluster/test_cluster_core_history.py | 78 ++++++++ .../test_cluster_history_permission.py | 95 +++++++++ .../unit/cluster/test_cluster_permission.py | 189 ++++++++++++++++++ .../tests/unit/cluster/test_cluster_views.py | 29 +++ 5 files changed, 433 insertions(+) create mode 100644 app/itim/tests/unit/cluster/test_cluster.py create mode 100644 app/itim/tests/unit/cluster/test_cluster_core_history.py create mode 100644 app/itim/tests/unit/cluster/test_cluster_history_permission.py create mode 100644 app/itim/tests/unit/cluster/test_cluster_permission.py create mode 100644 app/itim/tests/unit/cluster/test_cluster_views.py diff --git a/app/itim/tests/unit/cluster/test_cluster.py b/app/itim/tests/unit/cluster/test_cluster.py new file mode 100644 index 00000000..819d9558 --- /dev/null +++ b/app/itim/tests/unit/cluster/test_cluster.py @@ -0,0 +1,42 @@ +import pytest +import unittest + +from django.test import TestCase + +from access.models import Organization + +from app.tests.abstract.models import TenancyModel + +from itim.models.clusters import Cluster + + + +@pytest.mark.django_db +class ClusterModel( + TestCase, + TenancyModel +): + + model = Cluster + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. Create an item + + """ + + self.organization = Organization.objects.create(name='test_org') + + + self.item = self.model.objects.create( + organization = self.organization, + name = 'one', + ) + + self.second_item = self.model.objects.create( + organization = self.organization, + name = 'one_two', + ) diff --git a/app/itim/tests/unit/cluster/test_cluster_core_history.py b/app/itim/tests/unit/cluster/test_cluster_core_history.py new file mode 100644 index 00000000..4de46dcf --- /dev/null +++ b/app/itim/tests/unit/cluster/test_cluster_core_history.py @@ -0,0 +1,78 @@ + +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 itim.models.clusters import Cluster + + + +class ClusterHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + + model = Cluster + + + @classmethod + def setUpTestData(self): + """ Setup Test """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + self.item_parent = self.model.objects.create( + name = 'test_item_parent_' + self.model._meta.model_name, + organization = self.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": "' + self.item_change.name + '"}' + + 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.item_parent._meta.model_name, + ) diff --git a/app/itim/tests/unit/cluster/test_cluster_history_permission.py b/app/itim/tests/unit/cluster/test_cluster_history_permission.py new file mode 100644 index 00000000..051352c4 --- /dev/null +++ b/app/itim/tests/unit/cluster/test_cluster_history_permission.py @@ -0,0 +1,95 @@ +# 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 itim.models.clusters import Cluster + +from core.tests.abstract.history_permissions import HistoryPermissions + + + +class ClusterHistoryPermissions(TestCase, HistoryPermissions): + + + item_model = Cluster + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. create an organization that is different to item + 3. Create a device + 4. Add history device history entry as item + 5. create a user + 6. create user in different organization (with the required permission) + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + self.item = self.item_model.objects.create( + organization=organization, + name = 'deviceone' + ) + + self.history = self.model.objects.get( + item_pk = self.item.id, + item_class = self.item._meta.model_name, + action = self.model.Actions.ADD, + ) + + 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]) + + + 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.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, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) diff --git a/app/itim/tests/unit/cluster/test_cluster_permission.py b/app/itim/tests/unit/cluster/test_cluster_permission.py new file mode 100644 index 00000000..a44b7d91 --- /dev/null +++ b/app/itim/tests/unit/cluster/test_cluster_permission.py @@ -0,0 +1,189 @@ +# 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 itim.models.clusters import Cluster + + +class ClusterPermissions(TestCase, ModelPermissions): + + + model = Cluster + + app_namespace = 'ITIM' + + url_name_view = '_cluster_view' + + url_name_add = '_cluster_add' + + url_name_change = '_cluster_change' + + url_name_delete = '_cluster_delete' + + url_delete_response = reverse('ITIM:Clusters') + + + @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 device + 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 = 'deviceone' + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + # self.url_add_kwargs = {'pk': self.item.id} + + self.add_data = {'device': 'device', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'device': 'device', 'organization': self.organization.id} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'device': 'device', '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 + ) diff --git a/app/itim/tests/unit/cluster/test_cluster_views.py b/app/itim/tests/unit/cluster/test_cluster_views.py new file mode 100644 index 00000000..ba93b740 --- /dev/null +++ b/app/itim/tests/unit/cluster/test_cluster_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 ClusterViews( + TestCase, + PrimaryModel +): + + add_module = 'itim.views.clusters' + 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 b65e577017e651f715c5f51fa05e40ff66401b8d Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 13:04:27 +0930 Subject: [PATCH 70/82] chore(itim): add placeholder for assigning service to a cluster ref: #244 #71 #125 --- app/itim/templates/itim/cluster.html.j2 | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/itim/templates/itim/cluster.html.j2 b/app/itim/templates/itim/cluster.html.j2 index 57d9e7d4..ad541563 100644 --- a/app/itim/templates/itim/cluster.html.j2 +++ b/app/itim/templates/itim/cluster.html.j2 @@ -62,6 +62,33 @@ +
+ +

Services

+ + {% include 'icons/issue_link.html.j2' with issue=125 %} + +
Title
+ + + + + {% if item.services.all %} + {% for device in item.devices.all %} + + + + + {% endfor%} + {% else %} + + + + {% endif %} +
NamePorts
Nothing Found
+ +
+

Config

{{ item.config | json_pretty }}
From 75203c022a4a16cf37bff155f36f691942e905d7 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 13:49:42 +0930 Subject: [PATCH 71/82] feat(itim): Ability to add and configure Cluster Types ref: #244 #71 --- app/core/views/history.py | 6 + app/itim/forms/cluster_type.py | 84 ++++++++ app/itim/migrations/0001_initial.py | 4 +- ...lustertype_created_clustertype_modified.py | 25 +++ app/itim/models/clusters.py | 13 +- app/itim/templates/itim/cluster_type.html.j2 | 22 +++ .../templates/itim/cluster_type_index.html.j2 | 47 +++++ app/itim/views/cluster_types.py | 185 ++++++++++++++++++ app/settings/templates/settings/home.html.j2 | 1 + app/settings/urls.py | 39 ++-- 10 files changed, 405 insertions(+), 21 deletions(-) create mode 100644 app/itim/forms/cluster_type.py create mode 100644 app/itim/migrations/0002_clustertype_created_clustertype_modified.py create mode 100644 app/itim/templates/itim/cluster_type.html.j2 create mode 100644 app/itim/templates/itim/cluster_type_index.html.j2 create mode 100644 app/itim/views/cluster_types.py diff --git a/app/core/views/history.py b/app/core/views/history.py index b983a703..5f24a009 100644 --- a/app/core/views/history.py +++ b/app/core/views/history.py @@ -53,6 +53,12 @@ class View(OrganizationPermission, generic.View): self.model = Cluster + case 'clustertype': + + from itim.models.clusters import ClusterType + + self.model = ClusterType + case 'configgroups': self.model = ConfigGroups diff --git a/app/itim/forms/cluster_type.py b/app/itim/forms/cluster_type.py new file mode 100644 index 00000000..7bf7ba75 --- /dev/null +++ b/app/itim/forms/cluster_type.py @@ -0,0 +1,84 @@ +from django import forms +from django.forms import ValidationError +from django.urls import reverse + +from itim.models.clusters import ClusterType + +from app import settings + +from core.forms.common import CommonModelForm + + + +class ClusterTypeForm(CommonModelForm): + + + class Meta: + + fields = '__all__' + + model = ClusterType + + prefix = 'cluster_type' + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + + +class DetailForm(ClusterTypeForm): + + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'name', + 'organization', + 'c_created', + 'c_modified' + ], + "right": [ + 'model_notes', + ] + }, + ] + }, + "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:_cluster_type_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Settings:_cluster_types') + diff --git a/app/itim/migrations/0001_initial.py b/app/itim/migrations/0001_initial.py index d85e6bce..71baeb9c 100644 --- a/app/itim/migrations/0001_initial.py +++ b/app/itim/migrations/0001_initial.py @@ -29,8 +29,8 @@ class Migration(migrations.Migration): ('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': 'ClusterType', - 'verbose_name_plural': 'ClusterTypes', + 'verbose_name': 'Cluster Type', + 'verbose_name_plural': 'Cluster Types', 'ordering': ['name'], }, ), diff --git a/app/itim/migrations/0002_clustertype_created_clustertype_modified.py b/app/itim/migrations/0002_clustertype_created_clustertype_modified.py new file mode 100644 index 00000000..c55a2d11 --- /dev/null +++ b/app/itim/migrations/0002_clustertype_created_clustertype_modified.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.7 on 2024-08-18 03:57 + +import access.fields +import django.utils.timezone +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('itim', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='clustertype', + name='created', + field=access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False), + ), + migrations.AddField( + model_name='clustertype', + name='modified', + field=access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False), + ), + ] diff --git a/app/itim/models/clusters.py b/app/itim/models/clusters.py index 31010e7d..d003157f 100644 --- a/app/itim/models/clusters.py +++ b/app/itim/models/clusters.py @@ -18,9 +18,9 @@ class ClusterType(TenancyObject): 'name', ] - verbose_name = "ClusterType" + verbose_name = "Cluster Type" - verbose_name_plural = "ClusterTypes" + verbose_name_plural = "Cluster Types" id = models.AutoField( @@ -39,6 +39,15 @@ class ClusterType(TenancyObject): slug = AutoSlugField() + created = AutoCreatedField() + + modified = AutoLastModifiedField() + + + def __str__(self): + + return self.name + class Cluster(TenancyObject): diff --git a/app/itim/templates/itim/cluster_type.html.j2 b/app/itim/templates/itim/cluster_type.html.j2 new file mode 100644 index 00000000..ad99f6eb --- /dev/null +++ b/app/itim/templates/itim/cluster_type.html.j2 @@ -0,0 +1,22 @@ +{% extends 'detail.html.j2' %} + +{% load json %} +{% load markdown %} + + +{% block tabs %} + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.details %} + +
+ + +
+ + {% include 'content/section.html.j2' with tab=form.tabs.notes %} + +
+ +{% endblock %} \ No newline at end of file diff --git a/app/itim/templates/itim/cluster_type_index.html.j2 b/app/itim/templates/itim/cluster_type_index.html.j2 new file mode 100644 index 00000000..cb8e38b2 --- /dev/null +++ b/app/itim/templates/itim/cluster_type_index.html.j2 @@ -0,0 +1,47 @@ +{% extends 'base.html.j2' %} + +{% block content %} + + + + + + + + + + {% if items %} + {% for item in items %} + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} +
Title Organization 
{{ item.name }} {{ item.organization }} 
Nothing Found
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/itim/views/cluster_types.py b/app/itim/views/cluster_types.py new file mode 100644 index 00000000..55380d1e --- /dev/null +++ b/app/itim/views/cluster_types.py @@ -0,0 +1,185 @@ +from django.contrib.auth import decorators as auth_decorator +from django.urls import reverse +from django.utils.decorators import method_decorator + +from core.forms.comment import AddNoteForm +from core.models.notes import Notes +from core.views.common import AddView, ChangeView, DeleteView, IndexView + +from itim.forms.cluster_type import ClusterTypeForm, DetailForm +from itim.models.clusters import ClusterType + +from settings.models.user_settings import UserSettings + + + +class Add(AddView): + + form_class = ClusterTypeForm + + model = ClusterType + + permission_required = [ + 'itim.add_clustertype', + ] + + + def get_initial(self): + + initial: dict = { + 'organization': UserSettings.objects.get(user = self.request.user).default_organization + } + + 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:_cluster_type_types') + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['content_title'] = 'New Cluster Type' + + return context + + + +class Change(ChangeView): + + form_class = ClusterTypeForm + + model = ClusterType + + permission_required = [ + 'itim.change_clustertype', + ] + + + 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:_cluster_type_view', args=(self.kwargs['pk'],)) + + + +class Delete(DeleteView): + + model = ClusterType + + permission_required = [ + 'itim.delete_clustertype', + ] + + + 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:_cluster_types') + + + +class Index(IndexView): + + context_object_name = "items" + + model = ClusterType + + paginate_by = 10 + + permission_required = [ + 'itim.view_clustertype' + ] + + template_name = 'itim/cluster_type_index.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 + + context['content_title'] = 'Cluster Types' + + return context + + + +class View(ChangeView): + + context_object_name = "item" + + form_class = DetailForm + + model = ClusterType + + permission_required = [ + 'itim.view_clustertype', + ] + + template_name = 'itim/cluster_type.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:_cluster_type_delete', args=(self.kwargs['pk'],)) + + + context['content_title'] = self.object.name + + return context + + + def post(self, request, *args, **kwargs): + + item = ClusterType.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:_cluster_type_view', args=(self.kwargs['pk'],)) diff --git a/app/settings/templates/settings/home.html.j2 b/app/settings/templates/settings/home.html.j2 index f7308962..fda2e5af 100644 --- a/app/settings/templates/settings/home.html.j2 +++ b/app/settings/templates/settings/home.html.j2 @@ -62,6 +62,7 @@ div#content article h3 { diff --git a/app/settings/urls.py b/app/settings/urls.py index 38fc2c3e..7f266131 100644 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -8,24 +8,20 @@ from settings.views import app_settings, home, device_models, device_types, exte from itam.views import device_type, device_model, software_category -from itim.views import ports +from itim.views import cluster_types, ports app_name = "Settings" urlpatterns = [ path("", home.View.as_view(), name="Settings"), - path("external_links", external_link.Index.as_view(), name="External Links"), - path("external_links/add", external_link.Add.as_view(), name="_external_link_add"), - path("external_links/", external_link.View.as_view(), name="_external_link_view"), - path("external_links//edit", external_link.Change.as_view(), name="_external_link_change"), - path("external_links//delete", external_link.Delete.as_view(), name="_external_link_delete"), - - path('application', app_settings.View.as_view(), name="_settings_application"), - - path("task_results", celery_log.Index.as_view(), name="_task_results"), - path("task_result/", celery_log.View.as_view(), name="_task_result_view"), + + path("cluster_types", cluster_types.Index.as_view(), name="_cluster_types"), + path("cluster_types/add", cluster_types.Add.as_view(), name="_cluster_type_add"), + path("cluster_types//edit", cluster_types.Change.as_view(), name="_cluster_type_change"), + path("cluster_types//delete", cluster_types.Delete.as_view(), name="_cluster_type_delete"), + path("cluster_types/", cluster_types.View.as_view(), name="_cluster_type_view"), path("device_models", device_models.Index.as_view(), name="_device_models"), path("device_model/", device_model.View.as_view(), name="_device_model_view"), @@ -39,18 +35,18 @@ urlpatterns = [ path("device_type//delete", device_type.Delete.as_view(), name="_device_type_delete"), path("device_type//edit", device_type.Change.as_view(), name="_device_type_change"), + path("external_links", external_link.Index.as_view(), name="External Links"), + path("external_links/add", external_link.Add.as_view(), name="_external_link_add"), + path("external_links/", external_link.View.as_view(), name="_external_link_view"), + path("external_links//edit", external_link.Change.as_view(), name="_external_link_change"), + path("external_links//delete", external_link.Delete.as_view(), name="_external_link_delete"), + path("kb/category", knowledge_base_category.Index.as_view(), name="KB Categories"), path("kb/category/add", knowledge_base_category.Add.as_view(), name="_knowledge_base_category_add"), path("kb/category//edit", knowledge_base_category.Change.as_view(), name="_knowledge_base_category_change"), path("kb/category//delete", knowledge_base_category.Delete.as_view(), name="_knowledge_base_category_delete"), path("kb/category/", knowledge_base_category.View.as_view(), name="_knowledge_base_category_view"), - path("software_category", software_categories.Index.as_view(), name="_software_categories"), - path("software_category/", software_category.View.as_view(), name="_software_category_view"), - path("software_category/add/", software_category.Add.as_view(), name="_software_category_add"), - 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("manufacturers", manufacturer.Index.as_view(), name="_manufacturers"), path("manufacturer/", manufacturer.View.as_view(), name="_manufacturer_view"), path("manufacturer/add/", manufacturer.Add.as_view(), name="_manufacturer_add"), @@ -63,4 +59,13 @@ urlpatterns = [ path("port//delete", ports.Delete.as_view(), name="_port_delete"), path("port/", ports.View.as_view(), name="_port_view"), + path("software_category", software_categories.Index.as_view(), name="_software_categories"), + path("software_category/", software_category.View.as_view(), name="_software_category_view"), + path("software_category/add/", software_category.Add.as_view(), name="_software_category_add"), + 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("task_results", celery_log.Index.as_view(), name="_task_results"), + path("task_result/", celery_log.View.as_view(), name="_task_result_view"), + ] From caa47a3bb6d8ce5a4c25af601a8c89a761e5e149 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 13:49:59 +0930 Subject: [PATCH 72/82] test(itim): Cluster Types unit tests ref: #244 #71 --- .../unit/cluster_types/test_cluster_type.py | 42 ++++ .../test_cluster_type_core_history.py | 78 ++++++++ .../test_cluster_type_history_permission.py | 95 +++++++++ .../test_cluster_type_permission.py | 189 ++++++++++++++++++ .../cluster_types/test_cluster_type_views.py | 29 +++ 5 files changed, 433 insertions(+) create mode 100644 app/itim/tests/unit/cluster_types/test_cluster_type.py create mode 100644 app/itim/tests/unit/cluster_types/test_cluster_type_core_history.py create mode 100644 app/itim/tests/unit/cluster_types/test_cluster_type_history_permission.py create mode 100644 app/itim/tests/unit/cluster_types/test_cluster_type_permission.py create mode 100644 app/itim/tests/unit/cluster_types/test_cluster_type_views.py diff --git a/app/itim/tests/unit/cluster_types/test_cluster_type.py b/app/itim/tests/unit/cluster_types/test_cluster_type.py new file mode 100644 index 00000000..75cbc7e5 --- /dev/null +++ b/app/itim/tests/unit/cluster_types/test_cluster_type.py @@ -0,0 +1,42 @@ +import pytest +import unittest + +from django.test import TestCase + +from access.models import Organization + +from app.tests.abstract.models import TenancyModel + +from itim.models.clusters import ClusterType + + + +@pytest.mark.django_db +class ClusterTypeModel( + TestCase, + TenancyModel +): + + model = ClusterType + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. Create an item + + """ + + self.organization = Organization.objects.create(name='test_org') + + + self.item = self.model.objects.create( + organization = self.organization, + name = 'one', + ) + + self.second_item = self.model.objects.create( + organization = self.organization, + name = 'one_two', + ) diff --git a/app/itim/tests/unit/cluster_types/test_cluster_type_core_history.py b/app/itim/tests/unit/cluster_types/test_cluster_type_core_history.py new file mode 100644 index 00000000..3fe81cb9 --- /dev/null +++ b/app/itim/tests/unit/cluster_types/test_cluster_type_core_history.py @@ -0,0 +1,78 @@ + +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 itim.models.clusters import ClusterType + + + +class ClusterTypeHistory(TestCase, HistoryEntry, HistoryEntryParentItem): + + + model = ClusterType + + + @classmethod + def setUpTestData(self): + """ Setup Test """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + self.item_parent = self.model.objects.create( + name = 'test_item_parent_' + self.model._meta.model_name, + organization = self.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": "' + self.item_change.name + '"}' + + 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.item_parent._meta.model_name, + ) diff --git a/app/itim/tests/unit/cluster_types/test_cluster_type_history_permission.py b/app/itim/tests/unit/cluster_types/test_cluster_type_history_permission.py new file mode 100644 index 00000000..80e16f7e --- /dev/null +++ b/app/itim/tests/unit/cluster_types/test_cluster_type_history_permission.py @@ -0,0 +1,95 @@ +# 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 itim.models.clusters import ClusterType + +from core.tests.abstract.history_permissions import HistoryPermissions + + + +class ClusterTypeHistoryPermissions(TestCase, HistoryPermissions): + + + item_model = ClusterType + + + @classmethod + def setUpTestData(self): + """Setup Test + + 1. Create an organization for user and item + 2. create an organization that is different to item + 3. Create a device + 4. Add history device history entry as item + 5. create a user + 6. create user in different organization (with the required permission) + """ + + organization = Organization.objects.create(name='test_org') + + self.organization = organization + + different_organization = Organization.objects.create(name='test_different_organization') + + self.item = self.item_model.objects.create( + organization=organization, + name = 'deviceone' + ) + + self.history = self.model.objects.get( + item_pk = self.item.id, + item_class = self.item._meta.model_name, + action = self.model.Actions.ADD, + ) + + 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]) + + + 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.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, + ]) + + TeamUsers.objects.create( + team = different_organization_team, + user = self.different_organization_user + ) diff --git a/app/itim/tests/unit/cluster_types/test_cluster_type_permission.py b/app/itim/tests/unit/cluster_types/test_cluster_type_permission.py new file mode 100644 index 00000000..1a215089 --- /dev/null +++ b/app/itim/tests/unit/cluster_types/test_cluster_type_permission.py @@ -0,0 +1,189 @@ +# 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 itim.models.clusters import ClusterType + + +class ClusterTypePermissions(TestCase, ModelPermissions): + + + model = ClusterType + + app_namespace = 'Settings' + + url_name_view = '_cluster_type_view' + + url_name_add = '_cluster_type_add' + + url_name_change = '_cluster_type_change' + + url_name_delete = '_cluster_type_delete' + + url_delete_response = reverse('Settings:_cluster_types') + + + @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 device + 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 = 'deviceone' + ) + + + self.url_view_kwargs = {'pk': self.item.id} + + # self.url_add_kwargs = {'pk': self.item.id} + + self.add_data = {'device': 'device', 'organization': self.organization.id} + + self.url_change_kwargs = {'pk': self.item.id} + + self.change_data = {'device': 'device', 'organization': self.organization.id} + + self.url_delete_kwargs = {'pk': self.item.id} + + self.delete_data = {'device': 'device', '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 + ) diff --git a/app/itim/tests/unit/cluster_types/test_cluster_type_views.py b/app/itim/tests/unit/cluster_types/test_cluster_type_views.py new file mode 100644 index 00000000..663190a8 --- /dev/null +++ b/app/itim/tests/unit/cluster_types/test_cluster_type_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 ClusterTypeViews( + TestCase, + PrimaryModel +): + + add_module = 'itim.views.cluster_types' + 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 e70d0392c01c7e0a098e8a786cba2f2c70177a3b Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 14:00:31 +0930 Subject: [PATCH 73/82] chore(itim): Add Cluster icon to navigation ref: #244 #71 --- app/templates/icons/clusters.svg | 1 + app/templates/navigation.html.j2 | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 app/templates/icons/clusters.svg diff --git a/app/templates/icons/clusters.svg b/app/templates/icons/clusters.svg new file mode 100644 index 00000000..48946b44 --- /dev/null +++ b/app/templates/icons/clusters.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 a3c4188b..6db7d474 100644 --- a/app/templates/navigation.html.j2 +++ b/app/templates/navigation.html.j2 @@ -45,7 +45,9 @@ span.navigation_icon { {% for group_urls in group.urls %} - {% if group_urls.name == 'Devices' %} + {% if group_urls.name == 'Clusters' %} + {% include 'icons/clusters.svg' %} + {% elif group_urls.name == 'Devices' %} {% include 'icons/devices.svg' %} {% elif group_urls.name == 'Knowledge Base' %} {% include 'icons/information.svg' %} From 5d660694c35044508606728daa3edda2a665eb9c Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 14:23:21 +0930 Subject: [PATCH 74/82] refactor(itim): Add Cluster type to index page ref: #244 #71 --- app/itim/templates/itim/cluster_index.html.j2 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/itim/templates/itim/cluster_index.html.j2 b/app/itim/templates/itim/cluster_index.html.j2 index 7f1dd0ec..af6da768 100644 --- a/app/itim/templates/itim/cluster_index.html.j2 +++ b/app/itim/templates/itim/cluster_index.html.j2 @@ -6,7 +6,7 @@ - + @@ -15,10 +15,10 @@ From 79b2c668fac1bd66b46347103a5bbd0b0c5212d0 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 14:30:19 +0930 Subject: [PATCH 75/82] docs(itim): cluster user docs ref: #244 #71 --- .../centurion_erp/user/itam/device.md | 2 + .../centurion_erp/user/itim/cluster.md | 61 +++++++++++++++++++ .../centurion_erp/user/itim/clustertype.md | 12 ++++ mkdocs.yml | 4 ++ 4 files changed, 79 insertions(+) create mode 100644 docs/projects/centurion_erp/user/itim/cluster.md create mode 100644 docs/projects/centurion_erp/user/itim/clustertype.md diff --git a/docs/projects/centurion_erp/user/itam/device.md b/docs/projects/centurion_erp/user/itam/device.md index 74422acd..aea28aba 100644 --- a/docs/projects/centurion_erp/user/itam/device.md +++ b/docs/projects/centurion_erp/user/itam/device.md @@ -27,6 +27,8 @@ For each device within your inventory, the following fields/tabs are available t - [Change History](../index.md#history) +- Virtual Status + ### Status at a glance diff --git a/docs/projects/centurion_erp/user/itim/cluster.md b/docs/projects/centurion_erp/user/itim/cluster.md new file mode 100644 index 00000000..860b32fd --- /dev/null +++ b/docs/projects/centurion_erp/user/itim/cluster.md @@ -0,0 +1,61 @@ +--- +title: Cluster +description: Cluster as part of IT Infrastructure Management Documentation for Centurion ERP by No Fuss Computing +date: 2024-08-18 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +This component as part of ITIM is for the management of a cluster. + + +## Features + +- Assign Devices as a cluster node + +- Assign a Device to be deployed upon a cluster + +- Assign configuration for a cluster + + +## Cluster + +Within the services the following fields are available: + +- Parent Cluster _Cluster that this cluster is deployed upon_ + +- [Cluster Type](./clustertype.md) _Type of cluster_ + +- Name _name of the cluster_ + +- [Organization](../access/organization.md) _organization this cluster belongs to_ + +- [Nodes](../itam/device.md) _Cluster Nodes_ + +- [Devices](../itam/device.md) _Devices deployed upon the cluster_ + +- [Services](./service.md) _Services deployed upon the cluster_ + +- Config _Cluster Configuration_ + +We have designed the cluster management feature to track all that is required to configure, deploy and manage. This allows for a cluster to be deployed to a cluster and to have a cluster span multiple sites and/or locations. i.e. like would be the case having nodes from multiple providers. + + +### Node + +A Cluster Node is a physical or virtual device that the cluster is deployed upon/across. The resources of a node are for the clusters consumption. + + +### Devices + +A Cluster Device is deployed onto the cluster and consumes it resources. This is generally a virtual machine or containerised application. + + +### Services + +A Cluster service is a [service](./service.md) deployed to a cluster. See [#125](https://github.com/nofusscomputing/centurion_erp/issues/125) for it's implementation details. + + +### Configuration + +Cluster configuration is configuration that is used by Ansible to setup/deploy the cluster. The configuration is presented by Centurion ERP within a format that is designed for [our collection](../../../ansible/collections/centurion/index.md). diff --git a/docs/projects/centurion_erp/user/itim/clustertype.md b/docs/projects/centurion_erp/user/itim/clustertype.md new file mode 100644 index 00000000..e0985802 --- /dev/null +++ b/docs/projects/centurion_erp/user/itim/clustertype.md @@ -0,0 +1,12 @@ +--- +title: Cluster Type +description: Cluster Type as part of IT Infrastructure Management Documentation for Centurion ERP by No Fuss Computing +date: 2024-08-18 +template: project.html +about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/centurion_erp +--- + +This component as part of ITIM is for the classification of a [cluster](./cluster.md), namely the type of cluster. + +!!! info + This feature is ready for further features if desired. i.e. `Cluster Type` configuration. want to see this log a feature request on github. diff --git a/mkdocs.yml b/mkdocs.yml index 8a9e82c6..17787f85 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -202,6 +202,10 @@ nav: - projects/centurion_erp/user/itim/index.md + - projects/centurion_erp/user/itim/cluster.md + + - projects/centurion_erp/user/itim/clustertype.md + - projects/centurion_erp/user/itim/port.md - projects/centurion_erp/user/itim/service.md From 6b28569bca97586c01a67a0503be64be7c90fdbf Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 15:16:11 +0930 Subject: [PATCH 76/82] fix(settings): return the rendering of external links to models ref: #244 --- app/templates/content/section.html.j2 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/templates/content/section.html.j2 b/app/templates/content/section.html.j2 index 4f229f6f..751ee0e0 100644 --- a/app/templates/content/section.html.j2 +++ b/app/templates/content/section.html.j2 @@ -12,7 +12,12 @@ {% if forloop.first %} -

{{ tab.name }}

+

+ {{ tab.name }} + {% for external_link in external_links %} + {% include 'icons/external_link.html.j2' with external_link=external_link %} + {% endfor %} +

{% else %} From bfb20dab0f2203d8c8888b38babfbf33eb59a81a Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 18 Aug 2024 15:41:05 +0930 Subject: [PATCH 77/82] feat(itim): Ability to add external link to cluster ref: #244 #71 #6 --- app/core/views/common.py | 6 +- app/itim/templates/itim/cluster.html.j2 | 14 +- app/itim/views/clusters.py | 2 +- app/settings/forms/external_links.py | 69 ++++++- .../migrations/0004_externallink_cluster.py | 18 ++ app/settings/models/external_link.py | 7 + .../templates/settings/external_link.html.j2 | 191 ++---------------- app/settings/views/external_link.py | 4 +- .../user/settings/external_links.md | 4 +- 9 files changed, 123 insertions(+), 192 deletions(-) create mode 100644 app/settings/migrations/0004_externallink_cluster.py diff --git a/app/core/views/common.py b/app/core/views/common.py index a1350afa..42f86c92 100644 --- a/app/core/views/common.py +++ b/app/core/views/common.py @@ -83,7 +83,11 @@ class ChangeView(View, generic.UpdateView): context['open_tab'] = None - if self.model._meta.model_name == 'device': + if self.model._meta.model_name == 'cluster': + + external_links_query = ExternalLink.objects.filter(cluster=True) + + elif self.model._meta.model_name == 'device': external_links_query = ExternalLink.objects.filter(devices=True) diff --git a/app/itim/templates/itim/cluster.html.j2 b/app/itim/templates/itim/cluster.html.j2 index ad541563..f04afe84 100644 --- a/app/itim/templates/itim/cluster.html.j2 +++ b/app/itim/templates/itim/cluster.html.j2 @@ -21,8 +21,8 @@
- {% if item.nodes.all %} - {% for node in item.nodes.all %} + {% if cluster.nodes.all %} + {% for node in cluster.nodes.all %} @@ -46,8 +46,8 @@ - {% if item.devices.all %} - {% for device in item.devices.all %} + {% if cluster.devices.all %} + {% for device in cluster.devices.all %} @@ -73,8 +73,8 @@ - {% if item.services.all %} - {% for device in item.devices.all %} + {% if cluster.services.all %} + {% for device in cluster.devices.all %} @@ -91,7 +91,7 @@

Config

-
{{ item.config | json_pretty }}
+
{{ cluster.config | json_pretty }}
diff --git a/app/itim/views/clusters.py b/app/itim/views/clusters.py index a62f4a99..e02806ed 100644 --- a/app/itim/views/clusters.py +++ b/app/itim/views/clusters.py @@ -134,7 +134,7 @@ class Index(IndexView): class View(ChangeView): - context_object_name = "item" + context_object_name = "cluster" form_class = DetailForm diff --git a/app/settings/forms/external_links.py b/app/settings/forms/external_links.py index 51061def..4537e264 100644 --- a/app/settings/forms/external_links.py +++ b/app/settings/forms/external_links.py @@ -1,10 +1,12 @@ -from django import forms -from django.db.models import Q -from django.contrib.auth.models import User +from django import forms +# from django.contrib.auth.models import User +from django.urls import reverse from access.models import Organization, TeamUsers +from app import settings + from core.forms.common import CommonModelForm from settings.models.external_link import ExternalLink @@ -19,3 +21,64 @@ class ExternalLinksForm(CommonModelForm): fields = '__all__' model = ExternalLink + + + +class DetailForm(ExternalLinksForm): + + tabs: dict = { + "details": { + "name": "Details", + "slug": "details", + "sections": [ + { + "layout": "double", + "left": [ + 'organization', + 'name', + 'template', + 'colour', + 'cluster', + 'devices' + 'software', + 'c_created', + 'c_modified', + ], + "right": [ + 'model_notes', + ] + } + ] + }, + "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:_external_link_change', args=(self.instance.pk,)) + }) + + self.url_index_view = reverse('Settings:External Links') diff --git a/app/settings/migrations/0004_externallink_cluster.py b/app/settings/migrations/0004_externallink_cluster.py new file mode 100644 index 00000000..0581ed06 --- /dev/null +++ b/app/settings/migrations/0004_externallink_cluster.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.7 on 2024-08-18 05:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('settings', '0003_alter_externallink_options'), + ] + + operations = [ + migrations.AddField( + model_name='externallink', + name='cluster', + field=models.BooleanField(default=False, help_text='Render link for clusters', verbose_name='Clusters'), + ), + ] diff --git a/app/settings/models/external_link.py b/app/settings/models/external_link.py index 427811f3..92b5e622 100644 --- a/app/settings/models/external_link.py +++ b/app/settings/models/external_link.py @@ -47,6 +47,13 @@ class ExternalLink(TenancyObject): verbose_name = 'Button Colour', ) + cluster = models.BooleanField( + default = False, + blank = False, + help_text = 'Render link for clusters', + verbose_name = 'Clusters', + ) + devices = models.BooleanField( default = False, blank = False, diff --git a/app/settings/templates/settings/external_link.html.j2 b/app/settings/templates/settings/external_link.html.j2 index e16e75e1..086cea63 100644 --- a/app/settings/templates/settings/external_link.html.j2 +++ b/app/settings/templates/settings/external_link.html.j2 @@ -1,194 +1,31 @@ -{% extends 'base.html.j2' %} +{% extends 'detail.html.j2' %} +{% load json %} {% load markdown %} -{% block title %}{{ externallink.name }}{% endblock %} -{% block content %} +{% block tabs %} - - -
- - -
- - - {% csrf_token %} -
-

- Details - {% for external_link in external_links %} - {% include 'icons/external_link.html.j2' with external_link=external_link %} - {% endfor %} -

-
+
-
- -
- - {{ externallink.organization }} -
- -
- - {{ form.name.value }} -
- -
- - {{ externallink.template }} -
- -
- - - {% if form.colour.value %} - {{ form.colour.value }} - {% else %} -   - {% endif %} - -
- -
- - {{ form.devices.value }} -
- -
- - {{ externallink.software }} -
- -
- - {{ externallink.created }} -
- -
- - {{ externallink.modified }} -
- -
- -
-
- - -
- {% if form.model_notes.value %} - {{ form.model_notes.value | markdown | safe }} - {% else %} -   - {% endif %} -
-
-
-
- - - - - - - {% if not tab %} - - {% endif %} - -
- - - - -
-

- Notes -

+ {% include 'content/section.html.j2' with tab=form.tabs.notes %} {{ notes_form }}
- {% if notes %} - {% for note in notes%} - {% include 'note.html.j2' %} - {% endfor %} - {% endif %} + {% if notes %} + {% for note in notes%} + {% include 'note.html.j2' %} + {% endfor %} + {% endif %}
- {% if tab == 'notes' %} - - {% endif %} -
+
- - -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/settings/views/external_link.py b/app/settings/views/external_link.py index 6ebc2b1b..f25d762a 100644 --- a/app/settings/views/external_link.py +++ b/app/settings/views/external_link.py @@ -9,7 +9,7 @@ from access.mixin import OrganizationPermission from core.views.common import AddView, ChangeView, DeleteView, DisplayView, IndexView -from settings.forms.external_links import ExternalLinksForm +from settings.forms.external_links import DetailForm, ExternalLinksForm from settings.models.external_link import ExternalLink @@ -44,7 +44,7 @@ class View(ChangeView): context_object_name = "externallink" - form_class = ExternalLinksForm + form_class = DetailForm model = ExternalLink diff --git a/docs/projects/centurion_erp/user/settings/external_links.md b/docs/projects/centurion_erp/user/settings/external_links.md index 1f20e5c9..4cb428d1 100644 --- a/docs/projects/centurion_erp/user/settings/external_links.md +++ b/docs/projects/centurion_erp/user/settings/external_links.md @@ -11,8 +11,10 @@ External Links allow an end user to specify by means of a jinja template a link ## Create a link -- Software context is under key `software` +- Cluster context is under key `cluster` - Device context is under key `device` +- Software context is under key `software` + To add a templated link within the `Link Template` field enter your url, with the variable within jinja braces. for example to add a link that will expand with the devices id, specify `{{ device.id }}`. i.e. `https://domainname.tld/{{ device.id }}`. If the link is for software use key `software`. Available fields under context key all of those that are available at the time the page is rendered. From 66b8bd5a74f6c3eefc97e1b7733e41462a900ecf Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 19 Aug 2024 14:49:39 +0930 Subject: [PATCH 78/82] feat(itim): Ability to add configuration to cluster type ref: #247 closes #71 --- app/itim/forms/cluster_type.py | 10 ++++++ app/itim/forms/clusters.py | 12 +++++-- .../migrations/0003_clustertype_config.py | 18 +++++++++++ app/itim/models/clusters.py | 32 +++++++++++++++++++ .../centurion_erp/user/itim/cluster.md | 2 ++ .../centurion_erp/user/itim/clustertype.md | 18 +++++++++++ 6 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 app/itim/migrations/0003_clustertype_config.py diff --git a/app/itim/forms/cluster_type.py b/app/itim/forms/cluster_type.py index 7bf7ba75..2baa9a8f 100644 --- a/app/itim/forms/cluster_type.py +++ b/app/itim/forms/cluster_type.py @@ -47,6 +47,16 @@ class DetailForm(ClusterTypeForm): 'model_notes', ] }, + { + "layout": "single", + "name": "Configuration", + "fields": [ + 'config' + ], + "json": [ + 'config', + ] + } ] }, "notes": { diff --git a/app/itim/forms/clusters.py b/app/itim/forms/clusters.py index d9abb08a..923f28c2 100644 --- a/app/itim/forms/clusters.py +++ b/app/itim/forms/clusters.py @@ -84,10 +84,10 @@ class DetailForm(ClusterForm): { "layout": "single", "fields": [ - 'config_variables', + 'rendered_config', ], "json": [ - 'config_variables' + 'rendered_config' ] } ] @@ -136,6 +136,14 @@ class DetailForm(ClusterForm): initial = 'xx/yy CPU, xx/yy RAM, xx/yy Storage', ) + + self.fields['rendered_config'] = forms.fields.JSONField( + label = 'Available Resources', + disabled = True, + initial = self.instance.rendered_config, + ) + + self.tabs['details'].update({ "edit_url": reverse('ITIM:_cluster_change', args=(self.instance.pk,)) }) diff --git a/app/itim/migrations/0003_clustertype_config.py b/app/itim/migrations/0003_clustertype_config.py new file mode 100644 index 00000000..b4d8bf02 --- /dev/null +++ b/app/itim/migrations/0003_clustertype_config.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.7 on 2024-08-19 04:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('itim', '0002_clustertype_created_clustertype_modified'), + ] + + operations = [ + migrations.AddField( + model_name='clustertype', + name='config', + field=models.JSONField(blank=True, default=None, help_text='Cluster Type Configuration that is applied to all clusters of this type', null=True, verbose_name='Configuration'), + ), + ] diff --git a/app/itim/models/clusters.py b/app/itim/models/clusters.py index d003157f..58fb84e7 100644 --- a/app/itim/models/clusters.py +++ b/app/itim/models/clusters.py @@ -39,6 +39,16 @@ class ClusterType(TenancyObject): slug = AutoSlugField() + + config = models.JSONField( + blank = True, + default = None, + help_text = 'Cluster Type Configuration that is applied to all clusters of this type', + null = True, + verbose_name = 'Configuration', + ) + + created = AutoCreatedField() modified = AutoLastModifiedField() @@ -131,6 +141,28 @@ class Cluster(TenancyObject): modified = AutoLastModifiedField() + @property + def rendered_config(self): + + rendered_config: dict = {} + + if self.cluster_type.config: + + rendered_config.update( + self.cluster_type.config + ) + + if self.config: + + rendered_config.update( + self.config + ) + + + + return rendered_config + + def __str__(self): return self.name diff --git a/docs/projects/centurion_erp/user/itim/cluster.md b/docs/projects/centurion_erp/user/itim/cluster.md index 860b32fd..4c64fd1e 100644 --- a/docs/projects/centurion_erp/user/itim/cluster.md +++ b/docs/projects/centurion_erp/user/itim/cluster.md @@ -59,3 +59,5 @@ A Cluster service is a [service](./service.md) deployed to a cluster. See [#125] ### Configuration Cluster configuration is configuration that is used by Ansible to setup/deploy the cluster. The configuration is presented by Centurion ERP within a format that is designed for [our collection](../../../ansible/collections/centurion/index.md). + +Configuration if applied within the [cluster type](./clustertype.md#configuration) is used as the base and if also defined within the cluster will take precedence. This allows the cluster type configuration to be used as a base template for clusters of the same type. diff --git a/docs/projects/centurion_erp/user/itim/clustertype.md b/docs/projects/centurion_erp/user/itim/clustertype.md index e0985802..fe86e305 100644 --- a/docs/projects/centurion_erp/user/itim/clustertype.md +++ b/docs/projects/centurion_erp/user/itim/clustertype.md @@ -10,3 +10,21 @@ This component as part of ITIM is for the classification of a [cluster](./cluste !!! info This feature is ready for further features if desired. i.e. `Cluster Type` configuration. want to see this log a feature request on github. + + +## Cluster Type + +Within the Cluster Type the following fields are available: + +- `Name` _name of the cluster type_ + +- `Organization` _organization the cluster belongs to_ + +- `Notes` _model notes for cluster type_ + +- `configuration` _cluster type config_ + + +## Configuration + +Configuration can be applied to the cluster type. This configuration is then treated as a template for all clusters of the same type. If the same configuration key is also defined within the cluster, it will take precedence over the cluster type configuration. From 32f45f2d5faefd92c5502f4cd06720794fe6a626 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 19 Aug 2024 15:28:40 +0930 Subject: [PATCH 79/82] feat(itim): Services assignable to cluster ref: #247 #125 --- app/itim/templates/itim/cluster.html.j2 | 14 ++++++++------ app/itim/views/clusters.py | 5 +++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/itim/templates/itim/cluster.html.j2 b/app/itim/templates/itim/cluster.html.j2 index f04afe84..de59180b 100644 --- a/app/itim/templates/itim/cluster.html.j2 +++ b/app/itim/templates/itim/cluster.html.j2 @@ -66,18 +66,20 @@

Services

- {% include 'icons/issue_link.html.j2' with issue=125 %} -
TitleCluster / DeviceType Organization  
{{ item.name }} - {% if item.device %} - {{ item.device }} + {% if item.cluster_type %} + {{ item.cluster_type }} {% else %} - {{ item.cluster }} +   {% endif %} {{ item.organization }}Name Organization
{{ node }} {{ node.organization }}Name Organization
{{ device }} {{ device.organization }}Name Ports
- {% if cluster.services.all %} - {% for device in cluster.devices.all %} + {% if services %} + {% for service in services.all %} - - + + {% endfor%} {% else %} diff --git a/app/itim/views/clusters.py b/app/itim/views/clusters.py index e02806ed..6de866e1 100644 --- a/app/itim/views/clusters.py +++ b/app/itim/views/clusters.py @@ -8,6 +8,7 @@ from core.views.common import AddView, ChangeView, DeleteView, IndexView from itim.forms.clusters import ClusterForm, DetailForm from itim.models.clusters import Cluster +from itim.models.services import Service from settings.models.user_settings import UserSettings @@ -159,6 +160,10 @@ class View(ChangeView): context['model_delete_url'] = reverse('ITIM:_cluster_delete', args=(self.kwargs['pk'],)) + context['services'] = Service.objects.filter( + cluster = self.kwargs['pk'] + ) + context['content_title'] = self.object.name From cf5c512a64fd49bf360e7b2705f1e6c499cfdbbe Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 19 Aug 2024 16:12:40 +0930 Subject: [PATCH 80/82] feat(itim): dont force config key, validate when it's required ref: #247 #69 --- app/itim/forms/services.py | 5 +++++ app/itim/models/services.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/itim/forms/services.py b/app/itim/forms/services.py index c648e85b..293c53ec 100644 --- a/app/itim/forms/services.py +++ b/app/itim/forms/services.py @@ -44,6 +44,7 @@ class ServiceForm(CommonModelForm): dependent_service = cleaned_data.get("dependent_service") device = cleaned_data.get("device") cluster = cleaned_data.get("cluster") + config_key_variable = cleaned_data.get("config_key_variable") is_template = cleaned_data.get("is_template") template = cleaned_data.get("template") port = cleaned_data.get("port") @@ -65,6 +66,10 @@ class ServiceForm(CommonModelForm): raise ValidationError('Port(s) must be assigned to a service.') + if not is_template and not config_key_variable: + + raise ValidationError('Configuration Key must be specified') + if dependent_service: for dependency in dependent_service: diff --git a/app/itim/models/services.py b/app/itim/models/services.py index f2c5afa9..b6bbf25b 100644 --- a/app/itim/models/services.py +++ b/app/itim/models/services.py @@ -169,7 +169,7 @@ class Service(TenancyObject): ) config_key_variable = models.CharField( - blank = False, + blank = True, help_text = 'Key name to use when merging with cluster/device config.', max_length = 50, null = True, From e696129f0b11cae698b59496e3d8bd38ea2f5fed Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 19 Aug 2024 16:16:39 +0930 Subject: [PATCH 81/82] feat(itim): Service config rendered as part of cluster config ref: #247 #125 closes #69 --- app/itim/models/clusters.py | 10 ++++++++++ docs/projects/centurion_erp/user/itim/cluster.md | 2 ++ docs/projects/centurion_erp/user/itim/service.md | 12 ++++++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/itim/models/clusters.py b/app/itim/models/clusters.py index 58fb84e7..bad2b284 100644 --- a/app/itim/models/clusters.py +++ b/app/itim/models/clusters.py @@ -144,6 +144,8 @@ class Cluster(TenancyObject): @property def rendered_config(self): + from itim.models.services import Service + rendered_config: dict = {} if self.cluster_type.config: @@ -152,6 +154,14 @@ class Cluster(TenancyObject): self.cluster_type.config ) + + for service in Service.objects.filter(cluster = self.pk): + + if service.config_variables: + + rendered_config.update( service.config_variables ) + + if self.config: rendered_config.update( diff --git a/docs/projects/centurion_erp/user/itim/cluster.md b/docs/projects/centurion_erp/user/itim/cluster.md index 4c64fd1e..112e2a88 100644 --- a/docs/projects/centurion_erp/user/itim/cluster.md +++ b/docs/projects/centurion_erp/user/itim/cluster.md @@ -61,3 +61,5 @@ A Cluster service is a [service](./service.md) deployed to a cluster. See [#125] Cluster configuration is configuration that is used by Ansible to setup/deploy the cluster. The configuration is presented by Centurion ERP within a format that is designed for [our collection](../../../ansible/collections/centurion/index.md). Configuration if applied within the [cluster type](./clustertype.md#configuration) is used as the base and if also defined within the cluster will take precedence. This allows the cluster type configuration to be used as a base template for clusters of the same type. + +A [Services config](./service.md#config) is also rendered as part of the clusters configuration. diff --git a/docs/projects/centurion_erp/user/itim/service.md b/docs/projects/centurion_erp/user/itim/service.md index 4f2cd817..8473fb66 100644 --- a/docs/projects/centurion_erp/user/itim/service.md +++ b/docs/projects/centurion_erp/user/itim/service.md @@ -32,10 +32,14 @@ Within the services the following fields are available: - dependent_service _A List of services this service depends upon_ -## Service Template - -A service can be setup as a template `is_template=True` for which then can be used as the base template for further service creations. Both config and Ports are inherited from the template with any conflict taking the current services values. - ## Deployed to A service can be deployed to a Cluster or a Device. When assosiated with an item, within it's details page the services deployed to it are available. + + +## Config + +A service can be setup as a template `is_template=True` for which then can be used as the base template for further service creations. Both config and Ports are inherited from the template with any conflict taking the current services values. + +A service assigned to a cluster will have it's config rendered as part of the [cluster configuration](./cluster.md#configuration). + From 4ac0c157bcb5d439ed0017b4336e9c74de19b156 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 19 Aug 2024 16:27:01 +0930 Subject: [PATCH 82/82] feat(itim): Dont attempt to apply cluster type config if no type specified. ref: #247 #71 --- app/itim/models/clusters.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/itim/models/clusters.py b/app/itim/models/clusters.py index bad2b284..9351a574 100644 --- a/app/itim/models/clusters.py +++ b/app/itim/models/clusters.py @@ -148,11 +148,13 @@ class Cluster(TenancyObject): rendered_config: dict = {} - if self.cluster_type.config: + if self.cluster_type: - rendered_config.update( - self.cluster_type.config - ) + if self.cluster_type.config: + + rendered_config.update( + self.cluster_type.config + ) for service in Service.objects.filter(cluster = self.pk):
Name Ports
{{ service.name }} + {% for port in service.port.all %} + {{ port.protocol }}/{{ port.number }} - {{ port.description }}, + {% endfor %} +